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.client import HaikuRAG from haiku.rag.config.models import AppConfig, ModelConfig from haiku.rag.store.models.citation import Citation 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" CITATION_PREVIEW_CHARS = 300 def _citation_pages(c: "Citation") -> str | None: if not c.page_numbers: return None if len(c.page_numbers) == 1: return f"p. {c.page_numbers[0]}" return f"pp. {c.page_numbers[0]}-{c.page_numbers[-1]}" def _citation_section(c: "Citation") -> str | None: if c.headings: return c.headings[-1] return None def _citation_label(c: "Citation") -> str: if c.document_title and c.document_uri: return f"{c.document_title} ({c.document_uri})" return c.document_title or c.document_uri 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. Pictures referenced by the chunk are surfaced as ``[Figure: ]`` markers. """ 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_parts = [] pages = _citation_pages(c) if pages: location_parts.append(pages) section = _citation_section(c) if section: location_parts.append(f"Section: {section}") source = c.document_uri if location_parts: source += f" - {', '.join(location_parts)}" lines.append(f"{header} {source}") for ref in c.picture_refs: lines.append(f"[Figure: {ref}]") lines.append(c.content) lines.append("") return "\n".join(lines) async def format_citations_rich( citations: "list[Citation]", client: "HaikuRAG | None" = None, ) -> "list[RenderableType]": """Format citations as Rich renderables for terminal display. Each citation becomes a Panel with a compact header (``[N] Title (URI) — locator``), a body holding any referenced figures followed by a truncated text preview, and a dimmed footer that exposes the document and chunk IDs. When ``client`` is supplied, picture bytes for ``picture_refs`` are fetched and rendered inline via ``textual_image``. Without a client, picture refs appear as ``[Figure: ]`` text markers. """ from rich.console import Group from rich.panel import Panel from rich.text import Text if not citations: return [] renderables: list[RenderableType] = [] renderables.append(Text("")) renderables.append(Text("Citations", style="bold green")) renderables.append(Text("")) for i, c in enumerate(citations): if i > 0: renderables.append(Text("")) idx = c.index if c.index is not None else (i + 1) header_parts: list[str] = [f"[{idx}] {_citation_label(c)}"] pages = _citation_pages(c) if pages: header_parts.append(pages) section = _citation_section(c) if section: header_parts.append(f"§{section}") header = Text(" — ".join(header_parts), style="bold") body: list[RenderableType] = [] for ref in c.picture_refs: image_renderable = await _render_picture(client, c.document_id, ref) body.append( image_renderable if image_renderable else Text(f"[Figure: {ref}]", style="italic dim") ) preview = c.content if len(preview) > CITATION_PREVIEW_CHARS: preview = preview[:CITATION_PREVIEW_CHARS].rstrip() + "…" body.append(Text(preview)) footer = Text() footer.append("doc: ", style="dim") footer.append(c.document_id, style="dim cyan") footer.append(" chunk: ", style="dim") footer.append(c.chunk_id, style="dim cyan") panel = Panel( Group(*body), title=header, title_align="left", subtitle=footer, subtitle_align="left", border_style="dim", ) renderables.append(panel) return renderables async def _render_picture( client: "HaikuRAG | None", document_id: str, ref: str ) -> "RenderableType | None": """Fetch a picture and return a Rich renderable, or None on failure/no client.""" if client is None: return None from io import BytesIO from PIL import Image as PILImage from textual_image.renderable import Image as RichImage data = await client.document_item_repository.get_picture_bytes(document_id, ref) if not data: return None try: pil = PILImage.open(BytesIO(data)) pil.load() except Exception: return None return RichImage(pil) 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//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