haiku.rag 0.10.0__py3-none-any.whl → 0.10.1__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 might be problematic. Click here for more details.

haiku/rag/app.py CHANGED
@@ -39,9 +39,9 @@ class HaikuRAGApp:
39
39
  f"[b]Document with id [cyan]{doc.id}[/cyan] added successfully.[/b]"
40
40
  )
41
41
 
42
- async def add_document_from_source(self, source: str):
42
+ async def add_document_from_source(self, source: str, title: str | None = None):
43
43
  async with HaikuRAG(db_path=self.db_path) as self.client:
44
- doc = await self.client.create_document_from_source(source)
44
+ doc = await self.client.create_document_from_source(source, title=title)
45
45
  self._rich_print_document(doc, truncate=True)
46
46
  self.console.print(
47
47
  f"[b]Document with id [cyan]{doc.id}[/cyan] added successfully.[/b]"
@@ -252,8 +252,16 @@ class HaikuRAGApp:
252
252
  content = Markdown(content)
253
253
  else:
254
254
  content = Markdown(doc.content)
255
+ title_part = (
256
+ f" [repr.attrib_name]title[/repr.attrib_name]: {doc.title}"
257
+ if doc.title
258
+ else ""
259
+ )
255
260
  self.console.print(
256
- f"[repr.attrib_name]id[/repr.attrib_name]: {doc.id} [repr.attrib_name]uri[/repr.attrib_name]: {doc.uri} [repr.attrib_name]meta[/repr.attrib_name]: {doc.metadata}"
261
+ f"[repr.attrib_name]id[/repr.attrib_name]: {doc.id} "
262
+ f"[repr.attrib_name]uri[/repr.attrib_name]: {doc.uri}"
263
+ + title_part
264
+ + f" [repr.attrib_name]meta[/repr.attrib_name]: {doc.metadata}"
257
265
  )
258
266
  self.console.print(
259
267
  f"[repr.attrib_name]created at[/repr.attrib_name]: {doc.created_at} [repr.attrib_name]updated at[/repr.attrib_name]: {doc.updated_at}"
@@ -272,6 +280,9 @@ class HaikuRAGApp:
272
280
  if chunk.document_uri:
273
281
  self.console.print("[repr.attrib_name]document uri[/repr.attrib_name]:")
274
282
  self.console.print(chunk.document_uri)
283
+ if chunk.document_title:
284
+ self.console.print("[repr.attrib_name]document title[/repr.attrib_name]:")
285
+ self.console.print(chunk.document_title)
275
286
  if chunk.document_meta:
276
287
  self.console.print("[repr.attrib_name]document meta[/repr.attrib_name]:")
277
288
  self.console.print(chunk.document_meta)
@@ -289,8 +300,6 @@ class HaikuRAGApp:
289
300
  try:
290
301
  if transport == "stdio":
291
302
  await server.run_stdio_async()
292
- elif transport == "sse":
293
- await server.run_sse_async()
294
303
  else:
295
304
  await server.run_http_async(transport="streamable-http")
296
305
  except KeyboardInterrupt:
haiku/rag/cli.py CHANGED
@@ -3,28 +3,16 @@ import warnings
3
3
  from importlib.metadata import version
4
4
  from pathlib import Path
5
5
 
6
- import logfire
7
6
  import typer
8
- from rich.console import Console
9
7
 
10
- from haiku.rag.app import HaikuRAGApp
11
8
  from haiku.rag.config import Config
12
9
  from haiku.rag.logging import configure_cli_logging
13
- from haiku.rag.migration import migrate_sqlite_to_lancedb
14
10
  from haiku.rag.utils import is_up_to_date
15
11
 
16
- if Config.ENV == "development":
17
- logfire.configure(send_to_logfire="if-token-present")
18
- logfire.instrument_pydantic_ai()
19
- else:
20
- warnings.filterwarnings("ignore")
21
-
22
12
  cli = typer.Typer(
23
13
  context_settings={"help_option_names": ["-h", "--help"]}, no_args_is_help=True
24
14
  )
25
15
 
26
- console = Console()
27
-
28
16
 
29
17
  def complete_document_ids(ctx: typer.Context, incomplete: str):
30
18
  """Autocomplete document IDs from the selected DB."""
@@ -89,16 +77,16 @@ async def check_version():
89
77
  """Check if haiku.rag is up to date and show warning if not."""
90
78
  up_to_date, current_version, latest_version = await is_up_to_date()
91
79
  if not up_to_date:
92
- console.print(
93
- f"[yellow]Warning: haiku.rag is outdated. Current: {current_version}, Latest: {latest_version}[/yellow]"
80
+ typer.echo(
81
+ f"Warning: haiku.rag is outdated. Current: {current_version}, Latest: {latest_version}",
94
82
  )
95
- console.print("[yellow]Please update.[/yellow]")
83
+ typer.echo("Please update.")
96
84
 
97
85
 
98
86
  def version_callback(value: bool):
99
87
  if value:
100
88
  v = version("haiku.rag")
101
- console.print(f"haiku.rag version {v}")
89
+ typer.echo(f"haiku.rag version {v}")
102
90
  raise typer.Exit()
103
91
 
104
92
 
@@ -113,10 +101,26 @@ def main(
113
101
  ),
114
102
  ):
115
103
  """haiku.rag CLI - Vector database RAG system"""
116
- # Ensure only haiku.rag logs are emitted in CLI context
117
- configure_cli_logging()
104
+ # Configure logging minimally for CLI context
105
+ if Config.ENV == "development":
106
+ # Lazy import logfire only in development
107
+ try:
108
+ import logfire # type: ignore
109
+
110
+ logfire.configure(send_to_logfire="if-token-present")
111
+ logfire.instrument_pydantic_ai()
112
+ except Exception:
113
+ pass
114
+ else:
115
+ configure_cli_logging()
116
+ warnings.filterwarnings("ignore")
117
+
118
118
  # Run version check before any command
119
- asyncio.run(check_version())
119
+ try:
120
+ asyncio.run(check_version())
121
+ except Exception:
122
+ # Do not block CLI on version check issues
123
+ pass
120
124
 
121
125
 
122
126
  @cli.command("list", help="List all stored documents")
@@ -127,6 +131,8 @@ def list_documents(
127
131
  help="Path to the LanceDB database file",
128
132
  ),
129
133
  ):
134
+ from haiku.rag.app import HaikuRAGApp
135
+
130
136
  app = HaikuRAGApp(db_path=db)
131
137
  asyncio.run(app.list_documents())
132
138
 
@@ -142,6 +148,8 @@ def add_document_text(
142
148
  help="Path to the LanceDB database file",
143
149
  ),
144
150
  ):
151
+ from haiku.rag.app import HaikuRAGApp
152
+
145
153
  app = HaikuRAGApp(db_path=db)
146
154
  asyncio.run(app.add_document_from_text(text=text))
147
155
 
@@ -152,14 +160,21 @@ def add_document_src(
152
160
  help="The file path or URL of the document to add",
153
161
  autocompletion=complete_local_paths,
154
162
  ),
163
+ title: str | None = typer.Option(
164
+ None,
165
+ "--title",
166
+ help="Optional human-readable title to store with the document",
167
+ ),
155
168
  db: Path = typer.Option(
156
169
  Config.DEFAULT_DATA_DIR / "haiku.rag.lancedb",
157
170
  "--db",
158
171
  help="Path to the LanceDB database file",
159
172
  ),
160
173
  ):
174
+ from haiku.rag.app import HaikuRAGApp
175
+
161
176
  app = HaikuRAGApp(db_path=db)
162
- asyncio.run(app.add_document_from_source(source=source))
177
+ asyncio.run(app.add_document_from_source(source=source, title=title))
163
178
 
164
179
 
165
180
  @cli.command("get", help="Get and display a document by its ID")
@@ -174,6 +189,8 @@ def get_document(
174
189
  help="Path to the LanceDB database file",
175
190
  ),
176
191
  ):
192
+ from haiku.rag.app import HaikuRAGApp
193
+
177
194
  app = HaikuRAGApp(db_path=db)
178
195
  asyncio.run(app.get_document(doc_id=doc_id))
179
196
 
@@ -190,6 +207,8 @@ def delete_document(
190
207
  help="Path to the LanceDB database file",
191
208
  ),
192
209
  ):
210
+ from haiku.rag.app import HaikuRAGApp
211
+
193
212
  app = HaikuRAGApp(db_path=db)
194
213
  asyncio.run(app.delete_document(doc_id=doc_id))
195
214
 
@@ -215,6 +234,8 @@ def search(
215
234
  help="Path to the LanceDB database file",
216
235
  ),
217
236
  ):
237
+ from haiku.rag.app import HaikuRAGApp
238
+
218
239
  app = HaikuRAGApp(db_path=db)
219
240
  asyncio.run(app.search(query=query, limit=limit))
220
241
 
@@ -235,6 +256,8 @@ def ask(
235
256
  help="Include citations in the response",
236
257
  ),
237
258
  ):
259
+ from haiku.rag.app import HaikuRAGApp
260
+
238
261
  app = HaikuRAGApp(db_path=db)
239
262
  asyncio.run(app.ask(question=question, cite=cite))
240
263
 
@@ -271,6 +294,8 @@ def research(
271
294
  help="Show verbose progress output",
272
295
  ),
273
296
  ):
297
+ from haiku.rag.app import HaikuRAGApp
298
+
274
299
  app = HaikuRAGApp(db_path=db)
275
300
  asyncio.run(
276
301
  app.research(
@@ -285,6 +310,8 @@ def research(
285
310
 
286
311
  @cli.command("settings", help="Display current configuration settings")
287
312
  def settings():
313
+ from haiku.rag.app import HaikuRAGApp
314
+
288
315
  app = HaikuRAGApp(db_path=Path()) # Don't need actual DB for settings
289
316
  app.show_settings()
290
317
 
@@ -300,6 +327,8 @@ def rebuild(
300
327
  help="Path to the LanceDB database file",
301
328
  ),
302
329
  ):
330
+ from haiku.rag.app import HaikuRAGApp
331
+
303
332
  app = HaikuRAGApp(db_path=db)
304
333
  asyncio.run(app.rebuild())
305
334
 
@@ -312,6 +341,8 @@ def vacuum(
312
341
  help="Path to the LanceDB database file",
313
342
  ),
314
343
  ):
344
+ from haiku.rag.app import HaikuRAGApp
345
+
315
346
  app = HaikuRAGApp(db_path=db)
316
347
  asyncio.run(app.vacuum())
317
348
 
@@ -330,24 +361,15 @@ def serve(
330
361
  "--stdio",
331
362
  help="Run MCP server on stdio Transport",
332
363
  ),
333
- sse: bool = typer.Option(
334
- False,
335
- "--sse",
336
- help="Run MCP server on SSE transport",
337
- ),
338
364
  ) -> None:
339
365
  """Start the MCP server."""
340
- if stdio and sse:
341
- console.print("[red]Error: Cannot use both --stdio and --http options[/red]")
342
- raise typer.Exit(1)
366
+ from haiku.rag.app import HaikuRAGApp
343
367
 
344
368
  app = HaikuRAGApp(db_path=db)
345
369
 
346
370
  transport = None
347
371
  if stdio:
348
372
  transport = "stdio"
349
- elif sse:
350
- transport = "sse"
351
373
 
352
374
  asyncio.run(app.serve(transport=transport))
353
375
 
@@ -361,6 +383,9 @@ def migrate(
361
383
  # Generate LanceDB path in same parent directory
362
384
  lancedb_path = sqlite_path.parent / (sqlite_path.stem + ".lancedb")
363
385
 
386
+ # Lazy import to avoid heavy deps on simple invocations
387
+ from haiku.rag.migration import migrate_sqlite_to_lancedb
388
+
364
389
  success = asyncio.run(migrate_sqlite_to_lancedb(sqlite_path, lancedb_path))
365
390
 
366
391
  if not success:
haiku/rag/client.py CHANGED
@@ -33,8 +33,6 @@ class HaikuRAG:
33
33
  db_path: Path to the database file.
34
34
  skip_validation: Whether to skip configuration validation on database load.
35
35
  """
36
- if not db_path.parent.exists():
37
- Path.mkdir(db_path.parent, parents=True)
38
36
  self.store = Store(db_path, skip_validation=skip_validation)
39
37
  self.document_repository = DocumentRepository(self.store)
40
38
  self.chunk_repository = ChunkRepository(self.store)
@@ -52,6 +50,7 @@ class HaikuRAG:
52
50
  self,
53
51
  docling_document,
54
52
  uri: str | None = None,
53
+ title: str | None = None,
55
54
  metadata: dict | None = None,
56
55
  chunks: list[Chunk] | None = None,
57
56
  ) -> Document:
@@ -60,6 +59,7 @@ class HaikuRAG:
60
59
  document = Document(
61
60
  content=content,
62
61
  uri=uri,
62
+ title=title,
63
63
  metadata=metadata or {},
64
64
  )
65
65
  return await self.document_repository._create_with_docling(
@@ -70,6 +70,7 @@ class HaikuRAG:
70
70
  self,
71
71
  content: str,
72
72
  uri: str | None = None,
73
+ title: str | None = None,
73
74
  metadata: dict | None = None,
74
75
  chunks: list[Chunk] | None = None,
75
76
  ) -> Document:
@@ -90,6 +91,7 @@ class HaikuRAG:
90
91
  document = Document(
91
92
  content=content,
92
93
  uri=uri,
94
+ title=title,
93
95
  metadata=metadata or {},
94
96
  )
95
97
  return await self.document_repository._create_with_docling(
@@ -97,7 +99,7 @@ class HaikuRAG:
97
99
  )
98
100
 
99
101
  async def create_document_from_source(
100
- self, source: str | Path, metadata: dict = {}
102
+ self, source: str | Path, title: str | None = None, metadata: dict | None = None
101
103
  ) -> Document:
102
104
  """Create or update a document from a file path or URL.
103
105
 
@@ -118,11 +120,16 @@ class HaikuRAG:
118
120
  httpx.RequestError: If URL request fails
119
121
  """
120
122
 
123
+ # Normalize metadata
124
+ metadata = metadata or {}
125
+
121
126
  # Check if it's a URL
122
127
  source_str = str(source)
123
128
  parsed_url = urlparse(source_str)
124
129
  if parsed_url.scheme in ("http", "https"):
125
- return await self._create_or_update_document_from_url(source_str, metadata)
130
+ return await self._create_or_update_document_from_url(
131
+ source_str, title=title, metadata=metadata
132
+ )
126
133
  elif parsed_url.scheme == "file":
127
134
  # Handle file:// URI by converting to path
128
135
  source_path = Path(parsed_url.path)
@@ -138,37 +145,51 @@ class HaikuRAG:
138
145
  uri = source_path.absolute().as_uri()
139
146
  md5_hash = hashlib.md5(source_path.read_bytes()).hexdigest()
140
147
 
148
+ # Get content type from file extension (do before early return)
149
+ content_type, _ = mimetypes.guess_type(str(source_path))
150
+ if not content_type:
151
+ content_type = "application/octet-stream"
152
+ # Merge metadata with contentType and md5
153
+ metadata.update({"contentType": content_type, "md5": md5_hash})
154
+
141
155
  # Check if document already exists
142
156
  existing_doc = await self.get_document_by_uri(uri)
143
157
  if existing_doc and existing_doc.metadata.get("md5") == md5_hash:
144
- # MD5 unchanged, return existing document
158
+ # MD5 unchanged; update title/metadata if provided
159
+ updated = False
160
+ if title is not None and title != existing_doc.title:
161
+ existing_doc.title = title
162
+ updated = True
163
+ if metadata:
164
+ existing_doc.metadata = {**(existing_doc.metadata or {}), **metadata}
165
+ updated = True
166
+ if updated:
167
+ return await self.document_repository.update(existing_doc)
145
168
  return existing_doc
146
169
 
170
+ # Parse file only when content changed or new document
147
171
  docling_document = FileReader.parse_file(source_path)
148
172
 
149
- # Get content type from file extension
150
- content_type, _ = mimetypes.guess_type(str(source_path))
151
- if not content_type:
152
- content_type = "application/octet-stream"
153
-
154
- # Merge metadata with contentType and md5
155
- metadata.update({"contentType": content_type, "md5": md5_hash})
156
-
157
173
  if existing_doc:
158
174
  # Update existing document
159
175
  existing_doc.content = docling_document.export_to_markdown()
160
176
  existing_doc.metadata = metadata
177
+ if title is not None:
178
+ existing_doc.title = title
161
179
  return await self.document_repository._update_with_docling(
162
180
  existing_doc, docling_document
163
181
  )
164
182
  else:
165
183
  # Create new document using DoclingDocument
166
184
  return await self._create_document_with_docling(
167
- docling_document=docling_document, uri=uri, metadata=metadata
185
+ docling_document=docling_document,
186
+ uri=uri,
187
+ title=title,
188
+ metadata=metadata,
168
189
  )
169
190
 
170
191
  async def _create_or_update_document_from_url(
171
- self, url: str, metadata: dict = {}
192
+ self, url: str, title: str | None = None, metadata: dict | None = None
172
193
  ) -> Document:
173
194
  """Create or update a document from a URL by downloading and parsing the content.
174
195
 
@@ -188,20 +209,35 @@ class HaikuRAG:
188
209
  ValueError: If the content cannot be parsed
189
210
  httpx.RequestError: If URL request fails
190
211
  """
212
+ metadata = metadata or {}
213
+
191
214
  async with httpx.AsyncClient() as client:
192
215
  response = await client.get(url)
193
216
  response.raise_for_status()
194
217
 
195
218
  md5_hash = hashlib.md5(response.content).hexdigest()
196
219
 
220
+ # Get content type early (used for potential no-op update)
221
+ content_type = response.headers.get("content-type", "").lower()
222
+
197
223
  # Check if document already exists
198
224
  existing_doc = await self.get_document_by_uri(url)
199
225
  if existing_doc and existing_doc.metadata.get("md5") == md5_hash:
200
- # MD5 unchanged, return existing document
226
+ # MD5 unchanged; update title/metadata if provided
227
+ updated = False
228
+ if title is not None and title != existing_doc.title:
229
+ existing_doc.title = title
230
+ updated = True
231
+ metadata.update({"contentType": content_type, "md5": md5_hash})
232
+ if metadata:
233
+ existing_doc.metadata = {
234
+ **(existing_doc.metadata or {}),
235
+ **metadata,
236
+ }
237
+ updated = True
238
+ if updated:
239
+ return await self.document_repository.update(existing_doc)
201
240
  return existing_doc
202
-
203
- # Get content type to determine file extension
204
- content_type = response.headers.get("content-type", "").lower()
205
241
  file_extension = self._get_extension_from_content_type_or_url(
206
242
  url, content_type
207
243
  )
@@ -228,12 +264,17 @@ class HaikuRAG:
228
264
  if existing_doc:
229
265
  existing_doc.content = docling_document.export_to_markdown()
230
266
  existing_doc.metadata = metadata
267
+ if title is not None:
268
+ existing_doc.title = title
231
269
  return await self.document_repository._update_with_docling(
232
270
  existing_doc, docling_document
233
271
  )
234
272
  else:
235
273
  return await self._create_document_with_docling(
236
- docling_document=docling_document, uri=url, metadata=metadata
274
+ docling_document=docling_document,
275
+ uri=url,
276
+ title=title,
277
+ metadata=metadata,
237
278
  )
238
279
 
239
280
  def _get_extension_from_content_type_or_url(
@@ -418,6 +459,7 @@ class HaikuRAG:
418
459
  content="".join(combined_content_parts),
419
460
  metadata=original_chunk.metadata,
420
461
  document_uri=original_chunk.document_uri,
462
+ document_title=original_chunk.document_title,
421
463
  document_meta=original_chunk.document_meta,
422
464
  )
423
465
 
@@ -524,7 +566,7 @@ class HaikuRAG:
524
566
 
525
567
  # Try to re-create from source (this creates the document with chunks)
526
568
  new_doc = await self.create_document_from_source(
527
- doc.uri, doc.metadata or {}
569
+ source=doc.uri, metadata=doc.metadata or {}
528
570
  )
529
571
 
530
572
  assert new_doc.id is not None, "New document ID should not be None"
haiku/rag/config.py CHANGED
@@ -53,6 +53,10 @@ class AppConfig(BaseModel):
53
53
  ANTHROPIC_API_KEY: str = ""
54
54
  COHERE_API_KEY: str = ""
55
55
 
56
+ # If true, refuse to auto-create a new LanceDB database or tables
57
+ # and error out when the database does not already exist.
58
+ DISABLE_DB_AUTOCREATE: bool = False
59
+
56
60
  @field_validator("MONITOR_DIRECTORIES", mode="before")
57
61
  @classmethod
58
62
  def parse_monitor_directories(cls, v):
haiku/rag/mcp.py CHANGED
@@ -17,6 +17,7 @@ class DocumentResult(BaseModel):
17
17
  id: str | None
18
18
  content: str
19
19
  uri: str | None = None
20
+ title: str | None = None
20
21
  metadata: dict[str, Any] = {}
21
22
  created_at: str
22
23
  updated_at: str
@@ -28,13 +29,15 @@ def create_mcp_server(db_path: Path) -> FastMCP:
28
29
 
29
30
  @mcp.tool()
30
31
  async def add_document_from_file(
31
- file_path: str, metadata: dict[str, Any] | None = None
32
+ file_path: str,
33
+ metadata: dict[str, Any] | None = None,
34
+ title: str | None = None,
32
35
  ) -> str | None:
33
36
  """Add a document to the RAG system from a file path."""
34
37
  try:
35
38
  async with HaikuRAG(db_path) as rag:
36
39
  document = await rag.create_document_from_source(
37
- Path(file_path), metadata or {}
40
+ Path(file_path), title=title, metadata=metadata or {}
38
41
  )
39
42
  return document.id
40
43
  except Exception:
@@ -42,24 +45,31 @@ def create_mcp_server(db_path: Path) -> FastMCP:
42
45
 
43
46
  @mcp.tool()
44
47
  async def add_document_from_url(
45
- url: str, metadata: dict[str, Any] | None = None
48
+ url: str, metadata: dict[str, Any] | None = None, title: str | None = None
46
49
  ) -> str | None:
47
50
  """Add a document to the RAG system from a URL."""
48
51
  try:
49
52
  async with HaikuRAG(db_path) as rag:
50
- document = await rag.create_document_from_source(url, metadata or {})
53
+ document = await rag.create_document_from_source(
54
+ url, title=title, metadata=metadata or {}
55
+ )
51
56
  return document.id
52
57
  except Exception:
53
58
  return None
54
59
 
55
60
  @mcp.tool()
56
61
  async def add_document_from_text(
57
- content: str, uri: str | None = None, metadata: dict[str, Any] | None = None
62
+ content: str,
63
+ uri: str | None = None,
64
+ metadata: dict[str, Any] | None = None,
65
+ title: str | None = None,
58
66
  ) -> str | None:
59
67
  """Add a document to the RAG system from text content."""
60
68
  try:
61
69
  async with HaikuRAG(db_path) as rag:
62
- document = await rag.create_document(content, uri, metadata or {})
70
+ document = await rag.create_document(
71
+ content, uri, title=title, metadata=metadata or {}
72
+ )
63
73
  return document.id
64
74
  except Exception:
65
75
  return None
@@ -102,6 +112,7 @@ def create_mcp_server(db_path: Path) -> FastMCP:
102
112
  id=document.id,
103
113
  content=document.content,
104
114
  uri=document.uri,
115
+ title=document.title,
105
116
  metadata=document.metadata,
106
117
  created_at=str(document.created_at),
107
118
  updated_at=str(document.updated_at),
@@ -123,6 +134,7 @@ def create_mcp_server(db_path: Path) -> FastMCP:
123
134
  id=doc.id,
124
135
  content=doc.content,
125
136
  uri=doc.uri,
137
+ title=doc.title,
126
138
  metadata=doc.metadata,
127
139
  created_at=str(doc.created_at),
128
140
  updated_at=str(doc.updated_at),
haiku/rag/qa/agent.py CHANGED
@@ -12,7 +12,9 @@ from haiku.rag.qa.prompts import QA_SYSTEM_PROMPT, QA_SYSTEM_PROMPT_WITH_CITATIO
12
12
  class SearchResult(BaseModel):
13
13
  content: str = Field(description="The document text content")
14
14
  score: float = Field(description="Relevance score (higher is more relevant)")
15
- document_uri: str = Field(description="Source URI/path of the document")
15
+ document_uri: str = Field(
16
+ description="Source title (if available) or URI/path of the document"
17
+ )
16
18
 
17
19
 
18
20
  class Dependencies(BaseModel):
@@ -59,7 +61,7 @@ class QuestionAnswerAgent:
59
61
  SearchResult(
60
62
  content=chunk.content,
61
63
  score=score,
62
- document_uri=chunk.document_uri or "",
64
+ document_uri=(chunk.document_title or chunk.document_uri or ""),
63
65
  )
64
66
  for chunk, score in expanded_results
65
67
  ]
haiku/rag/qa/prompts.py CHANGED
@@ -44,9 +44,9 @@ Guidelines:
44
44
 
45
45
  Citation Format:
46
46
  After your answer, include a "Citations:" section that lists:
47
- - The document URI from each search result used
47
+ - The document title (if available) or URI from each search result used
48
48
  - A brief excerpt (first 50-100 characters) of the content that supported your answer
49
- - Format: "Citations:\n- [document_uri]: [content_excerpt]..."
49
+ - Format: "Citations:\n- [document title or URI]: [content_excerpt]..."
50
50
 
51
51
  Example response format:
52
52
  [Your answer here]
@@ -19,8 +19,8 @@ class SearchAnswer(BaseModel):
19
19
  )
20
20
  sources: list[str] = Field(
21
21
  description=(
22
- "Document URIs corresponding to the snippets actually used in the"
23
- " answer (one URI per snippet; omit if none)"
22
+ "Document titles (if available) or URIs corresponding to the"
23
+ " snippets actually used in the answer (one per snippet; omit if none)"
24
24
  ),
25
25
  default_factory=list,
26
26
  )
@@ -59,7 +59,9 @@ class SearchDispatchNode(BaseNode[ResearchState, ResearchDeps, ResearchReport]):
59
59
  {
60
60
  "text": chunk.content,
61
61
  "score": score,
62
- "document_uri": (chunk.document_uri or ""),
62
+ "document_uri": (
63
+ chunk.document_title or chunk.document_uri or ""
64
+ ),
63
65
  }
64
66
  for chunk, score in expanded
65
67
  ]
@@ -27,13 +27,14 @@ Tasks:
27
27
  Tool usage:
28
28
  - Always call search_and_answer before drafting any answer.
29
29
  - The tool returns snippets with verbatim `text`, a relevance `score`, and the
30
- originating `document_uri`.
30
+ originating document identifier (document title if available, otherwise URI).
31
31
  - You may call the tool multiple times to refine or broaden context, but do not
32
32
  exceed 3 total calls. Favor precision over volume.
33
33
  - Use scores to prioritize evidence, but include only the minimal subset of
34
34
  snippet texts (verbatim) in SearchAnswer.context (typically 1‑4).
35
- - Set SearchAnswer.sources to the corresponding document_uris for the snippets
36
- you used (one URI per snippet; same order as context). Context must be text‑only.
35
+ - Set SearchAnswer.sources to the corresponding document identifiers for the
36
+ snippets you used (title if available, otherwise URI; one per snippet; same
37
+ order as context). Context must be text‑only.
37
38
  - If no relevant information is found, clearly say so and return an empty
38
39
  context list and sources list.
39
40
 
haiku/rag/store/engine.py CHANGED
@@ -19,6 +19,7 @@ class DocumentRecord(LanceModel):
19
19
  id: str = Field(default_factory=lambda: str(uuid4()))
20
20
  content: str
21
21
  uri: str | None = None
22
+ title: str | None = None
22
23
  metadata: str = Field(default="{}")
23
24
  created_at: str = Field(default_factory=lambda: "")
24
25
  updated_at: str = Field(default_factory=lambda: "")
@@ -54,6 +55,19 @@ class Store:
54
55
  # Create the ChunkRecord model with the correct vector dimension
55
56
  self.ChunkRecord = create_chunk_model(self.embedder._vector_dim)
56
57
 
58
+ # Local filesystem handling for DB directory
59
+ if not self._has_cloud_config():
60
+ if Config.DISABLE_DB_AUTOCREATE:
61
+ # LanceDB uses a directory path for local databases; enforce presence
62
+ if not db_path.exists():
63
+ raise FileNotFoundError(
64
+ f"LanceDB path does not exist: {db_path}. Auto-creation is disabled."
65
+ )
66
+ else:
67
+ # Ensure parent directories exist when autocreation allowed
68
+ if not db_path.parent.exists():
69
+ Path.mkdir(db_path.parent, parents=True)
70
+
57
71
  # Connect to LanceDB
58
72
  self.db = self._connect_to_lancedb(db_path)
59
73
 
@@ -12,5 +12,6 @@ class Chunk(BaseModel):
12
12
  metadata: dict = {}
13
13
  order: int = 0
14
14
  document_uri: str | None = None
15
+ document_title: str | None = None
15
16
  document_meta: dict = {}
16
17
  embedding: list[float] | None = None
@@ -11,6 +11,7 @@ class Document(BaseModel):
11
11
  id: str | None = None
12
12
  content: str
13
13
  uri: str | None = None
14
+ title: str | None = None
14
15
  metadata: dict = {}
15
16
  created_at: datetime = Field(default_factory=datetime.now)
16
17
  updated_at: datetime = Field(default_factory=datetime.now)
@@ -317,6 +317,7 @@ class ChunkRepository:
317
317
  )
318
318
 
319
319
  doc_uri = doc_results[0].uri if doc_results else None
320
+ doc_title = doc_results[0].title if doc_results else None
320
321
  doc_meta = doc_results[0].metadata if doc_results else "{}"
321
322
 
322
323
  chunks: list[Chunk] = []
@@ -330,6 +331,7 @@ class ChunkRepository:
330
331
  metadata=md,
331
332
  order=rec.order,
332
333
  document_uri=doc_uri,
334
+ document_title=doc_title,
333
335
  document_meta=json.loads(doc_meta),
334
336
  )
335
337
  )
@@ -398,6 +400,7 @@ class ChunkRepository:
398
400
  # Get document info from pre-fetched map
399
401
  doc = documents_map.get(chunk_record.document_id)
400
402
  doc_uri = doc.uri if doc else None
403
+ doc_title = doc.title if doc else None
401
404
  doc_meta = doc.metadata if doc else "{}"
402
405
 
403
406
  md = json.loads(chunk_record.metadata)
@@ -409,6 +412,7 @@ class ChunkRepository:
409
412
  metadata=md,
410
413
  order=chunk_record.order,
411
414
  document_uri=doc_uri,
415
+ document_title=doc_title,
412
416
  document_meta=json.loads(doc_meta),
413
417
  )
414
418
 
@@ -34,6 +34,7 @@ class DocumentRepository:
34
34
  id=record.id,
35
35
  content=record.content,
36
36
  uri=record.uri,
37
+ title=record.title,
37
38
  metadata=json.loads(record.metadata),
38
39
  created_at=datetime.fromisoformat(record.created_at)
39
40
  if record.created_at
@@ -56,6 +57,7 @@ class DocumentRepository:
56
57
  id=doc_id,
57
58
  content=entity.content,
58
59
  uri=entity.uri,
60
+ title=entity.title,
59
61
  metadata=json.dumps(entity.metadata),
60
62
  created_at=now,
61
63
  updated_at=now,
@@ -97,6 +99,7 @@ class DocumentRepository:
97
99
  values={
98
100
  "content": entity.content,
99
101
  "uri": entity.uri,
102
+ "title": entity.title,
100
103
  "metadata": json.dumps(entity.metadata),
101
104
  "updated_at": now,
102
105
  },
@@ -55,6 +55,8 @@ def run_pending_upgrades(store: Store, from_version: str, to_version: str) -> No
55
55
 
56
56
  from .v0_9_3 import upgrade_fts_phrase as upgrade_0_9_3_fts # noqa: E402
57
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
58
59
 
59
60
  upgrades.append(upgrade_0_9_3_order)
60
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
+ )
haiku/rag/utils.py CHANGED
@@ -9,10 +9,6 @@ from io import BytesIO
9
9
  from pathlib import Path
10
10
  from types import ModuleType
11
11
 
12
- import httpx
13
- from docling.document_converter import DocumentConverter
14
- from docling_core.types.doc.document import DoclingDocument
15
- from docling_core.types.io import DocumentStream
16
12
  from packaging.version import Version, parse
17
13
 
18
14
 
@@ -82,6 +78,9 @@ async def is_up_to_date() -> tuple[bool, Version, Version]:
82
78
  the running version and the latest version.
83
79
  """
84
80
 
81
+ # Lazy import to avoid pulling httpx (and its deps) on module import
82
+ import httpx
83
+
85
84
  async with httpx.AsyncClient() as client:
86
85
  running_version = parse(metadata.version("haiku.rag"))
87
86
  try:
@@ -94,7 +93,7 @@ async def is_up_to_date() -> tuple[bool, Version, Version]:
94
93
  return running_version >= pypi_version, running_version, pypi_version
95
94
 
96
95
 
97
- def text_to_docling_document(text: str, name: str = "content.md") -> DoclingDocument:
96
+ def text_to_docling_document(text: str, name: str = "content.md"):
98
97
  """Convert text content to a DoclingDocument.
99
98
 
100
99
  Args:
@@ -104,6 +103,10 @@ def text_to_docling_document(text: str, name: str = "content.md") -> DoclingDocu
104
103
  Returns:
105
104
  A DoclingDocument created from the text content.
106
105
  """
106
+ # Lazy import docling deps to keep import-time light
107
+ from docling.document_converter import DocumentConverter # type: ignore
108
+ from docling_core.types.io import DocumentStream # type: ignore
109
+
107
110
  bytes_io = BytesIO(text.encode("utf-8"))
108
111
  doc_stream = DocumentStream(name=name, stream=bytes_io)
109
112
  converter = DocumentConverter()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: haiku.rag
3
- Version: 0.10.0
3
+ Version: 0.10.1
4
4
  Summary: Agentic Retrieval Augmented Generation (RAG) with LanceDB
5
5
  Author-email: Yiorgis Gozadinos <ggozadinos@gmail.com>
6
6
  License: MIT
@@ -1,15 +1,15 @@
1
1
  haiku/rag/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- haiku/rag/app.py,sha256=m5agkPrJhbzEbdC01CU_GR2Gj4voFuAGmxR7DS2K9is,12934
2
+ haiku/rag/app.py,sha256=06nsdjrljPNqZew4gsLFIA0BSwv-CxPovJaAYSFzm-w,13265
3
3
  haiku/rag/chunker.py,sha256=PVe6ysv8UlacUd4Zb3_8RFWIaWDXnzBAy2VDJ4TaUsE,1555
4
- haiku/rag/cli.py,sha256=oXEQoRTlzrrJ9hC27_Dht9ElBb9q_wTEESnXdNy3eW8,10257
5
- haiku/rag/client.py,sha256=QgJQu7g7JjAzWN6R10NeDqpFf89Dml_LiWce4QRHLHc,21177
6
- haiku/rag/config.py,sha256=SPEIv2IElZmZh4Wsp8gk7ViRW5ZzD-UGmIqRAXscDdI,2134
4
+ haiku/rag/cli.py,sha256=4aYUXPr54q-7U2-cySNpSnW1ntvD0Jck6oj8vvA6IoI,10830
5
+ haiku/rag/client.py,sha256=iUaa6YUac3CXFniIm8DsaaNsiyHsi4cp8-fPhF5XuVU,22925
6
+ haiku/rag/config.py,sha256=SEV2OzaKavYwHZ0LmRzBj-0dbI6YFIRuNiTw9el7SO0,2307
7
7
  haiku/rag/logging.py,sha256=dm65AwADpcQsH5OAPtRA-4hsw0w5DK-sGOvzYkj6jzw,1720
8
- haiku/rag/mcp.py,sha256=bR9Y-Nz-hvjiql20Y0KE0hwNGwyjmPGX8K9d-qmXptY,4683
8
+ haiku/rag/mcp.py,sha256=H7XibtSNUviFeaJVsXzHiRqUm0nJCpA7A1QHuBv6SKQ,5057
9
9
  haiku/rag/migration.py,sha256=M--KnSF3lxgKjxmokb4vuzGH-pV8eg0C_8e7jvPqW8Y,11058
10
10
  haiku/rag/monitor.py,sha256=r386nkhdlsU8UECwIuVwnrSlgMk3vNIuUZGNIzkZuec,2770
11
11
  haiku/rag/reader.py,sha256=qkPTMJuQ_o4sK-8zpDl9WFYe_MJ7aL_gUw6rczIpW-g,3274
12
- haiku/rag/utils.py,sha256=aiuPu_rrfpyIvJJq0o5boUIIvCdNzdpKwAIPYYn3iG8,4965
12
+ haiku/rag/utils.py,sha256=hKH8bBBbAVYlLFBOAcErvX-4cuWIaPTbrAFeeLN1HdM,5062
13
13
  haiku/rag/embeddings/__init__.py,sha256=44IfDITGIFTflGT6UEmiYOwpWFVbYv5smLY59D0YeCs,1419
14
14
  haiku/rag/embeddings/base.py,sha256=BnSviKrlzjv3L0sZJs_T-pxfawd-bcTak-rsX-D2f3A,497
15
15
  haiku/rag/embeddings/ollama.py,sha256=LuLlHH6RGoO9_gFCIlbmesuXOj017gTw6z-p8Ez0CfE,595
@@ -17,8 +17,8 @@ haiku/rag/embeddings/openai.py,sha256=fIFCk-jpUtaW0xsnrQnJ824O0UCjaGG2sgvBzREhil
17
17
  haiku/rag/embeddings/vllm.py,sha256=vhaUnCn6VMkfSluLhWKtSV-sekFaPsp4pKo2N7-SBCY,626
18
18
  haiku/rag/embeddings/voyageai.py,sha256=UW-MW4tJKnPB6Fs2P7A3yt-ZeRm46H9npckchSriPX8,661
19
19
  haiku/rag/qa/__init__.py,sha256=Sl7Kzrg9CuBOcMF01wc1NtQhUNWjJI0MhIHfCWrb8V4,434
20
- haiku/rag/qa/agent.py,sha256=f4Keh-ESgctNbTg96QL95HYjINVLOcxa8t8crx92MMk,3081
21
- haiku/rag/qa/prompts.py,sha256=LhRfDtO8Pb06lpr4PpwEaKUYItZ5OiIkeqcCogcssHY,3347
20
+ haiku/rag/qa/agent.py,sha256=rtUkEmnD8lMHIxpPPVY6TdmF4aSlZnLjad5eDefrlBw,3145
21
+ haiku/rag/qa/prompts.py,sha256=Lqwn3m4zCsu_CJiC4s9cLsuPNbb9nq6j2PqEF3lw1eA,3380
22
22
  haiku/rag/reranking/__init__.py,sha256=IRXHs4qPu6VbGJQpzSwhgtVWWumURH_vEoVFE-extlo,894
23
23
  haiku/rag/reranking/base.py,sha256=LM9yUSSJ414UgBZhFTgxGprlRqzfTe4I1vgjricz2JY,405
24
24
  haiku/rag/reranking/cohere.py,sha256=1iTdiaa8vvb6oHVB2qpWzUOVkyfUcimVSZp6Qr4aq4c,1049
@@ -28,26 +28,27 @@ haiku/rag/research/__init__.py,sha256=t4JAmIXcKaWqvpFGX5yaehsNrfblskEMn-4mDmdKn9
28
28
  haiku/rag/research/common.py,sha256=EUnsA6VZ3-WMweXESuUYezH1ALit8N38064bsZFqtBE,1688
29
29
  haiku/rag/research/dependencies.py,sha256=ZiSQdV6jHti4DuUp4WCaJL73TqYDr5vC8ppB34M2cNg,1639
30
30
  haiku/rag/research/graph.py,sha256=m3vDP1nPXWzfS7VeTQzmTOk-lFpoaTvKHvRIF2mbxvs,798
31
- haiku/rag/research/models.py,sha256=klE2qGF5fom5gJRQzQUbnoGYaXusNKeJ9veeXoYDD5Q,2308
32
- haiku/rag/research/prompts.py,sha256=v_DZNaKk88CDEF8qt9c-puO6QF-NyBQKnl_mO1pMauY,5013
31
+ haiku/rag/research/models.py,sha256=Q92oxBNq3qp3DyUzTim9YGDOBtGzXH25K_mmfLAA7Y8,2329
32
+ haiku/rag/research/prompts.py,sha256=0_EMA5CS7O37QhKJM7OCDdrdgMcoF2DgehBHR4L7xmk,5103
33
33
  haiku/rag/research/state.py,sha256=vFwO8c2JmwwfkELE5Mwjt9Oat-bHn5tayf31MIG2SRs,623
34
34
  haiku/rag/research/nodes/evaluate.py,sha256=Cp2J-jXYZothiQV3zRZFaCsBLaUU0Tm_-ri-hlgQQII,2897
35
35
  haiku/rag/research/nodes/plan.py,sha256=9AkTls01Q3zTLKGgIgSCX9X4VYC8IWjEWii8A_f77YQ,2439
36
- haiku/rag/research/nodes/search.py,sha256=lHgDCCL7hQdpQeMK-HVzsF_hH_pIv44xxSIiv1JuvYo,3513
36
+ haiku/rag/research/nodes/search.py,sha256=2ioc5Ba3ciq2zpFxgzoGkZOvVsJ1TBX9zseURLDJpBg,3591
37
37
  haiku/rag/research/nodes/synthesize.py,sha256=4acKduqWnE11ML7elUksKLozxzWJTkBLSJ2li_YMxgY,1736
38
38
  haiku/rag/store/__init__.py,sha256=hq0W0DAC7ysqhWSP2M2uHX8cbG6kbr-sWHxhq6qQcY0,103
39
- haiku/rag/store/engine.py,sha256=-3MZJYft2XTWaLuyKha8DKhWQeU5E5CBeskXXF5fXso,9555
39
+ haiku/rag/store/engine.py,sha256=BceAeTpDgV92B1A3GVcjsTwlD-c0cZPPvGiXW2Gola0,10215
40
40
  haiku/rag/store/models/__init__.py,sha256=s0E72zneGlowvZrFWaNxHYjOAUjgWdLxzdYsnvNRVlY,88
41
- haiku/rag/store/models/chunk.py,sha256=Ww_hj3DMwJLNM33l1GvIP84yzDFc6cxfiWcotUfWSYg,383
42
- haiku/rag/store/models/document.py,sha256=zSSpt6pyrMJAIXGQvIcqojcqUzwZnhp3WxVokaWxNRc,396
41
+ haiku/rag/store/models/chunk.py,sha256=3EuZav4QekJIeHBCub48EM8SjNX8HEJ6wVDXGot4PEQ,421
42
+ haiku/rag/store/models/document.py,sha256=cZXy_jEti-hnhq7FKhuhCfd99ccY9fIHMLovB_Thbb8,425
43
43
  haiku/rag/store/repositories/__init__.py,sha256=Olv5dLfBQINRV3HrsfUpjzkZ7Qm7goEYyMNykgo_DaY,291
44
- haiku/rag/store/repositories/chunk.py,sha256=O2SEhQy3ZptWjwwpxS-L8KNq2tEqEBqheHfLw-M_FqA,15012
45
- haiku/rag/store/repositories/document.py,sha256=m11SamQoGYs5ODfmarJGU1yIcqtgmnba-5bGOPQuYrI,7773
44
+ haiku/rag/store/repositories/chunk.py,sha256=UfajEWf5VmMuSozGRDlWBjJNR0ngvOVFDrp6_augzBg,15217
45
+ haiku/rag/store/repositories/document.py,sha256=C9GbIl8sa2-Djaml4hlaPTtjV2HwHaz_Wzs35sdbdhg,7876
46
46
  haiku/rag/store/repositories/settings.py,sha256=7XMBMavU8zRgdBoQzQg0Obfa7UKjuVnBugidTC6sEW0,5548
47
- haiku/rag/store/upgrades/__init__.py,sha256=gDOxiq3wdZPr3JoenjNYxx0cpgZJhbaFKNX2fzXRq1Q,1852
47
+ haiku/rag/store/upgrades/__init__.py,sha256=RQ8A6rEXBASLb5PD9vdDnEas_m_GgRzzdVu4B88Snqc,1975
48
+ haiku/rag/store/upgrades/v0_10_1.py,sha256=qNGnxj6hoHaHJ1rKTiALfw0c9NQOi0KAK-VZCD_073A,1959
48
49
  haiku/rag/store/upgrades/v0_9_3.py,sha256=NrjNilQSgDtFWRbL3ZUtzQzJ8tf9u0dDRJtnDFwwbdw,3322
49
- haiku_rag-0.10.0.dist-info/METADATA,sha256=QLc8BBJ4WCNEvseyYpWNfkuUfmdxGywD6Jtn0OTsrc0,5879
50
- haiku_rag-0.10.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
51
- haiku_rag-0.10.0.dist-info/entry_points.txt,sha256=G1U3nAkNd5YDYd4v0tuYFbriz0i-JheCsFuT9kIoGCI,48
52
- haiku_rag-0.10.0.dist-info/licenses/LICENSE,sha256=eXZrWjSk9PwYFNK9yUczl3oPl95Z4V9UXH7bPN46iPo,1065
53
- haiku_rag-0.10.0.dist-info/RECORD,,
50
+ haiku_rag-0.10.1.dist-info/METADATA,sha256=Bu1Nmz3AoD_EquvCvsbcJjGXmFsGDEwqnfaYIBgOLqQ,5879
51
+ haiku_rag-0.10.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
52
+ haiku_rag-0.10.1.dist-info/entry_points.txt,sha256=G1U3nAkNd5YDYd4v0tuYFbriz0i-JheCsFuT9kIoGCI,48
53
+ haiku_rag-0.10.1.dist-info/licenses/LICENSE,sha256=eXZrWjSk9PwYFNK9yUczl3oPl95Z4V9UXH7bPN46iPo,1065
54
+ haiku_rag-0.10.1.dist-info/RECORD,,