Files
brain-of-reese/tests/e2e/test_pdf_support.py
T
ducoterra c275735db4
Build and Push Containers / build-and-push-app (push) Successful in 2m26s
Build and Push Containers / build-and-push-db (push) Successful in 16s
phase: 154_sync_status_freshness
All 4 task files exist in `complete/`, and only `00_phase.md` remains in `todo/`. This is the final verification pass. I'll read through the task files and check the current state of the code.
2026-10-04 14:24:33 -04:00

695 lines
31 KiB
Python
Raw 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 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 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?"
)
def _embedded_png() -> bytes:
"""A small deterministic colorful noise PNG (channel means ~97 —
the "real image" stand-in that CLEARS the phase-151 blank/
decorative gate's near-white floor with room to spare). The old
1×1 transparent fixture decomposed in PyMuPDF to a near-white
1-sample base — blank by the owner-locked gate — and is now
(correctly) skipped as a decorative underlay, so the suite's
pipeline pins ride on a real non-blank image instead (the
pipeline is content-agnostic; the well-formed bytes keep the
extraction + copy + serve + render path honest)."""
pix = fitz.Pixmap(fitz.csRGB, fitz.IRect(0, 0, 24, 18))
for y in range(18):
for x in range(24):
pix.set_pixel(x, y, ((x * 7) % 256, (y * 13) % 256, ((x + y) * 5) % 256))
return pix.tobytes("png")
#: The embedded fixture image (24×18 colorful noise — non-blank by the
#: phase-151 gate; see :func:`_embedded_png`).
EMBED_PNG = _embedded_png()
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, 96, 168), stream=EMBED_PNG)
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 _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_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 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 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()