Merge pull request #74 from ggozad/feat/meta-add
Add --meta option to add-src & add CLI commands
This commit is contained in:
commit
14bbcd13c8
9 changed files with 156 additions and 18 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 human‑readable title stored in the DB schema
|
# Optionally set a human‑readable 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]"
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from .engine import Store
|
from .engine import Store
|
||||||
from .models import Chunk, Document
|
from .models import Chunk, Document
|
||||||
|
|
||||||
__all__ = ["Store", "Chunk", "Document"]
|
__all__ = ["Store", "Chunk", "Document"]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from .chunk import Chunk
|
from .chunk import Chunk
|
||||||
from .document import Document
|
from .document import Document
|
||||||
|
|
||||||
__all__ = ["Chunk", "Document"]
|
__all__ = ["Chunk", "Document"]
|
||||||
|
|
|
||||||
|
|
@ -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]"
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue