feat(viewer): show document summary together with the original (TODO.md L5)
This commit is contained in:
@@ -98,6 +98,7 @@ def get_document_content(
|
|||||||
path=doc.path,
|
path=doc.path,
|
||||||
title=doc.title,
|
title=doc.title,
|
||||||
format=doc_format(doc.path),
|
format=doc_format(doc.path),
|
||||||
|
summary=doc.summary,
|
||||||
content=doc.content,
|
content=doc.content,
|
||||||
indexed_at=doc.indexed_at.isoformat(),
|
indexed_at=doc.indexed_at.isoformat(),
|
||||||
chunks=chunks,
|
chunks=chunks,
|
||||||
|
|||||||
@@ -105,6 +105,10 @@ class DocContent(BaseModel):
|
|||||||
path: str
|
path: str
|
||||||
title: str
|
title: str
|
||||||
format: str
|
format: str
|
||||||
|
#: Lite-model summary (phase 30) — non-markdown A9 docs only; None for
|
||||||
|
#: markdown documents, pre-phase-30 rows, and the fail-soft path where
|
||||||
|
#: summary generation failed but the document was still indexed.
|
||||||
|
summary: str | None = None
|
||||||
content: str
|
content: str
|
||||||
indexed_at: str
|
indexed_at: str
|
||||||
chunks: int
|
chunks: int
|
||||||
|
|||||||
@@ -12,7 +12,13 @@
|
|||||||
* in a ≤46rem centered column;
|
* in a ≤46rem centered column;
|
||||||
* • any other → the raw content as a text node inside
|
* • any other → the raw content as a text node inside
|
||||||
* <pre class="doc-raw"> (mono, horizontal
|
* <pre class="doc-raw"> (mono, horizontal
|
||||||
* scroll).
|
* scroll);
|
||||||
|
* • doc.summary non-empty (phase 36 — phase 30 summaries exist
|
||||||
|
* only on non-markdown docs) → a labeled
|
||||||
|
* .doc-summary section ABOVE the content;
|
||||||
|
* null / empty / whitespace renders nothing, so
|
||||||
|
* markdown docs and fail-soft rows are
|
||||||
|
* byte-for-byte unchanged.
|
||||||
*
|
*
|
||||||
* 2. The /document.html page itself: reads `source`/`path` query
|
* 2. The /document.html page itself: reads `source`/`path` query
|
||||||
* params, fetches the stateless content endpoint
|
* params, fetches the stateless content endpoint
|
||||||
@@ -40,6 +46,11 @@
|
|||||||
* around the page block below). Importing renderDocument elsewhere has
|
* around the page block below). Importing renderDocument elsewhere has
|
||||||
* no side effects: no back-link resolution, no whoami, no content
|
* no side effects: no back-link resolution, no whoami, no content
|
||||||
* fetch, no New Chat binding.
|
* fetch, no New Chat binding.
|
||||||
|
*
|
||||||
|
* Phase 36: the renderer owns the optional summary panel — a non-empty
|
||||||
|
* doc.summary renders as a labeled .doc-summary section above the
|
||||||
|
* original content on BOTH surfaces (page + modal) through this one
|
||||||
|
* core; the summary text is a text node (XSS contract unchanged).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { fetchIsAdmin, initSharedHeader } from "./header.js";
|
import { fetchIsAdmin, initSharedHeader } from "./header.js";
|
||||||
@@ -66,10 +77,13 @@ function metaBadge(cls, text) {
|
|||||||
/* ---------- shared renderer (phase 26, task 02) ----------
|
/* ---------- shared renderer (phase 26, task 02) ----------
|
||||||
* Populates the three elements every render surface provides: a title,
|
* Populates the three elements every render surface provides: a title,
|
||||||
* a .doc-meta badge row (source · format · mono path · indexed ·
|
* a .doc-meta badge row (source · format · mono path · indexed ·
|
||||||
* chunks), and a content container — .doc-md for md/markdown (the
|
* chunks), and a content container — an optional .doc-summary section
|
||||||
* shared escape-first renderer), <pre class="doc-raw"> otherwise. The
|
* first (phase 36: only when doc.summary is non-empty — markdown docs
|
||||||
* XSS contract: innerHTML only through renderMarkdown; every
|
* and fail-soft rows carry none, so they render exactly as before),
|
||||||
* document-derived string is a text node. */
|
* then .doc-md for md/markdown (the shared escape-first renderer),
|
||||||
|
* <pre class="doc-raw"> otherwise. The XSS contract: innerHTML only
|
||||||
|
* through renderMarkdown; every document-derived string (summary text
|
||||||
|
* included) is a text node. */
|
||||||
export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
|
export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
|
||||||
titleEl.textContent = doc.title;
|
titleEl.textContent = doc.title;
|
||||||
// Phase 34 task 04: the titlebar title ellipsizes — the full title
|
// Phase 34 task 04: the titlebar title ellipsizes — the full title
|
||||||
@@ -88,6 +102,24 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
contentEl.replaceChildren();
|
contentEl.replaceChildren();
|
||||||
|
// Phase 36: the summary panel — labeled section ABOVE the original
|
||||||
|
// content, on BOTH surfaces (page + modal) through this one core.
|
||||||
|
// Only a non-empty summary renders: markdown docs carry none (phase
|
||||||
|
// 30) and the fail-soft path leaves summary NULL, so both are
|
||||||
|
// byte-for-byte unchanged here.
|
||||||
|
if (doc.summary && doc.summary.trim() !== "") {
|
||||||
|
const section = document.createElement("section");
|
||||||
|
section.className = "doc-summary";
|
||||||
|
section.setAttribute("aria-label", "Summary");
|
||||||
|
const title = document.createElement("h2");
|
||||||
|
title.className = "doc-summary-title";
|
||||||
|
title.textContent = "Summary";
|
||||||
|
const body = document.createElement("p");
|
||||||
|
body.className = "doc-summary-text";
|
||||||
|
body.textContent = doc.summary; // text node — XSS contract unchanged
|
||||||
|
section.append(title, body);
|
||||||
|
contentEl.appendChild(section);
|
||||||
|
}
|
||||||
if (doc.format === "md" || doc.format === "markdown") {
|
if (doc.format === "md" || doc.format === "markdown") {
|
||||||
const wrap = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
wrap.className = "doc-md";
|
wrap.className = "doc-md";
|
||||||
|
|||||||
@@ -1675,6 +1675,45 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
.doc-md code { font-family: var(--mono); font-size: 0.88em; background: var(--brand-soft); padding: 0.08em 0.35em; border-radius: 5px; }
|
.doc-md code { font-family: var(--mono); font-size: 0.88em; background: var(--brand-soft); padding: 0.08em 0.35em; border-radius: 5px; }
|
||||||
.doc-md pre code { background: none; padding: 0; }
|
.doc-md pre code { background: none; padding: 0; }
|
||||||
|
|
||||||
|
/* Summary panel (phase 36): the labeled "Summary" section the shared
|
||||||
|
renderDocument core (document.js) draws ABOVE the original content
|
||||||
|
whenever doc.summary is non-empty (phase 30 — non-markdown docs
|
||||||
|
only). A "summary, not content" look: the surface card carries a 3px
|
||||||
|
brand left border (no shadow — the content cards own those) and a
|
||||||
|
small-caps brand-ink label. Phase-08 tokens only; static content —
|
||||||
|
no animation (nothing for prefers-reduced-motion to still), and the
|
||||||
|
aria-label + heading carry the accessibility. */
|
||||||
|
.doc-summary {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-left: 3px solid var(--brand);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.9rem 1.1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
/* md/markdown: the panel matches the .doc-md ≤46rem centered reading
|
||||||
|
column — it is the column's label. Raw formats stay full width (the
|
||||||
|
.doc-raw default above), matching the full-width pre; in engines
|
||||||
|
without :has() the panel degrades to that full-width default. */
|
||||||
|
.doc-summary:has(+ .doc-md) {
|
||||||
|
max-width: 46rem;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
.doc-summary-title {
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--brand-ink); /* #a5b4fc on --surface ≈8.7:1 */
|
||||||
|
}
|
||||||
|
.doc-summary-text {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink); /* on --surface ≈14.5:1 */
|
||||||
|
}
|
||||||
|
|
||||||
/* Raw (non-markdown) formats: full-width mono pre, horizontal scroll. */
|
/* Raw (non-markdown) formats: full-width mono pre, horizontal scroll. */
|
||||||
.doc-raw {
|
.doc-raw {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -2011,6 +2050,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
#doc-title { font-size: 1.1rem; }
|
#doc-title { font-size: 1.1rem; }
|
||||||
.doc-path { max-width: 16rem; }
|
.doc-path { max-width: 16rem; }
|
||||||
.doc-md { padding: 1.1rem 1rem; }
|
.doc-md { padding: 1.1rem 1rem; }
|
||||||
|
.doc-summary { padding: 0.75rem 0.9rem; }
|
||||||
.doc-raw { padding: 1rem; font-size: 0.8rem; }
|
.doc-raw { padding: 1rem; font-size: 0.8rem; }
|
||||||
/* Phase 26: the modal bar squeezes like the other bars — the Full page
|
/* Phase 26: the modal bar squeezes like the other bars — the Full page
|
||||||
pill goes icon-only (aria-label keeps the name), the title clips;
|
pill goes icon-only (aria-label keeps the name), the title clips;
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
"""Phase 36 E2E (Playwright): the summary and the original document
|
||||||
|
are visible **together** in the viewer.
|
||||||
|
|
||||||
|
Story: ``.agent/user_stories/summary-in-viewer.md``
|
||||||
|
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||||
|
|
||||||
|
uv run pytest tests/e2e/test_summary_in_viewer.py -v --no-cov
|
||||||
|
|
||||||
|
The fixture KB is the story-dedicated ``tests/fixtures/summary_kb/``
|
||||||
|
directory (the same one the phase-30 suite imports — its machinery is
|
||||||
|
reused here as closely as possible):
|
||||||
|
|
||||||
|
* ``quadlet/qwen-llamacpp.yaml`` — a non-markdown A9 doc. At import the
|
||||||
|
mock ``lite`` model (``SUMMARY_MODE`` marker, ``tests/e2e/mock_llm.py``)
|
||||||
|
reduces it to a deterministic 24-token digest, stored on
|
||||||
|
``documents.summary``. The sentinel ``RESE-SUMMARY-SENTINEL-7f3a`` sits
|
||||||
|
on the document's LAST line — **outside** the 24-token digest — so it
|
||||||
|
is a marker for "the original, not the summary".
|
||||||
|
* ``notes/qwen-llamacpp-notes.md`` — a markdown control doc, never
|
||||||
|
summarized (phase 30 scope): the viewer must render it exactly as
|
||||||
|
before, with no Summary panel.
|
||||||
|
|
||||||
|
This phase only surfaces the stored field: the content endpoint returns
|
||||||
|
``summary`` (task 01) and the shared ``renderDocument`` core draws the
|
||||||
|
labeled ``.doc-summary`` panel above the content on BOTH surfaces (task
|
||||||
|
02) — the full-page viewer and the chat/sources modal. The tests assert
|
||||||
|
exactly that contract, plus the phase-16 soft rule: the content
|
||||||
|
endpoint stays public (anonymous fetch → 200, no admin cookie needed).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Thread
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from playwright.sync_api import Page, expect
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.rag.importer import ImportSummary, import_sources
|
||||||
|
from app.rag.llm import LLMClient
|
||||||
|
from e2e.auth_helpers import login
|
||||||
|
from tests.e2e.mock_llm import TOKEN_RE
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
|
FIXTURES = REPO / "tests" / "fixtures" / "summary_kb"
|
||||||
|
|
||||||
|
SENTINEL = "RESE-SUMMARY-SENTINEL-7f3a"
|
||||||
|
SOURCE = "summary_kb"
|
||||||
|
YAML_PATH = "quadlet/qwen-llamacpp.yaml"
|
||||||
|
MD_PATH = "notes/qwen-llamacpp-notes.md"
|
||||||
|
|
||||||
|
#: Encoded viewer URL query values (slashes come out as %2F, same as the
|
||||||
|
#: chips / Sources table links build them).
|
||||||
|
YAML_URL_PATH = "quadlet%2Fqwen-llamacpp.yaml"
|
||||||
|
MD_URL_PATH = "notes%2Fqwen-llamacpp-notes.md"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Importer + thread helpers (test_document_summaries.py pattern) ---
|
||||||
|
|
||||||
|
|
||||||
|
async def _import_fixtures(mock_port: int) -> ImportSummary:
|
||||||
|
kwargs: dict[str, Any] = {
|
||||||
|
"_env_file": None,
|
||||||
|
"llm_base_url": f"http://127.0.0.1:{mock_port}/v1",
|
||||||
|
}
|
||||||
|
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
|
||||||
|
return await import_sources([FIXTURES], LLMClient(settings))
|
||||||
|
|
||||||
|
|
||||||
|
def _run_in_thread(coro: Any) -> Any:
|
||||||
|
"""Run a coroutine on a worker thread.
|
||||||
|
|
||||||
|
Playwright's sync API keeps an asyncio loop running on the test thread,
|
||||||
|
so ``asyncio.run`` cannot be called directly from a test body.
|
||||||
|
"""
|
||||||
|
box: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def runner() -> None:
|
||||||
|
try:
|
||||||
|
box["value"] = asyncio.run(coro)
|
||||||
|
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
|
||||||
|
box["error"] = e
|
||||||
|
|
||||||
|
t = Thread(target=runner)
|
||||||
|
t.start()
|
||||||
|
t.join()
|
||||||
|
if "error" in box:
|
||||||
|
raise box["error"]
|
||||||
|
return box["value"]
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_db_and_import(mock_llm: int) -> ImportSummary:
|
||||||
|
"""Truncate the KB (and query log + steering) and re-import the
|
||||||
|
summary_kb fixtures — yaml summarized, md control not."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
|
||||||
|
db.commit()
|
||||||
|
summary = _run_in_thread(_import_fixtures(mock_llm))
|
||||||
|
assert summary.added == 2 # yaml + md control
|
||||||
|
assert summary.summaries == 1 and summary.summary_errors == 0
|
||||||
|
assert summary.errors == 0
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_summary(content: str, source: str, path: str) -> str:
|
||||||
|
"""The mock lite model's byte-stable digest + the code pointer line.
|
||||||
|
|
||||||
|
Mirrors ``mock_llm.compose_answer``'s ``SUMMARY_MODE`` branch (first
|
||||||
|
24 tokens of the document content) plus the summarizer's
|
||||||
|
deterministic ``Source:`` line — no model output is ever trusted.
|
||||||
|
"""
|
||||||
|
digest = " ".join(TOKEN_RE.findall(content.lower())[:24])
|
||||||
|
return f"This document covers {digest}.\nSource: {source}/{path}"
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_lines(source: str, path: str) -> tuple[str, str]:
|
||||||
|
"""(digest line, pointer line) of the stored summary for a fixture."""
|
||||||
|
expected = _expected_summary(
|
||||||
|
(FIXTURES / path).read_text(encoding="utf-8"), source, path
|
||||||
|
)
|
||||||
|
digest_line, pointer_line = expected.split("\n", 1)
|
||||||
|
return digest_line, pointer_line
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Full page: labeled Summary panel ABOVE the original, both visible
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_page_shows_summary_and_original_together(
|
||||||
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""The dedicated viewer for a summarized yaml shows the labeled
|
||||||
|
Summary panel (deterministic mock digest) and, below it, the FULL
|
||||||
|
original content — its tail sentinel, which the 24-token digest
|
||||||
|
cannot contain, is rendered in the raw ``<pre>``."""
|
||||||
|
_reset_db_and_import(mock_llm)
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
|
||||||
|
digest_line, pointer_line = _summary_lines(SOURCE, YAML_PATH)
|
||||||
|
page.goto(f"{app_url}/document.html?source={SOURCE}&path={YAML_URL_PATH}")
|
||||||
|
|
||||||
|
# Title + meta row unchanged by the phase (badge row is untouched).
|
||||||
|
expect(page.locator("#doc-title")).to_have_text("qwen-llamacpp")
|
||||||
|
expect(page.locator("#doc-meta .doc-source-badge")).to_have_text(SOURCE)
|
||||||
|
expect(page.locator("#doc-meta .format-badge")).to_have_text("yaml")
|
||||||
|
expect(page.locator("#doc-meta .doc-path")).to_have_text(YAML_PATH)
|
||||||
|
|
||||||
|
# Exactly one labeled Summary section — a real <section aria-label>
|
||||||
|
# with a visible heading (UI Structure Check: labeled section).
|
||||||
|
panel = page.locator(".doc-summary")
|
||||||
|
expect(panel).to_have_count(1)
|
||||||
|
expect(panel).to_be_visible()
|
||||||
|
expect(panel).to_have_attribute("aria-label", "Summary")
|
||||||
|
expect(panel.locator(".doc-summary-title")).to_have_text("Summary")
|
||||||
|
|
||||||
|
# The panel carries the DETERMINISTIC digest (byte-stable, no real
|
||||||
|
# LLM) — digest line + code pointer line — and nothing from the raw
|
||||||
|
# body beyond the first 24 tokens ("healthcheck" is deeper in the
|
||||||
|
# file, so the panel cannot be the original content).
|
||||||
|
expect(panel).to_contain_text(digest_line)
|
||||||
|
expect(panel).to_contain_text(pointer_line)
|
||||||
|
expect(panel).not_to_contain_text("healthcheck")
|
||||||
|
|
||||||
|
# The ORIGINAL is fully visible: the raw yaml in its <pre>, including
|
||||||
|
# the tail sentinel that sits outside the digest.
|
||||||
|
pre = page.locator("#doc-content pre.doc-raw")
|
||||||
|
expect(pre).to_have_count(1)
|
||||||
|
expect(pre).to_contain_text(SENTINEL)
|
||||||
|
expect(pre).to_contain_text("healthcheck")
|
||||||
|
|
||||||
|
# The panel sits ABOVE the content: first child of #doc-content.
|
||||||
|
order = page.evaluate(
|
||||||
|
"() => [...document.querySelector('#doc-content').children]"
|
||||||
|
".map((el) => el.className)"
|
||||||
|
)
|
||||||
|
assert order[0] == "doc-summary", f"panel not first: {order}"
|
||||||
|
assert "doc-raw" in order and order.index("doc-raw") > order.index("doc-summary")
|
||||||
|
|
||||||
|
# Theme (phase 08): panel on the surface colour, title in brand-ink
|
||||||
|
# (#a5b4fc on #121a2e ≈8.7:1 ≥4.5:1), and the panel matches the
|
||||||
|
# raw-content width — it does not break the content layout.
|
||||||
|
bg = panel.evaluate("el => getComputedStyle(el).backgroundColor")
|
||||||
|
assert bg == "rgb(18, 26, 46)", f"panel not on the Phase-08 surface: {bg}"
|
||||||
|
title_color = panel.locator(".doc-summary-title").evaluate(
|
||||||
|
"el => getComputedStyle(el).color"
|
||||||
|
)
|
||||||
|
assert title_color == "rgb(165, 180, 252)", f"title not brand-ink: {title_color}"
|
||||||
|
panel_box = panel.bounding_box()
|
||||||
|
pre_box = pre.bounding_box()
|
||||||
|
assert panel_box is not None and pre_box is not None
|
||||||
|
assert abs(panel_box["width"] - pre_box["width"]) < 2, (
|
||||||
|
f"panel width {panel_box['width']} != raw width {pre_box['width']}"
|
||||||
|
)
|
||||||
|
assert panel_box["y"] < pre_box["y"], "panel must sit above the content"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Modal from the Sources table: same panel + content; "Full page"
|
||||||
|
# agrees (the two surfaces cannot drift — one shared renderer)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_modal_shows_panel_and_full_page_agrees(
|
||||||
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""Admin opens the yaml from the Sources table row: the SAME-PAGE
|
||||||
|
modal shows the Summary panel + the sentinel-bearing original; the
|
||||||
|
'Full page' escape hatch lands on the dedicated page with the panel
|
||||||
|
still there — both surfaces render through one shared core."""
|
||||||
|
_reset_db_and_import(mock_llm)
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
login(page, app_url) # phase 16: the Sources catalog is admin-only
|
||||||
|
|
||||||
|
digest_line, pointer_line = _summary_lines(SOURCE, YAML_PATH)
|
||||||
|
row = page.locator("#docs-tbody tr", has_text=YAML_PATH)
|
||||||
|
expect(row).to_have_count(1)
|
||||||
|
before = len(page.context.pages)
|
||||||
|
row.locator("td:nth-child(2) a.doc-link").click()
|
||||||
|
assert len(page.context.pages) == before, "row link must not open a new tab"
|
||||||
|
|
||||||
|
# The modal shows the panel + the original content, in order.
|
||||||
|
expect(page.locator(".doc-modal")).to_be_visible()
|
||||||
|
expect(page.locator("#doc-modal-title")).to_have_text("qwen-llamacpp")
|
||||||
|
modal_panel = page.locator("#doc-modal .doc-summary")
|
||||||
|
expect(modal_panel).to_have_count(1)
|
||||||
|
expect(modal_panel).to_be_visible()
|
||||||
|
expect(modal_panel).to_have_attribute("aria-label", "Summary")
|
||||||
|
expect(modal_panel).to_contain_text(digest_line)
|
||||||
|
expect(modal_panel).to_contain_text(pointer_line)
|
||||||
|
modal_pre = page.locator("#doc-modal pre.doc-raw")
|
||||||
|
expect(modal_pre).to_have_count(1)
|
||||||
|
expect(modal_pre).to_contain_text(SENTINEL)
|
||||||
|
order = page.evaluate(
|
||||||
|
"() => [...document.querySelector('#doc-modal-content').children]"
|
||||||
|
".map((el) => el.className)"
|
||||||
|
)
|
||||||
|
assert order[0] == "doc-summary", f"panel not first in the modal: {order}"
|
||||||
|
|
||||||
|
# "Full page" (the #doc-modal-open escape hatch — target=_blank by
|
||||||
|
# design) opens the dedicated viewer in a NEW TAB, and the panel is
|
||||||
|
# there with the same digest and the same sentinel: the surfaces
|
||||||
|
# agree (one shared renderer, no drift).
|
||||||
|
with page.expect_popup() as popup_info:
|
||||||
|
page.click("#doc-modal-open")
|
||||||
|
full_page = popup_info.value
|
||||||
|
expect(
|
||||||
|
full_page
|
||||||
|
).to_have_url(
|
||||||
|
re.compile(
|
||||||
|
r"^" + re.escape(f"{app_url}/document.html")
|
||||||
|
+ r"\?source=summary_kb&path=quadlet%2Fqwen-llamacpp\.yaml$"
|
||||||
|
),
|
||||||
|
timeout=30_000,
|
||||||
|
)
|
||||||
|
full_panel = full_page.locator(".doc-summary")
|
||||||
|
expect(full_panel).to_have_count(1)
|
||||||
|
expect(full_panel).to_be_visible()
|
||||||
|
expect(full_panel).to_contain_text(digest_line)
|
||||||
|
expect(full_panel).to_contain_text(pointer_line)
|
||||||
|
expect(full_page.locator("#doc-content pre.doc-raw")).to_contain_text(SENTINEL)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. No-summary control: the markdown doc renders exactly as before
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_markdown_doc_has_no_summary_panel(
|
||||||
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""Control doc (markdown — never summarized): NO .doc-summary
|
||||||
|
element on either surface, and the content renders exactly as
|
||||||
|
before (first child of the content container is the doc body)."""
|
||||||
|
_reset_db_and_import(mock_llm)
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
|
||||||
|
# Full page: no panel, markdown column untouched.
|
||||||
|
page.goto(f"{app_url}/document.html?source={SOURCE}&path={MD_URL_PATH}")
|
||||||
|
expect(page.locator(".doc-summary")).to_have_count(0)
|
||||||
|
expect(page.locator("#doc-content .doc-md")).to_have_count(1)
|
||||||
|
expect(page.locator("#doc-content")).to_contain_text(
|
||||||
|
"came out of a week of"
|
||||||
|
)
|
||||||
|
order = page.evaluate(
|
||||||
|
"() => [...document.querySelector('#doc-content').children]"
|
||||||
|
".map((el) => el.className)"
|
||||||
|
)
|
||||||
|
assert order == ["doc-md"], f"markdown doc gained children: {order}"
|
||||||
|
|
||||||
|
# Modal: same story — no panel, .doc-md is the sole content child.
|
||||||
|
login(page, app_url)
|
||||||
|
row = page.locator("#docs-tbody tr", has_text=MD_PATH)
|
||||||
|
expect(row).to_have_count(1)
|
||||||
|
row.locator("td:nth-child(2) a.doc-link").click()
|
||||||
|
expect(page.locator(".doc-modal")).to_be_visible()
|
||||||
|
expect(page.locator("#doc-modal .doc-summary")).to_have_count(0)
|
||||||
|
expect(page.locator("#doc-modal .doc-md")).to_have_count(1)
|
||||||
|
expect(page.locator("#doc-modal-content")).to_contain_text(
|
||||||
|
"came out of a week of"
|
||||||
|
)
|
||||||
|
order = page.evaluate(
|
||||||
|
"() => [...document.querySelector('#doc-modal-content').children]"
|
||||||
|
".map((el) => el.className)"
|
||||||
|
)
|
||||||
|
assert order == ["doc-md"], f"markdown modal gained children: {order}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. API shape (cheap, via the page context's request — still anonymous)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_api_summary_shape_anonymous(
|
||||||
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""``GET /api/documents/content`` carries ``summary`` — the string
|
||||||
|
for the summarized yaml, ``null`` for the markdown control — and
|
||||||
|
stays PUBLIC: no admin cookie is set anywhere in this test, so both
|
||||||
|
200s prove the phase-16 soft rule (viewer public) is unchanged."""
|
||||||
|
_reset_db_and_import(mock_llm)
|
||||||
|
|
||||||
|
digest_line, _ = _summary_lines(SOURCE, YAML_PATH)
|
||||||
|
expected_summary = f"{digest_line}\nSource: {SOURCE}/{YAML_PATH}"
|
||||||
|
|
||||||
|
resp = page.context.request.get(
|
||||||
|
f"{app_url}/api/documents/content?source={SOURCE}&path={YAML_URL_PATH}"
|
||||||
|
)
|
||||||
|
assert resp.status == 200, "anonymous viewer access must stay public"
|
||||||
|
body = resp.json()
|
||||||
|
assert body["source"] == SOURCE
|
||||||
|
assert body["path"] == YAML_PATH
|
||||||
|
assert body["format"] == "yaml"
|
||||||
|
assert isinstance(body["summary"], str)
|
||||||
|
assert body["summary"] == expected_summary
|
||||||
|
assert SENTINEL in body["content"] # full original, never just the digest
|
||||||
|
|
||||||
|
resp_md = page.context.request.get(
|
||||||
|
f"{app_url}/api/documents/content?source={SOURCE}&path={MD_URL_PATH}"
|
||||||
|
)
|
||||||
|
assert resp_md.status == 200, "anonymous viewer access must stay public"
|
||||||
|
md_body = resp_md.json()
|
||||||
|
assert md_body["format"] == "md"
|
||||||
|
assert md_body["summary"] is None, "markdown docs never carry a summary"
|
||||||
|
assert "came out of a week of" in md_body["content"]
|
||||||
@@ -161,10 +161,12 @@ def test_anonymous_document_content_stays_public(client: TestClient, db) -> None
|
|||||||
"path",
|
"path",
|
||||||
"title",
|
"title",
|
||||||
"format",
|
"format",
|
||||||
|
"summary", # nullable field added in phase 36 (null here — markdown)
|
||||||
"content",
|
"content",
|
||||||
"indexed_at",
|
"indexed_at",
|
||||||
"chunks",
|
"chunks",
|
||||||
}
|
}
|
||||||
|
assert body["summary"] is None
|
||||||
|
|
||||||
# Unknown docs still 404 anonymously (no enumeration of titles).
|
# Unknown docs still 404 anonymously (no enumeration of titles).
|
||||||
r = client.get("/api/documents/content", params={"source": "docs", "path": "nope.md"})
|
r = client.get("/api/documents/content", params={"source": "docs", "path": "nope.md"})
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
Uses the real compose Postgres (``db`` fixture) and FastAPI's TestClient:
|
Uses the real compose Postgres (``db`` fixture) and FastAPI's TestClient:
|
||||||
* 200 with the full field set for a seeded document (all formats);
|
* 200 with the full field set for a seeded document (all formats);
|
||||||
|
* ``summary`` surfaced for summarized docs, ``null`` for markdown (phase 36);
|
||||||
|
* anonymous access stays 200 (phase 16 soft rule — public viewer);
|
||||||
* 404 for an unknown (source, path) pair;
|
* 404 for an unknown (source, path) pair;
|
||||||
* 404 for traversal-style ``path`` values (no filesystem access → no leak).
|
* 404 for traversal-style ``path`` values (no filesystem access → no leak).
|
||||||
"""
|
"""
|
||||||
@@ -21,6 +23,7 @@ def _seed_doc(
|
|||||||
title: str = "Kubernetes Homelab Cluster",
|
title: str = "Kubernetes Homelab Cluster",
|
||||||
content: str = "# Kubernetes\n\nTalos on 3 nodes.",
|
content: str = "# Kubernetes\n\nTalos on 3 nodes.",
|
||||||
chunks: int = 2,
|
chunks: int = 2,
|
||||||
|
summary: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Truncate the KB and insert one document with ``chunks`` chunk rows."""
|
"""Truncate the KB and insert one document with ``chunks`` chunk rows."""
|
||||||
db.execute(text("TRUNCATE chunks, documents"))
|
db.execute(text("TRUNCATE chunks, documents"))
|
||||||
@@ -33,6 +36,7 @@ def _seed_doc(
|
|||||||
content=content,
|
content=content,
|
||||||
content_hash="a" * 64,
|
content_hash="a" * 64,
|
||||||
indexed_at=datetime.now(UTC),
|
indexed_at=datetime.now(UTC),
|
||||||
|
summary=summary,
|
||||||
)
|
)
|
||||||
db.add(doc)
|
db.add(doc)
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -53,11 +57,14 @@ def test_content_200_all_fields(client, db) -> None:
|
|||||||
)
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert set(body) == {"source", "path", "title", "format", "content", "indexed_at", "chunks"}
|
assert set(body) == {
|
||||||
|
"source", "path", "title", "format", "summary", "content", "indexed_at", "chunks"
|
||||||
|
}
|
||||||
assert body["source"] == "Homelab"
|
assert body["source"] == "Homelab"
|
||||||
assert body["path"] == "kubernetes.md"
|
assert body["path"] == "kubernetes.md"
|
||||||
assert body["title"] == "Kubernetes Homelab Cluster"
|
assert body["title"] == "Kubernetes Homelab Cluster"
|
||||||
assert body["format"] == "md"
|
assert body["format"] == "md"
|
||||||
|
assert body["summary"] is None # markdown doc → no summary (phase 36)
|
||||||
assert body["content"] == "# Kubernetes\n\nTalos on 3 nodes."
|
assert body["content"] == "# Kubernetes\n\nTalos on 3 nodes."
|
||||||
assert body["chunks"] == 2
|
assert body["chunks"] == 2
|
||||||
datetime.fromisoformat(body["indexed_at"]) # raises if not ISO-8601
|
datetime.fromisoformat(body["indexed_at"]) # raises if not ISO-8601
|
||||||
@@ -66,6 +73,62 @@ def test_content_200_all_fields(client, db) -> None:
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_summary_surfaced_for_summarized_doc(client, db) -> None:
|
||||||
|
"""A non-markdown document with a phase-30 summary returns it verbatim
|
||||||
|
(phase 36 — the viewer's data contract gains the nullable field).
|
||||||
|
|
||||||
|
Anonymous by design: the ``client`` fixture carries no admin cookie,
|
||||||
|
so the 200 here re-confirms the phase-16 soft rule (public viewer).
|
||||||
|
"""
|
||||||
|
summary = (
|
||||||
|
"GitLab CE runs in a Podman compose stack on the homelab NAS with a "
|
||||||
|
"persistent volume for data and a backup job."
|
||||||
|
)
|
||||||
|
_seed_doc(
|
||||||
|
db,
|
||||||
|
path="container_gitlab/gitlab-compose.yaml",
|
||||||
|
title="gitlab-compose",
|
||||||
|
content="services:\n gitlab:\n image: gitlab/gitlab-ce",
|
||||||
|
summary=summary,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
r = client.get(
|
||||||
|
"/api/documents/content",
|
||||||
|
params={"source": "Homelab", "path": "container_gitlab/gitlab-compose.yaml"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200 # anonymous (no cookie) — public viewer
|
||||||
|
body = r.json()
|
||||||
|
assert body["summary"] == summary # verbatim, no wrapping
|
||||||
|
assert body["content"] == "services:\n gitlab:\n image: gitlab/gitlab-ce"
|
||||||
|
finally:
|
||||||
|
db.execute(text("TRUNCATE chunks, documents"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_summary_null_for_markdown_doc(client, db) -> None:
|
||||||
|
"""Markdown documents carry no summary (phase 30) → JSON ``null``, and
|
||||||
|
anonymous access still returns 200 (phase 16 soft rule)."""
|
||||||
|
_seed_doc(
|
||||||
|
db,
|
||||||
|
path="kubernetes.md",
|
||||||
|
content="# Kubernetes\n\nTalos on 3 nodes.",
|
||||||
|
summary=None,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
r = client.get(
|
||||||
|
"/api/documents/content",
|
||||||
|
params={"source": "Homelab", "path": "kubernetes.md"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200 # anonymous (no cookie) — public viewer
|
||||||
|
body = r.json()
|
||||||
|
assert "summary" in body
|
||||||
|
assert body["summary"] is None
|
||||||
|
assert body["content"] == "# Kubernetes\n\nTalos on 3 nodes."
|
||||||
|
finally:
|
||||||
|
db.execute(text("TRUNCATE chunks, documents"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def test_content_404_unknown_path(client, db) -> None:
|
def test_content_404_unknown_path(client, db) -> None:
|
||||||
_seed_doc(db)
|
_seed_doc(db)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -143,11 +143,14 @@ def test_content_known_pair_maps_to_doc_content() -> None:
|
|||||||
)
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert set(body) == {"source", "path", "title", "format", "content", "indexed_at", "chunks"}
|
assert set(body) == {
|
||||||
|
"source", "path", "title", "format", "summary", "content", "indexed_at", "chunks"
|
||||||
|
}
|
||||||
assert body["source"] == "Homelab"
|
assert body["source"] == "Homelab"
|
||||||
assert body["path"] == "notes/deep mark.md"
|
assert body["path"] == "notes/deep mark.md"
|
||||||
assert body["title"] == "Deep Mark"
|
assert body["title"] == "Deep Mark"
|
||||||
assert body["format"] == "md"
|
assert body["format"] == "md"
|
||||||
|
assert body["summary"] is None # markdown doc → no summary (phase 36)
|
||||||
assert body["content"] == "# Deep Mark\n\nbody"
|
assert body["content"] == "# Deep Mark\n\nbody"
|
||||||
assert body["indexed_at"] == "2026-08-22T01:02:03+00:00"
|
assert body["indexed_at"] == "2026-08-22T01:02:03+00:00"
|
||||||
assert body["chunks"] == 3
|
assert body["chunks"] == 3
|
||||||
|
|||||||
Reference in New Issue
Block a user