Add --full-citations to ask and analyze
This commit is contained in:
parent
b87ae16910
commit
22a5b7d17c
8 changed files with 90 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue