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:
parent
2bfb661c10
commit
b601489896
6 changed files with 97 additions and 9 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue