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
+62
View File
@@ -366,6 +366,36 @@ class Settings(BaseSettings):
#: pattern).
upload_max_mb: int = 512
# --- Image documents (phase 122: standalone images as documents) ---
#: Master switch for image-document indexing (phase 122,
#: ``BOR_IMAGES``; ``0``/``false`` = off — the DEFAULT, LOCKED A3).
#: Enable only when ``llm_chat_model`` supports vision: image
#: descriptions are generated by the chat model, and the description
#: is the ONLY part of an image that gets indexed (the embedding
#: model never sees pixels). While off, the import walks ignore
#: image files and a sync never prunes existing ``is_image``
#: documents (the phase-122 prune guard — the image is invisible to
#: an images-off walk, not a deleted file).
images: bool = False
#: Comma-separated, case-insensitive file extensions (no dot) treated
#: as standalone images when ``images`` is on (phase 122,
#: ``BOR_IMAGE_EXTENSIONS``). Stored as a raw CSV string (the
#: ``import_extensions`` house convention) and parsed on demand via
#: :py:meth:`image_extension_set`. A SEPARATE set from
#: ``import_extension_set`` — images are never user-added via
#: ``BOR_IMPORT_EXTENSIONS`` (the ``images`` toggle is the single
#: knob). The validator rejects an empty list and malformed tokens,
#: exactly like ``import_extensions`` (a typo would otherwise index
#: zero images silently).
image_extensions: str = "png,jpg,jpeg,webp,gif,bmp"
#: Where ingested image bytes are copied for serving (phase 122,
#: ``BOR_IMAGE_DIR``). Raw string — ``Path.expanduser()`` is applied
#: by the importer, not here (the ``sources_dir``/``upload_dir``
#: convention). Deliberately separate from ``sources_dir`` (git
#: checkouts, re-cloned) and ``upload_dir`` (replaced on every
#: upload): the served copy must outlive the source file.
image_dir: str = "~/bor-sources/images"
# --- Docs push (phase 59: save a chat answer as documentation) ---
#: The git repo a saved chat answer is committed to (phase 59, D3):
#: **any** remote — a URL (``https://``, ``ssh://``, ``git@``) or a
@@ -471,6 +501,25 @@ class Settings(BaseSettings):
)
return v
@field_validator("image_extensions")
@classmethod
def _image_extensions_known(cls, v: str) -> str:
"""Reject an empty list or malformed tokens loudly (the
``import_extensions`` precedent, phase 122): a typo like
``png,jpeb`` would otherwise index zero images silently."""
exts = {part.strip().lstrip(".").lower() for part in v.split(",") if part.strip()}
if not exts:
raise ValueError("image_extensions must name at least one format")
malformed = sorted(
ext for ext in exts if re.fullmatch(r"[a-z0-9]{1,16}", ext) is None
)
if malformed:
raise ValueError(
f"image_extensions contains malformed token(s): {', '.join(malformed)} — "
"each extension must be lowercase letters/digits only, 1-16 chars, no dot"
)
return v
@field_validator("agent_max_rounds")
@classmethod
def _agent_max_rounds_non_negative(cls, v: int) -> int:
@@ -647,6 +696,19 @@ class Settings(BaseSettings):
if part.strip()
)
@property
def image_extension_set(self) -> frozenset[str]:
"""Lowercased, dotted image-extension set (``.png``) for the
phase-122 walk filter — SEPARATE from
:py:attr:`import_extension_set` (images are never user-added via
``BOR_IMPORT_EXTENSIONS``; the ``images`` toggle is the single
knob)."""
return frozenset(
f".{part.strip().lstrip('.').lower()}"
for part in self.image_extensions.split(",")
if part.strip()
)
@property
def git_source_list(self) -> list[str]:
"""Non-empty, stripped git URLs from :py:attr:`git_sources` (phase 28).