From 140b97ebf3745d06201589999a6addea9db0b36a Mon Sep 17 00:00:00 2001 From: ducoterra Date: Mon, 31 Aug 2026 23:52:22 -0400 Subject: [PATCH] feat(kb): edit + re-embed document summaries from the viewer (admin) --- app/api/docs.py | 77 +++- app/schemas.py | 34 ++ frontend/assets/document.js | 157 +++++++ frontend/assets/styles.css | 91 ++++ tests/e2e/test_edit_summaries.py | 430 ++++++++++++++++++ .../quadlet/llamacpp.container | 22 + tests/integration/test_document_content.py | 358 ++++++++++++++- tests/unit/test_summary_edit_ui.py | 380 ++++++++++++++++ 8 files changed, 1540 insertions(+), 9 deletions(-) create mode 100644 tests/e2e/test_edit_summaries.py create mode 100644 tests/fixtures/summary_edit_kb/quadlet/llamacpp.container create mode 100644 tests/unit/test_summary_edit_ui.py 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 + ``