haiku.rag-slim 0.16.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of haiku.rag-slim might be problematic. Click here for more details.

Files changed (71) hide show
  1. haiku/rag/__init__.py +0 -0
  2. haiku/rag/app.py +542 -0
  3. haiku/rag/chunker.py +65 -0
  4. haiku/rag/cli.py +466 -0
  5. haiku/rag/client.py +731 -0
  6. haiku/rag/config/__init__.py +74 -0
  7. haiku/rag/config/loader.py +94 -0
  8. haiku/rag/config/models.py +99 -0
  9. haiku/rag/embeddings/__init__.py +49 -0
  10. haiku/rag/embeddings/base.py +25 -0
  11. haiku/rag/embeddings/ollama.py +28 -0
  12. haiku/rag/embeddings/openai.py +26 -0
  13. haiku/rag/embeddings/vllm.py +29 -0
  14. haiku/rag/embeddings/voyageai.py +27 -0
  15. haiku/rag/graph/__init__.py +26 -0
  16. haiku/rag/graph/agui/__init__.py +53 -0
  17. haiku/rag/graph/agui/cli_renderer.py +135 -0
  18. haiku/rag/graph/agui/emitter.py +197 -0
  19. haiku/rag/graph/agui/events.py +254 -0
  20. haiku/rag/graph/agui/server.py +310 -0
  21. haiku/rag/graph/agui/state.py +34 -0
  22. haiku/rag/graph/agui/stream.py +86 -0
  23. haiku/rag/graph/common/__init__.py +5 -0
  24. haiku/rag/graph/common/models.py +42 -0
  25. haiku/rag/graph/common/nodes.py +265 -0
  26. haiku/rag/graph/common/prompts.py +46 -0
  27. haiku/rag/graph/common/utils.py +44 -0
  28. haiku/rag/graph/deep_qa/__init__.py +1 -0
  29. haiku/rag/graph/deep_qa/dependencies.py +27 -0
  30. haiku/rag/graph/deep_qa/graph.py +243 -0
  31. haiku/rag/graph/deep_qa/models.py +20 -0
  32. haiku/rag/graph/deep_qa/prompts.py +59 -0
  33. haiku/rag/graph/deep_qa/state.py +56 -0
  34. haiku/rag/graph/research/__init__.py +3 -0
  35. haiku/rag/graph/research/common.py +87 -0
  36. haiku/rag/graph/research/dependencies.py +151 -0
  37. haiku/rag/graph/research/graph.py +295 -0
  38. haiku/rag/graph/research/models.py +166 -0
  39. haiku/rag/graph/research/prompts.py +107 -0
  40. haiku/rag/graph/research/state.py +85 -0
  41. haiku/rag/logging.py +56 -0
  42. haiku/rag/mcp.py +245 -0
  43. haiku/rag/monitor.py +194 -0
  44. haiku/rag/qa/__init__.py +33 -0
  45. haiku/rag/qa/agent.py +93 -0
  46. haiku/rag/qa/prompts.py +60 -0
  47. haiku/rag/reader.py +135 -0
  48. haiku/rag/reranking/__init__.py +63 -0
  49. haiku/rag/reranking/base.py +13 -0
  50. haiku/rag/reranking/cohere.py +34 -0
  51. haiku/rag/reranking/mxbai.py +28 -0
  52. haiku/rag/reranking/vllm.py +44 -0
  53. haiku/rag/reranking/zeroentropy.py +59 -0
  54. haiku/rag/store/__init__.py +4 -0
  55. haiku/rag/store/engine.py +309 -0
  56. haiku/rag/store/models/__init__.py +4 -0
  57. haiku/rag/store/models/chunk.py +17 -0
  58. haiku/rag/store/models/document.py +17 -0
  59. haiku/rag/store/repositories/__init__.py +9 -0
  60. haiku/rag/store/repositories/chunk.py +442 -0
  61. haiku/rag/store/repositories/document.py +261 -0
  62. haiku/rag/store/repositories/settings.py +165 -0
  63. haiku/rag/store/upgrades/__init__.py +62 -0
  64. haiku/rag/store/upgrades/v0_10_1.py +64 -0
  65. haiku/rag/store/upgrades/v0_9_3.py +112 -0
  66. haiku/rag/utils.py +211 -0
  67. haiku_rag_slim-0.16.0.dist-info/METADATA +128 -0
  68. haiku_rag_slim-0.16.0.dist-info/RECORD +71 -0
  69. haiku_rag_slim-0.16.0.dist-info/WHEEL +4 -0
  70. haiku_rag_slim-0.16.0.dist-info/entry_points.txt +2 -0
  71. haiku_rag_slim-0.16.0.dist-info/licenses/LICENSE +7 -0
@@ -0,0 +1,165 @@
1
+ import json
2
+
3
+ from haiku.rag.store.engine import SettingsRecord, Store
4
+
5
+
6
+ class ConfigMismatchError(Exception):
7
+ """Raised when stored config doesn't match current config."""
8
+
9
+ pass
10
+
11
+
12
+ class SettingsRepository:
13
+ """Repository for Settings operations."""
14
+
15
+ def __init__(self, store: Store) -> None:
16
+ self.store = store
17
+
18
+ async def create(self, entity: dict) -> dict:
19
+ """Create settings in the database."""
20
+ settings_record = SettingsRecord(id="settings", settings=json.dumps(entity))
21
+ self.store.settings_table.add([settings_record])
22
+ return entity
23
+
24
+ async def get_by_id(self, entity_id: str) -> dict | None:
25
+ """Get settings by ID."""
26
+ results = list(
27
+ self.store.settings_table.search()
28
+ .where(f"id = '{entity_id}'")
29
+ .limit(1)
30
+ .to_pydantic(SettingsRecord)
31
+ )
32
+
33
+ if not results:
34
+ return None
35
+
36
+ return json.loads(results[0].settings) if results[0].settings else {}
37
+
38
+ async def update(self, entity: dict) -> dict:
39
+ """Update existing settings."""
40
+ self.store.settings_table.update(
41
+ where="id = 'settings'", values={"settings": json.dumps(entity)}
42
+ )
43
+ return entity
44
+
45
+ async def delete(self, entity_id: str) -> bool:
46
+ """Delete settings by ID."""
47
+ self.store.settings_table.delete(f"id = '{entity_id}'")
48
+ return True
49
+
50
+ async def list_all(
51
+ self, limit: int | None = None, offset: int | None = None
52
+ ) -> list[dict]:
53
+ """List all settings."""
54
+ results = list(self.store.settings_table.search().to_pydantic(SettingsRecord))
55
+ return [
56
+ json.loads(record.settings) if record.settings else {} for record in results
57
+ ]
58
+
59
+ def get_current_settings(self) -> dict:
60
+ """Get the current settings."""
61
+ results = list(
62
+ self.store.settings_table.search()
63
+ .where("id = 'settings'")
64
+ .limit(1)
65
+ .to_pydantic(SettingsRecord)
66
+ )
67
+
68
+ if not results:
69
+ return {}
70
+
71
+ return json.loads(results[0].settings) if results[0].settings else {}
72
+
73
+ def save_current_settings(self) -> None:
74
+ """Save the current configuration to the database."""
75
+ current_config = self.store._config.model_dump(mode="json")
76
+
77
+ # Check if settings exist
78
+ existing = list(
79
+ self.store.settings_table.search()
80
+ .where("id = 'settings'")
81
+ .limit(1)
82
+ .to_pydantic(SettingsRecord)
83
+ )
84
+
85
+ if existing:
86
+ # Preserve existing version if present to avoid interfering with upgrade flow
87
+ try:
88
+ existing_settings = (
89
+ json.loads(existing[0].settings) if existing[0].settings else {}
90
+ )
91
+ except Exception:
92
+ existing_settings = {}
93
+ if "version" in existing_settings:
94
+ current_config["version"] = existing_settings["version"]
95
+
96
+ # Update existing settings
97
+ if existing_settings != current_config:
98
+ self.store.settings_table.update(
99
+ where="id = 'settings'",
100
+ values={"settings": json.dumps(current_config)},
101
+ )
102
+ else:
103
+ # Create new settings
104
+ settings_record = SettingsRecord(
105
+ id="settings", settings=json.dumps(current_config)
106
+ )
107
+ self.store.settings_table.add([settings_record])
108
+
109
+ def validate_config_compatibility(self) -> None:
110
+ """Validate that the current configuration is compatible with stored settings."""
111
+ stored_settings = self.get_current_settings()
112
+
113
+ # If no stored settings, this is a new database - save current config and return
114
+ if not stored_settings:
115
+ self.save_current_settings()
116
+ return
117
+
118
+ current_config = self.store._config.model_dump(mode="json")
119
+
120
+ # Check if embedding provider or model has changed
121
+ # Support both old flat structure and new nested structure for backward compatibility
122
+ stored_embeddings = stored_settings.get("embeddings", {})
123
+ current_embeddings = current_config.get("embeddings", {})
124
+
125
+ # Try nested structure first, fall back to flat for old databases
126
+ stored_provider = stored_embeddings.get("provider") or stored_settings.get(
127
+ "EMBEDDINGS_PROVIDER"
128
+ )
129
+ current_provider = current_embeddings.get("provider")
130
+
131
+ stored_model = stored_embeddings.get("model") or stored_settings.get(
132
+ "EMBEDDINGS_MODEL"
133
+ )
134
+ current_model = current_embeddings.get("model")
135
+
136
+ stored_vector_dim = stored_embeddings.get("vector_dim") or stored_settings.get(
137
+ "EMBEDDINGS_VECTOR_DIM"
138
+ )
139
+ current_vector_dim = current_embeddings.get("vector_dim")
140
+
141
+ # Check for incompatible changes
142
+ incompatible_changes = []
143
+
144
+ if stored_provider and stored_provider != current_provider:
145
+ incompatible_changes.append(
146
+ f"Stored (db) embedding provider: '{stored_provider}' -> Environment (current) embedding provider: '{current_provider}'"
147
+ )
148
+
149
+ if stored_model and stored_model != current_model:
150
+ incompatible_changes.append(
151
+ f"Stored (db) embedding model '{stored_model}' -> Environment (current) embedding model '{current_model}'"
152
+ )
153
+
154
+ if stored_vector_dim and stored_vector_dim != current_vector_dim:
155
+ incompatible_changes.append(
156
+ f"Stored (db) embedding vector dimension {stored_vector_dim} -> Environment (current) embedding vector dimension {current_vector_dim}"
157
+ )
158
+
159
+ if incompatible_changes:
160
+ error_msg = (
161
+ "Database configuration is incompatible with current settings:\n"
162
+ + "\n".join(f" - {change}" for change in incompatible_changes)
163
+ )
164
+ error_msg += "\n\nPlease rebuild the database using: haiku-rag rebuild"
165
+ raise ConfigMismatchError(error_msg)
@@ -0,0 +1,62 @@
1
+ import logging
2
+ from collections.abc import Callable
3
+ from dataclasses import dataclass
4
+
5
+ from packaging.version import Version, parse
6
+
7
+ from haiku.rag.store.engine import Store
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ @dataclass
13
+ class Upgrade:
14
+ """Represents a database upgrade step."""
15
+
16
+ version: str
17
+ apply: Callable[[Store], None]
18
+ description: str = ""
19
+
20
+
21
+ # Registry of upgrade steps (ordered by version)
22
+ upgrades: list[Upgrade] = []
23
+
24
+
25
+ def run_pending_upgrades(store: Store, from_version: str, to_version: str) -> None:
26
+ """Run upgrades where from_version < step.version <= to_version."""
27
+ v_from: Version = parse(from_version)
28
+ v_to: Version = parse(to_version)
29
+
30
+ # Ensure that tests/development run available code upgrades even if the
31
+ # installed package version hasn't been bumped to include them yet.
32
+ if upgrades:
33
+ highest_step_version: Version = max(parse(u.version) for u in upgrades)
34
+ if highest_step_version > v_to:
35
+ v_to = highest_step_version
36
+
37
+ # Determine applicable steps
38
+ sorted_steps = sorted(upgrades, key=lambda u: parse(u.version))
39
+ applicable = [s for s in sorted_steps if v_from < parse(s.version) <= v_to]
40
+ if applicable:
41
+ logger.info("%d upgrade step(s) pending", len(applicable))
42
+
43
+ # Apply in ascending order
44
+ for idx, step in enumerate(applicable, start=1):
45
+ logger.info(
46
+ "Applying upgrade %s: %s (%d/%d)",
47
+ step.version,
48
+ step.description or "",
49
+ idx,
50
+ len(applicable),
51
+ )
52
+ step.apply(store)
53
+ logger.info("Completed upgrade %s", step.version)
54
+
55
+
56
+ from .v0_9_3 import upgrade_fts_phrase as upgrade_0_9_3_fts # noqa: E402
57
+ from .v0_9_3 import upgrade_order as upgrade_0_9_3_order # noqa: E402
58
+ from .v0_10_1 import upgrade_add_title as upgrade_0_10_1_add_title # noqa: E402
59
+
60
+ upgrades.append(upgrade_0_9_3_order)
61
+ upgrades.append(upgrade_0_9_3_fts)
62
+ upgrades.append(upgrade_0_10_1_add_title)
@@ -0,0 +1,64 @@
1
+ import json
2
+
3
+ from lancedb.pydantic import LanceModel
4
+ from pydantic import Field
5
+
6
+ from haiku.rag.store.engine import Store
7
+ from haiku.rag.store.upgrades import Upgrade
8
+
9
+
10
+ def _apply_add_document_title(store: Store) -> None:
11
+ """Add a nullable 'title' column to the documents table."""
12
+
13
+ # Read existing rows using Arrow for schema-agnostic access
14
+ try:
15
+ docs_arrow = store.documents_table.search().to_arrow()
16
+ rows = docs_arrow.to_pylist()
17
+ except Exception:
18
+ rows = []
19
+
20
+ class DocumentRecordV2(LanceModel):
21
+ id: str
22
+ content: str
23
+ uri: str | None = None
24
+ title: str | None = None
25
+ metadata: str = Field(default="{}")
26
+ created_at: str = Field(default_factory=lambda: "")
27
+ updated_at: str = Field(default_factory=lambda: "")
28
+
29
+ # Drop and recreate documents table with the new schema
30
+ try:
31
+ store.db.drop_table("documents")
32
+ except Exception:
33
+ pass
34
+
35
+ store.documents_table = store.db.create_table("documents", schema=DocumentRecordV2)
36
+
37
+ # Reinsert previous rows with title=None
38
+ if rows:
39
+ backfilled = []
40
+ for row in rows:
41
+ backfilled.append(
42
+ DocumentRecordV2(
43
+ id=row.get("id"),
44
+ content=row.get("content", ""),
45
+ uri=row.get("uri"),
46
+ title=None,
47
+ metadata=(
48
+ row.get("metadata")
49
+ if isinstance(row.get("metadata"), str)
50
+ else json.dumps(row.get("metadata") or {})
51
+ ),
52
+ created_at=row.get("created_at", ""),
53
+ updated_at=row.get("updated_at", ""),
54
+ )
55
+ )
56
+
57
+ store.documents_table.add(backfilled)
58
+
59
+
60
+ upgrade_add_title = Upgrade(
61
+ version="0.10.1",
62
+ apply=_apply_add_document_title,
63
+ description="Add nullable 'title' column to documents table",
64
+ )
@@ -0,0 +1,112 @@
1
+ import json
2
+
3
+ from lancedb.pydantic import LanceModel, Vector
4
+ from pydantic import Field
5
+
6
+ from haiku.rag.store.engine import Store
7
+ from haiku.rag.store.upgrades import Upgrade
8
+
9
+
10
+ def _infer_vector_dim(store: Store) -> int:
11
+ """Infer vector dimension from existing data; fallback to embedder config."""
12
+ try:
13
+ arrow = store.chunks_table.search().limit(1).to_arrow()
14
+ rows = arrow.to_pylist()
15
+ if rows:
16
+ vec = rows[0].get("vector")
17
+ if isinstance(vec, list) and vec:
18
+ return len(vec)
19
+ except Exception:
20
+ pass
21
+ # Fallback to configured embedder vector dim
22
+ return getattr(store.embedder, "_vector_dim", 1024)
23
+
24
+
25
+ def _apply_chunk_order(store: Store) -> None:
26
+ """Add integer 'order' column to chunks and backfill from metadata."""
27
+
28
+ vector_dim = _infer_vector_dim(store)
29
+
30
+ class ChunkRecordV2(LanceModel):
31
+ id: str
32
+ document_id: str
33
+ content: str
34
+ metadata: str = Field(default="{}")
35
+ order: int = Field(default=0)
36
+ vector: Vector(vector_dim) = Field( # type: ignore
37
+ default_factory=lambda: [0.0] * vector_dim
38
+ )
39
+
40
+ # Read existing chunks
41
+ try:
42
+ chunks_arrow = store.chunks_table.search().to_arrow()
43
+ rows = chunks_arrow.to_pylist()
44
+ except Exception:
45
+ rows = []
46
+
47
+ new_chunk_records: list[ChunkRecordV2] = []
48
+ for row in rows:
49
+ md_raw = row.get("metadata") or "{}"
50
+ try:
51
+ md = json.loads(md_raw) if isinstance(md_raw, str) else md_raw
52
+ except Exception:
53
+ md = {}
54
+ # Extract and normalize order
55
+ order_val = 0
56
+ try:
57
+ if isinstance(md, dict) and "order" in md:
58
+ order_val = int(md["order"]) # type: ignore[arg-type]
59
+ except Exception:
60
+ order_val = 0
61
+
62
+ if isinstance(md, dict) and "order" in md:
63
+ md = {k: v for k, v in md.items() if k != "order"}
64
+
65
+ vec = row.get("vector") or [0.0] * vector_dim
66
+
67
+ new_chunk_records.append(
68
+ ChunkRecordV2(
69
+ id=row.get("id"),
70
+ document_id=row.get("document_id"),
71
+ content=row.get("content", ""),
72
+ metadata=json.dumps(md),
73
+ order=order_val,
74
+ vector=vec,
75
+ )
76
+ )
77
+
78
+ # Recreate chunks table with new schema
79
+ try:
80
+ store.db.drop_table("chunks")
81
+ except Exception:
82
+ pass
83
+
84
+ store.chunks_table = store.db.create_table("chunks", schema=ChunkRecordV2)
85
+ store.chunks_table.create_fts_index("content", replace=True)
86
+
87
+ if new_chunk_records:
88
+ store.chunks_table.add(new_chunk_records)
89
+
90
+
91
+ upgrade_order = Upgrade(
92
+ version="0.9.3",
93
+ apply=_apply_chunk_order,
94
+ description="Add 'order' column to chunks and backfill from metadata",
95
+ )
96
+
97
+
98
+ def _apply_fts_phrase_support(store: Store) -> None:
99
+ """Recreate FTS index with phrase query support and no stop-word removal."""
100
+ try:
101
+ store.chunks_table.create_fts_index(
102
+ "content", replace=True, with_position=True, remove_stop_words=False
103
+ )
104
+ except Exception:
105
+ pass
106
+
107
+
108
+ upgrade_fts_phrase = Upgrade(
109
+ version="0.9.3",
110
+ apply=_apply_fts_phrase_support,
111
+ description="Enable FTS phrase queries (with positions) and keep stop-words",
112
+ )
haiku/rag/utils.py ADDED
@@ -0,0 +1,211 @@
1
+ import asyncio
2
+ import importlib
3
+ import importlib.util
4
+ import sys
5
+ from collections.abc import Callable
6
+ from functools import wraps
7
+ from importlib import metadata
8
+ from io import BytesIO
9
+ from pathlib import Path
10
+ from types import ModuleType
11
+
12
+ from packaging.version import Version, parse
13
+
14
+
15
+ def debounce(wait: float) -> Callable:
16
+ """
17
+ A decorator to debounce a function, ensuring it is called only after a specified delay
18
+ and always executes after the last call.
19
+
20
+ Args:
21
+ wait (float): The debounce delay in seconds.
22
+
23
+ Returns:
24
+ Callable: The decorated function.
25
+ """
26
+
27
+ def decorator(func: Callable) -> Callable:
28
+ last_call = None
29
+ task = None
30
+
31
+ @wraps(func)
32
+ async def debounced(*args, **kwargs):
33
+ nonlocal last_call, task
34
+ last_call = asyncio.get_event_loop().time()
35
+
36
+ if task:
37
+ task.cancel()
38
+
39
+ async def call_func():
40
+ await asyncio.sleep(wait)
41
+ if asyncio.get_event_loop().time() - last_call >= wait: # type: ignore
42
+ await func(*args, **kwargs)
43
+
44
+ task = asyncio.create_task(call_func())
45
+
46
+ return debounced
47
+
48
+ return decorator
49
+
50
+
51
+ def get_default_data_dir() -> Path:
52
+ """Get the user data directory for the current system platform.
53
+
54
+ Linux: ~/.local/share/haiku.rag
55
+ macOS: ~/Library/Application Support/haiku.rag
56
+ Windows: C:/Users/<USER>/AppData/Roaming/haiku.rag
57
+
58
+ Returns:
59
+ User Data Path.
60
+ """
61
+ home = Path.home()
62
+
63
+ system_paths = {
64
+ "win32": home / "AppData/Roaming/haiku.rag",
65
+ "linux": home / ".local/share/haiku.rag",
66
+ "darwin": home / "Library/Application Support/haiku.rag",
67
+ }
68
+
69
+ data_path = system_paths[sys.platform]
70
+ return data_path
71
+
72
+
73
+ async def is_up_to_date() -> tuple[bool, Version, Version]:
74
+ """Check whether haiku.rag is current.
75
+
76
+ Returns:
77
+ A tuple containing a boolean indicating whether haiku.rag is current,
78
+ the running version and the latest version.
79
+ """
80
+
81
+ # Lazy import to avoid pulling httpx (and its deps) on module import
82
+ import httpx
83
+
84
+ async with httpx.AsyncClient() as client:
85
+ running_version = parse(metadata.version("haiku.rag-slim"))
86
+ try:
87
+ response = await client.get("https://pypi.org/pypi/haiku.rag/json")
88
+ data = response.json()
89
+ pypi_version = parse(data["info"]["version"])
90
+ except Exception:
91
+ # If no network connection, do not raise alarms.
92
+ pypi_version = running_version
93
+ return running_version >= pypi_version, running_version, pypi_version
94
+
95
+
96
+ def text_to_docling_document(text: str, name: str = "content.md"):
97
+ """Convert text content to a DoclingDocument.
98
+
99
+ Args:
100
+ text: The text content to convert.
101
+ name: The name to use for the document stream (defaults to "content.md").
102
+
103
+ Returns:
104
+ A DoclingDocument created from the text content.
105
+ """
106
+ try:
107
+ import docling # noqa: F401
108
+ except ImportError as e:
109
+ raise ImportError(
110
+ "Docling is required for document conversion. "
111
+ "Install with: pip install haiku.rag-slim[docling]"
112
+ ) from e
113
+
114
+ from docling.document_converter import DocumentConverter
115
+ from docling_core.types.io import DocumentStream
116
+
117
+ bytes_io = BytesIO(text.encode("utf-8"))
118
+ doc_stream = DocumentStream(name=name, stream=bytes_io)
119
+ converter = DocumentConverter()
120
+ result = converter.convert(doc_stream)
121
+ return result.document
122
+
123
+
124
+ def load_callable(path: str):
125
+ """Load a callable from a dotted path or file path.
126
+
127
+ Supported formats:
128
+ - "package.module:func" or "package.module.func"
129
+ - "path/to/file.py:func"
130
+
131
+ Returns the loaded callable. Raises ValueError on failure.
132
+ """
133
+ if not path:
134
+ raise ValueError("Empty callable path provided")
135
+
136
+ module_part = None
137
+ func_name = None
138
+
139
+ if ":" in path:
140
+ module_part, func_name = path.split(":", 1)
141
+ else:
142
+ # split by last dot for module.attr
143
+ if "." in path:
144
+ module_part, func_name = path.rsplit(".", 1)
145
+ else:
146
+ raise ValueError(
147
+ "Invalid callable path format. Use 'module:func' or 'module.func' or 'file.py:func'."
148
+ )
149
+
150
+ # Try file path first
151
+ mod: ModuleType | None = None
152
+ module_path = Path(module_part)
153
+ if module_path.suffix == ".py" and module_path.exists():
154
+ spec = importlib.util.spec_from_file_location(module_path.stem, module_path)
155
+ if spec and spec.loader:
156
+ mod = importlib.util.module_from_spec(spec)
157
+ spec.loader.exec_module(mod)
158
+ else:
159
+ # Import as a module path
160
+ try:
161
+ mod = importlib.import_module(module_part)
162
+ except Exception as e:
163
+ raise ValueError(f"Failed to import module '{module_part}': {e}")
164
+
165
+ if not hasattr(mod, func_name):
166
+ raise ValueError(f"Callable '{func_name}' not found in module '{module_part}'")
167
+ func = getattr(mod, func_name)
168
+ if not callable(func):
169
+ raise ValueError(
170
+ f"Attribute '{func_name}' in module '{module_part}' is not callable"
171
+ )
172
+ return func
173
+
174
+
175
+ def prefetch_models():
176
+ """Prefetch runtime models (Docling + Ollama as configured)."""
177
+ import httpx
178
+
179
+ from haiku.rag.config import Config
180
+
181
+ try:
182
+ from docling.utils.model_downloader import download_models
183
+
184
+ download_models()
185
+ except ImportError:
186
+ # Docling not installed, skip downloading docling models
187
+ pass
188
+
189
+ # Collect Ollama models from config
190
+ required_models: set[str] = set()
191
+ if Config.embeddings.provider == "ollama":
192
+ required_models.add(Config.embeddings.model)
193
+ if Config.qa.provider == "ollama":
194
+ required_models.add(Config.qa.model)
195
+ if Config.research.provider == "ollama":
196
+ required_models.add(Config.research.model)
197
+ if Config.reranking.provider == "ollama":
198
+ required_models.add(Config.reranking.model)
199
+
200
+ if not required_models:
201
+ return
202
+
203
+ base_url = Config.providers.ollama.base_url
204
+
205
+ with httpx.Client(timeout=None) as client:
206
+ for model in sorted(required_models):
207
+ with client.stream(
208
+ "POST", f"{base_url}/api/pull", json={"model": model}
209
+ ) as r:
210
+ for _ in r.iter_lines():
211
+ pass