diff --git a/CHANGELOG.md b/CHANGELOG.md index e0f4231a..719fcbe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### Added + +- `--full-citations` on `haiku-rag ask` and `haiku-rag analyze` renders citation + text untruncated. `format_citations_rich` takes a `full` argument. + ### Fixed - Migration to 0.38.0 no longer fails with `UnicodeDecodeError` on a diff --git a/docs/cli.md b/docs/cli.md index 42c10a45..249a4060 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -172,10 +172,16 @@ haiku-rag ask "Does this photo satisfy the spec in the design document?" --image `ask` runs the [RAG capability](capabilities/rag.md) and always renders citations under the answer. When available, citations use the document title, otherwise they fall back to the URI. +Citation text is truncated to a 300-character preview. To read the whole passage the model saw: +```bash +haiku-rag ask "What are the main findings?" --full-citations +``` + Flags: - `--filter` / `-f`: Restrict searches to documents matching the filter (see [Filtering Search Results](python.md#filtering-search-results)) - `--image`: Path to an image attached to the question (repeatable). Retrieval stays text-based; the model must have `vision: true` configured. +- `--full-citations`: Show the full text of each citation instead of a truncated preview ## Analyze @@ -195,6 +201,7 @@ Flags: - `--filter` / `-f`: SQL WHERE clause to restrict document access - `--image`: Path to an image attached to the question (repeatable). Requires `vision: true` on the analysis model. +- `--full-citations`: Show the full text of each citation instead of a truncated preview See [Analysis capability](capabilities/analysis.md) for details and configuration. diff --git a/haiku_rag_slim/haiku/rag/app.py b/haiku_rag_slim/haiku/rag/app.py index 4043a99e..76c82637 100644 --- a/haiku_rag_slim/haiku/rag/app.py +++ b/haiku_rag_slim/haiku/rag/app.py @@ -638,6 +638,7 @@ class HaikuRAGApp: question: str, filter: str | None = None, images: list[Path] | None = None, + full_citations: bool = False, ): """Ask a question using the RAG system. @@ -645,6 +646,7 @@ class HaikuRAGApp: question: The question to ask filter: SQL WHERE clause to filter documents images: Paths of images to attach to the question + full_citations: Render citation text without truncating it """ async with HaikuRAG._covering( self.scope, self.config, read_only=True @@ -660,7 +662,7 @@ class HaikuRAGApp: self.console.print("[bold green]Answer:[/bold green]") self.console.print(Markdown(answer)) for renderable in await format_citations_rich( - citations, client=self.client + citations, client=self.client, full=full_citations ): self.console.print(renderable) @@ -669,6 +671,7 @@ class HaikuRAGApp: question: str, filter: str | None = None, images: list[Path] | None = None, + full_citations: bool = False, ): """Answer a question using the analysis capability. @@ -676,6 +679,7 @@ class HaikuRAGApp: question: The question to answer filter: SQL WHERE clause to filter documents images: Paths of images to attach to the question + full_citations: Render citation text without truncating it """ async with HaikuRAG._covering( self.scope, self.config, read_only=True @@ -696,7 +700,7 @@ class HaikuRAGApp: self.console.print("[bold green]Answer:[/bold green]") self.console.print(Markdown(result.answer)) for renderable in await format_citations_rich( - result.citations, client=self.client + result.citations, client=self.client, full=full_citations ): self.console.print(renderable) diff --git a/haiku_rag_slim/haiku/rag/cli.py b/haiku_rag_slim/haiku/rag/cli.py index fa738833..cbdbaf77 100644 --- a/haiku_rag_slim/haiku/rag/cli.py +++ b/haiku_rag_slim/haiku/rag/cli.py @@ -413,6 +413,11 @@ def ask( "--image", help="Path to an image to attach to the question (repeatable; requires a vision-capable model)", ), + full_citations: bool = typer.Option( + False, + "--full-citations", + help="Show the full text of each citation instead of a truncated preview", + ), ): app = create_app(db, covers_set=True) asyncio.run( @@ -420,6 +425,7 @@ def ask( question=question, filter=filter, images=image, + full_citations=full_citations, ) ) @@ -445,6 +451,11 @@ def analyze( "--image", help="Path to an image to attach to the question (repeatable; requires a vision-capable model)", ), + full_citations: bool = typer.Option( + False, + "--full-citations", + help="Show the full text of each citation instead of a truncated preview", + ), ): app = create_app(db, covers_set=True) asyncio.run( @@ -452,6 +463,7 @@ def analyze( question=question, filter=filter, images=image, + full_citations=full_citations, ) ) diff --git a/haiku_rag_slim/haiku/rag/utils.py b/haiku_rag_slim/haiku/rag/utils.py index e3d17780..812740ea 100644 --- a/haiku_rag_slim/haiku/rag/utils.py +++ b/haiku_rag_slim/haiku/rag/utils.py @@ -430,13 +430,15 @@ def truncated(text: str, limit: int) -> str: async def format_citations_rich( citations: "list[Citation]", client: "HaikuRAG | None" = None, + full: bool = False, ) -> "list[RenderableType]": """Format citations as Rich renderables for terminal display. Each citation becomes a Panel with a compact header (``[N] Title (URI) — locator``, with the database name before the locator when ``client`` covers - several), a body holding any referenced figures followed by a truncated text - preview, and a dimmed footer that exposes the document and chunk IDs. + several), a body holding any referenced figures followed by a text preview + truncated at ``CITATION_PREVIEW_CHARS``, and a dimmed footer that exposes the + document and chunk IDs. ``full`` renders the content untruncated. When ``client`` is supplied, picture bytes for ``picture_refs`` are fetched and rendered inline via ``textual_image``. Without a client, picture refs appear as @@ -481,7 +483,9 @@ async def format_citations_rich( else Text(f"[Figure: {ref}]", style="italic dim") ) - body.append(Text(truncated(c.content, CITATION_PREVIEW_CHARS))) + body.append( + Text(c.content if full else truncated(c.content, CITATION_PREVIEW_CHARS)) + ) footer = Text() footer.append("doc: ", style="dim") diff --git a/tests/test_app.py b/tests/test_app.py index b580053e..f0812ef5 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -307,7 +307,7 @@ async def test_visualize_reports_no_grounding(app, client): async def test_ask_prints_question_answer_and_citations(app, client, monkeypatch): client.ask.return_value = ("The answer.", []) - async def no_citations(citations, client=None): + async def no_citations(citations, client=None, full=False): return ["citation block"] monkeypatch.setattr("haiku.rag.app.format_citations_rich", no_citations) @@ -326,7 +326,7 @@ async def test_ask_attaches_image_bytes(app, client, monkeypatch, tmp_path): image.write_bytes(b"img") client.ask.return_value = ("answer", []) - async def no_citations(citations, client=None): + async def no_citations(citations, client=None, full=False): return [] monkeypatch.setattr("haiku.rag.app.format_citations_rich", no_citations) @@ -336,13 +336,36 @@ async def test_ask_attaches_image_bytes(app, client, monkeypatch, tmp_path): assert client.ask.await_args.kwargs["images"] == [b"img"] +@pytest.mark.parametrize("verb", ["ask", "analyze"]) +async def test_full_citations_reaches_the_formatter(app, client, monkeypatch, verb): + """The flag has to survive the app layer, or the CLI switch does nothing.""" + client.ask.return_value = ("answer", []) + result = AsyncMock() + result.answer = "answer" + result.citations = [] + client.analyze.return_value = result + + seen = [] + + async def record(citations, client=None, full=False): + seen.append(full) + return [] + + monkeypatch.setattr("haiku.rag.app.format_citations_rich", record) + + await getattr(app, verb)("why?", full_citations=True) + await getattr(app, verb)("why?") + + assert seen == [True, False] + + async def test_analyze_prints_the_answer(app, client, monkeypatch): result = AsyncMock() result.answer = "computed answer" result.citations = [] client.analyze.return_value = result - async def no_citations(citations, client=None): + async def no_citations(citations, client=None, full=False): return [] monkeypatch.setattr("haiku.rag.app.format_citations_rich", no_citations) @@ -964,7 +987,7 @@ async def test_analyze_prints_citation_renderables(app, client, monkeypatch): result.citations = ["c1"] client.analyze.return_value = result - async def one_citation(citations, client=None): + async def one_citation(citations, client=None, full=False): return ["citation renderable"] monkeypatch.setattr("haiku.rag.app.format_citations_rich", one_citation) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4ea63747..202de516 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -951,10 +951,18 @@ def test_question_commands_dispatch(app_stub, command, method): assert result.exit_code == 0, result.output getattr(app_stub, method).assert_called_once_with( - question="why?", filter=None, images=None + question="why?", filter=None, images=None, full_citations=False ) +@pytest.mark.parametrize("command, method", [("ask", "ask"), ("analyze", "analyze")]) +def test_full_citations_flag_dispatches(app_stub, command, method): + result = runner.invoke(cli, [command, "why?", "--full-citations"] + DB_ARGS) + + assert result.exit_code == 0, result.output + assert getattr(app_stub, method).call_args.kwargs["full_citations"] is True + + @pytest.mark.parametrize( "flag, mode_name", [ diff --git a/tests/test_utils.py b/tests/test_utils.py index 92224dd3..5b233d31 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -907,6 +907,23 @@ async def test_format_citations_rich_truncates_long_content(): assert "A" * (CITATION_PREVIEW_CHARS + 1) not in output +async def test_format_citations_rich_full_keeps_the_whole_content(): + from haiku.rag.store.models.citation import Citation + from haiku.rag.utils import CITATION_PREVIEW_CHARS, format_citations_rich + + content = "A" * (CITATION_PREVIEW_CHARS + 200) + citation = Citation( + document_id="doc1", + chunk_id="chunk1", + document_uri="test://doc", + content=content, + ) + output = _render_rich(await format_citations_rich([citation], full=True)) + assert "…" not in output + # Rich wraps the body across panel lines, so count the content instead. + assert output.count("A") == len(content) + + async def test_format_citations_rich_picture_marker_without_client(): from haiku.rag.store.models.citation import Citation from haiku.rag.utils import format_citations_rich