Protocol B append: failed-turn retry (L3–4), git source tokens (L5), image documents (L6 ingest), chat image questions (L6 chat side). TODO.md items now live in .agents/phases/todo/ and the file is cleared. LLM-Generated: true
14 KiB
Phase 122 — Image documents: standalone images become first-class, retrievable documents
Source: TODO.md L6 — "Need to support images. Images uploaded as part of documents or as standalone images should be read, summarized, and retrieved like any other document. Note that the embedding model won't support images, so the only embedded part of an image will be the summary generated by the model. The user should be able to turn on and off image support in their .env depending on whether their model supports it. Images retrieved by the RAG should be shown in the chat nicely and users should be able to submit images as part of their question in brain of reese."
Story: n/a (feature request; extends the phase-28/30/38 import pipeline, phase-90 no-scan uploads, and the RAG/chat assets).
Context: app/config.py — Settings (BOR_ prefix; upload_dir L361, sources_dir L355, upload_max_mb L370 — raw-string/expanduser house convention). app/rag/importer.py — iter_importable_files (L235, extension filter via llm.settings.import_extension_set + match_extension L217), import_sources (L279, prune L283), _index_file (L435 — read_text L453, sha256 over text, Document upsert, the unchanged/hash path with the phase-118 summary backfill), _store_summary (L582 — lite-model summary + the position −1 is_summary chunk), _prune (L649 — deletes docs of the imported sources not in seen). app/models.py — Document (L102: content, content_hash, summary L140, created_at/created_at_manual), Chunk (L148: is_summary L161). app/rag/summarizer.py + app/rag/llm.py — the lite summary path + the chat-model client (Settings model names, check_models). app/rag/archive_upload.py — archive unpack into upload_dir (image members land on disk today, then get filtered out by the extension walk). app/api/git_sources.py:432 — the upload endpoint. app/api/docs.py — the document content endpoint (document viewer). app/rag/retriever.py / app/rag/agent.py (read tool, message build L1446–1448) / app/api/chat.py — RAG + the SSE sources frames. app/api/config.py:30 — GET /api/config public flags dict (task 01 of phase 123 extends it). Frontend: frontend/assets/app.js (chat source chips), frontend/assets/document.js + frontend/document.html (#doc-content L144), frontend/assets/sources.js (Sources page). alembic/ — migrations.
Objective
With BOR_IMAGES=true, a standalone image file — arriving as a direct upload, inside an uploaded archive, or as a file in a git/local source — becomes a first-class document: the vision model (the chat model) describes it, the description is the document's content AND summary, only the description is embedded (the embedding model never sees pixels), the image bytes persist and are served, and the image shows up in the Sources page, the document viewer, and the chat — with retrieved image docs rendered inline in the answer's sources.
Dependencies
121_git_source_tokens(todo) — pipeline predecessor (execution order) only; no code dependency.- Code dependencies (all complete): phase 30 summary pipeline (
_store_summary,is_summarychunk), phase 90 no-scan upload, phase 89/105 per-source walk options, the RAG agent + SSE sources frames.
Design (shared by all tasks — the executor reads this, not the chat)
- Toggle (task 01, LOCKED A3): three new
Settingsfields —images: bool = False(BOR_IMAGES,0/falseoff — the phase-67llm_retriesbool style),image_extensions: str = "png,jpg,jpeg,webp,gif,bmp"(BOR_IMAGE_EXTENSIONS, comma-separated, lowercased into a frozenset by the importer — theimport_extension_setproperty precedent), andimage_dir: str = "~/bor-sources/images"(BOR_IMAGE_DIR, raw-string/expanduserconvention — the persistent home for image bytes, deliberately separate fromsources_dir/upload_dir)..env.examplegets all three with a comment: off by default — enable only when your chat model supports vision, because image descriptions are generated by the chat model.GET /api/config(task 01) gainsimages: boolso the UI can gate affordances (consumed by phase 123; the Sources page can show an "images off" hint — optional, not required). - Why the bytes are copied (task 02): upload dirs are REPLACED on every upload (
archive_upload.swap_in), git checkouts are re-cloned, and local dirs are user-edited — a served image must outlive its source file. The importer copies each ingested image toimage_dir/<doc-uuid>.<ext>(created on demand) and stores that path inDocument.image_path. The copy happens ONLY when the doc is new or its hash changes; a replaced image deletes the stale copy; pruned docs delete their copy. - Storage (task 02):
Document.is_image: bool(server defaultfalse— every pre-phase-122 row is a text doc) +Document.image_path: str | None(NULL for text docs). One migration, one downgrade. - Ingest (task 02): the walk:
iter_importable_files/import_sourcesaccept the image frozenset IN ADDITION toimport_extension_set, ONLY whensettings.imagesis true (images are never user-configurable viaBOR_IMPORT_EXTENSIONS— the toggle is the single knob, LOCKED A3/A4)._index_filebranches on image extension: read BYTES (notread_text), sha256 over the bytes (the digest rule is unchanged — content identity), copy toimage_dir, setis_image+image_path, andcontent= the vision description (task 03). The normal chunk pipeline then embeds the content (= the description) — that is exactly the TODO's "the only embedded part of an image will be the summary generated by the model"; the phase-30 summary chunk (is_summary, position −1) mirrorsDocument.summary, which equals the description too. Title = the file stem (the non-markdown rule at L505–509). The unchanged/hash path works unmodified (byte digest → "unchanged" skips re-describing; the phase-118 backfill path re-describes a NULL-summary image doc on its next sync — same fail-soft). Prune guard:_prune(L649) must NOT deleteis_imagedocs whilesettings.imagesis false (an image doc is invisible to an images-off walk, not a deleted file — otherwise turning the toggle off and syncing would silently destroy the image documents). Toggle ON → normal prune semantics (a deleted image file prunes its doc + copy). - Description (task 03, LOCKED A3):
describe_imageinapp/rag/summarizer.py(one function, the summarizer module owns model-text generation): a SINGLE chat-model call (Settings.llm_chat_model— the vision model; the lite summary model is NOT assumed vision-capable, LOCKED A3) with a multimodal user message[{type: "text", text: <fixed describe prompt>}, {type: "image_url", image_url: {url: <data URL from the bytes + mime>}}]; the prompt asks for a faithful, retrieval-oriented description (what is shown, any text/labels/diagram content, salient details — the description is the ONLY thing retrievable, so it must carry the image's meaning). Output capped atsettings.summary_max_chars(the description IS the doc's summary; the phase-30 cap keeps it uniform). Stored:Document.summary = Document.content = description. Fail-soft: a failed/empty description → the doc is SKIPPED (no row,ImportSummarycounts it in a newimages_failedcounter + alogger.warningwith source/path) — an undescribed image is unsearchable noise; the sync continues (the importer's existing fail-soft convention). - Serve + display (task 04):
GET /api/documents/{doc_id}/image(new route inapp/api/docs.py) — 404 for missing docs and non-image docs; servesimage_pathbytes with the correctContent-Type(ext → mime map: png/jpeg/webp/gif/bmp) — PUBLIC like the document content itself (this app's document content is already anonymous-readable; the image is part of that content). The document content endpoint (the onefrontend/assets/document.jsboots against) gainsis_image: bool+image_url(the new route's path, absent for text docs) sodocument.htmlrenders<img src>(max-width 100%, the theme's image treatment) with the summary/description text below it instead of the markdown content; the Sources page row for an image doc shows a small thumbnail (lazy-loaded,loading="lazy", aspect-ratio box) or the existing doc icon when the fetch is not yet possible offline — the thumbnail is a progressive enhancement (a fetch failure falls back to the icon). - RAG display (task 05): the SSE sources frames (and any sources-list shape the chat bubble renders from) carry an OPTIONAL
image_urlon image docs (the retriever/agent know theDocumentrow — add the field whereSourceRef-shaped frames are built inapp/api/chat.py/app/rag/retriever.py); the chat's sources block renders a compact inline<img>(capped height, the summary as caption/alt) for image docs — "shown in the chat nicely" (TODO L6). The agent'sreadtool on an image doc returns its description prefixed with a one-line marker (e.g.Image document — description generated from the image:) so the model knows what it is reading.alttext = the summary everywhere (WCAG). - NOT touched (this phase): chat-side image submission (phase 123), the lite summary path for TEXT docs, archive unpacking (image members already land on disk — only the walk filter changes), and git/local sync scheduling.
- Locked assumptions: A3 — descriptions use the CHAT model (
BOR_LLM_CHAT_MODEL, must be vision-capable);BOR_IMAGESdefaults to false; generation failure → doc skipped + logged. A4 — "images uploaded as part of documents" = standalone image files arriving via direct upload / uploaded archives / source walks — NOT embedded-image extraction from PDFs/DOCX.
Tasks
01_image_toggle.md—BOR_IMAGES/BOR_IMAGE_EXTENSIONS/BOR_IMAGE_DIRsettings +.env.example+GET /api/configflag; off = byte-identical behavior.02_image_ingest.md—Document.is_image/image_path+ migration; walk accepts image extensions when on;_index_filebinary branch + persistent copy; prune guard when off.03_image_description.md—describe_image(chat-model vision), content = summary = description, fail-soft skip + counter.04_serve_and_display.md—GET /api/documents/{id}/image; document viewer + Sources page rendering.05_rag_display.md—image_urlon chat source frames + inline image in the chat sources block + the agentreadmarker.06_image_tests.md— unit + integration + isolated E2Etest_image_documents.py.
Testing & Quality
- Unit:
tests/unit/test_image_documents.py(new, task 06) — settings parsing (toggle off by default, extensions frozenset, mime map), the_index_fileimage branch (bytes digest, copy toimage_dir,is_image/image_pathset, textcontentnever read for an image), the prune guard (toggle off → image docs survive; toggle on → deleted image prunes),describe_imageprompt shape (multimodal content list, chat model, cap) with a mock client, and the fail-soft skip path. - Integration:
tests/integration/test_docs_api.py(extend) — the image route (200 + correct Content-Type for a seeded image doc; 404 for text docs and missing ids); the content endpoint exposesis_image/image_urlfor image docs and omits them for text docs (byte-identical text-doc responses);import_sourcesend-to-end withimages=Trueand a mock vision client (a fixture PNG → doc row with description content +is_summarychunk embedding;images=False→ the file is ignored, pre-existing image doc survives prune). - E2E:
tests/e2e/test_image_documents.py(new, task 06) — isolated run per AGENTS.md §4,BOR_IMAGES=truefor this suite's app instance: upload a small fixture PNG (via the existing upload endpoint's UI orpage.request) → sync → the Sources page lists it (thumbnail or icon) → open the document viewer → the image renders with its description → ask a question the mock LLM grounds on the image doc → the chat's sources block shows the inline image. - Coverage: >90% on
app/(validate.sh gate).
Completion Criteria
- With
BOR_IMAGES=true: an uploaded standalone image (direct or in an archive) and an image file in a git/local source become documents whose content/summary is the vision description and whose ONLY embedded text is that description. - With
BOR_IMAGES=false(the default): every request, walk, and response is byte-identical to pre-phase; existing image docs (if any) survive a sync. - The image renders in the document viewer and in the chat's sources block (inline, with alt text); a failed description skips the doc and logs — the sync completes.
uv run pytestgreen;uv run pytest --cov=app --cov-report=term-missingTOTAL >90%;uv run ruff check . && uv run pyrightclean.- One
--no-gpg-signcommit; phase dir moved to.agents/phases/complete/by the pipeline gate.
Locked decisions
- A3 — image descriptions are generated by the CHAT model (
BOR_LLM_CHAT_MODEL, vision-capable);BOR_IMAGESdefaults to false; a failed/empty description skips the doc and logs (owner-confirmed 2026-09-24, roadmap confirmation). - A4 — "images uploaded as part of documents" = standalone image files via direct upload / uploaded archives / source walks — no embedded-image extraction from PDFs/DOCX (owner-confirmed 2026-09-24).
- Prune guard (derived from A3/A4, same confirmation): images-off syncs never prune
is_imagedocs — turning the toggle off must not destroy image documents.
Commit
git add app/ alembic/ frontend/ tests/ .env.example .agents/phases/ && git commit --no-gpg-sign -m "feat(rag): index standalone images as documents — described, embedded, and displayed via the vision model"