haiku.rag/haiku_rag_slim/haiku/rag/client/downloads.py
Yiorgis Gozadinos 4967765878
Delete comments that restate the line below them
Sixty-three comments said what the next statement already said: # Connect to
LanceDB above connect_lancedb, # Path object above isinstance(source, Path),
# Get page numbers from provenance above the prov loop, # Clear and populate
results above list_view.clear(). They cost a read and carry nothing.

The line is whether a comment restates one statement or labels a phase. Phase
labels stay: the migrations keep # Create staging table with new schema and
# Copy from staging to final table in batches, each heading ten lines of a
long procedure. So do comments carrying a fact the code cannot: the
merge_insert update-only note on document_meta, why the poller builds sources
eagerly, why create_document_from_source returns a list for directories, that
indexes need training data, the field-group markers in the config models, and
the file:// URL-encoding note in create_document_from_source.

capabilities/ is untouched. Its docstrings sit next to prompt surface, and
changing them needs an eval to back it.

The cassette-recording docs were wrong three ways. They named
tests/test_qa.py::test_qa_anthropic, which no longer exists; they targeted
whole modules, so a rewrite would re-record cassettes for services the
recorder is not running; and they used COHERE_API_KEY where the SDK reads
CO_API_KEY. docs/development.md now names exact tests with -n0, and the keyed
example is test_cohere_reranker, which owns the one cassette recording
api.cohere.com.
2026-08-20 15:22:33 +03:00

156 lines
5.6 KiB
Python

import asyncio
import json
from collections.abc import AsyncGenerator
from dataclasses import dataclass
import httpx
from haiku.rag.config import AppConfig
@dataclass
class DownloadProgress:
"""Progress event for model downloads."""
model: str
status: str
completed: int = 0
total: int = 0
digest: str = ""
async def download_models(
config: AppConfig,
) -> AsyncGenerator[DownloadProgress, None]:
"""Download required models per config, yielding progress events.
Yields DownloadProgress events for:
- Docling models
- HuggingFace tokenizer
- Sentence-transformers embedder (if configured)
- HuggingFace reranker models (cross-encoder, jina-local)
- Ollama models
"""
try:
from docling.utils.model_downloader import download_models
yield DownloadProgress(model="docling", status="start")
await asyncio.to_thread(download_models)
yield DownloadProgress(model="docling", status="done")
except ImportError: # pragma: no cover - docling installed in test env
pass
# HuggingFace tokenizer
from transformers import AutoTokenizer
tokenizer_name = config.processing.chunking_tokenizer
yield DownloadProgress(model=tokenizer_name, status="start")
await asyncio.to_thread(AutoTokenizer.from_pretrained, tokenizer_name)
yield DownloadProgress(model=tokenizer_name, status="done")
# Sentence-transformers embedder
if config.embeddings.model.provider == "sentence-transformers": # pragma: no cover
try:
from sentence_transformers import ( # type: ignore[import-not-found]
SentenceTransformer,
)
model_name = config.embeddings.model.name
yield DownloadProgress(model=model_name, status="start")
# Wrap in lambda: ty loses ParamSpec inference on third-party __init__.
await asyncio.to_thread(lambda: SentenceTransformer(model_name))
yield DownloadProgress(model=model_name, status="done")
except ImportError:
pass
# HuggingFace reranker models
if config.reranking.model: # pragma: no cover
provider = config.reranking.model.provider
model_name = config.reranking.model.name
if provider == "cross-encoder":
try:
from sentence_transformers import ( # type: ignore[import-not-found]
CrossEncoder,
)
yield DownloadProgress(model=model_name, status="start")
await asyncio.to_thread(lambda: CrossEncoder(model_name))
yield DownloadProgress(model=model_name, status="done")
except ImportError:
pass
elif provider == "jina-local":
try:
from transformers import AutoModel
yield DownloadProgress(model=model_name, status="start")
await asyncio.to_thread(
AutoModel.from_pretrained,
model_name,
trust_remote_code=True,
)
yield DownloadProgress(model=model_name, status="done")
except ImportError:
pass
# Collect Ollama models from config
required_models: set[str] = set()
if config.embeddings.model.provider == "ollama":
required_models.add(config.embeddings.model.name)
if config.qa.model.provider == "ollama":
required_models.add(config.qa.model.name)
if config.reranking.model and config.reranking.model.provider == "ollama":
required_models.add(config.reranking.model.name)
pic_desc = config.processing.conversion_options.picture_description
if (
config.processing.pictures == "description"
and pic_desc.model.provider == "ollama"
):
required_models.add(pic_desc.model.name)
if (
config.processing.auto_title
and config.processing.title_model.provider == "ollama"
):
required_models.add(config.processing.title_model.name)
if not required_models:
return
base_url = config.providers.ollama.base_url
try:
async with httpx.AsyncClient(timeout=None) as client:
for model in sorted(required_models):
yield DownloadProgress(model=model, status="pulling")
async with client.stream(
"POST", f"{base_url}/api/pull", json={"model": model}
) as r:
async for line in r.aiter_lines():
if not line:
continue
try:
data = json.loads(line)
status = data.get("status", "")
digest = data.get("digest", "")
if digest and "total" in data:
yield DownloadProgress(
model=model,
status="downloading",
total=data.get("total", 0),
completed=data.get("completed", 0),
digest=digest,
)
elif status:
yield DownloadProgress(model=model, status=status)
except json.JSONDecodeError:
pass
yield DownloadProgress(model=model, status="done")
except httpx.ConnectError:
raise ConnectionError(
f"Cannot connect to Ollama at {base_url}. "
"Is Ollama running? Start it with 'ollama serve'."
)