Merge pull request #74 from ggozad/feat/meta-add

Add --meta option to add-src & add CLI commands
This commit is contained in:
Yiorgis Gozadinos 2025-09-23 16:48:09 +03:00 committed by GitHub
commit 14bbcd13c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 156 additions and 18 deletions

View file

@ -28,7 +28,8 @@ uv pip install haiku.rag
# Add documents # Add documents
haiku-rag add "Your content here" haiku-rag add "Your content here"
haiku-rag add-src document.pdf haiku-rag add "Your content here" --meta author=alice --meta topic=notes
haiku-rag add-src document.pdf --meta source=manual
# Search # Search
haiku-rag search "query" haiku-rag search "query"

View file

@ -27,6 +27,9 @@ haiku-rag list
From text: From text:
```bash ```bash
haiku-rag add "Your document content here" haiku-rag add "Your document content here"
# Attach metadata (repeat --meta for multiple entries)
haiku-rag add "Your document content here" --meta author=alice --meta topic=notes
``` ```
From file or URL: From file or URL:
@ -36,6 +39,10 @@ haiku-rag add-src https://example.com/article.html
# Optionally set a humanreadable title stored in the DB schema # Optionally set a humanreadable title stored in the DB schema
haiku-rag add-src /mnt/data/doc1.pdf --title "Q3 Financial Report" haiku-rag add-src /mnt/data/doc1.pdf --title "Q3 Financial Report"
# Optionally attach metadata (repeat --meta). Values use JSON parsing if possible:
# numbers, booleans, null, arrays/objects; otherwise kept as strings.
haiku-rag add-src /mnt/data/doc1.pdf --meta source=manual --meta page_count=12 --meta published=true
``` ```
!!! note !!! note

View file

@ -43,7 +43,8 @@ async with HaikuRAG("database.lancedb") as client:
Or use the CLI: Or use the CLI:
```bash ```bash
haiku-rag add "Your document content" haiku-rag add "Your document content"
haiku-rag add-src /path/to/document.pdf --title "Q3 Financial Report" haiku-rag add "Your document content" --meta author=alice
haiku-rag add-src /path/to/document.pdf --title "Q3 Financial Report" --meta source=manual
haiku-rag search "query" haiku-rag search "query"
haiku-rag ask "Who is the author of haiku.rag?" haiku-rag ask "Who is the author of haiku.rag?"
haiku-rag migrate old_database.sqlite # Migrate from SQLite haiku-rag migrate old_database.sqlite # Migrate from SQLite

View file

@ -144,17 +144,21 @@ class HaikuRAGApp:
for doc in documents: for doc in documents:
self._rich_print_document(doc, truncate=True) self._rich_print_document(doc, truncate=True)
async def add_document_from_text(self, text: str): async def add_document_from_text(self, text: str, metadata: dict | None = None):
async with HaikuRAG(db_path=self.db_path) as self.client: async with HaikuRAG(db_path=self.db_path) as self.client:
doc = await self.client.create_document(text) doc = await self.client.create_document(text, metadata=metadata)
self._rich_print_document(doc, truncate=True) self._rich_print_document(doc, truncate=True)
self.console.print( self.console.print(
f"[bold green]Document {doc.id} added successfully.[/bold green]" f"[bold green]Document {doc.id} added successfully.[/bold green]"
) )
async def add_document_from_source(self, source: str, title: str | None = None): async def add_document_from_source(
self, source: str, title: str | None = None, metadata: dict | None = None
):
async with HaikuRAG(db_path=self.db_path) as self.client: async with HaikuRAG(db_path=self.db_path) as self.client:
doc = await self.client.create_document_from_source(source, title=title) doc = await self.client.create_document_from_source(
source, title=title, metadata=metadata
)
self._rich_print_document(doc, truncate=True) self._rich_print_document(doc, truncate=True)
self.console.print( self.console.print(
f"[bold green]Document {doc.id} added successfully.[/bold green]" f"[bold green]Document {doc.id} added successfully.[/bold green]"

View file

@ -1,7 +1,9 @@
import asyncio import asyncio
import json
import warnings import warnings
from importlib.metadata import version from importlib.metadata import version
from pathlib import Path from pathlib import Path
from typing import Any
import typer import typer
@ -137,11 +139,41 @@ def list_documents(
asyncio.run(app.list_documents()) asyncio.run(app.list_documents())
def _parse_meta_options(meta: list[str] | None) -> dict[str, Any]:
"""Parse repeated --meta KEY=VALUE options into a dictionary.
Raises a Typer error if any entry is malformed.
"""
result: dict[str, Any] = {}
if not meta:
return result
for item in meta:
if "=" not in item:
raise typer.BadParameter("--meta must be in KEY=VALUE format")
key, value = item.split("=", 1)
if not key:
raise typer.BadParameter("--meta key cannot be empty")
# Best-effort JSON coercion: numbers, booleans, null, arrays/objects
try:
parsed = json.loads(value)
result[key] = parsed
except Exception:
# Leave as string if not valid JSON literal
result[key] = value
return result
@cli.command("add", help="Add a document from text input") @cli.command("add", help="Add a document from text input")
def add_document_text( def add_document_text(
text: str = typer.Argument( text: str = typer.Argument(
help="The text content of the document to add", help="The text content of the document to add",
), ),
meta: list[str] | None = typer.Option(
None,
"--meta",
help="Metadata entries as KEY=VALUE (repeatable)",
metavar="KEY=VALUE",
),
db: Path = typer.Option( db: Path = typer.Option(
Config.DEFAULT_DATA_DIR / "haiku.rag.lancedb", Config.DEFAULT_DATA_DIR / "haiku.rag.lancedb",
"--db", "--db",
@ -151,7 +183,8 @@ def add_document_text(
from haiku.rag.app import HaikuRAGApp from haiku.rag.app import HaikuRAGApp
app = HaikuRAGApp(db_path=db) app = HaikuRAGApp(db_path=db)
asyncio.run(app.add_document_from_text(text=text)) metadata = _parse_meta_options(meta)
asyncio.run(app.add_document_from_text(text=text, metadata=metadata or None))
@cli.command("add-src", help="Add a document from a file path or URL") @cli.command("add-src", help="Add a document from a file path or URL")
@ -165,6 +198,12 @@ def add_document_src(
"--title", "--title",
help="Optional human-readable title to store with the document", help="Optional human-readable title to store with the document",
), ),
meta: list[str] | None = typer.Option(
None,
"--meta",
help="Metadata entries as KEY=VALUE (repeatable)",
metavar="KEY=VALUE",
),
db: Path = typer.Option( db: Path = typer.Option(
Config.DEFAULT_DATA_DIR / "haiku.rag.lancedb", Config.DEFAULT_DATA_DIR / "haiku.rag.lancedb",
"--db", "--db",
@ -174,7 +213,12 @@ def add_document_src(
from haiku.rag.app import HaikuRAGApp from haiku.rag.app import HaikuRAGApp
app = HaikuRAGApp(db_path=db) app = HaikuRAGApp(db_path=db)
asyncio.run(app.add_document_from_source(source=source, title=title)) metadata = _parse_meta_options(meta)
asyncio.run(
app.add_document_from_source(
source=source, title=title, metadata=metadata or None
)
)
@cli.command("get", help="Get and display a document by its ID") @cli.command("get", help="Get and display a document by its ID")

View file

@ -54,7 +54,10 @@ async def test_add_document_from_text(app: HaikuRAGApp, monkeypatch):
with patch("haiku.rag.app.HaikuRAG", return_value=mock_client): with patch("haiku.rag.app.HaikuRAG", return_value=mock_client):
await app.add_document_from_text("test document") await app.add_document_from_text("test document")
mock_client.create_document.assert_called_once_with("test document") mock_client.create_document.assert_called_once()
args, kwargs = mock_client.create_document.call_args
assert args[0] == "test document"
assert kwargs.get("metadata") is None
mock_rich_print.assert_called_once_with(mock_doc, truncate=True) mock_rich_print.assert_called_once_with(mock_doc, truncate=True)
mock_print.assert_called_once_with( mock_print.assert_called_once_with(
"[bold green]Document 1 added successfully.[/bold green]" "[bold green]Document 1 added successfully.[/bold green]"
@ -78,9 +81,11 @@ async def test_add_document_from_source(app: HaikuRAGApp, monkeypatch):
with patch("haiku.rag.app.HaikuRAG", return_value=mock_client): with patch("haiku.rag.app.HaikuRAG", return_value=mock_client):
await app.add_document_from_source(file_path) await app.add_document_from_source(file_path)
mock_client.create_document_from_source.assert_called_once_with( mock_client.create_document_from_source.assert_called_once()
file_path, title=None args, kwargs = mock_client.create_document_from_source.call_args
) assert args[0] == file_path
assert kwargs.get("title") is None
assert kwargs.get("metadata") is None
mock_rich_print.assert_called_once_with(mock_doc, truncate=True) mock_rich_print.assert_called_once_with(mock_doc, truncate=True)
mock_print.assert_called_once_with( mock_print.assert_called_once_with(
"[bold green]Document 1 added successfully.[/bold green]" "[bold green]Document 1 added successfully.[/bold green]"

View file

@ -28,9 +28,10 @@ def test_add_document_text():
result = runner.invoke(cli, ["add", "test document"]) result = runner.invoke(cli, ["add", "test document"])
assert result.exit_code == 0 assert result.exit_code == 0
mock_app_instance.add_document_from_text.assert_called_once_with( mock_app_instance.add_document_from_text.assert_called_once()
text="test document" _, kwargs = mock_app_instance.add_document_from_text.call_args
) assert kwargs.get("text") == "test document"
assert kwargs.get("metadata") is None
def test_add_document_src(): def test_add_document_src():
@ -57,8 +58,83 @@ def test_add_document_src_with_title():
mock_app_instance.add_document_from_source.assert_called_once() mock_app_instance.add_document_from_source.assert_called_once()
# Verify title is forwarded (inspect call kwargs) # Verify title is forwarded (inspect call kwargs)
_, kwargs = mock_app_instance.add_document_from_source.call_args _, kwargs = mock_app_instance.add_document_from_source.call_args
assert kwargs.get("title") == "Nice Name" assert kwargs.get("title") == "Nice Name"
assert kwargs.get("source") == "test.txt"
def test_add_document_text_with_meta():
with patch("haiku.rag.app.HaikuRAGApp") as mock_app:
mock_app_instance = MagicMock()
mock_app_instance.add_document_from_text = AsyncMock()
mock_app.return_value = mock_app_instance
result = runner.invoke(
cli,
[
"add",
"some text",
"--meta",
"author=alice",
"--meta",
"topic=notes",
],
)
assert result.exit_code == 0
mock_app_instance.add_document_from_text.assert_called_once()
_, kwargs = mock_app_instance.add_document_from_text.call_args
assert kwargs.get("text") == "some text"
assert kwargs.get("metadata") == {"author": "alice", "topic": "notes"}
def test_add_document_src_with_meta():
with patch("haiku.rag.app.HaikuRAGApp") as mock_app:
mock_app_instance = MagicMock()
mock_app_instance.add_document_from_source = AsyncMock()
mock_app.return_value = mock_app_instance
result = runner.invoke(
cli,
[
"add-src",
"test.txt",
"--meta",
"source=manual",
"--meta",
"lang=en",
],
)
assert result.exit_code == 0
mock_app_instance.add_document_from_source.assert_called_once()
_, kwargs = mock_app_instance.add_document_from_source.call_args
assert kwargs.get("source") == "test.txt" assert kwargs.get("source") == "test.txt"
assert kwargs.get("metadata") == {"source": "manual", "lang": "en"}
def test_add_document_text_with_numeric_meta():
with patch("haiku.rag.app.HaikuRAGApp") as mock_app:
mock_app_instance = MagicMock()
mock_app_instance.add_document_from_text = AsyncMock()
mock_app.return_value = mock_app_instance
result = runner.invoke(
cli,
[
"add",
"some text",
"--meta",
"version=3",
"--meta",
"published=true",
],
)
assert result.exit_code == 0
mock_app_instance.add_document_from_text.assert_called_once()
_, kwargs = mock_app_instance.add_document_from_text.call_args
assert kwargs.get("text") == "some text"
assert kwargs.get("metadata") == {"version": 3, "published": True}
def test_get_document(): def test_get_document():