phase: 122_image_documents
**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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user