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 13s

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
+57 -1
View File
@@ -1,6 +1,7 @@
"""Pydantic request/response schemas (API contract)."""
from __future__ import annotations
import re
import uuid
from datetime import datetime
from typing import Annotated, Any, Literal
@@ -60,7 +61,8 @@ class HistoryTurn(BaseModel):
class ChatRequest(BaseModel):
"""``POST /api/chat`` body: the current question plus the optional
prior turns (phase 74 — the client-provided history, stateless per
A10).
A10) and, since phase 123, the question's attached image (the
stored path — :attr:`image`).
``history`` is the client's earlier turns, oldest first (the
``bor.chat.v1`` record minus the current question); the mapper
@@ -74,6 +76,30 @@ class ChatRequest(BaseModel):
message: str = Field(min_length=1, max_length=4000)
history: list[HistoryTurn] = Field(default_factory=list, max_length=100)
#: Phase 123 (TODO L6, LOCKED A5): the STORED PATH of the question's
#: attached image — ``/api/chat-images/<uuid4-hex>.<ext>`` exactly as
#: ``POST /api/chat-images`` returns it. NEVER a raw data URL: the
#: upload endpoint already did the size/mime enforcement, and
#: re-validating a 10 MB base64 string at the schema would be the
#: anti-pattern. ``None`` (the default, every text-only question)
#: keeps the turn byte-identical to pre-phase-123.
image: str | None = Field(default=None, max_length=500)
@field_validator("image")
@classmethod
def _image_is_stored_path(cls, v: str | None) -> str | None:
"""A set ``image`` must be the upload endpoint's stored-path
shape — ``/api/chat-images/<uuid4().hex>.<ext>`` (32 hex chars,
the six image extensions; the pattern is pinned in the
phase-123 design and mirrored by the serve route's filename
guard, ``app.api.chat_images``). A data URL, a bare filename, a
traversal, a wrong extension, or a mistyped uuid all 422 with
ONE fixed detail — no echo of the input."""
if v is None:
return v
if re.fullmatch(r"/api/chat-images/[0-9a-fA-F]{32}\.(png|jpe?g|webp|gif|bmp)", v) is None:
raise ValueError("image must be an uploaded chat image path")
return v
class LoginRequest(BaseModel):
"""``POST /api/login`` body (phase 16): the single admin's password.
@@ -928,6 +954,36 @@ class ChatMessage(BaseModel):
# persisted error detail (the phase-48 ``stopped`` precedent).
failed: bool | None = None
error: str | None = Field(default=None, max_length=500)
# Phase 123 (task 01, LOCKED A5): the question's attached image —
# the STORED PATH (``/api/chat-images/<uuid>.<ext>``, from
# ``POST /api/chat-images``), on the USER record only: the
# attachment belongs to the question, so a brain record never
# carries it (the answer may cite the image doc's sources, but the
# attachment itself is the user's). The saved/shared shape gains
# this one optional key — omitted when ``None`` (the
# :meth:`_drop_image_when_absent` serializer below), so a text-only
# chat round-trips byte-identically to pre-phase-123 (the
# phase-50 contract; the phase-122 ``SourceRef.image_url``
# omission precedent). The path is ≤ 500 chars — no phase-83
# cap pressure (it is never a data URL, LOCKED A5).
image: str | None = Field(default=None, max_length=500)
@model_serializer(mode="wrap")
def _drop_image_when_absent(self, handler: SerializerFunctionWrapHandler) -> Any:
"""The phase-123 image omission rule: ``image: None`` (every
text-only record, and every pre-phase-123 record) serializes
WITHOUT the key — ABSENT, never ``null`` — so the stored
``bor.chat.v1`` JSONB and the saved/shared wire shape stay
byte-identical to pre-phase for text-only chats (the phase-50
round-trip contract). A record WITH an image keeps the path —
the user bubble, the refreshed page, and the shared chat all
render it from the served route (the image is part of the
chat's content, so it rides the same public/credential-
is-the-id trust model)."""
data = handler(self)
if self.image is None:
data.pop("image", None)
return data
class SavedChatCreate(BaseModel):