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`
overrides it.
- 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

View file

@ -910,21 +910,22 @@ class HaikuRAGApp:
)
if result.source and self.scope.covers_multiple:
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:
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:
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:
self.console.print("[repr.attrib_name]pages[/repr.attrib_name]:")
self.console.print(", ".join(str(p) for p in result.page_numbers))
if result.headings:
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(content)
self.console.rule()

View file

@ -2,6 +2,7 @@ from io import BytesIO
from typing import TYPE_CHECKING, Any
from PIL import Image as PILImage
from rich.markup import escape
from textual.containers import Horizontal, VerticalScroll
from textual.css.query import NoMatches
from textual.message import Message
@ -129,6 +130,8 @@ class CitationWidget(Collapsible):
if len(citation.page_numbers) > 3:
pages += "..."
title += f" (p.{pages})"
# The title is data, not Textual markup.
title = escape(title)
content = citation.content
if len(content) > 500:
@ -143,9 +146,14 @@ class CitationWidget(Collapsible):
children.append(TextualImage(pil, classes="citation-image"))
if citation.headings:
headings = " > ".join(citation.headings[:3])
children.append(Static(f"Section: {headings}", classes="citation-metadata"))
children.append(
Static(escape(f"Section: {headings}"), classes="citation-metadata")
)
children.append(
Static(f"Source: {citation.document_uri}", classes="citation-metadata")
Static(
escape(f"Source: {citation.document_uri}"),
classes="citation-metadata",
)
)
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.binding import Binding
from textual.containers import Horizontal, Vertical, VerticalScroll
@ -22,11 +23,14 @@ class DocumentCheckbox(Checkbox):
def _labelled(docs) -> list[tuple[str, str]]:
"""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(
(
f"{doc.title or doc.uri or doc.id}"
+ (f" ({doc.source})" if doc.source else ""),
escape(
f"{doc.title or doc.uri or doc.id}"
+ (f" ({doc.source})" if doc.source else "")
),
doc.id,
)
for doc in docs

View file

@ -686,6 +686,48 @@ class TestDocumentSelectionIdentity:
await pilot.pause()
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:
@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
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):
image = tmp_path / "query.png"
image.write_bytes(b"pixels")