haiku.rag/haiku_rag_slim/haiku/rag/utils.py
2026-05-13 13:45:09 +03:00

511 lines
16 KiB
Python

import math
import sys
from datetime import UTC, datetime
from importlib import metadata
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from dateutil import parser as dateutil_parser
from packaging.version import Version, parse
if TYPE_CHECKING:
from rich.console import RenderableType
from haiku.rag.agents.research.models import Citation
from haiku.rag.config.models import AppConfig, ModelConfig
def parse_model_option(value: str) -> "ModelConfig":
"""Parse a 'provider:name' string into a ModelConfig."""
from haiku.rag.config.models import ModelConfig
parts = value.split(":", 1)
if len(parts) != 2 or not parts[0] or not parts[1]:
raise ValueError(
f"Invalid model format '{value}'. Expected 'provider:name' (e.g. 'ollama:gpt-oss')."
)
return ModelConfig(provider=parts[0], name=parts[1])
def cosine_similarity(vec1: list[float], vec2: list[float]) -> float:
"""Compute cosine similarity between two vectors."""
dot_product = sum(a * b for a, b in zip(vec1, vec2))
norm1 = math.sqrt(sum(a * a for a in vec1))
norm2 = math.sqrt(sum(b * b for b in vec2))
if norm1 == 0 or norm2 == 0:
return 0.0
return dot_product / (norm1 * norm2)
def parse_datetime(s: str) -> datetime:
"""Parse a datetime string into a datetime object.
Supports:
- ISO 8601 format: "2025-01-15T14:30:00", "2025-01-15T14:30:00Z", "2025-01-15T14:30:00+00:00"
- Date only: "2025-01-15" (interpreted as 00:00:00)
- Various other formats via dateutil
Args:
s: String to parse
Returns:
Parsed datetime object
Raises:
ValueError: If the string cannot be parsed
"""
try:
return dateutil_parser.parse(s)
except (ValueError, TypeError) as e:
raise ValueError(
f"Could not parse datetime: {s}. "
"Use ISO 8601 format (e.g., 2025-01-15T14:30:00) or date (e.g., 2025-01-15)"
) from e
def to_utc(dt: datetime) -> datetime:
"""Convert a datetime to UTC.
- Naive datetimes are assumed to be local time and converted to UTC
- Datetimes with timezone info are converted to UTC
- UTC datetimes are returned as-is
Args:
dt: Datetime to convert
Returns:
Datetime in UTC timezone
"""
if dt.tzinfo is None:
# Naive datetime - assume local time
local_dt = dt.astimezone() # Adds local timezone
return local_dt.astimezone(UTC)
elif dt.tzinfo == UTC:
return dt
else:
return dt.astimezone(UTC)
def apply_common_settings(
settings: Any | None,
settings_class: type[Any],
model_config: Any,
) -> Any | None:
"""Apply common settings (temperature, max_tokens) to model settings.
Args:
settings: Existing settings instance or None
settings_class: Settings class to instantiate if needed
model_config: ModelConfig with temperature and max_tokens
Returns:
Updated settings instance or None if no settings to apply
"""
if (
model_config.temperature is None
and model_config.max_tokens is None
and model_config.extra_body is None
):
return settings
if settings is None:
settings_dict = settings_class()
else:
settings_dict = settings
if model_config.temperature is not None:
settings_dict["temperature"] = model_config.temperature
if model_config.max_tokens is not None:
settings_dict["max_tokens"] = model_config.max_tokens
if model_config.extra_body is not None:
settings_dict["extra_body"] = model_config.extra_body
return settings_dict
def get_model(
model_config: "ModelConfig",
app_config: "AppConfig | None" = None,
) -> Any:
"""
Get a model instance for the specified configuration.
Args:
model_config: ModelConfig with provider, model, and settings
app_config: AppConfig for provider base URLs (defaults to global Config)
Returns:
A configured model instance
"""
from pydantic_ai.models.openai import OpenAIChatModel, OpenAIChatModelSettings
from pydantic_ai.providers.ollama import OllamaProvider
from pydantic_ai.providers.openai import OpenAIProvider
if app_config is None:
from haiku.rag.config import Config
app_config = Config
provider = model_config.provider
model = model_config.name
if provider == "ollama":
model_settings = None
# Apply thinking control for gpt-oss
if model == "gpt-oss" and model_config.enable_thinking is not None:
if model_config.enable_thinking is False:
model_settings = OpenAIChatModelSettings(openai_reasoning_effort="low")
else:
model_settings = OpenAIChatModelSettings(openai_reasoning_effort="high")
model_settings = apply_common_settings(
model_settings, OpenAIChatModelSettings, model_config
)
# Ollama's OpenAI-compatible API lives under /v1. Append it if the
# configured base_url doesn't already include it.
base_url = model_config.base_url or app_config.providers.ollama.base_url
if not base_url.rstrip("/").endswith("/v1"):
base_url = base_url.rstrip("/") + "/v1"
return OpenAIChatModel(
model_name=model,
provider=OllamaProvider(base_url=base_url),
settings=model_settings,
)
elif provider == "openai":
from pydantic_ai.profiles.openai import OpenAIModelProfile, openai_model_profile
openai_settings: Any = None
# Apply thinking control only for reasoning models (o-series, gpt-5)
profile = cast(OpenAIModelProfile, openai_model_profile(model))
if (
model_config.enable_thinking is not None
and profile.openai_supports_encrypted_reasoning_content
):
if model_config.enable_thinking is False:
openai_settings = OpenAIChatModelSettings(openai_reasoning_effort="low")
else:
openai_settings = OpenAIChatModelSettings(
openai_reasoning_effort="high"
)
openai_settings = apply_common_settings(
openai_settings, OpenAIChatModelSettings, model_config
)
# Use model-level base_url if set (for vLLM, LM Studio, etc.)
if model_config.base_url:
return OpenAIChatModel(
model_name=model,
provider=OpenAIProvider(base_url=model_config.base_url),
settings=openai_settings,
)
return OpenAIChatModel(model_name=model, settings=openai_settings)
elif provider == "anthropic":
from anthropic.types.beta import (
BetaThinkingConfigDisabledParam,
BetaThinkingConfigEnabledParam,
)
from pydantic_ai.models.anthropic import AnthropicModel, AnthropicModelSettings
anthropic_settings: Any = None
# Apply thinking control
if model_config.enable_thinking is not None:
if model_config.enable_thinking:
thinking_config: BetaThinkingConfigEnabledParam = {
"type": "enabled",
"budget_tokens": 4096,
}
anthropic_settings = AnthropicModelSettings(
anthropic_thinking=thinking_config
)
else:
thinking_disabled: BetaThinkingConfigDisabledParam = {
"type": "disabled"
}
anthropic_settings = AnthropicModelSettings(
anthropic_thinking=thinking_disabled
)
anthropic_settings = apply_common_settings(
anthropic_settings, AnthropicModelSettings, model_config
)
return AnthropicModel(model_name=model, settings=anthropic_settings)
elif provider == "gemini":
from pydantic_ai.models.google import GoogleModel, GoogleModelSettings
gemini_settings: Any = None
# Apply thinking control
if model_config.enable_thinking is not None:
gemini_settings = GoogleModelSettings(
google_thinking_config={
"include_thoughts": model_config.enable_thinking
}
)
gemini_settings = apply_common_settings(
gemini_settings, GoogleModelSettings, model_config
)
return GoogleModel(model_name=model, settings=gemini_settings)
elif provider == "groq":
from pydantic_ai.models.groq import GroqModel, GroqModelSettings
groq_settings: Any = None
# Apply thinking control
if model_config.enable_thinking is not None:
if model_config.enable_thinking:
groq_settings = GroqModelSettings(groq_reasoning_format="parsed")
else:
groq_settings = GroqModelSettings(groq_reasoning_format="hidden")
groq_settings = apply_common_settings(
groq_settings, GroqModelSettings, model_config
)
return GroqModel(model_name=model, settings=groq_settings)
elif provider == "bedrock":
from pydantic_ai.models.bedrock import (
BedrockConverseModel,
BedrockModelSettings,
)
bedrock_settings: Any = None
# Apply thinking control for Claude models
if model_config.enable_thinking is not None:
additional_fields: dict[str, Any] = {}
if model.startswith("anthropic.claude"):
if model_config.enable_thinking:
additional_fields = {
"thinking": {"type": "enabled", "budget_tokens": 4096}
}
else:
additional_fields = {"thinking": {"type": "disabled"}}
elif "o1" in model or "o3" in model:
# OpenAI reasoning models on Bedrock (o-series only, not gpt-4o)
additional_fields = {
"reasoning_effort": "high"
if model_config.enable_thinking
else "low"
}
elif "qwen" in model:
# Qwen models on Bedrock
additional_fields = {
"reasoning_config": "high"
if model_config.enable_thinking
else "low"
}
if additional_fields:
bedrock_settings = BedrockModelSettings(
bedrock_additional_model_requests_fields=additional_fields
)
bedrock_settings = apply_common_settings(
bedrock_settings, BedrockModelSettings, model_config
)
return BedrockConverseModel(model_name=model, settings=bedrock_settings)
else:
# For any other provider, use string format and let Pydantic AI handle it
return f"{provider}:{model}"
def format_bytes(num_bytes: int) -> str:
"""Format bytes as human-readable string."""
size = float(num_bytes)
for unit in ["B", "KB", "MB", "GB", "TB"]:
if size < 1024.0:
return f"{size:.1f} {unit}"
size /= 1024.0
return f"{size:.1f} PB"
def format_citations(citations: "list[Citation]") -> str:
"""Format citations as plain text with preserved formatting.
Used by things like the MCP server where Rich renderables are not available.
"""
if not citations:
return ""
lines = ["## Citations\n"]
for i, c in enumerate(citations):
idx = c.index if c.index is not None else (i + 1)
title = c.document_title or c.document_uri
header = f"[{idx}] {title}"
# Location info
location_parts = []
if c.page_numbers:
if len(c.page_numbers) == 1:
location_parts.append(f"p. {c.page_numbers[0]}")
else:
location_parts.append(f"pp. {c.page_numbers[0]}-{c.page_numbers[-1]}")
if c.headings:
location_parts.append(f"Section: {c.headings[-1]}")
source = c.document_uri
if location_parts:
source += f" - {', '.join(location_parts)}"
lines.append(f"{header} {source}")
lines.append(c.content)
lines.append("")
return "\n".join(lines)
def format_citations_rich(citations: "list[Citation]") -> "list[RenderableType]":
"""Format citations as Rich renderables.
Returns a list of Rich Panel objects for direct console printing,
with content rendered as markdown for syntax highlighting.
"""
from rich.markdown import Markdown
from rich.panel import Panel
from rich.text import Text
if not citations:
return []
renderables: list[RenderableType] = []
renderables.append(Text("Citations", style="bold"))
for c in citations:
# Build header with IDs
header = Text()
header.append("doc: ", style="dim")
header.append(c.document_id, style="cyan")
header.append(" chunk: ", style="dim")
header.append(c.chunk_id, style="cyan")
# Location info for subtitle
location_parts = []
if c.page_numbers:
if len(c.page_numbers) == 1:
location_parts.append(f"p. {c.page_numbers[0]}")
else:
location_parts.append(f"pp. {c.page_numbers[0]}-{c.page_numbers[-1]}")
if c.headings:
location_parts.append(f"Section: {c.headings[-1]}")
subtitle = c.document_uri
if c.document_title:
subtitle = f"{c.document_title} ({c.document_uri})"
if location_parts:
subtitle += f" - {', '.join(location_parts)}"
panel = Panel(
Markdown(c.content),
title=header,
subtitle=subtitle,
subtitle_align="left",
border_style="dim",
)
renderables.append(panel)
return renderables
def get_default_data_dir() -> Path:
"""Get the user data directory for the current system platform.
Linux: ~/.local/share/haiku.rag
macOS: ~/Library/Application Support/haiku.rag
Windows: C:/Users/<USER>/AppData/Roaming/haiku.rag
Returns:
User Data Path.
"""
home = Path.home()
system_paths = {
"win32": home / "AppData/Roaming/haiku.rag",
"linux": home / ".local/share/haiku.rag",
"darwin": home / "Library/Application Support/haiku.rag",
}
data_path = system_paths[sys.platform]
return data_path
def build_prompt(base_prompt: str, config: "AppConfig") -> str:
"""Build a prompt with domain_preamble prepended if configured.
Args:
base_prompt: The base prompt to use
config: AppConfig with prompts.domain_preamble
Returns:
Prompt with domain_preamble prepended if configured
"""
if config.prompts.domain_preamble:
return f"{config.prompts.domain_preamble}\n\n{base_prompt}"
return base_prompt
def escape_sql_string(value: str) -> str:
"""Escape single quotes in SQL string literals."""
return value.replace("'", "''")
def get_package_versions() -> dict[str, str]:
"""Get versions of haiku.rag and its dependencies.
Returns:
Dict with keys: haiku_rag, lancedb, docling, pydantic_ai, docling_document_schema
"""
from docling_core.types.doc.document import DoclingDocument
versions = {
"haiku_rag": metadata.version("haiku.rag-slim"),
"lancedb": metadata.version("lancedb"),
"pydantic_ai": metadata.version("pydantic-ai-slim"),
"docling_document_schema": DoclingDocument.model_construct().version,
}
try:
versions["docling"] = metadata.version("docling")
except metadata.PackageNotFoundError:
versions["docling"] = "not installed"
return versions
async def is_up_to_date() -> tuple[bool, Version, Version]:
"""Check whether haiku.rag is current.
Returns:
A tuple containing a boolean indicating whether haiku.rag is current,
the running version and the latest version.
"""
# Lazy import to avoid pulling httpx (and its deps) on module import
import httpx
async with httpx.AsyncClient() as client:
running_version = parse(metadata.version("haiku.rag-slim"))
try:
response = await client.get("https://pypi.org/pypi/haiku.rag/json")
data = response.json()
pypi_version = parse(data["info"]["version"])
except Exception: # pragma: no cover
# If no network connection, do not raise alarms.
pypi_version = running_version
return running_version >= pypi_version, running_version, pypi_version