phase: 122_image_documents
Build and Push Containers / build-and-push-app (push) Successful in 1m57s
Build and Push Containers / build-and-push-db (push) Failing after 13s

**Phase 122 (image documents) — final verification pass: all green. No code changes were needed; defects found: none.**

**Verified (implementation already complete in working tree, reviewed end-to-end):**
- Toggle (`BOR_IMAGES`/`BOR_IMAGE_EXTENSIONS`/`BOR_IMAGE_DIR`, off by default) + `GET /api/config` `images` flag
- Ingest: bytes digest, `image_dir` persistent copy, `content = summary = vision description` (chat-model call; only text embedded), fail-soft skip + `images_failed` counter
- Serve/display: `/api/documents/{id}/image` route (404 matrix), viewer `<img>` + description, Sources 48px lazy thumbnails, chat inline source figure (alt = summary), agent `read` marker
- Prune guard: images-off syncs never prune `is_image` docs

**Test / lint / coverage (exact commands & outcomes):**
- `uv run pytest` → exit 0 (green; note: pytest 9.1.1 `-q` omits the final count line in output — exit code authoritative)
- `uv run pytest --cov=app --cov-report=term-missing` → **2715 passed, exit 0, TOTAL 99%** (>90% gate)
- `uv run ruff check . && uv run pyright` → "All checks passed!" / "0 errors, 0 warnings, 0 informations"
- `uv run pytest tests/e2e/test_image_documents.py -v --no-cov` → **4 passed, exit 0** (isolation)

**Completion criteria:** (1) images=true → described/embedded/displayed docs: ✅ (E2E + integration) · (2) images=false byte-identical + image docs survive sync: ✅ (E2E negative app + unit/integration) · (3) viewer + chat rendering with alt text; failed description skips + logs, sync completes: ✅ · (4) test/lint/coverage gates: ✅ · (5) commit + phase move: deferred to harness per this pass's rules (working tree left uncommitted).

**Notable deviation (pre-existing, documented in code):** image route uses `require_user` (phase-79 posture, same gate as the document content endpoint) rather than the phase text's "public" parenthetical — matches the endpoint it mirrors.

**Next pending phase:** `123_chat_image_questions`.
This commit is contained in:
2026-09-25 01:54:23 -04:00
parent 0f77e9a876
commit a19d78d284
63 changed files with 5484 additions and 111 deletions
+121
View File
@@ -2076,3 +2076,124 @@ def test_done_event_related_defaults_empty_and_old_payload_parses() -> None:
dumped = ChatDoneEvent(**new_payload).model_dump()
assert [r["path"] for r in dumped["related"]] == ["b.md"]
assert [r["path"] for r in dumped["sources"]] == ["a.md"]
# ---------------------------------------------------------------------------
# Phase 122, task 05 — the SSE source frame's OPTIONAL image_url (the
# shared ``source_ref_with_image`` builder: present on an image doc's
# ref only, omitted — never null — on every text ref). Task 06
# finalizes the phase-122 suite here with the story-level pins.
# ---------------------------------------------------------------------------
def _seed_image_doc(db) -> Document:
"""An ``is_image`` documents row the agent can ``read`` (no chunks
needed — the read tool resolves the row by ``(source, path)`` and
serves its ``content``; the frame needs ``is_image`` + ``id``
only). The fixture's teardown truncates the tables."""
doc = Document(
id=uuid.uuid4(),
source="docs",
path="pic.png",
full_path="/tmp/pic.png",
title="pic",
content="A red square on a white background.",
summary="A red square on a white background.",
content_hash="0" * 64,
created_at=_FIXTURE_CREATED_AT,
is_image=True,
image_path="/tmp/pic.png",
)
db.add(doc)
db.commit()
return doc
def test_grounded_turn_reading_image_doc_frame_carries_image_url_only_for_it(
client, db, seeded_kb: FakeRagLLM
) -> None:
"""Task 05 wire contract: a mocked grounded answer whose agent
READS an image doc → the done frame's ref for THAT doc alone
carries ``image_url`` (the bytes route the frontend's sources
block renders from); the text-doc related refs carry NO
``image_url`` key at all (the omission rule — the key is absent,
never null)."""
doc = _seed_image_doc(db)
scripted = FakeRagLLM(
tool_script=[
[
ToolCallPiece(
id="call_1",
name="read",
arguments={"path": "docs/pic.png"},
)
]
]
)
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
try:
_, _, frames = _stream_chat(client, QUESTION)
finally:
fastapi_app.dependency_overrides.clear()
done = frames[-1]
assert done["deflected"] is False
# The read image doc is the citation surface (phase 119, A1) — and
# its ref is the frame's ONLY image_url carrier.
sources = done["sources"]
assert [(s["source"], s["path"]) for s in sources] == [("docs", "pic.png")]
assert sources[0]["image_url"] == f"/api/documents/{doc.id}/image"
# The related tier (ranks 6–7 for the Kubernetes question) is text
# docs — the key is ABSENT on every one of them, not null.
related = done["related"]
assert related
for ref in related:
assert "image_url" not in ref
def test_text_only_grounded_turn_frame_has_no_image_url_key_anywhere(
client, db, seeded_kb: FakeRagLLM
) -> None:
"""The omission rule at the BYTE level (the phase's byte-identity
criterion): a grounded turn whose cited + related docs are ALL
text docs serializes a done frame with no ``image_url`` key
anywhere — checked on the raw wire text (not a re-serialized
dict), and every ref keeps exactly the pre-phase-122 key set."""
scripted = FakeRagLLM(
tool_script=[
[
ToolCallPiece(
id="call_1",
name="read",
arguments={"path": "docs/homelab/kubernetes.md"},
)
]
]
)
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
try:
with client.stream("POST", "/api/chat", json={"message": QUESTION}) as r:
assert r.status_code == 200
buf = ""
raw_frames: list[str] = []
for part in r.iter_text():
buf += part
while "\n\n" in buf:
frame, buf = buf.split("\n\n", 1)
frame = frame.strip()
if frame.startswith("data:"):
raw_frames.append(frame)
finally:
fastapi_app.dependency_overrides.clear()
done_raw = [f for f in raw_frames if '"type": "done"' in f]
assert len(done_raw) == 1
# The BYTE check: the key is absent from the wire text itself.
assert "image_url" not in done_raw[0]
done = json.loads(done_raw[0].removeprefix("data:").strip())
assert done["deflected"] is False
assert [(s["source"], s["path"]) for s in done["sources"]] == [
("docs", "homelab/kubernetes.md")
]
for ref in done["sources"] + done["related"]:
assert set(ref) == {"source", "path", "title"} # the pre-122 key set