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]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `--full-citations` on `haiku-rag ask` and `haiku-rag analyze` renders citation
|
||||||
|
text untruncated. `format_citations_rich` takes a `full` argument.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Migration to 0.38.0 no longer fails with `UnicodeDecodeError` on a
|
- 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.
|
`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:
|
Flags:
|
||||||
|
|
||||||
- `--filter` / `-f`: Restrict searches to documents matching the filter (see [Filtering Search Results](python.md#filtering-search-results))
|
- `--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.
|
- `--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
|
## Analyze
|
||||||
|
|
||||||
|
|
@ -195,6 +201,7 @@ Flags:
|
||||||
|
|
||||||
- `--filter` / `-f`: SQL WHERE clause to restrict document access
|
- `--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.
|
- `--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.
|
See [Analysis capability](capabilities/analysis.md) for details and configuration.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -638,6 +638,7 @@ class HaikuRAGApp:
|
||||||
question: str,
|
question: str,
|
||||||
filter: str | None = None,
|
filter: str | None = None,
|
||||||
images: list[Path] | None = None,
|
images: list[Path] | None = None,
|
||||||
|
full_citations: bool = False,
|
||||||
):
|
):
|
||||||
"""Ask a question using the RAG system.
|
"""Ask a question using the RAG system.
|
||||||
|
|
||||||
|
|
@ -645,6 +646,7 @@ class HaikuRAGApp:
|
||||||
question: The question to ask
|
question: The question to ask
|
||||||
filter: SQL WHERE clause to filter documents
|
filter: SQL WHERE clause to filter documents
|
||||||
images: Paths of images to attach to the question
|
images: Paths of images to attach to the question
|
||||||
|
full_citations: Render citation text without truncating it
|
||||||
"""
|
"""
|
||||||
async with HaikuRAG._covering(
|
async with HaikuRAG._covering(
|
||||||
self.scope, self.config, read_only=True
|
self.scope, self.config, read_only=True
|
||||||
|
|
@ -660,7 +662,7 @@ class HaikuRAGApp:
|
||||||
self.console.print("[bold green]Answer:[/bold green]")
|
self.console.print("[bold green]Answer:[/bold green]")
|
||||||
self.console.print(Markdown(answer))
|
self.console.print(Markdown(answer))
|
||||||
for renderable in await format_citations_rich(
|
for renderable in await format_citations_rich(
|
||||||
citations, client=self.client
|
citations, client=self.client, full=full_citations
|
||||||
):
|
):
|
||||||
self.console.print(renderable)
|
self.console.print(renderable)
|
||||||
|
|
||||||
|
|
@ -669,6 +671,7 @@ class HaikuRAGApp:
|
||||||
question: str,
|
question: str,
|
||||||
filter: str | None = None,
|
filter: str | None = None,
|
||||||
images: list[Path] | None = None,
|
images: list[Path] | None = None,
|
||||||
|
full_citations: bool = False,
|
||||||
):
|
):
|
||||||
"""Answer a question using the analysis capability.
|
"""Answer a question using the analysis capability.
|
||||||
|
|
||||||
|
|
@ -676,6 +679,7 @@ class HaikuRAGApp:
|
||||||
question: The question to answer
|
question: The question to answer
|
||||||
filter: SQL WHERE clause to filter documents
|
filter: SQL WHERE clause to filter documents
|
||||||
images: Paths of images to attach to the question
|
images: Paths of images to attach to the question
|
||||||
|
full_citations: Render citation text without truncating it
|
||||||
"""
|
"""
|
||||||
async with HaikuRAG._covering(
|
async with HaikuRAG._covering(
|
||||||
self.scope, self.config, read_only=True
|
self.scope, self.config, read_only=True
|
||||||
|
|
@ -696,7 +700,7 @@ class HaikuRAGApp:
|
||||||
self.console.print("[bold green]Answer:[/bold green]")
|
self.console.print("[bold green]Answer:[/bold green]")
|
||||||
self.console.print(Markdown(result.answer))
|
self.console.print(Markdown(result.answer))
|
||||||
for renderable in await format_citations_rich(
|
for renderable in await format_citations_rich(
|
||||||
result.citations, client=self.client
|
result.citations, client=self.client, full=full_citations
|
||||||
):
|
):
|
||||||
self.console.print(renderable)
|
self.console.print(renderable)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -413,6 +413,11 @@ def ask(
|
||||||
"--image",
|
"--image",
|
||||||
help="Path to an image to attach to the question (repeatable; requires a vision-capable model)",
|
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)
|
app = create_app(db, covers_set=True)
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
|
|
@ -420,6 +425,7 @@ def ask(
|
||||||
question=question,
|
question=question,
|
||||||
filter=filter,
|
filter=filter,
|
||||||
images=image,
|
images=image,
|
||||||
|
full_citations=full_citations,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -445,6 +451,11 @@ def analyze(
|
||||||
"--image",
|
"--image",
|
||||||
help="Path to an image to attach to the question (repeatable; requires a vision-capable model)",
|
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)
|
app = create_app(db, covers_set=True)
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
|
|
@ -452,6 +463,7 @@ def analyze(
|
||||||
question=question,
|
question=question,
|
||||||
filter=filter,
|
filter=filter,
|
||||||
images=image,
|
images=image,
|
||||||
|
full_citations=full_citations,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -430,13 +430,15 @@ def truncated(text: str, limit: int) -> str:
|
||||||
async def format_citations_rich(
|
async def format_citations_rich(
|
||||||
citations: "list[Citation]",
|
citations: "list[Citation]",
|
||||||
client: "HaikuRAG | None" = None,
|
client: "HaikuRAG | None" = None,
|
||||||
|
full: bool = False,
|
||||||
) -> "list[RenderableType]":
|
) -> "list[RenderableType]":
|
||||||
"""Format citations as Rich renderables for terminal display.
|
"""Format citations as Rich renderables for terminal display.
|
||||||
|
|
||||||
Each citation becomes a Panel with a compact header (``[N] Title (URI) —
|
Each citation becomes a Panel with a compact header (``[N] Title (URI) —
|
||||||
locator``, with the database name before the locator when ``client`` covers
|
locator``, with the database name before the locator when ``client`` covers
|
||||||
several), a body holding any referenced figures followed by a truncated text
|
several), a body holding any referenced figures followed by a text preview
|
||||||
preview, and a dimmed footer that exposes the document and chunk IDs.
|
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
|
When ``client`` is supplied, picture bytes for ``picture_refs`` are fetched and
|
||||||
rendered inline via ``textual_image``. Without a client, picture refs appear as
|
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")
|
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 = Text()
|
||||||
footer.append("doc: ", style="dim")
|
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):
|
async def test_ask_prints_question_answer_and_citations(app, client, monkeypatch):
|
||||||
client.ask.return_value = ("The answer.", [])
|
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"]
|
return ["citation block"]
|
||||||
|
|
||||||
monkeypatch.setattr("haiku.rag.app.format_citations_rich", no_citations)
|
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")
|
image.write_bytes(b"img")
|
||||||
client.ask.return_value = ("answer", [])
|
client.ask.return_value = ("answer", [])
|
||||||
|
|
||||||
async def no_citations(citations, client=None):
|
async def no_citations(citations, client=None, full=False):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
monkeypatch.setattr("haiku.rag.app.format_citations_rich", no_citations)
|
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"]
|
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):
|
async def test_analyze_prints_the_answer(app, client, monkeypatch):
|
||||||
result = AsyncMock()
|
result = AsyncMock()
|
||||||
result.answer = "computed answer"
|
result.answer = "computed answer"
|
||||||
result.citations = []
|
result.citations = []
|
||||||
client.analyze.return_value = result
|
client.analyze.return_value = result
|
||||||
|
|
||||||
async def no_citations(citations, client=None):
|
async def no_citations(citations, client=None, full=False):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
monkeypatch.setattr("haiku.rag.app.format_citations_rich", no_citations)
|
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"]
|
result.citations = ["c1"]
|
||||||
client.analyze.return_value = result
|
client.analyze.return_value = result
|
||||||
|
|
||||||
async def one_citation(citations, client=None):
|
async def one_citation(citations, client=None, full=False):
|
||||||
return ["citation renderable"]
|
return ["citation renderable"]
|
||||||
|
|
||||||
monkeypatch.setattr("haiku.rag.app.format_citations_rich", one_citation)
|
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
|
assert result.exit_code == 0, result.output
|
||||||
getattr(app_stub, method).assert_called_once_with(
|
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(
|
@pytest.mark.parametrize(
|
||||||
"flag, mode_name",
|
"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
|
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():
|
async def test_format_citations_rich_picture_marker_without_client():
|
||||||
from haiku.rag.store.models.citation import Citation
|
from haiku.rag.store.models.citation import Citation
|
||||||
from haiku.rag.utils import format_citations_rich
|
from haiku.rag.utils import format_citations_rich
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue