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
haiku/rag/__init__.py ADDED
File without changes
haiku/rag/app.py ADDED
@@ -0,0 +1,542 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ from importlib.metadata import version as pkg_version
5
+ from pathlib import Path
6
+
7
+ from rich.console import Console
8
+ from rich.markdown import Markdown
9
+ from rich.progress import Progress
10
+
11
+ from haiku.rag.client import HaikuRAG
12
+ from haiku.rag.config import AppConfig, Config
13
+ from haiku.rag.graph.agui import AGUIConsoleRenderer, stream_graph
14
+ from haiku.rag.graph.research.dependencies import ResearchContext
15
+ from haiku.rag.graph.research.graph import build_research_graph
16
+ from haiku.rag.graph.research.state import ResearchDeps, ResearchState
17
+ from haiku.rag.mcp import create_mcp_server
18
+ from haiku.rag.monitor import FileWatcher
19
+ from haiku.rag.store.models.chunk import Chunk
20
+ from haiku.rag.store.models.document import Document
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class HaikuRAGApp:
26
+ def __init__(self, db_path: Path, config: AppConfig = Config):
27
+ self.db_path = db_path
28
+ self.config = config
29
+ self.console = Console()
30
+
31
+ async def info(self):
32
+ """Display read-only information about the database without modifying it."""
33
+
34
+ import lancedb
35
+
36
+ # Basic: show path
37
+ self.console.print("[bold]haiku.rag database info[/bold]")
38
+ self.console.print(
39
+ f" [repr.attrib_name]path[/repr.attrib_name]: {self.db_path}"
40
+ )
41
+
42
+ if not self.db_path.exists():
43
+ self.console.print("[red]Database path does not exist.[/red]")
44
+ return
45
+
46
+ # Connect without going through Store to avoid upgrades/validation writes
47
+ try:
48
+ db = lancedb.connect(self.db_path)
49
+ table_names = set(db.table_names())
50
+ except Exception as e:
51
+ self.console.print(f"[red]Failed to open database: {e}[/red]")
52
+ return
53
+
54
+ try:
55
+ ldb_version = pkg_version("lancedb")
56
+ except Exception:
57
+ ldb_version = "unknown"
58
+ try:
59
+ hr_version = pkg_version("haiku.rag-slim")
60
+ except Exception:
61
+ hr_version = "unknown"
62
+ try:
63
+ docling_version = pkg_version("docling")
64
+ except Exception:
65
+ docling_version = "unknown"
66
+
67
+ # Read settings (if present) to find stored haiku.rag version and embedding config
68
+ stored_version = "unknown"
69
+ embed_provider: str | None = None
70
+ embed_model: str | None = None
71
+ vector_dim: int | None = None
72
+
73
+ if "settings" in table_names:
74
+ settings_tbl = db.open_table("settings")
75
+ arrow = settings_tbl.search().where("id = 'settings'").limit(1).to_arrow()
76
+ rows = arrow.to_pylist() if arrow is not None else []
77
+ if rows:
78
+ raw = rows[0].get("settings") or "{}"
79
+ data = json.loads(raw) if isinstance(raw, str) else (raw or {})
80
+ stored_version = str(data.get("version", stored_version))
81
+ embeddings = data.get("embeddings", {})
82
+ embed_provider = embeddings.get("provider")
83
+ embed_model = embeddings.get("model")
84
+ vector_dim = embeddings.get("vector_dim")
85
+
86
+ num_docs = 0
87
+ if "documents" in table_names:
88
+ docs_tbl = db.open_table("documents")
89
+ num_docs = int(docs_tbl.count_rows()) # type: ignore[attr-defined]
90
+
91
+ # Table versions per table (direct API)
92
+ doc_versions = (
93
+ len(list(db.open_table("documents").list_versions()))
94
+ if "documents" in table_names
95
+ else 0
96
+ )
97
+ chunk_versions = (
98
+ len(list(db.open_table("chunks").list_versions()))
99
+ if "chunks" in table_names
100
+ else 0
101
+ )
102
+
103
+ self.console.print(
104
+ f" [repr.attrib_name]haiku.rag version (db)[/repr.attrib_name]: {stored_version}"
105
+ )
106
+ if embed_provider or embed_model or vector_dim:
107
+ provider_part = embed_provider or "unknown"
108
+ model_part = embed_model or "unknown"
109
+ dim_part = f"{vector_dim}" if vector_dim is not None else "unknown"
110
+ self.console.print(
111
+ " [repr.attrib_name]embeddings[/repr.attrib_name]: "
112
+ f"{provider_part}/{model_part} (dim: {dim_part})"
113
+ )
114
+ else:
115
+ self.console.print(
116
+ " [repr.attrib_name]embeddings[/repr.attrib_name]: unknown"
117
+ )
118
+ self.console.print(
119
+ f" [repr.attrib_name]documents[/repr.attrib_name]: {num_docs}"
120
+ )
121
+ self.console.print(
122
+ f" [repr.attrib_name]versions (documents)[/repr.attrib_name]: {doc_versions}"
123
+ )
124
+ self.console.print(
125
+ f" [repr.attrib_name]versions (chunks)[/repr.attrib_name]: {chunk_versions}"
126
+ )
127
+ self.console.rule()
128
+ self.console.print("[bold]Versions[/bold]")
129
+ self.console.print(
130
+ f" [repr.attrib_name]haiku.rag[/repr.attrib_name]: {hr_version}"
131
+ )
132
+ self.console.print(
133
+ f" [repr.attrib_name]lancedb[/repr.attrib_name]: {ldb_version}"
134
+ )
135
+ self.console.print(
136
+ f" [repr.attrib_name]docling[/repr.attrib_name]: {docling_version}"
137
+ )
138
+
139
+ async def list_documents(self, filter: str | None = None):
140
+ async with HaikuRAG(
141
+ db_path=self.db_path, config=self.config, allow_create=False
142
+ ) as self.client:
143
+ documents = await self.client.list_documents(filter=filter)
144
+ for doc in documents:
145
+ self._rich_print_document(doc, truncate=True)
146
+
147
+ async def add_document_from_text(self, text: str, metadata: dict | None = None):
148
+ async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client:
149
+ doc = await self.client.create_document(text, metadata=metadata)
150
+ self._rich_print_document(doc, truncate=True)
151
+ self.console.print(
152
+ f"[bold green]Document {doc.id} added successfully.[/bold green]"
153
+ )
154
+
155
+ async def add_document_from_source(
156
+ self, source: str, title: str | None = None, metadata: dict | None = None
157
+ ):
158
+ async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client:
159
+ result = await self.client.create_document_from_source(
160
+ source, title=title, metadata=metadata
161
+ )
162
+ if isinstance(result, list):
163
+ for doc in result:
164
+ self._rich_print_document(doc, truncate=True)
165
+ self.console.print(
166
+ f"[bold green]{len(result)} documents added successfully.[/bold green]"
167
+ )
168
+ else:
169
+ self._rich_print_document(result, truncate=True)
170
+ self.console.print(
171
+ f"[bold green]Document {result.id} added successfully.[/bold green]"
172
+ )
173
+
174
+ async def get_document(self, doc_id: str):
175
+ async with HaikuRAG(
176
+ db_path=self.db_path, config=self.config, allow_create=False
177
+ ) as self.client:
178
+ doc = await self.client.get_document_by_id(doc_id)
179
+ if doc is None:
180
+ self.console.print(f"[red]Document with id {doc_id} not found.[/red]")
181
+ return
182
+ self._rich_print_document(doc, truncate=False)
183
+
184
+ async def delete_document(self, doc_id: str):
185
+ async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client:
186
+ deleted = await self.client.delete_document(doc_id)
187
+ if deleted:
188
+ self.console.print(
189
+ f"[bold green]Document {doc_id} deleted successfully.[/bold green]"
190
+ )
191
+ else:
192
+ self.console.print(
193
+ f"[yellow]Document with id {doc_id} not found.[/yellow]"
194
+ )
195
+
196
+ async def search(self, query: str, limit: int = 5, filter: str | None = None):
197
+ async with HaikuRAG(
198
+ db_path=self.db_path, config=self.config, allow_create=False
199
+ ) as self.client:
200
+ results = await self.client.search(query, limit=limit, filter=filter)
201
+ if not results:
202
+ self.console.print("[yellow]No results found.[/yellow]")
203
+ return
204
+ for chunk, score in results:
205
+ self._rich_print_search_result(chunk, score)
206
+
207
+ async def ask(
208
+ self,
209
+ question: str,
210
+ cite: bool = False,
211
+ deep: bool = False,
212
+ verbose: bool = False,
213
+ ):
214
+ """Ask a question using the RAG system.
215
+
216
+ Args:
217
+ question: The question to ask
218
+ cite: Include citations in the answer
219
+ deep: Use deep QA mode (multi-step reasoning)
220
+ verbose: Show verbose output
221
+ """
222
+ async with HaikuRAG(
223
+ db_path=self.db_path, config=self.config, allow_create=False
224
+ ) as self.client:
225
+ try:
226
+ if deep:
227
+ from haiku.rag.graph.deep_qa.dependencies import DeepQAContext
228
+ from haiku.rag.graph.deep_qa.graph import build_deep_qa_graph
229
+ from haiku.rag.graph.deep_qa.state import DeepQADeps, DeepQAState
230
+
231
+ graph = build_deep_qa_graph(config=self.config)
232
+ context = DeepQAContext(
233
+ original_question=question, use_citations=cite
234
+ )
235
+ state = DeepQAState.from_config(context=context, config=self.config)
236
+ deps = DeepQADeps(client=self.client)
237
+
238
+ if verbose:
239
+ # Use AG-UI renderer to process and display events
240
+ from haiku.rag.graph.agui import AGUIConsoleRenderer
241
+
242
+ renderer = AGUIConsoleRenderer(self.console)
243
+ result_dict = await renderer.render(
244
+ stream_graph(graph, state, deps)
245
+ )
246
+ # Result should be a dict with 'answer' key
247
+ answer = result_dict.get("answer", "") if result_dict else ""
248
+ else:
249
+ # Run without rendering events, just get the result
250
+ result = await graph.run(state=state, deps=deps)
251
+ answer = result.answer
252
+ else:
253
+ answer = await self.client.ask(question, cite=cite)
254
+
255
+ self.console.print(f"[bold blue]Question:[/bold blue] {question}")
256
+ self.console.print()
257
+ self.console.print("[bold green]Answer:[/bold green]")
258
+ self.console.print(Markdown(answer))
259
+ except Exception as e:
260
+ self.console.print(f"[red]Error: {e}[/red]")
261
+
262
+ async def research(self, question: str, verbose: bool = False):
263
+ """Run research via the pydantic-graph pipeline.
264
+
265
+ Args:
266
+ question: The research question
267
+ verbose: Show AG-UI event stream during execution
268
+ """
269
+ async with HaikuRAG(
270
+ db_path=self.db_path, config=self.config, allow_create=False
271
+ ) as client:
272
+ try:
273
+ self.console.print("[bold cyan]Starting research[/bold cyan]")
274
+ self.console.print(f"[bold blue]Question:[/bold blue] {question}")
275
+ self.console.print()
276
+
277
+ graph = build_research_graph(config=self.config)
278
+ context = ResearchContext(original_question=question)
279
+ state = ResearchState.from_config(context=context, config=self.config)
280
+ deps = ResearchDeps(client=client)
281
+
282
+ if verbose:
283
+ # Use AG-UI renderer to process and display events
284
+ renderer = AGUIConsoleRenderer(self.console)
285
+ report_dict = await renderer.render(
286
+ stream_graph(graph, state, deps)
287
+ )
288
+ else:
289
+ # Run without rendering events, just get the result
290
+ report = await graph.run(state=state, deps=deps)
291
+ report_dict = (
292
+ report.model_dump() if hasattr(report, "model_dump") else report
293
+ )
294
+
295
+ if report_dict is None:
296
+ self.console.print("[red]Research did not produce a report.[/red]")
297
+ return
298
+
299
+ # Convert dict to ResearchReport model
300
+ from haiku.rag.graph.research.models import ResearchReport
301
+
302
+ report = ResearchReport.model_validate(report_dict)
303
+
304
+ # Display the report
305
+ self.console.print("[bold green]Research Report[/bold green]")
306
+ self.console.rule()
307
+
308
+ # Title and Executive Summary
309
+ self.console.print(f"[bold]{report.title}[/bold]")
310
+ self.console.print()
311
+ self.console.print("[bold cyan]Executive Summary:[/bold cyan]")
312
+ self.console.print(report.executive_summary)
313
+ self.console.print()
314
+
315
+ # Confidence (from last evaluation)
316
+ if state.last_eval:
317
+ conf = state.last_eval.confidence_score # type: ignore[attr-defined]
318
+ self.console.print(f"[bold cyan]Confidence:[/bold cyan] {conf:.1%}")
319
+ self.console.print()
320
+
321
+ # Main Findings
322
+ if report.main_findings:
323
+ self.console.print("[bold cyan]Main Findings:[/bold cyan]")
324
+ for finding in report.main_findings:
325
+ self.console.print(f"• {finding}")
326
+ self.console.print()
327
+
328
+ # (Themes section removed)
329
+
330
+ # Conclusions
331
+ if report.conclusions:
332
+ self.console.print("[bold cyan]Conclusions:[/bold cyan]")
333
+ for conclusion in report.conclusions:
334
+ self.console.print(f"• {conclusion}")
335
+ self.console.print()
336
+
337
+ # Recommendations
338
+ if report.recommendations:
339
+ self.console.print("[bold cyan]Recommendations:[/bold cyan]")
340
+ for rec in report.recommendations:
341
+ self.console.print(f"• {rec}")
342
+ self.console.print()
343
+
344
+ # Limitations
345
+ if report.limitations:
346
+ self.console.print("[bold yellow]Limitations:[/bold yellow]")
347
+ for limitation in report.limitations:
348
+ self.console.print(f"• {limitation}")
349
+ self.console.print()
350
+
351
+ # Sources Summary
352
+ if report.sources_summary:
353
+ self.console.print("[bold cyan]Sources:[/bold cyan]")
354
+ self.console.print(report.sources_summary)
355
+
356
+ except Exception as e:
357
+ self.console.print(f"[red]Error during research: {e}[/red]")
358
+
359
+ async def rebuild(self):
360
+ async with HaikuRAG(
361
+ db_path=self.db_path, config=self.config, skip_validation=True
362
+ ) as client:
363
+ try:
364
+ documents = await client.list_documents()
365
+ total_docs = len(documents)
366
+
367
+ if total_docs == 0:
368
+ self.console.print(
369
+ "[yellow]No documents found in database.[/yellow]"
370
+ )
371
+ return
372
+
373
+ self.console.print(
374
+ f"[bold cyan]Rebuilding database with {total_docs} documents...[/bold cyan]"
375
+ )
376
+ with Progress() as progress:
377
+ task = progress.add_task("Rebuilding...", total=total_docs)
378
+ async for _ in client.rebuild_database():
379
+ progress.update(task, advance=1)
380
+
381
+ self.console.print(
382
+ "[bold green]Database rebuild completed successfully.[/bold green]"
383
+ )
384
+ except Exception as e:
385
+ self.console.print(f"[red]Error rebuilding database: {e}[/red]")
386
+
387
+ async def vacuum(self):
388
+ """Run database maintenance: optimize and cleanup table history."""
389
+ try:
390
+ async with HaikuRAG(
391
+ db_path=self.db_path, config=self.config, skip_validation=True
392
+ ) as client:
393
+ await client.vacuum()
394
+ self.console.print(
395
+ "[bold green]Vacuum completed successfully.[/bold green]"
396
+ )
397
+ except Exception as e:
398
+ self.console.print(f"[red]Error during vacuum: {e}[/red]")
399
+
400
+ def show_settings(self):
401
+ """Display current configuration settings."""
402
+ self.console.print("[bold]haiku.rag configuration[/bold]")
403
+ self.console.print()
404
+
405
+ # Get all config fields dynamically
406
+ for field_name, field_value in self.config.model_dump().items():
407
+ # Format the display value
408
+ if isinstance(field_value, str) and (
409
+ "key" in field_name.lower()
410
+ or "password" in field_name.lower()
411
+ or "token" in field_name.lower()
412
+ ):
413
+ # Hide sensitive values but show if they're set
414
+ display_value = "✓ Set" if field_value else "✗ Not set"
415
+ else:
416
+ display_value = field_value
417
+
418
+ self.console.print(
419
+ f" [repr.attrib_name]{field_name}[/repr.attrib_name]: {display_value}"
420
+ )
421
+
422
+ def _rich_print_document(self, doc: Document, truncate: bool = False):
423
+ """Format a document for display."""
424
+ if truncate:
425
+ content = doc.content.splitlines()
426
+ if len(content) > 3:
427
+ content = content[:3] + ["\n…"]
428
+ content = "\n".join(content)
429
+ content = Markdown(content)
430
+ else:
431
+ content = Markdown(doc.content)
432
+ title_part = (
433
+ f" [repr.attrib_name]title[/repr.attrib_name]: {doc.title}"
434
+ if doc.title
435
+ else ""
436
+ )
437
+ self.console.print(
438
+ f"[repr.attrib_name]id[/repr.attrib_name]: {doc.id} "
439
+ f"[repr.attrib_name]uri[/repr.attrib_name]: {doc.uri}"
440
+ + title_part
441
+ + f" [repr.attrib_name]meta[/repr.attrib_name]: {doc.metadata}"
442
+ )
443
+ self.console.print(
444
+ f"[repr.attrib_name]created at[/repr.attrib_name]: {doc.created_at} [repr.attrib_name]updated at[/repr.attrib_name]: {doc.updated_at}"
445
+ )
446
+ self.console.print("[repr.attrib_name]content[/repr.attrib_name]:")
447
+ self.console.print(content)
448
+ self.console.rule()
449
+
450
+ def _rich_print_search_result(self, chunk: Chunk, score: float):
451
+ """Format a search result chunk for display."""
452
+ content = Markdown(chunk.content)
453
+ self.console.print(
454
+ f"[repr.attrib_name]document_id[/repr.attrib_name]: {chunk.document_id} "
455
+ f"[repr.attrib_name]score[/repr.attrib_name]: {score:.4f}"
456
+ )
457
+ if chunk.document_uri:
458
+ self.console.print("[repr.attrib_name]document uri[/repr.attrib_name]:")
459
+ self.console.print(chunk.document_uri)
460
+ if chunk.document_title:
461
+ self.console.print("[repr.attrib_name]document title[/repr.attrib_name]:")
462
+ self.console.print(chunk.document_title)
463
+ if chunk.document_meta:
464
+ self.console.print("[repr.attrib_name]document meta[/repr.attrib_name]:")
465
+ self.console.print(chunk.document_meta)
466
+ self.console.print("[repr.attrib_name]content[/repr.attrib_name]:")
467
+ self.console.print(content)
468
+ self.console.rule()
469
+
470
+ async def serve(
471
+ self,
472
+ enable_monitor: bool = True,
473
+ enable_mcp: bool = True,
474
+ mcp_transport: str | None = None,
475
+ mcp_port: int = 8001,
476
+ enable_agui: bool = False,
477
+ ):
478
+ """Start the server with selected services."""
479
+ async with HaikuRAG(self.db_path, config=self.config) as client:
480
+ tasks = []
481
+
482
+ # Start file monitor if enabled
483
+ if enable_monitor:
484
+ monitor = FileWatcher(client=client, config=self.config)
485
+ monitor_task = asyncio.create_task(monitor.observe())
486
+ tasks.append(monitor_task)
487
+
488
+ # Start MCP server if enabled
489
+ if enable_mcp:
490
+ server = create_mcp_server(self.db_path, config=self.config)
491
+
492
+ async def run_mcp():
493
+ if mcp_transport == "stdio":
494
+ await server.run_stdio_async()
495
+ else:
496
+ logger.info(f"Starting MCP server on port {mcp_port}")
497
+ await server.run_http_async(
498
+ transport="streamable-http", port=mcp_port
499
+ )
500
+
501
+ mcp_task = asyncio.create_task(run_mcp())
502
+ tasks.append(mcp_task)
503
+
504
+ # Start AG-UI server if enabled
505
+ if enable_agui:
506
+
507
+ async def run_agui():
508
+ import uvicorn
509
+
510
+ from haiku.rag.graph.agui import create_agui_server
511
+
512
+ logger.info(
513
+ f"Starting AG-UI server on {self.config.agui.host}:{self.config.agui.port}"
514
+ )
515
+ app = create_agui_server(self.config, db_path=self.db_path)
516
+ config = uvicorn.Config(
517
+ app=app,
518
+ host=self.config.agui.host,
519
+ port=self.config.agui.port,
520
+ log_level="info",
521
+ )
522
+ server = uvicorn.Server(config)
523
+ await server.serve()
524
+
525
+ agui_task = asyncio.create_task(run_agui())
526
+ tasks.append(agui_task)
527
+
528
+ if not tasks:
529
+ logger.warning("No services enabled")
530
+ return
531
+
532
+ try:
533
+ # Wait for any task to complete (or KeyboardInterrupt)
534
+ await asyncio.gather(*tasks)
535
+ except KeyboardInterrupt:
536
+ pass
537
+ finally:
538
+ # Cancel all tasks
539
+ for task in tasks:
540
+ task.cancel()
541
+ # Wait for cancellation
542
+ await asyncio.gather(*tasks, return_exceptions=True)
haiku/rag/chunker.py ADDED
@@ -0,0 +1,65 @@
1
+ from typing import ClassVar
2
+
3
+ import tiktoken
4
+ from docling_core.transforms.chunker.tokenizer.openai import OpenAITokenizer
5
+ from docling_core.types.doc.document import DoclingDocument
6
+
7
+ from haiku.rag.config import Config
8
+
9
+ # Check if docling is available
10
+ try:
11
+ import docling # noqa: F401
12
+
13
+ DOCLING_AVAILABLE = True
14
+ except ImportError:
15
+ DOCLING_AVAILABLE = False
16
+
17
+
18
+ class Chunker:
19
+ """A class that chunks text into smaller pieces for embedding and retrieval.
20
+
21
+ Uses docling's structure-aware chunking to create semantically meaningful chunks
22
+ that respect document boundaries.
23
+
24
+ Args:
25
+ chunk_size: The maximum size of a chunk in tokens.
26
+ """
27
+
28
+ encoder: ClassVar[tiktoken.Encoding] = tiktoken.encoding_for_model("gpt-4o")
29
+
30
+ def __init__(
31
+ self,
32
+ chunk_size: int = Config.processing.chunk_size,
33
+ ):
34
+ if not DOCLING_AVAILABLE:
35
+ raise ImportError(
36
+ "Docling is required for chunking. "
37
+ "Install with: pip install haiku.rag-slim[docling]"
38
+ )
39
+ from docling.chunking import HybridChunker # type: ignore
40
+
41
+ self.chunk_size = chunk_size
42
+ tokenizer = OpenAITokenizer(
43
+ tokenizer=tiktoken.encoding_for_model("gpt-4o"), max_tokens=chunk_size
44
+ )
45
+
46
+ self.chunker = HybridChunker(tokenizer=tokenizer) # type: ignore
47
+
48
+ async def chunk(self, document: DoclingDocument) -> list[str]:
49
+ """Split the document into chunks using docling's structure-aware chunking.
50
+
51
+ Args:
52
+ document: The DoclingDocument to be split into chunks.
53
+
54
+ Returns:
55
+ A list of text chunks with semantic boundaries.
56
+ """
57
+ if document is None:
58
+ return []
59
+
60
+ # Chunk using docling's hybrid chunker
61
+ chunks = list(self.chunker.chunk(document))
62
+ return [self.chunker.contextualize(chunk) for chunk in chunks]
63
+
64
+
65
+ chunker = Chunker()