feat(kb): edit + re-embed document summaries from the viewer (admin)

This commit is contained in:
2026-08-31 23:52:22 -04:00
parent d94f3d5a52
commit 140b97ebf3
8 changed files with 1540 additions and 9 deletions
+430
View File
@@ -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.
+350 -8
View File
@@ -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:
+380
View File
@@ -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