feat(kb): edit + re-embed document summaries from the viewer (admin)
This commit is contained in:
+76
-1
@@ -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)
|
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
|
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).
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -13,10 +18,12 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.sync import _sanitize_error
|
||||||
from app.core.auth import require_admin
|
from app.core.auth import require_admin
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import Chunk, Document
|
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"])
|
router = APIRouter(tags=["kb"])
|
||||||
|
|
||||||
@@ -103,3 +110,71 @@ def get_document_content(
|
|||||||
indexed_at=doc.indexed_at.isoformat(),
|
indexed_at=doc.indexed_at.isoformat(),
|
||||||
chunks=chunks,
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -141,6 +141,40 @@ class DocContent(BaseModel):
|
|||||||
chunks: int
|
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):
|
class SteeringNoteIn(BaseModel):
|
||||||
"""``POST /api/steering`` body: one tuning instruction (phase 15).
|
"""``POST /api/steering`` body: one tuning instruction (phase 15).
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,15 @@
|
|||||||
* doc.summary renders as a labeled .doc-summary section above the
|
* doc.summary renders as a labeled .doc-summary section above the
|
||||||
* original content on BOTH surfaces (page + modal) through this one
|
* original content on BOTH surfaces (page + modal) through this one
|
||||||
* core; the summary text is a text node (XSS contract unchanged).
|
* 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";
|
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
|
body.textContent = doc.summary; // text node — XSS contract unchanged
|
||||||
section.append(title, body);
|
section.append(title, body);
|
||||||
contentEl.appendChild(section);
|
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") {
|
if (doc.format === "md" || doc.format === "markdown") {
|
||||||
const wrap = document.createElement("div");
|
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) ----------
|
/* ---------- /document.html page (phases 10/13/19) ----------
|
||||||
* Phase 26: viewer-page-specific — see the import-safety note in the
|
* 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
|
* header. The guard is #doc-title: it exists only on this page, so the
|
||||||
|
|||||||
@@ -2382,6 +2382,97 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
color: var(--ink); /* on --surface ≈14.5:1 */
|
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. */
|
/* Raw (non-markdown) formats: full-width mono pre, horizontal scroll. */
|
||||||
.doc-raw {
|
.doc-raw {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -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 ``<pre>`` 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 <p>.
|
||||||
|
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"]
|
||||||
@@ -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.
|
||||||
@@ -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:
|
Uses the real compose Postgres (``db`` fixture) and FastAPI's TestClient:
|
||||||
* 200 with the full field set for a seeded document (all formats);
|
* GET: 200 with the full field set for a seeded document (all formats);
|
||||||
* ``summary`` surfaced for summarized docs, ``null`` for markdown (phase 36);
|
* GET: ``summary`` surfaced for summarized docs, ``null`` for markdown
|
||||||
* anonymous access stays 200 (phase 16 soft rule — public viewer);
|
(phase 36);
|
||||||
* 404 for an unknown (source, path) pair;
|
* GET: anonymous access stays 200 (phase 16 soft rule — public viewer);
|
||||||
* 404 for traversal-style ``path`` values (no filesystem access → no leak).
|
* 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 __future__ import annotations
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
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.models import Chunk, Document
|
||||||
|
from app.rag.llm import EmbeddingError
|
||||||
|
from tests.fakes import FakeEmbedder
|
||||||
|
|
||||||
|
|
||||||
def _seed_doc(
|
def _seed_doc(
|
||||||
@@ -24,8 +45,14 @@ def _seed_doc(
|
|||||||
content: str = "# Kubernetes\n\nTalos on 3 nodes.",
|
content: str = "# Kubernetes\n\nTalos on 3 nodes.",
|
||||||
chunks: int = 2,
|
chunks: int = 2,
|
||||||
summary: str | None = None,
|
summary: str | None = None,
|
||||||
|
summary_chunk: bool = False,
|
||||||
) -> None:
|
) -> 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.execute(text("TRUNCATE chunks, documents"))
|
||||||
db.commit()
|
db.commit()
|
||||||
doc = Document(
|
doc = Document(
|
||||||
@@ -45,6 +72,321 @@ def _seed_doc(
|
|||||||
Chunk(document_id=doc.id, position=i, content=f"chunk {i}", embedding=[0.01] * 768)
|
Chunk(document_id=doc.id, position=i, content=f"chunk {i}", embedding=[0.01] * 768)
|
||||||
for i in range(chunks)
|
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()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
``<textarea class="doc-summary-editor">`` prefilled via ``.value``
|
||||||
|
(never innerHTML — XSS contract), Save / Cancel buttons, and the
|
||||||
|
``role="status"`` ``aria-live="polite"`` live region;
|
||||||
|
* the exact PATCH call — ``/api/documents/summary`` with method PATCH
|
||||||
|
and the ``{source, path, summary}`` body (the pair from the doc
|
||||||
|
object — the same values the modal core carries);
|
||||||
|
* the outcomes — success re-renders the text node via ``textContent``
|
||||||
|
("Summary updated."), an empty save that clears removes the panel
|
||||||
|
("Summary cleared." — the renderer only draws it for non-empty
|
||||||
|
summaries), Cancel restores the text node, and a failure (non-ok OR
|
||||||
|
network) keeps the editor open with the user's text and shows
|
||||||
|
neutral retry copy (phase-55 convention); the double-click guard
|
||||||
|
releases in the ``finally`` — never stale;
|
||||||
|
* styles.css — the five new ``.doc-summary-*`` classes (plus the two
|
||||||
|
layout wrappers) on the house dark-tech palette (phase-08 tokens),
|
||||||
|
8rem-min editor, 24px+ edit target, the ``[hidden]`` override,
|
||||||
|
``:focus-visible`` via the global outline rule, no CDN.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||||
|
DOCUMENT_JS = FRONTEND / "assets" / "document.js"
|
||||||
|
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
||||||
|
|
||||||
|
|
||||||
|
def _js() -> str:
|
||||||
|
return DOCUMENT_JS.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _css() -> str:
|
||||||
|
return STYLES_CSS.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _fn(js: str, name: str) -> str:
|
||||||
|
"""The source of a (possibly async, possibly nested) function via
|
||||||
|
balanced-brace counting (works for top-level and the editor's
|
||||||
|
inner helpers alike)."""
|
||||||
|
for prefix in ("async function ", "function "):
|
||||||
|
start = js.find(f"{prefix}{name}(")
|
||||||
|
if start != -1:
|
||||||
|
depth = 0
|
||||||
|
for i in range(js.find("{", start), len(js)):
|
||||||
|
if js[i] == "{":
|
||||||
|
depth += 1
|
||||||
|
elif js[i] == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return js[start : i + 1]
|
||||||
|
raise AssertionError(f"unbalanced braces in {name}()")
|
||||||
|
raise AssertionError(f"{name}() must exist in document.js")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- the admin gate (D4: the viewer stays public) ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_doc_admin_ready_wraps_the_cached_whoami_promise() -> None:
|
||||||
|
"""docAdminReady() exists and resolves the module-cached whoami
|
||||||
|
promise (header.js's fetchIsAdmin — one request per page, shared
|
||||||
|
with initSharedHeader) to a strict boolean: a non-admin OR any
|
||||||
|
fetch failure resolves false (the anonymous viewer)."""
|
||||||
|
js = _js()
|
||||||
|
body = _fn(js, "docAdminReady")
|
||||||
|
assert "await fetchIsAdmin()" in body, (
|
||||||
|
"the gate must reuse the cached whoami promise (no new call site)"
|
||||||
|
)
|
||||||
|
assert "=== true" in body, "a strict boolean — only an authenticated admin"
|
||||||
|
assert "} catch {" in body and "return false" in body, (
|
||||||
|
"any failure resolves false — the anonymous viewer"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_second_whoami_call_site_in_document_js() -> None:
|
||||||
|
"""document.js never fetches /api/whoami itself: header.js's
|
||||||
|
fetchIsAdmin is the SINGLE whoami call site for the whole frontend
|
||||||
|
(the cached promise), so the gate adds no request of its own — and
|
||||||
|
no admin-only network call exists for anonymous visitors (the only
|
||||||
|
admin call, the PATCH, lives inside the wired editor)."""
|
||||||
|
js = _js()
|
||||||
|
assert 'fetch("/api/whoami")' not in js, (
|
||||||
|
"whoami must come from the header.js cached promise"
|
||||||
|
)
|
||||||
|
assert 'from "./header.js"' in js and "fetchIsAdmin" in js
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_runs_after_the_phase36_base_construction() -> None:
|
||||||
|
"""The .doc-summary section is built for EVERYONE exactly as phase
|
||||||
|
36 (the anonymous byte-for-byte shape): the base construction
|
||||||
|
(className, the bare h2 label, the .doc-summary-text node, the
|
||||||
|
append) precedes the gate call, and the admin wiring runs ONLY in
|
||||||
|
the gate's success branch (``if (admin) wireSummaryEdit(...)``)."""
|
||||||
|
js = _js()
|
||||||
|
base = js.find('section.className = "doc-summary"')
|
||||||
|
label = js.find('title.textContent = "Summary"')
|
||||||
|
text_node = js.find('body.className = "doc-summary-text"')
|
||||||
|
append = js.find("section.append(title, body)")
|
||||||
|
mount = js.find("contentEl.appendChild(section)")
|
||||||
|
gate = js.find("void docAdminReady().then(")
|
||||||
|
wiring = js.find("if (admin) wireSummaryEdit(section, doc);")
|
||||||
|
assert 0 < base < label < text_node < append < mount < gate < wiring, (
|
||||||
|
"phase-36 base construction first; the admin affordance is a "
|
||||||
|
"post-render gate branch (anonymous DOM is never touched)"
|
||||||
|
)
|
||||||
|
assert "wireSummaryEdit(section, doc)" in js[gate:]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- the editor construction ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_button_is_a_real_button_in_the_header_row() -> None:
|
||||||
|
"""The Edit affordance: a real ``type="button"`` with the visible
|
||||||
|
text "Edit" and the .doc-summary-edit class, added to a
|
||||||
|
.doc-summary-head row that keeps the bare h2 label (label left,
|
||||||
|
button right — the anonymous section keeps its bare h2)."""
|
||||||
|
body = _fn(_js(), "wireSummaryEdit")
|
||||||
|
assert 'editBtn.type = "button"' in body
|
||||||
|
assert 'editBtn.className = "doc-summary-edit"' in body
|
||||||
|
assert 'editBtn.textContent = "Edit"' in body
|
||||||
|
assert 'head.className = "doc-summary-head"' in body
|
||||||
|
assert "head.append(title, editBtn)" in body
|
||||||
|
# The header row REPLACES the bare h2 as the section's first child.
|
||||||
|
assert "section.replaceChildren(head, body)" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_swap_builds_textarea_save_cancel_and_live_region() -> None:
|
||||||
|
"""Edit swaps the .doc-summary-text node for the inline editor:
|
||||||
|
a <textarea class="doc-summary-editor"> prefilled via ``.value``
|
||||||
|
(NEVER innerHTML — the XSS contract), Save / Cancel real
|
||||||
|
type=buttons, and a <p class="doc-summary-status" role="status"
|
||||||
|
aria-live="polite"> live region. The Edit button hides while the
|
||||||
|
editor is open (no re-open mid-edit) and the textarea gets focus.
|
||||||
|
The ENTIRE wiring is textContent/.value-only — no innerHTML
|
||||||
|
anywhere (summary text is user-storable)."""
|
||||||
|
js = _js()
|
||||||
|
body = _fn(js, "wireSummaryEdit")
|
||||||
|
assert 'editor.className = "doc-summary-editor"' in body
|
||||||
|
assert (
|
||||||
|
'editor.value = typeof doc.summary === "string" ? doc.summary : ""' in body
|
||||||
|
), "prefill via .value — value, not innerHTML"
|
||||||
|
assert 'saveBtn.type = "button"' in body
|
||||||
|
assert 'saveBtn.className = "doc-summary-save"' in body
|
||||||
|
assert 'saveBtn.textContent = "Save"' in body
|
||||||
|
assert 'cancelBtn.type = "button"' in body
|
||||||
|
assert 'cancelBtn.className = "doc-summary-cancel"' in body
|
||||||
|
assert 'cancelBtn.textContent = "Cancel"' in body
|
||||||
|
assert 'status.className = "doc-summary-status"' in body
|
||||||
|
assert 'status.setAttribute("role", "status")' in body
|
||||||
|
assert 'status.setAttribute("aria-live", "polite")' in body
|
||||||
|
# The swap: text node out, editor parts in, focus in.
|
||||||
|
assert "section.replaceChildren(head, editor, actions, status)" in body
|
||||||
|
assert "editor.focus()" in body
|
||||||
|
hide = body.find("editBtn.hidden = true")
|
||||||
|
swap = body.find("section.replaceChildren(head, editor, actions, status)")
|
||||||
|
focus = body.find("editor.focus()")
|
||||||
|
assert -1 < hide < swap < focus, "hide Edit → swap → focus the textarea"
|
||||||
|
# The bindings.
|
||||||
|
assert 'editBtn.addEventListener("click", openEditor)' in body
|
||||||
|
assert 'cancelBtn.addEventListener("click", () => closeEditor(""))' in body
|
||||||
|
# XSS contract: the whole affordance is textContent/.value only
|
||||||
|
# (comments stripped — the word may appear in a note, never in code).
|
||||||
|
code = re.sub(r"//.*?$|/\*.*?\*/", "", body, flags=re.S | re.M)
|
||||||
|
assert "innerHTML" not in code, "XSS contract: no innerHTML in the wiring"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- the PATCH round-trip ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_patches_the_exact_endpoint_with_the_doc_pair() -> None:
|
||||||
|
"""Save → PATCH /api/documents/summary (the phase-57 admin
|
||||||
|
endpoint) with the EXACT body shape {source, path, summary} — the
|
||||||
|
pair from the doc object (the same values the modal core carries),
|
||||||
|
JSON content type. This is the ONLY admin-only call in document.js
|
||||||
|
(exactly one call site, inside the wired editor — anonymous
|
||||||
|
visitors never have it)."""
|
||||||
|
js = _js()
|
||||||
|
assert js.count('fetch("/api/documents/summary"') == 1, (
|
||||||
|
"exactly one PATCH call site (inside wireSummaryEdit)"
|
||||||
|
)
|
||||||
|
body = _fn(js, "wireSummaryEdit")
|
||||||
|
fetch_i = body.find('fetch("/api/documents/summary"')
|
||||||
|
assert fetch_i != -1, "the PATCH must live in the wired editor"
|
||||||
|
assert 'method: "PATCH"' in body[fetch_i:]
|
||||||
|
assert '"Content-Type": "application/json"' in body[fetch_i:]
|
||||||
|
assert (
|
||||||
|
"JSON.stringify({ source: doc.source, path: doc.path, summary: value })"
|
||||||
|
in body
|
||||||
|
), "the exact body shape: {source, path, summary}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_success_rerenders_text_node_and_announces() -> None:
|
||||||
|
"""A 200 update syncs the doc object (a later re-open prefills the
|
||||||
|
CURRENT summary), announces "Summary updated." through
|
||||||
|
closeEditor — which re-renders the text node via textContent ONLY
|
||||||
|
(XSS contract) from the doc object."""
|
||||||
|
body = _fn(_js(), "wireSummaryEdit")
|
||||||
|
ok_i = body.find("if (!res.ok)")
|
||||||
|
json_i = body.find("await res.json()")
|
||||||
|
null_i = body.find("if (data.summary === null)")
|
||||||
|
sync_i = body.find("doc.summary = data.summary")
|
||||||
|
announce_i = body.find('closeEditor("Summary updated.")')
|
||||||
|
assert -1 < ok_i < json_i < null_i < sync_i < announce_i, (
|
||||||
|
"non-ok checked first → JSON → clear branch → doc sync → announce"
|
||||||
|
)
|
||||||
|
close = _fn(body, "closeEditor")
|
||||||
|
assert "body.textContent = doc.summary" in close, (
|
||||||
|
"the display state re-renders the text node from the doc object"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_save_clears_and_removes_the_panel() -> None:
|
||||||
|
"""An empty save that clears (response summary === null, D4) syncs
|
||||||
|
the doc object, announces "Summary cleared." in the live region,
|
||||||
|
and removes the panel a short beat LATER (setTimeout — the
|
||||||
|
confirmation stays readable before the panel leaves the DOM; the
|
||||||
|
renderer only draws it for non-empty summaries). Failures keep the
|
||||||
|
panel (their slices carry no removal)."""
|
||||||
|
body = _fn(_js(), "wireSummaryEdit")
|
||||||
|
null_i = body.find("if (data.summary === null)")
|
||||||
|
sync_i = body.find("doc.summary = null")
|
||||||
|
announce_i = body.find('status.textContent = "Summary cleared."')
|
||||||
|
remove_i = body.find("setTimeout(() => section.remove(), 2000)")
|
||||||
|
assert -1 < null_i < sync_i < announce_i < remove_i, (
|
||||||
|
"the null branch: sync → announce → delayed removal"
|
||||||
|
)
|
||||||
|
# Exactly two removals in the whole affordance, both tied to a
|
||||||
|
# CLEARED state (the clear branch + the closeEditor empty guard —
|
||||||
|
# no failure path removes the panel).
|
||||||
|
assert body.count("section.remove()") == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_restores_the_text_node() -> None:
|
||||||
|
"""Cancel restores the display state: the .doc-summary-text node
|
||||||
|
back in the section, re-rendered from the doc object (the CURRENT
|
||||||
|
stored summary — the node was never mutated, only swapped out),
|
||||||
|
the (empty) live region kept, and the Edit button un-hidden +
|
||||||
|
re-focused (focus returns to the opener). A summary that is GONE
|
||||||
|
(a clear landed while the editor was open — Cancel right after a
|
||||||
|
successful empty save) never renders an empty panel: the guard
|
||||||
|
drops the panel instead."""
|
||||||
|
body = _fn(_js(), "wireSummaryEdit")
|
||||||
|
close = _fn(body, "closeEditor")
|
||||||
|
assert 'status.textContent = message' in close
|
||||||
|
assert "editBtn.hidden = false" in close
|
||||||
|
guard = 'typeof doc.summary !== "string" || doc.summary.trim() === ""'
|
||||||
|
guard_i = close.find(guard)
|
||||||
|
remove_i = close.find("section.remove()")
|
||||||
|
sync_i = close.find("body.textContent = doc.summary")
|
||||||
|
restore_i = close.find("section.replaceChildren(head, body, status)")
|
||||||
|
focus_i = close.find("editBtn.focus()")
|
||||||
|
assert -1 < guard_i < remove_i < sync_i < restore_i < focus_i, (
|
||||||
|
"empty guard first; otherwise re-render → restore → focus"
|
||||||
|
)
|
||||||
|
assert 'cancelBtn.addEventListener("click", () => closeEditor(""))' in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_failure_keeps_the_editor_with_neutral_copy() -> None:
|
||||||
|
"""A failed Save (non-ok HTTP OR network) keeps the editor open
|
||||||
|
with the user's text (no swap back, no panel removal) and shows
|
||||||
|
neutral retry copy (phase-55 convention — no sign-in wording):
|
||||||
|
"…try again." for a non-ok response, "…is the app reachable?" for
|
||||||
|
the network path."""
|
||||||
|
body = _fn(_js(), "wireSummaryEdit")
|
||||||
|
nonok = body.find("if (!res.ok)")
|
||||||
|
neutral = body.find("Couldn't update the summary — try again.")
|
||||||
|
assert -1 < nonok < neutral, "the non-ok branch lands on the neutral copy"
|
||||||
|
catch_i = body.find("} catch {")
|
||||||
|
reachable = body.find("Couldn't update the summary — is the app reachable?")
|
||||||
|
assert -1 < catch_i < reachable, "the network path carries the reachable? copy"
|
||||||
|
assert "signed in" not in body, "no sign-in wording (neutral retry copy)"
|
||||||
|
# The two FAILURE branches (the non-ok early return and the network
|
||||||
|
# catch) never restore or remove — the editor stays open with the
|
||||||
|
# user's text (only the clear branch removes the panel).
|
||||||
|
nonok_slice = body[nonok:body.find("await res.json()")]
|
||||||
|
assert "section.remove()" not in nonok_slice
|
||||||
|
assert "closeEditor" not in nonok_slice
|
||||||
|
catch_slice = body[catch_i:body.find("finally")]
|
||||||
|
assert "section.remove()" not in catch_slice
|
||||||
|
assert "closeEditor" not in catch_slice
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_double_click_guard_releases_in_finally() -> None:
|
||||||
|
"""One PATCH at a time: Save disables itself BEFORE the fetch and
|
||||||
|
re-enables in the ``finally`` (every outcome — success, clear,
|
||||||
|
non-ok, network — never leaves a stale disabled button, PLAN §7.4)."""
|
||||||
|
body = _fn(_js(), "wireSummaryEdit")
|
||||||
|
disable_i = body.find("saveBtn.disabled = true")
|
||||||
|
fetch_i = body.find('fetch("/api/documents/summary"')
|
||||||
|
finally_i = body.find("finally")
|
||||||
|
enable_i = body.find("saveBtn.disabled = false")
|
||||||
|
assert -1 < disable_i < fetch_i < finally_i < enable_i, (
|
||||||
|
"disable before the fetch; re-enable in the finally"
|
||||||
|
)
|
||||||
|
assert body.count("saveBtn.disabled = true") == 1
|
||||||
|
assert body.count("saveBtn.disabled = false") == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- styles.css ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_summary_edit_classes_present() -> None:
|
||||||
|
"""styles.css carries the five task-named .doc-summary-* classes
|
||||||
|
(plus the two layout wrappers the wiring emits) — the house
|
||||||
|
dark-tech palette (phase-08 tokens), system fonts, no CDN."""
|
||||||
|
css = _css()
|
||||||
|
for cls in (
|
||||||
|
".doc-summary-edit",
|
||||||
|
".doc-summary-editor",
|
||||||
|
".doc-summary-save",
|
||||||
|
".doc-summary-cancel",
|
||||||
|
".doc-summary-status",
|
||||||
|
".doc-summary-head",
|
||||||
|
".doc-summary-actions",
|
||||||
|
):
|
||||||
|
assert f"{cls} " in css or f"{cls}." in css or f"{cls}[" in css, (
|
||||||
|
f"styles.css must style {cls}"
|
||||||
|
)
|
||||||
|
assert "url(http" not in css and "@import url(" not in css, (
|
||||||
|
"no CDN (AGENTS.md rule 6)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_edit_css_targets_and_palette() -> None:
|
||||||
|
"""The house AA palette on the edit affordance: the edit target is
|
||||||
|
24px+ with a 3px global-outline focus (the global :focus-visible
|
||||||
|
rule — no local override needed); the editor is a full-width block
|
||||||
|
textarea with the 8rem min-height; the save pill is the solid
|
||||||
|
brand family (--bg on --brand = 5.2:1, AA, borderless); the ghost
|
||||||
|
buttons ride the ink-soft 5.1:1-on-surface pair; the status line
|
||||||
|
is ink-soft (AA). The [hidden] override must beat the edit
|
||||||
|
button's display rule (the editor hides Edit while open)."""
|
||||||
|
css = _css()
|
||||||
|
edit = css[css.find(".doc-summary-edit {") :]
|
||||||
|
edit = edit[: edit.find("\n}")]
|
||||||
|
assert "min-height: 24px" in edit, "the 24px+ edit target (task 02)"
|
||||||
|
assert "var(--line)" in edit and "var(--ink-soft)" in edit
|
||||||
|
hidden = css.find(".doc-summary-edit[hidden]")
|
||||||
|
assert hidden != -1 and "display: none" in css[hidden : hidden + 80], (
|
||||||
|
"the hidden attr must beat the base display rule"
|
||||||
|
)
|
||||||
|
editor = css[css.find(".doc-summary-editor {") :]
|
||||||
|
editor = editor[: editor.find("\n}")]
|
||||||
|
for prop in (
|
||||||
|
"display: block",
|
||||||
|
"width: 100%",
|
||||||
|
"min-height: 8rem",
|
||||||
|
"resize: vertical",
|
||||||
|
"var(--bg)",
|
||||||
|
"var(--ink)",
|
||||||
|
):
|
||||||
|
assert prop in editor, f".doc-summary-editor must keep {prop}"
|
||||||
|
save = css[css.find(".doc-summary-save {") :]
|
||||||
|
save = save[: save.find("\n}")]
|
||||||
|
assert "background: var(--brand)" in save and "color: var(--bg)" in save
|
||||||
|
assert "border: 0" in save, "the solid brand pill family (Save/Share)"
|
||||||
|
status = css[css.find(".doc-summary-status {") :]
|
||||||
|
status = status[: status.find("\n}")]
|
||||||
|
assert "var(--ink-soft)" in status, "AA status copy on --surface (5.1:1)"
|
||||||
|
# :focus-visible via the GLOBAL 3px outline rule (no local
|
||||||
|
# suppression anywhere for these controls).
|
||||||
|
assert ":focus-visible {" in css
|
||||||
|
assert "outline: 3px solid var(--brand)" in css
|
||||||
Reference in New Issue
Block a user