diff --git a/CHANGELOG.md b/CHANGELOG.md index e27bcd2f..ba8fd32d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/haiku_rag_slim/haiku/rag/app.py b/haiku_rag_slim/haiku/rag/app.py index 7d037c21..715895bc 100644 --- a/haiku_rag_slim/haiku/rag/app.py +++ b/haiku_rag_slim/haiku/rag/app.py @@ -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() diff --git a/haiku_rag_slim/haiku/rag/chat/widgets/chat_history.py b/haiku_rag_slim/haiku/rag/chat/widgets/chat_history.py index 27cde368..7db96829 100644 --- a/haiku_rag_slim/haiku/rag/chat/widgets/chat_history.py +++ b/haiku_rag_slim/haiku/rag/chat/widgets/chat_history.py @@ -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) diff --git a/haiku_rag_slim/haiku/rag/chat/widgets/document_filter_modal.py b/haiku_rag_slim/haiku/rag/chat/widgets/document_filter_modal.py index b5cb5967..d06cc1dd 100644 --- a/haiku_rag_slim/haiku/rag/chat/widgets/document_filter_modal.py +++ b/haiku_rag_slim/haiku/rag/chat/widgets/document_filter_modal.py @@ -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 diff --git a/tests/chat/test_chat_app.py b/tests/chat/test_chat_app.py index 1f558dd9..4a5caed9 100644 --- a/tests/chat/test_chat_app.py +++ b/tests/chat/test_chat_app.py @@ -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 diff --git a/tests/test_app.py b/tests/test_app.py index d0c910fc..319b0f4c 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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")