Add --full-citations to ask and analyze

This commit is contained in:
Yiorgis Gozadinos 2026-08-31 16:31:09 +03:00
parent b87ae16910
commit 22a5b7d17c
No known key found for this signature in database
8 changed files with 90 additions and 10 deletions

View file

@ -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

View file

@ -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.

View file

@ -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)

View file

@ -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,
)
)

View file

@ -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")

View file

@ -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)

View file

@ -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",
[

View file

@ -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