Files
brain-of-reese/tests/e2e/test_edit_summaries.py
ducoterra ad7585d474
Build and Push Containers / build-and-push-app (push) Successful in 2m11s
Build and Push Containers / build-and-push-db (push) Successful in 11s
phase: 97_kb_tree_catalog
All completion criteria verified — everything is green, no defects found. Final report:

## Phase 97 final verification pass — ALL GREEN

**Verified (no code changes needed):**
- `GET /api/docs/tree` (admin), `build_kb_tree` pure builder, `PATCH /api/folders/summary`, migration 0018 (`manually_edited`, head confirmed), generator skip/keep + `kept_manual` stat, RAG tree UI + edit affordance in `sources.js`/`index.html`/`styles.css`
- `tests/e2e/test_kb_tree.py`: 8 passed — top level, drill source/folder, edit round-trip, clear, manual-desc-survives-sync, reload fallback, anonymous gate
- Integration: tree shape/order/403/empty/indexed-only + PATCH update/create/root/clear/404/403/no-LLM + stat-walk equivalence (in `test_docs_api.py`); 3-field `folder_summaries=` import token preserved

**Gates (exact commands):**
- `uv run pytest --cov=app --cov-report=term-missing` → **2053 passed**, TOTAL coverage **99%** (>90% ✓)
- `uv run ruff check . && uv run pyright` → **All checks passed / 0 errors**
- `uv run pytest tests/e2e/test_kb_tree.py -v --no-cov` → **8 passed** in isolation
- 30 story/RAG-view E2E suites run **one per process**: all passed, incl. `test_ls_tree_drilldown` (agent `ls` byte-identical ✓), `test_import_documents`, `test_edit_summaries`, `test_admin_auth`, `test_kb_overview`

**Completion criteria:** tree view ✓ · edit round-trip + clear ✓ · manual persists/clear resets ✓ · `ls` unchanged ✓ · pytest/coverage/lint ✓ · E2E isolation ✓ · commit — left to harness per protocol (working tree untouched, `git add/commit` not run)

**Deviations:** none. **Next pending phase:** none — `todo/` contains only 97 (96 already committed).
2026-09-11 22:48:02 -04:00

433 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 content endpoint (phase 79 superseded the
phase-16 soft rule: the endpoint is require_user, so the pin runs
under the admin session — the shape is unchanged).
"""
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 ADMIN_PASSWORD, 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 content endpoint's ``summary``. Phase 79: the endpoint is
require_user (the phase-16 soft rule is superseded) — the fresh
httpx client signs in as the admin first (every caller is an
admin-flow test)."""
client = httpx.Client(timeout=10)
r = client.post(f"{app_url}/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204
r = client.get(
f"{app_url}/api/documents/content",
params={"source": SOURCE, "path": DOC_PATH},
)
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)
# Fresh context (the function-scoped page fixture — no login).
# Phase 79 (task 05): the content endpoint is require_user — the
# anonymous viewer meets the inline token gate (the content fetch
# never runs — no summary panel, no not-found card either), so the
# edit affordance is absent by construction.
page.goto(_doc_url(app_url))
expect(page.locator("#doc-auth-gate")).to_be_visible(timeout=15_000)
expect(page.locator("#doc-title")).to_have_text("Loading…")
expect(page.locator(".doc-summary")).to_have_count(0)
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)
# 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)
# Phase 97: the catalog is the drill-down tree — the row lives at
# the quadlet level (the drill is the only change; the phase-57
# summary-edit flow on the viewer itself is untouched).
for name in (SOURCE, "quadlet"):
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
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"]