CLI citations: compact panel, inline figures, doc/chunk IDs in footer

This commit is contained in:
Yiorgis Gozadinos 2026-05-20 12:00:24 +03:00
parent 3858ab905a
commit a317a951d9
No known key found for this signature in database
3 changed files with 184 additions and 52 deletions

View file

@ -462,7 +462,9 @@ class HaikuRAGApp: # pragma: no cover
self.console.print() self.console.print()
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 format_citations_rich(citations): for renderable in await format_citations_rich(
citations, client=self.client
):
self.console.print(renderable) self.console.print(renderable)
async def analyze( async def analyze(
@ -493,7 +495,9 @@ class HaikuRAGApp: # pragma: no cover
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 format_citations_rich(result.citations): for renderable in await format_citations_rich(
result.citations, client=self.client
):
self.console.print(renderable) self.console.print(renderable)
async def research( async def research(

View file

@ -12,6 +12,7 @@ if TYPE_CHECKING:
from rich.console import RenderableType from rich.console import RenderableType
from haiku.rag.agents.research.models import Citation from haiku.rag.agents.research.models import Citation
from haiku.rag.client import HaikuRAG
from haiku.rag.config.models import AppConfig, ModelConfig from haiku.rag.config.models import AppConfig, ModelConfig
@ -338,10 +339,34 @@ def format_bytes(num_bytes: int) -> str:
return f"{size:.1f} PB" return f"{size:.1f} PB"
CITATION_PREVIEW_CHARS = 300
def _citation_pages(c: "Citation") -> str | None:
if not c.page_numbers:
return None
if len(c.page_numbers) == 1:
return f"p. {c.page_numbers[0]}"
return f"pp. {c.page_numbers[0]}-{c.page_numbers[-1]}"
def _citation_section(c: "Citation") -> str | None:
if c.headings:
return c.headings[-1]
return None
def _citation_label(c: "Citation") -> str:
if c.document_title and c.document_uri:
return f"{c.document_title} ({c.document_uri})"
return c.document_title or c.document_uri
def format_citations(citations: "list[Citation]") -> str: def format_citations(citations: "list[Citation]") -> str:
"""Format citations as plain text with preserved formatting. """Format citations as plain text with preserved formatting.
Used by things like the MCP server where Rich renderables are not available. Used by things like the MCP server where Rich renderables are not available.
Pictures referenced by the chunk are surfaced as ``[Figure: <ref>]`` markers.
""" """
if not citations: if not citations:
return "" return ""
@ -353,34 +378,42 @@ def format_citations(citations: "list[Citation]") -> str:
title = c.document_title or c.document_uri title = c.document_title or c.document_uri
header = f"[{idx}] {title}" header = f"[{idx}] {title}"
# Location info
location_parts = [] location_parts = []
if c.page_numbers: pages = _citation_pages(c)
if len(c.page_numbers) == 1: if pages:
location_parts.append(f"p. {c.page_numbers[0]}") location_parts.append(pages)
else: section = _citation_section(c)
location_parts.append(f"pp. {c.page_numbers[0]}-{c.page_numbers[-1]}") if section:
if c.headings: location_parts.append(f"Section: {section}")
location_parts.append(f"Section: {c.headings[-1]}")
source = c.document_uri source = c.document_uri
if location_parts: if location_parts:
source += f" - {', '.join(location_parts)}" source += f" - {', '.join(location_parts)}"
lines.append(f"{header} {source}") lines.append(f"{header} {source}")
for ref in c.picture_refs:
lines.append(f"[Figure: {ref}]")
lines.append(c.content) lines.append(c.content)
lines.append("") lines.append("")
return "\n".join(lines) return "\n".join(lines)
def format_citations_rich(citations: "list[Citation]") -> "list[RenderableType]": async def format_citations_rich(
"""Format citations as Rich renderables. citations: "list[Citation]",
client: "HaikuRAG | None" = None,
) -> "list[RenderableType]":
"""Format citations as Rich renderables for terminal display.
Returns a list of Rich Panel objects for direct console printing, Each citation becomes a Panel with a compact header (``[N] Title (URI) locator``),
with content rendered as markdown for syntax highlighting. a body holding any referenced figures followed by a truncated text preview, and
a dimmed footer that exposes the document and chunk IDs.
When ``client`` is supplied, picture bytes for ``picture_refs`` are fetched and
rendered inline via ``textual_image``. Without a client, picture refs appear as
``[Figure: <ref>]`` text markers.
""" """
from rich.markdown import Markdown from rich.console import Group
from rich.panel import Panel from rich.panel import Panel
from rich.text import Text from rich.text import Text
@ -388,35 +421,49 @@ def format_citations_rich(citations: "list[Citation]") -> "list[RenderableType]"
return [] return []
renderables: list[RenderableType] = [] renderables: list[RenderableType] = []
renderables.append(Text("Citations", style="bold")) renderables.append(Text(""))
renderables.append(Text("Citations", style="bold green"))
renderables.append(Text(""))
for c in citations: for i, c in enumerate(citations):
# Build header with IDs if i > 0:
header = Text() renderables.append(Text(""))
header.append("doc: ", style="dim") idx = c.index if c.index is not None else (i + 1)
header.append(c.document_id, style="cyan")
header.append(" chunk: ", style="dim")
header.append(c.chunk_id, style="cyan")
# Location info for subtitle header_parts: list[str] = [f"[{idx}] {_citation_label(c)}"]
location_parts = [] pages = _citation_pages(c)
if c.page_numbers: if pages:
if len(c.page_numbers) == 1: header_parts.append(pages)
location_parts.append(f"p. {c.page_numbers[0]}") section = _citation_section(c)
else: if section:
location_parts.append(f"pp. {c.page_numbers[0]}-{c.page_numbers[-1]}") header_parts.append(f"§{section}")
if c.headings: header = Text("".join(header_parts), style="bold")
location_parts.append(f"Section: {c.headings[-1]}")
body: list[RenderableType] = []
for ref in c.picture_refs:
image_renderable = await _render_picture(client, c.document_id, ref)
body.append(
image_renderable
if image_renderable
else Text(f"[Figure: {ref}]", style="italic dim")
)
preview = c.content
if len(preview) > CITATION_PREVIEW_CHARS:
preview = preview[:CITATION_PREVIEW_CHARS].rstrip() + ""
body.append(Text(preview))
footer = Text()
footer.append("doc: ", style="dim")
footer.append(c.document_id, style="dim cyan")
footer.append(" chunk: ", style="dim")
footer.append(c.chunk_id, style="dim cyan")
subtitle = c.document_uri
if c.document_title:
subtitle = f"{c.document_title} ({c.document_uri})"
if location_parts:
subtitle += f" - {', '.join(location_parts)}"
panel = Panel( panel = Panel(
Markdown(c.content), Group(*body),
title=header, title=header,
subtitle=subtitle, title_align="left",
subtitle=footer,
subtitle_align="left", subtitle_align="left",
border_style="dim", border_style="dim",
) )
@ -425,6 +472,28 @@ def format_citations_rich(citations: "list[Citation]") -> "list[RenderableType]"
return renderables return renderables
async def _render_picture(
client: "HaikuRAG | None", document_id: str, ref: str
) -> "RenderableType | None":
"""Fetch a picture and return a Rich renderable, or None on failure/no client."""
if client is None:
return None
from io import BytesIO
from PIL import Image as PILImage
from textual_image.renderable import Image as RichImage
data = await client.document_item_repository.get_picture_bytes(document_id, ref)
if not data:
return None
try:
pil = PILImage.open(BytesIO(data))
pil.load()
except Exception:
return None
return RichImage(pil)
def get_default_data_dir() -> Path: def get_default_data_dir() -> Path:
"""Get the user data directory for the current system platform. """Get the user data directory for the current system platform.

View file

@ -618,19 +618,82 @@ def test_format_citations_sequential_indices():
assert "[2] Second" in result assert "[2] Second" in result
# --- format_citations tests (pictures) ---
def test_format_citations_picture_refs_render_as_markers():
from haiku.rag.agents.research.models import Citation
from haiku.rag.utils import format_citations
citation = Citation(
document_id="doc1",
chunk_id="chunk1",
document_uri="test://doc",
document_title="Test Doc",
content="text body",
picture_refs=["#/pictures/0", "#/pictures/3"],
)
result = format_citations([citation])
assert "[Figure: #/pictures/0]" in result
assert "[Figure: #/pictures/3]" in result
# --- format_citations_rich tests --- # --- format_citations_rich tests ---
def test_format_citations_rich_empty(): def _render_rich(renderables: list) -> str:
from rich.console import Console
console = Console(record=True, width=200)
for r in renderables:
console.print(r)
return console.export_text()
async def test_format_citations_rich_empty():
from haiku.rag.utils import format_citations_rich from haiku.rag.utils import format_citations_rich
assert format_citations_rich([]) == [] assert await format_citations_rich([]) == []
def test_format_citations_rich_with_citation(): async def test_format_citations_rich_header_and_footer():
from rich.panel import Panel from haiku.rag.agents.research.models import Citation
from rich.text import Text from haiku.rag.utils import format_citations_rich
citation = Citation(
document_id="doc-uuid-1",
chunk_id="chunk-uuid-1",
document_uri="test://doc",
document_title="Test Doc",
content="Body",
page_numbers=[1, 2, 3],
headings=["Intro", "Background"],
)
output = _render_rich(await format_citations_rich([citation]))
assert "Citations" in output
assert "[1] Test Doc (test://doc)" in output
assert "pp. 1-3" in output
assert "§Background" in output
assert "doc: doc-uuid-1" in output
assert "chunk: chunk-uuid-1" in output
async def test_format_citations_rich_truncates_long_content():
from haiku.rag.agents.research.models import Citation
from haiku.rag.utils import CITATION_PREVIEW_CHARS, format_citations_rich
citation = Citation(
document_id="doc1",
chunk_id="chunk1",
document_uri="test://doc",
content="A" * (CITATION_PREVIEW_CHARS + 200),
)
output = _render_rich(await format_citations_rich([citation]))
assert "" in output
assert "A" * (CITATION_PREVIEW_CHARS + 1) not in output
async def test_format_citations_rich_picture_marker_without_client():
from haiku.rag.agents.research.models import Citation from haiku.rag.agents.research.models import Citation
from haiku.rag.utils import format_citations_rich from haiku.rag.utils import format_citations_rich
@ -638,15 +701,11 @@ def test_format_citations_rich_with_citation():
document_id="doc1", document_id="doc1",
chunk_id="chunk1", chunk_id="chunk1",
document_uri="test://doc", document_uri="test://doc",
document_title="Test Doc", content="body",
content="Some content", picture_refs=["#/pictures/0"],
page_numbers=[1, 2],
headings=["Intro"],
) )
result = format_citations_rich([citation]) output = _render_rich(await format_citations_rich([citation]))
assert len(result) == 2 assert "[Figure: #/pictures/0]" in output
assert isinstance(result[0], Text)
assert isinstance(result[1], Panel)
# --- get_default_data_dir tests --- # --- get_default_data_dir tests ---