All verification complete — everything is 100% green. Final report: **Phase 137 final verification pass — all tasks already complete, all criteria verified** - **Full suite:** `uv run pytest --cov=app --cov-report=term-missing` → 3272 passed, exit 0; `app/` coverage **99%** (>90% ✓) - **Phase E2E in isolation:** `uv run pytest tests/e2e/test_pdf_image_context.py -v --no-cov` → **4 passed** (forward context, display line, standalone parity, both-blocks markdown case) - **Pre-existing suites in isolation:** `test_pdf_support.py` + `test_image_anchor_context.py` + `test_image_documents.py` → **14 passed**; ripple-edited `test_garbage_pdf.py` → **3 passed** - **Lint/types:** `uv run ruff check .` → all passed; `uv run pyright` → 0 errors **Completion criteria:** (1) forward-path context in vision msg + stored `image_context`/summary — ✓ (unit builder/seam + integration fake-LLM + E2E); (2) both blocks document-first + standalone describe byte-identical — ✓ (combiner + standalone pins); (3) backfill with context + zero-vision idempotent re-sync + parent-gone origin-only — ✓ (6 integration tests); (4) suggestion block leads with `From <source>/<pdf> (page N)` — ✓ (renderer units + E2E); (5) all gates green — ✓ above; (6) no behavior change in phases 122/125/126 + sync `detail` shape untouched — ✓ (verified in code: new counter is log-only); (7) ruff+pyright — ✓; (8) commit — left to the harness per pipeline rules (working tree untouched by me). **Documentation:** README.md audited in full — the new phase-137 bullet verified against code (6 000-char cap, log-only `image_context_backfilled=N`, `<document>`-body locator placement), all env tables/defaults cross-checked vs `app/config.py` and `.env.example` — consistent, no changes needed. AGENTS.md: every rule/command verified — consistent, no changes needed. **Notable deviations (pre-existing, documented in task-05 report, forced by design):** `test_pdf_support.py`/`test_garbage_pdf.py` got minimal ripple edits (the "UNEDITED" pin was unsatisfiable once describe calls carry context by design); the unit `test_image_anchor_context.py` CONTEXT_PREFIX pin generalized per ASSUMPTION A5; one extra mock line (`_seed_context_line` accepts `From …`). **Next pending phase:** none — `todo/` holds only this phase.
669 lines
30 KiB
Python
669 lines
30 KiB
Python
"""Phase 126 E2E (Playwright): PDF support — the user path.
|
||
|
||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||
|
||
uv run pytest tests/e2e/test_pdf_support.py -v --no-cov
|
||
|
||
PDFs are first-class, retrievable documents behind the ``pdf`` token of
|
||
``BOR_IMPORT_EXTENSIONS`` (the two module apps pin the token EXPLICITLY
|
||
— an operator's local gitignored ``.env`` cannot leak the scope in
|
||
either direction, and without the token the walk is blind to the file):
|
||
|
||
* the story app (``BOR_IMAGES=true``): the fixture PDF (two text pages
|
||
+ one embedded PNG on page 2, built with PyMuPDF in the fixture —
|
||
LOCKED A1) arrives through the REAL admin upload flow (a zip with a
|
||
single PDF member — the archive unpack is content-agnostic, the
|
||
extension walk admits it once ``pdf`` is in the token set) and is
|
||
first-class at every surface:
|
||
- the Sources page lists it (the extracted text's document — a plain
|
||
text row, no thumbnail) plus the embedded image's document (the
|
||
phase-122 pipeline, LOCKED A3 — the 48px thumbnail from the bytes
|
||
route, alt = the mock vision description);
|
||
- the document viewer renders the EXTRACTED TEXT (LOCKED A4 — the
|
||
``---PAGE-N---`` page markers and the page text, never binary
|
||
bytes) with the metadata title (LOCKED A2 — one document per PDF);
|
||
- a grounded chat question (the mock's token-overlap cosine against
|
||
the summary chunk ≈0.61, over the mock-calibrated gate 0.30 —
|
||
FTS-corroborated anyway) scripted to READ it echoes the read result
|
||
verbatim (the extracted text reaches the model AND the answer), and
|
||
the sources block carries the PDF's citation chip PLUS the embedded
|
||
image's auto-cited chip + compact inline figure (phase 124 — the
|
||
suggested-tier image doc, best chunk ≈0.22 over the usefulness
|
||
floor);
|
||
* the default app (``BOR_IMAGES`` forced ``false``): the SAME upload +
|
||
sync indexes the PDF's text normally — the embedded image is skipped
|
||
(no image document, no copy, the source is a 1-document source) and
|
||
the chat turn grounds with the PDF's chip only (no image chip, no
|
||
figure — LOCKED A3's toggle gate end-to-end).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import json
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import zipfile
|
||
from collections.abc import Iterator
|
||
from pathlib import Path
|
||
|
||
import httpx
|
||
import pymupdf as fitz # PyMuPDF — the fixture-PDF builder (LOCKED A1)
|
||
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 e2e.auth_helpers import login
|
||
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
|
||
from e2e.mock_llm import IMAGE_DESCRIPTION_CONTEXT_ANSWER
|
||
|
||
REPO = Path(__file__).resolve().parents[2]
|
||
SOURCE_NAME = "e2e-pdf" # the archive stem (archive_source_name)
|
||
DOC_PATH = "report.pdf"
|
||
IMAGE_REL = "report.pdf#page=2#img=0.png" # the synthetic embedded-image path
|
||
PDF_TITLE = "Quarterly Homelab Report" # the metadata title (LOCKED A2)
|
||
|
||
#: The fixture PDF's page texts (the chunker's ``Page N:`` anchors make
|
||
#: the chunks ``Page 1:\n<text>`` / ``Page 2:\n<text>``). The question
|
||
#: below shares its tokens with them (the mock's token-overlap cosine —
|
||
#: the summary chunk ≈0.61 — clears the mock-calibrated gate 0.30).
|
||
PAGE_ONE = "The quarterly homelab report covers the core switch firmware upgrade."
|
||
PAGE_TWO = "The homelab backup schedule runs nightly at 02:00 and keeps seven rotations."
|
||
|
||
#: The grounded question, carrying the house scripted-read call
|
||
#: (``SUMMARY_SEED_READ_TRIGGER`` — the phase-119 A1 convention: a
|
||
#: zero-read grounded turn chips NOTHING, so the PDF doc must be READ to
|
||
#: earn its citation chip): the mock emits the scripted
|
||
#: ``read e2e-pdf/report.pdf``, then echoes the tool result VERBATIM —
|
||
#: the read result carries the document's FULL extracted text, so the
|
||
#: echo in the answer is the E2E lens on LOCKED A4 (the PDF served as
|
||
#: extracted text, page markers and all).
|
||
QUESTION = (
|
||
"Read the suggested document: read e2e-pdf/report.pdf — "
|
||
"what does the homelab report say about the backup schedule?"
|
||
)
|
||
|
||
#: A real 1×1 transparent PNG (the unit/integration suites' fixture —
|
||
#: the pipeline is content-agnostic; the well-formed bytes keep the
|
||
#: extraction + copy + serve + render path honest).
|
||
PNG_1X1 = base64.b64decode(
|
||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
|
||
"AAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
|
||
)
|
||
|
||
UPLOAD_TIMEOUT_MS = 30_000
|
||
SYNC_TIMEOUT_MS = 60_000
|
||
SYNCED_LABEL = re.compile(r"^Synced \d{1,2}:\d{2}$")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Module apps: images ON (the story) and images forced OFF (the default
|
||
# contract). Each owns its port + its scratch upload/source/image dirs.
|
||
# Both pin BOR_IMPORT_EXTENSIONS WITH the ``pdf`` token explicitly
|
||
# (phase 126 — the feature lives behind the token, and process env ranks
|
||
# above an operator's local gitignored ``.env``).
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _app_env(mock_llm: int, app_port: int, *, images: bool, scratch: Path) -> dict[str, str]:
|
||
"""The conftest app env (leak guards included) with the phase-126
|
||
knobs: ``BOR_IMPORT_EXTENSIONS`` pinned to a set carrying the
|
||
``pdf`` token, ``BOR_IMAGES`` pinned EXPLICITLY (true for the story
|
||
app, false for the default app — the contract under test cannot
|
||
leak in either direction), and the image/upload homes in the suite's
|
||
scratch dir (the app under test must not write copies into the
|
||
owner's real ``~/bor-sources``)."""
|
||
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 126: the import scope WITH the ``pdf`` token (the feature is
|
||
# gated by the token — the explicit pin keeps a local ``.env`` from
|
||
# changing the premise) + the toggle under test + the suite-private
|
||
# homes.
|
||
env["BOR_IMPORT_EXTENSIONS"] = "md,markdown,txt,pdf"
|
||
env["BOR_IMAGES"] = "true" if images else "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 story app — ``BOR_IMAGES=true`` + the ``pdf`` token (the
|
||
module env override; the conftest session app is never started in
|
||
this isolated run, so no port clash)."""
|
||
scratch = tmp_path_factory.mktemp("bor_pdf_on")
|
||
port = int(os.environ.get("E2E_APP_PORT_PDF", "8152"))
|
||
proc = subprocess.Popen(
|
||
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||
"--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
|
||
cwd=REPO,
|
||
env=_app_env(mock_llm, port, images=True, scratch=scratch),
|
||
)
|
||
try:
|
||
_wait_http(f"http://127.0.0.1:{port}/api/health")
|
||
yield f"http://127.0.0.1:{port}"
|
||
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
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def default_app_server(
|
||
mock_llm: int, tmp_path_factory: pytest.TempPathFactory
|
||
) -> Iterator[str]:
|
||
"""The default-contract app — ``BOR_IMAGES`` forced ``false`` (the
|
||
toggle gate for the embedded images; only the off-app tests start
|
||
it)."""
|
||
scratch = tmp_path_factory.mktemp("bor_pdf_off")
|
||
port = int(os.environ.get("E2E_APP_PORT_PDF_OFF", "8153"))
|
||
proc = subprocess.Popen(
|
||
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||
"--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
|
||
cwd=REPO,
|
||
env=_app_env(mock_llm, port, images=False, scratch=scratch),
|
||
)
|
||
try:
|
||
_wait_http(f"http://127.0.0.1:{port}/api/health")
|
||
yield f"http://127.0.0.1:{port}"
|
||
finally:
|
||
proc.terminate()
|
||
try:
|
||
proc.wait(timeout=10)
|
||
except subprocess.TimeoutExpired:
|
||
proc.kill()
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def default_app_url(default_app_server: str) -> str:
|
||
return default_app_server
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def zip_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||
"""The fixture archive: ONE PDF at the root (two text pages + one
|
||
embedded PNG on page 2, built with PyMuPDF — LOCKED A1) — source
|
||
``e2e-pdf``, document path ``report.pdf``."""
|
||
root = tmp_path_factory.mktemp("bor_pdf_zip")
|
||
pdf_path = root / DOC_PATH
|
||
doc = fitz.open()
|
||
doc.new_page().insert_text((72, 72), PAGE_ONE)
|
||
page2 = doc.new_page()
|
||
page2.insert_text((72, 72), PAGE_TWO)
|
||
page2.insert_image(fitz.Rect(72, 150, 73, 151), stream=PNG_1X1)
|
||
doc.set_metadata({"title": PDF_TITLE})
|
||
doc.save(pdf_path)
|
||
doc.close()
|
||
archive = root / f"{SOURCE_NAME}.zip"
|
||
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
|
||
zf.write(pdf_path, arcname=DOC_PATH)
|
||
return archive
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# DB + UI helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _truncate_all() -> None:
|
||
"""Fresh KB + registry per test group (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, kb_overview, "
|
||
"git_sources, sources_meta, folder_summaries"
|
||
))
|
||
db.commit()
|
||
|
||
|
||
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 _upload_zip(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:
|
||
"Uploaded <source> — press Sync sources to import it.")."""
|
||
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(
|
||
f"Uploaded {SOURCE_NAME} — press Sync sources to import it.",
|
||
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-result (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-result")).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 are 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)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# The module seeds: upload + sync the fixture PDF through the real UI,
|
||
# once per app (the story E2E truncate/re-import-per-module pattern).
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def seeded(
|
||
app_url: str,
|
||
browser: Browser,
|
||
zip_path: Path,
|
||
) -> Iterator[dict[str, str]]:
|
||
"""Upload the single-PDF zip through the admin UI on the story app,
|
||
then scan it with the RAG page's Sync button (the mock's vision
|
||
model describes the embedded image in-process). Yields the embedded
|
||
image doc's identity (the bytes route + the description) for the
|
||
render assertions. The module app's ``/api/config`` flag is
|
||
asserted first — the env override must have reached the app under
|
||
test, or the whole premise of the suite is void."""
|
||
if not _health_db(app_url):
|
||
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
||
cfg = httpx.get(f"{app_url}/api/config", timeout=5).json()
|
||
assert cfg["images"] is True, "the module app must run with BOR_IMAGES=true"
|
||
|
||
_truncate_all()
|
||
page = browser.new_page(viewport={"width": 1280, "height": 800})
|
||
try:
|
||
_upload_zip(page, app_url, zip_path)
|
||
# The PDF + its one embedded image (A3): both count added.
|
||
_sync_via_ui(page, app_url, expected_result="2 added")
|
||
with SessionLocal() as db:
|
||
pdf = db.scalar(
|
||
select(Document).where(
|
||
Document.source == SOURCE_NAME, Document.path == DOC_PATH
|
||
)
|
||
)
|
||
assert pdf is not None, "the uploaded PDF must become a document"
|
||
# LOCKED A2: one document per PDF, the extracted text with
|
||
# the page markers; the title is the metadata title.
|
||
assert pdf.is_image is False and pdf.image_path is None
|
||
assert pdf.title == PDF_TITLE
|
||
assert "---PAGE-1---" in pdf.content and PAGE_ONE in pdf.content
|
||
assert "---PAGE-2---" in pdf.content and PAGE_TWO in pdf.content
|
||
assert pdf.summary is not None
|
||
img = db.scalar(
|
||
select(Document).where(
|
||
Document.source == SOURCE_NAME, Document.path == IMAGE_REL
|
||
)
|
||
)
|
||
assert img is not None, "the embedded image must become an image doc"
|
||
assert img.is_image is True and img.image_path is not None
|
||
# Phase 137 (task 02): the document context — the
|
||
# provenance's origin line + page 2's text (no markdown
|
||
# reference exists — no ``Referenced in`` block) — is
|
||
# stored on ``image_context`` and enriches content (context
|
||
# first, then the mock's description); summary === content
|
||
# (the image convention — the viewer's Summary-panel
|
||
# suppression still applies). Task 05: the describe call
|
||
# carries the ``Extracted from `` context, so the mock's
|
||
# vision reply is the CONTEXT-AWARE canned description
|
||
# (it names the document + page — the deterministic
|
||
# witness the context reached the model), not the bare
|
||
# pre-137 one.
|
||
assert img.image_context == (
|
||
f"Extracted from {SOURCE_NAME}/{DOC_PATH} (page 2):\n{PAGE_TWO}"
|
||
)
|
||
assert img.content == (
|
||
f"{img.image_context}\n\n{IMAGE_DESCRIPTION_CONTEXT_ANSWER}"
|
||
)
|
||
assert img.summary == img.content
|
||
finally:
|
||
page.close()
|
||
yield {
|
||
"image_url": f"/api/documents/{img.id}/image",
|
||
# The doc's stored text (the document context + the mock's
|
||
# description) — every render surface (the thumbnail alt, the
|
||
# viewer's content slot, the figure's caption) settles to it.
|
||
"description": img.content,
|
||
}
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def seeded_off(
|
||
default_app_url: str,
|
||
browser: Browser,
|
||
zip_path: Path,
|
||
) -> Iterator[dict[str, str]]:
|
||
"""The SAME fixture zip through the default app (images off): the
|
||
PDF's text indexes normally, the embedded image is skipped — the
|
||
source is a 1-document source. Yields the PDF doc's identity."""
|
||
if not _health_db(default_app_url):
|
||
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
||
cfg = httpx.get(f"{default_app_url}/api/config", timeout=5).json()
|
||
assert cfg["images"] is False, "the default app must run with images off"
|
||
|
||
_truncate_all()
|
||
page = browser.new_page(viewport={"width": 1280, "height": 800})
|
||
try:
|
||
_upload_zip(page, default_app_url, zip_path)
|
||
_sync_via_ui(page, default_app_url, expected_result="1 added")
|
||
with SessionLocal() as db:
|
||
pdf = db.scalar(
|
||
select(Document).where(
|
||
Document.source == SOURCE_NAME, Document.path == DOC_PATH
|
||
)
|
||
)
|
||
assert pdf is not None, "the uploaded PDF must become a document"
|
||
assert PAGE_ONE in pdf.content and PAGE_TWO in pdf.content
|
||
assert (
|
||
db.scalar(select(Document).where(Document.is_image.is_(True)))
|
||
is None
|
||
), "images off: the embedded image is skipped (no image doc)"
|
||
finally:
|
||
page.close()
|
||
yield {"title": PDF_TITLE}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 1. Sources page (images ON): the PDF's text row + the image's thumbnail
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_sources_page_lists_the_pdf_and_image_docs(
|
||
page: Page, app_url: str, seeded: dict[str, str]
|
||
) -> None:
|
||
"""The KB total is 2 docs / 5 chunks (the PDF: 2 page-anchored
|
||
content chunks + the phase-30 summary chunk; the image: the
|
||
description chunk + its summary chunk). The drill-down table lists
|
||
both documents: the PDF as a PLAIN text row (LOCKED A4 — it is a
|
||
text document, no thumbnail box) and the embedded image with the
|
||
FIXED 48px thumbnail (the bytes route, alt = the description)."""
|
||
login(page, app_url) # lands on /sources.html
|
||
expect(page.locator("#stat-docs")).to_have_text("2")
|
||
expect(page.locator("#stat-chunks")).to_have_text("5")
|
||
expect(page.locator("#sources-empty")).to_be_hidden()
|
||
|
||
_drill_to_source(page, SOURCE_NAME)
|
||
expect(page.locator("#docs-tbody tr")).to_have_count(2)
|
||
|
||
# The PDF — a plain text row (no thumbnail box at all). The row is
|
||
# pinned by the EXACT doc-link text (the image row's path carries
|
||
# ``report.pdf`` as a prefix — a substring match would hit both).
|
||
pdf_row = page.locator(
|
||
"#docs-tbody tr",
|
||
has=page.locator(f'a.doc-link:text-is("{DOC_PATH}")'),
|
||
)
|
||
expect(pdf_row).to_have_count(1)
|
||
expect(pdf_row.locator(".kb-doc-thumb")).to_have_count(0)
|
||
|
||
# The embedded image — the thumbnail row (the bytes route's lazy
|
||
# <img>, alt = the description — the WCAG contract).
|
||
img_row = page.locator(
|
||
"#docs-tbody tr",
|
||
has=page.locator(f'a.doc-link:text-is("{IMAGE_REL}")'),
|
||
)
|
||
expect(img_row).to_have_count(1)
|
||
img = img_row.locator(".kb-doc-thumb-img")
|
||
expect(img).to_have_count(1)
|
||
expect(img).to_be_visible(timeout=15_000) # the fetch resolves — no glyph
|
||
expect(img).to_have_attribute("src", seeded["image_url"])
|
||
expect(img).to_have_attribute("loading", "lazy")
|
||
expect(img).to_have_attribute("alt", seeded["description"])
|
||
expect(img_row.locator(".kb-doc-thumb-glyph")).to_have_count(0)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 2. Document viewer (images ON): the extracted text renders (A4)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_document_viewer_renders_the_extracted_text(
|
||
page: Page, app_url: str, seeded: dict[str, str]
|
||
) -> None:
|
||
"""The Sources row's path link opens the same-page document modal
|
||
(phase 26): the PDF renders its EXTRACTED TEXT (LOCKED A4 — the
|
||
``---PAGE-N---`` page markers + the page texts in the plain-content
|
||
slot, never binary bytes), under the METADATA title (LOCKED A2),
|
||
with the phase-30 Summary panel above (a non-image doc with a
|
||
non-empty summary — the panel renders)."""
|
||
login(page, app_url)
|
||
_drill_to_source(page, SOURCE_NAME)
|
||
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)
|
||
|
||
# The extracted text (A4) — both page markers and both page texts.
|
||
raw = modal.locator("pre.doc-raw")
|
||
expect(raw).to_contain_text("---PAGE-1---")
|
||
expect(raw).to_contain_text(PAGE_ONE)
|
||
expect(raw).to_contain_text("---PAGE-2---")
|
||
expect(raw).to_contain_text(PAGE_TWO)
|
||
# The summary panel (a non-image doc — the panel renders; the
|
||
# verbatim-description suppression applies to image docs only).
|
||
expect(modal.locator(".doc-summary")).to_have_count(1)
|
||
# No image block — the PDF is not an image document.
|
||
expect(modal.locator(".doc-image-img")).to_have_count(0)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 3. Document viewer (images ON): the embedded image doc renders
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_document_viewer_renders_the_embedded_image_doc(
|
||
page: Page, app_url: str, seeded: dict[str, str]
|
||
) -> None:
|
||
"""The embedded image's row (the synthetic ``#page=2#img=0.png``
|
||
path) opens the modal: the PERSISTENT copy renders first (the bytes
|
||
route, alt = the description), the description follows in the
|
||
content slot, and the labeled Summary panel is SUPPRESSED for the
|
||
verbatim-description case (summary === content — the importer
|
||
invariant; the panel would duplicate the text right below the
|
||
image)."""
|
||
login(page, app_url)
|
||
_drill_to_source(page, SOURCE_NAME)
|
||
page.locator(f'#docs-tbody a.doc-link:text-is("{IMAGE_REL}")').click()
|
||
|
||
modal = page.locator("#doc-modal")
|
||
expect(modal).to_be_visible(timeout=30_000)
|
||
|
||
img = modal.locator(".doc-image-img")
|
||
expect(img).to_have_count(1)
|
||
expect(img).to_be_visible(timeout=15_000) # the bytes route serves the PNG
|
||
expect(img).to_have_attribute("src", seeded["image_url"])
|
||
expect(img).to_have_attribute("alt", seeded["description"])
|
||
|
||
expect(modal.locator("pre.doc-raw")).to_have_text(seeded["description"])
|
||
# The verbatim case: no duplicate Summary panel.
|
||
expect(modal.locator(".doc-summary")).to_have_count(0)
|
||
# No "Image unavailable" note — the copy is intact.
|
||
expect(modal.locator(".doc-image-unavailable")).to_have_count(0)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 4. Chat (images ON): the answer grounds on the PDF; the embedded
|
||
# image is auto-cited with its figure
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_chat_grounds_on_the_pdf_and_chips_the_embedded_image(
|
||
page: Page, app_url: str, seeded: dict[str, str]
|
||
) -> None:
|
||
"""A question the mock grounds on (the summary chunk's cosine
|
||
≈0.61 over the 0.30 gate; FTS corroborates), scripted to READ the
|
||
PDF (phase 119 A1): the echo carries the read result VERBATIM —
|
||
the document's FULL extracted text (LOCKED A4 — the page markers
|
||
and both page texts reach the model and land in the answer), and
|
||
the sources block renders the mixed citation as phase-130
|
||
clusters (task 01): the chips row carries the PDF's citation chip
|
||
(the READ doc) and NO chip for the image doc (its figure IS the
|
||
citation), while the embedded image's AUTO-cite (phase 124 — the
|
||
suggested-tier image doc) sits in the images row as its compact
|
||
figure (the bytes route, caption/alt settling to the
|
||
description)."""
|
||
login(page, app_url, next="/")
|
||
expect(page.locator("#kb-banner")).to_be_hidden()
|
||
|
||
page.fill("#message-input", QUESTION)
|
||
page.click("#send-btn")
|
||
|
||
bubble = page.locator(".msg.brain .bubble").last
|
||
# The scripted read's echoed result — the read header and the FULL
|
||
# extracted text (page markers and all).
|
||
expect(bubble).to_contain_text("Here's what the read returned:", timeout=30_000)
|
||
expect(bubble).to_contain_text(f"Document {SOURCE_NAME}/{DOC_PATH}:")
|
||
expect(bubble).to_contain_text("---PAGE-1---")
|
||
expect(bubble).to_contain_text(PAGE_ONE)
|
||
expect(bubble).to_contain_text("---PAGE-2---")
|
||
expect(bubble).to_contain_text(PAGE_TWO)
|
||
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
|
||
expect(page.locator("#send-label")).to_have_text("Send")
|
||
|
||
# Phase 130 (task 01) — the mixed citation: the chips row carries
|
||
# the READ PDF ONLY (no chip for the image doc — the figure IS
|
||
# the citation), and the image doc's auto-cite (phase 124) is the
|
||
# figure in its own images row.
|
||
chip = page.locator(".msg.brain .source-chip")
|
||
expect(chip).to_have_count(1)
|
||
expect(chip.nth(0)).to_have_text(f"{SOURCE_NAME}/{DOC_PATH}")
|
||
row = page.locator(".msg.brain .msg-meta.msg-images")
|
||
expect(row).to_have_count(1)
|
||
expect(row).to_have_attribute("aria-label", "Source images")
|
||
|
||
# The inline figure in the images row: image + caption, both
|
||
# settling to the description (the content fetch resolves the
|
||
# summary after the title).
|
||
fig = row.locator(".source-image")
|
||
expect(fig).to_have_count(1)
|
||
img = fig.locator(".source-image-img")
|
||
expect(img).to_be_visible(timeout=15_000)
|
||
expect(img).to_have_attribute("src", seeded["image_url"])
|
||
expect(img).to_have_attribute("alt", seeded["description"], timeout=15_000)
|
||
caption = fig.locator(".source-image-caption")
|
||
expect(caption).to_have_text(seeded["description"], timeout=15_000)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 5. Default app (images off): the same upload + sync indexes the PDF
|
||
# text normally and skips the embedded image
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_images_off_upload_indexes_the_pdf_and_skips_the_image(
|
||
page: Page, default_app_url: str, seeded_off: dict[str, str]
|
||
) -> None:
|
||
"""The toggle gate end-to-end (LOCKED A3): with ``BOR_IMAGES``
|
||
false the PDF's text indexes normally (1 added — the PDF only; the
|
||
embedded image is skipped, not a failure) while NO image document
|
||
exists (no row, no copy). The Sources page reads 1 doc / 3 chunks
|
||
and the drill-down table lists the PDF alone."""
|
||
login(page, default_app_url) # lands on /sources.html (admin)
|
||
expect(page.locator("#stat-docs")).to_have_text("1")
|
||
expect(page.locator("#stat-chunks")).to_have_text("3")
|
||
|
||
_drill_to_source(page, SOURCE_NAME)
|
||
row = page.locator("#docs-tbody tr", has_text=DOC_PATH)
|
||
expect(row).to_have_count(1)
|
||
expect(row.locator(".kb-doc-thumb")).to_have_count(0)
|
||
expect(page.locator("#docs-tbody tr", has_text=IMAGE_REL)).to_have_count(0)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 6. Chat (images off): the answer grounds on the PDF; NO image chip
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_images_off_chat_grounds_with_no_image_chip(
|
||
page: Page, default_app_url: str, seeded_off: dict[str, str]
|
||
) -> None:
|
||
"""The SAME question on the off app: the turn grounds on the PDF's
|
||
extracted text (the read echo carries the page markers + texts —
|
||
the PDF text is indexed normally, LOCKED A3's off-contract) and the
|
||
sources block carries EXACTLY the PDF's chip — no image chip, no
|
||
figure (the embedded image was never indexed)."""
|
||
try:
|
||
login(page, default_app_url, next="/")
|
||
expect(page.locator("#kb-banner")).to_be_hidden()
|
||
|
||
page.fill("#message-input", QUESTION)
|
||
page.click("#send-btn")
|
||
|
||
bubble = page.locator(".msg.brain .bubble").last
|
||
expect(bubble).to_contain_text("Here's what the read returned:", timeout=30_000)
|
||
expect(bubble).to_contain_text(f"Document {SOURCE_NAME}/{DOC_PATH}:")
|
||
expect(bubble).to_contain_text(PAGE_ONE)
|
||
expect(bubble).to_contain_text(PAGE_TWO)
|
||
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
|
||
|
||
chip = page.locator(".msg.brain .source-chip")
|
||
expect(chip).to_have_count(1)
|
||
expect(chip.nth(0)).to_have_text(f"{SOURCE_NAME}/{DOC_PATH}")
|
||
# No image affordance at all (no chip, no figure).
|
||
expect(page.locator(".msg.brain .source-image")).to_have_count(0)
|
||
finally:
|
||
_truncate_all()
|