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
@@ -0,0 +1,19 @@
**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`.
@@ -0,0 +1,106 @@
........................................................................ [ 2%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 10%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 18%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 26%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 34%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 42%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 50%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 63%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 71%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 79%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 87%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 95%]
........................................................................ [ 98%]
................................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 226 1 99%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 179 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 139 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 247 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 66 0 100%
app/models.py 131 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 349 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 206 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 316 16 95%
app/rag/llm.py 244 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 102 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 46 0 100%
app/schemas.py 373 0 100%
--------------------------------------------------
TOTAL 4514 29 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,19 @@
All green. The untracked `.agents/reports/` dir is a harness runtime artifact — left untouched.
## Task 01 complete — image toggle (`BOR_IMAGES` + extensions + dir, off by default)
**Implemented**
- `app/config.py`: `images: bool = False`, `image_extensions: str = "png,jpg,jpeg,webp,gif,bmp"`, `image_dir: str = "~/bor-sources/images"` (house docstrings, LOCKED A3 default); `image_extension_set` property (dotted lowercased frozenset, separate from `import_extension_set`); fail-loud validator for empty/malformed `image_extensions` (the `import_extensions` precedent)
- `.env.example`: three entries + comment block (off by default, vision-model dependency note)
- `app/api/config.py`: `GET /api/config` gains `"images": settings.images` (six-key contract; docstring notes phase-123 composer consumption)
- Tests: new `tests/unit/test_image_documents.py` (7 tests: defaults, env overrides, case/trim parse, validators, set separation); `/api/config` pins updated to six keys in `test_api.py` (+ new `images=True` flag-tracking test), `test_save_as_doc_button.py`, `test_ui_settings_api.py`, and 2 E2E suites; conftest leak-pins for the 3 vars (house pattern)
**Verification**
- `uv run pytest` → 2658 passed
- `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (>90%); touched modules 100%
- `uv run ruff check . && uv run pyright` → clean (0 errors)
- E2E sanity (isolated): `test_configurable_brand.py` 5 passed, `test_ui_customization.py` 4 passed
**Decisions**: added the `image_extensions` validator (not explicit in the task, but the design cites the `import_extensions` precedent and the house fail-loud rule); `images` placed after `docs_repo_configured` in the response dict. No live-infra touched.
**Next pending task**: `.agents/phases/todo/122_image_documents/02_image_ingest.md`
@@ -0,0 +1,105 @@
........................................................................ [ 2%]
........................................................................ [ 5%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 13%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 21%]
........................................................................ [ 24%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 32%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 40%]
........................................................................ [ 43%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 51%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 59%]
........................................................................ [ 62%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 70%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 78%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 86%]
........................................................................ [ 89%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 97%]
.................................................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 226 1 99%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 156 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 139 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 247 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 66 0 100%
app/models.py 129 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 347 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 206 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 219 3 99%
app/rag/llm.py 244 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 102 0 100%
app/rag/retriever.py 242 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 344 0 100%
--------------------------------------------------
TOTAL 4333 16 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,17 @@
Everything is complete and green. The diff is coherent and matches the task spec exactly.
## Report — Task 02 (image ingest) complete
**Implemented**
- `app/models.py`: `Document.is_image` (bool, server-default false) + `Document.image_path` (Text NULL), house docstrings
- `alembic/versions/0022_documents_image.py`: both columns + tested downgrade (A13); applied to dev DB
- `app/rag/importer.py`: walk admits `image_extension_set` only while `settings.images` (separate set, empty default = byte-identical off); `_index_file` image branch BEFORE `read_text` → `_index_image_file` (bytes sha256, persistent copy `image_dir/<doc-id>.<ext>` written only after successful description, stale-copy delete on change, `is_image`/`image_path` set, description = content, normal chunk/`_store_summary` pipeline); single commented seam `_describe_or_skip` for task 03; `_prune` guard (off → `is_image` docs survive; on → pruned + copy deleted); `ImportSummary.images_failed` + slot in the PLAN §9 log line
- Tests: unit section in `tests/unit/test_image_documents.py` (walk filter, binary branch incl. `read_text`-never-called, fail-soft skip + log line, unchanged/backfill, stale-copy delete, prune guard); `tests/integration/test_migration_0022.py` (upgrade/downgrade/round-trip, backfill, ORM); `tests/integration/test_docs_api.py` `import_sources` e2e (on/off/prune)
**Results**
- `uv run pytest --cov=app --cov-report=term-missing` → 2672 passed, TOTAL 99% (>90% gate)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors
**Decisions**: task-02 seam `_describe_or_skip` is a placeholder returning `None` (task 03's `describe_image` fills its body — per the task file's strict reading, since task 03 isn't merged); log-line pin and a walk-stub signature in `tests/unit/test_importer.py` updated for the additive changes. Note: source *removal* (`DELETE /api/git-sources/{id}`) doesn't yet delete image copies — outside this task's list; flag for a later task.
**Next pending task**: `03_image_description.md`
@@ -0,0 +1,106 @@
........................................................................ [ 2%]
........................................................................ [ 5%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 13%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 21%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 29%]
........................................................................ [ 32%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 40%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 48%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 56%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 64%]
........................................................................ [ 67%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 75%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 83%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 91%]
........................................................................ [ 94%]
........................................................................ [ 97%]
........................................................................ [ 99%]
........ [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 226 1 99%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 156 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 139 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 247 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 66 0 100%
app/models.py 131 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 347 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 206 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 313 17 95%
app/rag/llm.py 244 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 102 0 100%
app/rag/retriever.py 242 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 344 0 100%
--------------------------------------------------
TOTAL 4429 30 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,22 @@
Everything is green and coherent. Final state of the task verified against all completion criteria.
## Report — Task 03 (image description) complete
**Implemented**
- `describe_image` in `app/rag/summarizer.py` — ONE CHAT-model (vision) call (LOCKED A3), multimodal user message (`DESCRIBE_PROMPT` text part + `image_url` data-URL part), reply stripped + capped at `summary_max_chars`, `None` on client error/empty/non-2xx (logs the reason; the importer's warning names the doc)
- New constants: `IMAGE_DESCRIPTION_MODE` marker + `DESCRIBE_PROMPT` (E2E-mock key, task 06 wires the branch), `IMAGE_MIMES` map + fallback (task 04 reuses for `Content-Type`)
- Importer: `_describe_or_skip` seam filled (real `describe_image`, mime from `IMAGE_MIMES`); `_store_summary` image-aware — for `is_image` docs the description IS the summary (verbatim, no `lite` call/pointer), so `content == summary` and the `is_summary` chunk mirrors it; the phase-118 backfill reuses the same path
- Widened `Embedder`/`SummaryLLM`/`FakeEmbedder` chat typings to multimodal (no `LLMClient` change needed); E2E mock `_user`/`_context` made list-safe for multimodal messages (byte-identical for string content)
- Tests: 8 new unit + 1 new integration (mock-vision end-to-end, **no seam patch**); fixed task-02's backfill test for task-03 semantics (an image's remaining summary failure class is now the `is_summary` chunk's embed, not a failing `lite` call)
**Results**
- `uv run pytest` → 2681 passed
- `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (summarizer 100%, importer 95%)
- `uv run ruff check . && uv run pyright` → clean
- E2E spot-checks (smoke / agent-document-tools / oneshot-retry, in isolation) → all passed
**Decisions**
- `describe_image` follows the existing one-shot `chat()` convention (house phase-96 empty-content retry policy) — no extra retry loop added
- Image `doc.summary` carries the description verbatim (no `Source:` pointer line)
**Next pending task:** `04_serve_and_display.md`
@@ -0,0 +1,106 @@
........................................................................ [ 2%]
........................................................................ [ 5%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 13%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 21%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 29%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 37%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 45%]
........................................................................ [ 48%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 56%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 64%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 72%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 80%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 88%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 96%]
........................................................................ [ 99%]
................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 226 1 99%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 156 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 139 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 247 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 66 0 100%
app/models.py 131 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 347 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 206 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 316 16 95%
app/rag/llm.py 244 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 102 0 100%
app/rag/retriever.py 242 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 46 0 100%
app/schemas.py 344 0 100%
--------------------------------------------------
TOTAL 4454 29 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,18 @@
All gates green. Final report:
**Implemented (task 04 — serve + display):**
- `GET /api/documents/{id}/image` (app/api/docs.py): exact bytes via `FileResponse`, `Content-Type` from the shared `IMAGE_MIMES` map, `Cache-Control: private, max-age=3600`; 404 `document not found` for missing/malformed id, text docs, NULL `image_path`, lost file
- Content endpoint gains `is_image` (always present) + `image_url` (absent for text docs, never null); tree file nodes gain the same affordance, row-driven (text nodes byte-identical to pre-phase)
- Viewer (`document.js`, shared page+modal core): `<img>` block from `image_url` (alt = summary), description stays in the plain-content slot, onerror → "Image unavailable" note; duplicate summary panel suppressed when summary === content
- Sources page: fixed 48px lazy thumbnail (object-fit cover) before the path link, document-glyph fallback on fetch failure; CSS in theme tokens
**Tests / gates (all green):**
- `uv run pytest --cov=app` → 2702 passed, TOTAL **99%** (>90%)
- `uv run ruff check . && uv run pyright` → clean (0 errors)
- New: route battery (6 ext Content-Types, exact bytes), 404 matrix, auth pin, content/tree wire tests (integration) + schema wire + frontend source pins + builder image-node tests (unit); spot-checked E2E `test_smoke`/`test_summary_in_viewer` → pass
**Notable decisions:**
- **Flagged deviation:** task says "PUBLIC" citing anonymous content — stale: phase 79 (owner, 2026-08-22) gated the content endpoint `require_user` (A10: only shared chats anonymous). The image route uses the same `require_user` gate ("like the document content endpoint"); anonymous → 401
- Thumbnail lives in the Path cell (the phase-97 file table has no icon column); tree affordance is row-driven so surviving image docs keep thumbnails after toggle-off
**Next pending task:** `.agents/phases/todo/122_image_documents/05_rag_display.md`
@@ -0,0 +1,106 @@
........................................................................ [ 2%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 10%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 18%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 26%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 34%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 42%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 50%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 66%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 74%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 82%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 90%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 98%]
...................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 226 1 99%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 179 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 139 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 247 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 66 0 100%
app/models.py 131 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 347 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 206 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 316 16 95%
app/rag/llm.py 244 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 102 0 100%
app/rag/retriever.py 242 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 46 0 100%
app/schemas.py 366 0 100%
--------------------------------------------------
TOTAL 4499 29 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,18 @@
Everything is complete and green. Final report:
**Task 05 (RAG display) — implemented**
- `app/schemas.py` — `SourceRef` gains optional `image_url` (bytes-route path); wrap-serializer drops it when `None` → text-doc frames byte-identical, never `null`
- `app/rag/retriever.py` — shared helper `source_ref_with_image(doc)` sets `image_url` iff `doc.is_image`; `app/api/chat.py` cited + related tiers both route through it (lockstep)
- `app/rag/agent.py` — `IMAGE_DOC_MARKER` constant; `read` of an image doc gets the marker line between the byte-identical header/date lines and the description (plain + truncation paths); text results byte-identical
- `frontend/assets/app.js` — `appendSourceImageFigure` + `fetchContentSummary`, gated on `s.image_url` in both `appendSources` and `appendRelated`: compact inline `<img>` (additive — chip text/affordance kept), alt + visible caption = doc summary (fetched from the existing content endpoint — the frame carries no summary per assumption 5; title until it settles), img error removes the figure (collapses to plain chip, never a broken icon)
- `frontend/assets/styles.css` — `.source-image*` rules (96px cap, `object-fit: contain`, `--surface`, AA 8.6:1 caption, flat underline hover)
- Tests: 9 new unit tests in `tests/unit/test_image_documents.py` (helper present/absent + nested done-frame pin + pre-phase saved-ref parse, marker pins incl. truncation, house-style frontend contracts) + 2 integration tests in `test_chat_api.py` (image doc's ref alone carries `image_url`; text-only grounded turn → raw wire frame contains no `image_url` bytes, refs keep exactly the pre-122 key set)
**Gates (exact):**
- `uv run pytest --cov=app --cov-report=term` → **2714 passed**, TOTAL **99%** (>90%)
- `uv run ruff check .` → **All checks passed**
- `uv run pyright` → **0 errors, 0 warnings**
**Notable decisions:** summary caption fetched client-side (frame carries no summary — `image_url` is the only new frame field); the figure also renders in the related-docs row (same ref shape, same helper); E2E rendering scenario is task 06's scope per the task file. No live-infrastructure changes.
**Next pending task:** `06_image_tests.md` (phase-122 test suite: unit consolidation + integration extensions + isolated E2E `test_image_documents.py`).
@@ -0,0 +1,106 @@
........................................................................ [ 2%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 10%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 18%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 26%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 34%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 42%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 50%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 71%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 79%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 87%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 95%]
........................................................................ [ 98%]
.................................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 226 1 99%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 179 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 139 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 247 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 66 0 100%
app/models.py 131 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 349 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 206 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 316 16 95%
app/rag/llm.py 244 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 102 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 46 0 100%
app/schemas.py 373 0 100%
--------------------------------------------------
TOTAL 4514 29 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,20 @@
All checks complete. The phase 122 task 06 is done — all gates green.
## Report
**Implemented (task 06 — image tests):**
- `tests/e2e/test_image_documents.py` (new, isolated): module app with `BOR_IMAGES=true` (leak-guard env) + a second default-env app (`BOR_IMAGES=false`); real admin upload flow (single-PNG zip) → UI "Sync sources" → Sources thumbnail, document-viewer image+description, chat sources inline figure, and the default-off negative case (same upload → no image doc, "0 added · 0 unchanged")
- `tests/e2e/mock_llm.py`: `IMAGE_DESCRIPTION_MODE` branch + `IMAGE_DESCRIPTION_ANSWER` (deterministic, token-dense so the question grounds at cosine ≈0.31)
- `tests/integration/test_docs_api.py`: added the task's failing-vision integration pin (`images_failed == 1`, no row, text doc indexed, warning names the doc)
- **Defect fix (task 04 work):** `frontend/assets/sources.js` `renderLevel` built the `makeRow` row without `is_image`/`image_url`/`summary` — the Sources thumbnail never rendered; now passed through (text nodes stay undefined → byte-identical bare link)
**Test / lint / coverage:**
- `uv run pytest` → **2715 passed** (baseline 2714 + 1)
- `uv run pytest --cov=app --cov-report=term` → **TOTAL 99%** (>90% gate)
- `uv run pytest tests/e2e/test_image_documents.py -v --no-cov` → **4 passed** (ran 3× incl. consecutive stability; DB up)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors
- Cross-check: `test_chat_rag.py` + `test_document_viewer.py` still pass (11) — mock change is additive; restored an unrelated screenshot regenerated by the cross-check run
**Decisions:** chat test uses the phase-119 A1 scripted-read convention (zero-read turns chip nothing — the image doc must be read to earn chip+figure); both E2E apps pin `BOR_IMAGES` explicitly so an operator `.env` can't leak the toggle either direction.
**Next pending task:** none in `122_image_documents` — phase 122 complete (harness moves task 06 + commits). Next phase: `123_chat_image_submission` (todo).
@@ -0,0 +1,106 @@
........................................................................ [ 2%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 10%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 18%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 26%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 34%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 42%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 50%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 63%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 71%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 79%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 87%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 95%]
........................................................................ [ 98%]
................................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 226 1 99%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 179 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 139 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 247 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 66 0 100%
app/models.py 131 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 349 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 206 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 316 16 95%
app/rag/llm.py 244 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 102 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 46 0 100%
app/schemas.py 373 0 100%
--------------------------------------------------
TOTAL 4514 29 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
+14
View File
@@ -112,6 +112,20 @@ BOR_IMPORT_EXTENSIONS=md,markdown,txt,yaml,yml,json,py,container,network,volume,
# BOR_UPLOAD_MAX_MB=512 # caps BOTH the compressed upload and the total
# extracted bytes (zip-bomb guard); must be > 0
# --- Image documents (phase 122) ---
# Master switch for indexing standalone image files (direct uploads,
# uploaded archives, git/local source walks) as first-class documents.
# OFF by default — enable only when BOR_LLM_CHAT_MODEL supports vision:
# the chat model generates the image's description, and that
# description is the ONLY part of the image that gets indexed (the
# embedding model never sees pixels). While off, image files are
# ignored by the import walks and a sync never prunes existing image
# documents.
# BOR_IMAGES=0 # 1 = index standalone images
# BOR_IMAGE_EXTENSIONS=png,jpg,jpeg,webp,gif,bmp # comma-separated, case-insensitive
# BOR_IMAGE_DIR=~/bor-sources/images # persistent home for the served image bytes
# (uploads are replaced, checkouts re-cloned)
# --- Docs push (phase 59: save a chat answer as documentation) ---
# The git repo chat answers can be committed to — any remote (URL or
# local path). While empty, the "Save as doc" action is hidden and the
+54
View File
@@ -0,0 +1,54 @@
"""documents image columns: is_image + image_path (phase 122, task 02)
Revision ID: 0022
Revises: 0021
Create Date: 2026-09-24
Phase 122 (standalone images become first-class documents — task 02,
storage only):
* ``documents.is_image`` — BOOLEAN NOT NULL, server default
``false``: True iff the doc is a standalone image (LOCKED A3) whose
``content``/``summary`` is the CHAT model's vision description — the
ONLY embedded text (the embedding model never sees pixels). The
server default makes EVERY pre-phase-122 row a text doc without a
backfill.
* ``documents.image_path`` — TEXT NULLABLE: the absolute path of the
image's persistent copy in ``settings.image_dir`` (``<doc-id>.<ext>``
— the copy must outlive the source file: uploads are replaced on
every upload, git checkouts are re-cloned). NULL for text docs.
One additive, fully reversible migration (A13); no other schema
change. The walk filter, the binary index path, and the prune guard
are importer code (task 02) — this revision only carries the columns.
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "0022"
down_revision = "0021"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"documents",
sa.Column(
"is_image",
sa.Boolean(),
server_default=sa.text("false"),
nullable=False,
),
)
op.add_column("documents", sa.Column("image_path", sa.Text(), nullable=True))
def downgrade() -> None:
# Both columns are the only 0022 artefacts — dropping them leaves
# 0021's schema byte-identical (A13, fully reversible).
op.drop_column("documents", "image_path")
op.drop_column("documents", "is_image")
+8 -5
View File
@@ -224,6 +224,7 @@ from app.rag.retriever import (
retrieve,
select_related,
select_suggested,
source_ref_with_image,
weak_hit_titles,
)
from app.rag.scaffolding import ScaffoldingFilter # phase 71: the streaming filter
@@ -984,14 +985,16 @@ async def chat(
# since phase 119). The UI renders the row as the
# de-emphasized related-docs row, never a citation chip;
# old clients ignore the field.
# Phase 122 (task 05): both tiers build their refs
# through the shared helper — a ref for an image doc
# carries the optional ``image_url`` (the bytes route),
# a text doc's stays byte-identical to pre-phase (the
# key is omitted, never null).
cited_refs: list[SourceRef] = []
if not plan.deflected:
cited_refs = [
SourceRef(source=d.source, path=d.path, title=d.title)
for d in cited_docs
]
cited_refs = [source_ref_with_image(d) for d in cited_docs]
related_refs = [
SourceRef(source=d.source, path=d.path, title=d.title)
source_ref_with_image(d)
for d in plan.related_docs
if (d.source, d.path) not in cited_seen
]
+14 -8
View File
@@ -1,6 +1,7 @@
"""Public app metadata (display name + version) for the frontend brand
layer, the phase-59 docs-push flag (the "Save as doc" gating), and the
phase-62 UI customization strings (composer placeholder, footer line).
layer, the phase-59 docs-push flag (the "Save as doc" gating), the
phase-122 image flag (UI affordance gating), and the phase-62 UI
customization strings (composer placeholder, footer line).
Phase 91 (task 01): the three UI strings are now the EFFECTIVE values —
the ``ui_settings`` row (admin Theme tab) over the env values (B1: DB
@@ -29,17 +30,21 @@ router = APIRouter(tags=["config"])
@router.get("/config")
def app_config(settings: Settings = Depends(get_settings)) -> dict[str, str | bool]: # noqa: B008
"""Public app metadata for the frontend brand layer (phase 39) +
the phase-59 ``docs_repo_configured`` flag + the phase-62 UI
customization keys (``input_placeholder``, ``footer_text``) — all
display strings, the SAME boot fetch (no new network surface) and
the same public posture as ``app_name`` (no secrets). Phase 91:
the phase-59 ``docs_repo_configured`` flag + the phase-122
``images`` flag + the phase-62 UI customization keys
(``input_placeholder``, ``footer_text``) — all display strings,
the SAME boot fetch (no new network surface) and the same public
posture as ``app_name`` (no secrets). Phase 91:
``app_name`` / ``input_placeholder`` / ``footer_text`` are the
EFFECTIVE values (the admin Theme tab's ``ui_settings`` row over
the env values — DB-over-env, B1); the frontend brand layer treats
an empty string as "keep the template default" (the unset =>
byte-identical contract). Phase 91 (task 03): the retired
CSS-file theming's ``theme`` key is gone — the five keys are the
entire response."""
CSS-file theming's ``theme`` key is gone. Phase 122 (task 01):
``images`` mirrors ``settings.images`` (the ``BOR_IMAGES`` master
switch) — consumed by the chat composer (phase 123) to show/hide
the image-attach control, optionally by the Sources page (an
"images off" hint). The six keys are the entire response."""
db = SessionLocal()
try:
effective = theming.effective_settings(db, settings)
@@ -49,6 +54,7 @@ def app_config(settings: Settings = Depends(get_settings)) -> dict[str, str | bo
"app_name": effective["app_name"],
"version": settings.app_version,
"docs_repo_configured": settings.docs_configured,
"images": settings.images,
"input_placeholder": effective["input_placeholder"],
"footer_text": effective["footer_text"],
}
+158 -17
View File
@@ -23,6 +23,18 @@ GET /api/docs/tree — the admin's full recursive KB tree in one fetch
walks, with the file metadata the RAG view's rows and stat cards need
(the view drills client-side; ``GET /api/docs`` is untouched).
GET /api/documents/{doc_id}/image — the phase-122 (task 04) image
BYTES route: the persistent copy behind an ``is_image`` document's
``image_path``, served with the extension's ``Content-Type`` (the
``app.rag.summarizer.IMAGE_MIMES`` map — one map, one truth) and a
``Cache-Control: private, max-age=3600`` header (the bytes are
content-hashed — long enough, bustable by re-upload). User-gated like
the content endpoint (phase 79 — the ONLY anonymous surface is the
shared chats): a missing doc, a non-image doc, a doc whose
``image_path`` is NULL, or a row whose copy was lost all map to 404
``document not found`` (the router's unknown-document shape —
traversal/UUID-guessing has no filesystem surface to hit).
PATCH /api/folders/summary — the admin folder-description editor
(phase 97, task 03): update / create / clear a stored
``folder_summaries`` row, marking every non-empty save
@@ -34,11 +46,13 @@ contrast with the phase-57 ``is_summary`` re-embed above.
"""
from __future__ import annotations
import uuid
from collections.abc import Mapping, Sequence
from datetime import UTC, datetime
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from sqlalchemy import func, select
from sqlalchemy.orm import Session
@@ -52,6 +66,7 @@ from app.rag.doc_dates import normalize_doc_date
from app.rag.folder_summaries import MIN_DOCS_PER_FOLDER, folder_of
from app.rag.importer import match_extension
from app.rag.llm import EmbeddingError, LLMClient
from app.rag.summarizer import IMAGE_FALLBACK_MIME, IMAGE_MIMES
from app.schemas import (
DateResult,
DateUpdate,
@@ -171,6 +186,11 @@ def get_document_content(
if row is None:
raise HTTPException(status_code=404, detail="document not found")
doc, chunks = row
# Phase 122 (task 04): the image affordance — ``is_image`` is ALWAYS
# present on the wire (text docs: false — the one new key); for an
# image doc, ``image_url`` is the bytes route's path (absent for
# text docs, and for an image row whose copy path is NULL — never
# null, the ``DocContent`` omission rule).
return DocContent(
source=doc.source,
path=doc.path,
@@ -181,6 +201,62 @@ def get_document_content(
content=doc.content,
indexed_at=doc.indexed_at.isoformat(),
chunks=chunks,
is_image=doc.is_image,
image_url=(
f"/api/documents/{doc.id}/image" if doc.is_image and doc.image_path else None
),
)
@router.get("/documents/{doc_id}/image", response_class=FileResponse)
def get_document_image(
doc_id: str,
db: Session = Depends(get_db), # noqa: B008
_user: None = Depends(require_user), # noqa: B008 # phase 79 posture (see docstring)
) -> FileResponse:
"""The phase-122 (task 04) image BYTES route — the persistent copy
behind an ``is_image`` document's ``image_path``.
Same auth posture as the document content endpoint (phase 79 —
``require_user``: admin OR live token holder; the ONLY anonymous
surface is the shared chats — the image is part of a document's
content, so it travels under the same gate): anonymous callers get
401 ``authentication required`` before any row is read.
404 ``document not found`` (the router's unknown-document shape) in
every non-servable case — a missing id (an unparseable string maps
here too, not to a 422 — a guessed id is an unknown document), a
text doc, an image doc whose ``image_path`` is NULL, or a row whose
copy was lost on disk (defensive — the row exists, the bytes
don't). There is no path parameter to a filesystem value: the path
comes from the ROW (the importer's ``image_dir`` copy), so there is
no traversal surface.
Servable rows stream the exact bytes with the extension's
``Content-Type`` (the ``IMAGE_MIMES`` map — one map, one truth with
the describe call's data-URL mime; an unexpected extension takes
``application/octet-stream``) and ``Cache-Control: private,
max-age=3600`` (the bytes are content-hashed — long enough to be
useful, bustable by re-upload).
"""
try:
uid = uuid.UUID(doc_id)
except ValueError:
raise HTTPException(status_code=404, detail="document not found") from None
doc = db.scalar(select(Document).where(Document.id == uid))
if doc is None or not doc.is_image or not doc.image_path:
raise HTTPException(status_code=404, detail="document not found")
image_file = Path(doc.image_path)
if not image_file.is_file():
# Defensive: the row exists but the copy was lost (the owner
# cleaned the image dir, the disk was wiped) — the viewer's
# onerror fallback renders the "image unavailable" note.
raise HTTPException(status_code=404, detail="document not found")
media_type = IMAGE_MIMES.get(image_file.suffix.lower(), IMAGE_FALLBACK_MIME)
return FileResponse(
image_file,
media_type=media_type,
headers={"Cache-Control": "private, max-age=3600"},
)
@@ -446,6 +522,13 @@ def _folder_counts(
return folders, counts
#: One image-docs map value (phase 122, task 04):
#: ``(doc_id, summary)`` — the id the bytes route's URL is built from
#: and the summary the RAG view's thumbnail uses as ``alt`` (the vision
#: description; ``None`` = the fail-soft backfill corner).
ImageDocInfo = tuple[str, str]
def _level_children(
source: str,
folder: str,
@@ -453,6 +536,7 @@ def _level_children(
counts: dict[str, int],
rows: Sequence[TreeFileRow],
summaries: Mapping[tuple[str, str], str],
images: Mapping[tuple[str, str], ImageDocInfo] | None = None,
) -> list[KbTreeFolder | KbTreeFile]:
"""One level's children (pure): subfolders in path order, then the
direct files in input (catalog) order.
@@ -477,10 +561,19 @@ def _level_children(
= the subtree's MAX document ``created_at``: the max over this
folder's direct files' dates and its subfolder children's (already
recursive) ``updated_at`` values, via :func:`_subtree_max`.
Since phase 122 (task 04), a file node whose ``(source, path)`` is
in *images* carries the thumbnail affordance (``is_image`` +
``image_url`` built from the mapped doc id + the mapped ``summary``
— see :class:`app.schemas.KbTreeFile`); every other file node is
the pre-phase shape (the omission rule keeps its wire shape
byte-identical).
"""
children: list[KbTreeFolder | KbTreeFile] = []
for sub in sorted(g for g in folders if folder_of(g) == folder):
sub_children = _level_children(source, sub, folders, counts, rows, summaries)
sub_children = _level_children(
source, sub, folders, counts, rows, summaries, images
)
children.append(
KbTreeFolder(
path=sub,
@@ -494,15 +587,34 @@ def _level_children(
)
for path, title, chunks, indexed_at, created_at in rows:
if folder_of(path) == folder:
children.append(
KbTreeFile(
path=path,
title=title,
chunks=chunks,
created_at=created_at,
indexed_at=indexed_at,
image_info = images.get((source, path)) if images else None
if image_info is not None:
# Phase 122 (task 04): the image-docs node — the RAG
# view's Path cell renders the 48px thumbnail from the
# bytes route's URL with ``alt = summary``.
doc_id, doc_summary = image_info
children.append(
KbTreeFile(
path=path,
title=title,
chunks=chunks,
created_at=created_at,
indexed_at=indexed_at,
is_image=True,
image_url=f"/api/documents/{doc_id}/image",
summary=doc_summary,
)
)
else:
children.append(
KbTreeFile(
path=path,
title=title,
chunks=chunks,
created_at=created_at,
indexed_at=indexed_at,
)
)
)
return children
@@ -530,6 +642,7 @@ def build_kb_tree(
names: Sequence[str],
doc_rows: Sequence[TreeDocRow],
summaries: Mapping[tuple[str, str], str],
images: Mapping[tuple[str, str], ImageDocInfo] | None = None,
) -> list[KbTreeSource]:
"""The pure tree builder behind ``GET /api/docs/tree`` (phase 97,
task 02) — module-level and DB-free so unit tests drive it
@@ -543,7 +656,13 @@ def build_kb_tree(
``{(source, folder_path): summary}`` over the stored
``folder_summaries`` rows (``folder_path = ""`` = the source root;
rows for sources the tree does not list are simply never
referenced).
referenced). *images* (phase 122, task 04) —
``{(source, path): (doc_id, summary)}`` over the stored image docs
(``is_image`` rows with a servable ``image_path`` — the endpoint
composes the bounded select); the default ``None``/empty map keeps
EVERY file node the pre-phase shape (byte-identical wire — a
pre-phase KB has no image rows, so the endpoint's own map is empty
for it).
Shape, per the phase-97 ``00_phase.md`` "The tree endpoint":
@@ -614,10 +733,10 @@ def build_kb_tree(
if name in listed: # defensive: list_source_names dedupes
continue
listed.add(name)
tree.append(_source_node(name, by_source.get(name, ()), summaries))
tree.append(_source_node(name, by_source.get(name, ()), summaries, images))
for source in sorted(by_source):
if source not in listed:
tree.append(_source_node(source, by_source[source], summaries))
tree.append(_source_node(source, by_source[source], summaries, images))
return tree
@@ -625,6 +744,7 @@ def _source_node(
source: str,
rows: Sequence[TreeFileRow],
summaries: Mapping[tuple[str, str], str],
images: Mapping[tuple[str, str], ImageDocInfo] | None = None,
) -> KbTreeSource:
"""One source node (pure): whole-source count + the source-root
summary + the root level's children (direct subfolders + direct
@@ -646,7 +766,7 @@ def _source_node(
0-document source (no children, no dates).
"""
folders, counts = _folder_counts(rows)
children = _level_children(source, "", folders, counts, rows, summaries)
children = _level_children(source, "", folders, counts, rows, summaries, images)
return KbTreeSource(
name=source,
documents=len(rows),
@@ -677,9 +797,12 @@ def list_kb_tree(
excluded — the tree has no document ids) + ALL stored
``folder_summaries`` rows (a bounded select — one row per
existing folder at the ≥ 1-doc rule; rows for sources the tree
does not list are never referenced by the builder) — through the
pure :func:`build_kb_tree`. ``GET /api/docs`` itself is
untouched.
does not list are never referenced by the builder) + the phase-122
(task 04) image-docs map (a second bounded select over the
``is_image`` rows with a servable ``image_path`` — empty for every
pre-phase KB, so the response stays byte-identical to pre-phase)
— through the pure :func:`build_kb_tree`. ``GET /api/docs`` itself
is untouched.
"""
names = list_source_names(db)
rows = db.execute(
@@ -712,4 +835,22 @@ def list_kb_tree(
select(FolderSummary.source, FolderSummary.folder_path, FolderSummary.summary)
).all()
}
return KbTree(sources=build_kb_tree(names, doc_rows, summaries))
# Phase 122 (task 04): the image-docs affordance map — a bounded
# select over the ``is_image`` rows only (a handful of rows at KB
# scale, never the whole catalog; the catalogue query above stays
# byte-identical). EMPTY for every pre-phase KB (no image rows), so
# the response stays byte-identical to pre-phase — the fields are
# row-driven, not toggle-driven (a surviving image doc keeps its
# thumbnail through a toggle-off sync, the prune guard's UX side).
images: dict[tuple[str, str], tuple[str, str]] = {
(source, path): (str(doc_id), summary)
for source, path, doc_id, summary in db.execute(
select(
Document.source,
Document.path,
Document.id,
Document.summary,
).where(Document.is_image.is_(True), Document.image_path.is_not(None))
).all()
}
return KbTree(sources=build_kb_tree(names, doc_rows, summaries, images))
+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).
+23
View File
@@ -139,6 +139,29 @@ class Document(Base):
#: failed, and until the phase-118 backfill stores one on the next
#: sync.
summary: Mapped[str | None] = mapped_column(Text, default=None)
#: True iff this document is a standalone image (phase 122,
#: LOCKED A3): ``content`` (and ``summary``) is the CHAT model's
#: vision description of the image — the ONLY embedded text (the
#: embedding model never sees pixels), and the image bytes
#: themselves live at :py:attr:`image_path` (served by the document
#: image route, task 04). ``False`` for every text document,
#: including all pre-phase-122 rows (the server default keeps them
#: valid without a backfill). An ``is_image`` doc is INVISIBLE to
#: an images-off walk, not a deleted file — the importer's prune
#: guard (the phase-122 derived decision) protects it while the
#: toggle is off.
is_image: Mapped[bool] = mapped_column(
Boolean, default=False, server_default=text("false"), nullable=False
)
#: Absolute path of the image's PERSISTENT copy in
#: ``settings.image_dir`` (phase 122) — the importer copies each
#: ingested image there (``<doc-id>.<ext>``) because the source
#: file is disposable: uploads are replaced on every upload, git
#: checkouts are re-cloned, local dirs are user-edited. The copy is
#: written only when the doc is new or its hash changes, deleted on
#: a content change (the stale copy) and on prune. NULL for text
#: documents.
image_path: Mapped[str | None] = mapped_column(Text, default=None)
chunks: Mapped[list[Chunk]] = relationship(
back_populates="document", cascade="all, delete-orphan"
+23 -2
View File
@@ -427,6 +427,18 @@ READ_TRUNCATION_NOTICE = (
"document."
)
#: The image-document marker (phase 122, task 05): the line prefixed to
#: the vision DESCRIPTION a ``read`` of a standalone-image document
#: returns — the model must reason about what it is reading (the text
#: below is a description GENERATED from the image, not the image's
#: own words). It sits on the result's THIRD line: the ``Document …``
#: header and the phase-106 date line stay byte-identical (the E2E
#: mock's ``_READ_RESULT_PREFIX`` header contract), and a NON-image
#: doc's result carries no marker at all (byte-identical to pre-122).
IMAGE_DOC_MARKER = (
"Image document — the text below is a description generated from the image:"
)
#: The no-source ``ls`` refusal with the teaching parenthetical
#: appended (phase 72): used when a stripped scope has no ``/`` and
#: matches no registered source (the incident's ``ls(path='.')``). The
@@ -1270,6 +1282,12 @@ def _execute_tool(
return _no_document_refusal(db, arg)
holder.read_docs.append(doc)
holder.tool_calls += 1
# Phase 122 (task 05): an image document's content IS the vision
# description — the marker line (a third line between the
# byte-identical header/date lines and the text) tells the model
# what it is reading. A text doc's ``marker`` is "" — the result
# stays byte-identical to pre-122.
marker = f"{IMAGE_DOC_MARKER}\n" if doc.is_image else ""
cap = settings.read_max_chars
if len(doc.content) > cap:
# Phase 95 (owner permission 2026-09-10, ``TODO.md`` L5): the
@@ -1293,17 +1311,20 @@ def _execute_tool(
return (
f"Document {doc.source}/{doc.path}:\n"
f"date: {doc.created_at:%Y-%m-%d}\n"
f"{marker}"
f"{doc.content[:cap]}\n"
f"{TRUNCATION_MARKER}\n"
f"{READ_TRUNCATION_NOTICE.format(shown=cap, total=len(doc.content))}"
)
# At or under the cap: the pre-phase-95 result plus the
# phase-106 D5 date line (first line byte-identical — the
# mock's header contract; no marker, no notice, no holder
# entry, no ToolResultPiece).
# mock's header contract; no truncation marker, no notice, no
# holder entry, no ToolResultPiece) — and, phase 122, the
# image-document marker line for image docs only.
return (
f"Document {doc.source}/{doc.path}:\n"
f"date: {doc.created_at:%Y-%m-%d}\n"
f"{marker}"
f"{doc.content}"
)
if call.name == "grep":
+374 -13
View File
@@ -30,7 +30,12 @@ by their exact lowercased full filename (``Dockerfile`` under the
no longer exist **or no longer match the format filter** — this is how
previously-imported junk (e.g. dot-dir READMEs) leaves the index. Per-file
logging uses the verbs ``added | updated | unchanged | pruned`` plus a
summary line with per-format counts (PLAN §9).
summary line with per-format counts (PLAN §9). Phase 122 prune guard
(LOCKED, derived from A3/A4): while the ``images`` toggle is OFF, an
``is_image`` doc is INVISIBLE to the walk, not a deleted file — prune
skips it (turning the toggle off and syncing must never destroy image
documents); a toggle-ON run prunes a deleted image file normally and
deletes its ``image_dir`` copy with the row.
Document dates (phase 106, D2/D4): every import sources
``documents.created_at`` from the file's source — the per-file git
@@ -54,6 +59,32 @@ backfill runs BEFORE the ``created_at_manual`` early-return (the manual
flag protects the DATE only, D1) and the strict ``is None`` check leaves
owner-set summaries (even empty strings, phase 57) alone.
Standalone images (phase 122, LOCKED A3): with the ``images`` toggle
(``BOR_IMAGES``) ON, the walk also admits the image extension set
(``BOR_IMAGE_EXTENSIONS`` — a SEPARATE set from ``import_extension_set``;
images are never user-added via ``BOR_IMPORT_EXTENSIONS``, the toggle is
the single knob). Such a file takes the binary index path
(:func:`_index_image_file`): the sha256 digest is over the raw BYTES
(content identity — the digest rule is unchanged), the bytes are copied
to the persistent home ``settings.image_dir/<doc-id>.<ext>`` (dir created
on demand; the copy is written ONLY after a successful description, so a
failure never leaves an orphan; a changed image deletes the stale copy
first; a pruned image doc deletes its copy), the row carries
``is_image=True`` + ``image_path``, and ``content`` is the vision
description — the ONLY embedded text of the document (the embedding model
never sees pixels; ``read_text`` is never called for an image). The
description comes through the single seam :func:`_describe_or_skip`
(task 03: :func:`app.rag.summarizer.describe_image` — ONE CHAT-model
(vision) call with the image bytes as a base64 data URL; the ``lite``
summary model is NOT assumed vision-capable, LOCKED A3); a failed/empty
description SKIPS the doc entirely (no row, no copy) — counted in
``images_failed`` + a warning, the sync continues (fail-soft). The normal
chunk pipeline then embeds ``content`` and the phase-30 summary path runs
on it — image-aware (task 03): for an image doc the description IS the
summary (stored verbatim, no ``lite`` call, no pointer line), so the
``is_summary`` position −1 chunk mirrors ``Document.summary``, which
equals ``Document.content``.
``import_sources`` accepts an optional per-file ``progress`` callback
(phase 64, task 01) reporting the file being processed right now.
"""
@@ -61,11 +92,12 @@ from __future__ import annotations
import hashlib
import logging
import uuid
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from typing import Protocol
from typing import Any, Protocol
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -76,7 +108,12 @@ from app.models import Chunk, Document
from app.rag.chunker import chunk_document, extract_title
from app.rag.doc_dates import file_mtime_datetime, normalize_doc_date
from app.rag.llm import EmbeddingError, LLMError
from app.rag.summarizer import generate_summary
from app.rag.summarizer import (
IMAGE_FALLBACK_MIME,
IMAGE_MIMES,
describe_image,
generate_summary,
)
logger = logging.getLogger("app.importer")
@@ -94,9 +131,12 @@ class Embedder(Protocol):
async def embed(self, texts: list[str]) -> list[list[float]]: ...
async def chat(self, messages: list[dict[str, str]], model: str | None = None) -> str: ...
# ^ the one-shot completion the summarizer uses for the ``lite`` model
# (phase 30, task 01); :class:`app.rag.llm.LLMClient` satisfies it.
async def chat(self, messages: list[dict[str, Any]], model: str | None = None) -> str: ...
# ^ the one-shot completion the summarizer uses — the ``lite`` model
# for text summaries (phase 30, task 01) and the CHAT (vision)
# model for the phase-122 image description (multimodal content:
# a string or a list of OpenAI-compatible parts); :class:`app.rag.
# llm.LLMClient` satisfies it.
@dataclass
@@ -129,6 +169,12 @@ class ImportSummary:
#: so no ``sources_meta`` bump, no overview/folder-summary
#: regeneration).
dates_updated: int = 0
#: Image docs (phase 122, LOCKED A3) whose vision description failed
#: or came back empty — the doc is SKIPPED entirely (no row, no
#: ``image_dir`` copy): an undescribed image is unsearchable noise.
#: Fail-soft: the sync continues, this counter + the warning line
#: are the signal.
images_failed: int = 0
#: Files walked, keyed by lowercased extension (``md``, ``yaml``, …).
formats: dict[str, int] = field(default_factory=dict)
@@ -143,7 +189,7 @@ class ImportSummary:
logger.info(
"import: summary files=%d added=%d updated=%d unchanged=%d pruned=%d "
"errors=%d chunks=%d embed_batches=%d summaries=%d summary_errors=%d "
"summary_backfilled=%d dates_updated=%d formats=%s",
"summary_backfilled=%d dates_updated=%d images_failed=%d formats=%s",
self.files,
self.added,
self.updated,
@@ -156,6 +202,7 @@ class ImportSummary:
self.summary_errors,
self.summary_backfilled,
self.dates_updated,
self.images_failed,
self.format_counts(),
)
@@ -238,6 +285,7 @@ def iter_importable_files(
excluded: frozenset[str] = EXCLUDED_DIRS,
ignore: tuple[str, ...] = (),
include_hidden: bool = False,
image_extensions: frozenset[str] = frozenset(),
) -> list[Path]:
"""All importable files under *root* (sorted), per the A9 scope rules.
@@ -255,6 +303,13 @@ def iter_importable_files(
source-relative POSIX path starts with any entry; the default ``()``
keeps every existing caller byte-identical. The *ignore* tuple
composes additively in both states.
*image_extensions* (phase 122) is the lowercased dotted
image-extension set admitted IN ADDITION to *extensions* — passed by
:func:`import_sources` only while the ``images`` toggle is on (it
reads ``llm.settings``; the image set is never merged into
*extensions*). The empty default admits nothing: every existing caller
(and the toggle-off walk) stays byte-identical to pre-phase.
"""
if not root.is_dir():
return []
@@ -270,7 +325,12 @@ def iter_importable_files(
continue
if ignore and is_ignored(rel.as_posix(), ignore):
continue
if match_extension(path, extensions) is None:
matched = match_extension(path, extensions)
if matched is None and image_extensions:
# Phase 122: the image set is admitted IN ADDITION to the
# import set (toggle on only — the caller passes it in).
matched = match_extension(path, image_extensions)
if matched is None:
continue
files.append(path)
return files
@@ -340,10 +400,23 @@ async def import_sources(
mtime fallback applies to every file — which IS the behavior
change, D4: an unchanged file now refreshes its stored date from
its source on every run (the backfill-correction case).
Images (phase 122): when ``llm.settings.images`` is on, BOTH walks
(the progress pre-walk and the processing loop — same rules, so
``total`` counts images) also admit ``llm.settings.image_extension_set``
files, each indexed through the binary image path (see the module
docstring). ``prune=True`` with the toggle ON prunes a deleted image
file normally (row + ``image_dir`` copy); with the toggle OFF the
prune skips ``is_image`` docs (the prune guard — the image is
invisible to the walk, not a deleted file).
"""
if limit is not None and limit <= 0:
raise ValueError("limit must be >= 1")
summary = ImportSummary()
# Phase 122: the image set is admitted by the walks ONLY while the
# toggle is on — the empty set admits nothing, so the toggle-off run
# (walk, counts, prune) stays byte-identical to pre-phase.
image_exts = llm.settings.image_extension_set if llm.settings.images else frozenset()
owns_session = session is None
if session is None:
session = SessionLocal()
@@ -364,6 +437,7 @@ async def import_sources(
include_hidden=_include_hidden_for_root(
root, include_hidden_by_root
),
image_extensions=image_exts,
)
)
try:
@@ -386,6 +460,7 @@ async def import_sources(
llm.settings.import_extension_set,
ignore=ignore,
include_hidden=include_hidden,
image_extensions=image_exts,
):
if limit is not None and summary.files >= limit:
break
@@ -394,9 +469,11 @@ async def import_sources(
summary.files += 1
# Phase 102: the matched bare token (``dockerfile`` for an
# extensionless ``Dockerfile``), never ``unknown`` — the
# file is in scope, so the walk matched it.
# file is in scope, so the walk matched it. Phase 122: an
# image file matches the image set, not the import set.
ext = (
match_extension(path, llm.settings.import_extension_set)
or match_extension(path, image_exts)
or "unknown"
)
summary.formats[ext] = summary.formats.get(ext, 0) + 1
@@ -423,7 +500,9 @@ async def import_sources(
if limit is not None:
logger.warning("import: --prune ignored because --limit was given")
else:
summary.pruned = _prune(session, source_names, seen)
summary.pruned = _prune(
session, source_names, seen, images=llm.settings.images
)
summary.embed_batches = llm.embed_batches
summary.log()
return summary
@@ -448,8 +527,24 @@ async def _index_file(
git last-commit datetime from the caller's ``doc_dates_by_root``
map, or ``None`` (every non-git case): the file's mtime is read
here, once, and becomes the source date (the D2 fallback).
Image files (phase 122, toggle on) delegate to
:func:`_index_image_file` — the binary path (bytes digest,
persistent copy, ``content`` = the vision description) — BEFORE
any text read: ``read_text`` is never called for an image.
"""
settings = llm.settings
# Phase 122 (task 02): the image branch FIRST. Only reachable while
# the ``images`` toggle is on — the walk never admits image files
# while it is off, and with it off this check is a no-op (the text
# path below stays byte-identical to pre-phase).
if settings.images:
image_set = settings.image_extension_set
if match_extension(full_path, image_set) is not None:
return await _index_image_file(
session, source=source, rel=rel, full_path=full_path, llm=llm,
summary=summary, raw_date=raw_date,
)
content = full_path.read_text(encoding="utf-8", errors="replace").replace("\x00", "")
digest = hashlib.sha256(content.encode("utf-8")).hexdigest()
doc = session.scalar(select(Document).where(Document.source == source, Document.path == rel))
@@ -609,9 +704,24 @@ async def _store_summary(
a success counts ``summary_backfilled`` instead of ``summaries``
(the doc content is untouched, so the import's KB-change signal must
not move); the rest of the mechanics are identical.
Image docs (phase 122, task 03, LOCKED A3): for an ``is_image`` doc
the vision description — ``content`` (which equals ``doc.content``
on the backfill path) — IS the summary: no ``lite`` call, no
pointer line (the summary mirrors the description verbatim, so
``doc.summary`` == ``doc.content``). The phase-30 chunk mechanics
(one ``is_summary`` position −1 chunk, replacement, best-effort
rollback) are unchanged; the only remaining failure class is the
summary chunk's embed (the ``doc`` row + content chunks survive —
fail-soft, same as the text path).
"""
try:
text = await generate_summary(llm, source=source, path=rel, content=content)
if doc.is_image:
# Phase 122 (task 03): the description IS the summary —
# stored verbatim (no ``lite`` call, no pointer line).
text = content
else:
text = await generate_summary(llm, source=source, path=rel, content=content)
# Replacement: at most one summary chunk per document at a time.
# Removing from the collection is what the ``delete-orphan``
# cascade turns into a row delete on flush — and it keeps the
@@ -646,14 +756,265 @@ async def _store_summary(
logger.error("import: summary failed source=%s path=%s — %s", source, rel, e)
def _prune(session: Session, source_names: set[str], seen: set[tuple[str, str]]) -> int:
"""Delete documents of *source_names* whose file is no longer in *seen*."""
async def _describe_or_skip(
llm: Embedder, *, data: bytes, source: str, rel: str, full_path: Path
) -> str | None:
"""Phase 122 — the SINGLE seam for the image description (task 03).
Returns the vision description that becomes the image document's
``content`` (and ``summary`` — the ONLY embedded text of the doc),
or ``None`` when the description failed or came back empty — the
caller then SKIPS the doc entirely (no row, no copy) and counts
``summary.images_failed`` (LOCKED A3, fail-soft: the sync continues,
the warning + counter are the signal).
The seam is one line by design (the importer's tests patch exactly
this function): :func:`app.rag.summarizer.describe_image` — ONE
CHAT-model (vision) call (LOCKED A3) with the bytes as a data URL
whose mime comes from :data:`app.rag.summarizer.IMAGE_MIMES`
(dotted extension; an unlisted ``BOR_IMAGE_EXTENSIONS`` token takes
the generic fallback — a rejection there fails soft like any other
description error). ``source``/``rel`` stay on the signature so the
caller (and the patch) reads like the document being described;
the failure's doc identity is logged by the caller's warning.
"""
mime = IMAGE_MIMES.get(full_path.suffix.lower(), IMAGE_FALLBACK_MIME)
return await describe_image(llm, data=data, mime=mime)
def _delete_image_copy(image_path: str | None) -> None:
"""Best-effort removal of a stale image copy (phase 122).
A missing path is a no-op (already gone — e.g. the owner cleaned
the image dir); an unreadable one is logged, never raised — copy
cleanup must not break the sync (the doc row's fate is decided by
the upsert/prune logic, not by filesystem hygiene).
"""
if not image_path:
return
try:
Path(image_path).unlink(missing_ok=True)
except OSError as e:
logger.warning("import: could not delete image copy %s — %s", image_path, e)
async def _index_image_file(
session: Session,
*,
source: str,
rel: str,
full_path: Path,
llm: Embedder,
summary: ImportSummary,
raw_date: datetime | None = None,
) -> None:
"""The phase-122 image branch of :func:`_index_file` — a standalone
image is indexed from its BYTES, never its text:
* the sha256 digest is over the raw bytes (the digest rule is
content identity — the same bytes are the same document);
* the PERSISTENT copy lands in ``settings.image_dir`` as
``<doc-id>.<ext>`` (the dir is created on demand; the copy is
written only AFTER a successful description, so a failure never
leaves an orphan; a changed image deletes the stale copy before
replacing it);
* ``content`` is the vision description — the ONLY embedded text of
the document (the embedding model never sees pixels) — and the
normal chunk pipeline then embeds it, with the phase-30 summary
path running on it (the ``is_summary`` position −1 chunk mirrors
``Document.summary``).
``raw_date`` follows the text path exactly (the phase-106 D2
fallback: no source date in the map → the file's mtime, read before
the unchanged early-return because the unchanged path refreshes the
stored date from the same source; the D1 manual-date lock and the
D4 refresh apply unmodified).
Fail-soft (LOCKED A3): a failed/empty description SKIPS the doc
entirely (no row, no copy) — ``summary.images_failed`` + a warning,
the sync continues.
"""
settings = llm.settings
data = full_path.read_bytes()
digest = hashlib.sha256(data).hexdigest()
doc = session.scalar(select(Document).where(Document.source == source, Document.path == rel))
if raw_date is None:
# D2 fallback (same as the text path): no source date in the map
# → the file's mtime (one stat).
raw_date = file_mtime_datetime(full_path)
if doc is not None and doc.content_hash == digest:
# Unchanged image (byte digest) — the text path's unchanged
# branch, unmodified in shape.
summary.unchanged += 1
logger.info("import: unchanged source=%s path=%s", source, rel)
# Phase 118 (A2) backfill, image flavour: an unchanged image doc
# whose summary is still NULL (an earlier fail-soft summary miss
# — for an image, the one remaining failure class: the summary
# chunk's embed) gets the same best-effort summary pass. For an
# image the summary IS the stored description (``doc.content``),
# so the image-aware ``_store_summary`` (task 03) re-stores it
# verbatim with one ``is_summary`` chunk; a failure (the
# embed) keeps the doc as-is (no row mutation) — fail-soft,
# same as the text path.
if doc.summary is None:
await _store_summary(
session, doc=doc, source=source, rel=rel, content=doc.content,
llm=llm, summary=summary, backfill=True,
)
if doc.created_at_manual:
# D1/D4: the owner's correction survives the sync — no write
# at all (the text path's manual-date early-return).
return
# D4: the date refreshes on every sync, including unchanged
# files, and may go OLDER (no monotonic guard).
target = normalize_doc_date(raw_date)
if target != doc.created_at:
doc.created_at = target
session.commit()
summary.dates_updated += 1
logger.info(
"import: date-refreshed source=%s path=%s date=%s",
source, rel, doc.created_at.isoformat(),
)
return
verb = "updated" if doc is not None else "added"
# The description is the doc's content (task 03: and its summary) —
# it is generated BEFORE anything is written, so a failure skips the
# doc with no row and no copy (the copy is only made after a
# successful description — a failure never leaves an orphan).
content = await _describe_or_skip(
llm, data=data, source=source, rel=rel, full_path=full_path
)
if content is None:
# LOCKED A3 fail-soft: an undescribed image is unsearchable
# noise — skip the doc entirely (no row, no copy).
summary.images_failed += 1
logger.warning("import: image description failed source=%s path=%s", source, rel)
return
# The persistent copy: uploads are replaced on every upload, git
# checkouts are re-cloned, local dirs are user-edited — the served
# bytes must outlive the source file. Named by the doc id: a new
# doc's id is the uuid4 chosen here (row and copy agree); a changed
# doc keeps its id (the copy path is stable).
doc_id = doc.id if doc is not None else uuid.uuid4()
image_dir = Path(settings.image_dir).expanduser()
image_dir.mkdir(parents=True, exist_ok=True)
if doc is not None:
# A CHANGED image (hash differs): the stale copy is deleted
# before replacement.
_delete_image_copy(doc.image_path)
copy_path = image_dir / f"{doc_id}{full_path.suffix.lower()}"
copy_path.write_bytes(data)
# The non-markdown title rule (the image's content is prose, but the
# doc IS the image — the file stem is the title).
title = full_path.stem
if doc is None:
doc = Document(
id=doc_id,
source=source,
path=rel,
full_path=str(full_path),
title=title,
content=content,
content_hash=digest,
indexed_at=datetime.now(UTC),
created_at=normalize_doc_date(raw_date),
is_image=True,
image_path=str(copy_path),
)
session.add(doc)
else:
doc.full_path = str(full_path)
doc.title = title
doc.content = content
doc.content_hash = digest
doc.indexed_at = datetime.now(UTC)
# Phase 106 (D4): a content change is a new document version —
# the date is re-sourced and a previous manual correction is
# reset (it referred to the old content).
doc.created_at = normalize_doc_date(raw_date)
doc.created_at_manual = False
doc.is_image = True
doc.image_path = str(copy_path)
session.flush() # guarantees doc.id even for brand-new rows
# Phase 1+2 — the UNCHANGED pipeline on the description: chunk,
# replace the chunk rows (embeddings NULL), embed, and commit the
# whole file atomically (one transaction per file). The token-cap
# retry loop is copied from the text path; a description is short,
# so it never fires in practice.
target = max(400, settings.chunk_target_chars)
while True:
chunks_text = chunk_document(content, rel, target, settings.chunk_overlap_chars)
doc.chunks = [
Chunk(document_id=doc.id, position=i, content=c) for i, c in enumerate(chunks_text)
]
session.flush() # delete-orphan cascade drops the previous rows
if not doc.chunks:
break
try:
vectors = await llm.embed([c.content for c in doc.chunks])
for row, vec in zip(doc.chunks, vectors, strict=True):
row.embedding = vec
break
except EmbeddingError as e:
if "token cap" not in str(e) or target <= 400:
raise
logger.info(
"import: re-chunking at %d chars after endpoint token cap: %s",
target // 2,
rel,
)
target //= 2
session.commit()
if verb == "added":
summary.added += 1
else:
summary.updated += 1
summary.chunks += len(chunks_text)
logger.info("import: %s source=%s path=%s chunks=%d", verb, source, rel, len(chunks_text))
# Phase 30 shape on the description — image-aware (task 03, LOCKED
# A3): the description IS the summary (stored verbatim, no
# ``lite`` call), so ``doc.summary`` == ``doc.content`` and the
# ``is_summary`` position −1 chunk mirrors it.
await _store_summary(
session, doc=doc, source=source, rel=rel, content=content, llm=llm, summary=summary
)
def _prune(
session: Session,
source_names: set[str],
seen: set[tuple[str, str]],
images: bool = False,
) -> int:
"""Delete documents of *source_names* whose file is no longer in *seen*.
*images* (phase 122 prune guard, LOCKED, derived from A3/A4): while
the image toggle is OFF (``images`` False), every ``is_image`` doc is
SKIPPED — the image is invisible to an images-off walk, not a deleted
file, so pruning it would silently destroy image documents on the
first images-off sync. Toggle ON → normal semantics: a deleted image
file prunes its doc, and the pruned image's ``image_dir`` copy is
deleted with it.
"""
if not source_names:
return 0
pruned = 0
docs = session.scalars(select(Document).where(Document.source.in_(source_names))).all()
for doc in docs:
if (doc.source, doc.path) not in seen:
if doc.is_image and not images:
# Prune guard: invisible to the walk, not deleted.
continue
_delete_image_copy(doc.image_path)
session.delete(doc)
pruned += 1
logger.info("import: pruned source=%s path=%s", doc.source, doc.path)
+25
View File
@@ -92,6 +92,7 @@ from sqlalchemy.orm import Session
from app.config import get_settings
from app.models import Chunk, Document
from app.schemas import SourceRef
#: Shared overflow marker (phase 15; imported by ``app.rag.prompts``)
#: — used by the steering (<tuning>) section, the phase-118 NULL-summary
@@ -939,3 +940,27 @@ def select_related(
continue
out.append(doc)
return out
def source_ref_with_image(doc: Document) -> SourceRef:
"""One :class:`~app.schemas.SourceRef` wire frame for *doc* — the
phase-122 (task 05) SHARED frame builder: the chat API's cited
tier (the agent's read docs) and the related tier both run through
it, so the per-doc ref shape has exactly one construction site.
The ref carries the chip identity (``source`` / ``path`` /
``title``) and, ONLY for a standalone-image document (``is_image``),
the optional ``image_url`` — the image BYTES route
``/api/documents/<id>/image`` (the chat's sources block renders the
compact inline image from it, the summary as alt + caption —
"shown in the chat nicely", TODO L6). For a TEXT document the field
stays ``None`` and is DROPPED by the model's serializer (never
``null`` — the omission rule): a text-doc frame is byte-identical
to pre-phase. The frame's doc id rides the path — the same way the
document content endpoint's ``(source, path)`` lookup does (no new
id leak beyond what the frame already carries).
"""
ref = SourceRef(source=doc.source, path=doc.path, title=doc.title)
if doc.is_image:
ref.image_url = f"/api/documents/{doc.id}/image"
return ref
+131 -6
View File
@@ -1,4 +1,5 @@
"""Document summarizer (phase 30, task 03).
"""Document summarizer (phase 30, task 03) + image descriptions
(phase 122, task 03).
Builds the ``SUMMARY_MODE`` prompt for one document, calls the aipi
``lite`` model through the one-shot ``LLMClient.chat`` (phase 30,
@@ -22,18 +23,33 @@ Quality contracts enforced here:
summarizer re-asserts defensively and never hands the importer a
pointer-only row).
The ``SUMMARY_MODE`` marker follows the ``DEFLECT_MODE`` convention:
the deterministic E2E mock LLM keys on it in the system prompt
(``tests/e2e/mock_llm.py`` — wired in task 06).
Image descriptions (phase 122, LOCKED A3): :func:`describe_image` is
this module's second one-shot generation path — a SINGLE CHAT-model
(vision) call describing one image's bytes as a base64 data URL. The
description becomes the image document's ``content`` AND ``summary``
(it is the ONLY embedded text of the doc — the embedding model never
sees pixels, and the ``lite`` summary model is deliberately NOT used:
it is not assumed vision-capable). Fail-soft by contract: any client
error, empty reply, or non-2xx yields ``None`` — the importer skips
the doc, counts ``images_failed``, and the sync continues.
The ``SUMMARY_MODE`` / ``IMAGE_DESCRIPTION_MODE`` markers follow the
``DEFLECT_MODE`` convention: the deterministic E2E mock LLM keys on
them (``tests/e2e/mock_llm.py`` — the image branch is wired by task
06's story suite).
"""
from __future__ import annotations
from typing import Protocol
import base64
import logging
from typing import Any, Protocol
from app.config import Settings, get_settings
from app.rag.llm import LLMError
from app.rag.retriever import TRUNCATION_MARKER
logger = logging.getLogger("app.summarizer")
#: System-prompt marker for summary generation — the E2E mock LLM keys on
#: it (same convention as ``DEFLECT_MODE``, PLAN §6).
SUMMARY_MODE = "SUMMARY_MODE"
@@ -51,6 +67,55 @@ SUMMARY_INSTRUCTION = (
#: Full system prompt: marker first (the mock's key), then the instruction.
SYSTEM_PROMPT = f"{SUMMARY_MODE}: {SUMMARY_INSTRUCTION}"
#: Image-description marker (phase 122, task 03) — the deterministic
#: E2E mock LLM keys on it (same convention as ``SUMMARY_MODE`` /
#: ``DEFLECT_MODE``, PLAN §6; the story suite wires the mock's branch
#: in task 06). It heads the text part of the multimodal describe
#: message, so it rides the user message, not a system prompt.
IMAGE_DESCRIPTION_MODE = "IMAGE_DESCRIPTION_MODE"
#: Locked instruction for the CHAT (vision) model (phase 122, LOCKED
#: A3): the description is the ONLY retrievable text of the image
#: document (the embedding model never sees pixels), so it must be a
#: faithful, retrieval-oriented account that carries the image's full
#: meaning — what is depicted, any visible text/labels/titles,
#: diagram/table structure, salient details.
DESCRIBE_INSTRUCTION = (
"Describe this image faithfully, in plain text, for a search index. "
"State what is depicted, transcribe any visible text, labels, or "
"titles, describe the structure of any diagram, table, or layout, and "
"call out the most salient details. Write 2-4 sentences of substance. "
"Do not use markdown. Do not invent anything that is not visible in "
"the image. Your description is the ONLY text that will ever be "
"retrieved for this image — it must carry the image's full meaning."
)
#: The full describe prompt: marker first (the mock's key), then the
#: instruction — the single text part of the multimodal user message.
DESCRIBE_PROMPT = f"{IMAGE_DESCRIPTION_MODE}: {DESCRIBE_INSTRUCTION}"
#: Extension → MIME type for the image family (phase 122). Dotted,
#: lowercase keys — the ``image_extension_set`` shape. Task 03 uses it
#: for the describe call's data-URL mime; task 04's serve route reuses
#: it for the image bytes' ``Content-Type`` (one map, one truth). A
#: ``BOR_IMAGE_EXTENSIONS`` token outside this map (a custom format)
#: takes the :data:`IMAGE_FALLBACK_MIME` data-URL mime in the describe
#: call — the vision endpoint may reject it, and the fail-soft skip
#: (``images_failed``) is the honest outcome.
IMAGE_MIMES: dict[str, str] = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".bmp": "image/bmp",
}
#: The data-URL mime for an image extension :data:`IMAGE_MIMES` does not
#: name (phase 122) — the best-effort generic, never a guess at a
#: specific type.
IMAGE_FALLBACK_MIME = "application/octet-stream"
class SummaryLLM(Protocol):
"""The one-shot chat surface the summarizer needs.
@@ -58,12 +123,17 @@ class SummaryLLM(Protocol):
:class:`app.rag.llm.LLMClient` satisfies it; unit tests pass a
duck-typed fake (``chat`` + ``settings``) instead — same pattern as
the importer's ``Embedder`` protocol.
``content`` may be a string (text calls — ``generate_summary``) or
a list of OpenAI-compatible parts (phase 122 multimodal image
descriptions — ``{type: "text", …}`` + ``{type: "image_url", …}``);
the client passes message dicts through untouched.
"""
settings: Settings
async def chat(
self, messages: list[dict[str, str]], model: str | None = None
self, messages: list[dict[str, Any]], model: str | None = None
) -> str: ...
@@ -121,3 +191,58 @@ async def generate_summary(
"refusing to store a silent summary"
)
return f"{summary}\nSource: {source}/{path}"
async def describe_image(
llm: SummaryLLM,
*,
data: bytes,
mime: str,
settings: Settings | None = None,
) -> str | None:
"""One-shot CHAT-model (vision) description of one image (phase 122,
LOCKED A3) — the text that becomes the image document's ``content``
AND ``summary`` (the ONLY embedded text of the doc; the embedding
model never sees pixels).
ONE chat-model call against ``settings.llm_chat_model`` (the vision
model — the ``lite`` summary model is NOT assumed vision-capable)
with the multimodal user message the OpenAI-compatible API expects:
``[{type: "text", text: DESCRIBE_PROMPT}, {type: "image_url",
image_url: {url: <data URL from *data* + *mime*>}}]`` — no system
prompt, no tools, no app-level retries beyond the client's own
(SDK-level + the house one-shot empty-content policy) — a
description failure must not stall a sync.
Returns the stripped reply cut exactly at
``(settings or llm.settings).summary_max_chars`` (the phase-30 cap —
the description IS the summary, so it keeps the same uniform
ceiling). Returns ``None`` on any client error, empty reply, or
non-2xx (the client raises :class:`LLMError` for all three classes)
— the caller (the importer's ``_describe_or_skip`` seam) fails soft:
the doc is skipped, counted in ``images_failed``, and the sync
continues (LOCKED A3).
"""
data_url = f"data:{mime};base64,{base64.b64encode(data).decode('ascii')}"
messages: list[dict[str, Any]] = [
{
"role": "user",
"content": [
{"type": "text", "text": DESCRIBE_PROMPT},
{"type": "image_url", "image_url": {"url": data_url}},
],
}
]
model = llm.settings.llm_chat_model
try:
raw = await llm.chat(messages, model=model)
except LLMError as e:
logger.warning("image description failed (model=%s): %s", model, e)
return None
text = raw.strip()
if not text:
# The client already rejects empty content; this is the
# defensive re-assert (the duck-typed fakes may return it).
return None
limit = (settings or llm.settings).summary_max_chars
return text[:limit]
+101 -2
View File
@@ -106,11 +106,36 @@ class SourceRef(BaseModel):
rows server-side, so every server-built SSE ref fits by construction
(A3: the SSE path is provably unaffected); the cap binds only
client-saved refs — bounded at the boundary with a 422.
Phase 122 (task 05): ``image_url`` — the image BYTES route
(``/api/documents/<id>/image``) for a ref whose document is a
standalone image: the chat's sources block renders the compact
inline image from it (the "shown in the chat nicely" contract, TODO
L6). It is the ONLY new frame field (the doc id rides the path —
the same way the document content endpoint's ``(source, path)``
lookup does). For a TEXT document the field stays ``None`` and is
DROPPED on serialization (never ``null`` — the :class:`DocContent`
omission precedent), so a text-doc frame is byte-identical to
pre-phase. Server-built refs go through the shared
:func:`app.rag.retriever.source_ref_with_image` (one shape, both
frame tiers); a client-saved ref without the field parses with the
``None`` default (pre-phase saved chats restore unchanged).
"""
source: str = Field(max_length=120)
path: str = Field(max_length=1000)
title: str = Field(max_length=500)
#: Phase 122 (task 05) — see the class docstring. ``None`` (every
#: text doc, and every pre-phase client-saved ref) is omitted on
#: serialization — the key is ABSENT, never ``null``.
image_url: str | None = None
@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
data = handler(self)
if self.image_url is None:
data.pop("image_url", None)
return data
class ChatThinkingEvent(BaseModel):
@@ -275,6 +300,20 @@ class KbTreeFile(BaseModel):
``GET /api/docs`` returns) / ``created_at`` (phase 106, D8 — the
document's creation date) / ``indexed_at`` (ISO-8601) are verbatim
from the catalogue row the endpoint reads.
Image affordance (phase 122, task 04): for an ``is_image`` file node
the three ``is_image`` / ``image_url`` / ``summary`` keys ride the
node (the RAG view's Path cell renders the 48px thumbnail from
``image_url`` with ``alt = summary``). For a TEXT file node all
three are OMITTED from the wire shape (the
:func:`_drop_image_fields` omission rule — a pre-phase KB, which has
no image rows, serializes byte-identically to pre-phase, and the
RAG view reads ``is_image === true`` — it never expects the keys on
a text node). ``image_url`` is ``None``-omitted even on an image
node (a row whose ``image_path`` was lost renders the glyph
fallback); ``summary`` stays ``null`` on an image node (the alt
falls back to the title client-side — the fail-soft backfill
corner).
"""
kind: Literal["file"] = "file"
@@ -286,6 +325,23 @@ class KbTreeFile(BaseModel):
#: the ``Created`` column (before ``Indexed``).
created_at: str
indexed_at: str
#: Phase 122 (task 04) — true iff the file is an image document
#: (LOCKED A3). Omitted from a text node's wire shape (see the class
#: docstring); the builder sets it only for a node whose
#: ``(source, path)`` is in the endpoint's image-docs map.
is_image: bool = False
#: Phase 122 (task 04) — the image bytes route
#: (``/api/documents/<id>/image``) for the RAG view's thumbnail;
#: ``None`` (→ absent) when the row has no servable copy.
image_url: str | None = None
#: Phase 122 (task 04) — the document's summary (for an image doc,
#: the vision description — the thumbnail's ``alt``); ``None`` for a
#: fail-soft row still awaiting the backfill.
summary: str | None = None
@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
return _drop_image_fields(self, handler)
class KbTreeFolder(BaseModel):
@@ -374,8 +430,11 @@ class KbTree(BaseModel):
98, D3): true iff its recursive document count ≥
``MIN_DOCS_PER_FOLDER`` (1) AND it has no stored ``folder_summaries``
row — exactly ``missing_folder_summaries``'s candidate set (the
marker never drifts from the gap-fill); FILE nodes carry no flag
(the file table has no description column).
marker never drifts from the gap-fill). FILE nodes carry no pending
flag (the file table has no description column) — but, since phase
122 (task 04), an image FILE node carries the thumbnail affordance
keys (``is_image`` / ``image_url`` / ``summary`` — omitted on text
nodes, see :class:`KbTreeFile`).
"""
sources: list[KbTreeSource]
@@ -400,6 +459,27 @@ class DocContent(BaseModel):
content: str
indexed_at: str
chunks: int
#: Phase 122 (task 04) — true iff the document is a standalone
#: image (LOCKED A3: ``content`` is the vision description, the
#: bytes live behind :attr:`image_url`). ALWAYS present on the wire
#: (text docs: ``false`` — the wire-additive key, the phase-106
#: ``created_at`` pattern); the viewer renders the ``<img>`` block
#: only when true.
is_image: bool = False
#: Phase 122 (task 04) — the image bytes route
#: (``/api/documents/<id>/image``) for the viewer's ``<img>``.
#: ABSENT from the wire for text docs (``None`` → dropped by the
#: serializer — never ``null``, the :func:`_drop_absent_share_url`
#: omission precedent); also absent for an image row whose
#: ``image_path`` is NULL (the viewer's onerror fallback covers it).
image_url: str | None = None
@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
data = handler(self)
if self.image_url is None:
data.pop("image_url", None)
return data
class SummaryUpdate(BaseModel):
@@ -891,6 +971,25 @@ class SavedChatUpdate(BaseModel):
messages: list[ChatMessage] = Field(min_length=1, max_length=200)
def _drop_image_fields(model: KbTreeFile, handler: SerializerFunctionWrapHandler) -> Any:
"""The phase-122 (task 04) image-affordance omission rule for
:class:`KbTreeFile` file nodes: a TEXT node (``is_image`` false) drops
ALL three image keys — a pre-phase KB (no image rows) serializes
byte-identically to pre-phase, and the RAG view's file row stays the
pre-phase bare-link cell. An IMAGE node keeps ``is_image`` +
``summary`` (a ``null`` summary is meaningful — the alt falls back
client-side) and drops ``image_url`` only when ``None`` (the
row-without-a-copy corner — never a ``null`` on the wire, the
:func:`_drop_absent_share_url` precedent)."""
data = handler(model)
if not model.is_image:
for key in ("is_image", "image_url", "summary"):
data.pop(key, None)
elif data.get("image_url") is None:
data.pop("image_url", None)
return data
def _drop_absent_share_url(model: BaseModel, handler: SerializerFunctionWrapHandler) -> Any:
"""The ``share_url`` omission rule (phase 51, task 02): ``None`` →
ABSENT from the JSON (not ``"share_url": null``) — an unshared chat
+76
View File
@@ -1526,6 +1526,10 @@ function appendSources(wrap, sources) {
chip.textContent = label;
chip.title = label;
meta.appendChild(chip);
// Phase 122 (task 05): a ref for an IMAGE document carries
// `image_url` (the frame's only new field — omitted on text refs):
// its chip gains the compact inline figure right after it.
if (s.image_url) appendSourceImageFigure(meta, s);
}
body.appendChild(meta);
// Accessible full path whenever the pill visually truncates.
@@ -1575,10 +1579,82 @@ function appendRelated(wrap, related) {
link.title = docLabel; // full path as the native tooltip (chip pattern)
link.setAttribute("aria-label", docLabel); // the accessible name is the full path
row.appendChild(link);
// Phase 122 (task 05): the related tier rides the same ref shape —
// an image doc's ref carries image_url and gets the same figure.
if (s.image_url) appendSourceImageFigure(row, s);
}
body.appendChild(row);
}
/* Phase 122 (task 05): the chat's sources block shows a retrieved
* IMAGE document "nicely" (TODO L6): the ref's chip gains a COMPACT
* INLINE FIGURE right after it. The chip keeps its text and its
* affordance — the figure is ADDITIVE, never a replacement — and the
* figure reuses the chip's navigation (the same documentUrl href, the
* /document.html escape hatch; the same left-click → same-page modal,
* phase 26). alt + the VISIBLE caption = the document's SUMMARY (the
* vision description — the WCAG alt contract): the frame carries no
* summary (image_url is the only new frame field), so the figure
* fetches the content endpoint the chip's modal already uses (the
* (source, path) pair is the same lookup key) and swaps the summary
* in; until it settles — and when it fails — the title stands in (a
* caption is ALWAYS visible). A FAILED IMAGE LOAD collapses the figure
* to the plain chip (the figure is removed — never a broken-image
* icon: the row's bytes route 404s when the copy was lost). */
function appendSourceImageFigure(meta, s) {
const label = `${s.source}/${s.path}`;
const fig = document.createElement("a");
fig.className = "source-image";
fig.setAttribute("role", "listitem");
fig.href = documentUrl(s.source, s.path, "/"); // back → the chat page
fig.title = label; // full path as the native tooltip (chip pattern)
fig.setAttribute("aria-label", label); // the accessible name is the full path
fig.addEventListener("click", (e) => {
e.preventDefault(); // no new tab (phase 26) — the modal takes over
e.stopPropagation();
openDocumentModal(s.source, s.path, fig);
});
const img = document.createElement("img");
img.className = "source-image-img";
img.src = s.image_url;
img.alt = s.title || label; // the summary arrives via the fetch below
img.addEventListener("error", () => {
// The plain chip stays (it is the collapse target) — the figure,
// with its not-yet-resolved alt, goes.
fig.remove();
});
const caption = document.createElement("span");
caption.className = "source-image-caption";
caption.textContent = s.title || label; // visible caption: the title first…
fig.append(img, caption);
meta.appendChild(fig);
// …and the document's summary once the content fetch settles.
fetchContentSummary(s.source, s.path).then((summary) => {
if (!fig.isConnected) return; // collapsed (img error) or bubble cleared
if (summary && summary.trim() !== "") {
img.alt = summary;
caption.textContent = summary;
}
});
}
/* The document content endpoint the chip's modal already boots
* against (the (source, path) lookup key the ref carries) — phase 122
* (task 05) asks it ONLY for the image figure's summary (alt +
* caption). Any failure (404, network, bad body) resolves to null —
* the title fallback stands and the figure is unaffected. */
function fetchContentSummary(source, path) {
const url =
"/api/documents/content?source=" +
encodeURIComponent(source) +
"&path=" +
encodeURIComponent(path);
return fetch(url)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null)
.then((doc) => (doc && typeof doc.summary === "string" ? doc.summary : null));
}
/* "Maybe try:" chips under a deflected bubble (honesty gate, phase 04,
shared component + one-tap submit, phase 05). The group is accessible
(role=list + aria-label) and wraps cleanly at every width. */
+65 -2
View File
@@ -102,6 +102,22 @@
* call — the phase-57 split). ONE shared core — the modal and
* /document.html both get it (document-modal.js imports
* renderDocument from this module — no per-surface copy).
*
* Phase 122 (task 04): image documents — when doc.is_image is true,
* #doc-content renders the persistent bytes FIRST (the <img> block
* from doc.image_url — the /api/documents/{id}/image route — alt =
* the summary, the WCAG alt contract; a NULL summary falls back to
* the title), and the description (doc.content — the ONLY readable
* text of the doc) follows in the EXISTING plain-content slot below
* (the .doc-raw path; a description is prose, not markdown). The
* labeled Summary panel is suppressed for the verbatim-description
* case (summary === content — the importer invariant; the panel
* would duplicate the text right below the image); an admin-edited
* summary (different text) still renders in its panel with the
* phase-57 edit affordance. An <img> load failure (the route's 404 —
* the row exists but the copy was lost) swaps the block for a small
* "Image unavailable" note (role=status): the page still shows the
* description. ONE shared core — page + modal both get it.
*/
import { bindSharedHeaderControls, fetchIsAdmin, initSharedHeader } from "./header.js";
@@ -189,12 +205,25 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
});
contentEl.replaceChildren();
// Phase 122 (task 04): an image document — the persistent bytes
// render as the <img> block FIRST; the description (= doc.content)
// follows in the normal content slot below (the plain-content path
// — the last branch in this function).
if (doc.is_image) {
contentEl.appendChild(docImageBlock(doc));
}
// Phase 36: the summary panel — labeled section ABOVE the original
// content, on BOTH surfaces (page + modal) through this one core.
// Only a non-empty summary renders: markdown docs carry none (phase
// 30) and the fail-soft path leaves summary NULL, so both are
// byte-for-byte unchanged here.
if (doc.summary && doc.summary.trim() !== "") {
// byte-for-byte unchanged here. Phase 122 (task 04): for an IMAGE
// doc whose summary IS the verbatim description (summary ===
// content — the importer invariant), the panel would duplicate the
// text right below the image, so it is suppressed; an admin-edited
// summary (different text) still renders with the phase-57 edit
// affordance.
const imageSummaryIsContent = doc.is_image && doc.summary === doc.content;
if (doc.summary && doc.summary.trim() !== "" && !imageSummaryIsContent) {
const section = document.createElement("section");
section.className = "doc-summary";
section.setAttribute("aria-label", "Summary");
@@ -228,6 +257,40 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
}
}
/* ---------- image block (phase 122, task 04) ----------
* The viewer's image block for an ``is_image`` document: the
* document's PERSISTENT bytes (doc.image_url — the
* /api/documents/{id}/image route) as a block <img> (max-width 100%;
* the theme's surface treatment lives in .doc-image). alt = the
* summary (the vision description — the WCAG alt contract everywhere);
* a NULL summary (the fail-soft backfill corner) falls back to the
* title. On a load failure (the route's 404 — the row exists but the
* copy was lost) the block shows a small "Image unavailable."
* note (role=status) in its place; the description in the content
* slot below still renders (the doc is still readable). Properties
* only (src, alt) — every document-derived value is a text node /
* property, never innerHTML (the XSS contract, unchanged). */
function docImageBlock(doc) {
const wrap = document.createElement("div");
wrap.className = "doc-image";
const img = document.createElement("img");
img.className = "doc-image-img";
img.src = doc.image_url;
img.alt =
typeof doc.summary === "string" && doc.summary.trim() !== ""
? doc.summary
: doc.title;
img.addEventListener("error", () => {
const note = document.createElement("p");
note.className = "doc-image-unavailable";
note.setAttribute("role", "status");
note.textContent = "Image unavailable — the file is missing.";
wrap.replaceChildren(note);
});
wrap.appendChild(img);
return wrap;
}
/* ---------- summary editing (phase 57, task 02 — D4, admin-only) ----------
* The .doc-summary panel is the ONE place the stored summary is edited
* (page + modal through this core). Only an admin (docAdminReady) ever
+93 -1
View File
@@ -285,6 +285,21 @@
* • the stat cards are UNTOUCHED — they keep their indexed_at
* "last indexed" semantics (the owner asked for the column, not
* the cards).
*
* Phase 122 (task 04) — the image-doc thumbnail: a file node of the
* tree carries the image affordance (is_image / image_url / summary —
* OMITTED on text nodes, the wire-additive rule) when it is an image
* document. makeRow's Path cell then renders a FIXED 48px thumbnail
* box (object-fit: cover, loading="lazy", alt = the summary — the
* vision description; a NULL summary falls back to the title) BEFORE
* the path link (the .kb-doc-path flex wrapper — the link keeps its
* ellipsis). Progressive enhancement: a failed fetch (or the lazy
* first paint) swaps in the document glyph INSIDE the same fixed box
* (no layout shift beyond the box, no broken-image placeholder). Text
* rows never get a box (the pre-phase bare-link cell, byte-identical).
* The glyph is static SVG (aria-hidden — the alt text is the
* accessible content); the box + img are properties only, never
* innerHTML with document-derived data (the house rule).
*/
import { fetchIsAdmin } from "./header.js";
@@ -1391,12 +1406,78 @@ export async function mount(root) {
chunks: f.chunks,
created_at: f.created_at, // phase 106 (task 08, D8): the tree's file date
indexed_at: f.indexed_at,
// Phase 122 (task 04): the image affordance — the tree's
// file node carries is_image / image_url / summary on an
// image doc (the omission rule: a TEXT node's wire shape
// carries none, so these stay undefined there and makeRow
// keeps the bare-link cell, byte-identical to pre-phase).
is_image: f.is_image,
image_url: f.image_url,
summary: f.summary,
})
);
}
if (tableWrap) tableWrap.hidden = files.length === 0;
}
/* Phase 122 (task 04): the document glyph — the fallback INSIDE the
* fixed thumbnail box (a failed fetch, or a node with no servable
* image_url). Static SVG, aria-hidden (the img's alt is the
* accessible content; this is decoration for the box). Built with
* createElementNS — the module keeps its ONE innerHTML (the static
* sync-modal skeleton, the test_kb_tree_ui pin). */
function docThumbGlyph() {
const span = document.createElement("span");
span.className = "kb-doc-thumb-glyph";
span.setAttribute("aria-hidden", "true");
const NS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(NS, "svg");
svg.setAttribute("viewBox", "0 0 48 48");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "2.4");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
const sheet = document.createElementNS(NS, "path");
sheet.setAttribute(
"d",
"M12 4h16l8 8v28a4 4 0 0 1-4 4H12a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4Z"
);
const fold = document.createElementNS(NS, "path");
fold.setAttribute("d", "M28 4v8h8");
svg.append(sheet, fold);
span.appendChild(svg);
return span;
}
/* Phase 122 (task 04): the image-doc thumbnail — the FIXED 48px box
* (object-fit: cover via CSS, loading="lazy", alt = the summary —
* the vision description; a NULL/blank summary falls back to the
* title, then the path). A failed fetch swaps in the document
* glyph in the SAME box (progressive enhancement — no layout shift
* beyond the fixed box, no broken-image placeholder). Called only
* for image rows (makeRow gates on d.is_image). */
function docThumb(d) {
const box = document.createElement("span");
box.className = "kb-doc-thumb";
const alt =
typeof d.summary === "string" && d.summary.trim() !== ""
? d.summary
: d.title || d.path;
if (d.image_url) {
const img = document.createElement("img");
img.className = "kb-doc-thumb-img";
img.loading = "lazy";
img.src = d.image_url;
img.alt = alt;
img.addEventListener("error", () => box.replaceChildren(docThumbGlyph()));
box.appendChild(img);
} else {
box.appendChild(docThumbGlyph());
}
return box;
}
/* The no-data state (phase 97): zero sources (nothing registered,
* nothing indexed) OR a failed tree fetch (the former showEmpty
* failure behavior, unchanged in kind) — every catalog surface
@@ -1440,7 +1521,18 @@ export async function mount(root) {
});
link.title = d.path; // full path as the link's hover/accessible name
link.textContent = d.path;
pathTd.appendChild(link);
// Phase 122 (task 04): an image row gets the FIXED 48px thumbnail
// box before the path link (the .kb-doc-path flex wrapper — the
// link keeps its ellipsis). Text rows keep the bare-link cell,
// byte-identical to pre-phase (no box at all).
if (d.is_image) {
const pathWrap = document.createElement("div");
pathWrap.className = "kb-doc-path";
pathWrap.append(docThumb(d), link);
pathTd.appendChild(pathWrap);
} else {
pathTd.appendChild(link);
}
tr.appendChild(pathTd);
// Phase 106 (task 08, D8): the cell order is [title, chunks,
+134
View File
@@ -737,6 +737,47 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
}
.source-chip:hover { background: var(--brand-soft); text-decoration: underline; }
/* Phase 122 (task 05): the sources block's COMPACT INLINE IMAGE for a
retrieved IMAGE document ("shown in the chat nicely", TODO L6) — the
.source-image figure sits beside its chip in the .msg-meta flex row
(additive: the chip's text and affordance stay). A capped (96px,
contain) img on the theme's surface, the document's summary as the
visible caption in the AA-safe muted ink (8.6:1 on --bg — the
row's page background). A failed image load removes the whole
figure in JS (the plain chip stays — never a broken-image icon). */
.source-image {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
max-width: 100%;
min-width: 0;
text-decoration: none;
}
.source-image-img {
display: block;
max-height: 96px;
max-width: 100%;
width: auto;
height: auto;
object-fit: contain;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
.source-image-caption {
max-width: 14rem;
color: var(--ink-soft); /* 8.6:1 on --bg (AA) — the row's page background */
font-size: 0.7rem;
line-height: 1.25;
text-align: center;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.source-image:hover { text-decoration: underline; } /* flat — the caption underlines, the chip pattern */
/* Phase 113 (task 02): the related-docs row — the SECONDARY tier of
scored docs (phase 113 task 01's usefulness bar demotes the
sub-floor hits out of the citation surface; on a deflected turn the
@@ -2246,6 +2287,61 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
text-decoration: underline;
}
/* Image-document rows (phase 122, task 04): the file table's Path cell
carries a FIXED 48px thumbnail box before the path link (image rows
only — text rows keep the pre-phase bare-link cell, no box). The
.kb-doc-path flex wrapper gives the link its own ellipsis budget
(min-width: 0 — the .sync-label pattern) now that the box shares
the cell; object-fit: cover keeps every aspect ratio inside the
square (CSS on .kb-doc-thumb-img). The glyph fallback (a failed
fetch, or a node without a servable image_url) swaps INSIDE the
same fixed box — no layout shift beyond the box, no broken-image
placeholder. The img's alt (the vision description) carries the
accessibility; the glyph is aria-hidden. Phase-08 tokens only. */
.kb-doc-path {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
max-width: 100%;
}
.kb-doc-path .doc-link {
flex: 1 1 auto;
min-width: 0; /* lets the link shrink — what engages the ellipsis */
overflow: hidden;
text-overflow: ellipsis;
}
.kb-doc-thumb {
flex: 0 0 auto;
display: block;
width: 48px;
height: 48px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--ink-soft); /* the glyph's stroke color (decorative) */
overflow: hidden;
}
.kb-doc-thumb-img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.kb-doc-thumb-glyph {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 9px;
box-sizing: border-box;
}
.kb-doc-thumb-glyph svg {
width: 100%;
height: 100%;
}
/* ---------- KB folder-description editor (phase 97, task 05) ----------
The phase-57 edit affordance on the RAG view's folder descriptions
(the .kb-summary-* family, mirroring the viewer's .doc-summary-*):
@@ -4267,6 +4363,44 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
line-height: 1.5;
}
/* Image documents (phase 122, task 04): the viewer's image block —
the document's persistent bytes (the /api/documents/{id}/image
route) above its description (the .doc-raw content slot below). The
theme's surface card treatment (the .doc-md language): the image
sits on --surface with the line border + radius; max-width 100% +
max-height 70vh keep any aspect ratio inside the page (height: auto
keeps the ratio). Static content — no animation; the alt text (the
vision description) carries the accessibility (WCAG). Phase-08
tokens only (no new hue — the monochrome invariant). */
.doc-image {
width: 100%;
max-width: var(--chat-column);
margin: 0 auto 1rem;
display: flex;
justify-content: center;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 0.5rem;
}
.doc-image-img {
display: block;
max-width: 100%;
max-height: 70vh;
height: auto;
border-radius: var(--radius-sm);
background: var(--bg);
}
/* The onerror fallback (the route's 404 — the row exists but the
copy was lost): a muted note in the card's place-of-image; the
description below still renders, so the doc stays readable. */
.doc-image-unavailable {
margin: 1.25rem 1rem;
color: var(--ink-soft); /* 5.1:1 on --surface (AA) */
font-size: 0.9rem;
}
/* Designed not-found state (no emoji — plain SVG mark, phase 08 rule). */
.doc-not-found {
width: 100%;
+9
View File
@@ -69,6 +69,15 @@ os.environ["BOR_RECENCY_HALF_LIFE_DAYS"] = str(
_Settings.model_fields["recency_half_life_days"].default
)
# Phase 122 (task 01): the same leak class for the image-document
# toggle — an operator's local ``.env`` may legitimately carry
# ``BOR_IMAGES``/``BOR_IMAGE_EXTENSIONS``/``BOR_IMAGE_DIR``, and the
# "off by default" pins (the ``/api/config`` ``images`` flag, the
# walk's images-off behavior) must see the code defaults.
os.environ["BOR_IMAGES"] = str(_Settings.model_fields["images"].default)
os.environ["BOR_IMAGE_EXTENSIONS"] = str(_Settings.model_fields["image_extensions"].default)
os.environ["BOR_IMAGE_DIR"] = str(_Settings.model_fields["image_dir"].default)
from app.db import SessionLocal, db_available # noqa: E402
from app.main import app as fastapi_app # noqa: E402
+73 -5
View File
@@ -26,6 +26,14 @@ Implements just enough of the aipi surface:
``SUMMARY_MODE`` branch: the folder marker CONTAINS the summary
marker as a substring, so the summary branch would otherwise
shadow every folder-summary call
- ``IMAGE_DESCRIPTION_MODE`` (phase 122, image documents) -> the
fixed ``IMAGE_DESCRIPTION_ANSWER`` description: the CHAT model's
(vision) reply to ``summarizer.describe_image``'s multimodal user
message (the marker is its first text part; the call is
non-streaming, so only this answer path serves it). The reply is
the image document's whole content + summary, so it is byte-
stable and token-dense (its cosine against the story suite's
question clears the mock-calibrated gate)
- ``DEFLECT_MODE`` -> honest "I haven't done anything like that" answer
- otherwise -> upbeat answer quoting the provided document context
- user message containing ``pretend to think slowly`` -> 3s warm-up delay
@@ -592,19 +600,46 @@ def _messages(body: dict[str, Any]) -> list[dict[str, str]]:
return body.get("messages", [])
def _content_text(content: Any) -> str:
"""The TEXT of one message's content (phase 122 list safety).
String content passes through byte-identical (as does an absent
value or an explicit ``None`` — the ``or ""`` semantics the
:func:`_context` docstring pins). A multimodal part LIST — the
phase-122 image description's ``[{type: "text", …},
{type: "image_url", …}]``, the only list-content message the app
produces (``app.rag.summarizer.describe_image``) — contributes its
text parts joined (the ``image_url`` part carries no text): the
trigger checks and marker branches read the text, and no existing
string-content request is affected.
"""
if isinstance(content, list):
return " ".join(
part.get("text", "")
for part in content
if isinstance(part, dict) and part.get("type") == "text"
)
return content or ""
def _system(body: dict[str, Any]) -> str:
return " ".join(m.get("content", "") for m in _messages(body) if m.get("role") == "system")
return " ".join(
_content_text(m.get("content"))
for m in _messages(body)
if m.get("role") == "system"
)
def _user(body: dict[str, Any]) -> str:
parts = [m.get("content", "") for m in _messages(body) if m.get("role") == "user"]
parts = [_content_text(m.get("content")) for m in _messages(body) if m.get("role") == "user"]
return parts[-1] if parts else ""
def _context(body: dict[str, Any]) -> str:
"""The document context is the longest system/user message in practice.
``m.get("content") or ""`` (NOT ``m.get("content", "")``): a well-formed
``_content_text(m.get("content"))`` (the ``or ""`` semantics, NOT
``m.get("content", "")``): a well-formed
OpenAI tool-call message carries ``content: None`` EXPLICITLY (the app's
agent loop appends exactly that — ``app/rag/agent.py``), and a forced
final answer after a tool round (the round-cap path) reaches this helper
@@ -612,9 +647,12 @@ def _context(body: dict[str, Any]) -> str:
an explicit ``None`` and crashes ``len()`` with a 500 (phase 93 task 04
caught it via the deterministic single-read flow's ALREADY_IN_CONTEXT
loop); ``or ""`` treats absent and explicit-None alike, so the fallback
composes deterministically instead of traceback-ing."""
composes deterministically instead of traceback-ing. Phase 122: a
multimodal part list maps to its text parts (see
:func:`_content_text`), so ``len()``/slicing never meet a list.
"""
msgs = _messages(body)
return max((m.get("content") or "" for m in msgs), key=len)
return max((_content_text(m.get("content")) for m in msgs), key=len)
LONG_ANSWER_TRIGGER = "write a long answer"
@@ -739,6 +777,25 @@ HISTORY_TRIGGER = "echo my history"
#: phrase, so every other suite is unaffected.
FOLDER_MAP_TRIGGER = "repeat your folder map"
#: Phase 122 (image documents, LOCKED A3): the fixed description the
#: mock's vision (CHAT) model returns for an ``IMAGE_DESCRIPTION_MODE``
#: request — the multimodal user message
#: ``[{type: "text", …IMAGE_DESCRIPTION_MODE…}, {type: "image_url",
#: …}]`` (``app.rag.summarizer.describe_image``, the app's only
#: list-content message). It becomes the image document's WHOLE
#: ``content`` AND ``summary`` (the only embedded text — the embedding
#: model never sees pixels), so the text is byte-stable across runs and
#: token-dense: the story suite's question ("What is shown in the
#: homelab network diagram?", cosine ≈0.38 against the mock's
#: token-overlap embeddings) clears the mock-calibrated gate (0.30)
#: and the citation usefulness floor (0.15).
IMAGE_DESCRIPTION_ANSWER = (
"A network diagram of the homelab server room: a core router on top, "
"a core switch in the middle, and three labeled subnets at the bottom — "
"VLAN 10 office, VLAN 20 lab, and VLAN 30 storage — with a legend of "
"cable runs. Title: Homelab Network Map."
)
TABLE_ANSWER = (
"Here's the shape, in a table:\n"
"\n"
@@ -2178,6 +2235,17 @@ def compose_answer(body: dict[str, Any]) -> str:
answer = "Knowledge base outline:\n- " + " ".join(
TOKEN_RE.findall(user.lower())[:8]
)
elif "IMAGE_DESCRIPTION_MODE" in user:
# Phase 122 (image documents, LOCKED A3): the CHAT model's
# (vision) image description — a NON-STREAMING multimodal user
# message, so only this path ever sees it. ``_content_text``
# joins the text parts, so the marker (the first text part)
# lands in ``user``. Checked before the user-trigger branches:
# the marker is a fixed app constant, and a real question is
# never expected to type it (the other user triggers are owner
# phrasings a question might legitimately contain — this one
# is an app-to-app marker).
answer = IMAGE_DESCRIPTION_ANSWER
elif TABLE_TRIGGER in user.lower():
# Markdown tables (phase 44, TODO.md L6): the story E2E's
# deterministic table answer — a 3-column table, the
+10 -8
View File
@@ -149,27 +149,29 @@ def test_api_config_serves_both_names(testy_server: str, app_server: str) -> Non
# "Save as doc" gating; both instances run with BOR_DOCS_REPO
# empty, so it is the inert false here. Phase 62 (task 01): the
# endpoint grew with the UI-customization keys; phase 91
# (task 03) deleted the retired CSS-file theming's ``theme`` key —
# the five keys below are the entire contract (this suite's
# instances carry no UI-customization overrides, so the string
# keys are their defaults).
# (task 03) deleted the retired CSS-file theming's ``theme`` key;
# phase 122 (task 01) added the ``images`` flag (inert false here
# — neither instance sets BOR_IMAGES) — the six keys below are
# the entire contract (this suite's instances carry no
# UI-customization overrides, so the string keys are their
# defaults).
assert set(body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text",
"images", "input_placeholder", "footer_text",
}
assert body["app_name"] == TESTY_NAME
assert body["docs_repo_configured"] is False
assert body["images"] is False
# The shared conftest instance keeps the default (the other
# suites' title/label contract rides on it) — and its key set
# tracks the endpoint contract (five keys after phase 91,
# task 03).
# tracks the endpoint contract (six keys after phase 122, task 01).
r2 = httpx.get(f"{app_server}/api/config", timeout=5)
assert r2.status_code == 200
r2_body = r2.json()
assert set(r2_body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text",
"images", "input_placeholder", "footer_text",
}
assert r2_body["app_name"] == DEFAULT_NAME
+499
View File
@@ -0,0 +1,499 @@
"""Phase 122 E2E (Playwright): standalone image documents — the user path.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_image_documents.py -v --no-cov
The suite's app instance runs with ``BOR_IMAGES=true`` (module env
override — the conftest per-suite-app pattern, leak guards included):
the standalone PNG arrives through the REAL admin upload flow (a zip
with a single image member — phase 90's no-scan contract: the upload
unpacks + registers, the RAG page's "Sync sources" button scans), the
mock's vision model (``IMAGE_DESCRIPTION_MODE`` branch —
``tests/e2e/mock_llm.py``) writes the description, and the document is
first-class at every surface:
* the Sources page lists it with the 48px thumbnail (the image bytes
route, alt = the summary);
* the document viewer renders the image with the description below it
(the summary panel is suppressed — the summary IS the description);
* a grounded chat question shows the compact inline figure in the
answer's sources block (image + the summary caption, the "shown in
the chat nicely" contract).
The negative case drives a SECOND module app with ``BOR_IMAGES``
forced ``false`` (the DEFAULT contract — an operator's local ``.env``
cannot leak the toggle in either direction: both apps pin the value
explicitly): the same upload + sync produces NO image document (the
walk is blind to the file, the source row stays a 0-document source).
"""
from __future__ import annotations
import base64
import json
import os
import re
import subprocess
import sys
import zipfile
from collections.abc import Iterator
from pathlib import Path
import httpx
import pytest
from playwright.sync_api import Browser, Page, expect
from sqlalchemy import select, text
from app.config import Settings
from app.db import SessionLocal
from app.models import Document
from app.rag.agent import IMAGE_DOC_MARKER
from e2e.auth_helpers import login
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
from e2e.mock_llm import IMAGE_DESCRIPTION_ANSWER
REPO = Path(__file__).resolve().parents[2]
GIT_SOURCES_URL = "/git-sources.html"
SOURCE_NAME = "e2e-image" # the archive stem (archive_source_name)
DOC_PATH = "pic.png"
#: The grounded question, carrying the house scripted-read call
#: (``SUMMARY_SEED_READ_TRIGGER`` — the phase-119 A1 convention: a
#: zero-read grounded turn chips NOTHING, so the image doc must be
#: READ to earn its citation chip + figure): the mock emits the
#: scripted ``read e2e-image/pic.png``, then echoes the tool result.
#: Grounding: the mock's token-overlap cosine against the
#: ``IMAGE_DESCRIPTION_ANSWER`` description is ≈0.31 (over the
#: mock-calibrated gate (0.30), and the FTS leg corroborates —
#: homelab/network/diagram all hit — the 0.15 floor backstop).
IMAGE_QUESTION = (
"Read the suggested document: read e2e-image/pic.png — "
"what is shown in the homelab network diagram?"
)
#: A real 1×1 transparent PNG (the unit/integration suites' fixture —
#: the pipeline is content-agnostic, the well-formed bytes keep the
#: upload + serve + render path honest).
PNG_1X1 = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
"AAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
)
UPLOAD_TIMEOUT_MS = 30_000
SYNC_TIMEOUT_MS = 60_000
SYNCED_LABEL = re.compile(r"^Synced \d{1,2}:\d{2}$")
# ---------------------------------------------------------------------------
# Module apps: images ON (the story) and images forced OFF (the default
# contract). Each owns its port + its scratch upload/source/image dirs.
# ---------------------------------------------------------------------------
def _app_env(mock_llm: int, app_port: int, *, images: bool, scratch: Path) -> dict[str, str]:
"""The conftest app env (leak guards included) with the phase-122
knobs: ``BOR_IMAGES`` pinned EXPLICITLY (true for the story app,
false for the default app — process env ranks above an operator's
local gitignored ``.env``, so the contract under test cannot leak
in either direction) and the image/upload homes in the suite's
scratch dir (the app under test must not write image copies into
the owner's real ``~/bor-sources``)."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_LLM_BASE_URL"] = (
"https://aipi.reeseapps.com/v1"
if USE_REAL_LLM
else f"http://127.0.0.1:{mock_llm}/v1"
)
# Mock-calibrated gate (conftest pattern) — the story's question
# grounds on these values (see IMAGE_QUESTION).
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
# Phase 67: instant retry waits + the code-default budget.
env["BOR_LLM_RETRY_DELAY"] = "0"
env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default)
env.setdefault(
"BOR_DATABASE_URL",
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
)
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
env["BOR_SESSION_SECRET"] = SESSION_SECRET
# Leak guards (conftest pattern).
env["BOR_GIT_SOURCES"] = ""
env["BOR_DOCS_REPO"] = ""
env["BOR_SUGGESTIONS"] = json.dumps(
Settings.model_fields["suggestions"].default
)
env["BOR_INPUT_PLACEHOLDER"] = Settings.model_fields["input_placeholder"].default
env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default
# Phase 122: the toggle under test + the suite-private homes.
env["BOR_IMAGES"] = "true" if images else "false"
env["BOR_UPLOAD_DIR"] = str(scratch / "uploads")
env["BOR_SOURCES_DIR"] = str(scratch / "checkouts")
env["BOR_IMAGE_DIR"] = str(scratch / "images")
return env
@pytest.fixture(scope="module")
def app_server(mock_llm: int, tmp_path_factory: pytest.TempPathFactory) -> Iterator[str]:
"""The story app — ``BOR_IMAGES=true`` (the module env override;
the conftest session app is never started in this isolated run,
so no port clash)."""
scratch = tmp_path_factory.mktemp("bor_image_on")
port = int(os.environ.get("E2E_APP_PORT_IMAGES", "8150"))
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
cwd=REPO,
env=_app_env(mock_llm, port, images=True, scratch=scratch),
)
try:
_wait_http(f"http://127.0.0.1:{port}/api/health")
yield f"http://127.0.0.1:{port}"
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_url(app_server: str) -> str:
return app_server
@pytest.fixture(scope="module")
def default_app_server(
mock_llm: int, tmp_path_factory: pytest.TempPathFactory
) -> Iterator[str]:
"""The default-contract app — ``BOR_IMAGES=false`` (the LOCKED A3
default; only the negative test starts it)."""
scratch = tmp_path_factory.mktemp("bor_image_off")
port = int(os.environ.get("E2E_APP_PORT_IMAGES_OFF", "8151"))
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
cwd=REPO,
env=_app_env(mock_llm, port, images=False, scratch=scratch),
)
try:
_wait_http(f"http://127.0.0.1:{port}/api/health")
yield f"http://127.0.0.1:{port}"
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def default_app_url(default_app_server: str) -> str:
return default_app_server
@pytest.fixture(scope="module")
def zip_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""The fixture archive: ONE standalone PNG at the root — source
``e2e-image``, document path ``pic.png``."""
root = tmp_path_factory.mktemp("bor_image_zip")
(root / DOC_PATH).write_bytes(PNG_1X1)
archive = root / f"{SOURCE_NAME}.zip"
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
zf.write(root / DOC_PATH, arcname=DOC_PATH)
return archive
# ---------------------------------------------------------------------------
# DB + UI helpers
# ---------------------------------------------------------------------------
def _truncate_all() -> None:
"""Fresh KB + registry per test (the E2E isolation pattern): the
suites share one Postgres, and a leftover row would corrupt the
counts. ``sources_meta`` (the KB generation counter) resets with
the KB — the sync re-creates the row."""
with SessionLocal() as db:
db.execute(text(
"TRUNCATE chunks, documents, query_log, kb_overview, "
"git_sources, sources_meta, folder_summaries"
))
db.commit()
def _health_db(app_url: str) -> bool:
try:
return httpx.get(f"{app_url}/api/health", timeout=5).json()["db"] == "up"
except Exception: # noqa: BLE001 — unreachable is the skip case
return False
def _upload_zip(page: Page, app_url: str, archive: Path) -> None:
"""The real admin upload flow: form login → the git-sources page →
pick the archive → submit → the terminal result line (phase 90:
"Uploaded <source> — press Sync sources to import it.")."""
login(page, app_url, next=GIT_SOURCES_URL)
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
page.set_input_files("#archive-upload-file", str(archive))
page.click("#archive-upload-btn")
result = page.locator("#archive-upload-result")
expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS)
expect(result).to_have_text(
f"Uploaded {SOURCE_NAME} — press Sync sources to import it.",
timeout=UPLOAD_TIMEOUT_MS,
)
def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
"""The RAG page's "Sync sources" button (the scan — phase 90 A3):
click → "Syncing…" → the terminal "Synced HH:MM" label + the fresh
counts in #sync-result (the never-stale contract, test_git_source_
dates' lifecycle wait)."""
page.goto(app_url + "/sources.html")
btn = page.locator("#sync-btn")
expect(btn).to_be_visible(timeout=30_000)
expect(page.locator("#sync-label")).to_have_text(
re.compile(r"^Sync sources$|^Synced \d{1,2}:\d{2}$")
)
btn.click()
expect(btn).to_be_disabled()
expect(page.locator("#sync-label")).to_have_text("Syncing…")
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
expect(btn).to_be_enabled()
expect(page.locator("#sync-result")).to_have_text(expected_result)
def _drill_to_source(page: Page, name: str) -> None:
"""Top level → the source (the file table then holds its direct
files — ours is at the source root)."""
page.locator("#folders-tbody .folder-link").first.wait_for(state="visible")
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=30_000)
# ---------------------------------------------------------------------------
# The module seed: upload + sync the fixture image through the real UI
# (the story E2E truncate/re-import-per-module pattern).
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def seeded(
app_url: str,
browser: Browser,
zip_path: Path,
) -> Iterator[dict[str, str]]:
"""Upload the single-PNG zip through the admin UI, then scan it
with the RAG page's Sync button (the mock's vision model describes
the image in-process). Yields the seeded doc's identity (the bytes
route + the description) for the render assertions. The module
app's ``/api/config`` flag is asserted first — the env override
must have reached the app under test, or the whole premise of the
suite is void."""
if not _health_db(app_url):
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
cfg = httpx.get(f"{app_url}/api/config", timeout=5).json()
assert cfg["images"] is True, "the module app must run with BOR_IMAGES=true"
_truncate_all()
page = browser.new_page(viewport={"width": 1280, "height": 800})
try:
_upload_zip(page, app_url, zip_path)
_sync_via_ui(page, app_url, expected_result="1 added")
with SessionLocal() as db:
doc = db.scalar(
select(Document).where(
Document.source == SOURCE_NAME, Document.path == DOC_PATH
)
)
assert doc is not None, "the uploaded image must become a document"
assert doc.is_image is True and doc.image_path is not None
# content == summary == the mock's description (the ONLY
# embedded text of the doc — the pipeline contract, LOCKED A3).
assert doc.content == IMAGE_DESCRIPTION_ANSWER
assert doc.summary == IMAGE_DESCRIPTION_ANSWER
finally:
page.close()
yield {
"image_url": f"/api/documents/{doc.id}/image",
"description": IMAGE_DESCRIPTION_ANSWER,
}
_truncate_all()
# ---------------------------------------------------------------------------
# 1. Sources page: the image doc is listed with its thumbnail
# ---------------------------------------------------------------------------
def test_sources_page_lists_the_image_doc(
page: Page, app_url: str, seeded: dict[str, str]
) -> None:
"""The KB total is the image doc (1 doc, 2 chunks — the one
content chunk + the phase-30 ``is_summary`` chunk), the drill-down
tree reaches it, and its row carries the FIXED 48px thumbnail box:
a lazy ``<img>`` from the bytes route (not the glyph fallback —
the fixture PNG is served), ``alt`` = the summary (the WCAG
contract)."""
login(page, app_url) # lands on /sources.html
expect(page.locator("#stat-docs")).to_have_text("1")
expect(page.locator("#stat-chunks")).to_have_text("2")
expect(page.locator("#sources-empty")).to_be_hidden()
_drill_to_source(page, SOURCE_NAME)
row = page.locator("#docs-tbody tr", has_text=DOC_PATH)
expect(row).to_have_count(1)
box = row.locator(".kb-doc-thumb")
expect(box).to_be_visible()
img = row.locator(".kb-doc-thumb-img")
expect(img).to_have_count(1)
expect(img).to_be_visible(timeout=15_000) # the fetch resolves — no glyph
expect(img).to_have_attribute("src", seeded["image_url"])
expect(img).to_have_attribute("loading", "lazy")
expect(img).to_have_attribute("alt", seeded["description"])
expect(row.locator(".kb-doc-thumb-glyph")).to_have_count(0)
# ---------------------------------------------------------------------------
# 2. Document viewer: the image renders, the description below it
# ---------------------------------------------------------------------------
def test_document_viewer_renders_image_and_description(
page: Page, app_url: str, seeded: dict[str, str]
) -> None:
"""The Sources row's path link opens the same-page document modal
(phase 26): the ``is_image`` content block renders the PERSISTENT
bytes first (``.doc-image-img`` from the bytes route, ``alt`` =
the summary), and the description follows in the normal content
slot (``pre.doc-raw`` — the plain-content path). The labeled
Summary panel is SUPPRESSED for the verbatim-description case
(summary === content — the importer invariant; the panel would
duplicate the text right below the image)."""
login(page, app_url)
_drill_to_source(page, SOURCE_NAME)
page.locator(f'#docs-tbody a.doc-link:text-is("{DOC_PATH}")').click()
modal = page.locator("#doc-modal")
expect(modal).to_be_visible(timeout=30_000)
expect(page.locator("#doc-modal-title")).to_have_text("pic")
img = modal.locator(".doc-image-img")
expect(img).to_have_count(1)
expect(img).to_be_visible(timeout=15_000) # the bytes route serves the PNG
expect(img).to_have_attribute("src", seeded["image_url"])
expect(img).to_have_attribute("alt", seeded["description"])
# The description below the image (the doc's readable content IS
# the vision description).
expect(modal.locator("pre.doc-raw")).to_have_text(seeded["description"])
# The verbatim case: no duplicate Summary panel.
expect(modal.locator(".doc-summary")).to_have_count(0)
# No "Image unavailable" note — the copy is intact.
expect(modal.locator(".doc-image-unavailable")).to_have_count(0)
# ---------------------------------------------------------------------------
# 3. Chat: a grounded question shows the inline image in the sources
# ---------------------------------------------------------------------------
def test_chat_sources_block_shows_the_inline_image(
page: Page, app_url: str, seeded: dict[str, str]
) -> None:
"""A question the mock grounds on the image doc (cosine ≈0.31,
FTS-corroborated), scripted to READ it (phase 119 A1 — a zero-read
turn chips nothing): the mock's echo carries the agent ``read``
result VERBATIM — including the task-05 ``IMAGE_DOC_MARKER`` line
(the model saw the description, not raw bytes) — and the answer's
sources block carries the citation chip (the READ doc clears the
usefulness floor) AND the compact inline figure — the ``<img>``
from the bytes route, the visible caption + ``alt`` settling to
the document's summary (the figure fetches it from the content
endpoint — the frame carries no summary), the "shown in the chat
nicely" contract."""
login(page, app_url, next="/")
expect(page.locator("#kb-banner")).to_be_hidden()
page.fill("#message-input", IMAGE_QUESTION)
page.click("#send-btn")
bubble = page.locator(".msg.brain .bubble").last
# The scripted read's echoed result — the marker line (task 05)
# and the description itself (the model's view of the image).
expect(bubble).to_contain_text(IMAGE_DOC_MARKER, timeout=30_000)
expect(bubble).to_contain_text(seeded["description"], timeout=30_000)
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send")
# The citation chip (the text affordance stays — the figure is
# additive, not a replacement).
chip = page.locator(".msg.brain .source-chip")
expect(chip).to_have_count(1)
expect(chip).to_have_text(f"{SOURCE_NAME}/{DOC_PATH}")
# The inline figure: image + caption, both settling to the summary
# (the content fetch resolves the alt/caption after the title).
fig = page.locator(".msg.brain .source-image")
expect(fig).to_have_count(1)
img = fig.locator(".source-image-img")
expect(img).to_be_visible(timeout=15_000)
expect(img).to_have_attribute("src", seeded["image_url"])
expect(img).to_have_attribute("alt", seeded["description"], timeout=15_000)
caption = fig.locator(".source-image-caption")
expect(caption).to_have_text(seeded["description"], timeout=15_000)
# ---------------------------------------------------------------------------
# 4. The default contract: BOR_IMAGES off (the default) → the same
# upload + sync produces NO image document
# ---------------------------------------------------------------------------
def test_default_env_upload_produces_no_image_doc(
page: Page, default_app_url: str, zip_path: Path
) -> None:
"""The off-by-default contract end-to-end: with ``BOR_IMAGES``
false, the SAME upload + sync is byte-identical to the pre-phase
walk — the PNG is invisible to the scan (0 files, 0 added), the
source row stays a registered 0-document source, and NO documents
row (let alone an ``is_image`` one) exists. The file still lands
on disk (the unpack is unchanged — only the walk filter changes)."""
if not _health_db(default_app_url):
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
cfg = httpx.get(f"{default_app_url}/api/config", timeout=5).json()
assert cfg["images"] is False, "the default app must run with images off"
_truncate_all()
try:
_upload_zip(page, default_app_url, zip_path)
_sync_via_ui(
page, default_app_url, expected_result="0 added · 0 unchanged"
)
# No document at all — the image file is not even a "file" to
# the images-off walk (not unknown, not indexed).
with SessionLocal() as db:
assert (
db.scalar(select(Document).where(Document.is_image.is_(True)))
is None
)
assert db.scalar(select(Document).where(Document.source == SOURCE_NAME)) is None
# The Sources page: 0 docs, the source row (registered) drills
# to an EMPTY file table.
page.goto(default_app_url + "/sources.html")
expect(page.locator("#stat-docs")).to_have_text("0")
expect(page.locator("#stat-chunks")).to_have_text("0")
page.locator("#folders-tbody .folder-link").first.wait_for(state="visible")
page.click(f'#folders-tbody a.folder-link:text-is("{SOURCE_NAME}")')
expect(page.locator("#docs-tbody tr")).to_have_count(0)
finally:
_truncate_all()
+7 -6
View File
@@ -164,14 +164,15 @@ def test_config_serves_the_overrides(custom_server: str) -> None:
r = httpx.get(f"{CUSTOM_URL}/api/config", timeout=5)
assert r.status_code == 200
body = r.json()
# The five-key set (the phase-39/59/62 endpoint contract, phase 91
# task 03: the retired CSS-file theming's ``theme`` key is gone)
# with the two customization overrides — the app NAME stays the
# default (this suite does not re-test BOR_APP_NAME; that is the
# phase-39 suite's job).
# The six-key set (the phase-39/59/62 endpoint contract, phase 91
# task 03: the retired CSS-file theming's ``theme`` key is gone;
# phase 122 task 01: the ``images`` flag) with the two
# customization overrides — the app NAME stays the default (this
# suite does not re-test BOR_APP_NAME; that is the phase-39
# suite's job).
assert set(body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text",
"images", "input_placeholder", "footer_text",
}
assert body["app_name"] == DEFAULT_NAME
assert body["input_placeholder"] == CUSTOM_PLACEHOLDER
+8 -1
View File
@@ -1,6 +1,8 @@
"""Shared test fakes (no network, deterministic)."""
from __future__ import annotations
from typing import Any
from app.config import Settings
from app.rag.llm import LLMError
@@ -15,6 +17,11 @@ class FakeEmbedder:
``"Summary of <first token of the user content>"`` and raises
:class:`LLMError` when the content contains the sentinel word
``SUMMARY-BLOWUP`` (drives the importer's fail-soft summary path).
``content`` may be a string or a phase-122 multimodal part list
(``dict[str, Any]`` messages, the ``LLMClient.chat`` shape) — the
text-summary body above is string-only; a subclass handling the
multimodal image description (task 03's vision mock) overrides
``chat``.
"""
def __init__(self, dim: int = 768) -> None:
@@ -36,7 +43,7 @@ class FakeEmbedder:
return vec
async def chat(
self, messages: list[dict[str, str]], model: str | None = None
self, messages: list[dict[str, Any]], model: str | None = None
) -> str:
self.chat_calls.append(list(messages))
user = next((m["content"] for m in messages if m.get("role") == "user"), "")
+37 -9
View File
@@ -48,25 +48,30 @@ def test_health_reports_ok(client) -> None:
def test_config_returns_default_app_metadata(client, db: Session) -> None:
"""GET /api/config is public (anonymous) and returns exactly five
"""GET /api/config is public (anonymous) and returns exactly six
keys — the phase-39 app metadata, the phase-59 docs flag (inert
false while BOR_DOCS_REPO is empty — the "Save as doc" gating),
and the phase-62 UI customization strings (composer placeholder,
footer line). Phase 91: with an empty ui_settings table the
effective strings are the env defaults (B1 — DB-over-env, the row
absent here); the retired CSS-file theming's ``theme`` key is gone
(task 03 — the five keys are the entire contract)."""
the phase-122 images flag (default false in the test env —
LOCKED A3: off by default), and the phase-62 UI customization
strings (composer placeholder, footer line). Phase 91: with an
empty ui_settings table the effective strings are the env defaults
(B1 — DB-over-env, the row absent here); the retired CSS-file
theming's ``theme`` key is gone (task 03 — the six keys are the
entire contract)."""
_clear_ui_settings(db)
r = client.get("/api/config")
assert r.status_code == 200
body = r.json()
assert set(body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text",
"images", "input_placeholder", "footer_text",
}
assert body["app_name"] == "Brain of Reese"
assert body["version"] == get_settings().app_version
assert body["docs_repo_configured"] is False
# Phase 122 (task 01): the images flag is the default off (the
# test env sets no BOR_IMAGES) — a real bool, not a truthy string.
assert body["images"] is False
# Phase 62: UNSET => the phase-61 neutral copy stands (the
# byte-identical contract).
assert body["input_placeholder"] == "Ask me anything…"
@@ -91,7 +96,7 @@ def test_config_follows_overridden_app_name(client, db: Session) -> None:
body = r.json()
assert set(body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text",
"images", "input_placeholder", "footer_text",
}
assert body["app_name"] == "Brain of Testy"
assert body["version"] == "0.1.0"
@@ -122,7 +127,7 @@ def test_config_serves_ui_customization_overrides(client, db: Session) -> None:
body = r.json()
assert set(body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text",
"images", "input_placeholder", "footer_text",
}
assert body["input_placeholder"] == "Ask the vault…"
assert body["footer_text"] == "Powered by my own models"
@@ -153,6 +158,29 @@ def test_config_docs_flag_tracks_settings(client, db: Session) -> None:
fastapi_app.dependency_overrides.clear()
def test_config_images_flag_tracks_settings(client, db: Session) -> None:
"""Phase 122 (task 01): ``images`` mirrors ``settings.images``
(``BOR_IMAGES``) — a real bool (never a truthy string) that flips
true the moment the toggle is on: that flag is the entire frontend
gating of the image affordances (the phase-123 attach control,
optionally the Sources page hint)."""
from app.config import Settings
from app.main import app as fastapi_app
_clear_ui_settings(db)
fastapi_app.dependency_overrides[get_settings] = lambda: Settings(
images=True,
)
try:
r = client.get("/api/config")
assert r.status_code == 200
body = r.json()
assert isinstance(body["images"], bool)
assert body["images"] is True
finally:
fastapi_app.dependency_overrides.clear()
def test_suggestions_returns_list(client) -> None:
# Phase 79: the chips are user-gated — sign in as the admin first
# (the test's purpose is the list shape, not the auth contract).
+3
View File
@@ -431,8 +431,11 @@ def test_document_content_admin_contract(client: TestClient, db) -> None:
"content",
"indexed_at",
"chunks",
"is_image", # added in phase 122 (task 04) — always present
}
assert body["summary"] is None
assert body["is_image"] is False # text doc — and no image_url key (never null)
assert "image_url" not in body
# Unknown docs still 404 (same shape as the phase-16 pin).
r = client.get("/api/documents/content", params={"source": "docs", "path": "nope.md"})
+121
View File
@@ -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
+611
View File
@@ -7,22 +7,33 @@ Uses the real compose Postgres (``db`` fixture) and FastAPI's TestClient.
"""
from __future__ import annotations
import asyncio
import base64
import hashlib
import inspect
import itertools
import logging
import uuid
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import delete, func, select, text
import app.api.docs as docs_api
import app.rag.importer as rag_importer
from app.config import Settings
from app.core import tokens as token_service
from app.main import app as fastapi_app
from app.models import Chunk, Document, FolderSummary, GitSource
from app.rag import git_sources as rag_git_sources
from app.rag.folder_summaries import missing_folder_summaries
from app.rag.importer import import_sources
from app.rag.llm import LLMError
from app.rag.summarizer import DESCRIBE_PROMPT
from tests.fakes import FakeEmbedder
_TREE_TABLES = "chunks, documents, folder_summaries, git_sources"
@@ -823,3 +834,603 @@ def test_docs_tree_stat_walk_equivalence_with_flat_list(admin_client, db) -> Non
)
_truncate_tree_tables(db)
# ---------------------------------------------------------------------------
# Phase 122, task 02 — image ingest end-to-end (the full ``import_sources``
# pipeline against the real DB; task 06 finalizes the phase-122 suite here
# with the image route + content-endpoint shapes).
# ---------------------------------------------------------------------------
#: A real 1×1 transparent PNG — the importer is content-agnostic (it
#: never parses the image), but a well-formed fixture keeps the tests
#: honest about what a real upload looks like.
PNG_1X1 = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
)
def _image_llm[
ImageLLM: FakeEmbedder
](tmp_path, llm_cls: type[ImageLLM] = FakeEmbedder, **kwargs) -> ImageLLM:
"""A fake LLM with the phase-122 image knobs (toggle ON by default;
the image dir defaults under *tmp_path* unless overridden).
*llm_cls* (task 03) may be the mock vision client (``_MockVisionLLM``
below) for the no-seam-patch end-to-end path — the PEP 695 type
parameter keeps the helper's return type honest (``chat_models``).
"""
kwargs.setdefault("_env_file", None)
kwargs.setdefault("images", True)
kwargs.setdefault("image_dir", str(tmp_path / "images"))
llm = llm_cls()
llm.settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return llm
def _patch_description(monkeypatch, description) -> None:
"""Pin the task-02 seam (``rag_importer._describe_or_skip``) to
*description*. Task 03 fills the seam with the CHAT model's vision
call — these end-to-end mechanics do not change with it."""
async def _fake(llm, *, data, source, rel, full_path):
return description
monkeypatch.setattr(rag_importer, "_describe_or_skip", _fake)
def _cleanup_source(db, source: str) -> None:
for doc in db.scalars(select(Document).where(Document.source == source)).all():
db.delete(doc)
db.commit()
def test_import_sources_images_on_indexes_image_docs(
db, tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""``images=True`` end-to-end: a standalone image in a source becomes
a Document — bytes digested, the persistent copy in ``image_dir``
(``<doc-id>.png``), ``content`` = the (mock) description, and ONLY
that text is embedded (the content chunks + the phase-30 summary
chunk, 768 dims) — while a text doc in the same source stays a
plain text doc."""
source_root = tmp_path / "imgsource"
source_root.mkdir()
(source_root / "diagram.png").write_bytes(PNG_1X1)
(source_root / "notes.md").write_text("# Notes\n\nBody text.\n", encoding="utf-8")
llm = _image_llm(tmp_path)
_patch_description(monkeypatch, "A network diagram of the homelab VLANs.")
try:
summary = asyncio.run(
import_sources([source_root], llm, session=db, prune=True)
)
assert (summary.files, summary.added, summary.images_failed) == (2, 2, 0)
assert summary.formats == {"md": 1, "png": 1}
doc = db.scalar(
select(Document).where(
Document.source == source_root.name, Document.path == "diagram.png"
)
)
assert doc is not None, "the image file must become a document"
assert doc.is_image is True and doc.image_path is not None
assert doc.content == "A network diagram of the homelab VLANs."
assert doc.title == "diagram" # the non-markdown stem rule
copy = Path(doc.image_path)
assert copy.parent == Path(llm.settings.image_dir)
assert copy.name == f"{doc.id}.png"
assert copy.read_bytes() == PNG_1X1
# The ONLY embedded text is the description (the embedding model
# never sees pixels): every content chunk carries it, the
# phase-30 summary chunk exists + is embedded, 768 dims. Task
# 03: the description IS the summary (stored verbatim — no
# ``lite`` call, no pointer line), so the summary chunk mirrors
# ``doc.content`` exactly.
chunks = db.scalars(select(Chunk).where(Chunk.document_id == doc.id)).all()
content_chunks = [c for c in chunks if not c.is_summary]
assert [c.content for c in content_chunks] == [doc.content]
assert doc.summary == doc.content
summary_chunks = [c for c in chunks if c.is_summary]
assert len(summary_chunks) == 1 and summary_chunks[0].position == -1
assert summary_chunks[0].content == doc.content
for c in chunks:
assert c.embedding is not None and len(c.embedding) == 768
# The text doc is untouched by the image machinery.
md = db.scalar(
select(Document).where(
Document.source == source_root.name, Document.path == "notes.md"
)
)
assert md is not None and md.is_image is False and md.image_path is None
finally:
_cleanup_source(db, source_root.name)
class _MockVisionLLM(FakeEmbedder):
"""The phase-122 mock VISION client (task 03): the CHAT model
answers the multimodal describe call (the image bytes' data URL)
with a fixed, retrieval-oriented description; text (``lite``) calls
keep the ``FakeEmbedder`` behaviour. The REAL
``rag_importer._describe_or_skip`` → ``summarizer.describe_image``
chain runs end-to-end against it (no seam patch); every chat
call's model is recorded (``chat_models``)."""
DESCRIPTION = (
"A network diagram of the homelab VLANs: the core switch, the "
"router, and three labeled subnets."
)
def __init__(self) -> None:
super().__init__()
self.chat_models: list[str | None] = []
async def chat(self, messages, model=None):
self.chat_calls.append(list(messages))
self.chat_models.append(model)
user = next((m["content"] for m in messages if m.get("role") == "user"), "")
if isinstance(user, list):
# The phase-122 describe call — the multimodal message.
return self.DESCRIPTION
first = user.split()
return "Summary of " + (first[0] if first else "<empty>")
def test_import_sources_mock_vision_client_end_to_end(db, tmp_path) -> None:
"""Task 03 end-to-end (NO seam patch): a fixture PNG through the
mock vision client — the real ``_describe_or_skip`` →
``describe_image`` → CHAT-model call — yields a doc whose
``content`` == ``summary`` == the description, with its
``is_summary`` chunk embedded, and whose ONLY embedded text is that
description (the embedding model never sees pixels)."""
source_root = tmp_path / "visione2e"
source_root.mkdir()
(source_root / "diagram.png").write_bytes(PNG_1X1)
llm = _image_llm(tmp_path, _MockVisionLLM)
try:
summary = asyncio.run(
import_sources([source_root], llm, session=db)
)
assert (summary.files, summary.added, summary.images_failed) == (1, 1, 0)
# The describe call went to the CHAT model (LOCKED A3), once.
assert llm.chat_models == [llm.settings.llm_chat_model]
# The wire shape: the multimodal user message — the fixed
# prompt's text part + the image's data-URL part.
(message,) = llm.chat_calls[0]
assert message["role"] == "user"
content: Any = message["content"] # the multimodal part list
assert content[0] == {"type": "text", "text": DESCRIBE_PROMPT}
assert content[1]["type"] == "image_url"
assert content[1]["image_url"]["url"].startswith("data:image/png;base64,")
doc = db.scalar(
select(Document).where(
Document.source == source_root.name, Document.path == "diagram.png"
)
)
assert doc is not None, "the image file must become a document"
assert doc.is_image is True and doc.image_path is not None
assert doc.content == _MockVisionLLM.DESCRIPTION
assert doc.summary == _MockVisionLLM.DESCRIPTION # task 03: verbatim
# The ONLY embedded text of the doc is the description — twice
# (the one content chunk + the is_summary chunk), 768 dims.
chunks = db.scalars(select(Chunk).where(Chunk.document_id == doc.id)).all()
summary_chunks = [c for c in chunks if c.is_summary]
assert len(summary_chunks) == 1 and summary_chunks[0].position == -1
assert all(c.content == _MockVisionLLM.DESCRIPTION for c in chunks)
for c in chunks:
assert c.embedding is not None and len(c.embedding) == 768
embedded = [t for batch in llm.calls for t in batch]
assert embedded == [_MockVisionLLM.DESCRIPTION] * 2
finally:
_cleanup_source(db, source_root.name)
class _MockVisionFailsLLM(FakeEmbedder):
"""A NON-VISION chat model (LOCKED A3's honest failure): the
multimodal describe call raises (the SDK errors — a chat model
without vision rejects the ``image_url`` part), text (``lite``)
calls keep the ``FakeEmbedder`` behaviour."""
async def chat(self, messages, model=None):
self.chat_calls.append(list(messages))
user = next((m["content"] for m in messages if m.get("role") == "user"), "")
if isinstance(user, list):
raise LLMError("simulated non-vision chat model (test sentinel)")
first = user.split()
return "Summary of " + (first[0] if first else "<empty>")
def test_import_sources_failing_vision_skips_image_keeps_sync_green(
db, tmp_path, caplog: pytest.LogCaptureFixture
) -> None:
"""LOCKED A3 fail-soft end-to-end (the task-06 integration pin):
a fixture PNG through a NON-VISION chat model — the real seam, no
patch — skips the image doc (``images_failed == 1``, NO row, NO
orphan copy — not even the image dir) while the TEXT doc in the
same source is indexed as usual (the sync completes, no row
mutation anywhere for the failed image)."""
source_root = tmp_path / "visionfail"
source_root.mkdir()
(source_root / "diagram.png").write_bytes(PNG_1X1)
(source_root / "notes.md").write_text("# Notes\n\nBody.\n", encoding="utf-8")
llm = _image_llm(tmp_path, _MockVisionFailsLLM)
try:
with caplog.at_level(logging.INFO, logger="app.importer"):
summary = asyncio.run(
import_sources([source_root], llm, session=db)
)
assert (summary.files, summary.added, summary.images_failed) == (2, 1, 1)
# The image: no row, no copy (the dir itself was never created).
assert (
db.scalar(
select(Document).where(
Document.source == source_root.name, Document.path == "diagram.png"
)
)
is None
)
assert not Path(llm.settings.image_dir).expanduser().exists()
# The text doc indexed as usual (the sync stayed green).
md = db.scalar(
select(Document).where(
Document.source == source_root.name, Document.path == "notes.md"
)
)
assert md is not None and md.is_image is False
# The importer's warning names the document (the PLAN §9 signal).
warnings = [
r
for r in caplog.records
if r.name == "app.importer" and "image description failed" in r.getMessage()
]
assert len(warnings) == 1 and warnings[0].levelno == logging.WARNING
assert f"source={source_root.name} path=diagram.png" in warnings[0].getMessage()
finally:
_cleanup_source(db, source_root.name)
def test_import_sources_images_off_ignores_and_prune_guard_protects(
db, tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""``images=False`` (the default) end-to-end: the walk ignores the
image file entirely (not counted, no row, no copy), and a
``prune=True`` run MUST NOT delete a pre-existing image doc — the
LOCKED prune guard (invisible to the walk ≠ deleted)."""
source_root = tmp_path / "imgsrc_off"
source_root.mkdir()
(source_root / "diagram.png").write_bytes(PNG_1X1)
_patch_description(monkeypatch, "A network diagram.")
try:
llm_on = _image_llm(tmp_path)
s_on = asyncio.run(import_sources([source_root], llm_on, session=db))
assert s_on.added == 1
doc = db.scalar(
select(Document).where(
Document.source == source_root.name, Document.path == "diagram.png"
)
)
assert doc is not None
copy = Path(doc.image_path)
assert copy.exists()
# Toggle OFF (a fresh fake on the code defaults): the walk is
# blind to the file, and prune protects the pre-existing image
# doc + its copy.
llm_off = FakeEmbedder() # Settings(_env_file=None) → images False
s_off = asyncio.run(
import_sources([source_root], llm_off, session=db, prune=True)
)
assert (s_off.files, s_off.added, s_off.pruned) == (0, 0, 0)
assert db.scalar(select(Document).where(Document.id == doc.id)) is not None
assert copy.exists(), "the copy survives with the doc"
finally:
_cleanup_source(db, source_root.name)
def test_import_sources_toggle_on_prunes_deleted_image_with_copy(
db, tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Toggle ON, the image file deleted: the normal prune runs — the
doc row AND its ``image_dir`` copy are removed (the copy's
lifecycle is tied to the row)."""
source_root = tmp_path / "imgsrc_prune"
source_root.mkdir()
(source_root / "diagram.png").write_bytes(PNG_1X1)
llm_on = _image_llm(tmp_path)
_patch_description(monkeypatch, "A network diagram.")
try:
asyncio.run(import_sources([source_root], llm_on, session=db))
doc = db.scalar(
select(Document).where(
Document.source == source_root.name, Document.path == "diagram.png"
)
)
assert doc is not None
copy = Path(doc.image_path)
assert copy.exists()
(source_root / "diagram.png").unlink()
s = asyncio.run(
import_sources([source_root], llm_on, session=db, prune=True)
)
assert (s.pruned, s.added, s.unchanged) == (1, 0, 0)
assert (
db.scalar(select(Document).where(Document.source == source_root.name))
is None
)
assert not copy.exists(), "the image_dir copy is deleted with the doc"
finally:
_cleanup_source(db, source_root.name)
# ---------------------------------------------------------------------------
# Phase 122 (task 04) — the image BYTES route + the content/tree wire
# affordance (the serve side of the image-document contract).
# ---------------------------------------------------------------------------
def _seed_image_doc(
db,
tmp_path: Path,
*,
source: str = "ImgSrc",
path: str = "pic.png",
data: bytes = PNG_1X1,
content: str = "A red square on a white background.",
image_path: str | None = "auto",
doc_id: str | None = None,
) -> Document:
"""One ``is_image`` document row with its persistent copy (the
importer's ``image_dir`` layout) under *tmp_path*; ``image_path``
``"auto"`` writes the copy, ``None`` leaves the row without a copy
(the lost-copy corner)."""
doc = Document(
id=uuid.UUID(doc_id) if doc_id else uuid.uuid4(),
source=source,
path=path,
full_path=f"/tmp/{path}",
title=path.rsplit(".", 1)[0],
content=content,
content_hash=hashlib.sha256(data).hexdigest(),
indexed_at=datetime.now(UTC),
created_at=datetime(2026, 1, 1, tzinfo=UTC),
summary=content,
is_image=True,
)
if image_path == "auto":
copy = tmp_path / f"{doc.id}{Path(path).suffix}"
copy.write_bytes(data)
doc.image_path = str(copy)
elif image_path is not None:
doc.image_path = image_path
db.add(doc)
db.flush()
db.add_all(
[
Chunk(
document_id=doc.id, position=0, content=content, embedding=[0.01] * 768
),
Chunk(
document_id=doc.id,
position=-1,
content=content,
embedding=[0.01] * 768,
is_summary=True,
),
]
)
db.commit()
return doc
def _cleanup_kb(db) -> None:
"""Truncate the KB tables. The settle commit matters: SQLAlchemy
does NOT autoflush pending ORM objects before a raw ``text()``
statement — a pending chunks INSERT flushed *after* the TRUNCATE
would FK-violate (the document row is already gone), so any
pending state is committed (then truncated) first."""
db.commit()
db.execute(text("TRUNCATE chunks, documents"))
db.commit()
@pytest.mark.parametrize(
("ext", "mime"),
[
(".png", "image/png"),
(".jpg", "image/jpeg"),
(".jpeg", "image/jpeg"),
(".webp", "image/webp"),
(".gif", "image/gif"),
(".bmp", "image/bmp"),
],
)
def test_image_route_serves_exact_bytes_with_content_type(
admin_client: TestClient, db, tmp_path: Path, ext: str, mime: str
) -> None:
"""The serve contract: the route streams the EXACT stored bytes
(a per-extension sentinel — a mix-up between the six formats is
caught) with the extension's ``Content-Type`` (the
``IMAGE_MIMES`` map — one map, one truth) and
``Cache-Control: private, max-age=3600`` (content-hashed bytes —
long enough, bustable by re-upload)."""
_cleanup_kb(db)
try:
data = PNG_1X1 + ext.encode("ascii") # per-extension sentinel bytes
doc = _seed_image_doc(db, tmp_path, path=f"pic{ext}", data=data)
r = admin_client.get(f"/api/documents/{doc.id}/image")
assert r.status_code == 200
assert r.headers["content-type"] == mime # exact type per extension
assert r.headers["cache-control"] == "private, max-age=3600"
assert r.content == data # the exact uploaded bytes, nothing else
finally:
_cleanup_kb(db)
def test_image_route_404_matrix(admin_client: TestClient, db, tmp_path: Path) -> None:
"""Every non-servable case is 404 ``document not found`` (the
router's unknown-document shape — the same detail string the
content endpoint uses): a missing id, a MALFORMED id (an unparseable
string maps here, not to a 422 — a guessed id is an unknown
document), a text doc, an image doc whose ``image_path`` is NULL,
and a row whose copy was lost on disk (defensive — the row exists,
the bytes don't)."""
_cleanup_kb(db)
try:
_seed_doc(db, "TextSrc", "note.md", "Note", 1, datetime.now(UTC))
db.commit() # the module's _seed_doc leaves the row uncommitted
text_doc_id = db.scalar(
select(Document.id).where(
Document.source == "TextSrc", Document.path == "note.md"
)
)
no_copy = _seed_image_doc(db, tmp_path, path="nopy.png", image_path=None)
lost = _seed_image_doc(db, tmp_path, path="lost.png")
assert lost.image_path is not None # the "auto" copy was written
Path(lost.image_path).unlink() # the copy is lost (the row remains)
for doc_id in (
str(uuid.uuid4()), # missing id
"not-a-uuid", # malformed id → 404, not 422
str(text_doc_id), # text doc
str(no_copy.id), # image doc, image_path NULL
str(lost.id), # image doc, copy lost
):
r = admin_client.get(f"/api/documents/{doc_id}/image")
assert r.status_code == 404, doc_id
assert r.json() == {"detail": "document not found"}, doc_id
finally:
_cleanup_kb(db)
def test_image_route_requires_user_like_the_content_endpoint(
admin_client: TestClient, db, tmp_path: Path
) -> None:
"""Phase 79 posture (the task's "PUBLIC, like the document content
endpoint" — the content endpoint has been user-gated since phase
79; the ONLY anonymous surface is the shared chats, PLAN A10): an
anonymous caller gets 401 ``authentication required`` before any
row is read (a FRESH client — the module's fixture ``client``
stays unsigned here), a signed-in caller gets the bytes."""
_cleanup_kb(db)
try:
doc = _seed_image_doc(db, tmp_path)
anonymous = TestClient(fastapi_app)
r = anonymous.get(f"/api/documents/{doc.id}/image")
assert r.status_code == 401
assert r.json() == {"detail": "authentication required"}
# The signed-in client (admin) passes.
assert admin_client.get(f"/api/documents/{doc.id}/image").status_code == 200
finally:
_cleanup_kb(db)
def test_content_endpoint_exposes_the_image_affordance(
admin_client: TestClient, db, tmp_path: Path
) -> None:
"""The content endpoint (the viewer's data source): ``is_image`` is
ALWAYS present (text doc: false — the one new key; the wire shape
gains nothing else), and ``image_url`` — the bytes route's path —
is present for an image doc and ABSENT for a text doc (never null,
the ``DocContent`` omission rule)."""
_cleanup_kb(db)
try:
_seed_doc(db, "TextSrc", "note.md", "Note", 1, datetime.now(UTC))
db.commit() # the app's endpoint session reads committed data only
r = admin_client.get(
"/api/documents/content", params={"source": "TextSrc", "path": "note.md"}
)
assert r.status_code == 200
body = r.json()
assert body["is_image"] is False
assert "image_url" not in body # absent — never null (text doc)
doc = _seed_image_doc(db, tmp_path, source="ImgSrc", path="pic.png")
r = admin_client.get(
"/api/documents/content", params={"source": "ImgSrc", "path": "pic.png"}
)
assert r.status_code == 200
body = r.json()
assert body["is_image"] is True
assert body["image_url"] == f"/api/documents/{doc.id}/image"
assert body["content"] == body["summary"] # the description (task 03)
finally:
_cleanup_kb(db)
def _tree_file_nodes_all(sources) -> list[dict]:
"""Every file node of a tree response, walked recursively (all
sources — env-registered 0-document sources may join the response
when the ``git_sources`` table is truncated, and they carry no
file nodes; the assertions below hold over whatever files exist).
"""
files: list[dict] = []
def _walk(node: dict) -> None:
for child in node.get("children", ()):
if child["kind"] == "file":
files.append(child)
else:
_walk(child)
for source in sources:
_walk(source)
return files
def test_tree_image_file_node_affordance_and_text_node_byte_identical(
admin_client: TestClient, db, tmp_path: Path
) -> None:
"""The tree (the RAG view's single fetch): an image doc's file node
carries the thumbnail affordance (``is_image`` true,
``image_url`` = the bytes route's path, ``summary`` verbatim — the
RAG view's thumbnail ``alt``); EVERY text file node keeps the
pre-phase wire shape byte-identically (the six keys — no
``is_image``/``image_url``/``summary`` — the phase's
byte-identical criterion: the fields are row-driven, so a KB with
no image rows serializes exactly as pre-phase)."""
_truncate_tree_tables(db)
try:
base = datetime.now(UTC)
_seed_doc(db, "MixedSrc", "a.md", "A", 1, base)
img = _seed_image_doc(db, tmp_path, source="MixedSrc", path="pic.png")
r = admin_client.get("/api/docs/tree") # both seeds committed (_seed_image_doc)
assert r.status_code == 200
files = {f["path"]: f for f in _tree_file_nodes_all(r.json()["sources"])}
# Text node: byte-identical pre-phase wire shape (no image keys).
assert set(files["a.md"]) == {
"kind", "path", "title", "chunks", "created_at", "indexed_at"
}
# Image node: the affordance rides the node.
pic = files["pic.png"]
assert pic["is_image"] is True
assert pic["image_url"] == f"/api/documents/{img.id}/image"
assert pic["summary"] == "A red square on a white background."
finally:
_truncate_tree_tables(db)
def test_tree_with_no_image_rows_is_byte_identical(admin_client: TestClient, db) -> None:
"""The pre-phase KB (no ``is_image`` rows): the image-docs map is
empty and EVERY file node serializes in the pre-phase shape — the
row-driven fields introduce no wire change at all (the phase's
byte-identical criterion, the toggle irrelevant)."""
_truncate_tree_tables(db)
try:
base = datetime.now(UTC)
_seed_doc(db, "PlainSrc", "x.md", "X", 2, base)
db.commit() # the app's endpoint session reads committed data only
r = admin_client.get("/api/docs/tree")
assert r.status_code == 200
files = _tree_file_nodes_all(r.json()["sources"])
assert len(files) == 1 # the seeded doc (env sources carry no files)
assert set(files[0]) == {
"kind", "path", "title", "chunks", "created_at", "indexed_at"
}
finally:
_truncate_tree_tables(db)
+7 -2
View File
@@ -422,11 +422,16 @@ def test_content_200_all_fields(client, db) -> None:
assert r.status_code == 200
body = r.json()
# Wire-additive (phase 106, task 05): ``created_at`` joins the
# content shape (after ``summary``, before ``content``).
# content shape (after ``summary``, before ``content``) — and
# (phase 122, task 04) ``is_image`` joins it ALWAYS present
# (text docs: false); ``image_url`` is ABSENT for a text doc
# (never null — the ``DocContent`` omission rule).
assert set(body) == {
"source", "path", "title", "format", "summary", "created_at",
"content", "indexed_at", "chunks",
"content", "indexed_at", "chunks", "is_image",
}
assert body["is_image"] is False
assert "image_url" not in body # absent — never null (text doc)
datetime.fromisoformat(body["created_at"]) # raises if not ISO-8601
assert body["source"] == "Homelab"
assert body["path"] == "kubernetes.md"
+344
View File
@@ -0,0 +1,344 @@
"""Integration: migration 0022 (documents.is_image + image_path) schema
contract (phase 122, task 02).
Drives the **real Alembic engine** against the live dev database
(``podman compose up -d db``), mirroring the house pattern of
``test_migration_0021.py`` (information_schema assertions on the state
the migration must leave). The tests target the 0021 → 0022 step
explicitly so later migrations cannot break the pins:
* upgrade 0021 → 0022 → ``is_image`` exists with the full contract —
BOOLEAN, NOT NULL, server default ``false`` — and ``image_path`` —
TEXT, NULLABLE, no server default — while the 0021 ``documents``
schema (``content``/``content_hash`` NOT NULL, ``summary`` NULLABLE,
``created_at`` + ``created_at_manual``, the (source, path) unique
constraint — asserted column-based, since the suite's table
self-heal renames copied constraints) survives;
* a ``documents`` row inserted while the DB is at 0021 backfills
``is_image`` to ``false`` and ``image_path`` to NULL (every
pre-phase-122 row is a text doc — the LOCKED A3 default);
* the ORM contract agrees: a freshly inserted ``Document`` without the
image fields reads ``is_image is False`` / ``image_path is None``,
and one with them round-trips through a fresh session;
* downgrade to 0021 → both columns are GONE (A13) while the row
survives; upgrade back to 0022 → the columns are back (round-trip).
The ``alembic`` fixture guarantees the DB ends at head even if a test
fails or the process is interrupted.
"""
from __future__ import annotations
import uuid
from collections.abc import Iterator
from typing import Any
import pytest
from alembic.config import Config
from sqlalchemy import text
from sqlalchemy.orm import Session
from alembic import command
from app.db import SessionLocal, db_available
from app.models import Document
SOURCE = "mig0022"
PATH_TEXT = "notes/readme.md"
PATH_IMAGE = "notes/diagram.png"
@pytest.fixture()
def alembic(db: Session) -> Iterator[Config]:
"""Real Alembic config bound to the dev DB (URL from app settings).
Starts at head (repairs an interrupted earlier run); teardown
upgrades to head no matter what happened, so the dev DB is never
left below head.
"""
if not db_available():
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
cfg = Config() # no alembic.ini file — env.py gets the URL from app config
cfg.set_main_option("script_location", "alembic")
command.upgrade(cfg, "head")
try:
yield cfg
finally:
# Release the test session's open transaction BEFORE the repair
# DDL: an idle-in-transaction SELECT holds an ACCESS SHARE lock
# on ``documents``, which would deadlock the repair's
# ``ALTER TABLE`` (0022) forever.
db.rollback()
command.upgrade(cfg, "head")
def _version(db: Session) -> str | None:
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
def _column(db: Session, column: str) -> tuple[Any, ...] | None:
"""(data_type, is_nullable, column_default) for one documents
column."""
row = db.execute(
text(
"SELECT data_type, is_nullable, column_default"
" FROM information_schema.columns"
" WHERE table_name = 'documents' AND column_name = :c"
),
{"c": column},
).fetchone()
return tuple(row) if row is not None else None
def _insert_sql(db: Session, path: str) -> uuid.UUID:
"""Insert one documents row with the PRE-0022 column set (the 0021
shape — the image columns, when present, are omitted so their
backfill is what the row reads)."""
row_id = uuid.uuid4()
db.execute(
text(
"INSERT INTO documents (id, source, path, full_path, title,"
" content, content_hash)"
" VALUES (:id, :s, :p, :f, :t, :c, :h)"
),
{
"id": row_id,
"s": SOURCE,
"p": path,
"f": f"/tmp/{SOURCE}/{path}",
"t": path.rsplit("/", 1)[-1],
"c": "content",
"h": "0" * 64,
},
)
db.commit()
return row_id
def _delete(db: Session, row_id: uuid.UUID) -> None:
db.execute(text("DELETE FROM documents WHERE id = :id"), {"id": row_id})
db.commit()
def _unique_column_sets(db: Session) -> set[tuple[str, ...]]:
"""The column tuples of every UNIQUE constraint on ``documents``.
Column-based (not name-based): the integration suite's table
self-heal (``tests/integration/conftest.py``) rewrites bloated
tables via ``CREATE TABLE (LIKE …)``, which renames copied
constraints (PG auto-names them) — the (source, path) uniqueness
contract is what must hold, not the original name.
"""
rows = db.execute(
text(
"SELECT (SELECT string_agg(a.attname, ',' ORDER BY k.ord)"
" FROM unnest(c.conkey) WITH ORDINALITY k(attnum, ord)"
" JOIN pg_attribute a"
" ON a.attrelid = c.conrelid AND a.attnum = k.attnum)"
" FROM pg_constraint c"
" WHERE c.contype = 'u' AND c.conrelid = 'documents'::regclass"
)
).fetchall()
return {tuple(r[0].split(",")) for r in rows}
def test_upgrade_to_0022_adds_image_columns(db: Session, alembic: Config) -> None:
"""Upgrade 0021 → 0022: ``is_image`` exists with the full contract
(BOOLEAN, NOT NULL, server default ``false`` — every pre-phase-122
row is a text doc) and ``image_path`` (TEXT, NULLABLE, no server
default — NULL for text docs), both ABSENT at 0021; a pre-0022 row
backfills ``is_image`` to ``false`` + ``image_path`` to NULL; and
the 0021 table contract survives the additive upgrade."""
command.downgrade(alembic, "0021") # start from the pre-0022 state
assert _version(db) == "0021"
assert _column(db, "is_image") is None, "is_image must be absent at 0021"
assert _column(db, "image_path") is None, "image_path must be absent at 0021"
pre_id = _insert_sql(db, PATH_TEXT) # the 0021 column set
try:
command.upgrade(alembic, "0022")
assert _version(db) == "0022", "alembic_version must be at 0022"
is_image = _column(db, "is_image")
assert is_image is not None, "documents.is_image is missing"
assert is_image[0] == "boolean", "is_image must be BOOLEAN"
assert is_image[1] == "NO", "is_image must be NOT NULL"
assert is_image[2] == "false", (
"is_image must carry the `false` server default — every"
" pre-phase-122 row is a text doc"
)
image_path = _column(db, "image_path")
assert image_path is not None, "documents.image_path is missing"
assert image_path[0] == "text", "image_path must be TEXT"
assert image_path[1] == "YES", "image_path must be NULLABLE"
assert image_path[2] is None, (
"image_path must carry NO server default — NULL is the"
" text-doc value"
)
# The pre-0022 row backfilled to (false, NULL) — a text doc.
row = db.execute(
text("SELECT is_image, image_path FROM documents WHERE id = :id"),
{"id": pre_id},
).fetchone()
assert row is not None, "the pre-0022 row must survive the upgrade"
assert row[0] is False, "the backfilled is_image must be false"
assert row[1] is None, "the backfilled image_path must be NULL"
# A row written without the image columns reads the same
# (the Python-side defaults are False/None — same values).
new_id = _insert_sql(db, PATH_IMAGE)
try:
backfilled = db.execute(
text("SELECT is_image, image_path FROM documents WHERE id = :id"),
{"id": new_id},
).fetchone()
assert backfilled == (False, None), (
"an omitted image state must read (false, NULL)"
)
finally:
_delete(db, new_id)
# The 0021 schema survives the additive upgrade.
content = _column(db, "content")
assert content is not None and content[0] == "text" and content[1] == "NO", (
"documents.content (0001) must keep its 0021 contract"
)
hash_col = _column(db, "content_hash")
assert (
hash_col is not None
and hash_col[0] == "character varying"
and hash_col[1] == "NO"
), "documents.content_hash (0001) must survive the upgrade"
summary = _column(db, "summary")
assert summary is not None and summary[0] == "text" and summary[1] == "YES", (
"documents.summary (phase 30) must survive the upgrade"
)
created = _column(db, "created_at")
assert created is not None and created[0] == "timestamp with time zone"
assert created[1] == "NO" and "now()" in str(created[2]), (
"documents.created_at (0020) must keep its `now()` server default"
)
manual = _column(db, "created_at_manual")
assert manual is not None and manual[0] == "boolean" and manual[1] == "NO"
assert manual[2] == "false", (
"documents.created_at_manual (0020) must keep its `false` default"
)
assert ("source", "path") in _unique_column_sets(db), (
"the (source, path) unique constraint must survive the upgrade"
)
finally:
_delete(db, pre_id)
def test_orm_image_fields_round_trip(db: Session, alembic: Config) -> None:
"""The ORM contract agrees with the column contract: a freshly
inserted ``Document`` WITHOUT the image fields reads ``is_image is
False`` / ``image_path is None`` (the text-doc default state), and
one WITH them round-trips the pair through a FRESH session."""
command.upgrade(alembic, "head")
text_doc = Document(
source=SOURCE,
path=PATH_TEXT,
full_path=f"/tmp/{SOURCE}/{PATH_TEXT}",
title="readme",
content="# readme\n",
content_hash="1" * 64,
)
image_doc = Document(
source=SOURCE,
path=PATH_IMAGE,
full_path=f"/tmp/{SOURCE}/{PATH_IMAGE}",
title="diagram",
content="A description of the diagram.",
content_hash="2" * 64,
is_image=True,
image_path="/srv/bor-images/diagram.png",
)
db.add(text_doc)
db.add(image_doc)
db.commit()
try:
with SessionLocal() as fresh:
reloaded_text = fresh.get(Document, text_doc.id)
assert reloaded_text is not None, "the text row must be readable"
assert reloaded_text.is_image is False, (
"an omitted is_image must read the False default"
)
assert reloaded_text.image_path is None, (
"an omitted image_path must read NULL"
)
reloaded_image = fresh.get(Document, image_doc.id)
assert reloaded_image is not None, "the image row must be readable"
assert reloaded_image.is_image is True
assert reloaded_image.image_path == "/srv/bor-images/diagram.png"
finally:
_delete(db, text_doc.id)
_delete(db, image_doc.id)
def test_downgrade_to_0021_drops_the_columns(db: Session, alembic: Config) -> None:
"""Downgrade 0022 → 0021: both image columns are gone (A13 — fully
reversible) while the row + its 0021 columns survive, and the rest
of the 0021 table contract (``content``, ``content_hash``,
``created_at``) is intact."""
command.upgrade(alembic, "head")
row = Document(
source=SOURCE,
path=PATH_IMAGE,
full_path=f"/tmp/{SOURCE}/{PATH_IMAGE}",
title="diagram",
content="A description of the diagram.",
content_hash="3" * 64,
is_image=True,
image_path="/srv/bor-images/diagram.png",
)
db.add(row)
db.commit()
try:
command.downgrade(alembic, "0021")
assert _version(db) == "0021"
assert _column(db, "is_image") is None, "is_image must be dropped"
assert _column(db, "image_path") is None, "image_path must be dropped"
surviving = db.execute(
text(
"SELECT source, path, title, content, content_hash, created_at"
" FROM documents WHERE id = :id"
),
{"id": row.id},
).fetchone()
assert surviving is not None, "the row must survive the column drops"
assert surviving[0] == SOURCE and surviving[1] == PATH_IMAGE
assert surviving[3] == "A description of the diagram."
assert surviving[4] == "3" * 64
assert surviving[5] is not None, "created_at must survive the drops"
assert ("source", "path") in _unique_column_sets(db), (
"the (source, path) unique constraint must survive the downgrade"
)
finally:
_delete(db, row.id)
# Repair: the fixture teardown re-upgrades to head.
def test_upgrade_round_trip_restores_the_columns(db: Session, alembic: Config) -> None:
"""Downgrade to 0021, then upgrade back to 0022: both columns are
back with the full contract (``is_image`` BOOLEAN NOT NULL default
``false``; ``image_path`` TEXT NULLABLE no default)."""
command.downgrade(alembic, "0021")
command.upgrade(alembic, "0022")
assert _version(db) == "0022", "round-trip upgrade must land at 0022"
is_image = _column(db, "is_image")
assert is_image is not None, "documents.is_image must be back"
assert is_image[0] == "boolean", "is_image must be BOOLEAN after the round-trip"
assert is_image[1] == "NO", "is_image must be NOT NULL after the round-trip"
assert is_image[2] == "false", (
"is_image must still carry the `false` server default"
)
image_path = _column(db, "image_path")
assert image_path is not None, "documents.image_path must be back"
assert image_path[0] == "text"
assert image_path[1] == "YES"
assert image_path[2] is None, "image_path must still carry NO server default"
+4 -3
View File
@@ -216,10 +216,11 @@ def test_admin_semantic_fields_round_trip(client: TestClient, db: Session) -> No
def _config_keys() -> set[str]:
"""The /api/config key set after task 03: the five phase-39/59/62
keys — the retired CSS-file theming's ``theme`` key is gone."""
"""The /api/config key set after task 03 (phase 91): the retired
CSS-file theming's ``theme`` key is gone; phase 122 (task 01) added
the ``images`` flag — the six keys below are the contract."""
return {"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text"}
"images", "input_placeholder", "footer_text"}
def test_api_config_env_only_deployment_returns_env_strings(client: TestClient) -> None:
+7 -2
View File
@@ -179,6 +179,7 @@ def test_content_known_pair_maps_to_doc_content() -> None:
title="Deep Mark",
content="# Deep Mark\n\nbody",
content_hash="f" * 64,
is_image=False, # phase 122: the stub session never applies defaults
)
doc.indexed_at = datetime(2026, 8, 22, 1, 2, 3, tzinfo=UTC)
doc.created_at = datetime(2026, 8, 20, 9, 0, 0, tzinfo=UTC) # phase 106
@@ -190,11 +191,15 @@ def test_content_known_pair_maps_to_doc_content() -> None:
assert r.status_code == 200
body = r.json()
# Wire-additive (phase 106, task 05): the pre-date keys are all
# still there, joined by ``created_at``.
# still there, joined by ``created_at`` — and (phase 122, task 04)
# by ``is_image`` (ALWAYS present; text docs: false) while
# ``image_url`` is ABSENT (never null — the omission rule).
assert set(body) == {
"source", "path", "title", "format", "summary", "created_at",
"content", "indexed_at", "chunks",
"content", "indexed_at", "chunks", "is_image",
}
assert body["is_image"] is False
assert "image_url" not in body # absent — never null (text doc)
assert body["source"] == "Homelab"
assert body["path"] == "notes/deep mark.md"
assert body["title"] == "Deep Mark"
File diff suppressed because it is too large Load Diff
+11 -5
View File
@@ -970,12 +970,14 @@ def test_import_summary_log_line_includes_summary_counters(
"""PLAN §9 summary line: the phase-30 counters sit between
``embed_batches`` and ``formats``; the phase-118 backfill counter
sits between ``summary_errors`` and ``dates_updated``; the
phase-106 date-refresh counter sits before ``formats``."""
phase-106 date-refresh counter and the phase-122 ``images_failed``
counter sit after ``dates_updated``, before ``formats``."""
s = ImportSummary()
s.files, s.added, s.chunks, s.embed_batches = 3, 3, 5, 4
s.summaries, s.summary_errors = 2, 1
s.summary_backfilled = 1
s.dates_updated = 0
s.images_failed = 0
s.formats = {"md": 1, "yaml": 2}
with caplog.at_level(logging.INFO, logger="app.importer"):
s.log()
@@ -983,7 +985,7 @@ def test_import_summary_log_line_includes_summary_counters(
assert line == (
"import: summary files=3 added=3 updated=0 unchanged=0 pruned=0 errors=0 "
"chunks=5 embed_batches=4 summaries=2 summary_errors=1 summary_backfilled=1 "
"dates_updated=0 formats=yaml:2,md:1"
"dates_updated=0 images_failed=0 formats=yaml:2,md:1"
)
@@ -1103,13 +1105,17 @@ def test_no_progress_means_no_prewalk(
excluded: frozenset[str] = EXCLUDED_DIRS,
ignore: tuple[str, ...] = (),
include_hidden: bool = False,
image_extensions: frozenset[str] = frozenset(),
) -> list[Path]:
# Phase 89: the walker gained the ``ignore`` keyword; phase 105:
# the ``include_hidden`` flag — the sentinel accepts (and forwards)
# both to stay a drop-in.
# the ``include_hidden`` flag; phase 122: the ``image_extensions``
# set — the sentinel accepts (and forwards) all three to stay a
# drop-in.
nonlocal walk_calls
walk_calls += 1
return real_walker(r, extensions, excluded, ignore, include_hidden)
return real_walker(
r, extensions, excluded, ignore, include_hidden, image_extensions
)
monkeypatch.setattr(importer, "iter_importable_files", counting)
try:
+91 -4
View File
@@ -169,14 +169,21 @@ def test_summaries_present_and_absent() -> None:
one, two = _folder_nodes(source)
assert one.summary == "One desc."
assert two.summary is None
# File nodes carry no summary key at all (the 00_phase.md shape);
# since phase 106 they DO carry the creation date (``created_at``
# — the RAG view's ``Created`` column).
# File nodes carry no summary key at all on the WIRE (the
# 00_phase.md shape — the model_dump set check below is the wire
# pin); since phase 106 they DO carry the creation date
# (``created_at`` — the RAG view's ``Created`` column).
file = _file_nodes(one)[0]
assert set(file.model_dump()) == {
"kind", "path", "title", "chunks", "created_at", "indexed_at"
}
assert "summary" not in file.__class__.model_fields
# Phase 122 (task 04): the image-affordance fields DO exist on the
# class now (image file nodes set them — the wire omission for text
# nodes is the ``KbTreeFile`` serializer, pinned by the model_dump
# set check above: a text node never leaks the three keys).
from app.schemas import KbTreeFile
assert {"is_image", "image_url", "summary"} <= set(KbTreeFile.model_fields)
def test_file_metadata_unchanged_in_tree() -> None:
@@ -546,3 +553,83 @@ def test_updated_at_does_not_leak_across_sources() -> None:
a, b = build_kb_tree(["A", "B"], rows, {})
assert a.updated_at == C0
assert b.updated_at == C3
# ---------------------------------------------------------------------------
# Phase 122 (task 04) — the image-docs affordance on tree file nodes
# ---------------------------------------------------------------------------
DOC_ID = "11111111-2222-3333-4444-555555555555"
def test_image_file_node_carries_the_affordance_and_text_node_unchanged() -> None:
"""The ``images`` map (``{(source, path): (doc_id, summary)}``) turns
a file node into the image-docs node: ``is_image`` true,
``image_url`` = the bytes route's path built from the mapped id,
``summary`` verbatim (the RAG view's thumbnail ``alt``). A file
node NOT in the map keeps the pre-phase wire shape byte-identically
(the three image keys are OMITTED, not false/null)."""
rows = [
("S", "one/a.md", "A", 1, T0, C0),
("S", "one/pic.png", "pic", 2, T0, C0),
]
images = {("S", "one/pic.png"): (DOC_ID, "A red square on a white background.")}
(source,) = build_kb_tree(["S"], rows, {}, images)
one = _folder_nodes(source)[0]
# File nodes keep the FULL source-relative path (the phase-97
# shape — the folder prefix rides the node).
files = {f.path: f for f in _file_nodes(one)}
# Text node: the pre-phase wire shape, byte-identical (no image keys).
assert set(files["one/a.md"].model_dump()) == {
"kind", "path", "title", "chunks", "created_at", "indexed_at"
}
# Image node: the affordance rides the node.
dumped = files["one/pic.png"].model_dump()
assert dumped["kind"] == "file"
assert dumped["is_image"] is True
assert dumped["image_url"] == f"/api/documents/{DOC_ID}/image"
assert dumped["summary"] == "A red square on a white background."
# The catalogue fields still ride verbatim.
assert (dumped["title"], dumped["chunks"]) == ("pic", 2)
assert (dumped["created_at"], dumped["indexed_at"]) == (C0, T0)
def test_image_file_node_null_summary_and_missing_map_entry() -> None:
"""A mapped image node with a NULL summary (the fail-soft backfill
corner) keeps ``summary: null`` on the wire (meaningful — the alt
falls back client-side). A map entry that points at a NON-existent
(source, path) affects nothing (the builder only reads mapped keys
it meets in the catalogue rows)."""
rows = [
("S", "one/ghost.png", "ghost", 1, T0, C0),
("S", "one/other.md", "Other", 1, T0, C0),
]
images = {
("S", "one/ghost.png"): (DOC_ID, None),
("S", "one/absent.png"): (DOC_ID, "never matched"),
}
(source,) = build_kb_tree(["S"], rows, {}, images)
one = _folder_nodes(source)[0]
files = {f.path: f for f in _file_nodes(one)}
ghost = files["one/ghost.png"].model_dump()
assert ghost["is_image"] is True
assert ghost["summary"] is None # null stays (the alt fallback corner)
assert ghost["image_url"] == f"/api/documents/{DOC_ID}/image"
assert set(files["one/other.md"].model_dump()) == {
"kind", "path", "title", "chunks", "created_at", "indexed_at"
}
def test_image_affordance_absent_without_the_map() -> None:
"""No map (the default) → every file node is the pre-phase shape,
even for ``.png`` paths: the fields are map-driven (the endpoint
composes the map from the ``is_image`` rows), never path-guessed —
a pre-phase KB serializes byte-identically."""
rows = [("S", "pic.png", "pic", 1, T0, C0)]
(source,) = build_kb_tree(["S"], rows, {})
(file,) = _file_nodes(source)
assert set(file.model_dump()) == {
"kind", "path", "title", "chunks", "created_at", "indexed_at"
}
+8 -3
View File
@@ -53,18 +53,23 @@ def test_app_config_dict_carries_the_docs_flag() -> None:
body = app_config(s)
# Phase 62 (task 01): the response grew to the phase-62 UI
# customization keys; phase 91 (task 03) deleted the retired
# CSS-file theming's ``theme`` key — the five keys below are the
# entire endpoint contract.
# CSS-file theming's ``theme`` key; phase 122 (task 01) added the
# ``images`` flag — the six keys below are the entire endpoint
# contract.
assert set(body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text",
"images", "input_placeholder", "footer_text",
}
assert body["docs_repo_configured"] is s.docs_configured
assert body["docs_repo_configured"] is False
assert body["images"] is False # LOCKED A3: off by default
s2 = _settings(docs_repo="/srv/docs-repo")
assert app_config(s2)["docs_repo_configured"] is True
s3 = _settings(images=True)
assert app_config(s3)["images"] is True
# ---------------------------------------------------------------------------
# brand.js — the flag + promise are surfaced the way app_name is
+9 -7
View File
@@ -114,23 +114,25 @@ def test_no_literal_46rem_width_remains() -> None:
# ---------- the four reading-column selectors ----------
def test_the_four_reading_columns_use_the_token() -> None:
""".chat-shell, .shared-shell, .doc-md and
.doc-summary:has(+ .doc-md) each cap with
max-width: var(--chat-column) — and exactly those four rules use
the token for a max-width (no other selector)."""
def test_the_reading_columns_use_the_token() -> None:
""".chat-shell, .shared-shell, .doc-md, .doc-image (phase 122,
task 04 — the viewer's image block rides the SAME reading column
as its .doc-md sibling) and .doc-summary:has(+ .doc-md) each cap
with max-width: var(--chat-column) — and exactly those five rules
use the token for a max-width (no other selector)."""
css = _css()
for selector in (
".chat-shell",
".shared-shell",
".doc-md",
".doc-image",
".doc-summary:has(+ .doc-md)",
):
assert "max-width: var(--chat-column)" in _rule_block(css, selector), (
f"{selector} must cap with max-width: var(--chat-column)"
)
assert css.count("max-width: var(--chat-column)") == 4, (
"exactly the four reading-column selectors use the token"
assert css.count("max-width: var(--chat-column)") == 5, (
"exactly the reading-column selectors use the token"
)