**Phase 118 final verification pass — complete.** All criteria verified; 4 pre-existing defects found and fixed.
- **Verified:** summary-seed wiring (`select_suggested` top-5 no-floor → summary blocks, no full text in HIGH prompt), all-doc markdown summaries + NULL backfill (`summary_backfilled`, no `sources_meta` bump), `read` adds full text with `read_docs`-only dedupe, `done.sources` = suggested+read / durable record = suggested+related+read + `suggested=N` log line (seen live in E2E), byte-locked PERSONA/LOW/TOOLS_SECTION, battery gate PASS recorded in `TOOL_CALLING_TESTING.md` §10 (turbo 2026-09-16: 1/2/4 GREEN, cond-3 reported 9/10 per A7, contract 21/21, caps 0).
- **Defects fixed (all pre-existing, none phase-118):** ① `ChatMessage` schema missing the phase-113 `related` key → `extra="forbid"` 422'd every done-time auto-save of grounded turns with a related tier, leaving `message_count=1` (root cause of `test_share_chat` 3F; browser-level instrumentation proved the PUT 422) — added the field + unit/integration pins; ② `test_theme_semantic_completion` pins stale vs phase-117 debox (border/chip removed) — re-targeted to assert border/chip *absence*; ③ `test_header_consistency` `<26`px pin red on 26.125px native date-input line — bound relaxed to `<34` (wrap-detection intent kept); ④ `test_navbar_refresh` bor.chat.v1 key set updated for `related`.
- **Test/lint/coverage:** `uv run pytest --cov=app --cov-report=term-missing` → **2506 passed, app/ 99%** (>90%); `uv run ruff check . && uv run pyright` → clean, 0 errors.
- **E2E:** new story suite in isolation → **2 passed**; full 103-suite matrix sweep (each isolated) → **all 103 green** after the fixes; `test_share_chat` 4 passed, `test_theme_semantic_completion` 8 passed, `test_header_consistency` 3 passed, `test_navbar_refresh` 7 passed.
- **Deviations:** none from LOCKED decisions. Note: orphaned diagnostic uvicorn processes briefly made E2E sessions exercise stale code — killed and re-verified; a sweep-regenerated tracked screenshot was restored. No commits made (harness commits).
- **Completion criteria:** all 7 ✅ (commit/phase-move is the harness's step).
- **Next pending phase:** none — `todo/` holds only this phase's overview pending the harness move.
390 lines
16 KiB
Python
390 lines
16 KiB
Python
"""Phase 36 E2E (Playwright): the summary and the original document
|
|
are visible **together** in the viewer.
|
|
|
|
Story: ``.agents/user_stories/summary-in-viewer.md``
|
|
Run in isolation (DB must be up: ``podman compose up -d db``):
|
|
|
|
uv run pytest tests/e2e/test_summary_in_viewer.py -v --no-cov
|
|
|
|
The fixture KB is the story-dedicated ``tests/fixtures/summary_kb/``
|
|
directory (the same one the phase-30 suite imports — its machinery is
|
|
reused here as closely as possible):
|
|
|
|
* ``quadlet/qwen-llamacpp.yaml`` — a non-markdown A9 doc. At import the
|
|
mock ``lite`` model (``SUMMARY_MODE`` marker, ``tests/e2e/mock_llm.py``)
|
|
reduces it to a deterministic 24-token digest, stored on
|
|
``documents.summary``. The sentinel ``RESE-SUMMARY-SENTINEL-7f3a`` sits
|
|
on the document's LAST line — **outside** the 24-token digest — so it
|
|
is a marker for "the original, not the summary".
|
|
* ``notes/qwen-llamacpp-notes.md`` — a markdown doc that phase 118 (A2)
|
|
summarizes TOO (the phase-30 non-markdown-only scope is retired): the
|
|
viewer must show its Summary panel exactly like the yaml's, above the
|
|
rendered markdown.
|
|
|
|
This phase only surfaces the stored field: the content endpoint returns
|
|
``summary`` (task 01) and the shared ``renderDocument`` core draws the
|
|
labeled ``.doc-summary`` panel above the content on BOTH surfaces (task
|
|
02) — the full-page viewer and the chat/sources modal. The tests assert
|
|
exactly that contract. Phase 79 supersedes the phase-16 soft rule: the
|
|
content endpoint is ``require_user``-gated, so the viewer surfaces and
|
|
the API shape pin run under a signed-in session (the shape itself —
|
|
``summary`` for BOTH fixture docs, phase 118 A2 — is what the pins
|
|
carry).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import re
|
|
from pathlib import Path
|
|
from threading import Thread
|
|
from typing import Any
|
|
|
|
from playwright.sync_api import Page, expect
|
|
from sqlalchemy import text
|
|
|
|
from app.config import Settings
|
|
from app.db import SessionLocal
|
|
from app.rag.importer import ImportSummary, import_sources
|
|
from app.rag.llm import LLMClient
|
|
from e2e.auth_helpers import login
|
|
from tests.e2e.mock_llm import TOKEN_RE
|
|
|
|
REPO = Path(__file__).resolve().parents[2]
|
|
FIXTURES = REPO / "tests" / "fixtures" / "summary_kb"
|
|
|
|
SENTINEL = "RESE-SUMMARY-SENTINEL-7f3a"
|
|
SOURCE = "summary_kb"
|
|
YAML_PATH = "quadlet/qwen-llamacpp.yaml"
|
|
MD_PATH = "notes/qwen-llamacpp-notes.md"
|
|
|
|
#: Encoded viewer URL query values (slashes come out as %2F, same as the
|
|
#: chips / Sources table links build them).
|
|
YAML_URL_PATH = "quadlet%2Fqwen-llamacpp.yaml"
|
|
MD_URL_PATH = "notes%2Fqwen-llamacpp-notes.md"
|
|
|
|
|
|
# --- 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 _reset_db_and_import(mock_llm: int) -> ImportSummary:
|
|
"""Truncate the KB (and query log + steering) and re-import the
|
|
summary_kb fixtures — both docs summarized (phase 118, A2: the
|
|
markdown doc too)."""
|
|
with SessionLocal() as db:
|
|
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
|
|
db.commit()
|
|
summary = _run_in_thread(_import_fixtures(mock_llm))
|
|
assert summary.added == 2 # yaml + md
|
|
assert summary.summaries == 2 and summary.summary_errors == 0 # A2: md too
|
|
assert summary.errors == 0
|
|
return summary
|
|
|
|
|
|
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 _summary_lines(source: str, path: str) -> tuple[str, str]:
|
|
"""(digest line, pointer line) of the stored summary for a fixture."""
|
|
expected = _expected_summary(
|
|
(FIXTURES / path).read_text(encoding="utf-8"), source, path
|
|
)
|
|
digest_line, pointer_line = expected.split("\n", 1)
|
|
return digest_line, pointer_line
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Full page: labeled Summary panel ABOVE the original, both visible
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_full_page_shows_summary_and_original_together(
|
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
|
) -> None:
|
|
"""The dedicated viewer for a summarized yaml shows the labeled
|
|
Summary panel (deterministic mock digest) and, below it, the FULL
|
|
original content — its tail sentinel, which the 24-token digest
|
|
cannot contain, is rendered in the raw ``<pre>``."""
|
|
_reset_db_and_import(mock_llm)
|
|
page.set_default_timeout(30_000)
|
|
login(page, app_url, next="/") # phase 79: the viewer content is gated
|
|
|
|
digest_line, pointer_line = _summary_lines(SOURCE, YAML_PATH)
|
|
page.goto(f"{app_url}/document.html?source={SOURCE}&path={YAML_URL_PATH}")
|
|
|
|
# Title + meta row unchanged by the phase (badge row is untouched).
|
|
expect(page.locator("#doc-title")).to_have_text("qwen-llamacpp")
|
|
expect(page.locator("#doc-meta .doc-source-badge")).to_have_text(SOURCE)
|
|
expect(page.locator("#doc-meta .format-badge")).to_have_text("yaml")
|
|
expect(page.locator("#doc-meta .doc-path")).to_have_text(YAML_PATH)
|
|
|
|
# Exactly one labeled Summary section — a real <section aria-label>
|
|
# with a visible heading (UI Structure Check: labeled section).
|
|
panel = page.locator(".doc-summary")
|
|
expect(panel).to_have_count(1)
|
|
expect(panel).to_be_visible()
|
|
expect(panel).to_have_attribute("aria-label", "Summary")
|
|
expect(panel.locator(".doc-summary-title")).to_have_text("Summary")
|
|
|
|
# The panel carries the DETERMINISTIC digest (byte-stable, no real
|
|
# LLM) — digest line + code pointer line — and nothing from the raw
|
|
# body beyond the first 24 tokens ("healthcheck" is deeper in the
|
|
# file, so the panel cannot be the original content).
|
|
expect(panel).to_contain_text(digest_line)
|
|
expect(panel).to_contain_text(pointer_line)
|
|
expect(panel).not_to_contain_text("healthcheck")
|
|
|
|
# The ORIGINAL is fully visible: the raw yaml in its <pre>, including
|
|
# the tail sentinel that sits outside the digest.
|
|
pre = page.locator("#doc-content pre.doc-raw")
|
|
expect(pre).to_have_count(1)
|
|
expect(pre).to_contain_text(SENTINEL)
|
|
expect(pre).to_contain_text("healthcheck")
|
|
|
|
# The panel sits ABOVE the content: first child of #doc-content.
|
|
order = page.evaluate(
|
|
"() => [...document.querySelector('#doc-content').children]"
|
|
".map((el) => el.className)"
|
|
)
|
|
assert order[0] == "doc-summary", f"panel not first: {order}"
|
|
assert "doc-raw" in order and order.index("doc-raw") > order.index("doc-summary")
|
|
|
|
# Theme (phase 08, dark-red rebrand 2026-08-28): panel on the
|
|
# surface colour, title in brand-ink (--brand-ink #fca5a5 on
|
|
# --surface #1a0f0f = 9.0:1 ≥4.5:1), and the panel matches the
|
|
# raw-content width — it does not break the content layout.
|
|
bg = panel.evaluate("el => getComputedStyle(el).backgroundColor")
|
|
assert bg == "rgb(26, 15, 15)", f"panel not on the --surface colour (rebrand #1a0f0f): {bg}"
|
|
title_color = panel.locator(".doc-summary-title").evaluate(
|
|
"el => getComputedStyle(el).color"
|
|
)
|
|
assert title_color == "rgb(252, 165, 165)", f"title not brand-ink: {title_color}"
|
|
panel_box = panel.bounding_box()
|
|
pre_box = pre.bounding_box()
|
|
assert panel_box is not None and pre_box is not None
|
|
assert abs(panel_box["width"] - pre_box["width"]) < 2, (
|
|
f"panel width {panel_box['width']} != raw width {pre_box['width']}"
|
|
)
|
|
assert panel_box["y"] < pre_box["y"], "panel must sit above the content"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Modal from the Sources table: same panel + content; "Full page"
|
|
# agrees (the two surfaces cannot drift — one shared renderer)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_modal_shows_panel_and_full_page_agrees(
|
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
|
) -> None:
|
|
"""Admin opens the yaml from the Sources table row: the SAME-PAGE
|
|
modal shows the Summary panel + the sentinel-bearing original; the
|
|
'Full page' escape hatch lands on the dedicated page with the panel
|
|
still there — both surfaces render through one shared core."""
|
|
_reset_db_and_import(mock_llm)
|
|
page.set_default_timeout(30_000)
|
|
login(page, app_url) # phase 16: the Sources catalog is admin-only
|
|
|
|
digest_line, pointer_line = _summary_lines(SOURCE, YAML_PATH)
|
|
# Phase 97: the catalog is the drill-down tree — the row lives at
|
|
# the quadlet level (the drill is the only change).
|
|
for name in (SOURCE, "quadlet"):
|
|
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
|
|
row = page.locator("#docs-tbody tr", has_text=YAML_PATH)
|
|
expect(row).to_have_count(1)
|
|
before = len(page.context.pages)
|
|
row.locator("td:nth-child(2) a.doc-link").click()
|
|
assert len(page.context.pages) == before, "row link must not open a new tab"
|
|
|
|
# The modal shows the panel + the original content, in order.
|
|
expect(page.locator(".doc-modal")).to_be_visible()
|
|
expect(page.locator("#doc-modal-title")).to_have_text("qwen-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_have_attribute("aria-label", "Summary")
|
|
expect(modal_panel).to_contain_text(digest_line)
|
|
expect(modal_panel).to_contain_text(pointer_line)
|
|
modal_pre = page.locator("#doc-modal pre.doc-raw")
|
|
expect(modal_pre).to_have_count(1)
|
|
expect(modal_pre).to_contain_text(SENTINEL)
|
|
order = page.evaluate(
|
|
"() => [...document.querySelector('#doc-modal-content').children]"
|
|
".map((el) => el.className)"
|
|
)
|
|
assert order[0] == "doc-summary", f"panel not first in the modal: {order}"
|
|
|
|
# "Full page" (the #doc-modal-open escape hatch — target=_blank by
|
|
# design) opens the dedicated viewer in a NEW TAB, and the panel is
|
|
# there with the same digest and the same sentinel: the surfaces
|
|
# agree (one shared renderer, no drift).
|
|
with page.expect_popup() as popup_info:
|
|
page.click("#doc-modal-open")
|
|
full_page = popup_info.value
|
|
expect(
|
|
full_page
|
|
).to_have_url(
|
|
re.compile(
|
|
r"^" + re.escape(f"{app_url}/document.html")
|
|
+ r"\?source=summary_kb&path=quadlet%2Fqwen-llamacpp\.yaml$"
|
|
),
|
|
timeout=30_000,
|
|
)
|
|
full_panel = full_page.locator(".doc-summary")
|
|
expect(full_panel).to_have_count(1)
|
|
expect(full_panel).to_be_visible()
|
|
expect(full_panel).to_contain_text(digest_line)
|
|
expect(full_panel).to_contain_text(pointer_line)
|
|
expect(full_page.locator("#doc-content pre.doc-raw")).to_contain_text(SENTINEL)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. No-summary control: the markdown doc renders exactly as before
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_markdown_doc_has_a_summary_panel(
|
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
|
) -> None:
|
|
"""Phase 118 (A2): the markdown doc is summarized TOO — the SAME
|
|
labeled ``.doc-summary`` panel (the deterministic mock digest +
|
|
pointer line) renders above the rendered markdown on BOTH surfaces
|
|
(the phase-30 "markdown never summarized" control is retired with
|
|
the non-markdown-only scope)."""
|
|
_reset_db_and_import(mock_llm)
|
|
page.set_default_timeout(30_000)
|
|
login(page, app_url, next="/") # phase 79: the viewer content is gated
|
|
|
|
digest_line, pointer_line = _summary_lines(SOURCE, MD_PATH)
|
|
|
|
# Full page: the labeled panel above the markdown column (the same
|
|
# contract as the yaml — one shared renderer).
|
|
page.goto(f"{app_url}/document.html?source={SOURCE}&path={MD_URL_PATH}")
|
|
panel = page.locator(".doc-summary")
|
|
expect(panel).to_have_count(1)
|
|
expect(panel).to_be_visible()
|
|
expect(panel).to_have_attribute("aria-label", "Summary")
|
|
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(page.locator("#doc-content .doc-md")).to_have_count(1)
|
|
expect(page.locator("#doc-content")).to_contain_text(
|
|
"came out of a week of"
|
|
)
|
|
order = page.evaluate(
|
|
"() => [...document.querySelector('#doc-content').children]"
|
|
".map((el) => el.className)"
|
|
)
|
|
assert order == ["doc-summary", "doc-md"], f"panel not first: {order}"
|
|
|
|
# Modal: same story — the panel above .doc-md (the session is
|
|
# already signed in — the form login above).
|
|
page.goto(f"{app_url}/sources.html")
|
|
# Phase 97: the re-mount lands on the tree's top level — drill to
|
|
# the notes level where the row lives (the drill is the only
|
|
# change).
|
|
for name in (SOURCE, "notes"):
|
|
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
|
|
row = page.locator("#docs-tbody tr", has_text=MD_PATH)
|
|
expect(row).to_have_count(1)
|
|
row.locator("td:nth-child(2) a.doc-link").click()
|
|
expect(page.locator(".doc-modal")).to_be_visible()
|
|
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)
|
|
expect(modal_panel).to_contain_text(pointer_line)
|
|
expect(page.locator("#doc-modal .doc-md")).to_have_count(1)
|
|
expect(page.locator("#doc-modal-content")).to_contain_text(
|
|
"came out of a week of"
|
|
)
|
|
order = page.evaluate(
|
|
"() => [...document.querySelector('#doc-modal-content').children]"
|
|
".map((el) => el.className)"
|
|
)
|
|
assert order == ["doc-summary", "doc-md"], f"markdown modal order: {order}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. API shape (cheap, via the page context's request — signed in since
|
|
# phase 79 gated the endpoint)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_content_api_summary_shape(
|
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
|
) -> None:
|
|
"""``GET /api/documents/content`` carries ``summary`` — the string
|
|
for BOTH fixture docs (phase 118, A2: the markdown doc too). Phase
|
|
79 superseded the phase-16 soft rule: the endpoint is require_user,
|
|
so the pin runs under the form-login session."""
|
|
_reset_db_and_import(mock_llm)
|
|
login(page, app_url, next="/")
|
|
|
|
digest_line, _ = _summary_lines(SOURCE, YAML_PATH)
|
|
expected_summary = f"{digest_line}\nSource: {SOURCE}/{YAML_PATH}"
|
|
|
|
resp = page.context.request.get(
|
|
f"{app_url}/api/documents/content?source={SOURCE}&path={YAML_URL_PATH}"
|
|
)
|
|
assert resp.status == 200
|
|
body = resp.json()
|
|
assert body["source"] == SOURCE
|
|
assert body["path"] == YAML_PATH
|
|
assert body["format"] == "yaml"
|
|
assert isinstance(body["summary"], str)
|
|
assert body["summary"] == expected_summary
|
|
assert SENTINEL in body["content"] # full original, never just the digest
|
|
|
|
resp_md = page.context.request.get(
|
|
f"{app_url}/api/documents/content?source={SOURCE}&path={MD_URL_PATH}"
|
|
)
|
|
assert resp_md.status == 200
|
|
md_body = resp_md.json()
|
|
assert md_body["format"] == "md"
|
|
# Phase 118 (A2): the markdown doc carries its summary too — the
|
|
# same byte-stable digest + pointer line.
|
|
md_digest_line, _ = _summary_lines(SOURCE, MD_PATH)
|
|
assert md_body["summary"] == (
|
|
f"{md_digest_line}\nSource: {SOURCE}/{MD_PATH}"
|
|
)
|
|
assert "came out of a week of" in md_body["content"]
|