phase: 123_chat_image_questions
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:
+57
-1
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user