feat(chat): render markdown tables in answers, viewer, and thinking
GFM pipe tables in the shared renderer (TODO.md L6): a table-protection
pass in frontend/assets/markdown.js (fences -> tables -> escape order)
pulls each header+separator+body block out as a placeholder, renders
cells escape-first with the same inline transforms, and reinserts a
semantic <table class="md-table"> inside a horizontal-overflow
.md-table-wrap — so a pipe table in a chat answer, the document
viewer/modal, and the thinking block all render the same semantic
table. Fences win over tables; lone pipes stay text.
- styles.css: .md-table palette rules (PLAN §7.2 tokens, no motion);
min-width: max-content so a WIDE table keeps its natural width and
the wrapper is the real scroller (width:100% alone wrapped the wide
table's cells — proven by the new E2E).
- mock_llm.py: TABLE_TRIGGER ("show me a table") -> byte-stable
TABLE_ANSWER (3-column table, <img onerror> XSS probe line, wide
5-column table), checked before DEFLECT_MODE like SUMMARY_MODE.
- tests/fixtures/docs/homelab/tables.md: 3x3 pipe table + pipe-heavy
fenced block (viewer/fence subject); the shared fixture set grows
8 -> 9 docs, so every suite pinning the count (added/formats/
stat-docs/EXPECTED_ROWS) is updated accordingly.
- tests/e2e/test_markdown_tables.py (new, story suite): chat table
shape + non-deflection, wide-table wrapper scroll (no page
overflow), XSS probe inert, viewer modal table, fence-not-a-table,
lone pipe stays text.
- tests/e2e/test_agent_document_tools.py: fix a pre-existing flake —
the "Calling tool…" label window is ~0.4 s at the mock's 0.1 s
tool-frame pacing, and a polling expect could stride over it
(failed 3 of 5 runs on the committed baseline). The pre-submit
MutationObserver record is the deterministic source of truth; the
racy to_have_text gate is gone.
uv run pytest: 738 passed, app/ coverage 99% (TOTAL unchanged);
ruff + pyright clean; story E2E 6/6 in isolation; regression E2E
suites (chat_rag, document_viewer, document_summaries, smoke) green.
This commit is contained in:
@@ -47,7 +47,7 @@ def seeded_kb(db) -> Iterator[FakeRagLLM]:
|
||||
db.commit()
|
||||
llm = FakeRagLLM()
|
||||
summary = asyncio.run(import_sources([FIXTURES], llm, session=db))
|
||||
assert summary.added == 8 # A9 formats; .hidden/ skipped
|
||||
assert summary.added == 9 # A9 formats (phase 44 added tables.md); .hidden/ skipped
|
||||
yield llm
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
||||
db.commit()
|
||||
|
||||
@@ -138,7 +138,7 @@ def seeded_kb(db) -> Iterator[FakeRagLLM]:
|
||||
db.commit()
|
||||
llm = FakeRagLLM()
|
||||
summary = asyncio.run(import_sources([FIXTURES], llm, session=db))
|
||||
assert summary.added == 8 # A9 formats; .hidden/ skipped
|
||||
assert summary.added == 9 # A9 formats (phase 44 added tables.md); .hidden/ skipped
|
||||
yield llm
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
||||
db.commit()
|
||||
|
||||
@@ -27,6 +27,7 @@ EXPECTED_DOCS = {
|
||||
("docs", "homelab/networking/static-dns.json"),
|
||||
("docs", "homelab/scripts/uptime_probe.py"),
|
||||
("docs", "homelab/ssh/ssh_aliases.txt"),
|
||||
("docs", "homelab/tables.md"), # phase 44: the markdown-tables fixture
|
||||
}
|
||||
|
||||
|
||||
@@ -36,12 +37,13 @@ def test_import_fixtures_end_to_end(admin_client, db) -> None:
|
||||
llm = FakeEmbedder()
|
||||
|
||||
summary = asyncio.run(import_sources([FIXTURES], llm, session=db))
|
||||
# Eight A9-format files; .hidden/junk.md is out of scope (A9 revised).
|
||||
assert (summary.files, summary.added, summary.unchanged) == (8, 8, 0)
|
||||
assert summary.chunks >= 8
|
||||
assert summary.formats == {"md": 4, "yaml": 1, "json": 1, "py": 1, "txt": 1}
|
||||
# Nine A9-format files (phase 44 added homelab/tables.md);
|
||||
# .hidden/junk.md is out of scope (A9 revised).
|
||||
assert (summary.files, summary.added, summary.unchanged) == (9, 9, 0)
|
||||
assert summary.chunks >= 9
|
||||
assert summary.formats == {"md": 5, "yaml": 1, "json": 1, "py": 1, "txt": 1}
|
||||
# PLAN §9 per-format summary line: highest count first, then alpha.
|
||||
assert summary.format_counts() == "md:4,json:1,py:1,txt:1,yaml:1"
|
||||
assert summary.format_counts() == "md:5,json:1,py:1,txt:1,yaml:1"
|
||||
|
||||
docs = db.scalars(select(Document)).all()
|
||||
assert {(d.source, d.path) for d in docs} == EXPECTED_DOCS
|
||||
@@ -90,13 +92,13 @@ def test_import_fixtures_end_to_end(admin_client, db) -> None:
|
||||
r = admin_client.get("/api/docs") # phase 16: the catalog is admin-only
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert len(body["documents"]) == 8
|
||||
assert len(body["documents"]) == 9
|
||||
assert all(d["chunks"] >= 1 for d in body["documents"])
|
||||
|
||||
# Idempotent re-run: nothing re-embedded.
|
||||
calls_before = len(llm.calls)
|
||||
s2 = asyncio.run(import_sources([FIXTURES], llm, session=db))
|
||||
assert s2.unchanged == 8 and s2.added == 0
|
||||
assert s2.unchanged == 9 and s2.added == 0
|
||||
assert len(llm.calls) == calls_before # unchanged → no embedding requests
|
||||
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
||||
|
||||
@@ -67,7 +67,7 @@ def seeded_kb(db) -> Iterator[FakeRagLLM]:
|
||||
db.commit()
|
||||
llm = FakeRagLLM()
|
||||
summary = asyncio.run(import_sources([FIXTURES], llm, session=db))
|
||||
assert summary.added == 8 # A9 formats; .hidden/ skipped
|
||||
assert summary.added == 9 # A9 formats (phase 44 added tables.md); .hidden/ skipped
|
||||
yield llm
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log, kb_overview"))
|
||||
db.commit()
|
||||
|
||||
@@ -51,7 +51,7 @@ def seeded_kb(db) -> Iterator[FakeRagLLM]:
|
||||
db.commit()
|
||||
llm = FakeRagLLM()
|
||||
summary = asyncio.run(import_sources([FIXTURES], llm, session=db))
|
||||
assert summary.added == 8 # A9 formats; .hidden/ skipped
|
||||
assert summary.added == 9 # A9 formats (phase 44 added tables.md); .hidden/ skipped
|
||||
yield llm
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
|
||||
db.commit()
|
||||
|
||||
Reference in New Issue
Block a user