Render titles and database names as text, not markup

A database name, document title, uri or heading containing Rich markup
crashed search output, chat citations and the chat document filter with
MarkupError. Every interpolation into markup-parsed text now escapes.
This commit is contained in:
Yiorgis Gozadinos 2026-08-28 15:12:19 +03:00
parent 2bfb661c10
commit b601489896
No known key found for this signature in database
6 changed files with 97 additions and 9 deletions

View file

@ -31,6 +31,8 @@
- A `lancedb.uri` without a scheme is treated as a local path. `--db PATH` - A `lancedb.uri` without a scheme is treated as a local path. `--db PATH`
overrides it. overrides it.
- Inspector search results mark truncated previews with an ellipsis. - Inspector search results mark truncated previews with an ellipsis.
- Document titles, URIs, headings and database names render as text, not Rich
markup, in `search` output, chat citations and the chat document filter.
## [0.78.0] - 2026-08-24 ## [0.78.0] - 2026-08-24

View file

@ -910,21 +910,22 @@ class HaikuRAGApp:
) )
if result.source and self.scope.covers_multiple: if result.source and self.scope.covers_multiple:
self.console.print( self.console.print(
f"[repr.attrib_name]database[/repr.attrib_name]: {result.source}" f"[repr.attrib_name]database[/repr.attrib_name]: {escape(result.source)}"
) )
if result.document_uri: if result.document_uri:
self.console.print( self.console.print(
f"[repr.attrib_name]document uri[/repr.attrib_name]: {result.document_uri}" "[repr.attrib_name]document uri[/repr.attrib_name]: "
f"{escape(result.document_uri)}"
) )
if result.document_title: if result.document_title:
self.console.print("[repr.attrib_name]document title[/repr.attrib_name]:") self.console.print("[repr.attrib_name]document title[/repr.attrib_name]:")
self.console.print(result.document_title) self.console.print(escape(result.document_title))
if result.page_numbers: if result.page_numbers:
self.console.print("[repr.attrib_name]pages[/repr.attrib_name]:") self.console.print("[repr.attrib_name]pages[/repr.attrib_name]:")
self.console.print(", ".join(str(p) for p in result.page_numbers)) self.console.print(", ".join(str(p) for p in result.page_numbers))
if result.headings: if result.headings:
self.console.print("[repr.attrib_name]headings[/repr.attrib_name]:") self.console.print("[repr.attrib_name]headings[/repr.attrib_name]:")
self.console.print(" > ".join(result.headings)) self.console.print(escape(" > ".join(result.headings)))
self.console.print("[repr.attrib_name]content[/repr.attrib_name]:") self.console.print("[repr.attrib_name]content[/repr.attrib_name]:")
self.console.print(content) self.console.print(content)
self.console.rule() self.console.rule()

View file

@ -2,6 +2,7 @@ from io import BytesIO
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from PIL import Image as PILImage from PIL import Image as PILImage
from rich.markup import escape
from textual.containers import Horizontal, VerticalScroll from textual.containers import Horizontal, VerticalScroll
from textual.css.query import NoMatches from textual.css.query import NoMatches
from textual.message import Message from textual.message import Message
@ -129,6 +130,8 @@ class CitationWidget(Collapsible):
if len(citation.page_numbers) > 3: if len(citation.page_numbers) > 3:
pages += "..." pages += "..."
title += f" (p.{pages})" title += f" (p.{pages})"
# The title is data, not Textual markup.
title = escape(title)
content = citation.content content = citation.content
if len(content) > 500: if len(content) > 500:
@ -143,9 +146,14 @@ class CitationWidget(Collapsible):
children.append(TextualImage(pil, classes="citation-image")) children.append(TextualImage(pil, classes="citation-image"))
if citation.headings: if citation.headings:
headings = " > ".join(citation.headings[:3]) headings = " > ".join(citation.headings[:3])
children.append(Static(f"Section: {headings}", classes="citation-metadata"))
children.append( children.append(
Static(f"Source: {citation.document_uri}", classes="citation-metadata") Static(escape(f"Section: {headings}"), classes="citation-metadata")
)
children.append(
Static(
escape(f"Source: {citation.document_uri}"),
classes="citation-metadata",
)
) )
super().__init__(*children, title=title, collapsed=True, **kwargs) super().__init__(*children, title=title, collapsed=True, **kwargs)

View file

@ -1,3 +1,4 @@
from rich.markup import escape
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.binding import Binding from textual.binding import Binding
from textual.containers import Horizontal, Vertical, VerticalScroll from textual.containers import Horizontal, Vertical, VerticalScroll
@ -22,11 +23,14 @@ class DocumentCheckbox(Checkbox):
def _labelled(docs) -> list[tuple[str, str]]: def _labelled(docs) -> list[tuple[str, str]]:
"""Each document's label and id, sorted. The database is named alongside the """Each document's label and id, sorted. The database is named alongside the
title, which a title alone does not say.""" title, which a title alone does not say. Labels are escaped: titles and
database names are data, not Textual markup."""
return sorted( return sorted(
( (
escape(
f"{doc.title or doc.uri or doc.id}" f"{doc.title or doc.uri or doc.id}"
+ (f" ({doc.source})" if doc.source else ""), + (f" ({doc.source})" if doc.source else "")
),
doc.id, doc.id,
) )
for doc in docs for doc in docs

View file

@ -686,6 +686,48 @@ class TestDocumentSelectionIdentity:
await pilot.pause() await pilot.pause()
assert modal._selected == {"id-one"} assert modal._selected == {"id-one"}
def test_a_label_that_looks_like_markup_is_text(self):
from haiku.rag.chat.widgets.document_filter_modal import (
DocumentCheckbox,
_labelled,
)
from haiku.rag.store.models.document import Document
docs = [
Document(
id="id-one", content="", title="Report [/red]", source="alpha [/x]"
)
]
((label, doc_id),) = _labelled(docs)
box = DocumentCheckbox(label, doc_id, value=False)
assert str(box.label) == "Report [/red] (alpha [/x])"
def test_a_citation_title_that_looks_like_markup_is_text():
from rich.text import Text
from haiku.rag.chat.widgets.chat_history import CitationWidget
from haiku.rag.store.models.citation import Citation
citation = Citation(
index=1,
document_id="doc1",
chunk_id="chunk1",
document_uri="file:///doc [/blue].pdf",
document_title="Report [/red]",
headings=["Chapter [/dim]"],
content="content",
source="alpha [/x]",
)
widget = CitationWidget(citation, include_collection=True)
title = Text.from_markup(str(widget.title)).plain
assert "Report [/red]" in title
assert "alpha [/x]" in title
class TestRenderingUnattributedPictures: class TestRenderingUnattributedPictures:
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -229,6 +229,37 @@ async def test_a_result_names_its_database_only_across_several(tmp_path):
assert "database:" not in printed assert "database:" not in printed
async def test_a_result_renders_names_that_look_like_markup(tmp_path):
"""Database names, titles, uris and headings render as text, not markup."""
config = AppConfig(
lancedb=LanceDBConfig(
databases={
"alpha [/red]": str(tmp_path / "a.lancedb"),
"beta": str(tmp_path / "b.lancedb"),
}
)
)
hit = SearchResult(
content="hit",
score=0.9,
chunk_id="c1",
source="alpha [/red]",
document_uri="test://doc [/blue]",
document_title="The [/bold] Title",
headings=["Chapter [/dim]"],
)
app = HaikuRAGApp(scope=DatabaseScope.resolve(config), config=config)
app.console = Console(record=True, width=200)
app._rich_print_search_result(hit)
printed = app.console.export_text()
assert "database: alpha [/red]" in printed
assert "test://doc [/blue]" in printed
assert "The [/bold] Title" in printed
assert "Chapter [/dim]" in printed
async def test_search_by_image_reads_the_bytes(app, client, tmp_path): async def test_search_by_image_reads_the_bytes(app, client, tmp_path):
image = tmp_path / "query.png" image = tmp_path / "query.png"
image.write_bytes(b"pixels") image.write_bytes(b"pixels")