haiku.rag/haiku_rag_slim/haiku/rag/utils.py
Yiorgis Gozadinos 40d40bcbf2
Cover the configured database set from the MCP server
`haiku-rag mcp` passes covers_set=True and _covering no longer refuses a
scope over several databases. search_documents, search_documents_by_image,
ask_question and analyze take `sources`; get_document takes `source`;
DocumentInfo carries `source`. format_citations gains include_source, which
ask_question sets from covers_multiple so citations name their database
only when the server covers several.

Refs #599
2026-09-04 13:00:14 +03:00

661 lines
22 KiB
Python

import asyncio
import math
import sys
from collections.abc import Awaitable
from importlib import metadata
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, NoReturn, cast
from packaging.version import Version, parse
if TYPE_CHECKING:
from pydantic_ai.messages import BinaryContent
from pydantic_ai.profiles.openai import OpenAIModelProfile
from rich.console import RenderableType
from haiku.rag.client import HaikuRAG
from haiku.rag.config.models import AppConfig, EmbeddingModelConfig, ModelConfig
from haiku.rag.store.models.citation import Citation
async def gather_all[T](*awaitables: Awaitable[T]) -> list[T]:
"""Run `awaitables` concurrently, leaving none of them running when one fails.
`asyncio.gather` leaves its siblings running; `TaskGroup` wraps the failure in
an `ExceptionGroup`.
"""
tasks = [asyncio.ensure_future(awaitable) for awaitable in awaitables]
try:
return await asyncio.gather(*tasks)
except BaseException:
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
raise
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:qwen3.8')."
)
return ModelConfig(provider=parts[0], name=parts[1])
def check_api_key_supported(
model_config: "ModelConfig | EmbeddingModelConfig", supported: set[str]
) -> None:
"""Reject a configured api_key on a provider whose client we never build.
Those providers reach their vendor SDK by name and read their own
environment variable, so a key in the config would be dropped silently.
"""
if model_config.api_key and model_config.provider not in supported:
raise ValueError(
f"api_key is not supported on the '{model_config.provider}' provider "
f"(supported: {', '.join(sorted(supported))}). Set that provider's "
"own API key environment variable instead."
)
def vllm_base_url(base_url: str | None) -> str:
"""Normalize a vLLM endpoint to its OpenAI-compatible `/v1` root.
Embedders and rerankers take the same endpoint from config, so both accept
it written with or without `/v1`.
"""
base_url = base_url or "http://localhost:8000/v1"
if not base_url.rstrip("/").endswith("/v1"):
base_url = base_url.rstrip("/") + "/v1"
return base_url
def _check_provider_known(provider: str) -> None:
"""Reject a chat provider pydantic-ai cannot resolve.
Providers we do not branch on reach pydantic-ai as a `provider:name` string,
so without this an unusable name fails deep inside pydantic-ai with nothing
naming the config key it came from. Asking pydantic-ai's own resolver rather
than keeping a list here leaves a newly added provider working with no
release of ours.
"""
from pydantic_ai.providers import infer_provider_class
try:
infer_provider_class(provider)
except ImportError:
# pydantic-ai knows the name, its SDK is just not installed here. That
# failure names the extra to install, so leave it to be raised in place.
return
except ValueError:
raise ValueError(
f"Unknown model provider '{provider}'. See "
"https://ai.pydantic.dev/models/ for the providers pydantic-ai "
"supports. An OpenAI-compatible server (vLLM, sglang, LM Studio) "
"uses provider 'openai' with base_url."
) from None
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 image_binary_content(data: bytes) -> "BinaryContent":
"""Wrap raw image bytes as BinaryContent with the sniffed media type."""
from io import BytesIO
from PIL import Image as PILImage
from PIL import UnidentifiedImageError
from pydantic_ai.messages import BinaryContent
try:
fmt = PILImage.open(BytesIO(data)).format or "PNG"
except UnidentifiedImageError as e:
raise ValueError("data is not a recognizable image") from e
return BinaryContent(data=data, media_type=f"image/{fmt.lower()}")
def apply_common_settings(
settings: Any | None,
model_config: Any,
*,
map_thinking: bool = True,
) -> Any | None:
"""Apply the settings every provider shares onto a model settings dict.
Args:
settings: Existing settings instance or None
model_config: ModelConfig with temperature and max_tokens
map_thinking: Whether to map `enable_thinking` onto the unified
`thinking` setting. The OpenAI-compatible branches opt out and set
`openai_reasoning_effort` themselves, so that models whose profile
advertises thinking without OpenAI reasoning support (Ollama's
deepseek-r1, for one) keep receiving no `reasoning_effort`.
Returns:
Updated settings instance or None if no settings to apply
"""
thinking = model_config.enable_thinking if map_thinking else None
if (
model_config.temperature is None
and model_config.max_tokens is None
and model_config.extra_body is None
and thinking is None
):
return settings
settings_dict = {} if settings is None else 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
if thinking is not None:
settings_dict["thinking"] = thinking
return settings_dict
# Strict OpenAI-compatible backends (some vLLM chat templates, e.g. Qwen's)
# reject more than one leading system message. Instructions from multiple
# sources (agent preamble, capability instructions, dynamic notices) map to
# one system message each, so have pydantic-ai merge them for any endpoint
# that is not api.openai.com. Harmless on backends that allow multiples.
_OPENAI_COMPAT_PROFILE: "OpenAIModelProfile" = {
"openai_chat_supports_multiple_system_messages": False
}
def reasoning_effort(
model_config: "ModelConfig",
) -> Literal["none", "low", "high"] | None:
"""OpenAI `reasoning_effort` for a model config, or None when unset.
"low" is gpt-oss's floor; its template rejects "none".
"""
if model_config.enable_thinking is None:
return None
if model_config.enable_thinking:
return "high"
return "low" if model_config.name == "gpt-oss" else "none"
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 the current 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 get_config
app_config = get_config()
provider = model_config.provider
model = model_config.name
_check_provider_known(provider)
check_api_key_supported(model_config, {"openai", "ollama"})
if provider == "ollama":
model_settings = None
effort = reasoning_effort(model_config)
if effort is not None:
model_settings = OpenAIChatModelSettings(openai_reasoning_effort=effort)
model_settings = apply_common_settings(
model_settings, model_config, map_thinking=False
)
# 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, api_key=model_config.api_key),
settings=model_settings,
profile=_OPENAI_COMPAT_PROFILE,
)
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.get(
"openai_supports_reasoning", False
):
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, model_config, map_thinking=False
)
# 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, api_key=model_config.api_key
),
settings=openai_settings,
profile=_OPENAI_COMPAT_PROFILE,
)
return OpenAIChatModel(
model_name=model,
provider=(
OpenAIProvider(api_key=model_config.api_key)
if model_config.api_key
else "openai"
),
settings=openai_settings,
)
elif provider == "anthropic":
from anthropic.types.beta import BetaThinkingConfigDisabledParam
from pydantic_ai.models.anthropic import AnthropicModel, AnthropicModelSettings
anthropic_settings: Any = None
# Unified `thinking=False` omits the request field, which leaves the
# adaptive-thinking models (Sonnet 4.6+, Opus 4.6+) thinking by default.
disable_thinking = model_config.enable_thinking is False
if disable_thinking:
thinking_disabled: BetaThinkingConfigDisabledParam = {"type": "disabled"}
anthropic_settings = AnthropicModelSettings(
anthropic_thinking=thinking_disabled
)
anthropic_settings = apply_common_settings(
anthropic_settings, model_config, map_thinking=not disable_thinking
)
return AnthropicModel(model_name=model, settings=anthropic_settings)
elif provider == "google":
from pydantic_ai.models.google import GoogleModel
return GoogleModel(
model_name=model,
settings=apply_common_settings(None, model_config),
)
elif provider == "groq":
from pydantic_ai.models.groq import GroqModel
return GroqModel(
model_name=model,
settings=apply_common_settings(None, model_config),
)
elif provider == "bedrock":
from pydantic_ai.models.bedrock import (
BedrockConverseModel,
BedrockModelSettings,
)
bedrock_settings: Any = None
# Same omission as the direct Anthropic branch: unified `thinking=False`
# leaves the adaptive-thinking Claude models thinking. Bedrock ids are
# `[<geo>.]<family>.<model>`, as in `us.anthropic.claude-...`.
disable_claude_thinking = (
model_config.enable_thinking is False and "anthropic." in model
)
if disable_claude_thinking:
bedrock_settings = BedrockModelSettings(
bedrock_additional_model_requests_fields={
"thinking": {"type": "disabled"}
}
)
return BedrockConverseModel(
model_name=model,
settings=apply_common_settings(
bedrock_settings, model_config, map_thinking=not disable_claude_thinking
),
)
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]", include_source: bool = False) -> 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: <ref>]`` markers.
``include_source`` names each citation's database, for a client covering
several.
"""
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 = []
if include_source and c.source:
location_parts.append(f"Collection: {c.source}")
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)
def truncated(text: str, limit: int) -> str:
"""The first `limit` characters of `text`, with `…` appended when anything
was dropped. A cut result is `limit` characters plus the mark."""
if len(text) <= limit:
return text
return text[:limit].rstrip() + ""
async def format_citations_rich(
citations: "list[Citation]",
client: "HaikuRAG | None" = None,
full: bool = False,
) -> "list[RenderableType]":
"""Format citations as Rich renderables for terminal display.
Each citation becomes a Panel with a compact header (``[N] Title (URI) —
locator``, with the database name before the locator when ``client`` covers
several), a body holding any referenced figures followed by a text preview
truncated at ``CITATION_PREVIEW_CHARS``, and a dimmed footer that exposes the
document and chunk IDs. ``full`` renders the content untruncated.
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: <ref>]`` 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)}"]
if c.source and client is not None and client.covers_multiple:
header_parts.append(c.source)
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, c.source
)
body.append(
image_renderable
if image_renderable
else Text(f"[Figure: {ref}]", style="italic dim")
)
body.append(
Text(c.content if full else truncated(c.content, CITATION_PREVIEW_CHARS))
)
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, source: str | None = None
) -> "RenderableType | None":
"""A picture as a Rich renderable, or None where it cannot be rendered.
None where no client, no source to place it across databases, or bytes that
do not decode. The caller renders its figure marker instead.
"""
if client is None:
return None
if source is None and client.covers_multiple:
return None
from io import BytesIO
from PIL import Image as PILImage
from textual_image.renderable import Image as RichImage
data = await client.get_picture_bytes(document_id, ref, source)
if not data:
return None
try:
pil = PILImage.open(BytesIO(data))
pil.load()
except Exception:
return None
return RichImage(pil)
def raise_missing_extra(module: str, extra: str, exc: ModuleNotFoundError) -> NoReturn:
"""Report `module` as a missing optional dependency, naming its extra.
Re-raises `exc` untouched when the failure came from inside an installed
package rather than from `module` itself, so a broken transitive import is
not misreported as "not installed".
"""
if exc.name != module:
raise exc
raise ImportError(
f"{module} is not installed. Install it with "
f"`uv pip install 'haiku.rag-slim[{extra}]'`."
) from exc
def locate_database(location: str) -> Path | str:
"""A configured location as a URI, or as a local path.
A value with a scheme is a URI, which `ConnectionMode` opens without the
existence check a local database gets; anything else is a local path.
"""
if "://" in location:
return location
return Path(location)
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