phase: 123_chat_image_questions
Build and Push Containers / build-and-push-app (push) Successful in 1m54s
Build and Push Containers / build-and-push-db (push) Failing after 1m50s

All gates green. Verification complete.

**Phase 123 — final verification pass (all 4 tasks already in `complete/`)**

- Verified the full implementation is in the working tree: `app/api/chat_images.py` (upload/serve pair), `ChatRequest.image`/`ChatMessage.image` (path-validated, omitted-when-None), toggle-off + stale-file hinted error frames, `build_user_content` multimodal build at both sites (chat.py deflected branch + `run_agent`), config-gated composer attach/preview/upload-then-send, restore + shared rendering, CSP `img-src 'self' data:` carve-out, mock-LLM capture buffer.
- `uv run pytest` → **2796 passed**, exit 0 (unit + integration).
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (29/4615 missed; phase-123 modules 99–100%).
- `uv run pytest tests/e2e/test_chat_image_questions.py -v --no-cov` → **5 passed** in isolation.
- `uv run ruff check . && uv run pyright` → clean (0 errors).

**Completion criteria:** (1) attach→send→multimodal text+image to the model, bubble/reload/shared all render it, saved chat stores the PATH with `"base64" not in json.dumps(stored)` — **verified** (E2E tests 1–4 + integration round-trip); (2) `BOR_IMAGES=false` — control hidden, exact hinted error frame, zero model calls / no query_log row — **verified** (E2E test 5 + integration); (3) text-only byte-identical (`content` stays a plain `str`) — **verified** (unit + integration); (4) all gates green — **verified**; (5) commit + phase move — left to the harness per pipeline rules (no `git add`/`commit` run).

No defects found; no live-infrastructure changes (repo + local dev DB only). **Next pending phase: none** — 123 is the last phase in `todo/`.
This commit is contained in:
2026-09-25 05:19:18 -04:00
parent a19d78d284
commit bef24e05e2
48 changed files with 3910 additions and 71 deletions
+100
View File
@@ -24,6 +24,7 @@ Requires: podman compose up -d db
"""
from __future__ import annotations
import json
import re
import time
import uuid
@@ -799,6 +800,105 @@ def test_public_read_returns_snapshot_without_private_keys(
assert body["messages"] == _expect([_user(FIRST_QUESTION), FULL_BRAIN])
# ---------------------------------------------------------------------------
# Phase 123 (tasks 03/04): the question's attached image — the saved
# and shared shape carries the stored PATH (LOCKED A5: never base64),
# on the USER record only (the attachment belongs to the question — a
# brain record never carries the key). Text-only records stay WITHOUT
# the key (absent, never null — the phase-50 byte-identical contract,
# pinned by ``_expect`` above; the ``ChatMessage`` wrap serializer does
# the dropping, so every surface — the stored JSONB, the admin GET,
# the public shared read — inherits it).
# ---------------------------------------------------------------------------
#: A well-formed stored path (the ``POST /api/chat-images`` response's
#: shape — the chat-images router names the file
#: ``<uuid4().hex>.<ext>``; the API boundary itself only bounds the
#: string to 500 chars, the pattern gate is the chat request's).
IMAGE_PATH = "/api/chat-images/" + "a" * 32 + ".png"
def _user_with_image(text: str) -> dict[str, Any]:
return {"who": "user", "text": text, "image": IMAGE_PATH}
def test_create_round_trips_the_user_image_path(
admin_client: TestClient, db: Session
) -> None:
"""The user record carries the PATH at every boundary: the 201
body, the RAW stored JSONB (never base64 — the A5 contract), and
the admin GET — and the brain record stays WITHOUT the key.
A text-only record in the SAME chat stays key-free too."""
r = admin_client.post(
"/api/chats",
json={
"messages": [
_user(FIRST_QUESTION), # text-only — stays WITHOUT the key
_user_with_image("What is in this image?"),
{"who": "brain", "text": "A cat, by the look of it."},
]
},
)
assert r.status_code == 201, r.text
body = r.json()
assert "image" not in body["messages"][0]
assert body["messages"][1]["image"] == IMAGE_PATH
assert "image" not in body["messages"][2]
# The RAW stored JSONB (the DB is the durable boundary — the
# served bodies could in principle re-derive it): the path, and
# NOTHING base64 anywhere in the payload.
raw = db.execute(
text("SELECT messages FROM saved_chats WHERE id = :id"),
{"id": body["id"]},
).scalar_one()
assert "image" not in raw[0]
assert raw[1]["image"] == IMAGE_PATH
assert "image" not in raw[2]
assert "base64" not in json.dumps(raw)
got = admin_client.get(f"/api/chats/{body['id']}")
assert got.status_code == 200
assert "image" not in got.json()["messages"][0]
assert got.json()["messages"][1]["image"] == IMAGE_PATH
assert "image" not in got.json()["messages"][2]
def test_shared_serve_includes_the_user_image_path(
admin_client: TestClient,
) -> None:
"""The shared view is faithful (task 03's pin): the PUBLIC
snapshot's user record carries the image path — the public
``messages`` shape already gains the one optional key, so no new
shared-shape field exists — and the brain / text-only records
stay WITHOUT it. The image bytes themselves ride the public
serve route (phase 55 A1 — the image is part of the chat's
content, the token is the credential): pinned in
``test_chat_api.py`` alongside the upload."""
r = admin_client.post(
"/api/chats",
json={
"messages": [
_user(FIRST_QUESTION), # text-only — stays WITHOUT the key
_user_with_image("What is in this image?"),
{"who": "brain", "text": "A cat, by the look of it."},
]
},
)
assert r.status_code == 201, r.text
share_url = _share(admin_client, r.json()["id"])["share_url"]
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
got = anon.get(f"/api{share_url}")
assert got.status_code == 200
body = got.json()
assert set(body) == SHARED_OUT_KEYS # no new shared-shape field
messages = body["messages"]
assert "image" not in messages[0]
assert messages[1]["image"] == IMAGE_PATH
assert "image" not in messages[2]
def test_public_read_wrong_and_revoked_tokens_404_with_one_detail(
admin_client: TestClient,
) -> None: