All gates verified. Final report: **Phase 163 — final verification pass (all 5 tasks already complete; verification-only run)** - Verified D1–D4 in code: `page_strip_mode` column + migration 0027, `DocContent` absent-never-null rule keyed off `page_count`; `_persist_pdf_pages` render → atomic tmp+`os.replace` writes → stale-tail-last + `strip_class` stamp; `_regate_pdf_strip` on both unchanged paths (mirror gate, heal, repair, fail-soft, one INFO line only when acting); viewer mode branch + Text/Pages toggle (Text default, `aria-pressed`, no innerHTML, lazy imgs). - `uv run pytest --cov=app --cov-report=term-missing` → **3796 tests, exit 0, coverage 99%** (>90%) - `uv run pytest tests/e2e/test_pdf_strip_additive_selfheal.py -v --no-cov` → **5 passed** in isolation - `test_pdf_pages_in_sources + test_pictorial_pdf + test_garbage_pdf` → 10 passed; `test_document_viewer(_context) + test_image_documents` → 20 passed - `uv run ruff check .` clean; `uv run pyright` → 0 errors; alembic **0027 downgrade→upgrade CLI round-trip clean** (+ integration pins) - Completion criteria: additive view w/ toggle ✅ · primary DOM byte-identity ✅ · write-first order/mid-write-failure/atomic pins ✅ · heal + repair E2E ✅ · zero vision/LLM on fast path (mock-captured) ✅ · non-qualifying payload byte-identity ✅ · docs pass ✅ - **Documentation**: README.md + AGENTS.md reviewed in full — the phase's updates are accurate; all config-table defaults cross-checked against `app/config.py` (no drift), every page route, script, and E2E command referenced exists, every `.env.example` var documented — **verified consistent, no changes needed**. - Notable: live `Letumdei` repair is owner-runtime post-deploy per D5 (next production Sync self-heals; no live changes made); the modified `.agents/screenshots/doc_modal_scrollbar_1280.png` is an E2E-regenerated artifact left for the pipeline commit. - **Next pending phase:** `164_chat_error_honesty`
801 lines
37 KiB
Python
801 lines
37 KiB
Python
"""Phase 140 task 04 E2E (Playwright, mock-only): the pictorial PDF —
|
|
sparse pages rendered + vision-described into the indexed content.
|
|
|
|
Run in isolation (DB must be up: ``podman compose up -d db``):
|
|
|
|
uv run pytest tests/e2e/test_pictorial_pdf.py -v --no-cov
|
|
|
|
The dedicated suite for ``140_pictorial_pdf_pages`` (A16 — one Playwright
|
|
file per phase, run in isolation): it proves the owner's TODO L5 scenario
|
|
end to end on a SYNTHETIC fixture (the owner's real
|
|
``ikea_instructions.tar`` is pinned at the integration level, task 03 —
|
|
the E2E keeps the deterministic canned-reply premise): a 2-page PDF
|
|
built with PyMuPDF (the ``test_pdf_support.py`` recipe) —
|
|
|
|
* page 1 = a 700+ char prose paragraph (over the 100-char sparseness
|
|
bar — NOT a pictorial candidate),
|
|
* page 2 = the 14-char ``Illustration 2`` marker + a drawn VECTOR figure
|
|
(a rectangle + an arrow — the IKEA class: vector-drawn pictograms,
|
|
ZERO embedded raster images, so the phase-122/137 embedded-image
|
|
pipeline sees nothing; the thin text IS a candidate),
|
|
|
|
tarred as a single-root-member ``.tar`` (the owner's fixture's exact
|
|
format — the upload gate admits it) and arriving through the REAL admin
|
|
upload flow → the RAG page's Sync button:
|
|
|
|
* (a) the document appears on the Sources page and the document viewer
|
|
opens the PDF in its ADDITIVE view (phase 163 — a described sparse
|
|
page makes the doc page-QUALIFYING, and a usable text layer makes
|
|
the strip ADDITIVE: the text stays, the pages sit behind a
|
|
Text/Pages toggle — LOCKED A1/A6): the Text view first (``.doc-raw``
|
|
+ ``.doc-summary`` visible, ``Text`` pressed), the strip one
|
|
``Pages`` click away — two ``.doc-page`` figures with loaded lazy
|
|
images + ``Page N`` captions, the text view hidden while it shows;
|
|
the described content itself
|
|
(page 1's prose WITHOUT an illustration block, page 2 WITH
|
|
``[Page 2 illustration: <the mock's canned PICTORIAL_PAGE_ANSWER>]``
|
|
— the task-02 ``PICTORIAL_PAGE_MODE`` branch, deterministic) stays in
|
|
the DB: the seed asserts it BYTE-EXACT against the fixture's own
|
|
extraction (the candidates computed from
|
|
``extract_pdf_page_texts`` + ``pictorial_pages`` IN the test, no
|
|
hardcoded page list) and the chat test below proves it still reaches
|
|
the model;
|
|
* (b) a chat question that ONLY the illustration answers ("how are the
|
|
two parts joined on page two?" — the phrase lives NOWHERE in the text
|
|
layer) grounds (the described page's embedding retrieves — the mock's
|
|
deterministic token-overlap cosine clears the mock-calibrated 0.30
|
|
gate; FTS corroborates) and, scripted through the house seeded-read
|
|
convention (phase 119 A1 — a zero-read grounded turn chips NOTHING, so
|
|
the PDF must be READ to land in ``done.sources``), the SSE completes
|
|
without deflecting: the ``done`` event's ``sources`` carries the PDF
|
|
and the answer echoes the read result VERBATIM — the
|
|
``[Page 2 illustration: …]`` block and its phrase reach the model and
|
|
land in the answer;
|
|
* (c) the sync's ``detail`` JSON keeps its pre-phase keys EXACTLY (no
|
|
new field — the ``pdfs_pictorial`` / ``pdf_pictorial_pages`` counters
|
|
are log-only, PLAN §9, the phase-133 convention).
|
|
|
|
House pattern: one module app over the shared E2E Postgres (its own
|
|
port, module env — the conftest session app is never started in this
|
|
isolated run, so no port clash). The three tests SHARE the module seed
|
|
(one source, one document), so the shared registry + KB is truncated
|
|
ONCE, after the last test (a module-granularity autouse — a per-test
|
|
truncate would wipe the seed from under its siblings), with a
|
|
no-running-sync wait so a leaked run cannot race it.
|
|
|
|
App env: ``BOR_IMPORT_EXTENSIONS`` pinned WITH the ``pdf`` token (the
|
|
phase-126 convention — without the token the walk is blind to the file)
|
|
and ``BOR_PDF_PICTORIAL`` pinned EXPLICITLY to ``1``: the conftest pins
|
|
it OFF in ``os.environ`` for the pre-phase-140 story suites (their
|
|
synthetic PDFs carry sparse pages — a default-ON app would move their
|
|
byte-exact pins), and this suite is the documented opt-in (the
|
|
conftest's phase-140 comment). ``BOR_IMAGES`` is pinned ``false`` — the
|
|
embedded-image pipeline is out of scope (the fixture's figure is
|
|
vector-drawn; it yields no image doc either way). The byte-exact
|
|
content assertion in the seed is the toggle's premise witness: without
|
|
the opt-in, page 2 would lack its illustration block and the seed
|
|
fails loudly.
|
|
|
|
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the described
|
|
block is the mock's deterministic ``PICTORIAL_PAGE_MODE`` canned reply
|
|
(``PICTORIAL_PAGE_ANSWER``), so the byte-exact content + echo
|
|
assertions are exact only against it.
|
|
|
|
Test → observable mapping (Playwright Mapping Rule):
|
|
1. ``test_sources_page_and_viewer_show_the_described_page`` — the
|
|
Sources page reads 1 doc / 3 chunks (2 page-anchored content chunks +
|
|
the summary chunk — no image doc), the drill-down table lists the PDF
|
|
as a plain text row (LOCKED A4 — a text document, no thumbnail), and
|
|
the same-page document viewer (the row's path link) opens the
|
|
PHASE-163 ADDITIVE VIEW under the METADATA title (the fixture is
|
|
page-qualifying with a usable text layer — the pictorial pass
|
|
described its sparse page): the TEXT view first — ``.doc-raw`` +
|
|
``.doc-summary`` visible with the ``[Page 2 illustration: …]``
|
|
block among them, the toggle's ``Text`` pressed, the strip mounted
|
|
but hidden; pressing ``Pages`` shows exactly two ``.doc-page``
|
|
figures, each a LOADED lazy ``<img>`` (naturalWidth > 0) with the
|
|
``Page N of <title>`` alt + the ``Page N`` caption while the text
|
|
view hides (mounted for the flip-back — the text is never
|
|
destroyed).
|
|
2. ``test_chat_question_grounds_and_echoes_the_illustration`` — the raw
|
|
SSE over the scripted seeded-read question (httpx — the ``done``
|
|
event is the wire lens the task pins; the UI flow's premise is the
|
|
module seed): the stream settles ``done`` with ``deflected`` false
|
|
and NO ``error`` frame, EXACTLY one ``tool`` frame (the scripted
|
|
``read`` on the PDF; the doc is far under the read cap — no
|
|
truncation, so no ``tool_result`` frame), the ``done`` event's
|
|
``sources`` is EXACTLY the PDF (the citation surface — read docs
|
|
only, phase 119 A1), and the joined deltas echo the read result
|
|
VERBATIM — the ``Document <source>/<path>:`` header and the
|
|
``[Page 2 illustration: …]`` block with the canned phrase (the
|
|
described page's content reaches the model and lands in the answer).
|
|
3. ``test_sync_detail_keeps_the_pre_phase_keys`` — the module run's
|
|
terminal ``detail`` (``GET /api/sync/status`` — the same object the
|
|
UI's "1 added" line renders from) carries the pre-phase key set
|
|
EXACTLY: the ``pdfs_pictorial`` / ``pdf_pictorial_pages`` counters
|
|
never ride the wire (the stats log-only convention), and the run's
|
|
own file counters read 1 file / 1 added / 0 unchanged / 0 pruned /
|
|
0 errors.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import time
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pymupdf as fitz # PyMuPDF — the synthetic-fixture builder (the house recipe)
|
|
import pytest
|
|
from playwright.sync_api import Browser, Page, expect
|
|
from sqlalchemy import select, text
|
|
|
|
from app.config import Settings
|
|
from app.db import SessionLocal
|
|
from app.models import Document
|
|
from app.rag.pdf import (
|
|
PAGE_SEPARATOR,
|
|
extract_pdf_page_texts,
|
|
pictorial_pages,
|
|
)
|
|
from e2e.auth_helpers import login
|
|
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
|
|
from e2e.mock_llm import PICTORIAL_PAGE_ANSWER
|
|
|
|
REPO = Path(__file__).resolve().parents[2]
|
|
SOURCE_NAME = "e2e-pictorial" # the archive stem (archive_source_name)
|
|
DOC_PATH = "instructions.pdf"
|
|
PDF_TITLE = "Assembly Instructions" # the metadata title (LOCKED A2)
|
|
|
|
#: Page 1 — a 700+ char prose paragraph (over the 100-char sparseness
|
|
#: bar — NOT a candidate). Deliberately carries NO "two parts joined"
|
|
#: content: the phrase the question targets lives ONLY in the
|
|
#: description the vision pass appends to page 2 (the owner's L5
|
|
#: scenario — the illustration answers what the text layer cannot).
|
|
PAGE_ONE = (
|
|
"This manual covers the complete flat-pack furniture frame, its "
|
|
"shipped hardware kit, and the safety checks a first-time assembler "
|
|
"should run before starting. The box holds the two side panels, the "
|
|
"top board, the adjustable leg set, eight cam locks, fourteen wood "
|
|
"screws, two bolts, and the wall-anchoring strap. Work on a flat "
|
|
"surface with soft flooring, keep the hardware sorted by size, and "
|
|
"read every step through before you begin so you know which panel "
|
|
"is which. The frame stands roughly a metre tall once assembled, "
|
|
"weights under twelve kilograms, and needs no further tools beyond "
|
|
"the included Allen key. Keep the small parts away from children "
|
|
"until the final step is done and the cam locks are fully seated."
|
|
)
|
|
assert len(PAGE_ONE) > 500, "page 1 must be a 500+ char paragraph (the task's recipe)"
|
|
|
|
#: Page 2 — the sparse page: the marker line is ALL the text layer
|
|
#: carries (14 stripped chars, under the 100-char bar — the candidate);
|
|
#: the figure is drawn vector (no raster image).
|
|
PAGE_TWO = "Illustration 2"
|
|
|
|
#: The stored illustration block (LOCKED A4) — the mock's canned
|
|
#: ``PICTORIAL_PAGE_MODE`` reply, deterministic.
|
|
ILLUSTRATION_BLOCK = f"[Page 2 illustration: {PICTORIAL_PAGE_ANSWER}]"
|
|
|
|
#: The grounded question (b): the house seeded-read call (phase 119 A1 —
|
|
#: a zero-read grounded turn chips NOTHING, so the PDF must be READ to
|
|
#: earn its ``done.sources`` citation) + the natural question, whose
|
|
#: tokens ("two parts joined page") overlap ONLY the illustrated page's
|
|
#: content (the mock's token-overlap cosine grounds; the text layer
|
|
#: alone would answer nothing).
|
|
QUESTION = (
|
|
f"Read the suggested document: read {SOURCE_NAME}/{DOC_PATH} — "
|
|
"how are the two parts joined on page two?"
|
|
)
|
|
|
|
#: The sync ``detail`` key set EXACTLY as pre-phase 140 (the
|
|
#: ``app/api/sync.py`` terminal detail — phase 126/133 shape). The
|
|
#: phase-140 counters are log-only and must NOT appear (c).
|
|
PRE_PHASE_DETAIL_KEYS = frozenset(
|
|
{
|
|
"files",
|
|
"added",
|
|
"updated",
|
|
"unchanged",
|
|
"pruned",
|
|
"errors",
|
|
"chunks",
|
|
"summaries",
|
|
"summary_errors",
|
|
"dates_updated",
|
|
"overview",
|
|
"sources_version",
|
|
}
|
|
)
|
|
|
|
#: Phase 79 (task 04, full inventory): the conftest session app owns its
|
|
#: port in a combined run — this module app binds its own port instead
|
|
#: (a same-port second uvicorn dies on bind and would drive the wrong
|
|
#: server). Env-overridable.
|
|
APP_PORT = int(os.environ.get("E2E_APP_PORT_PICTPDF", "8155"))
|
|
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
|
|
|
UPLOAD_TIMEOUT_MS = 30_000
|
|
SYNC_TIMEOUT_MS = 60_000
|
|
SYNCED_LABEL = re.compile(r"^Synced \d{1,2}:\d{2}$")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixture builder (the ``test_pdf_support.py`` PyMuPDF recipe, the
|
|
# owner's class: a dense prose page + a sparse vector-illustrated page)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _build_pdf(path: Path) -> None:
|
|
"""ONE 2-page PDF (the task's recipe): page 1 = the 700+ char prose
|
|
paragraph (``insert_textbox`` — word-wrapped, no mid-word breaks in
|
|
the extraction), page 2 = the ``Illustration 2`` marker + a drawn
|
|
VECTOR figure (a rectangle + a line arrow with a two-stroke head —
|
|
the IKEA pictogram class: ``get_image_info()`` sees ZERO embedded
|
|
raster images, the phase-122/137 pipeline is blind to it)."""
|
|
doc = fitz.open()
|
|
page1 = doc.new_page()
|
|
rc = page1.insert_textbox(fitz.Rect(72, 72, 523, 772), PAGE_ONE, fontsize=11)
|
|
assert rc >= 0, "the page-1 paragraph must fit its text box"
|
|
page2 = doc.new_page()
|
|
page2.insert_text((72, 72), PAGE_TWO)
|
|
# The vector figure: a rectangle (a part) + an arrow (the join).
|
|
page2.draw_rect(fitz.Rect(100, 120, 300, 220))
|
|
page2.draw_line(fitz.Point(320, 170), fitz.Point(470, 170))
|
|
page2.draw_line(fitz.Point(470, 170), fitz.Point(450, 158))
|
|
page2.draw_line(fitz.Point(470, 170), fitz.Point(450, 182))
|
|
doc.set_metadata({"title": PDF_TITLE})
|
|
doc.save(path)
|
|
doc.close()
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def pdf_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
|
"""The fixture PDF (module-scoped, built once) — the local copy the
|
|
byte-exact content assertion computes against (the app indexes the
|
|
unpacked copy of the SAME bytes)."""
|
|
root = tmp_path_factory.mktemp("bor_pict_pdf")
|
|
pdf = root / DOC_PATH
|
|
_build_pdf(pdf)
|
|
# The premise pins (deterministic — the pass's candidate set and the
|
|
# gate's own contract, computed from the fixture itself): page 1 is
|
|
# prose (no candidate), page 2 is sparse (the candidate).
|
|
page_texts = extract_pdf_page_texts(pdf)
|
|
candidates = pictorial_pages(
|
|
page_texts,
|
|
min_chars=Settings.model_fields["pdf_pictorial_min_chars"].default,
|
|
)
|
|
assert candidates == [2], (
|
|
f"the fixture's sparse-page premise broke — candidates={candidates}"
|
|
)
|
|
return pdf
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def tar_path(pdf_path: Path) -> Path:
|
|
"""The fixture archive: ONE PDF at the root (the ``.tar`` format —
|
|
the owner's ``ikea_instructions.tar`` class; the upload gate admits
|
|
it) — source ``e2e-pictorial``, document path ``instructions.pdf``."""
|
|
archive = pdf_path.parent / f"{SOURCE_NAME}.tar"
|
|
with tarfile.open(archive, "w") as tf:
|
|
tf.add(pdf_path, arcname=DOC_PATH)
|
|
return archive
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Module app (the conftest env pattern + the phase-140 knobs)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _app_env(mock_llm: int, app_port: int, scratch: Path) -> dict[str, str]:
|
|
"""The conftest app env (leak guards included) with the phase-140
|
|
knobs: ``BOR_IMPORT_EXTENSIONS`` pinned to a set carrying the
|
|
``pdf`` token (the phase-126 gate — an operator's local gitignored
|
|
``.env`` cannot leak the scope either way), ``BOR_PDF_PICTORIAL``
|
|
pinned EXPLICITLY on (the conftest pins it off in ``os.environ``
|
|
for the pre-phase suites — this suite is the documented opt-in;
|
|
without it the whole premise is void), and ``BOR_IMAGES`` pinned
|
|
off (out of scope — the vector figure yields no image doc anyway)."""
|
|
env = dict(os.environ)
|
|
env.pop("DEBUGPY", None)
|
|
env["BOR_ENVIRONMENT"] = "e2e"
|
|
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
|
|
env["BOR_LLM_BASE_URL"] = (
|
|
"https://aipi.reeseapps.com/v1"
|
|
if USE_REAL_LLM
|
|
else f"http://127.0.0.1:{mock_llm}/v1"
|
|
)
|
|
# Mock-calibrated gate (conftest pattern) — the story's question
|
|
# grounds on these values (see QUESTION).
|
|
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
|
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
|
|
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
|
|
# Phase 67: instant retry waits + the code-default budget.
|
|
env["BOR_LLM_RETRY_DELAY"] = "0"
|
|
env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default)
|
|
env.setdefault(
|
|
"BOR_DATABASE_URL",
|
|
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
|
|
)
|
|
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
|
env["BOR_SESSION_SECRET"] = SESSION_SECRET
|
|
# Leak guards (conftest pattern).
|
|
env["BOR_GIT_SOURCES"] = ""
|
|
env["BOR_DOCS_REPO"] = ""
|
|
env["BOR_SUGGESTIONS"] = json.dumps(
|
|
Settings.model_fields["suggestions"].default
|
|
)
|
|
env["BOR_INPUT_PLACEHOLDER"] = Settings.model_fields["input_placeholder"].default
|
|
env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default
|
|
# Phase 140: the import scope WITH the ``pdf`` token + the toggle
|
|
# under test (the EXPLICIT opt-in — see the module docstring) + the
|
|
# suite-private homes.
|
|
env["BOR_IMPORT_EXTENSIONS"] = "md,markdown,txt,pdf"
|
|
env["BOR_PDF_PICTORIAL"] = "1"
|
|
env["BOR_IMAGES"] = "false"
|
|
env["BOR_UPLOAD_DIR"] = str(scratch / "uploads")
|
|
env["BOR_SOURCES_DIR"] = str(scratch / "checkouts")
|
|
env["BOR_IMAGE_DIR"] = str(scratch / "images")
|
|
return env
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def app_server(
|
|
mock_llm: int, tmp_path_factory: pytest.TempPathFactory
|
|
) -> Iterator[str]:
|
|
"""The app under test (all three tests): one module app with the
|
|
phase-140 env (see :func:`_app_env`)."""
|
|
scratch = tmp_path_factory.mktemp("bor_pict_scratch")
|
|
proc = subprocess.Popen(
|
|
[sys.executable, "-m", "uvicorn", "app.main:app",
|
|
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
|
|
cwd=REPO,
|
|
env=_app_env(mock_llm, APP_PORT, scratch=scratch),
|
|
)
|
|
try:
|
|
_wait_http(f"{APP_URL}/api/health")
|
|
yield APP_URL
|
|
finally:
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def app_url(app_server: str) -> str:
|
|
return app_server
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DB + UI helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _truncate_all() -> None:
|
|
"""Fresh KB + registry (the E2E isolation pattern): the suites share
|
|
one Postgres, and a leftover row would corrupt the counts.
|
|
``sources_meta`` (the KB generation counter) resets with the KB —
|
|
the sync re-creates the row."""
|
|
with SessionLocal() as db:
|
|
db.execute(
|
|
text(
|
|
"TRUNCATE chunks, documents, query_log, steering_notes, "
|
|
"kb_overview, git_sources, folder_summaries, sources_meta"
|
|
)
|
|
)
|
|
db.commit() # without the commit the TRUNCATE rolls back (the house pattern)
|
|
|
|
|
|
def _health_db(app_url: str) -> bool:
|
|
try:
|
|
return httpx.get(f"{app_url}/api/health", timeout=5).json()["db"] == "up"
|
|
except Exception: # noqa: BLE001 — unreachable is the skip case
|
|
return False
|
|
|
|
|
|
def _wait_sync_settled(base_url: str, timeout_s: float = 60.0) -> None:
|
|
"""No background sync may leak across tests (the run state lives in
|
|
the app's memory, and a still-running run would keep importing into
|
|
the NEXT test's truncated KB): wait for the app's sync status to be
|
|
non-running BEFORE the truncate (the phase-96 pattern)."""
|
|
with httpx.Client(base_url=base_url, timeout=5.0) as client:
|
|
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
|
assert r.status_code == 204, r.text
|
|
deadline = time.monotonic() + timeout_s
|
|
while time.monotonic() < deadline:
|
|
r = client.get("/api/sync/status")
|
|
assert r.status_code == 200, r.text
|
|
if r.json()["state"] != "running":
|
|
return
|
|
time.sleep(0.2)
|
|
raise AssertionError(f"a sync was still running at a test boundary: {base_url}")
|
|
|
|
|
|
@pytest.fixture(scope="module", autouse=True)
|
|
def _clean_kb(app_url: str) -> Iterator[None]:
|
|
"""Module-end KB cleanup (the phase-96 pattern, module granularity):
|
|
the three tests SHARE the module seed's single source + document,
|
|
so there is NO per-test truncate (it would wipe the seed from under
|
|
the next test — the per-test pattern belongs to the suites where
|
|
every test imports its own source); instead the shared KB is
|
|
flushed ONCE, after the last test — no rows leak into the next
|
|
suite's run. The pre-wait keeps a leaked sync (the module app is
|
|
still up here) from racing the truncate."""
|
|
yield
|
|
_wait_sync_settled(app_url)
|
|
_truncate_all()
|
|
|
|
|
|
def _ready_for_sync_line(source: str) -> re.Pattern[str]:
|
|
"""Phase 154 (task 02): the ready-for-sync line carrying the
|
|
completion stamp — "Uploaded <source> (<stamp>) — press Sync
|
|
sources to import it." (the fresh 202 settle). In E2E the stamp
|
|
is ALWAYS the same-day ``HH:MM`` form (the run settles within
|
|
seconds; the date-qualified forms are unit-pinned on a fixed
|
|
clock — E2E never depends on the wall clock)."""
|
|
return re.compile(
|
|
rf"^Uploaded {re.escape(source)} \(\d{{1,2}}:\d{{2}}\)"
|
|
r" — press Sync sources to import it\.$"
|
|
)
|
|
|
|
|
|
def _upload_tar(page: Page, app_url: str, archive: Path) -> None:
|
|
"""The real admin upload flow: form login → the git-sources page →
|
|
pick the archive → submit → the terminal result line (phase 90 A3,
|
|
phase 154 task 02: "Uploaded <source> (<stamp>) — press Sync
|
|
sources to import it." — the completion stamp)."""
|
|
login(page, app_url, next="/git-sources.html")
|
|
expect(page).to_have_url(app_url + "/git-sources.html", timeout=30_000)
|
|
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
|
page.set_input_files("#archive-upload-file", str(archive))
|
|
page.click("#archive-upload-btn")
|
|
result = page.locator("#archive-upload-result")
|
|
expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS)
|
|
expect(result).to_have_text(
|
|
_ready_for_sync_line(SOURCE_NAME),
|
|
timeout=UPLOAD_TIMEOUT_MS,
|
|
)
|
|
|
|
|
|
def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
|
|
"""The RAG page's "Sync sources" button (the scan): click →
|
|
"Syncing…" → the terminal "Synced HH:MM" label + the fresh counts
|
|
in #sync-progress (the never-stale contract)."""
|
|
page.goto(app_url + "/sources.html")
|
|
btn = page.locator("#sync-btn")
|
|
expect(btn).to_be_visible(timeout=30_000)
|
|
expect(page.locator("#sync-label")).to_have_text(
|
|
re.compile(r"^Sync sources$|^Synced \d{1,2}:\d{2}$")
|
|
)
|
|
btn.click()
|
|
expect(btn).to_be_disabled()
|
|
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
|
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
|
|
expect(btn).to_be_enabled()
|
|
expect(page.locator("#sync-progress-text")).to_have_text(expected_result)
|
|
|
|
|
|
def _drill_to_source(page: Page, name: str) -> None:
|
|
"""Top level → the source (the file table then holds its direct
|
|
files — ours is at the source root)."""
|
|
page.locator("#folders-tbody .folder-link").first.wait_for(state="visible")
|
|
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
|
|
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=30_000)
|
|
|
|
|
|
def _chat_frames(app_url: str, message: str) -> list[dict[str, Any]]:
|
|
"""One ``POST /api/chat`` turn as RAW SSE frames (the wire lens —
|
|
the house pattern from ``test_honest_deflection.py``): the signed
|
|
admin client streams the turn and parses the ``data: <json>``
|
|
frames (PLAN §4: ``data: <json>\\n\\n``)."""
|
|
with httpx.Client(base_url=app_url, timeout=120.0) as client:
|
|
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
|
assert r.status_code == 204, r.text
|
|
frames: list[dict[str, Any]] = []
|
|
with client.stream(
|
|
"POST", "/api/chat", json={"message": message}, timeout=120.0
|
|
) as r:
|
|
assert r.status_code == 200, r.read().decode()
|
|
buf = ""
|
|
for part in r.iter_text():
|
|
buf += part
|
|
while "\n\n" in buf:
|
|
frame, buf = buf.split("\n\n", 1)
|
|
if frame.strip().startswith("data:"):
|
|
frames.append(
|
|
json.loads(frame.strip().removeprefix("data:").strip())
|
|
)
|
|
assert buf.strip() == "" # the stream ends cleanly on a frame boundary
|
|
return frames
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The module seed: upload + sync the fixture PDF through the real UI,
|
|
# once (the story E2E truncate/re-import-per-module pattern)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def seeded(
|
|
app_url: str,
|
|
browser: Browser,
|
|
tar_path: Path,
|
|
pdf_path: Path,
|
|
) -> Iterator[dict[str, str]]:
|
|
"""Upload the single-PDF tar through the admin UI, then scan it with
|
|
the RAG page's Sync button (the mock's vision model describes the
|
|
sparse page in-process — the ``PICTORIAL_PAGE_MODE`` canned reply).
|
|
The seed asserts the stored document BYTE-EXACT: the fixture's own
|
|
extraction (page blocks, the page-2 candidate carrying the canned
|
|
illustration block) — the candidates computed from
|
|
``extract_pdf_page_texts`` + ``pictorial_pages`` IN the fixture (no
|
|
hardcoded page list). This is the module app's toggle premise
|
|
witness too: without the ``BOR_PDF_PICTORIAL`` opt-in, page 2 would
|
|
lack its block and this fails loudly. Yields the doc's identity."""
|
|
if not _health_db(app_url):
|
|
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
|
|
|
_truncate_all()
|
|
page = browser.new_page(viewport={"width": 1280, "height": 800})
|
|
try:
|
|
_upload_tar(page, app_url, tar_path)
|
|
# The PDF alone (no embedded raster images — the vector figure
|
|
# yields no image doc): one file, one added document.
|
|
_sync_via_ui(page, app_url, expected_result="1 added")
|
|
with SessionLocal() as db:
|
|
doc = db.scalar(
|
|
select(Document).where(
|
|
Document.source == SOURCE_NAME, Document.path == DOC_PATH
|
|
)
|
|
)
|
|
assert doc is not None, "the uploaded PDF must become a document"
|
|
# LOCKED A2: one document per PDF, the metadata title; a TEXT
|
|
# document (no image doc, no image path).
|
|
assert doc.title == PDF_TITLE
|
|
assert doc.is_image is False and doc.image_path is None
|
|
# The candidates (computed from the fixture itself — the
|
|
# deterministic gate, LOCKED A1) + the byte-exact stored
|
|
# content: the extraction's page blocks, the described
|
|
# candidate carrying ``[Page N illustration: <canned>]``
|
|
# (LOCKED A4).
|
|
page_texts = extract_pdf_page_texts(pdf_path)
|
|
candidates = pictorial_pages(
|
|
page_texts,
|
|
min_chars=Settings.model_fields["pdf_pictorial_min_chars"].default,
|
|
)
|
|
assert candidates == [2]
|
|
parts = []
|
|
for p, t in page_texts.items():
|
|
block = t
|
|
if p in candidates:
|
|
block += f"\n\n[Page {p} illustration: {PICTORIAL_PAGE_ANSWER}]"
|
|
parts.append(f"{PAGE_SEPARATOR.format(page=p)}\n{block}")
|
|
expected = "\n\n".join(parts)
|
|
assert doc.content == expected, (
|
|
"the stored content is not the extraction with the "
|
|
"described sparse page — the pictorial pass misfired"
|
|
)
|
|
# The non-markdown doc got its lite summary (the mock's
|
|
# SUMMARY_MODE digest) — the summary chunk exists (the
|
|
# additive view shows the Summary panel; the summary
|
|
# itself stays in the DB regardless).
|
|
assert doc.summary
|
|
# Phase 148: the described sparse page makes this doc
|
|
# page-QUALIFYING — the strip's payload (page_urls) rides
|
|
# on page_count (the 2-page fixture's count).
|
|
assert doc.page_count == 2
|
|
finally:
|
|
page.close()
|
|
yield {"title": PDF_TITLE}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Sources page + document viewer (a): the described page renders
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_sources_page_and_viewer_show_the_described_page(
|
|
page: Page, app_url: str, seeded: dict[str, str]
|
|
) -> None:
|
|
"""The KB total is 1 doc / 3 chunks (the PDF: 2 page-anchored
|
|
content chunks + the phase-30 summary chunk — no image doc; the
|
|
vector figure is invisible to the embedded-image pipeline). The
|
|
drill-down table lists the PDF as a plain text row (LOCKED A4), and
|
|
the same-page document viewer (the row's path link, phase 26)
|
|
opens the PHASE-163 ADDITIVE VIEW (LOCKED A1/A6): the TEXT view
|
|
first — Summary panel + ``.doc-raw`` visible, the Text/Pages
|
|
toggle's ``Text`` pressed — with the ``[Page 2 illustration:
|
|
<canned>]`` block riding the SHOWN text (the text is never hidden
|
|
for the pictorial class). Pressing ``Pages`` reveals the strip —
|
|
two ``.doc-page`` figures, each a loaded lazy ``<img>`` with the
|
|
``Page N of <title>`` alt + the ``Page N`` caption — while the
|
|
text view is hidden (mounted, not removed: flipping back shows it
|
|
with zero refetch). The described content (page 1's prose WITHOUT
|
|
an illustration block, page 2 WITH the block — LOCKED A4) also
|
|
stays in the DB for Q&A: the seed asserted it byte-exact, the chat
|
|
test proves it reaches the model."""
|
|
login(page, app_url) # lands on /sources.html
|
|
expect(page.locator("#stat-docs")).to_have_text("1")
|
|
expect(page.locator("#stat-chunks")).to_have_text("3")
|
|
expect(page.locator("#sources-empty")).to_be_hidden()
|
|
|
|
_drill_to_source(page, SOURCE_NAME)
|
|
expect(page.locator("#docs-tbody tr")).to_have_count(1)
|
|
row = page.locator("#docs-tbody tr").first
|
|
# A plain text row — no thumbnail box (it is a text document, A4).
|
|
expect(row.locator(".kb-doc-thumb")).to_have_count(0)
|
|
|
|
page.locator(f'#docs-tbody a.doc-link:text-is("{DOC_PATH}")').click()
|
|
modal = page.locator("#doc-modal")
|
|
expect(modal).to_be_visible(timeout=30_000)
|
|
expect(page.locator("#doc-modal-title")).to_have_text(PDF_TITLE)
|
|
|
|
# Phase 163 (task 04, LOCKED A1/A6): the page-qualifying fixture
|
|
# with a usable text layer is an ADDITIVE doc — it opens on its
|
|
# TEXT view: Summary panel + content visible, Text pressed, the
|
|
# strip mounted but hidden (its lazy imgs never fetched).
|
|
toggle = modal.locator(".doc-view-toggle")
|
|
expect(toggle).to_have_count(1)
|
|
expect(toggle.get_by_role("button", name="Text")).to_have_attribute(
|
|
"aria-pressed", "true"
|
|
)
|
|
expect(toggle.get_by_role("button", name="Pages")).to_have_attribute(
|
|
"aria-pressed", "false"
|
|
)
|
|
expect(modal.locator("pre.doc-raw")).to_be_visible()
|
|
expect(modal.locator(".doc-summary")).to_be_visible()
|
|
expect(modal.locator(".doc-pages")).to_be_hidden()
|
|
# The canned page description RIDES the shown text (phase-140
|
|
# blocks are part of the content — the additive view never hides
|
|
# the text). The node stays in the DOM, so a visibility-scoped
|
|
# inner_text() check would miss it — use the DOM text.
|
|
assert PICTORIAL_PAGE_ANSWER in modal.evaluate("el => el.textContent"), (
|
|
"the additive text view shows the indexed content including "
|
|
"the described sparse page"
|
|
)
|
|
# …and the strip is one Pages click away (phase 148's ask):
|
|
toggle.get_by_role("button", name="Pages").click()
|
|
strip = modal.locator(".doc-pages")
|
|
expect(strip).to_have_count(1)
|
|
figures = strip.locator(".doc-page")
|
|
expect(figures).to_have_count(2)
|
|
for i in range(2):
|
|
n = i + 1
|
|
img = figures.nth(i).locator("img")
|
|
# The figure still carries its <img> (a lost copy would have
|
|
# been swapped for the "Page N unavailable" note instead).
|
|
expect(img).to_have_count(1)
|
|
expect(img).to_have_attribute("loading", "lazy")
|
|
expect(img).to_have_attribute("alt", f"Page {n} of {PDF_TITLE}")
|
|
img.scroll_into_view_if_needed()
|
|
img.evaluate("el => el.decode()")
|
|
assert img.evaluate("el => el.naturalWidth") > 0, (
|
|
f"page {n} image did not load (the route must serve a real PNG)"
|
|
)
|
|
expect(figures.nth(i).locator("figcaption.doc-page-num")).to_have_text(
|
|
f"Page {n}"
|
|
)
|
|
# While the strip shows, the text view is HIDDEN — mounted for
|
|
# the flip back (LOCKED A1), never removed. No image block (the
|
|
# PDF is not an image document).
|
|
expect(modal.locator("pre.doc-raw")).not_to_be_visible()
|
|
expect(modal.locator(".doc-summary")).not_to_be_visible()
|
|
expect(modal.locator(".doc-image-img")).to_have_count(0)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Chat (b): the question only the illustration answers grounds, and
|
|
# the answer echoes the described phrase
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_chat_question_grounds_and_echoes_the_illustration(
|
|
app_url: str, seeded: dict[str, str]
|
|
) -> None:
|
|
"""The raw SSE over the scripted seeded-read question (phase 119 A1
|
|
— a zero-read grounded turn chips NOTHING, so the PDF must be READ
|
|
to earn its ``done.sources`` citation): the turn GROUNDS (no
|
|
deflection — the described page's embedding retrieves: the mock's
|
|
deterministic token-overlap cosine clears the mock-calibrated 0.30
|
|
gate, FTS corroborates), the mock's scripted ``read`` runs (EXACTLY
|
|
one ``tool`` frame — the doc is far under the read cap, so no
|
|
truncation and no ``tool_result`` frame), and the answer ECHOES the
|
|
read result verbatim — the ``[Page 2 illustration: …]`` block and
|
|
its phrase reach the model and land in the rendered answer (the
|
|
described page's content is what a chat turn can ground on — the
|
|
owner's L5 rationale end to end)."""
|
|
frames = _chat_frames(app_url, QUESTION)
|
|
assert frames, "the SSE stream settled with no frames at all"
|
|
assert not any(f.get("type") == "error" for f in frames), (
|
|
f"the turn errored: {[f for f in frames if f.get('type') == 'error']}"
|
|
)
|
|
|
|
# The scripted read — exactly one tool call, on the PDF.
|
|
tools = [f for f in frames if f.get("type") == "tool"]
|
|
assert len(tools) == 1, f"expected exactly one tool frame: {tools}"
|
|
assert tools[0]["name"] == "read"
|
|
assert tools[0]["argument"] == f"{SOURCE_NAME}/{DOC_PATH}"
|
|
# The doc is far under the read cap — no truncation frame (the echo
|
|
# below therefore carries the FULL content, the block included).
|
|
assert not any(f.get("type") == "tool_result" for f in frames)
|
|
|
|
# The terminal frame: ``done``, NOT deflected.
|
|
done = [f for f in frames if f.get("type") == "done"]
|
|
assert len(done) == 1 and frames[-1] is done[0], (
|
|
f"the stream must settle with exactly one terminal done frame: "
|
|
f"{[f.get('type') for f in frames]}"
|
|
)
|
|
assert done[0]["deflected"] is False, (
|
|
"the question grounded on the described page — a deflection means "
|
|
"the illustrated content did not retrieve"
|
|
)
|
|
# The ``done`` event's ``sources`` — the citation surface (read docs
|
|
# only, phase 119 A1) — carries EXACTLY the PDF.
|
|
refs = done[0]["sources"]
|
|
assert [(r["source"], r["path"]) for r in refs] == [(SOURCE_NAME, DOC_PATH)], (
|
|
f"the done event's sources must be exactly the read PDF: {refs}"
|
|
)
|
|
assert refs[0]["title"] == PDF_TITLE
|
|
|
|
# The answer (the joined deltas) echoes the read result VERBATIM —
|
|
# the header line and the described block with the canned phrase.
|
|
answer = "".join(f["text"] for f in frames if f.get("type") == "delta")
|
|
assert "Here's what the read returned:" in answer
|
|
assert f"Document {SOURCE_NAME}/{DOC_PATH}:" in answer
|
|
assert ILLUSTRATION_BLOCK in answer, (
|
|
"the answer does not echo the described illustration block — "
|
|
"the vision pass's content never reached the model"
|
|
)
|
|
assert PICTORIAL_PAGE_ANSWER in answer # the phrase itself (the task's pin)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Sync detail (c): the pre-phase key set is untouched (stats are
|
|
# log-only)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_sync_detail_keeps_the_pre_phase_keys(
|
|
app_url: str, seeded: dict[str, str]
|
|
) -> None:
|
|
"""The module run's terminal ``detail`` (``GET /api/sync/status`` —
|
|
the same object the UI's "1 added" line renders from) keeps the
|
|
pre-phase key set EXACTLY: the phase-140 counters
|
|
(``pdfs_pictorial`` / ``pdf_pictorial_pages``) are log-only (PLAN
|
|
§9, the phase-133 convention) and must NOT ride the wire, and the
|
|
run's own file counters read 1 file / 1 added / 0 unchanged /
|
|
0 pruned / 0 errors (the single PDF, a fresh KB)."""
|
|
with httpx.Client(base_url=app_url, timeout=30.0) as client:
|
|
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
|
assert r.status_code == 204, r.text
|
|
r = client.get("/api/sync/status")
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
assert body["state"] == "success", body
|
|
detail = body["detail"]
|
|
# The stats log-only convention — the pictorial counters never
|
|
# reach the wire.
|
|
assert "pdfs_pictorial" not in detail, (
|
|
"the log-only pdfs_pictorial counter leaked into the sync detail"
|
|
)
|
|
assert "pdf_pictorial_pages" not in detail, (
|
|
"the log-only pdf_pictorial_pages counter leaked into the sync detail"
|
|
)
|
|
# The shape itself — the pre-phase key set EXACTLY (no new field).
|
|
assert set(detail) == set(PRE_PHASE_DETAIL_KEYS), (
|
|
f"the sync detail shape changed — got {sorted(detail)}"
|
|
)
|
|
# The run's own file counters (a fresh KB: one file, one added).
|
|
assert detail["files"] == 1, detail
|
|
assert detail["added"] == 1, detail
|
|
assert detail["unchanged"] == 0, detail
|
|
assert detail["pruned"] == 0, detail
|
|
assert detail["errors"] == 0, detail
|