diff --git a/app/api/docs.py b/app/api/docs.py index 717a8b2..3d12b75 100644 --- a/app/api/docs.py +++ b/app/api/docs.py @@ -4,6 +4,11 @@ GET /api/documents/content — one indexed document's full content (feeds the clickable document viewer, phase 10). DB-only by design: the (source, path) pair is looked up as a row, so there is no filesystem access and no path-traversal surface — ``../``-style values simply aren't rows (→ 404). + +PATCH /api/documents/summary — the admin summary editor (phase 57): +update or clear ``documents.summary`` and re-embed the ``is_summary`` +chunk (embed first, mutate second — a failed LLM call leaves the row and +chunk untouched; the content chunks are never re-embedded, D4). """ from __future__ import annotations @@ -13,10 +18,12 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import func, select from sqlalchemy.orm import Session +from app.api.sync import _sanitize_error from app.core.auth import require_admin from app.db import get_db from app.models import Chunk, Document -from app.schemas import DocContent, DocList, DocSummary +from app.rag.llm import EmbeddingError, LLMClient +from app.schemas import DocContent, DocList, DocSummary, SummaryResult, SummaryUpdate router = APIRouter(tags=["kb"]) @@ -103,3 +110,71 @@ def get_document_content( indexed_at=doc.indexed_at.isoformat(), chunks=chunks, ) + + +@router.patch("/documents/summary", response_model=SummaryResult) +async def update_document_summary( + payload: SummaryUpdate, + db: Session = Depends(get_db), # noqa: B008 + _admin: None = Depends(require_admin), # noqa: B008 +) -> SummaryResult: + """Update or clear a document's stored summary and re-embed it. + + Admin-only (phase 57, D4) — the document viewer itself stays + PUBLIC (phase 16 owner decision); only this edit affordance is + gated. The re-embed scope is the ``is_summary`` chunk only (D4): + the summary is the only text that changed, so the document's + content chunks keep their existing embeddings — the total chunk + count is unchanged by an update. + + Fail-before-write (phase 57 locked decision): when the stripped + text is non-empty it is embedded **before** any DB mutation — an + embedding failure returns 503 with a sanitized ``detail`` naming + the failure (the ``ModelUnavailableError`` handling of + ``app/api/git_sources.py``) and leaves the row and chunk untouched. + An empty/whitespace-only ``summary`` clears instead: + ``documents.summary = NULL`` and the ``is_summary`` chunk (if any) + is deleted. + """ + doc = db.scalar( + select(Document).where( + Document.source == payload.source, Document.path == payload.path + ) + ) + if doc is None: + raise HTTPException(status_code=404, detail="document not found") + + summary_chunk = db.scalar( + select(Chunk).where(Chunk.document_id == doc.id, Chunk.is_summary.is_(True)) + ) + text = payload.summary.strip() + if text: + # Embed first, mutate second — a failed LLM call must never + # leave a half-updated row (phase 57 locked decision). + llm = LLMClient() + try: + vector = (await llm.embed([text]))[0] + except EmbeddingError as e: + raise HTTPException(status_code=503, detail=_sanitize_error(str(e))) from None + if summary_chunk is None: + # Markdown doc, or a phase-30 fail-soft import that indexed + # without a summary chunk — create the position −1 chunk. + summary_chunk = Chunk(document_id=doc.id, position=-1, is_summary=True) + db.add(summary_chunk) + summary_chunk.content = text + summary_chunk.embedding = vector + doc.summary = text + else: + if summary_chunk is not None: + db.delete(summary_chunk) + doc.summary = None + db.commit() + chunks = db.scalar( + select(func.count(Chunk.id)) + .select_from(Document) + .outerjoin(Chunk, Chunk.document_id == Document.id) + .where(Document.id == doc.id) + ) or 0 + return SummaryResult( + source=doc.source, path=doc.path, summary=doc.summary, chunks=chunks + ) diff --git a/app/schemas.py b/app/schemas.py index 33418dd..62ea9c0 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -141,6 +141,40 @@ class DocContent(BaseModel): chunks: int +class SummaryUpdate(BaseModel): + """``PATCH /api/documents/summary`` body (phase 57, task 01). + + ``source`` / ``path`` name the indexed document (the same pair the + public ``GET /api/documents/content`` looks up); ``summary`` is the + raw new text. The API strips it before storing — an + empty/whitespace-only value is the *clear* operation (a first-class + action, phase 57 D4), not a 422. Unconstrained on purpose: unknown + pairs must 404 as "document not found" (row-lookup semantics), + exactly like the public content endpoint. + """ + + source: str + path: str + summary: str + + +class SummaryResult(BaseModel): + """``PATCH /api/documents/summary`` response (phase 57, task 01). + + ``summary`` is the stored text after the change (``null`` after a + clear — the viewer's summary box hides on null) and ``chunks`` the + document's post-change total chunk count: an update leaves the + content chunks untouched (the count is unchanged — only the single + ``is_summary`` chunk is replaced), a clear drops one (the + ``is_summary`` chunk is deleted). + """ + + source: str + path: str + summary: str | None + chunks: int + + class SteeringNoteIn(BaseModel): """``POST /api/steering`` body: one tuning instruction (phase 15). diff --git a/frontend/assets/document.js b/frontend/assets/document.js index 9bd12f1..7f5efa7 100644 --- a/frontend/assets/document.js +++ b/frontend/assets/document.js @@ -51,6 +51,15 @@ * 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). + * + * Phase 57 (task 02, D4): the panel gains an ADMIN-ONLY edit + * affordance (docAdminReady() gate on the module-cached whoami): an + * Edit button in the panel's header row opens an inline editor + * (prefilled textarea + Save/Cancel + a role=status live region), and + * Save PATCHes /api/documents/summary. The section is built for + * everyone exactly as phase 36 — the public viewer stays + * byte-for-byte identical (no button, no wiring, no admin-only + * network call) until the admin gate resolves true. */ import { fetchIsAdmin, initSharedHeader } from "./header.js"; @@ -125,6 +134,14 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) { body.textContent = doc.summary; // text node — XSS contract unchanged section.append(title, body); contentEl.appendChild(section); + // Phase 57 (task 02, D4): the section above is the phase-36 shape + // for EVERYONE — only an authenticated admin (docAdminReady(), the + // module-cached whoami promise) then gains the header row + Edit + // button + editor wiring. Anonymous / fetch failure: the panel is + // exactly what phase 36 built (byte-for-byte unchanged). + void docAdminReady().then((admin) => { + if (admin) wireSummaryEdit(section, doc); + }); } if (doc.format === "md" || doc.format === "markdown") { const wrap = document.createElement("div"); @@ -139,6 +156,146 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) { } } +/* ---------- summary editing (phase 57, task 02 — D4, admin-only) ---------- + * The .doc-summary panel is the ONE place the stored summary is edited + * (page + modal through this core). Only an admin (docAdminReady) ever + * gets the affordance; the public viewer is byte-for-byte unchanged. */ + +/* The admin gate (D4 — the viewer stays public): the module-cached + * /api/whoami promise (header.js's fetchIsAdmin — the SAME single + * request per page the shared header already makes on every surface, + * so this adds no request of its own). A non-admin or any fetch + * failure resolves false → the anonymous viewer. */ +async function docAdminReady() { + try { + return (await fetchIsAdmin()) === true; + } catch { + return false; + } +} + +/* The edit affordance on one rendered .doc-summary section. The bare + * h2 becomes a header row (label left, Edit button right). Edit swaps + * the .doc-summary-text node for the inline editor — a prefilled + * textarea (value, never innerHTML — XSS contract), Save / Cancel, + * and a role=status live region. Save PATCHes /api/documents/summary + * with { source, path, summary } — the pair comes from the doc object + * (the same values the modal core carries, document-modal.js). Success + * re-renders the text node via textContent and announces "Summary + * updated."; an empty save that clears announces "Summary cleared." + * and removes the panel a short beat later — the confirmation stays + * readable, and the renderer only draws the panel for non-empty + * summaries. Cancel restores the text node. A failure keeps the + * editor open with the user's text and shows neutral retry copy + * (phase-55 convention). */ +function wireSummaryEdit(section, doc) { + const title = section.querySelector(".doc-summary-title"); + const body = section.querySelector(".doc-summary-text"); + if (!title || !body) return; + + /* Header row: label left, Edit button right (admin-only — the + * anonymous section keeps its bare h2). */ + const head = document.createElement("div"); + head.className = "doc-summary-head"; + const editBtn = document.createElement("button"); + editBtn.type = "button"; + editBtn.className = "doc-summary-edit"; + editBtn.textContent = "Edit"; + head.append(title, editBtn); + section.replaceChildren(head, body); + + /* Editor parts (built once; the textarea is rebuilt on every open so + * it always starts from the CURRENT stored summary). */ + let editor = null; + const actions = document.createElement("div"); + actions.className = "doc-summary-actions"; + const saveBtn = document.createElement("button"); + saveBtn.type = "button"; + saveBtn.className = "doc-summary-save"; + saveBtn.textContent = "Save"; + const cancelBtn = document.createElement("button"); + cancelBtn.type = "button"; + cancelBtn.className = "doc-summary-cancel"; + cancelBtn.textContent = "Cancel"; + actions.append(saveBtn, cancelBtn); + const status = document.createElement("p"); + status.className = "doc-summary-status"; + status.setAttribute("role", "status"); + status.setAttribute("aria-live", "polite"); + + /* Back to the display state: the text node re-rendered from the + * doc object (the CURRENT stored summary), the live region (the + * announced message), the Edit button available again. If the + * summary is gone (a clear landed while the editor was open — e.g. + * Cancel right after a successful empty save) the panel is gone + * too: the renderer only draws it for non-empty summaries. */ + function closeEditor(message) { + status.textContent = message; + editBtn.hidden = false; + if (typeof doc.summary !== "string" || doc.summary.trim() === "") { + section.remove(); + return; + } + body.textContent = doc.summary; // text node — the CURRENT stored summary + section.replaceChildren(head, body, status); + editBtn.focus(); + } + + async function saveSummary() { + const value = editor.value; + saveBtn.disabled = true; // one PATCH at a time (never stale) + status.textContent = ""; + try { + const res = await fetch("/api/documents/summary", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ source: doc.source, path: doc.path, summary: value }), + }); + if (!res.ok) { + // Neutral retry copy (phase-55 convention) — the user's text + // stays in the editor (the editor stays open on failure). + status.textContent = "Couldn't update the summary — try again."; + return; + } + const data = await res.json(); + if (data.summary === null) { + // Cleared (D4): the panel disappears — the renderer only draws + // it for non-empty summaries. The live-region confirmation + // stays visible for a short beat before the panel leaves the + // DOM (a screen reader must be able to read it; the removal + // is a no-op if the surface re-rendered or closed meanwhile). + doc.summary = null; + status.textContent = "Summary cleared."; + setTimeout(() => section.remove(), 2000); + return; + } + doc.summary = data.summary; + closeEditor("Summary updated."); + } catch { + // Network failure: same neutral shape, the reachable? copy. + status.textContent = "Couldn't update the summary — is the app reachable?"; + } finally { + saveBtn.disabled = false; + } + } + + function openEditor() { + editor = document.createElement("textarea"); + editor.className = "doc-summary-editor"; + editor.value = typeof doc.summary === "string" ? doc.summary : ""; // value, never innerHTML + status.textContent = ""; + editBtn.hidden = true; + section.replaceChildren(head, editor, actions, status); + editor.focus(); + } + + editBtn.addEventListener("click", openEditor); + saveBtn.addEventListener("click", () => { + void saveSummary(); + }); + cancelBtn.addEventListener("click", () => closeEditor("")); +} + /* ---------- /document.html page (phases 10/13/19) ---------- * Phase 26: viewer-page-specific — see the import-safety note in the * header. The guard is #doc-title: it exists only on this page, so the diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index 62a339a..42e3af9 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -2382,6 +2382,97 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } color: var(--ink); /* on --surface ≈14.5:1 */ } +/* Summary edit affordance (phase 57, task 02 — D4, admin-only): the + header row (label + Edit button), the inline editor (prefilled + textarea + Save/Cancel + a role=status live region). House + dark-tech palette (phase-08 tokens), system fonts, no CDN; + :focus-visible via the global 3px outline rule. Anonymous visitors + never see any of it — the button and editor are wired only for + admins (docAdminReady in document.js), so the public panel is + byte-for-byte the phase-36 shape. */ +.doc-summary-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.4rem; /* the old .doc-summary-title bottom margin */ +} +.doc-summary-head .doc-summary-title { margin: 0; } +.doc-summary-edit { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0.15rem 0.7rem; + border: 1px solid var(--line); + border-radius: 999px; + background: transparent; + color: var(--ink-soft); /* 5.1:1 on --surface (AA) */ + font: inherit; + font-weight: 600; + font-size: 0.78rem; + letter-spacing: 0.02em; + cursor: pointer; +} +.doc-summary-edit:hover { background: var(--brand-soft); color: var(--brand-ink); border-color: var(--brand); } +.doc-summary-edit[hidden] { display: none; } /* the hidden attr must beat the display above */ +.doc-summary-editor { + display: block; + width: 100%; + min-height: 8rem; + padding: 0.6rem 0.8rem; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--bg); /* inset against the --surface panel */ + color: var(--ink); /* 16.7:1 on --bg (AA) */ + font: inherit; + line-height: 1.5; + resize: vertical; +} +.doc-summary-actions { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.75rem; +} +.doc-summary-save { + display: inline-flex; + align-items: center; + min-height: 32px; + padding: 0.35rem 0.95rem; + border: 0; + border-radius: 999px; + background: var(--brand); + color: var(--bg); /* --bg on --brand = 5.2:1 (AA) */ + font: inherit; + font-weight: 600; + font-size: 0.85rem; + cursor: pointer; +} +.doc-summary-save:hover { background: #f55a72; } /* the house hover lightening */ +.doc-summary-save:disabled { opacity: 0.6; cursor: default; } /* one PATCH at a time */ +.doc-summary-cancel { + display: inline-flex; + align-items: center; + min-height: 32px; + padding: 0.35rem 0.95rem; + border: 1px solid var(--line); + border-radius: 999px; + background: transparent; + color: var(--ink-soft); /* 5.1:1 on --surface (AA) */ + font: inherit; + font-weight: 600; + font-size: 0.85rem; + cursor: pointer; +} +.doc-summary-cancel:hover { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); } +.doc-summary-status { + margin: 0.6rem 0 0; + font-size: 0.85rem; + color: var(--ink-soft); /* 5.1:1 on --surface (AA) */ +} +.doc-summary-status:empty { margin-top: 0; } + /* Raw (non-markdown) formats: full-width mono pre, horizontal scroll. */ .doc-raw { width: 100%; diff --git a/tests/e2e/test_edit_summaries.py b/tests/e2e/test_edit_summaries.py new file mode 100644 index 0000000..63ccd6e --- /dev/null +++ b/tests/e2e/test_edit_summaries.py @@ -0,0 +1,430 @@ +"""Phase 57 E2E (Playwright): edit the AI-generated summary in the +viewer — and watch it get re-embedded. + +TODO.md L4: "Be able to edit the summaries for documents in the RAG. +Click an edit button in summary box and change the summary that the AI +created. re-embed that document after changing the summary." +Run in isolation (DB must be up: ``podman compose up -d db``): + + uv run pytest tests/e2e/test_edit_summaries.py -v --no-cov + +The fixture KB is a story-dedicated directory +(``tests/fixtures/summary_edit_kb/`` — the shared ``tests/fixtures/docs/`` +and the phase-30 ``summary_kb/`` stay pinned at their files) with ONE +non-markdown A9 document: + +* ``quadlet/llamacpp.container`` — a podman quadlet unit (the + ``container`` extension is in the A9 default family, so the DEFAULT + import scope walks it — no ``BOR_IMPORT_EXTENSIONS`` override). At + import the mock ``lite`` model (``SUMMARY_MODE`` marker, + ``tests/e2e/mock_llm.py``) reduces it to the deterministic 24-token + digest — the first 24 tokens of the file, the header comment line — + stored on ``documents.summary`` and indexed as one ``is_summary`` + chunk. The rest of the file is deliberately token-diluted + (config keys the digest never contains), and the sentinel + ``RESE-EDIT-SUMMARY-SENTINEL-b41d`` sits on the document's LAST line — + outside the 24-token digest — so the raw ``
`` content is
+ distinguishable from the stored summary (the digest/sentinel mechanic
+ of ``tests/e2e/test_document_summaries.py``).
+
+DB isolation: the fixture's source name (``summary_edit_kb``) is
+distinctive — the suite never asserts on absolute row counts and
+deletes the rows it creates in a ``finally`` (other suites' documents
+stay untouched in the shared E2E database).
+
+The admin edits through the REAL browser flow (form login → the
+viewer's Edit button → the inline editor → Save); the re-embed itself
+is then verified against the live database (the chunk's NEW content, a
+FRESH non-NULL vector, the content chunks byte-for-byte untouched — the
+D4 re-embed scope) and against the public content endpoint (no cookie —
+the viewer stays public, phase 16).
+"""
+from __future__ import annotations
+
+import asyncio
+from collections.abc import Iterator
+from pathlib import Path
+from threading import Thread
+from typing import Any
+
+import httpx
+import pytest
+from playwright.sync_api import Page, expect
+from sqlalchemy import select
+
+from app.config import Settings
+from app.db import SessionLocal
+from app.models import Chunk, Document
+from app.rag.importer import ImportSummary, import_sources
+from app.rag.llm import LLMClient
+from e2e.auth_helpers import login
+from e2e.mock_llm import TOKEN_RE
+
+REPO = Path(__file__).resolve().parents[2]
+FIXTURES = REPO / "tests" / "fixtures" / "summary_edit_kb"
+SOURCE = FIXTURES.name # "summary_edit_kb" — distinctive, never asserted by count
+DOC_PATH = "quadlet/llamacpp.container"
+#: Encoded viewer URL query value (slash → %2F, same as the chips /
+#: Sources table links build it).
+DOC_URL_PATH = "quadlet%2Fllamacpp.container"
+SENTINEL = "RESE-EDIT-SUMMARY-SENTINEL-b41d"
+
+#: The hand-edited summary (test 1) — a distinctive sentence no part of
+#: the fixture or its digest contains, so the round-trip assertion can
+#: never pass against the old text.
+NEW_SUMMARY = (
+ "Hand-edited: the quadlet unit serves the homelab's local model on "
+ "port 8081. (RESE-HANDEDIT-77ab)"
+)
+#: The modal-surface test's own edit (test 4) — likewise distinctive.
+MODAL_SUMMARY = (
+ "Edited from the modal: quadlet unit for the local inference server. "
+ "(RESE-MODAL-EDIT-3cd9)"
+)
+
+
+# --- 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 _delete_source_rows() -> None:
+ """Delete every row of this suite's distinctive source (chunks
+ cascade with the document rows)."""
+ with SessionLocal() as db:
+ for doc in db.scalars(select(Document).where(Document.source == SOURCE)).all():
+ db.delete(doc)
+ db.commit()
+
+
+@pytest.fixture(autouse=True)
+def seeded_kb(mock_llm: int, db_ready: None) -> Iterator[ImportSummary]:
+ """Seed the story-dedicated fixture for one test (the DEFAULT import
+ scope — ``container`` is A9) and delete every row it creates
+ afterwards (DB isolation — see the module docstring)."""
+ _delete_source_rows() # idempotent: leftovers from a crashed run
+ summary = _run_in_thread(_import_fixtures(mock_llm))
+ assert summary.formats == {"container": 1}
+ assert summary.added == 1
+ assert summary.summaries == 1 and summary.summary_errors == 0
+ assert summary.errors == 0
+ try:
+ yield summary
+ finally:
+ _delete_source_rows()
+
+
+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 _chunk_state() -> dict[str, Any]:
+ """The fixture document's DB state, read back through a fresh session.
+
+ ``total`` — the document's chunk count (never a table-wide count —
+ DB isolation); ``summary`` — ``documents.summary``; ``summary_*`` —
+ the single ``is_summary`` chunk (content + vector read-back);
+ ``raw_contents`` — the content chunks' text, sorted (the D4 pin:
+ the content chunks are byte-for-byte untouched by a summary edit).
+ """
+ with SessionLocal() as db:
+ doc = db.scalar(
+ select(Document).where(Document.source == SOURCE, Document.path == DOC_PATH)
+ )
+ assert doc is not None, "fixture doc was not imported"
+ chunks = db.scalars(select(Chunk).where(Chunk.document_id == doc.id)).all()
+ summary_chunks = [c for c in chunks if c.is_summary]
+ assert len(summary_chunks) <= 1, f"more than one is_summary chunk: {len(summary_chunks)}"
+ sc = summary_chunks[0] if summary_chunks else None
+ return {
+ "total": len(chunks),
+ "summary": doc.summary,
+ "summary_count": len(summary_chunks),
+ "summary_content": sc.content if sc else None,
+ "summary_vec": list(sc.embedding) if sc and sc.embedding is not None else None,
+ "raw_contents": sorted(c.content for c in chunks if not c.is_summary),
+ }
+
+
+def _api_summary(app_url: str) -> str | None:
+ """The public content endpoint's ``summary`` — NO cookie (the viewer
+ stays public, phase 16; a fresh httpx client carries no session)."""
+ r = httpx.get(
+ f"{app_url}/api/documents/content",
+ params={"source": SOURCE, "path": DOC_PATH},
+ timeout=10,
+ )
+ assert r.status_code == 200, r.text
+ return r.json()["summary"]
+
+
+def _doc_url(app_url: str) -> str:
+ return f"{app_url}/document.html?source={SOURCE}&path={DOC_URL_PATH}"
+
+
+# ---------------------------------------------------------------------------
+# 1. Admin: edit → Save → the panel, the API, and the DB all agree —
+# and the is_summary chunk carries a FRESH embedding (D4 re-embed)
+# ---------------------------------------------------------------------------
+
+
+def test_admin_edits_summary(page: Page, app_url: str) -> None:
+ page.set_default_timeout(30_000)
+ content = (FIXTURES / DOC_PATH).read_text(encoding="utf-8")
+ assert SENTINEL in content.splitlines()[-1] # last line, by design
+ expected = _expected_summary(content, SOURCE, DOC_PATH)
+ digest_line, pointer_line = expected.split("\n", 1)
+
+ before = _chunk_state()
+ assert before["summary"] == expected # the mock digest is the stored summary
+ assert before["summary_count"] == 1
+ assert before["summary_vec"] is not None
+
+ login(page, app_url)
+ page.goto(_doc_url(app_url))
+
+ # The .doc-summary panel shows the digest (digest + pointer lines) —
+ # and nothing from the diluted raw body ("PublishPort" is deeper in
+ # the file, outside the 24-token digest).
+ panel = page.locator(".doc-summary")
+ expect(panel).to_have_count(1)
+ expect(panel).to_be_visible()
+ expect(panel.locator(".doc-summary-title")).to_have_text("Summary")
+ expect(panel).to_contain_text(digest_line)
+ expect(panel).to_contain_text(pointer_line)
+ expect(panel).not_to_contain_text("PublishPort")
+ # The original still renders below, sentinel and all.
+ expect(page.locator("#doc-content pre.doc-raw")).to_contain_text(SENTINEL)
+
+ # The admin-only Edit button (phase 57, D4 — the viewer itself is
+ # public; only the affordance is gated).
+ edit = panel.locator(".doc-summary-edit")
+ expect(edit).to_be_visible()
+ expect(edit).to_have_text("Edit")
+
+ # Edit → inline editor: textarea PREFILLED with the current summary,
+ # Save / Cancel, and the role=status live region.
+ edit.click()
+ editor = page.locator(".doc-summary-editor")
+ expect(editor).to_be_visible()
+ expect(editor).to_have_value(expected)
+ expect(page.locator(".doc-summary-save")).to_be_visible()
+ expect(page.locator(".doc-summary-cancel")).to_be_visible()
+ status = page.locator(".doc-summary-status")
+ expect(status).to_have_attribute("role", "status")
+ expect(status).to_have_attribute("aria-live", "polite")
+
+ # Replace the text with the distinctive hand-edit and Save.
+ page.fill(".doc-summary-editor", NEW_SUMMARY)
+ page.click(".doc-summary-save")
+
+ # Live-region confirmation, and the panel text is the NEW summary
+ # (re-rendered through the textContent contract — the digest is gone).
+ expect(status).to_have_text("Summary updated.")
+ expect(panel.locator(".doc-summary-text")).to_have_text(NEW_SUMMARY)
+ expect(panel).not_to_contain_text(digest_line)
+
+ # Public read (no cookie): the content endpoint serves the new text.
+ assert _api_summary(app_url) == NEW_SUMMARY
+
+ # The re-embed, verified in the DB (D4): the is_summary chunk's
+ # content is the new text with a FRESH non-NULL vector; the total
+ # chunk count is unchanged and the content chunks are byte-for-byte
+ # untouched — only the summary changed, so only it was re-embedded.
+ after = _chunk_state()
+ assert after["total"] == before["total"] # count unchanged by an update
+ assert after["summary_count"] == 1
+ assert after["summary"] == NEW_SUMMARY
+ assert after["summary_content"] == NEW_SUMMARY
+ assert after["summary_vec"] is not None # re-embedded, non-NULL
+ assert after["summary_vec"] != before["summary_vec"] # a FRESH vector
+ assert after["raw_contents"] == before["raw_contents"] # content chunks untouched
+
+
+# ---------------------------------------------------------------------------
+# 2. Admin: an empty save CLEARS — the panel disappears, summary NULL,
+# the is_summary chunk row is gone (count −1)
+# ---------------------------------------------------------------------------
+
+
+def test_admin_clears_summary(page: Page, app_url: str) -> None:
+ page.set_default_timeout(30_000)
+ content = (FIXTURES / DOC_PATH).read_text(encoding="utf-8")
+ expected = _expected_summary(content, SOURCE, DOC_PATH)
+
+ before = _chunk_state()
+ assert before["summary"] == expected
+ assert before["summary_count"] == 1
+
+ login(page, app_url)
+ page.goto(_doc_url(app_url))
+ panel = page.locator(".doc-summary")
+ expect(panel).to_have_count(1)
+ expect(panel.locator(".doc-summary-edit")).to_be_visible()
+
+ # Select-all + delete — clear the prefilled editor — then Save.
+ panel.locator(".doc-summary-edit").click()
+ page.fill(".doc-summary-editor", "")
+ page.click(".doc-summary-save")
+
+ # "Summary cleared." in the live region, then the panel leaves the
+ # DOM (the renderer only draws it for non-empty summaries — the
+ # removal lands a short beat after the confirmation). The original
+ # content below is untouched.
+ expect(page.locator(".doc-summary-status")).to_have_text("Summary cleared.")
+ expect(page.locator(".doc-summary")).to_have_count(0, timeout=8_000)
+ expect(page.locator("#doc-content pre.doc-raw")).to_contain_text(SENTINEL)
+
+ # The public endpoint now reports no summary…
+ assert _api_summary(app_url) is None
+
+ # …and the DB agrees: summary NULL, the is_summary row deleted
+ # (count −1), the content chunks byte-for-byte untouched.
+ after = _chunk_state()
+ assert after["total"] == before["total"] - 1 # the is_summary chunk is gone
+ assert after["summary"] is None
+ assert after["summary_count"] == 0
+ assert after["summary_vec"] is None
+ assert after["raw_contents"] == before["raw_contents"]
+
+
+# ---------------------------------------------------------------------------
+# 3. Anonymous: the digest renders, but no Edit button — and the
+# endpoint is 403 (the public viewer is byte-for-byte phase 36)
+# ---------------------------------------------------------------------------
+
+
+def test_anonymous_cannot(page: Page, app_url: str) -> None:
+ page.set_default_timeout(30_000)
+ content = (FIXTURES / DOC_PATH).read_text(encoding="utf-8")
+ expected = _expected_summary(content, SOURCE, DOC_PATH)
+ digest_line, _ = expected.split("\n", 1)
+
+ # Fresh context (the function-scoped page fixture — no login): the
+ # panel renders the digest, but the edit affordance is ABSENT — no
+ # button, no header row, no editor wiring. The section keeps the
+ # phase-36 byte-for-byte shape: a bare h2 + the text-node .
+ page.goto(_doc_url(app_url))
+ panel = page.locator(".doc-summary")
+ expect(panel).to_have_count(1)
+ expect(panel).to_be_visible()
+ expect(panel).to_contain_text(digest_line)
+ expect(page.locator(".doc-summary-edit")).to_have_count(0)
+ expect(page.locator(".doc-summary-head")).to_have_count(0)
+ expect(page.locator(".doc-summary-editor")).to_have_count(0)
+ children = page.evaluate(
+ "() => [...document.querySelector('.doc-summary').children]"
+ ".map((el) => el.className)"
+ )
+ assert children == ["doc-summary-title", "doc-summary-text"], (
+ f"anonymous panel drifted from the phase-36 shape: {children}"
+ )
+
+ # The endpoint is admin-gated (D4): an anonymous PATCH → 403
+ # "admin only" (a fresh httpx client carries no session), and the
+ # stored summary is untouched.
+ r = httpx.patch(
+ f"{app_url}/api/documents/summary",
+ json={"source": SOURCE, "path": DOC_PATH, "summary": "not allowed"},
+ timeout=10,
+ )
+ assert r.status_code == 403
+ assert r.json() == {"detail": "admin only"}
+ assert _chunk_state()["summary"] == expected # untouched
+
+
+# ---------------------------------------------------------------------------
+# 4. Modal surface: the SAME shared renderer — the Sources-page modal
+# carries the Edit button too, and a save from it round-trips
+# ---------------------------------------------------------------------------
+
+
+def test_admin_modal_surface_edit(page: Page, app_url: str) -> None:
+ """One core, two surfaces (phase 26/36): the document modal from the
+ Sources table renders through the SAME ``renderDocument`` — so the
+ admin gets the Edit button in the modal as well, and a save from
+ there hits the same endpoint + DB row (the page test is unchanged
+ in shape; this pins the second surface)."""
+ page.set_default_timeout(30_000)
+ content = (FIXTURES / DOC_PATH).read_text(encoding="utf-8")
+ expected = _expected_summary(content, SOURCE, DOC_PATH)
+ digest_line, _ = expected.split("\n", 1)
+
+ before = _chunk_state()
+ assert before["summary"] == expected
+
+ login(page, app_url) # lands on /sources.html (the catalog is admin-only)
+ row = page.locator("#docs-tbody tr", has_text=DOC_PATH)
+ expect(row).to_have_count(1)
+ before_tabs = len(page.context.pages)
+ row.locator("td:nth-child(2) a.doc-link").click()
+ assert len(page.context.pages) == before_tabs, "row link must not open a new tab"
+
+ # The modal shows the panel with the digest + the admin Edit button.
+ expect(page.locator(".doc-modal")).to_be_visible()
+ expect(page.locator("#doc-modal-title")).to_have_text("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_contain_text(digest_line)
+ edit = modal_panel.locator(".doc-summary-edit")
+ expect(edit).to_be_visible()
+
+ # Edit → prefilled editor → replace → Save → the modal's panel
+ # reflects the new text and the live region confirms.
+ edit.click()
+ editor = page.locator("#doc-modal .doc-summary-editor")
+ expect(editor).to_be_visible()
+ expect(editor).to_have_value(expected)
+ page.fill("#doc-modal .doc-summary-editor", MODAL_SUMMARY)
+ page.click("#doc-modal .doc-summary-save")
+ expect(page.locator("#doc-modal .doc-summary-status")).to_have_text("Summary updated.")
+ expect(modal_panel.locator(".doc-summary-text")).to_have_text(MODAL_SUMMARY)
+
+ # Same endpoint, same DB row: the public read and the chunk agree —
+ # count unchanged, fresh vector, content chunks untouched.
+ assert _api_summary(app_url) == MODAL_SUMMARY
+ after = _chunk_state()
+ assert after["total"] == before["total"]
+ assert after["summary_count"] == 1
+ assert after["summary"] == MODAL_SUMMARY
+ assert after["summary_content"] == MODAL_SUMMARY
+ assert after["summary_vec"] is not None
+ assert after["summary_vec"] != before["summary_vec"]
+ assert after["raw_contents"] == before["raw_contents"]
diff --git a/tests/fixtures/summary_edit_kb/quadlet/llamacpp.container b/tests/fixtures/summary_edit_kb/quadlet/llamacpp.container
new file mode 100644
index 0000000..a0a4459
--- /dev/null
+++ b/tests/fixtures/summary_edit_kb/quadlet/llamacpp.container
@@ -0,0 +1,22 @@
+# llama.cpp quadlet container — the homelab local LLM inference server
+[Unit]
+Description=llama.cpp server for local inference (qwen3-8b)
+Requires=llamacpp-network.net
+After=network-online.target
+
+[Container]
+Image=docker.io/ggml-org/llama-cpp:0.1.43
+ContainerName=llamacpp
+Network=llamacpp-network
+PublishPort=8081:8080
+Volume=/srv/llama/models:/models:ro
+Environment=CONTEXT_LENGTH=32768
+Environment=BATCH_SIZE=512
+Restart=always
+
+[Service]
+TimeoutStartSec=300
+
+[Install]
+WantedBy=default.target
+# RESE-EDIT-SUMMARY-SENTINEL-b41d — phase 57 fixture marker: last line, outside the 24-token digest.
diff --git a/tests/integration/test_document_content.py b/tests/integration/test_document_content.py
index dbf1691..687ebe3 100644
--- a/tests/integration/test_document_content.py
+++ b/tests/integration/test_document_content.py
@@ -1,19 +1,40 @@
-"""Integration tests: GET /api/documents/content — the viewer's data source.
+"""Integration tests: the document-content API — the viewer's data source.
Uses the real compose Postgres (``db`` fixture) and FastAPI's TestClient:
-* 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 traversal-style ``path`` values (no filesystem access → no leak).
+* GET: 200 with the full field set for a seeded document (all formats);
+* GET: ``summary`` surfaced for summarized docs, ``null`` for markdown
+ (phase 36);
+* GET: anonymous access stays 200 (phase 16 soft rule — public viewer);
+* GET: 404 for an unknown (source, path) pair;
+* GET: 404 for traversal-style ``path`` values (no filesystem access → no
+ leak).
+
+PATCH /api/documents/summary (phase 57, task 01) — the admin summary
+editor, on the same DB-backed fixtures:
+* update: ``documents.summary`` + the ``is_summary`` chunk's content
+ replaced, fresh embedding, content chunks untouched (count unchanged —
+ D4 re-embed scope);
+* clear: empty/whitespace text → ``summary`` NULL + ``is_summary`` chunk
+ deleted (idempotent, no embed call);
+* markdown doc (no prior ``is_summary`` chunk) → one created at
+ position −1 with the new embedding;
+* 404 unknown (source, path) (incl. traversal strings); 403 anonymous;
+* embed failure → 503 sanitized detail, DB byte-for-byte untouched
+ (fail-before-write — embed before any mutation).
"""
from __future__ import annotations
from datetime import UTC, datetime
-from sqlalchemy import text
+import pytest
+from fastapi.testclient import TestClient
+from sqlalchemy import select, text
+from sqlalchemy.orm import Session
+import app.api.docs as docs_api
from app.models import Chunk, Document
+from app.rag.llm import EmbeddingError
+from tests.fakes import FakeEmbedder
def _seed_doc(
@@ -24,8 +45,14 @@ def _seed_doc(
content: str = "# Kubernetes\n\nTalos on 3 nodes.",
chunks: int = 2,
summary: str | None = None,
+ summary_chunk: bool = False,
) -> None:
- """Truncate the KB and insert one document with ``chunks`` chunk rows."""
+ """Truncate the KB and insert one document with ``chunks`` chunk rows.
+
+ ``summary_chunk`` additionally indexes the phase-30 ``is_summary``
+ chunk at position −1 with the seeded vector (requires ``summary`` —
+ the phase-30 invariant: the chunk mirrors ``documents.summary``).
+ """
db.execute(text("TRUNCATE chunks, documents"))
db.commit()
doc = Document(
@@ -45,9 +72,324 @@ def _seed_doc(
Chunk(document_id=doc.id, position=i, content=f"chunk {i}", embedding=[0.01] * 768)
for i in range(chunks)
)
+ if summary_chunk:
+ assert summary is not None
+ db.add(
+ Chunk(
+ document_id=doc.id,
+ position=-1,
+ content=summary,
+ embedding=[0.01] * 768,
+ is_summary=True,
+ )
+ )
db.commit()
+# ---------------------------------------------------------------------------
+# PATCH /api/documents/summary (phase 57, task 01)
+# ---------------------------------------------------------------------------
+
+
+class _DeadEmbedder:
+ """An ``LLMClient`` stand-in whose ``embed`` always fails — the
+ dead-endpoint path (phase 57 fail-before-write). The message mirrors
+ ``LLMClient.embed``'s transport wrap, with embedded credentials that
+ the sanitizer must mask."""
+
+ def __init__(self) -> None:
+ self.calls: list[list[str]] = []
+
+ async def embed(self, texts: list[str]) -> list[list[float]]:
+ self.calls.append(list(texts))
+ raise EmbeddingError(
+ "embeddings request to https://user:secret@aipi.example.com/v1 "
+ "failed: connection refused"
+ )
+
+
+def _seed_yaml_doc(db, *, summary: str, summary_chunk: bool) -> None:
+ """One phase-30-style non-markdown doc: 2 content chunks + (optionally)
+ its ``is_summary`` chunk."""
+ _seed_doc(
+ db,
+ path="container_gitlab/gitlab-compose.yaml",
+ title="gitlab-compose",
+ content="services:\n gitlab:\n image: gitlab/gitlab-ce",
+ summary=summary,
+ summary_chunk=summary_chunk,
+ )
+
+
+def _doc_id(db, path: str):
+ return db.scalar(select(Document.id).where(Document.path == path))
+
+
+def _chunk_snapshots(db, doc_id) -> dict:
+ """``{chunk id: (position, content, embedding, is_summary)}``.
+
+ Before/after comparisons prove exactly which rows a PATCH touched.
+ Embeddings are compared read-back to read-back: pgvector stores
+ float4, so raw Python floats do not round-trip exactly (the house
+ style elsewhere is ``is not None`` + dimension checks)."""
+ rows = db.scalars(select(Chunk).where(Chunk.document_id == doc_id)).all()
+ return {
+ c.id: (
+ c.position,
+ c.content,
+ list(c.embedding) if c.embedding is not None else None,
+ c.is_summary,
+ )
+ for c in rows
+ }
+
+
+def test_summary_patch_update_reembeds_summary_chunk(
+ admin_client: TestClient, client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session
+) -> None:
+ """Admin PATCH with new text (phase 57, D4): ``documents.summary`` and
+ the ``is_summary`` chunk's content are replaced and the chunk gets a
+ fresh embedding — in place (same row id); the content chunks are
+ untouched (same ids, positions, contents, vectors) and the total
+ count is unchanged. One ``embed`` call, only with the new text."""
+ old = "GitLab CE runs in a Podman compose stack on the homelab NAS."
+ new = "GitLab CE is now backed by external PostgreSQL and MinIO."
+ _seed_yaml_doc(db, summary=old, summary_chunk=True)
+ doc_id = _doc_id(db, "container_gitlab/gitlab-compose.yaml")
+ db.expire_all()
+ before = _chunk_snapshots(db, doc_id)
+ assert len(before) == 3
+ try:
+ fake = FakeEmbedder()
+ monkeypatch.setattr(docs_api, "LLMClient", lambda: fake)
+ r = admin_client.patch(
+ "/api/documents/summary",
+ json={
+ "source": "Homelab",
+ "path": "container_gitlab/gitlab-compose.yaml",
+ "summary": new,
+ },
+ )
+ assert r.status_code == 200, r.text
+ body = r.json()
+ assert set(body) == {"source", "path", "summary", "chunks"}
+ assert body["source"] == "Homelab"
+ assert body["path"] == "container_gitlab/gitlab-compose.yaml"
+ assert body["summary"] == new
+ assert body["chunks"] == 3 # 2 content + 1 is_summary — unchanged by an update
+
+ db.expire_all()
+ row = db.get(Document, doc_id)
+ assert row is not None
+ assert row.summary == new
+ after = _chunk_snapshots(db, doc_id)
+ assert set(after) == set(before) # same rows: nothing added or deleted
+ sc = next(cid for cid, v in after.items() if v[3])
+ assert sc in before # replaced in place — the same row id
+ pos, content, vec, _ = after[sc]
+ assert pos == -1
+ assert content == new
+ assert vec is not None and len(vec) == 768
+ assert vec != before[sc][2] # fresh embedding, not the seeded one
+ for cid, v in after.items():
+ if not v[3]:
+ assert v == before[cid] # content chunks untouched, byte for byte
+ assert fake.calls == [[new]] # one embed call, the new text only
+ # The public viewer's data source now carries the new summary
+ # verbatim (anonymous — the viewer stays public, phase 16).
+ g = client.get(
+ "/api/documents/content",
+ params={"source": "Homelab", "path": "container_gitlab/gitlab-compose.yaml"},
+ )
+ assert g.status_code == 200
+ assert g.json()["summary"] == new
+ finally:
+ db.execute(text("TRUNCATE chunks, documents"))
+ db.commit()
+
+
+def test_summary_patch_clear(
+ admin_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session
+) -> None:
+ """PATCH with empty/whitespace text clears (phase 57, D4):
+ ``documents.summary`` → NULL and the ``is_summary`` chunk is deleted;
+ the content chunks are untouched. A second clear is an idempotent 200
+ and clearing never calls the embedder."""
+ old = "GitLab CE runs in a Podman compose stack on the homelab NAS."
+ _seed_yaml_doc(db, summary=old, summary_chunk=True)
+ doc_id = _doc_id(db, "container_gitlab/gitlab-compose.yaml")
+ db.expire_all()
+ before = _chunk_snapshots(db, doc_id)
+ assert len(before) == 3
+ try:
+ fake = FakeEmbedder()
+ monkeypatch.setattr(docs_api, "LLMClient", lambda: fake)
+ for body_summary in (" ", ""): # whitespace, then a true empty string
+ r = admin_client.patch(
+ "/api/documents/summary",
+ json={
+ "source": "Homelab",
+ "path": "container_gitlab/gitlab-compose.yaml",
+ "summary": body_summary,
+ },
+ )
+ assert r.status_code == 200, r.text
+ assert r.json() == {
+ "source": "Homelab",
+ "path": "container_gitlab/gitlab-compose.yaml",
+ "summary": None,
+ "chunks": 2,
+ }
+ db.expire_all()
+ row = db.get(Document, doc_id)
+ assert row is not None
+ assert row.summary is None
+ after = _chunk_snapshots(db, doc_id)
+ assert len(after) == 2 # the is_summary chunk row is gone (count −1)
+ for cid, v in after.items():
+ assert not v[3]
+ assert v == before[cid] # content chunks untouched, byte for byte
+ assert fake.calls == [] # clearing never embeds
+ finally:
+ db.execute(text("TRUNCATE chunks, documents"))
+ db.commit()
+
+
+def test_summary_patch_markdown_doc_creates_summary_chunk(
+ admin_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session
+) -> None:
+ """A markdown doc (no ``is_summary`` chunk by construction — phase 30)
+ gets exactly one created at position −1 with the new embedding; the
+ content chunks are untouched and the count goes 2 → 3. This is also
+ the recovery path for a phase-30 fail-soft import (document indexed
+ without its summary chunk)."""
+ new = "Talos Kubernetes on 3 nodes with a CNI of choice."
+ _seed_doc(db, summary=None, summary_chunk=False) # the default kubernetes.md
+ doc_id = _doc_id(db, "kubernetes.md")
+ db.expire_all()
+ before = _chunk_snapshots(db, doc_id)
+ assert len(before) == 2
+ try:
+ fake = FakeEmbedder()
+ monkeypatch.setattr(docs_api, "LLMClient", lambda: fake)
+ r = admin_client.patch(
+ "/api/documents/summary",
+ json={"source": "Homelab", "path": "kubernetes.md", "summary": new},
+ )
+ assert r.status_code == 200, r.text
+ body = r.json()
+ assert body["source"] == "Homelab"
+ assert body["path"] == "kubernetes.md"
+ assert body["summary"] == new
+ assert body["chunks"] == 3 # 2 content + the new is_summary
+ db.expire_all()
+ row = db.get(Document, doc_id)
+ assert row is not None
+ assert row.summary == new
+ after = _chunk_snapshots(db, doc_id)
+ assert len(after) == 3
+ for cid, v in before.items():
+ assert after[cid] == v # content chunks untouched, byte for byte
+ sc = next(cid for cid in after if cid not in before)
+ pos, content, vec, is_summary = after[sc]
+ assert (pos, is_summary) == (-1, True)
+ assert content == new
+ assert vec is not None and len(vec) == 768
+ assert fake.calls == [[new]]
+ finally:
+ db.execute(text("TRUNCATE chunks, documents"))
+ db.commit()
+
+
+def test_summary_patch_404_unknown_pair(admin_client: TestClient, db: Session) -> None:
+ """Unknown (source, path) pairs — including traversal strings — are
+ just missing rows: 404 ``document not found`` (the same shape as the
+ public GET). A pair that exists under a DIFFERENT source is 404 too."""
+ _seed_yaml_doc(db, summary="old", summary_chunk=True)
+ try:
+ for source, path in (
+ ("Homelab", "nope/missing.md"),
+ ("Deployments", "container_gitlab/gitlab-compose.yaml"),
+ ("Homelab", "../../etc/passwd"),
+ ):
+ r = admin_client.patch(
+ "/api/documents/summary",
+ json={"source": source, "path": path, "summary": "whatever"},
+ )
+ assert r.status_code == 404, (source, path)
+ assert r.json() == {"detail": "document not found"}, (source, path)
+ finally:
+ db.execute(text("TRUNCATE chunks, documents"))
+ db.commit()
+
+
+def test_summary_patch_403_anonymous(client: TestClient, db: Session) -> None:
+ """The edit affordance is admin-only (phase 57, D4 — the viewer stays
+ public): an anonymous PATCH gets 403 ``admin only`` and touches
+ nothing."""
+ old = "GitLab CE runs in a Podman compose stack on the homelab NAS."
+ _seed_yaml_doc(db, summary=old, summary_chunk=True)
+ doc_id = _doc_id(db, "container_gitlab/gitlab-compose.yaml")
+ try:
+ r = client.patch(
+ "/api/documents/summary",
+ json={
+ "source": "Homelab",
+ "path": "container_gitlab/gitlab-compose.yaml",
+ "summary": "not allowed",
+ },
+ )
+ assert r.status_code == 403
+ assert r.json() == {"detail": "admin only"}
+ db.expire_all()
+ row = db.get(Document, doc_id)
+ assert row is not None
+ assert row.summary == old # untouched
+ assert len(_chunk_snapshots(db, doc_id)) == 3
+ finally:
+ db.execute(text("TRUNCATE chunks, documents"))
+ db.commit()
+
+
+def test_summary_patch_embed_failure_503_db_untouched(
+ admin_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session
+) -> None:
+ """Fail-before-write (phase 57 locked decision): a dead embedding
+ endpoint → 503 with a sanitized detail (credentials masked, the
+ reason survives — the ``git_sources.py`` ``ModelUnavailableError``
+ style) and the row + every chunk byte-for-byte as before."""
+ old = "GitLab CE runs in a Podman compose stack on the homelab NAS."
+ _seed_yaml_doc(db, summary=old, summary_chunk=True)
+ doc_id = _doc_id(db, "container_gitlab/gitlab-compose.yaml")
+ db.expire_all()
+ before = _chunk_snapshots(db, doc_id)
+ try:
+ dead = _DeadEmbedder()
+ monkeypatch.setattr(docs_api, "LLMClient", lambda: dead)
+ r = admin_client.patch(
+ "/api/documents/summary",
+ json={
+ "source": "Homelab",
+ "path": "container_gitlab/gitlab-compose.yaml",
+ "summary": "new text",
+ },
+ )
+ assert r.status_code == 503
+ detail = r.json()["detail"]
+ assert "*****@aipi.example.com" in detail # credentials masked
+ assert "user:secret" not in detail
+ assert "connection refused" in detail # the reason survives
+ assert dead.calls == [["new text"]] # the embed was attempted…
+ db.expire_all()
+ row = db.get(Document, doc_id)
+ assert row is not None
+ assert row.summary == old # …and failed before any mutation
+ assert _chunk_snapshots(db, doc_id) == before # every row, byte for byte
+ finally:
+ db.execute(text("TRUNCATE chunks, documents"))
+ db.commit()
+
+
def test_content_200_all_fields(client, db) -> None:
_seed_doc(db)
try:
diff --git a/tests/unit/test_summary_edit_ui.py b/tests/unit/test_summary_edit_ui.py
new file mode 100644
index 0000000..1f0de12
--- /dev/null
+++ b/tests/unit/test_summary_edit_ui.py
@@ -0,0 +1,380 @@
+"""Unit: the admin summary-edit affordance in the viewer (phase 57,
+task 02).
+
+The browser behavior itself is E2E-gated by the phase-57 story suite;
+like the other frontend-adjacent unit files (test_save_chat_ui.py
+pattern), this module pins the JS/CSS markers the edit contract depends
+on, so a silent regression is caught without a browser:
+
+* the ``docAdminReady()`` gate — the module-cached /api/whoami promise
+ (header.js's ``fetchIsAdmin``, the SAME single request per page the
+ shared header makes — no second whoami call site in document.js);
+ non-admin / fetch failure → NO button, NO wiring (the public viewer
+ is byte-for-byte the phase-36 section: the bare ``section.append
+ (title, body)`` construction stays first, the admin affordance is a
+ post-render ``.then`` on the gate promise);
+* the editor construction — the header row (``.doc-summary-head`` with
+ the bare h2 + a real ``type="button"`` Edit), the swap-in
+ ``