diff --git a/.agents/phases/complete/125_image_anchor_context/00_phase.md b/.agents/phases/complete/125_image_anchor_context/00_phase.md new file mode 100644 index 0000000..4755e54 --- /dev/null +++ b/.agents/phases/complete/125_image_anchor_context/00_phase.md @@ -0,0 +1,54 @@ +# Phase 125 — Image-anchor context: markdown image references enrich image documents with surrounding text + +**Source:** `TODO.md` — "improve image handling: use context of surrounding text to describe images, use alt text from markdown image syntax `![alt](image.png)` to give context to the summary." +**Story:** n/a (improvement on phase-122/124 image capability). +**Context:** `app/config.py` — `Settings` (`images` L379, `image_extensions` L390, `image_dir` L413). `app/rag/importer.py` — `iter_importable_files` (L235, extension walk), `import_sources` (L279), `_index_file` (L435 — text branch for markdown, binary branch delegates to `_index_image_file`), `_index_image_file` (L801 — bytes digest, persistent copy, `describe_image` seam, chunk + embed pipeline), `_store_summary` (L582), `_describe_or_skip` (L762 — calls `describe_image`). `app/rag/summarizer.py` — `describe_image` (L208 — multimodal chat call, DESCRIBE_PROMPT constant), `DESCRIBE_INSTRUCTION` (L89 — the locked prompt text), `generate_summary` (L182), `build_summary_prompt` (L162). `app/rag/chunker.py` — markdown section splitter on `##` headings, paragraph packing. `app/models.py` — `Document` (L102: `content`, `summary`, `is_image`, `image_path`), `Chunk` (L171). `app/rag/retriever.py` — `_selection_order` (L666, shared selection walk), `select_suggested` (L864, seeds prompt summaries), `source_ref_with_image` (L945). `app/rag/prompts.py` — `` block body (L438, uses `doc.summary`), `build_high_prompt` (L461 — the LOCKED `` section, phase-124 `show_image` clause). `app/api/chat.py` — done-frame build (L1119-1133, `cited_docs` merge from task 05 of phase 124). `alembic/versions/0022_documents_image.py` — the phase-122 migration (the pattern for additive, reversible migrations). + +## Objective +When a markdown file references an image (e.g. `![alt text](diagram.png)`), the image document's description is enriched with the alt text and surrounding paragraphs — so the image becomes retrievable not just by what it shows, but by why it exists in the documentation. This closes the gap between standalone image descriptions (phase 122) and the rich contextual text that lives in the parent markdown document. + +## Dependencies +- `122_image_documents` (complete) — the image-doc pipeline: `Document.is_image`/`image_path`, `describe_image`, the binary index path, `BOR_IMAGES` toggle, the vision description as content+summary. +- `124_image_chips_and_answers` (complete) — auto-cite for image docs, `BOR_ANSWER_IMAGES` toggle, the `show_image` tool. +- Code dependencies (all complete): phase 30 summary pipeline, phase 118 summary seeding, phase 119 read-docs citation tier. + +## Design (shared by all tasks — the executor reads this, not the chat) +- **The `image_context` field (task 01):** `Document.image_context: str | None = None` (TEXT, nullable). Stores the surrounding text extracted from the markdown file that references the image — alt text + N paragraphs before and after the reference. NULL for image docs that were not found to have a markdown reference (standalone images, images referenced in non-markdown formats, pre-phase rows). Migration: one additive, reversible migration (A13 precedent: `0023_documents_image_context.py`). +- **Markdown image extraction (task 02):** a new module-level function `extract_image_refs(content: str) -> list[ImageRef]` in `app/rag/importer.py` (or `app/rag/md_images.py` if preferred) that parses markdown content and extracts: (a) the `![alt_text](path)` pattern via regex; (b) for each reference, the N paragraphs before and after the line containing the reference (configurable, default 2); (c) the line number of the reference for context window calculation. The regex: `r'!\[([^\]]*)\]\(([^)]+)\)'` — captures alt text and the image path. The context window: split content into paragraphs (blank-line separated, respecting code fences), find the paragraph containing the reference, take N before and N after. Result: `ImageRef(path, alt_text, context_text, line_number)`. +- **Enrichment flow (task 03):** after `_index_file` processes a markdown file, check if it contains image references. For each reference whose image path resolves to an existing image document in the same source, load the image doc and enrich its content: `new_content = f"{alt_text}\n\n{context}\n\n{original_description}"` — the alt text first (short, purposeful), then the surrounding context (why the image exists), then the vision description (what the image shows). Then re-chunk and re-embed the enriched content. If the image doc doesn't exist yet (not indexed), store the reference on the markdown doc's `image_context` field so the enrichment can happen when the image is later indexed. +- **Enrichment at image-index time (task 04):** in `_index_image_file`, BEFORE calling `describe_image`, check if any markdown file in the same source references this image. If so, load the markdown content, extract the reference + context, and pass it to `describe_image` as additional context in the prompt (the vision model can acknowledge the context: "This diagram, referenced in `` as ``, shows..."). This is the primary enrichment path — the image gets context during its own indexing. The `image_context` field on the image doc stores the extracted context text for retrieval display. +- **Retrieval display (task 05):** when an image doc is in the suggested tier, the `` block (which currently shows `doc.summary`) is extended to also show the `image_context` (if present) — a short "Referenced in `/`: ``" line before the description. This is a small addition to `build_document_block` in `app/rag/prompts.py` — only for `is_image` docs with non-NULL `image_context`. +- **NOT touched:** the `BOR_IMAGES` toggle (the feature is gated by it, unchanged), the `BOR_ANSWER_IMAGES` toggle (unchanged), the `show_image` tool (unchanged), the image serve route (unchanged), the document viewer (unchanged), and the prune logic (unchanged). Existing image docs without context are unaffected — `image_context` is NULL, the prompt block shows only the description (byte-identical to pre-phase). +- **Locked assumptions:** **A1** — `image_context` stores the surrounding text from the referencing markdown file. **A2** — enrichment happens at image-index time (the image doc picks up context from its markdown parent). **A3** — the vision model receives context in the prompt but the description remains the primary content. **A4** — retrieval display adds a short context line for enriched image docs; pre-phase image docs are byte-identical. + +## Tasks +1. `01_migration_image_context.md` — `Document.image_context` column + migration. +2. `02_extract_image_refs.md` — `extract_image_refs()` function for markdown image reference extraction. +3. `03_enrich_image_description.md` — pass context to `describe_image`; enrich image doc content + re-embed. +4. `04_store_image_context.md` — store context on image docs; handle the case where markdown is indexed before/after the image. +5. `05_retrieval_display.md` — show context line for enriched image docs in the `` block. +6. `06_image_anchor_tests.md` — unit + integration + E2E tests. + +## Testing & Quality +- Unit: `tests/unit/test_image_anchor_context.py` (new, task 06) — `extract_image_refs` (basic reference, multiple references, references in code fences, references with no alt text, references in headings, edge cases); the enrichment pipeline (context extraction, content enrichment, re-chunking, re-embedding); the `describe_image` prompt extension (context included in the text part when provided); the retrieval display (context line present for enriched docs, absent for non-enriched). +- Integration: `tests/integration/test_importer.py` (extend) — a markdown file referencing an image in the same source → the image doc has `image_context` set; the image description includes the context; the chunks are re-embedded with the enriched content. A markdown file referencing a non-existent image → the markdown doc's `image_context` is set (the image will be enriched when indexed). A standalone image (no markdown reference) → `image_context` is NULL (byte-identical to pre-phase). +- E2E: `tests/e2e/test_image_anchor_context.py` (new, task 06) — isolated run: upload a fixture markdown file that references an image in the same archive → sync → the image doc has enriched context → ask a question that requires the context to answer correctly (the mock LLM should be able to use the context to give a better answer) → the `` block shows the context line. +- Coverage: **>90%** on `app/` (validate.sh gate). + +## Completion Criteria +- [ ] A markdown file referencing an image in the same source → the image document's description is enriched with alt text + surrounding context; the retrieval block shows the context line. +- [ ] A standalone image (no markdown reference) → `image_context` is NULL; behavior byte-identical to pre-phase. +- [ ] A markdown file referencing a non-existent image → the context is stored on the markdown doc; when the image is later indexed, it picks up the context. +- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL >90%; `uv run ruff check . && uv run pyright` clean. +- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agents/phases/complete/` by the pipeline gate. + +## Locked decisions +- **A1 — `image_context` stores the surrounding text (alt text + N paragraphs before/after) from the referencing markdown file (owner-confirmed 2026-09-26, roadmap confirmation).** +- **A2 — enrichment happens at image-index time: the image doc picks up context from its markdown parent (owner-confirmed 2026-09-26, roadmap confirmation).** +- **A3 — the vision model receives context in the prompt but the description remains the primary content (owner-confirmed 2026-09-26, roadmap confirmation).** +- **A4 — retrieval display adds a short context line for enriched image docs; pre-phase image docs are byte-identical (owner-confirmed 2026-09-26, roadmap confirmation).** + +## Commit +```bash +git add app/ alembic/ tests/ .agents/phases/ && git commit --no-gpg-sign -m "feat(rag): enrich image descriptions with markdown context (alt text + surrounding paragraphs)" +``` diff --git a/.agents/phases/complete/125_image_anchor_context/01_migration_image_context.md b/.agents/phases/complete/125_image_anchor_context/01_migration_image_context.md new file mode 100644 index 0000000..4fe97e0 --- /dev/null +++ b/.agents/phases/complete/125_image_anchor_context/01_migration_image_context.md @@ -0,0 +1,34 @@ +# Task 01 — Migration: `Document.image_context` column + +**Phase:** `125_image_anchor_context` · **Source:** `00_phase.md` — the `image_context` field + +## Objective +Add `Document.image_context: str | None` (TEXT, nullable) to the database schema — the column that stores the surrounding text extracted from the markdown file referencing the image. One additive, reversible migration (A13 precedent). + +## Work +1. `app/models.py` — add to `Document` (after `image_path`, L168): + ```python + #: Surrounding text from the markdown file that references this image + #: (phase 125, LOCKED A1): alt text + N paragraphs before/after the + #: reference. NULL for standalone images and pre-phase rows. The + #: embedding model embeds this alongside the vision description when + #: the image is enriched (task 03). + image_context: Mapped[str | None] = mapped_column(Text, default=None) + ``` +2. `alembic/versions/0023_documents_image_context.py` — new migration: + - `revision = "0023"`, `down_revision = "0022"` + - `upgrade()`: `op.add_column("documents", sa.Column("image_context", sa.Text(), nullable=True))` + - `downgrade()`: `op.drop_column("documents", "image_context")` + - Docstring following the `0022_documents_image.py` pattern: explain the column, its purpose, and that it's fully reversible. +3. ASSUMPTION: none. The column is nullable (NULL for all pre-phase rows) and additive (no existing code breaks). + +## Testing & Quality +- Unit: `tests/unit/test_models.py` (extend) — `Document(image_context="some context")` parses; `Document(image_context=None)` parses; the column is nullable (no NOT NULL constraint). +- Integration: `tests/integration/test_migrations.py` (extend) — run `alembic upgrade head` (the new migration applies cleanly); run `alembic downgrade 0022` (the downgrade restores the 0022 schema); run `alembic upgrade head` again (idempotent). Assert that pre-phase image docs (NULL `image_context`) round-trip correctly. +- Coverage: **>90%** on the touched modules. + +## Completion Criteria +- [ ] `alembic upgrade head` applies the new migration cleanly. +- [ ] `alembic downgrade 0022` + `upgrade head` round-trips the schema. +- [ ] `Document` model has the `image_context` field; NULL is valid. +- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean. diff --git a/.agents/phases/complete/125_image_anchor_context/02_extract_image_refs.md b/.agents/phases/complete/125_image_anchor_context/02_extract_image_refs.md new file mode 100644 index 0000000..03c3daa --- /dev/null +++ b/.agents/phases/complete/125_image_anchor_context/02_extract_image_refs.md @@ -0,0 +1,50 @@ +# Task 02 — Extract image references from markdown content + +**Phase:** `125_image_anchor_context` · **Source:** `00_phase.md` — the `extract_image_refs()` function + +## Objective +A pure function that parses markdown content and extracts all `![alt_text](path)` references, each with its alt text, the surrounding paragraph context (N before and N after), and the line number. Returns a list of `ImageRef` dataclasses. + +## Work +1. `app/rag/md_images.py` — new module (or add to `app/rag/importer.py` if preferred — the executor's call, but a separate module keeps the importer lean): + - `@dataclass class ImageRef: path: str; alt_text: str; context: str; line_number: int` — the extracted reference with its context window. + - `CONTEXT_PARAGRAPHS: int = 2` — configurable number of paragraphs before/after (the default gives ~2-4 sentences of surrounding text). + - `def extract_image_refs(content: str, context_paragraphs: int = CONTEXT_PARAGRAPHS) -> list[ImageRef]`: + - Split content into lines. + - Track code fence state (a fenced code block is NOT context — image references inside code fences are just text, not actual references). + - For each line, check for the image reference regex: `r'!\[([^\]]*)\]\(([^)]+)\)'`. + - When a reference is found: + - Record the line number (1-indexed). + - Find the paragraph containing this line (paragraphs are separated by blank lines, but code fences are atomic). + - Collect N paragraphs before and N after the reference paragraph. + - The `context` is the joined paragraphs (excluding the reference line itself, which is already captured as `alt_text` + `path`). + - The `alt_text` is the captured group (may be empty). + - The `path` is the captured group (may be relative or absolute). + - Return the list of `ImageRef` objects. + - Edge cases to handle: + - References inside code fences → skip (the reference is literal text, not an actual image). + - Multiple references on the same line → extract each separately. + - References with no alt text (`![](image.png)`) → `alt_text = ""`. + - References with no surrounding text (start/end of file) → context is whatever is available. + - Image paths with special characters (spaces, parentheses) → the regex should handle common cases; edge cases with nested parentheses in paths are acceptable to miss (they're rare in practice). +2. ASSUMPTION: none. This is a pure function — no I/O, no LLM calls, no database access. + +## Testing & Quality +- Unit: `tests/unit/test_md_images.py` (new, task 06) — test `extract_image_refs` against various inputs: + - Single reference: `![alt](image.png)` → one `ImageRef` with alt="alt", context from surrounding paragraphs. + - Multiple references: two `![...]` on different lines → two `ImageRef`s, each with its own context. + - Reference in code fence: ```` ![alt](image.png) ```` → skipped (not extracted). + - Reference with no alt text: `![](image.png)` → `alt_text = ""`. + - Reference at start of file → context is only paragraphs after. + - Reference at end of file → context is only paragraphs before. + - Reference in a heading: `## ![alt](image.png)` → extracted, but context is the surrounding text (the heading line itself is not included in context). + - Empty content → empty list. + - Content with no references → empty list. + - Verify that `line_number` is 1-indexed and correct. +- Coverage: **>90%** on the new module. + +## Completion Criteria +- [ ] `extract_image_refs("![alt](img.png)")` returns one `ImageRef` with correct alt, path, context, and line number. +- [ ] References inside code fences are skipped. +- [ ] Multiple references are extracted separately with correct context windows. +- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean. diff --git a/.agents/phases/complete/125_image_anchor_context/03_enrich_image_description.md b/.agents/phases/complete/125_image_anchor_context/03_enrich_image_description.md new file mode 100644 index 0000000..b3293c3 --- /dev/null +++ b/.agents/phases/complete/125_image_anchor_context/03_enrich_image_description.md @@ -0,0 +1,49 @@ +# Task 03 — Enrich image descriptions with context + +**Phase:** `125_image_anchor_context` · **Source:** `00_phase.md` — pass context to `describe_image`; enrich image doc content + re-embed + +## Objective +When indexing an image file, check if any markdown file in the same source references it. If so, extract the alt text and surrounding context, and pass it to `describe_image` so the vision model can produce a context-aware description. Store the extracted context on the image doc's `image_context` field. + +## Work +1. `app/rag/summarizer.py` — extend `describe_image`: + - Add an optional `context: str | None = None` parameter. + - When `context` is provided, prepend it to the DESCRIBE_PROMPT text part: + ```python + context_prefix = f"\n\nThis image is referenced in documentation with the following context:\n{context}" if context else "" + messages = [{ + "role": "user", + "content": [ + {"type": "text", "text": DESCRIBE_PROMPT + context_prefix}, + {"type": "image_url", "image_url": {"url": data_url}}, + ], + }] + ``` + - The context prefix teaches the vision model what the image is for, enabling a more useful description. The existing DESCRIBE_PROMPT (the locked instruction) is unchanged — the context is appended after it. + - The context is NOT stored on the doc (that's task 04's job); this task only passes it to the LLM. +2. `app/rag/importer.py` — in `_index_image_file`, BEFORE calling `describe_image`: + - Check if any markdown file in the same source references this image. The simplest approach: scan all `.md`/`.markdown` files in the same source directory for references to this image's filename. Use `extract_image_refs` from task 02. + - For each markdown file that references this image, collect the `ImageRef` objects where the image path matches (handle both relative paths like `image.png` and paths like `folder/image.png`). + - If references are found, combine them into a single context string: + ``` + Referenced in /: + - Alt text: "" + Context: + ``` + - Pass this context to `describe_image` as the `context` parameter. + - Store the combined context string on the image doc's `image_context` field (this is the primary storage — the retrieval display in task 05 reads from here). +3. `app/rag/importer.py` — in `_index_image_file`, after `describe_image` returns: + - If `image_context` is set (from step 2), the image doc's `content` should be enriched: `content = f"{image_context}\n\n{description}"` — the context first (for retrieval), then the description. Then the normal chunk pipeline embeds this enriched content. + - If `image_context` is NULL (no markdown reference found), the content is just the description (byte-identical to pre-phase). + - The `summary` field: for image docs, `summary = content` (the phase-122 convention). So the summary is also enriched. +4. ASSUMPTION: none. The context extraction uses the `extract_image_refs` function from task 02. The `describe_image` extension is backward-compatible (the `context` parameter defaults to `None`). + +## Testing & Quality +- Unit: `tests/unit/test_image_anchor_context.py` (task 06) — `describe_image` with `context=None` → same behavior as pre-phase (the DESCRIBE_PROMPT is unchanged); `describe_image` with `context="some context"` → the prompt includes the context prefix; the enriched content is `context + "\n\n" + description`. +- Integration: `tests/integration/test_importer.py` (extend) — seed a source with both a markdown file referencing an image and the image file itself → sync with `images=True` → the image doc has `image_context` set, its `content` includes the context, its chunks are embedded from the enriched content. A standalone image (no markdown reference) → `image_context` is NULL, `content` is just the description. +- Coverage: **>90%** on the touched modules. + +## Completion Criteria +- [ ] An image referenced in a markdown file → the description is context-aware (the LLM receives the context), and the image doc's `content` includes the context. +- [ ] A standalone image → `image_context` is NULL, `content` is just the description (byte-identical to pre-phase). +- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean. diff --git a/.agents/phases/complete/125_image_anchor_context/04_store_image_context.md b/.agents/phases/complete/125_image_anchor_context/04_store_image_context.md new file mode 100644 index 0000000..c7a21d1 --- /dev/null +++ b/.agents/phases/complete/125_image_anchor_context/04_store_image_context.md @@ -0,0 +1,48 @@ +# Task 04 — Store context on markdown docs for future image enrichment + +**Phase:** `125_image_anchor_context` · **Source:** `00_phase.md` — handle the case where markdown is indexed before/after the image + +## Objective +When a markdown file is indexed and references an image that doesn't yet exist as a document (e.g., the image hasn't been synced yet, or it's in a different source), store the image references on the markdown doc's `image_context` field. This enables a post-indexing enrichment pass that can pick up the context when the image is later indexed. + +## Work +1. `app/rag/importer.py` — in the markdown/text branch of `_index_file` (after the Document upsert, before the chunk pipeline): + - After extracting the title (L505-509), check if the file is markdown (`full_path.suffix.lower() in (".md", ".markdown")`). + - Call `extract_image_refs(content)` to get all image references. + - For each reference, check if an image document already exists in the same source with a matching path. Use a database query: `select(Document).where(Document.source == source, Document.path == image_ref.path, Document.is_image == True)`. + - For references where the image document DOES NOT exist, store the reference info on the markdown doc's `image_context` field: + ```python + # Build a structured context string for references to non-existent images + missing_refs = [r for r in image_refs if not image_exists_in_source(session, source, r.path)] + if missing_refs: + doc.image_context = _format_missing_image_refs(missing_refs, source) + ``` + - `_format_missing_image_refs(refs, source) -> str`: formats the missing references as a structured string: + ``` + IMAGE_CONTEXT: + - [image.png] (line 42): "Alt text here" — 2 paragraphs of context + - [diagram.jpg] (line 100): "" — 1 paragraph of context + ``` + This format is parseable by the enrichment pass (task 04b). + - For references where the image document DOES exist, the enrichment happens in task 03 (at image-index time). The markdown doc's `image_context` remains NULL. +2. `app/rag/importer.py` — a new helper `_format_missing_image_refs(refs, source) -> str` (module-level, pure function): + - Takes a list of `ImageRef` objects and the source name. + - Returns a formatted string as described above. + - Used by both `_index_file` (markdown branch) and the enrichment pass. +3. `app/rag/importer.py` — after `_index_file` returns for all files in a source (in `import_sources`), run a **post-indexing enrichment pass**: + - Query all image documents in the source that have `image_context IS NULL` (not yet enriched). + - For each, check if any markdown document in the same source has `image_context` set (contains references to this image). + - If a match is found, extract the reference info from the markdown doc's `image_context`, enrich the image doc (same logic as task 03: re-call `describe_image` with context, update `content`, re-embed chunks). + - This pass handles the case where the markdown was indexed first (storing references) and the image is indexed later. +4. ASSUMPTION: none. The enrichment pass is idempotent — if an image doc already has `image_context` set, it's skipped. The markdown doc's `image_context` is not cleared after enrichment (it's useful for retrieval display too). + +## Testing & Quality +- Unit: `tests/unit/test_image_anchor_context.py` (task 06) — `_format_missing_image_refs` (single ref, multiple refs, empty list → empty string); the enrichment pass (markdown with missing refs → image doc enriched when found; image doc already enriched → skipped). +- Integration: `tests/integration/test_importer.py` (extend) — seed a source with a markdown file referencing an image that doesn't exist yet → sync → the markdown doc has `image_context` set; then add the image file → sync again → the image doc is enriched with the context from the markdown doc. A markdown file referencing an image that exists → the image doc is enriched at index time (task 03 path). +- Coverage: **>90%** on the touched modules. + +## Completion Criteria +- [ ] A markdown file referencing a non-existent image → the markdown doc's `image_context` stores the reference info. +- [ ] When the image is later indexed (or re-synced), it picks up the context from the markdown doc. +- [ ] A markdown file referencing an existing image → the image is enriched at index time (task 03 path), the markdown doc's `image_context` is NULL. +- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean. diff --git a/.agents/phases/complete/125_image_anchor_context/05_retrieval_display.md b/.agents/phases/complete/125_image_anchor_context/05_retrieval_display.md new file mode 100644 index 0000000..e4606d1 --- /dev/null +++ b/.agents/phases/complete/125_image_anchor_context/05_retrieval_display.md @@ -0,0 +1,38 @@ +# Task 05 — Retrieval display: show context line for enriched image docs + +**Phase:** `125_image_anchor_context` · **Source:** `00_phase.md` — show context line for enriched image docs in the `` block + +## Objective +When an enriched image doc (one with non-NULL `image_context`) appears in the suggested tier, the `` block shows a short context line before the description — so the LLM knows why the image exists in the documentation. + +## Work +1. `app/rag/prompts.py` — extend the document block builder (`_document_block_body`, L438): + - Currently it returns `doc.summary` (stripped). For image docs with `image_context`, prepend a context line: + ```python + summary = (doc.summary or "").strip() + if doc.is_image and doc.image_context: + # Extract the first reference's alt text for a short context line + # The image_context is formatted as "Referenced in /: ..." + # Show: "Referenced in : " + context_line = _extract_context_line(doc.image_context) + if context_line: + summary = f"{context_line}\n\n{summary}" + return summary + ``` + - `_extract_context_line(image_context: str) -> str | None`: parses the `image_context` string (set by task 04) and extracts a short context line: + ```python + # image_context format: "Referenced in /:\n- [image.png] (line 42): \"Alt text here\"" + # Return: "Referenced in : \"Alt text here\"" + ``` + If parsing fails or the format is unexpected, return `None` (graceful degradation — the summary shows without the context line). +2. ASSUMPTION: none. The context line is short (one line) so it doesn't bloat the prompt. Pre-phase image docs (NULL `image_context`) are byte-identical — the `if doc.is_image and doc.image_context` guard ensures this. + +## Testing & Quality +- Unit: `tests/unit/test_prompts.py` (extend) — `_document_block_body` for a non-image doc → unchanged (byte-identical to pre-phase); for an image doc with `image_context = None` → unchanged (byte-identical to pre-phase); for an image doc with `image_context` set → the summary includes the context line before the description; `_extract_context_line` (valid format → extracts the line; invalid format → returns None). +- Integration: `tests/integration/test_chat_api.py` (extend) — a grounded turn whose suggested tier contains an enriched image doc → the `` section in the system prompt includes the context line; a non-enriched image doc → no context line. +- Coverage: **>90%** on the touched modules. + +## Completion Criteria +- [ ] An enriched image doc in the suggested tier → the `` block shows a context line before the description. +- [ ] A non-enriched image doc → byte-identical to pre-phase (no context line). +- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean. diff --git a/.agents/phases/complete/125_image_anchor_context/06_image_anchor_tests.md b/.agents/phases/complete/125_image_anchor_context/06_image_anchor_tests.md new file mode 100644 index 0000000..4993fa2 --- /dev/null +++ b/.agents/phases/complete/125_image_anchor_context/06_image_anchor_tests.md @@ -0,0 +1,57 @@ +# Task 06 — Tests: unit, integration, and E2E for image-anchor context + +**Phase:** `125_image_anchor_context` · **Source:** `00_phase.md` — the full test suite + +## Objective +Comprehensive tests across all new logic: the `extract_image_refs` function, the enrichment pipeline, the `describe_image` context extension, the retrieval display, and the end-to-end flow. + +## Work +1. `tests/unit/test_md_images.py` (new) — tests for `extract_image_refs`: + - Single reference with alt text and surrounding paragraphs. + - Multiple references on different lines, each with correct context windows. + - References inside code fences (skipped). + - References with no alt text (`![](image.png)`). + - References at start/end of file (context is partial). + - References in headings. + - Empty content, content with no references. + - Verify `line_number` is 1-indexed and correct. + - Edge cases: nested brackets in alt text, special characters in paths. +2. `tests/unit/test_image_anchor_context.py` (new) — tests for the enrichment pipeline: + - `describe_image` with `context=None` → same behavior as pre-phase. + - `describe_image` with `context="..."` → the prompt includes the context prefix. + - `_extract_context_line` (valid format → extracts the line; invalid format → returns None). + - `_document_block_body` for enriched vs non-enriched image docs. + - `_format_missing_image_refs` (single ref, multiple refs, empty list). + - The enrichment pass (markdown with missing refs → image enriched; image already enriched → skipped). +3. `tests/integration/test_importer.py` (extend) — integration tests: + - Seed a source with a markdown file referencing an image → the image doc has `image_context` set. + - A standalone image (no markdown reference) → `image_context` is NULL. + - A markdown file referencing a non-existent image → the markdown doc stores the reference info. + - Re-sync after adding the missing image → the image doc is enriched. + - Verify that the enriched image doc's chunks are embedded from the enriched content. +4. `tests/integration/test_chat_api.py` (extend) — integration tests: + - A grounded turn whose suggested tier contains an enriched image doc → the done frame's system prompt includes the context line. + - A non-enriched image doc → no context line (byte-identical to pre-phase). +5. `tests/e2e/test_image_anchor_context.py` (new) — isolated E2E suite: + - Module apps: `BOR_IMAGES=true` for this suite's app instance. + - KB fixture: upload an archive containing a markdown file that references an image within the same archive (e.g., `docs/guide.md` references `diagram.png`). + - Sync → the image doc is enriched with the markdown context. + - Ask a question that requires the context to answer correctly (the mock LLM should use the context line to give a better answer). + - Verify the `` block in the prompt includes the context line (via the saved chat record). + - A standalone image (no markdown reference) → the answer is byte-identical to pre-phase. + - Run in isolation: `uv run pytest tests/e2e/test_image_anchor_context.py -v --no-cov`. +6. Run the full test suite: `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL >90%; `uv run ruff check . && uv run pyright` clean. + +## Testing & Quality +- All new logic is covered by unit tests (the `extract_image_refs` function, the enrichment pipeline, the context extraction/display). +- Integration tests cover the full import + chat flow with seeded data. +- E2E tests cover the real user path: upload → sync → ask → verify enriched answer. +- The mock LLM should be configured to acknowledge the context line in its answer (extend the mock's prompt parser to recognize the context line format). + +## Completion Criteria +- [ ] `uv run pytest tests/unit/test_md_images.py -v --no-cov` passes. +- [ ] `uv run pytest tests/unit/test_image_anchor_context.py -v --no-cov` passes. +- [ ] `uv run pytest tests/integration/test_importer.py -v --no-cov` passes (extended). +- [ ] `uv run pytest tests/e2e/test_image_anchor_context.py -v --no-cov` passes in isolation. +- [ ] `uv run pytest` green; coverage TOTAL >90%; `ruff` + `pyright` clean. +- [ ] No regression in existing E2E suites (run `test_image_documents.py` and `test_answer_images.py` to verify). diff --git a/.agents/phases/todo/126_pdf_support/00_phase.md b/.agents/phases/todo/126_pdf_support/00_phase.md new file mode 100644 index 0000000..f71dc4a --- /dev/null +++ b/.agents/phases/todo/126_pdf_support/00_phase.md @@ -0,0 +1,79 @@ +# Phase 126 — PDF support: PDFs become first-class, retrievable documents + +**Source:** `TODO.md` — "add PDF support" (implied by the image handling assessment: "PDFs: Not supported. No PDF parsing library is in pyproject.toml.") +**Story:** n/a (new file format support). +**Context:** `app/config.py` — `Settings` (`import_extensions` L343, `import_extension_set` L733 — the extension filter; `images` L379, `image_extensions` L390, `image_dir` L413 — the image pipeline); `app/rag/importer.py` — `iter_importable_files` (L235, extension walk via `match_extension`), `import_sources` (L279, `prune` L283), `_index_file` (L435 — text branch: `read_text`, sha256 over text, Document upsert, chunk + embed pipeline), `_index_image_file` (L801 — binary branch: bytes digest, persistent copy, `describe_image`), `_store_summary` (L582), `_prune` (L649). `app/rag/chunker.py` — format-aware chunker (markdown, yaml, json, py, txt dispatch). `app/rag/summarizer.py` — `describe_image` (L208, vision model), `generate_summary` (L182, lite model). `app/rag/retriever.py` — `_selection_order` (L666), `select_suggested` (L864), `source_ref_with_image` (L945). `app/models.py` — `Document` (L102), `Chunk` (L171). `app/rag/llm.py` — the LLM client. `app/api/docs.py` — document content endpoint. `app/rag/archive_upload.py` — archive unpack (PDFs in archives should also be indexed). + +## Objective +PDF files become first-class, retrievable documents: text is extracted page by page, embedded images are extracted and described (joining the standalone image pipeline), and the full PDF content is chunked, embedded, and summarized — all behind a new `BOR_IMPORT_EXTENSIONS` token (`pdf`). + +## Dependencies +- `122_image_documents` (complete) — the image-doc pipeline: `describe_image`, `Document.is_image`/`image_path`, the vision model description path. Embedded PDF images reuse this pipeline. +- `125_image_anchor_context` (complete) — the `image_context` field and enrichment pipeline (PDF-embedded images can be enriched if referenced by a markdown file in the same source). +- Code dependencies (all complete): phase 30 summary pipeline, phase 118 summary seeding, the chunker, the LLM client. + +## Design (shared by all tasks — the executor reads this, not the chat) +- **Dependency (task 01):** add `pymupdf` (PyMuPDF, the `fitz` package) to `pyproject.toml` dependencies. PyMuPDF is chosen over `pdfplumber` because it natively supports both text extraction AND embedded image extraction in a single API, and it's a well-maintained package with good performance. The `fitz` module is the import name. +- **PDF parsing module (task 02):** `app/rag/pdf.py` — new module with: + - `def extract_pdf_text(pdf_path: Path) -> str`: extracts text from all pages, joined with `---PAGE-N---` separators (where N is 1-indexed). The separators enable the chunker to respect page boundaries. + - `def extract_pdf_images(pdf_path: Path) -> list[PdfImage]`: extracts embedded images from all pages. Returns a list of `PdfImage` dataclasses: `page: int`, `index: int` (image index on the page), `width: int`, `height: int`, `data: bytes`, `mime: str` (derived from the image type code via PyMuPDF's `page.get_image_info()`). + - `_mime_from_image_info(info: dict) -> str`: maps PyMuPDF's image type codes to MIME types (e.g., `2` → `image/jpeg`, `3` → `image/png`, `4` → `image/gif`, `8` → `image/webp`, `9` → `image/tiff`). Falls back to `application/octet-stream` for unknown types. + - All functions are pure (except I/O for reading the PDF file) — fully unit-testable with a temp PDF fixture. +- **PDF chunking (task 03):** extend `app/rag/chunker.py`'s `chunk_document` dispatch to handle `.pdf` files. The PDF chunker: + - Splits on `---PAGE-N---` separators first (one chunk per page, or sub-split if a page is too large). + - Each chunk keeps its page anchor (e.g., "Page 3:" prefix) so hits are readable in context. + - Code blocks within PDFs are handled by the existing paragraph packing (PDFs don't have code fences, so paragraph boundaries = blank lines). + - The `HARD_MAX_CHARS` cap (1200) still applies. + - For very large PDFs (>50 pages), the chunk target is reduced to keep individual chunks manageable. +- **PDF indexing (task 04):** in `app/rag/importer.py`'s `_index_file`: + - Add a new branch for `.pdf` files (checked AFTER the image branch, BEFORE the text branch): + ```python + if full_path.suffix.lower() == ".pdf": + return await _index_pdf_file(session, source, rel, full_path, llm, summary, raw_date) + ``` + - `_index_pdf_file`: + 1. Extract text via `extract_pdf_text` (task 02). + 2. Extract embedded images via `extract_pdf_images` (task 02). + 3. Create the PDF document row with the extracted text as `content`, the file's sha256 (over the text) as `content_hash`, and the title from the PDF's metadata (if available) or the file stem. + 4. Chunk and embed the text (task 03's chunker handles `.pdf`). + 5. Generate a lite summary of the PDF content. + 6. For each embedded image: + - If the image extension matches `BOR_IMAGE_EXTENSIONS` and `BOR_IMAGES` is on: create a standalone image document (reuse `_index_image_file`'s logic, but the image bytes come from the PDF, not a file on disk). The image's `path` is `/#page=#img=` (a synthetic path that uniquely identifies the embedded image). + - If the image extension doesn't match or `BOR_IMAGES` is off: log a warning and skip the image. + - The embedded image's description becomes the image doc's content+summary (the phase-122 pipeline). + 7. The PDF document's content includes inline references to embedded images: after each page's text, append `[Embedded image: on page ]` (a placeholder that the LLM can use to know images exist, even if they're not described). +- **PDF in archives (task 05):** PDFs inside uploaded archives should be indexed too. The archive unpack (`app/rag/archive_upload.py`) already extracts all files matching the import extensions. Add `pdf` to the archive's accepted file types (archives already unpack everything; the extension filter in `iter_importable_files` handles the rest). +- **NOT touched:** the `BOR_IMAGES` toggle (PDF embedded images reuse the existing image pipeline, gated by it), the `show_image` tool (unchanged), the image serve route (unchanged), the document viewer (PDFs are served as text content, not as binary PDFs — the viewer renders the extracted text), and the prune logic (unchanged). +- **Locked assumptions:** **A1** — `pymupdf` is the PDF parser (text + image extraction). **A2** — one document per PDF (not per page); pages are separated by `---PAGE-N---` markers. **A3** — embedded images become standalone image documents (reusing the phase-122 pipeline). **A4** — PDFs are served as extracted text in the document viewer (not as binary PDFs). + +## Tasks +1. `01_pdf_dependency.md` — add `pymupdf` to `pyproject.toml`. +2. `02_pdf_parser.md` — `app/rag/pdf.py`: text extraction, image extraction, MIME mapping. +3. `03_pdf_chunking.md` — extend the chunker to handle `.pdf` files. +4. `04_pdf_indexing.md` — `_index_pdf_file` in the importer: text + embedded images + summary. +5. `05_pdf_archive_and_tests.md` — PDFs in archives, unit + integration + E2E tests. + +## Testing & Quality +- Unit: `tests/unit/test_pdf.py` (new, task 05) — `extract_pdf_text` (multi-page PDF → text with page separators; single-page PDF → text without extra separators; empty PDF → empty string); `extract_pdf_images` (PDF with embedded images → list of `PdfImage` with correct page/index/size/data/mime; PDF with no images → empty list); `_mime_from_image_info` (known types → correct MIME; unknown type → fallback). +- Integration: `tests/integration/test_importer.py` (extend) — seed a source with a PDF file → sync → the PDF document has extracted text, chunks, and embedding; embedded images (if `BOR_IMAGES=true`) become image documents; a PDF without embedded images → no image docs. +- E2E: `tests/e2e/test_pdf_support.py` (new, task 05) — isolated run: upload a PDF via the admin upload flow → sync → the PDF appears on the Sources page → open the document viewer → the extracted text renders; ask a question the PDF answers → the chat grounds on the PDF content. +- Coverage: **>90%** on `app/` (validate.sh gate). + +## Completion Criteria +- [ ] A PDF file in a git/local source or uploaded archive becomes a document with extracted text, chunks, embedding, and summary. +- [ ] With `BOR_IMAGES=true`: embedded images in the PDF become standalone image documents (described by the vision model). +- [ ] With `BOR_IMAGES=false`: embedded images are skipped (logged), the PDF text is indexed normally. +- [ ] The PDF renders as extracted text in the document viewer. +- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL >90%; `uv run ruff check . && uv run pyright` clean. +- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agents/phases/complete/` by the pipeline gate. + +## Locked decisions +- **A1 — `pymupdf` (PyMuPDF) is the PDF parser; it handles both text and embedded image extraction (owner-confirmed 2026-09-26, roadmap confirmation).** +- **A2 — one document per PDF; pages are separated by `---PAGE-N---` markers in the content (owner-confirmed 2026-09-26, roadmap confirmation).** +- **A3 — embedded images become standalone image documents, reusing the phase-122 pipeline (gated by `BOR_IMAGES`) (owner-confirmed 2026-09-26, roadmap confirmation).** +- **A4 — PDFs are served as extracted text in the document viewer (not as binary PDFs) (owner-confirmed 2026-09-26, roadmap confirmation).** + +## Commit +```bash +git add app/ pyproject.toml tests/ .agents/phases/ && git commit --no-gpg-sign -m "feat(import): add PDF support — text extraction, embedded images, chunking" +``` diff --git a/.agents/phases/todo/126_pdf_support/01_pdf_dependency.md b/.agents/phases/todo/126_pdf_support/01_pdf_dependency.md new file mode 100644 index 0000000..8a13713 --- /dev/null +++ b/.agents/phases/todo/126_pdf_support/01_pdf_dependency.md @@ -0,0 +1,19 @@ +# Task 01 — Add `pymupdf` dependency + +**Phase:** `126_pdf_support` · **Source:** `00_phase.md` — the `pymupdf` dependency + +## Objective +Add `pymupdf` to `pyproject.toml` dependencies. PyMuPDF provides both PDF text extraction and embedded image extraction via the `fitz` module. + +## Work +1. `pyproject.toml` — add `pymupdf>=1.24,<2.0` to the `dependencies` list (after the existing dependencies, before the dev group). Version pin: `1.24` is a stable release with good text and image extraction support; `<2.0` follows the project's convention of pinning major versions. +2. ASSUMPTION: none. `pymupdf` is a pure-Python package with optional C extensions (the binary wheels are available for common platforms). + +## Testing & Quality +- No code tests needed — this is a dependency addition. +- Verify: `uv sync` completes without errors; `import fitz` works in a Python shell. + +## Completion Criteria +- [ ] `uv sync` completes successfully. +- [ ] `import fitz` works; `fitz.VersionBind` shows the installed version. +- [ ] `uv run pytest` still green (no regression from the dependency addition). diff --git a/.agents/phases/todo/126_pdf_support/02_pdf_parser.md b/.agents/phases/todo/126_pdf_support/02_pdf_parser.md new file mode 100644 index 0000000..ad0abd0 --- /dev/null +++ b/.agents/phases/todo/126_pdf_support/02_pdf_parser.md @@ -0,0 +1,173 @@ +# Task 02 — PDF parser module: `app/rag/pdf.py` + +**Phase:** `126_pdf_support` · **Source:** `00_phase.md` — text extraction, image extraction, MIME mapping + +## Objective +A new `app/rag/pdf.py` module with pure functions for PDF text extraction, embedded image extraction, and MIME type mapping. All functions are I/O-bound only (read a PDF file, return parsed data) — fully unit-testable with temp PDF fixtures. + +## Work +1. `app/rag/pdf.py` — new module: + ```python + """PDF parsing utilities (phase 126). + + Pure functions for extracting text and embedded images from PDF files + using PyMuPDF (the ``fitz`` module). + """ + from __future__ import annotations + + import hashlib + import logging + from dataclasses import dataclass + from pathlib import Path + + import fitz # pymupdf + + logger = logging.getLogger("app.pdf") + + #: Page separator used in extracted text (chunker splits on this). + PAGE_SEPARATOR = "---PAGE-{page}---" + + @dataclass + class PdfImage: + """One embedded image from a PDF page.""" + page: int #: 1-indexed page number + index: int #: image index on the page (0-based, from fitz) + width: int + height: int + data: bytes #: raw image bytes + mime: str #: MIME type (e.g., "image/png") + + #: Mapping from PyMuPDF image type codes to MIME types. + #: Type codes: 2=JPEG, 3=PNG, 4=GIF, 8=TIFF, 9=WebP (per fitz docs). + _TYPE_TO_MIME: dict[int, str] = { + 2: "image/jpeg", + 3: "image/png", + 4: "image/gif", + 8: "image/tiff", + 9: "image/webp", + } + + def _mime_from_image_info(info: dict) -> str: + """Map a PyMuPDF image info dict to a MIME type. + + Falls back to ``application/octet-stream`` for unknown types. + """ + # Try the 'ext' key first (file extension), then the 'type' code. + ext = info.get("ext", "").lower() + if ext == "png": + return "image/png" + if ext in ("jpg", "jpeg"): + return "image/jpeg" + if ext == "gif": + return "image/gif" + if ext == "webp": + return "image/webp" + if ext == "tiff" or ext == "tif": + return "image/tiff" + # Fallback to type code. + type_code = info.get("type") + if type_code is not None: + return _TYPE_TO_MIME.get(type_code, "application/octet-stream") + return "application/octet-stream" + + def extract_pdf_text(pdf_path: Path) -> str: + """Extract text from all pages of a PDF, joined with page separators. + + Returns the full text with ``---PAGE-N---`` separators between pages. + Returns an empty string if the PDF has no text or cannot be opened. + """ + try: + doc = fitz.open(str(pdf_path)) + except Exception as e: + logger.warning("pdf: could not open %s — %s", pdf_path, e) + return "" + + parts: list[str] = [] + for page_num in range(len(doc)): + page = doc[page_num] + text = page.get_text("text").strip() + if text: + parts.append(f"{PAGE_SEPARATOR.format(page=page_num + 1)}\n{text}") + else: + # Even empty pages get a separator (for chunker consistency). + parts.append(f"{PAGE_SEPARATOR.format(page=page_num + 1)}") + doc.close() + return "\n\n".join(parts) + + def extract_pdf_images(pdf_path: Path) -> list[PdfImage]: + """Extract all embedded images from a PDF. + + Returns a list of ``PdfImage`` dataclasses, one per embedded image. + Images are extracted in page order, then by their index on the page. + Returns an empty list if the PDF has no embedded images or cannot + be opened. + """ + try: + doc = fitz.open(str(pdf_path)) + except Exception as e: + logger.warning("pdf: could not open %s for image extraction — %s", pdf_path, e) + return [] + + images: list[PdfImage] = [] + for page_num in range(len(doc)): + page = doc[page_num] + image_list = page.get_images(full=True) + for img_index, img_info in enumerate(image_list): + try: + xref = img_info[0] + # Get the raw image bytes. + pix = fitz.Pixmap(doc, xref) + # Convert to RGB if needed (CMYK → RGB). + if pix.n - pix.alpha > 3: + pix = fitz.Pixmap(fitz.csRGB, pix) + data = pix.tobytes("png") if pix.n == 4 else pix.tobytes("jpeg") + # Determine the best bytes representation. + # If the image is grayscale or has alpha, use PNG. + # Otherwise, use JPEG for smaller size. + if pix.alpha > 0 or pix.n <= 2: + data = pix.tobytes("png") + mime = "image/png" + else: + data = pix.tobytes("jpeg") + mime = "image/jpeg" + # Fallback: if tobytes fails, use the raw data. + if not data: + data = pix.tobytes() + mime = _mime_from_image_info(img_info) + + images.append(PdfImage( + page=page_num + 1, + index=img_index, + width=pix.width, + height=pix.height, + data=data, + mime=mime, + )) + pix = None # free the pixmap + except Exception as e: + logger.warning( + "pdf: could not extract image page=%d index=%d — %s", + page_num + 1, img_index, e, + ) + doc.close() + return images + ``` +2. ASSUMPTION: none. The module uses only `fitz` (from `pymupdf`) and stdlib. + +## Testing & Quality +- Unit: `tests/unit/test_pdf.py` (task 05) — create temp PDF fixtures using `fitz` itself: + - A single-page PDF with text → `extract_pdf_text` returns the text with one page separator. + - A multi-page PDF → text with multiple page separators. + - A PDF with no text (image-only) → empty string. + - A PDF with embedded images → `extract_pdf_images` returns the correct list. + - A PDF with no embedded images → empty list. + - `_mime_from_image_info` (known types → correct MIME; unknown → fallback). + - Error handling: non-existent PDF → empty string / empty list (no exception). +- Coverage: **>90%** on the new module. + +## Completion Criteria +- [ ] `extract_pdf_text` returns correct text with page separators for a multi-page PDF. +- [ ] `extract_pdf_images` returns correct image data for a PDF with embedded images. +- [ ] `_mime_from_image_info` maps known type codes correctly; unknown types get the fallback. +- [ ] Non-existent or unreadable PDFs return empty results (no exceptions). +- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean. diff --git a/.agents/phases/todo/126_pdf_support/03_pdf_chunking.md b/.agents/phases/todo/126_pdf_support/03_pdf_chunking.md new file mode 100644 index 0000000..5070dcc --- /dev/null +++ b/.agents/phases/todo/126_pdf_support/03_pdf_chunking.md @@ -0,0 +1,56 @@ +# Task 03 — PDF chunking: extend the chunker for `.pdf` files + +**Phase:** `126_pdf_support` · **Source:** `00_phase.md` — extend the chunker to handle `.pdf` files + +## Objective +Extend `app/rag/chunker.py`'s `chunk_document` dispatch to handle `.pdf` files. PDF chunks are split on page separators (`---PAGE-N---`), with each page as a logical unit. + +## Work +1. `app/rag/chunker.py` — extend the `chunk_document` dispatch: + - Add `.pdf` to the suffix check that dispatches to the paragraph packer (PDFs don't have a format-specific splitter like markdown's heading-based splitting). + - The PDF chunker uses the same `chunk_text` function as `txt` (paragraph packing), but with page-aware splitting: + ```python + def _chunk_pdf(content: str, rel: str, target: int, overlap: int) -> list[str]: + """Chunk PDF content, respecting page separators. + + Pages are the primary split boundary. Each page's text is packed + into chunks of ``target`` chars with ``overlap`` chars carry-over. + """ + # Split on page separators. + pages = re.split(r'---PAGE-\d+---', content) + # pages[0] is empty (content starts with the separator). + result: list[str] = [] + for i, page_text in enumerate(pages[1:], start=1): + page_text = page_text.strip() + if not page_text: + continue + # Prepend the page anchor for readability. + anchored = f"Page {i}:\n{page_text}" + # Chunk the anchored text using paragraph packing. + chunks = chunk_text(anchored, target=target, overlap=overlap) + result.extend(chunks) + return result + ``` + - Register `.pdf` in the `chunk_document` dispatch table: + ```python + CHUNKERS: dict[str, Callable] = { + ... + ".pdf": _chunk_pdf, + } + ``` +2. ASSUMPTION: none. The PDF chunker reuses the existing `chunk_text` paragraph packer — it only adds page-aware splitting. + +## Testing & Quality +- Unit: `tests/unit/test_chunker.py` (extend) — add PDF-specific tests: + - A single-page PDF → one or more chunks with "Page 1:" prefix. + - A multi-page PDF → chunks with correct "Page N:" prefixes. + - An empty PDF → empty list. + - A very large page (>HARD_MAX_CHARS) → split by line (the `chunk_text` behavior). + - Verify that page separators in the input are removed from the output chunks. +- Coverage: **>90%** on the touched code. + +## Completion Criteria +- [ ] `chunk_document` dispatches `.pdf` files to the page-aware chunker. +- [ ] Chunks have "Page N:" prefixes and no page separator markers. +- [ ] Multi-page PDFs produce chunks with correct page anchors. +- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean. diff --git a/.agents/phases/todo/126_pdf_support/04_pdf_indexing.md b/.agents/phases/todo/126_pdf_support/04_pdf_indexing.md new file mode 100644 index 0000000..a7ca097 --- /dev/null +++ b/.agents/phases/todo/126_pdf_support/04_pdf_indexing.md @@ -0,0 +1,262 @@ +# Task 04 — PDF indexing: `_index_pdf_file` in the importer + +**Phase:** `126_pdf_support` · **Source:** `00_phase.md` — text + embedded images + summary + +## Objective +Add PDF indexing to the importer: `_index_pdf_file` extracts text, creates the document, chunks + embeds, generates a summary, and handles embedded images. + +## Work +1. `app/rag/importer.py` — add the import: + ```python + from app.rag.pdf import extract_pdf_text, extract_pdf_images, PdfImage + ``` +2. `app/rag/importer.py` — in `_index_file`, add the PDF branch (AFTER the image branch, BEFORE the text branch): + ```python + # Phase 126: PDF files take the PDF index path. + if full_path.suffix.lower() == ".pdf": + return await _index_pdf_file( + session, source=source, rel=rel, full_path=full_path, + llm=llm, summary=summary, raw_date=raw_date, + ) + ``` +3. `app/rag/importer.py` — new `_index_pdf_file` function (follow the `_index_file` text branch pattern): + ```python + async def _index_pdf_file( + session: Session, + *, + source: str, + rel: str, + full_path: Path, + llm: Embedder, + summary: ImportSummary, + raw_date: datetime | None = None, + ) -> None: + """Index one PDF file: extract text, create doc, chunk + embed, + summarize, and extract embedded images. + """ + settings = llm.settings + # Extract text. + content = extract_pdf_text(full_path) + if not content: + # No text in the PDF — still create a doc (it might have + # embedded images), but with empty content. + logger.warning("import: PDF has no extractable text source=%s path=%s", source, rel) + + # Digest over the extracted text (content identity). + digest = hashlib.sha256(content.encode("utf-8")).hexdigest() if content else hashlib.sha256(b"").hexdigest() + + # Get PDF metadata for the title. + title = _extract_pdf_title(full_path, fallback=full_path.stem) + + # Date: use the file's mtime (D2 fallback) — PDFs don't have + # standard creation dates accessible via the filesystem. + if raw_date is None: + raw_date = file_mtime_datetime(full_path) + + # Upsert the document row. + doc = session.scalar(select(Document).where(Document.source == source, Document.path == rel)) + if doc is None: + doc = Document( + 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), + ) + session.add(doc) + else: + # Check for content change. + if doc.content_hash == digest and doc.content == content: + summary.unchanged += 1 + logger.info("import: unchanged source=%s path=%s", source, rel) + # Summary backfill (phase 118, A2). + if doc.summary is None: + await _store_summary( + session, doc=doc, source=source, rel=rel, + content=content, llm=llm, summary=summary, backfill=True, + ) + if doc.created_at_manual: + return + target = normalize_doc_date(raw_date) + if target != doc.created_at: + doc.created_at = target + session.commit() + summary.dates_updated += 1 + return + + doc.full_path = str(full_path) + doc.title = title + doc.content = content + doc.content_hash = digest + doc.indexed_at = datetime.now(UTC) + doc.created_at = normalize_doc_date(raw_date) + doc.created_at_manual = False + + session.flush() + + # Chunk and embed. + if content: + 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() + 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 PDF at %d chars after endpoint token cap: %s", + target // 2, rel, + ) + target //= 2 + + session.commit() + + # Verb count. + if doc.content_hash == digest and doc.content == content and doc.id in [d.id for d in []]: + # This is a bit awkward — we need to track whether this was + # new or updated. Use the session's identity map. + pass # handled by the unchanged path above + else: + summary.added += 1 if doc.id not in session.identity_map else 0 + # Actually, let's just use the verb tracking from _index_file. + # The simplest approach: track added/updated before the upsert. + + # Generate summary (phase 30, A2). + await _store_summary( + session, doc=doc, source=source, rel=rel, + content=content, llm=llm, summary=summary, backfill=False, + ) + + # Extract and index embedded images. + await _index_pdf_embedded_images( + session, source=source, rel=rel, full_path=full_path, + llm=llm, summary=summary, raw_date=raw_date, + ) + + logger.info("import: %s source=%s path=%s chunks=%d", + "added" if newly_created else "updated", source, rel, len(doc.chunks)) + ``` + + Wait — the above is getting complex. Let me simplify. The key insight is that `_index_pdf_file` should follow the same pattern as `_index_file` but with PDF-specific extraction. Let me refactor. + + Actually, let me simplify this significantly. The `_index_pdf_file` function should: + 1. Extract text + 2. Upsert the document + 3. Chunk + embed + 4. Generate summary + 5. Extract embedded images + 6. Log the result + + The unchanged/hash path is the same as `_index_file` — check the hash, skip if unchanged. + +4. `app/rag/importer.py` — `_extract_pdf_title(full_path, fallback) -> str`: + - Open the PDF with `fitz`, try to get the metadata title (`doc.metadata.get("title", "")`). + - If no title in metadata, return the file stem (the fallback). + - Strip whitespace and limit to 500 chars (the `Document.title` column width). + +5. `app/rag/importer.py` — `_index_pdf_embedded_images(session, source, rel, full_path, llm, summary, raw_date)`: + - Extract embedded images via `extract_pdf_images(full_path)`. + - For each image: + - If `settings.images` is False → skip (log a debug message). + - If the image extension doesn't match `settings.image_extension_set` → skip (log a warning). + - Create a synthetic path for the embedded image: `#page=#img=.` (e.g., `report.pdf#page=3#img=0.png`). + - Call `_index_image_file` with the synthetic path and the image bytes (the `_index_image_file` function expects a `full_path` on disk — we need to either write a temp file or refactor `_index_image_file` to accept bytes directly). + - **Alternative approach:** instead of calling `_index_image_file`, handle the embedded image inline: copy the bytes to `settings.image_dir`, describe them via `describe_image`, create the image doc row, chunk + embed the description. This avoids the temp file and the `_index_image_file` refactoring. + + Let me go with the inline approach — it's simpler and avoids refactoring `_index_image_file`: + ```python + async def _index_pdf_embedded_images(...): + images = extract_pdf_images(full_path) + for img in images: + ext = _ext_from_mime(img.mime) # "image/png" → ".png" + if ext not in settings.image_extension_set: + logger.warning("import: PDF embedded image has unsupported extension: %s", img.mime) + continue + + # Write to image_dir. + img_id = uuid.uuid4() + img_filename = f"{img_id}{ext}" + img_dir_path = Path(settings.image_dir) + img_dir_path.mkdir(parents=True, exist_ok=True) + img_path = str(img_dir_path / img_filename) + Path(img_path).write_bytes(img.data) + + # Describe the image. + description = await _describe_or_skip( + llm, data=img.data, source=source, rel=f"{rel}#page={img.page}#img={img.index}", + mime=img.mime, full_path=Path(img_path), + ) + if description is None: + summary.images_failed += 1 + Path(img_path).unlink(missing_ok=True) + continue + + # Create the image doc row. + img_doc = Document( + source=source, + path=f"{rel}#page={img.page}#img={img.index}{ext}", + full_path=img_path, + title=f"Embedded image from {full_path.stem} page {img.page}", + content=description, + content_hash=hashlib.sha256(img.data).hexdigest(), + indexed_at=datetime.now(UTC), + created_at=normalize_doc_date(raw_date), + is_image=True, + image_path=img_path, + ) + session.add(img_doc) + session.flush() + + # Chunk + embed the description. + chunks_text = chunk_document(description, img_doc.path, + settings.chunk_target_chars, settings.chunk_overlap_chars) + img_doc.chunks = [ + Chunk(document_id=img_doc.id, position=i, content=c) + for i, c in enumerate(chunks_text) + ] + session.flush() + try: + vectors = await llm.embed([c.content for c in img_doc.chunks]) + for row, vec in zip(img_doc.chunks, vectors, strict=True): + row.embedding = vec + except EmbeddingError: + pass # fail soft, like the text path + + # Summary = description (phase-122 convention for image docs). + img_doc.summary = description + is_summary_chunk = Chunk( + document_id=img_doc.id, position=-1, content=description, + is_summary=True, + ) + img_doc.chunks.append(is_summary_chunk) + session.flush() + + session.commit() + ``` + +6. ASSUMPTION: none. The embedded image indexing reuses the same patterns as standalone image indexing (phase 122). + +## Testing & Quality +- Unit: `tests/unit/test_pdf.py` (extend) — `_extract_pdf_title` (PDF with metadata title → returns it; PDF without → returns fallback). +- Integration: `tests/integration/test_importer.py` (extend) — seed a source with a PDF file → sync → the PDF document has extracted text, chunks, and embedding; embedded images (if `BOR_IMAGES=true`) become image documents. +- Coverage: **>90%** on the touched modules. + +## Completion Criteria +- [ ] A PDF file in a source → the PDF document has extracted text, chunks, embedding, and summary. +- [ ] With `BOR_IMAGES=true`: embedded images become image documents. +- [ ] With `BOR_IMAGES=false`: embedded images are skipped. +- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean. diff --git a/.agents/phases/todo/126_pdf_support/05_pdf_archive_and_tests.md b/.agents/phases/todo/126_pdf_support/05_pdf_archive_and_tests.md new file mode 100644 index 0000000..d699e2b --- /dev/null +++ b/.agents/phases/todo/126_pdf_support/05_pdf_archive_and_tests.md @@ -0,0 +1,60 @@ +# Task 05 — PDFs in archives and full test suite + +**Phase:** `126_pdf_support` · **Source:** `00_phase.md` — PDFs in archives, unit + integration + E2E tests + +## Objective +Ensure PDFs inside uploaded archives are indexed, and write the full test suite (unit, integration, E2E). + +## Work +1. `app/rag/archive_upload.py` — no code changes needed. Archives are unpacked into `upload_dir`, and the extension walk (`iter_importable_files`) already filters by `import_extension_set`. Adding `pdf` to the set means PDFs in archives are automatically picked up. Verify that the archive unpack doesn't skip `.pdf` files (it shouldn't — archives unpack all files). + +2. `app/config.py` — no code changes needed. `pdf` is added to `BOR_IMPORT_EXTENSIONS` by the user (the config already accepts any well-formed extension via `import_extensions`). Document it in `.env.example` alongside the existing extension list: + ``` + # BOR_IMPORT_EXTENSIONS=md,markdown,txt,yaml,yml,json,py,pdf + ``` + +3. `tests/unit/test_pdf.py` (new) — comprehensive unit tests: + - Create temp PDF fixtures using `fitz`: + - Single-page PDF with text → `extract_pdf_text` returns text with one page separator. + - Multi-page PDF (3 pages) → text with 3 page separators. + - PDF with no text (blank pages) → empty string. + - PDF with embedded images (draw shapes, add text) → `extract_pdf_images` returns the images. + - PDF with no embedded images → empty list. + - `_mime_from_image_info` (all known type codes → correct MIME; unknown → fallback). + - `_extract_pdf_title` (PDF with metadata → returns title; PDF without → returns fallback). + - Error handling: non-existent PDF → empty results. + - `tests/unit/test_chunker.py` (extend) — PDF-specific chunking tests: + - Single-page PDF → correct chunks with "Page 1:" prefix. + - Multi-page PDF → correct chunks with "Page N:" prefixes. + - Empty PDF → empty list. + - Large page → split by line. + +4. `tests/integration/test_importer.py` (extend) — integration tests: + - Seed a source with a PDF file (create a temp PDF fixture) → sync → the PDF document has extracted text, chunks, and embedding. + - A PDF with embedded images + `BOR_IMAGES=true` → embedded images become image documents. + - A PDF with embedded images + `BOR_IMAGES=false` → embedded images are skipped. + - A PDF with no text → document is created with empty content (no chunks, no embedding). + - A PDF referenced in a markdown file → the markdown doc stores the reference info (phase 125 enrichment path). + +5. `tests/e2e/test_pdf_support.py` (new) — isolated E2E suite: + - Module apps: one app with `BOR_IMAGES=true`, one with `BOR_IMAGES=false`. + - KB fixture: create a test PDF with text and an embedded image (using `fitz` in the test setup). Upload via the admin upload flow → sync. + - Verify: the PDF appears on the Sources page → open the document viewer → the extracted text renders → ask a question the PDF answers → the chat grounds on the PDF content. + - With `BOR_IMAGES=true`: the embedded image appears as a source chip (if retrieved). + - With `BOR_IMAGES=false`: no image chip, the PDF text is indexed normally. + - Run in isolation: `uv run pytest tests/e2e/test_pdf_support.py -v --no-cov`. + +6. Run the full test suite: `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL >90%; `uv run ruff check . && uv run pyright` clean. + +## Testing & Quality +- All new logic covered by unit tests (PDF parsing, chunking, title extraction). +- Integration tests cover the full import flow with seeded PDF data. +- E2E tests cover the real user path: upload → sync → view → ask. +- No regression in existing tests (run `test_image_documents.py` and `test_image_anchor_context.py` to verify). + +## Completion Criteria +- [ ] `uv run pytest tests/unit/test_pdf.py -v --no-cov` passes. +- [ ] `uv run pytest tests/integration/test_importer.py -v --no-cov` passes (extended). +- [ ] `uv run pytest tests/e2e/test_pdf_support.py -v --no-cov` passes in isolation. +- [ ] `uv run pytest` green; coverage TOTAL >90%; `ruff` + `pyright` clean. +- [ ] No regression in existing E2E suites. diff --git a/.agents/reports/125_image_anchor_context/125_image_anchor_context__00_phase.a1.err b/.agents/reports/125_image_anchor_context/125_image_anchor_context__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/125_image_anchor_context/125_image_anchor_context__00_phase.a1.md b/.agents/reports/125_image_anchor_context/125_image_anchor_context__00_phase.a1.md new file mode 100644 index 0000000..cc6dd29 --- /dev/null +++ b/.agents/reports/125_image_anchor_context/125_image_anchor_context__00_phase.a1.md @@ -0,0 +1,15 @@ +All completion criteria verified. The repository is left functional with all changes in the working tree (no commits made, per the harness protocol). + +## Final report — phase 125 `image_anchor_context` (verification pass) + +All 6 tasks were already complete; this pass verified every criterion and ran the full gates: + +- **Migration 0023** `documents.image_context` (TEXT nullable, reversible) — applied (`alembic current` = 0023 head); round-trip test (4 passed) exercises real upgrade → downgrade 0022 → upgrade. +- **Criterion 1** (referenced image enriched + context line in retrieval): integration `test_markdown_referenced_image_gets_context_and_enriched_chunks` + E2E `test_sync_enriches_the_image_doc_with_markdown_context`, `test_chat_answer_quotes_the_context_line` (wire prompt pinned: `` block leads with the one-line `Referenced in …: "alt"` line) — verified present and passing. +- **Criterion 2** (standalone image byte-identical): E2E `test_standalone_*` (NULL `image_context`, block body exactly the bare description, no `Referenced in` anywhere in the prompt) + unit `test_describe_image_without_context_is_byte_identical` — passing. +- **Criterion 3** (markdown-first: record stored on markdown doc, image picks up context later): integration `test_markdown_indexed_before_image_stores_record_and_image_picks_it_up` + post-index enrichment pass test (markdown file gone from disk) — passing. +- **Gates:** `uv run pytest` → 2938 passed, exit 0 · `uv run pytest --cov=app --cov-report=term-missing` → exit 0, TOTAL **99%** (>90%) · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors, 0 warnings. +- **Phase E2E in isolation:** `uv run pytest tests/e2e/test_image_anchor_context.py -v --no-cov` → 4 passed. +- **Mock-LLM change regression check:** neighboring image E2E suites green in isolation — `test_smoke` (3), `test_image_documents` (16), `test_chat_image_questions` (17), `test_answer_images` (17). +- No defects found; no code changes needed. No live-infrastructure changes (local dev DB only). No commit made (harness commits). +- Next pending phase: check `.agents/phases/todo/` after the harness moves this phase. diff --git a/.agents/reports/125_image_anchor_context/125_image_anchor_context__00_phase.a1.validate b/.agents/reports/125_image_anchor_context/125_image_anchor_context__00_phase.a1.validate new file mode 100644 index 0000000..fec8448 --- /dev/null +++ b/.agents/reports/125_image_anchor_context/125_image_anchor_context__00_phase.a1.validate @@ -0,0 +1,111 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 7%] +........................................................................ [ 9%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 31%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 44%] +........................................................................ [ 46%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 54%] +........................................................................ [ 56%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 63%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 71%] +........................................................................ [ 73%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 86%] +........................................................................ [ 88%] +........................................................................ [ 91%] +........................................................................ [ 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 262 1 99% +app/api/chat_images.py 50 0 100% +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 256 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 68 0 100% +app/models.py 132 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 374 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 432 10 98% +app/rag/llm.py 244 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 144 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 49 0 100% +app/schemas.py 400 0 100% +-------------------------------------------------- +TOTAL 4888 23 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 diff --git a/.agents/reports/125_image_anchor_context/125_image_anchor_context__01_migration_image_context.a1.err b/.agents/reports/125_image_anchor_context/125_image_anchor_context__01_migration_image_context.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/125_image_anchor_context/125_image_anchor_context__01_migration_image_context.a1.md b/.agents/reports/125_image_anchor_context/125_image_anchor_context__01_migration_image_context.a1.md new file mode 100644 index 0000000..6e861f7 --- /dev/null +++ b/.agents/reports/125_image_anchor_context/125_image_anchor_context__01_migration_image_context.a1.md @@ -0,0 +1,19 @@ +Task 01 complete — all gates green. + +**Implemented** +- `app/models.py`: `Document.image_context: Mapped[str | None]` (TEXT, nullable, no server default) added after `image_path` with the phase-125/A1 docstring. +- `alembic/versions/0023_documents_image_context.py`: additive, fully reversible migration (`upgrade` adds column, `downgrade` drops it), docstring in the 0022 pattern. +- `tests/unit/test_models.py`: 2 new tests — column contract (TEXT, nullable, no defaults) and ORM parse of `image_context` set / `None` / omitted. +- `tests/integration/test_migration_0023.py` (new, house `test_migration_00NN` pattern): 0022→0023 column contract + pre-phase row backfills NULL; ORM round-trip via fresh session; downgrade to 0022 drops column while 0022 columns survive; upgrade round-trip restores it. + +**Results** +- `uv run pytest --cov=app --cov-report=term-missing` → **2854 passed**, TOTAL **99%** (>90%) +- `uv run ruff check . && uv run pyright` → clean (0 errors) +- `uv run pytest tests/unit/test_models.py tests/integration/test_migration_0023.py --no-cov` → 17 passed +- Live dev DB verified at `0023`; column reads `('text', 'YES', None)` + +**Decisions/deviations** +- Task said "extend `tests/integration/test_migrations.py`" but no such file exists; followed the house per-migration pattern → `test_migration_0023.py`. +- No live-infra changes; only the repo's own dev Postgres via the existing alembic fixture (self-healing to head). No commit made (harness commits per phase). + +**Next pending task:** `02_extract_image_refs.md` (markdown image reference extraction) diff --git a/.agents/reports/125_image_anchor_context/125_image_anchor_context__01_migration_image_context.a1.validate b/.agents/reports/125_image_anchor_context/125_image_anchor_context__01_migration_image_context.a1.validate new file mode 100644 index 0000000..a894125 --- /dev/null +++ b/.agents/reports/125_image_anchor_context/125_image_anchor_context__01_migration_image_context.a1.validate @@ -0,0 +1,109 @@ +........................................................................ [ 2%] +........................................................................ [ 5%] +........................................................................ [ 7%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 25%] +........................................................................ [ 27%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 35%] +........................................................................ [ 37%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 50%] +........................................................................ [ 52%] +........................................................................ [ 55%] +........................................................................ [ 58%] +........................................................................ [ 60%] +........................................................................ [ 63%] +........................................................................ [ 65%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 73%] +........................................................................ [ 75%] +........................................................................ [ 78%] +........................................................................ [ 80%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 88%] +........................................................................ [ 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 262 1 99% +app/api/chat_images.py 50 0 100% +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 256 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 68 0 100% +app/models.py 132 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 374 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 106 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 48 0 100% +app/schemas.py 400 0 100% +-------------------------------------------------- +TOTAL 4670 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 diff --git a/.agents/reports/125_image_anchor_context/125_image_anchor_context__02_extract_image_refs.a1.err b/.agents/reports/125_image_anchor_context/125_image_anchor_context__02_extract_image_refs.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/125_image_anchor_context/125_image_anchor_context__02_extract_image_refs.a1.md b/.agents/reports/125_image_anchor_context/125_image_anchor_context__02_extract_image_refs.a1.md new file mode 100644 index 0000000..2e16510 --- /dev/null +++ b/.agents/reports/125_image_anchor_context/125_image_anchor_context__02_extract_image_refs.a1.md @@ -0,0 +1,10 @@ +**Task 02 complete — `extract_image_refs()`** + +- New pure module `app/rag/md_images.py`: `ImageRef` dataclass (path, alt_text, context, line_number), `CONTEXT_PARAGRAPHS = 2`, `extract_image_refs(content, context_paragraphs=2)` — regex `!\[([^\]]*)\]\(([^)]+)\)`, fence tracking + atomic-paragraph semantics mirroring the chunker (PLAN §5), N-before/N-after window (reference paragraph excluded), fail-loud `ValueError` on negative N. +- New `tests/unit/test_md_images.py` (19 tests): full task-02 matrix — single/multi refs (same line shares window), fence skip (backtick/tilde/4-char/unclosed/fence-as-atomic-context-unit), empty alt, start/end of file, heading refs, custom N=0/1/3/10, 1-based line numbers, spaces/nested-parens paths, nested-in-link, empty content. +- Tests: `uv run pytest tests/unit/test_md_images.py --no-cov` → 19 passed. +- Full suite: `uv run pytest --cov=app --cov-report=term-missing` → **2873 passed**, TOTAL coverage **99%** (>90% gate); `md_images.py` itself 100%. +- Lint/types: `uv run ruff check .` → clean; `uv run pyright` → 0 errors. +- Decisions: separate module (keeps importer lean); fences count as one atomic context paragraph (per "code fences are atomic"); paths/alt stripped of surrounding whitespace; task-01 artifacts (migration 0023, model, tests) verified intact — no prior defects found. +- No live changes needed (pure function); all changes left uncommitted in the working tree. +- Next pending task: `.agents/phases/todo/125_image_anchor_context/03_enrich_image_description.md`. diff --git a/.agents/reports/125_image_anchor_context/125_image_anchor_context__02_extract_image_refs.a1.validate b/.agents/reports/125_image_anchor_context/125_image_anchor_context__02_extract_image_refs.a1.validate new file mode 100644 index 0000000..3f9ef04 --- /dev/null +++ b/.agents/reports/125_image_anchor_context/125_image_anchor_context__02_extract_image_refs.a1.validate @@ -0,0 +1,110 @@ +........................................................................ [ 2%] +........................................................................ [ 5%] +........................................................................ [ 7%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 25%] +........................................................................ [ 27%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 35%] +........................................................................ [ 37%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 50%] +........................................................................ [ 52%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 65%] +........................................................................ [ 67%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 80%] +........................................................................ [ 82%] +........................................................................ [ 85%] +........................................................................ [ 87%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 95%] +........................................................................ [ 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 262 1 99% +app/api/chat_images.py 50 0 100% +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 256 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 68 0 100% +app/models.py 132 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 374 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/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 106 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 48 0 100% +app/schemas.py 400 0 100% +-------------------------------------------------- +TOTAL 4733 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 diff --git a/.agents/reports/125_image_anchor_context/125_image_anchor_context__03_enrich_image_description.a1.err b/.agents/reports/125_image_anchor_context/125_image_anchor_context__03_enrich_image_description.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/125_image_anchor_context/125_image_anchor_context__03_enrich_image_description.a1.md b/.agents/reports/125_image_anchor_context/125_image_anchor_context__03_enrich_image_description.a1.md new file mode 100644 index 0000000..369ad22 --- /dev/null +++ b/.agents/reports/125_image_anchor_context/125_image_anchor_context__03_enrich_image_description.a1.md @@ -0,0 +1,20 @@ +All complete and green. Final report: + +## Task 03 — Enrich image descriptions with context: DONE + +**Implemented** +- `app/rag/summarizer.py`: `describe_image` gains optional `context` — the exact task prefix is appended **after** the locked `DESCRIBE_PROMPT` (marker/`IMAGE_DESCRIPTION_MODE` mock key untouched; `None` → byte-identical pre-phase message). +- `app/rag/importer.py`: `_source_root_for`, `_ref_points_at` (reference resolved against the markdown dir; leading `/` = source root — same-name files in other dirs never match, per A1), `_markdown_image_context` (scans importable `.md`/`.markdown` files, builds the `Referenced in …` combined string, fail-soft on unreadable files); `_describe_or_skip` forwards `context`; `_index_image_file` scans **before** the vision call, stores `image_context` (new + update branches — a re-index re-scans and can drop it back to NULL), and enriches `content = context + "\n\n" + description` (summary + chunks mirror it). +- Seam-patched fakes in `tests/unit/test_image_documents.py` / `tests/integration/test_docs_api.py` updated to accept the new kwarg (mechanical consistency fix). +- Tests: `tests/unit/test_image_anchor_context.py` (new, 15) + `tests/integration/test_importer.py` (new, 2, real seam, real DB). + +**Results** +- `uv run pytest` → 2890 passed (2873 baseline + 17), exit 0 +- `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (>90% gate); summarizer 100%, md_images 100%, importer 95% +- `uv run ruff check .` → clean; `uv run pyright` → 0 errors + +**Notable decisions** +- Matched references by **path resolution** (not bare filename) — the task's "simplest approach" would false-positive on same-name images in other folders; resolution still handles `image.png` and `folder/image.png`. +- E2E mock + all phase-122/123/124 suites verified unaffected (no fixture markdown carries image references). + +**Next pending task:** `.agents/phases/todo/125_image_anchor_context/04_store_image_context.md` diff --git a/.agents/reports/125_image_anchor_context/125_image_anchor_context__03_enrich_image_description.a1.validate b/.agents/reports/125_image_anchor_context/125_image_anchor_context__03_enrich_image_description.a1.validate new file mode 100644 index 0000000..63794e1 --- /dev/null +++ b/.agents/reports/125_image_anchor_context/125_image_anchor_context__03_enrich_image_description.a1.validate @@ -0,0 +1,111 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 7%] +........................................................................ [ 9%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 37%] +........................................................................ [ 39%] +........................................................................ [ 42%] +........................................................................ [ 44%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 52%] +........................................................................ [ 54%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 67%] +........................................................................ [ 69%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 77%] +........................................................................ [ 79%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 87%] +........................................................................ [ 89%] +........................................................................ [ 92%] +........................................................................ [ 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 262 1 99% +app/api/chat_images.py 50 0 100% +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 256 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 68 0 100% +app/models.py 132 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 374 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 354 16 95% +app/rag/llm.py 244 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 106 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 49 0 100% +app/schemas.py 400 0 100% +-------------------------------------------------- +TOTAL 4772 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 diff --git a/.agents/reports/125_image_anchor_context/125_image_anchor_context__03_enrich_image_description.a2.err b/.agents/reports/125_image_anchor_context/125_image_anchor_context__03_enrich_image_description.a2.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/125_image_anchor_context/125_image_anchor_context__03_enrich_image_description.a2.md b/.agents/reports/125_image_anchor_context/125_image_anchor_context__03_enrich_image_description.a2.md new file mode 100644 index 0000000..7a6501a --- /dev/null +++ b/.agents/reports/125_image_anchor_context/125_image_anchor_context__03_enrich_image_description.a2.md @@ -0,0 +1,11 @@ +Everything is verified green. The previous rejection was a child-process termination issue, not a code defect — all task-03 changes were already in the working tree and now re-verified from scratch. + +## Final report + +- **Verified (no code change needed this run — prior changes intact):** `describe_image` context extension (appended after the locked `DESCRIBE_PROMPT`; `None` → byte-identical message); importer scan (`_source_root_for` / `_ref_points_at` path-resolution matching / `_markdown_image_context`); seam context forwarding; `_index_image_file` stores `image_context` + enriches `content` (summary/chunks mirror); new tests in `tests/unit/test_image_anchor_context.py` (15) and `tests/integration/test_importer.py` (2, real seam); seam-fake kwarg updates in 2 existing test files. +- `uv run pytest` → **2890 passed**, exit 0 +- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (gate >90%); summarizer 100%, md_images 100%, importer 95% +- `uv run ruff check .` → All checks passed +- `uv run pyright` → 0 errors, 0 warnings, exit 0 +- Notable: reference matching uses path resolution against the markdown dir (leading `/` = source root) instead of bare-filename matching, to avoid false-positives on same-name images in other folders; E2E mock and all phase-122/123/124 suites verified unaffected. +- Next pending task: `.agents/phases/todo/125_image_anchor_context/04_store_image_context.md` diff --git a/.agents/reports/125_image_anchor_context/125_image_anchor_context__03_enrich_image_description.a2.validate b/.agents/reports/125_image_anchor_context/125_image_anchor_context__03_enrich_image_description.a2.validate new file mode 100644 index 0000000..63794e1 --- /dev/null +++ b/.agents/reports/125_image_anchor_context/125_image_anchor_context__03_enrich_image_description.a2.validate @@ -0,0 +1,111 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 7%] +........................................................................ [ 9%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 37%] +........................................................................ [ 39%] +........................................................................ [ 42%] +........................................................................ [ 44%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 52%] +........................................................................ [ 54%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 67%] +........................................................................ [ 69%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 77%] +........................................................................ [ 79%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 87%] +........................................................................ [ 89%] +........................................................................ [ 92%] +........................................................................ [ 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 262 1 99% +app/api/chat_images.py 50 0 100% +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 256 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 68 0 100% +app/models.py 132 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 374 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 354 16 95% +app/rag/llm.py 244 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 106 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 49 0 100% +app/schemas.py 400 0 100% +-------------------------------------------------- +TOTAL 4772 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 diff --git a/.agents/reports/125_image_anchor_context/125_image_anchor_context__04_store_image_context.a1.err b/.agents/reports/125_image_anchor_context/125_image_anchor_context__04_store_image_context.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/125_image_anchor_context/125_image_anchor_context__04_store_image_context.a1.md b/.agents/reports/125_image_anchor_context/125_image_anchor_context__04_store_image_context.a1.md new file mode 100644 index 0000000..00bc227 --- /dev/null +++ b/.agents/reports/125_image_anchor_context/125_image_anchor_context__04_store_image_context.a1.md @@ -0,0 +1,16 @@ +Task 04 complete. All gates green. + +**Report** + +**Implemented** (`app/rag/importer.py`, phase 125 task 04): +- Markdown bookkeeping: `_index_file`'s markdown branch now records refs whose image has no doc yet on the md doc's `image_context` — `_missing_image_context()` + pure `_format_missing_image_refs()` (the `IMAGE_CONTEXT:` record: resolved path, line, alt, context-paragraph count) +- New post-index enrichment pass `_enrich_pending_images()` (per source in `import_sources`): enriches `image_context IS NULL` image docs from markdown doc's stored rows (record = index, stored `content` = source) — re-describe with context, `content = \n\n`, re-chunk + re-embed; idempotent, fail-soft, md record never cleared +- Shared `_resolve_ref_path()` (one resolution rule for scan/bookkeeping/pass) and `_replace_content_chunks()` (the token-cap chunk-embed loop, now one copy for text/image/pass) + +**Gates**: `uv run pytest` → 2903 passed · `uv run pytest --cov=app --cov-report=term-missing` → TOTAL 99% · `uv run ruff check . && uv run pyright` → clean (0 errors) · image E2E suites (`test_image_documents.py`, `test_chat_image_questions.py`, `test_answer_images.py`) pass in isolation + +**Tests**: extended `tests/unit/test_image_anchor_context.py` (+10: formatter/parser round-trip, resolution rule, bookkeeping, pass selectivity/fail-soft) and `tests/integration/test_importer.py` (+3: record-then-image sync pair, pass-only path with md file deleted from disk, existing-image → NULL record). Updated one task-03 unit test: same-run ordering (md sorted before image) now stores the record per task-04 (image still enriched via disk scan; record not cleared, A4). + +**Decisions**: existence check resolves ref paths (task-03 rule) instead of raw equality; refs escaping the source (`../…`) are not recorded; pass counts `updated` (correctly fires KB-change/version-bump gates). + +**Next pending task**: `05_retrieval_display.md` diff --git a/.agents/reports/125_image_anchor_context/125_image_anchor_context__04_store_image_context.a1.validate b/.agents/reports/125_image_anchor_context/125_image_anchor_context__04_store_image_context.a1.validate new file mode 100644 index 0000000..3d8bb0b --- /dev/null +++ b/.agents/reports/125_image_anchor_context/125_image_anchor_context__04_store_image_context.a1.validate @@ -0,0 +1,111 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 7%] +........................................................................ [ 9%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 37%] +........................................................................ [ 39%] +........................................................................ [ 42%] +........................................................................ [ 44%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 52%] +........................................................................ [ 54%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 69%] +........................................................................ [ 71%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 79%] +........................................................................ [ 81%] +........................................................................ [ 84%] +........................................................................ [ 86%] +........................................................................ [ 89%] +........................................................................ [ 91%] +........................................................................ [ 94%] +........................................................................ [ 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 262 1 99% +app/api/chat_images.py 50 0 100% +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 256 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 68 0 100% +app/models.py 132 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 374 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 432 10 98% +app/rag/llm.py 244 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 106 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 49 0 100% +app/schemas.py 400 0 100% +-------------------------------------------------- +TOTAL 4850 23 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 diff --git a/.agents/reports/125_image_anchor_context/125_image_anchor_context__05_retrieval_display.a1.err b/.agents/reports/125_image_anchor_context/125_image_anchor_context__05_retrieval_display.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/125_image_anchor_context/125_image_anchor_context__05_retrieval_display.a1.md b/.agents/reports/125_image_anchor_context/125_image_anchor_context__05_retrieval_display.a1.md new file mode 100644 index 0000000..124af24 --- /dev/null +++ b/.agents/reports/125_image_anchor_context/125_image_anchor_context__05_retrieval_display.a1.md @@ -0,0 +1,16 @@ +Task 05 is complete and everything is green. Final report: + +**Implemented (task 05 — retrieval display for enriched image docs):** +- `app/rag/prompts.py`: added `_extract_context_line()` (strict state-machine parser for the task-03/04 "Referenced in …" block shape, with `_IMAGE_CTX_HEADER_RE`/`_IMAGE_CTX_ALT_RE`), `_image_ctx_clause()`, and `_image_context_line()` (the `is_image` + non-NULL guard); `_document_body()` now prepends the one-line context (`Referenced in /: ""`, `;`-joined for multiple referencing files) before the summary/preview body. Graceful degradation: unparsable/`IMAGE_CONTEXT:` record values → `None` → pre-phase body. +- `tests/unit/test_prompts.py`: 16 new tests (parser contract incl. empty alt, two blocks, first-reference-only, inner quotes, spurious headers, pending-record → None; byte-identity for text docs, NULL-context image docs, and unparsable context; exact built-block shape; no-settings-call on the summary path). +- `tests/integration/test_chat_api.py`: 2 new end-to-end tests — grounded turn with an enriched suggested image doc → exact `` block with the context line before the description (Context: paragraphs never reach the prompt); NULL-context image doc → byte-identical pre-phase block. + +**Test / lint / coverage:** +- `uv run pytest --cov=app --cov-report=term-missing` → **2925 passed**, TOTAL **99%** (>90% gate); `app/rag/prompts.py` **100%** +- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings + +**Notable decisions:** +- The task sketch's assumed stored format (`- [img.png] (line 42): …`) didn't match what tasks 03/04 actually store; the parser targets the real `Referenced in /:` + `- Alt text: "…"` block format, keeping the full `source/` identity per the 00_phase design. +- The context line also leads the NULL-summary preview fallback (consistent "line before description" contract); dead defensive branch removed for 100% module coverage; no live-infrastructure changes. + +**Next pending task:** `.agents/phases/todo/125_image_anchor_context/06_image_anchor_tests.md` (phase finalization: dedicated unit/integration/E2E suites). diff --git a/.agents/reports/125_image_anchor_context/125_image_anchor_context__05_retrieval_display.a1.validate b/.agents/reports/125_image_anchor_context/125_image_anchor_context__05_retrieval_display.a1.validate new file mode 100644 index 0000000..0b9f944 --- /dev/null +++ b/.agents/reports/125_image_anchor_context/125_image_anchor_context__05_retrieval_display.a1.validate @@ -0,0 +1,111 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 7%] +........................................................................ [ 9%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 44%] +........................................................................ [ 46%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 54%] +........................................................................ [ 56%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 71%] +........................................................................ [ 73%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 86%] +........................................................................ [ 88%] +........................................................................ [ 91%] +........................................................................ [ 93%] +........................................................................ [ 96%] +........................................................................ [ 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 262 1 99% +app/api/chat_images.py 50 0 100% +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 256 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 68 0 100% +app/models.py 132 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 374 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 432 10 98% +app/rag/llm.py 244 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 144 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 49 0 100% +app/schemas.py 400 0 100% +-------------------------------------------------- +TOTAL 4888 23 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 diff --git a/.agents/reports/125_image_anchor_context/125_image_anchor_context__05_retrieval_display.a2.err b/.agents/reports/125_image_anchor_context/125_image_anchor_context__05_retrieval_display.a2.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/125_image_anchor_context/125_image_anchor_context__05_retrieval_display.a2.md b/.agents/reports/125_image_anchor_context/125_image_anchor_context__05_retrieval_display.a2.md new file mode 100644 index 0000000..c17faa5 --- /dev/null +++ b/.agents/reports/125_image_anchor_context/125_image_anchor_context__05_retrieval_display.a2.md @@ -0,0 +1,15 @@ +All gates green — the previous attempt's failure was process-level (no clean final report), not a code failure; the working tree was intact and re-verified. + +**Implemented (task 05 — retrieval display for enriched image docs):** +- `app/rag/prompts.py`: `_extract_context_line()` (strict parser for the task-03/04 `Referenced in /:` + `- Alt text: "…"` block shape) + `_image_ctx_clause()` + `_image_context_line()` (the `is_image` + non-NULL guard); `_document_body()` prepends the one-line context (`Referenced in /: ""`, `;`-joined for multiple referring files) before the summary/preview body; unparsable values → `None` → byte-identical pre-phase body (graceful degradation, A4). +- `tests/unit/test_prompts.py`: 16 new tests (parser contract, byte-identity for text docs / NULL-context & unparsable image docs, exact built-block shape, no-settings-call on summary path). +- `tests/integration/test_chat_api.py`: 2 new end-to-end tests — enriched suggested image doc → exact `` block with context line before the description (Context: paragraphs never reach the prompt); NULL-context image doc → byte-identical pre-phase block. + +**Test / lint / coverage (re-run this attempt):** +- `uv run pytest --cov=app --cov-report=term-missing` → **2925 passed**, TOTAL **99%** (>90% gate); `app/rag/prompts.py` **100%** +- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings +- Sanity: `uv run pytest tests/e2e/test_image_documents.py -v --no-cov` → 4 passed (E2E mock unaffected by the line) + +**Notable decisions:** parser targets the real stored block format from tasks 03/04 (not the sketch's assumed `IMAGE_CONTEXT` bullet shape, which rides text docs and parses to `None`); the line also leads the NULL-summary preview fallback for a consistent contract. + +**Next pending task:** `06_image_anchor_tests.md` (phase finalization: dedicated unit/integration/E2E suites). diff --git a/.agents/reports/125_image_anchor_context/125_image_anchor_context__05_retrieval_display.a2.validate b/.agents/reports/125_image_anchor_context/125_image_anchor_context__05_retrieval_display.a2.validate new file mode 100644 index 0000000..0b9f944 --- /dev/null +++ b/.agents/reports/125_image_anchor_context/125_image_anchor_context__05_retrieval_display.a2.validate @@ -0,0 +1,111 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 7%] +........................................................................ [ 9%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 44%] +........................................................................ [ 46%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 54%] +........................................................................ [ 56%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 71%] +........................................................................ [ 73%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 86%] +........................................................................ [ 88%] +........................................................................ [ 91%] +........................................................................ [ 93%] +........................................................................ [ 96%] +........................................................................ [ 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 262 1 99% +app/api/chat_images.py 50 0 100% +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 256 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 68 0 100% +app/models.py 132 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 374 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 432 10 98% +app/rag/llm.py 244 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 144 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 49 0 100% +app/schemas.py 400 0 100% +-------------------------------------------------- +TOTAL 4888 23 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 diff --git a/.agents/reports/125_image_anchor_context/125_image_anchor_context__06_image_anchor_tests.a1.err b/.agents/reports/125_image_anchor_context/125_image_anchor_context__06_image_anchor_tests.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/125_image_anchor_context/125_image_anchor_context__06_image_anchor_tests.a1.md b/.agents/reports/125_image_anchor_context/125_image_anchor_context__06_image_anchor_tests.a1.md new file mode 100644 index 0000000..ceb2185 --- /dev/null +++ b/.agents/reports/125_image_anchor_context/125_image_anchor_context__06_image_anchor_tests.a1.md @@ -0,0 +1,19 @@ +Task 06 complete — all phase-125 test surfaces green. + +**Implemented** +- `tests/e2e/test_image_anchor_context.py` (new, 4 tests, isolated): module app `BOR_IMAGES=true`/`BOR_ANSWER_IMAGES=false`; archive `docs/guide.md` → `docs/diagram.png` syncs to an enriched image doc (exact `Referenced in` block on `image_context`, content/summary = `\n\n`, content chunk packed-enriched + raw summary chunk, markdown record NULL); grounded marker question → rendered answer AND captured wire prompt prove the `` block leads with the one-line context line before the enriched summary; standalone-image phase (fresh KB, single PNG) → row and prompt block byte-identical pre-phase (NULL context, bare description, mock "no reference line" witness). +- `tests/e2e/mock_llm.py`: new `CONTEXT_LINE_TRIGGER` ("quote the image reference", verified disjoint) + `_seed_context_line` prompt parser (quotes the seeded image doc block's first line when it is the `Referenced in` rendering) + module-docstring entry. +- `tests/unit/test_md_images.py`: added the missing "nested brackets in alt text" edge case (tolerated `[` in alt; `]` in alt is the locked regex's accepted miss). + +**Results (all commands exit 0)** +- `uv run pytest tests/unit/test_md_images.py / tests/unit/test_image_anchor_context.py / tests/integration/test_importer.py --no-cov` → 20 + 24 + 5 passed (chat-API retrieval-display pins were extended in task 05: 2 tests). +- `uv run pytest tests/e2e/test_image_anchor_context.py -v --no-cov` → 4 passed in isolation. +- `uv run pytest` → 2926 passed; `--cov=app` TOTAL **99%** (>90% gate). +- `uv run ruff check .` clean; `uv run pyright` 0 errors. +- Regressions: `test_image_documents.py` 4 passed, `test_answer_images.py` 5 passed. + +**Notes** +- Unit/integration test files were already authored by tasks 03–05 (in the working tree); task 06's remaining gap was the E2E suite + mock extension + one unit edge case — all filled. +- No live-infrastructure changes; DB left clean (0 docs/chunks). Nothing committed, per harness protocol. + +Next pending: phase 125 is now complete (task 06 was its last); next phase in todo is `126_pdf_support` (task `01_pdf_dependency.md`). diff --git a/.agents/reports/125_image_anchor_context/125_image_anchor_context__06_image_anchor_tests.a1.validate b/.agents/reports/125_image_anchor_context/125_image_anchor_context__06_image_anchor_tests.a1.validate new file mode 100644 index 0000000..fec8448 --- /dev/null +++ b/.agents/reports/125_image_anchor_context/125_image_anchor_context__06_image_anchor_tests.a1.validate @@ -0,0 +1,111 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 7%] +........................................................................ [ 9%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 31%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 44%] +........................................................................ [ 46%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 54%] +........................................................................ [ 56%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 63%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 71%] +........................................................................ [ 73%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 86%] +........................................................................ [ 88%] +........................................................................ [ 91%] +........................................................................ [ 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 262 1 99% +app/api/chat_images.py 50 0 100% +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 256 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 68 0 100% +app/models.py 132 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 374 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 432 10 98% +app/rag/llm.py 244 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 144 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 49 0 100% +app/schemas.py 400 0 100% +-------------------------------------------------- +TOTAL 4888 23 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 diff --git a/.agents/reports/125_image_anchor_context/125_image_anchor_context__06_image_anchor_tests.a2.err b/.agents/reports/125_image_anchor_context/125_image_anchor_context__06_image_anchor_tests.a2.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/125_image_anchor_context/125_image_anchor_context__06_image_anchor_tests.a2.md b/.agents/reports/125_image_anchor_context/125_image_anchor_context__06_image_anchor_tests.a2.md new file mode 100644 index 0000000..a06cbfb --- /dev/null +++ b/.agents/reports/125_image_anchor_context/125_image_anchor_context__06_image_anchor_tests.a2.md @@ -0,0 +1,17 @@ +**What was implemented (task 06 — all in working tree, uncommitted)** +- `tests/e2e/test_image_anchor_context.py` (new, 4 tests): module app `BOR_IMAGES=true`; upload→sync of `docs/guide.md` + `docs/diagram.png` → image doc enriched (exact `Referenced in` block on `image_context`, content/summary = `\n\n`, packed-enriched content chunk + raw summary chunk, markdown record NULL); grounded marker question → answer + captured wire prompt prove the `` block leads with the one-line context line; standalone-image phase → row and prompt block byte-identical pre-phase. +- `tests/e2e/mock_llm.py`: new `CONTEXT_LINE_TRIGGER` + `_seed_context_line` parser (quotes the seeded image block's first line when it is the `Referenced in` rendering; docstring entry). +- `tests/unit/test_md_images.py`: added the missing nested-brackets-in-alt edge case. +- Unit/integration files for the pipeline were already authored by tasks 03–05 (24 + 5 + 2 chat-API tests). + +**Results (all exit 0)** +- `uv run pytest tests/e2e/test_image_anchor_context.py -v --no-cov` → 4 passed in isolation +- Regressions: `test_image_documents.py` 4 passed; `test_answer_images.py` 5 passed +- `uv run pytest --cov=app --cov-report=term-missing` → 2926 passed, TOTAL **99%** (>90%) +- `uv run ruff check .` clean; `uv run pyright` → 0 errors, 0 warnings + +**Notes** +- Previous rejection was infra-only ("no clean final report") — no code defect; re-validated the identical tree fully green. DB left clean (0 docs/chunks), no stray processes. +- No commits made (harness owns the atomic commit). + +Next pending: `126_pdf_support` (`01_pdf_dependency.md`); phase 125 is complete. diff --git a/.agents/reports/125_image_anchor_context/125_image_anchor_context__06_image_anchor_tests.a2.validate b/.agents/reports/125_image_anchor_context/125_image_anchor_context__06_image_anchor_tests.a2.validate new file mode 100644 index 0000000..fec8448 --- /dev/null +++ b/.agents/reports/125_image_anchor_context/125_image_anchor_context__06_image_anchor_tests.a2.validate @@ -0,0 +1,111 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 7%] +........................................................................ [ 9%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 31%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 44%] +........................................................................ [ 46%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 54%] +........................................................................ [ 56%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 63%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 71%] +........................................................................ [ 73%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 86%] +........................................................................ [ 88%] +........................................................................ [ 91%] +........................................................................ [ 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 262 1 99% +app/api/chat_images.py 50 0 100% +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 256 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 68 0 100% +app/models.py 132 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 374 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 432 10 98% +app/rag/llm.py 244 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 144 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 49 0 100% +app/schemas.py 400 0 100% +-------------------------------------------------- +TOTAL 4888 23 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 diff --git a/alembic/versions/0023_documents_image_context.py b/alembic/versions/0023_documents_image_context.py new file mode 100644 index 0000000..1189fc1 --- /dev/null +++ b/alembic/versions/0023_documents_image_context.py @@ -0,0 +1,43 @@ +"""documents.image_context: markdown anchor context for image docs +(phase 125, task 01) + +Revision ID: 0023 +Revises: 0022 +Create Date: 2026-09-26 + +Phase 125 (image-anchor context — task 01, storage only): + +* ``documents.image_context`` — TEXT NULLABLE: the surrounding text + extracted from the markdown file that references the image + (LOCKED A1) — the ``![alt text](image.png)`` alt text plus N + paragraphs before and after the reference. This is what makes the + image retrievable not just by what it shows but by WHY it exists in + the documentation (the phase-125 objective). + +The column is additive and NULL for every pre-phase row: standalone +images (no markdown reference), images referenced only from +non-markdown formats, and all pre-phase-125 rows simply read NULL — +no backfill (A4 keeps their behaviour byte-identical). One fully +reversible migration (A13): ``downgrade`` drops the column and leaves +0022's schema byte-identical. +""" +from __future__ import annotations + +import sqlalchemy as sa + +from alembic import op + +revision = "0023" +down_revision = "0022" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("documents", sa.Column("image_context", sa.Text(), nullable=True)) + + +def downgrade() -> None: + # The column is the only 0023 artefact — dropping it leaves 0022's + # schema byte-identical (A13, fully reversible). + op.drop_column("documents", "image_context") diff --git a/app/models.py b/app/models.py index 3a7f7d8..76ce4bc 100644 --- a/app/models.py +++ b/app/models.py @@ -162,6 +162,12 @@ class Document(Base): #: a content change (the stale copy) and on prune. NULL for text #: documents. image_path: Mapped[str | None] = mapped_column(Text, default=None) + #: Surrounding text from the markdown file that references this image + #: (phase 125, LOCKED A1): alt text + N paragraphs before/after the + #: reference. NULL for standalone images and pre-phase rows. The + #: embedding model embeds this alongside the vision description when + #: the image is enriched (task 03). + image_context: Mapped[str | None] = mapped_column(Text, default=None) chunks: Mapped[list[Chunk]] = relationship( back_populates="document", cascade="all, delete-orphan" diff --git a/app/rag/importer.py b/app/rag/importer.py index 1ff8d40..b66b135 100644 --- a/app/rag/importer.py +++ b/app/rag/importer.py @@ -85,6 +85,40 @@ summary (stored verbatim, no ``lite`` call, no pointer line), so the ``is_summary`` position −1 chunk mirrors ``Document.summary``, which equals ``Document.content``. +Image-anchor context (phase 125, task 03, LOCKED A1/A2/A3): BEFORE the +description, the same source's importable markdown files are scanned for +``![alt](path)`` references that name the image (the task-02 +:func:`app.rag.md_images.extract_image_refs`); when found, the combined +context string (``Referenced in /:`` + alt text + surrounding +paragraphs, see :func:`_markdown_image_context`) is (a) passed to the +vision call as ``context`` (appended AFTER the locked describe prompt — +the model learns what the image is for) and (b) stored on the doc's +``image_context`` (the retrieval display's context line, task 05), and +the stored ``content`` is ``\n\n`` (context first +for retrieval; ``summary`` mirrors it). A standalone image (no markdown +reference) gets ``image_context = NULL`` and the bare description — +byte-identical to pre-phase-125. + +Markdown-FIRST image context (phase 125, task 04, LOCKED A1): a +markdown file (re)indexed while a reference's image has NO document in +the source yet records those references on ITS OWN ``image_context`` +(the ``IMAGE_CONTEXT:`` record — :func:`_format_missing_image_refs`; +references are resolved exactly as the task-03 scan, and a reference +escaping the source is not recorded). A reference that DOES name an +existing image doc records nothing (the task-03 path owns that case — +enrichment at image-index time — and the markdown doc's field stays +NULL). After ALL of a source's files are indexed, the post-index +enrichment pass (:func:`_enrich_pending_images`) enriches the still- +``image_context IS NULL`` image docs that a markdown doc's record +references — the case where the markdown was indexed first and its +on-disk file is gone by the time the image is indexed (the task-03 +disk scan cannot see it) — with the task-03 mechanics (re-describe +with context, ``content = \n\n``, the context +stored, re-chunk + re-embed). The pass is idempotent (an image doc +with ``image_context`` set is never touched; a markdown doc's record +is NOT cleared on success — it is the retrieval display's record +also, task 05). + ``import_sources`` accepts an optional per-file ``progress`` callback (phase 64, task 01) reporting the file being processed right now. """ @@ -92,11 +126,13 @@ from __future__ import annotations import hashlib import logging +import posixpath +import re import uuid -from collections.abc import Callable +from collections.abc import Callable, Sequence from dataclasses import dataclass, field from datetime import UTC, datetime -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import Any, Protocol from sqlalchemy import select @@ -108,6 +144,7 @@ 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.md_images import ImageRef, extract_image_refs from app.rag.summarizer import ( IMAGE_FALLBACK_MIME, IMAGE_MIMES, @@ -122,6 +159,12 @@ EXCLUDED_DIRS: frozenset[str] = frozenset( {".venv", "node_modules", ".git", "__pycache__", ".pytest_cache", "dist", "build"} ) +#: Markdown extensions that can carry ``![alt](path)`` image references +#: (phase 125, task 03) — the two markdown tokens of the A9 family. The +#: scan (see :func:`_markdown_image_context`) only reads these, and only +#: among the source's importable files. +_MARKDOWN_EXTENSIONS: frozenset[str] = frozenset({".md", ".markdown"}) + class Embedder(Protocol): """Everything the importer needs from the LLM client (duck-typed for tests).""" @@ -409,6 +452,18 @@ async def import_sources( 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). + + Markdown-FIRST image context (phase 125, task 04): after ALL of a + source's files are indexed, the post-index enrichment pass + (:func:`_enrich_pending_images`) runs for that source — it enriches + the still-unenriched image docs (``image_context IS NULL``) that a + markdown doc's ``IMAGE_CONTEXT:`` record references (the case where + the markdown was indexed first and its on-disk file is gone by the + time the image is indexed — the task-03 disk scan cannot see it). + The pass is driven by committed doc rows, so it is safe under + ``limit`` too (it deletes nothing), and a per-image failure + (missing copy, failed re-description, embed error) keeps that image + as-is and moves on. """ if limit is not None and limit <= 0: raise ValueError("limit must be >= 1") @@ -496,6 +551,14 @@ async def import_sources( session.rollback() summary.errors += 1 logger.error("import: error source=%s path=%s — %s", source, rel, e) + # Phase 125 (task 04): the post-index enrichment pass — the + # markdown-FIRST case (a markdown indexed while its + # referenced image had no document yet recorded the + # reference on its doc row; the image was just indexed, but + # the task-03 disk scan could not see the reference — the + # markdown file is no longer on disk). Idempotent + + # fail-soft (see the function). + await _enrich_pending_images(session, source=source, llm=llm, summary=summary) if prune: if limit is not None: logger.warning("import: --prune ignored because --limit was given") @@ -511,6 +574,50 @@ async def import_sources( session.close() +async def _replace_content_chunks( + session: Session, *, doc: Document, llm: Embedder, content: str, rel: str +) -> int: + """Phase 1+2 on *content*: chunk it (format-aware, PLAN §5), replace + the doc's content chunk rows (embeddings NULL), and embed the new + chunks in batches — returning the chunk count. The caller owns the + transaction (it commits, or rolls back on the :class:`EmbeddingError` + that propagates when the token-cap retry gives up). + + The endpoint token-cap retry (halve this file's chunk target, floor + 400 — URL-dense paragraphs tokenize at ~1.1 chars/token; the global + policy stays intact for the rest of the KB) is the house rule, shared + verbatim by the text path (:func:`_index_file`), the image path + (task 03), and the task-04 enrichment pass — one copy, three call + sites. + """ + settings = llm.settings + target = max(400, settings.chunk_target_chars) + chunks_text: list[str] = [] + 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 + return len(chunks_text) + + async def _index_file( session: Session, *, @@ -627,42 +734,34 @@ async def _index_file( session.flush() # guarantees doc.id even for brand-new rows + # Phase 125 (task 04, LOCKED A1): a markdown file's image-reference + # bookkeeping — references whose image has NO document yet in this + # source are recorded on the doc's ``image_context`` (the + # ``IMAGE_CONTEXT:`` record) so the post-index enrichment pass picks + # them up when the image is later indexed; references that DO name an + # existing image doc record nothing (the task-03 path owns that case + # — the field stays NULL). The record rides the file's own + # transaction (committed by the ``session.commit()`` below). + if full_path.suffix.lower() in _MARKDOWN_EXTENSIONS: + doc.image_context = _missing_image_context( + session, source=source, md_rel=rel, content=content + ) + # Phase 1+2 — chunk, replace the chunk rows (embeddings NULL), embed, - # and commit the whole file atomically. If the endpoint rejects a chunk - # as over its input token cap (URL-dense paragraphs tokenize at ~1.1 - # chars/token), halve this file's chunk target and retry — the global - # policy stays intact for the rest of the KB. - 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 + # and commit the whole file atomically (the endpoint token-cap retry + # lives in the shared helper — the global policy stays intact for the + # rest of the KB). + chunks_n = await _replace_content_chunks( + session, doc=doc, llm=llm, content=content, rel=rel + ) 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)) + summary.chunks += chunks_n + logger.info("import: %s source=%s path=%s chunks=%d", verb, source, rel, chunks_n) # Phase 30; phase 118 (A2, 2026-09-15): EVERY new/changed document # gets a ``lite``-model summary — markdown included. Phase 30's @@ -756,8 +855,409 @@ async def _store_summary( logger.error("import: summary failed source=%s path=%s — %s", source, rel, e) +def _source_root_for(full_path: Path, rel: str) -> Path: + """The source root of one walked file (phase 125, task 03). + + The walk's invariant is ``full_path == root / rel`` (``rel`` is the + source-relative POSIX path, ``full_path`` the absolute path), so the + root is *full_path*'s parent walked UP by the number of DIRECTORY + components of *rel* (a top-level file has none — its own directory + IS the root). + """ + root = full_path.parent + for _ in PurePosixPath(rel).parts[:-1]: + root = root.parent + return root + + +def _resolve_ref_path(ref_path: str, md_rel: str) -> str | None: + """One ``![alt](path)`` reference path → the image's source-relative + POSIX path, or ``None`` when the reference names no in-source file + (phase 125 — the single resolution rule shared by the task-03 + context scan, the task-04 markdown bookkeeping, and the task-04 + post-index enrichment pass). + + A leading ``/`` = the source root (a root-absolute doc-style + reference); otherwise the path is resolved against the markdown + file's directory (``md_rel`` is the markdown's source-relative + POSIX path — its ``dirname``, so a top-level markdown joins against + the root). The result is normalized; a normalized path that still + escapes (``..``-prefixed — e.g. ``../../other/x.png``) or names no + file (``.``) matches no image document in this source, ever. + """ + if ref_path.startswith("/"): + resolved = ref_path.lstrip("/") + else: + resolved = posixpath.join(posixpath.dirname(md_rel), ref_path) + normalized = posixpath.normpath(resolved) + if normalized in ("", ".") or normalized.startswith(".."): + return None + return normalized + + +def _ref_points_at(ref: ImageRef, md_rel: str, image_rel: str) -> bool: + """Does *ref* point at the image at *image_rel* (phase 125, task 03)? + + The reference path is resolved against the markdown file's directory + (``md_rel`` is the markdown's source-relative POSIX path) — or, + when it starts with ``/``, against the SOURCE ROOT (a root-absolute + doc-style reference) — then normalized and compared for equality + with the image's source-relative path. Handles both the bare + relative reference (``pic.png`` next to the markdown) and the + folder-carrying one (``folder/pic.png`` from the root); a reference + to a different file that shares the filename is NOT a match. + """ + resolved = _resolve_ref_path(ref.path, md_rel) + return resolved is not None and resolved == image_rel + + +def _markdown_image_context( + root: Path, + llm: Embedder, + *, + source: str, + image_rel: str, +) -> str | None: + """The combined markdown reference context for one image (phase 125, + task 03, LOCKED A1/A2) — or ``None`` when no markdown file in the + source references it (the standalone-image case: the whole phase-125 + machinery is a no-op, byte-identical to pre-phase). + + Scans the source's importable MARKDOWN files (``.md``/``.markdown`` + under the source's import-extension set — hidden/excluded-dir rules + and the extension filter apply exactly as to the walk) and keeps + every :func:`~app.rag.md_images.extract_image_refs` reference that + POINTS AT this image (:func:`_md_ref_block` resolves each reference + via :func:`_resolve_ref_path` — ``pic.png`` from ``guide.md`` → + ``pic.png``; ``../pic.png`` from ``sub/guide.md`` → ``pic.png``; + ``folder/pic.png`` from ``guide.md`` → ``folder/pic.png``), one + block per referencing markdown file in the walk's sorted order. + The result is both the *context* argument of the vision call (the + model learns WHAT the image is for) and the stored + ``image_context`` (the retrieval display's context line, task 05). + """ + blocks: list[str] = [] + for md_path in iter_importable_files(root, llm.settings.import_extension_set): + if md_path.suffix.lower() not in _MARKDOWN_EXTENSIONS: + continue + try: + content = md_path.read_text(encoding="utf-8", errors="replace").replace("\x00", "") + except OSError: + continue # unreadable source file — no context from it + block = _md_ref_block(source, md_path.relative_to(root).as_posix(), content, image_rel) + if block is not None: + blocks.append(block) + return "\n\n".join(blocks) if blocks else None + + +def _md_ref_block(source: str, md_rel: str, content: str, image_rel: str) -> str | None: + """One ``Referenced in /:`` block for one markdown's + content (phase 125) — the shared builder of the task-03 disk scan + (:func:`_markdown_image_context`) and the task-04 pass + (:func:`_pending_image_context`). + + Keeps every image reference in *content* that POINTS AT *image_rel* + (the resolution rule: :func:`_resolve_ref_path` — a reference to a + different file that merely shares the filename never matches, + LOCKED A1), in line order, rendered as:: + + Referenced in /: + - Alt text: "" + Context: + + (the ``Context:`` line is omitted for a reference with no + surrounding paragraphs). ``None`` when *content* has no reference + to this image. + """ + refs = [ref for ref in extract_image_refs(content) if _ref_points_at(ref, md_rel, image_rel)] + if not refs: + return None + lines = [f"Referenced in {source}/{md_rel}:"] + for ref in refs: + lines.append(f'- Alt text: "{ref.alt_text}"') + if ref.context: + lines.append(f" Context: {ref.context}") + return "\n".join(lines) + + +#: Header line of a markdown doc's pending-image record (phase 125, +#: task 04, LOCKED A1) — the stored ``image_context`` of a markdown +#: file that references images with no document yet in the source. +#: :func:`_parse_missing_image_refs` is its parse target (the record is +#: the post-index enrichment pass's reference index; the header is +#: ignored by the parser — the bullets are the data). +IMAGE_CONTEXT_HEADER = "IMAGE_CONTEXT:" + + +def _format_missing_image_refs(refs: Sequence[ImageRef], source: str) -> str: + """One markdown's missing-image references → the ``IMAGE_CONTEXT:`` + record stored on the markdown doc's ``image_context`` (phase 125, + task 04, LOCKED A1) — the durable record that "this markdown + referenced these images, which have no document yet in this + source": + + .. code-block:: none + + IMAGE_CONTEXT: + - [image.png] (line 42): "Alt text here" — 2 paragraphs of context + - [diagram.jpg] (line 100): "" — 1 paragraph of context + + One bullet per reference, in reference order: the RESOLVED + source-relative image path in the brackets (the caller resolves via + :func:`_resolve_ref_path` — the post-index pass matches it against + the image doc's ``path``, so the resolution must agree with the + task-03 scan's), the 1-based line number, the alt text between the + quotes (``""`` for ``![](path)``), and the count of the reference's + context paragraphs (the task-02 window — the paragraphs themselves + live on the markdown doc's ``content``; the pass re-extracts them: + the record is the index, the content is the source). The shape is + exactly what :func:`_parse_missing_image_refs` parses back (the + "task 04b" parse target). *source* stays on the signature so the + call site reads like the record's home (the record rides a doc row + whose source is already known — display context, not data). + + Empty *refs* → ``""`` (the caller stores ``None`` for "no record" + — an empty record would be a non-NULL empty string, and the pass's + ``IS NOT NULL`` gate must see "no record" as NULL, not ``""``). + """ + if not refs: + return "" + lines = [IMAGE_CONTEXT_HEADER] + for ref in refs: + n = len(ref.context.split("\n\n")) if ref.context else 0 + unit = "paragraph" if n == 1 else "paragraphs" + lines.append( + f'- [{ref.path}] (line {ref.line_number}): "{ref.alt_text}"' + f" — {n} {unit} of context" + ) + return "\n".join(lines) + + +#: One bullet of the ``IMAGE_CONTEXT:`` record — the exact output shape +#: of :func:`_format_missing_image_refs`: the resolved image path in +#: the brackets, the 1-based line number, the alt text between the +#: quotes, the context-paragraph count. +_MISSING_REF_LINE_RE = re.compile( + r'^- \[(?P[^\]]+)\] \(line (?P\d+)\): "(?P.*)" — ' + r"(?P\d+) paragraph(?:s)? of context$" +) + + +def _parse_missing_image_refs(text: str) -> list[tuple[str, int, str]]: + """Parse a markdown doc's ``image_context`` record back into + ``(image path, line number, alt text)`` triples (phase 125, task 04 + — the enrichment pass's reference index). + + Only bullets in :func:`_format_missing_image_refs`' exact shape + parse (the greedy alt field still pins an alt that itself contains + ``"``, as long as it does not contain the ``" — N paragraph…"`` + tail): the header line, an owner-edited value, or a future shape + yields no references and the pass simply finds no match (fail-soft + — a malformed record must never raise into the sync). + """ + refs: list[tuple[str, int, str]] = [] + for line in text.splitlines(): + m = _MISSING_REF_LINE_RE.match(line) + if m is not None: + refs.append((m.group("path"), int(m.group("line")), m.group("alt"))) + return refs + + +def _missing_image_context( + session: Session, *, source: str, md_rel: str, content: str +) -> str | None: + """The markdown doc's ``image_context`` for one (re)indexed markdown + file (phase 125, task 04, LOCKED A1): the ``IMAGE_CONTEXT:`` record + of every ``![alt](path)`` reference whose image has NO ``is_image`` + document yet in this source, or ``None`` when every in-source + reference names an existing image doc (the task-03 path owns those + — enrichment at image-index time — and the field stays NULL) or the + file has no references at all. + + Each reference is resolved exactly as the task-03 scan + (:func:`_resolve_ref_path`); a reference that escapes the source + (e.g. ``../../other/x.png``) names no in-source image document, ever, + and is not recorded (the field is the pending-enrichment index, not + a general reference dump). A re-indexed markdown recomputes the + field from its CURRENT content — the image now exists (or the + reference was dropped) → the field drops back to ``None``. + """ + refs = extract_image_refs(content) + if not refs: + return None + existing = set( + session.scalars( + select(Document.path).where( + Document.source == source, Document.is_image.is_(True) + ) + ).all() + ) + missing: list[ImageRef] = [] + for ref in refs: + resolved = _resolve_ref_path(ref.path, md_rel) + if resolved is None or resolved in existing: + continue + missing.append( + ImageRef( + path=resolved, + alt_text=ref.alt_text, + context=ref.context, + line_number=ref.line_number, + ) + ) + if not missing: + return None + return _format_missing_image_refs(missing, source) + + +def _pending_image_context( + source: str, image_rel: str, md_docs: Sequence[Document] +) -> str | None: + """The task-04 pass's context for one pending image doc: the SAME + combined string the task-03 scan builds (``Referenced in …`` blocks + joined with a blank line) — but sourced from the markdown DOC ROWS + instead of the on-disk files: a markdown doc contributes only when + its stored ``image_context`` record lists the image's source-relative + path (the durable "this markdown referenced this image" record, + work 1), and the reference (alt text + surrounding paragraphs) is + re-extracted from the doc's stored ``content`` (the full text is on + the row — the record is the index, the content is the source). + ``None`` when no markdown doc references the image. + """ + blocks: list[str] = [] + for md in md_docs: + listed = _parse_missing_image_refs(md.image_context or "") + if image_rel not in {path for path, _, _ in listed}: + continue + block = _md_ref_block(source, md.path, md.content, image_rel) + if block is not None: + blocks.append(block) + return "\n\n".join(blocks) if blocks else None + + +async def _enrich_pending_images( + session: Session, *, source: str, llm: Embedder, summary: ImportSummary +) -> None: + """The phase-125 task-04 post-index enrichment pass — once per + source, after ALL of its files are indexed (:func:`import_sources`). + + Closes the markdown-FIRST case: a markdown file indexed while its + referenced image had no document yet stores the reference on its + ``image_context`` (work 1 — the ``IMAGE_CONTEXT:`` record). When the + image is later indexed but the task-03 disk scan could not see the + reference (the markdown file no longer on disk — a deleted / moved + source file whose doc row survives a ``prune=False`` run — or any + other disk-vs-DB drift), the image doc is still ``image_context IS + NULL`` and this pass enriches it from the markdown doc's STORED rows + (see :func:`_pending_image_context`). + + The enrichment is the task-03 mechanics on the existing doc: a fresh + context-aware description (``describe_image`` with the context — the + description remains the primary content, LOCKED A3), ``content`` = + ``\\n\\n`` (and so the ``summary`` — the + phase-122 ``summary = content`` convention), the context stored on + ``image_context``, and the re-chunked + re-embedded pipeline (the + re-describe reads the image's PERSISTENT copy — the source file may + be the one that is gone). + + Idempotent by contract (ASSUMPTION 4): an image doc with + ``image_context`` SET is never touched (already enriched — the + task-03 path or an earlier pass), and a markdown doc's + ``image_context`` is NOT cleared on success (it stays the retrieval + display's record, task 05). Fail-soft: a missing persistent copy, a + failed/empty re-description, or an embed error keeps the image doc + as-is (warned / counted) and the next sync retries it; one bad image + never blocks the others. A success counts ``updated`` + ``chunks`` + (the doc's content really changed — the caller's KB-change gate and + the sources-version bump both fire on ``added + updated``, which is + correct here) and a dedicated ``image-context-enriched`` log line + (PLAN §9). + """ + pending = session.scalars( + select(Document).where( + Document.source == source, + Document.is_image.is_(True), + Document.image_context.is_(None), + ) + ).all() + if not pending: + return + md_docs = session.scalars( + select(Document).where( + Document.source == source, + Document.is_image.is_(False), + Document.image_context.is_not(None), + ) + ).all() + if not md_docs: + return + for image_doc in pending: + context = _pending_image_context(source, image_doc.path, md_docs) + if context is None: + continue # no markdown doc records a reference to this image + if not image_doc.image_path or not Path(image_doc.image_path).is_file(): + # No persistent bytes to re-describe — keep the doc as-is + # (the next sync retries; a copy gone for good leaves an + # unenrichable doc, and the warning is the signal). + summary.images_failed += 1 + logger.warning( + "import: image-context enrichment skipped (no persistent copy) " + "source=%s path=%s", + source, image_doc.path, + ) + continue + copy_path = Path(image_doc.image_path) + description = await _describe_or_skip( + llm, + data=copy_path.read_bytes(), + source=source, + rel=image_doc.path, + full_path=copy_path, + context=context, + ) + if description is None: + summary.images_failed += 1 + logger.warning( + "import: image re-description failed source=%s path=%s", + source, image_doc.path, + ) + continue + try: + image_doc.image_context = context + image_doc.content = f"{context}\n\n{description}" + chunks_n = await _replace_content_chunks( + session, doc=image_doc, llm=llm, + content=image_doc.content, rel=image_doc.path, + ) + session.commit() + except EmbeddingError as e: + session.rollback() + summary.errors += 1 + logger.error( + "import: image-context enrichment failed source=%s path=%s — %s", + source, image_doc.path, e, + ) + continue + summary.updated += 1 + summary.chunks += chunks_n + logger.info( + "import: image-context-enriched source=%s path=%s chunks=%d", + source, image_doc.path, chunks_n, + ) + await _store_summary( + session, doc=image_doc, source=source, rel=image_doc.path, + content=image_doc.content, llm=llm, summary=summary, + ) + + async def _describe_or_skip( - llm: Embedder, *, data: bytes, source: str, rel: str, full_path: Path + llm: Embedder, + *, + data: bytes, + source: str, + rel: str, + full_path: Path, + context: str | None = None, ) -> str | None: """Phase 122 — the SINGLE seam for the image description (task 03). @@ -777,9 +1277,17 @@ async def _describe_or_skip( 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. + + ``context`` (phase 125, task 03) is the optional markdown reference + context (:func:`_markdown_image_context`), forwarded verbatim to + :func:`app.rag.summarizer.describe_image` — appended AFTER the + locked describe prompt so the vision model can write a + context-aware description (LOCKED A3: the description remains the + primary content). ``None`` (standalone image) keeps the message + byte-identical to pre-phase-125. """ mime = IMAGE_MIMES.get(full_path.suffix.lower(), IMAGE_FALLBACK_MIME) - return await describe_image(llm, data=data, mime=mime) + return await describe_image(llm, data=data, mime=mime, context=context) def _delete_image_copy(image_path: str | None) -> None: @@ -833,6 +1341,20 @@ async def _index_image_file( Fail-soft (LOCKED A3): a failed/empty description SKIPS the doc entirely (no row, no copy) — ``summary.images_failed`` + a warning, the sync continues. + + Phase 125 (task 03, LOCKED A1/A2/A3): a NEW/CHANGED image first + scans the same source's markdown files for references to it (see + :func:`_markdown_image_context`). When one or more exist, the + combined context string goes to the vision call (``context`` — the + model writes a context-aware description; the description remains + the primary content) and is stored on the doc's ``image_context``; + the doc's ``content`` becomes ``\n\n`` (and so + does ``summary`` — the phase-122 ``summary = content`` convention — + as do the embedded chunks). No reference → ``image_context`` stays + NULL and ``content`` is the bare description (byte-identical to + pre-phase). The UNCHANGED path never re-scans (the stored context + is part of the row's committed content; a changed source state + re-indexes the file through this path). """ settings = llm.settings data = full_path.read_bytes() @@ -880,12 +1402,24 @@ async def _index_image_file( return verb = "updated" if doc is not None else "added" + # Phase 125 (task 03, LOCKED A1/A2): BEFORE the description — the + # markdown reference context for this image (the alt text + + # surrounding paragraphs of the ``![alt](path)`` references that + # name it in the same source), or ``None`` (standalone image — the + # whole phase-125 enrichment below is a no-op, byte-identical to + # pre-phase). It goes to the vision model (a context-aware + # description, LOCKED A3) AND is stored on the doc (``image_context`` + # — the retrieval display's context line, task 05). + image_context = _markdown_image_context( + _source_root_for(full_path, rel), llm, source=source, image_rel=rel + ) # 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 + llm, data=data, source=source, rel=rel, full_path=full_path, + context=image_context, ) if content is None: # LOCKED A3 fail-soft: an undescribed image is unsearchable @@ -893,6 +1427,13 @@ async def _index_image_file( summary.images_failed += 1 logger.warning("import: image description failed source=%s path=%s", source, rel) return + if image_context is not None: + # Phase 125 (task 03): the enriched content — the reference + # context FIRST (for retrieval), then the description. The + # ``summary`` field mirrors this (the phase-122 image + # convention: ``summary = content``), and the chunk pipeline + # below embeds it. + content = f"{image_context}\n\n{content}" # The persistent copy: uploads are replaced on every upload, git # checkouts are re-cloned, local dirs are user-edited — the served @@ -925,6 +1466,10 @@ async def _index_image_file( created_at=normalize_doc_date(raw_date), is_image=True, image_path=str(copy_path), + # Phase 125 (task 03, LOCKED A1): the referencing markdown's + # alt text + surrounding paragraphs (``None`` for a + # standalone image — the pre-phase row shape). + image_context=image_context, ) session.add(doc) else: @@ -940,45 +1485,28 @@ async def _index_image_file( doc.created_at_manual = False doc.is_image = True doc.image_path = str(copy_path) + # Phase 125 (task 03): a re-indexed image re-scans its markdown + # references — the context follows the current source state + # (a deleted referencing markdown drops it back to ``None``). + doc.image_context = image_context 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 + # Phase 1+2 — the same pipeline on the description (chunk, replace + # the chunk rows (embeddings NULL), embed, commit — one transaction + # per file; the endpoint token-cap retry lives in the shared helper, + # and a description is short, so it never fires in practice). + chunks_n = await _replace_content_chunks( + session, doc=doc, llm=llm, content=content, rel=rel + ) 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)) + summary.chunks += chunks_n + logger.info("import: %s source=%s path=%s chunks=%d", verb, source, rel, chunks_n) # Phase 30 shape on the description — image-aware (task 03, LOCKED # A3): the description IS the summary (stored verbatim, no diff --git a/app/rag/md_images.py b/app/rag/md_images.py new file mode 100644 index 0000000..68d6337 --- /dev/null +++ b/app/rag/md_images.py @@ -0,0 +1,162 @@ +"""Markdown image-reference extraction (phase 125, LOCKED A1). + +Pure functions, no I/O — fully unit-testable. Stdlib only. + +Markdown documents reference images inline (``![alt](path)``). The +paragraphs AROUND the reference carry the *why* — what the diagram +illustrates, which procedure it belongs to — context that the +standalone vision description of the image itself (phase 122) does not +have. :func:`extract_image_refs` finds every image reference and +returns it with its alt text, its 1-based line number, and the +surrounding paragraph context: up to *n* paragraphs before and after +the reference paragraph (default :data:`CONTEXT_PARAGRAPHS`). + +Paragraph semantics mirror the chunker (PLAN §5): paragraphs are +separated by blank lines and code fences are **atomic** — a fenced +block (possibly containing blank lines) is ONE paragraph, so it counts +as a single unit of the context window. Only the *references* inside a +fence are skipped: there they are literal text, not actual images. +""" +from __future__ import annotations + +import re +from collections.abc import Sequence +from dataclasses import dataclass + +#: Opening/closing code fence (three or more backticks or tildes, +#: optionally indented) — the SAME semantics as the chunker's fence +#: handling (PLAN §5), so "fence" means the same thing here as in +#: chunking. +_FENCE_RE = re.compile(r"^(`{3,}|~{3,})") + +#: Markdown image reference ``![alt](path)``. The alt may be EMPTY +#: (``![](path)``); the path may contain spaces but not ``)`` — nested +#: parentheses inside a path are accepted as a miss (rare in practice, +#: phase 125 task 02). +_IMAGE_REF_RE = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)") + +#: Default number of context paragraphs BEFORE and AFTER the reference +#: paragraph (LOCKED A1: "alt text + N paragraphs before/after the +#: reference") — 2 gives ~2-4 sentences of surrounding text. +CONTEXT_PARAGRAPHS: int = 2 + + +@dataclass +class ImageRef: + """One extracted ``![alt](path)`` reference with its context window. + + * ``path`` — the image path (relative or absolute; surrounding + whitespace stripped); + * ``alt_text`` — the alt text (may be empty for ``![](path)``); + * ``context`` — the surrounding paragraphs: up to *n* before and + *n* after the reference paragraph, joined with blank lines. The + reference paragraph itself is NOT included (the reference is + already captured as ``alt_text`` + ``path``); + * ``line_number`` — 1-based line number of the reference. + """ + + path: str + alt_text: str + context: str + line_number: int + + +def _fence_flags(lines: Sequence[str]) -> list[bool]: + """Per-line flags: ``True`` on a fence delimiter or inside a fence. + + Mirrors the chunker (PLAN §5): an opener is three or more backticks + or tildes (indented allowed); while open, EVERY line — including + blank ones — is in-fence until the first line starting with the + same three-character marker. + """ + flags: list[bool] = [] + marker: str | None = None + for line in lines: + stripped = line.lstrip() + if marker is None: + m = _FENCE_RE.match(stripped) + if m: + marker = m.group(1)[:3] + flags.append(True) + else: + flags.append(False) + else: + flags.append(True) + if stripped.startswith(marker): + marker = None + return flags + + +def _paragraph_blocks( + lines: Sequence[str], flags: Sequence[bool] +) -> tuple[list[tuple[int, int]], list[int]]: + """Paragraph blocks as half-open (start, end) line ranges, plus a + per-line block-index map (``-1`` on blank outside-fence lines). + + Blocks are separated by blank lines *outside* fences; a fenced code + block (possibly containing blank lines) is ONE block — atomic (the + same paragraph semantics the chunker chunks with, PLAN §5). + """ + blocks: list[tuple[int, int]] = [] + start: int | None = None + for i, (line, in_fence) in enumerate(zip(lines, flags, strict=True)): + if in_fence or line.strip(): + if start is None: + start = i + elif start is not None: + blocks.append((start, i)) + start = None + if start is not None: + blocks.append((start, len(lines))) + line_block = [-1] * len(lines) + for bi, (s, e) in enumerate(blocks): + line_block[s:e] = [bi] * (e - s) + return blocks, line_block + + +def extract_image_refs( + content: str, context_paragraphs: int = CONTEXT_PARAGRAPHS +) -> list[ImageRef]: + """Extract every markdown image reference in *content*, with context. + + Each ``![alt](path)`` reference OUTSIDE a code fence becomes one + :class:`ImageRef` (multiple references on one line are extracted + separately, each with the same context window). ``context`` joins + the up-to-*context_paragraphs* paragraphs BEFORE and AFTER the + reference paragraph with blank lines; the reference paragraph + itself is excluded (the reference is already captured as + ``alt_text`` + ``path``), and a reference at the start/end of the + file gets whatever paragraphs are available. Fences are atomic + paragraphs (one fence block = one context unit), and references + inside one are literal text — skipped. + + A negative *context_paragraphs* raises :class:`ValueError` (fail + loud); 0 means "no surrounding context". + """ + if context_paragraphs < 0: + raise ValueError("context_paragraphs must be >= 0") + lines = content.splitlines() + if not lines: + return [] + flags = _fence_flags(lines) + blocks, line_block = _paragraph_blocks(lines, flags) + refs: list[ImageRef] = [] + for lineno, (line, in_fence) in enumerate(zip(lines, flags, strict=True), start=1): + if in_fence: + continue + for m in _IMAGE_REF_RE.finditer(line): + # The reference line is non-blank, outside-fence content, so + # it always belongs to a paragraph block (>= 0). + bi = line_block[lineno - 1] + before = blocks[max(0, bi - context_paragraphs) : bi] + after = blocks[bi + 1 : bi + 1 + context_paragraphs] + context = "\n\n".join("\n".join(lines[s:e]) for s, e in before + after) + refs.append( + ImageRef( + path=m.group(2).strip(), + alt_text=m.group(1).strip(), + context=context, + line_number=lineno, + ) + ) + return refs diff --git a/app/rag/prompts.py b/app/rag/prompts.py index c16a51c..76b3fcc 100644 --- a/app/rag/prompts.py +++ b/app/rag/prompts.py @@ -89,6 +89,7 @@ no longer injected — see :mod:`app.api.chat`). """ from __future__ import annotations +import re from collections.abc import Sequence from typing import Any @@ -434,6 +435,116 @@ def build_kb_section(overview: str, max_chars: int | None = None) -> str: return "" +#: Phase 125 (task 05, LOCKED A4) — the header line of one block of an +#: enriched image doc's stored ``image_context`` (the exact shape the +#: importer's reference-block writer emits, tasks 03/04): +#: ``Referenced in /:`` — a block is one +#: referring markdown file, and a block's line FOLLOWING its header is +#: always its first ``- Alt text: "..."`` bullet. A look-alike line in +#: the context body without that bullet is spurious and dropped +#: (graceful degradation, A4). +_IMAGE_CTX_HEADER_RE = re.compile(r"^Referenced in (?P.+):$") + +#: The ``- Alt text: "..."`` bullet of the same block. The greedy alt +#: field (the block writer does not escape quotes) is captured between +#: the opening quote (the pattern's literal) and the CLOSING quote +#: (the pattern's trailing ``"$``) — an alt with inner ``"`` survives +#: intact (the greedy walk keeps the inner quote, the final one is the +#: delimiter; an empty alt matches as the empty group). +_IMAGE_CTX_ALT_RE = re.compile(r'^- Alt text: "(?P.*)"$') + + +def _image_ctx_clause(ref: str, alt: str | None) -> str: + """One clause of the retrieval context line (phase 125, task 05): + the referring markdown's stored identity (``/`` + exactly as the block header carries it) plus the reference's alt + text quoted after a colon — the reference's purpose in one phrase. + A reference with no alt (``![](path)``) renders the bare identity + (nothing to quote). + """ + if alt: + return f'{ref}: "{alt}"' + return ref + + +def _extract_context_line(image_context: str) -> str | None: + """The ONE-LINE rendering of an enriched image doc's stored + ``image_context`` (phase 125, task 05, LOCKED A4) — or ``None`` + when nothing in the value parses as a reference block (graceful + degradation: the ```` block then shows only the + description, exactly pre-phase — the value never raises into the + prompt build). + + The stored value (tasks 03/04) is one block per referring markdown + file, blank-line joined:: + + Referenced in /: + - Alt text: "" + Context: + - Alt text: "" + + The result keeps only what tells the LLM WHY the image exists in + the documentation — one clause per referring file (its identity + + the FIRST reference's alt), after a single ``Referenced in `` + prefix, ``; ``-joined:: + + Referenced in docs/a.md: "alt a"; docs/b.md + + The ``Context:`` body is never rendered (the surrounding paragraphs + already reached the vision model at enrichment time, LOCKED A3 — + the line is the locator + purpose, not a duplicate of the + paragraphs), so the line stays short enough not to bloat the + prompt. The markdown doc's ``IMAGE_CONTEXT:`` pending record + (task 04) carries no ``Referenced in`` header at all and parses to + ``None`` — and it rides a TEXT doc, which :func:`_document_body` + excludes from the line via the ``is_image`` guard anyway. + """ + clauses: list[str] = [] + ref: str | None = None # the current block's "source/markdown_path" + alt: str | None = None # the current block's first bullet's alt text + expecting_alt = False # the line after a header must be its bullet + for line in image_context.splitlines(): + header = _IMAGE_CTX_HEADER_RE.match(line) + if header is not None: + if ref is not None and not expecting_alt: + clauses.append(_image_ctx_clause(ref, alt)) + ref, alt = header.group("ref"), None + expecting_alt = True + continue + alt_match = _IMAGE_CTX_ALT_RE.match(line) if ref is not None else None + if expecting_alt: + if alt_match is None: + # Spurious header (a look-alike line in context text): + # its bullet never came — drop the block. + ref, alt, expecting_alt = None, None, False + else: + alt = alt_match.group("alt") + expecting_alt = False + continue + # Any other line is context body or a second bullet of the + # same block — the first reference's alt is already captured, + # so the one-line rendering is unaffected (no branch needed: + # with a live block and no pending bullet, ``alt`` is never + # ``None``). + if ref is not None and not expecting_alt: + clauses.append(_image_ctx_clause(ref, alt)) + if not clauses: + return None + return "Referenced in " + "; ".join(clauses) + + +def _image_context_line(doc: Document) -> str | None: + """The phase-125 context line for one suggested document (task 05, + LOCKED A4) — ``None`` in the common case (EVERY text doc, and every + image doc without a parsable ``image_context`` — standalone / + pre-phase rows: the byte-identical path) and the one-line + :func:`_extract_context_line` rendering otherwise (``is_image`` + docs with a non-NULL, parsable ``image_context`` only).""" + if not doc.is_image or not doc.image_context: + return None + return _extract_context_line(doc.image_context) + + def _document_body(doc: Document) -> str: """The body of one ```` suggestion block (phase 118, LOCKED A6): the document's stored summary (stripped) — NEVER the full @@ -447,15 +558,33 @@ def _document_body(doc: Document) -> str: summary-bearing docs makes no settings call for the cap). Content at or under the cap rides whole, unmarked (nothing was cut). No LLM call at chat time — the preview is deterministic. + + Phase 125 (task 05, LOCKED A4): an ENRICHED image doc (``is_image`` + with a non-NULL ``image_context`` that parses — see + :func:`_image_context_line`) leads its body with the ONE short + context line — ``Referenced in /: ""`` — before + the description, so the LLM knows WHY the image exists in the + documentation, not only what it shows. Every other doc is + byte-identical to the pre-phase body: every text doc (its + ``image_context`` may carry the task-04 pending record, which never + renders) and every non-enriched image doc (NULL ``image_context`` + — the phase's byte-identity criterion). """ summary = (doc.summary or "").strip() if summary: - return summary - limit = get_settings().suggestion_preview_chars - content = doc.content - if len(content) > limit: - return content[:limit] + "\n" + TRUNCATION_MARKER - return content + body = summary + else: + limit = get_settings().suggestion_preview_chars + content = doc.content + body = ( + content[:limit] + "\n" + TRUNCATION_MARKER + if len(content) > limit + else content + ) + context_line = _image_context_line(doc) + if context_line is not None: + body = context_line + "\n\n" + body + return body def build_high_prompt( @@ -530,7 +659,8 @@ def build_high_prompt( APPENDED after ``title`` — the only position; always present, ``created_at`` is NOT NULL) — plus the document's summary (:func:`_document_body`; the NULL/whitespace-summary preview - fallback is LOCKED A5). + fallback is LOCKED A5; an enriched image doc's summary leads with + the phase-125 one-line context line, LOCKED A4). """ # Phase 106 (D5): every document the model sees carries its # creation date — the block's ``date`` attribute (the row's diff --git a/app/rag/summarizer.py b/app/rag/summarizer.py index fbd9328..d971778 100644 --- a/app/rag/summarizer.py +++ b/app/rag/summarizer.py @@ -211,6 +211,7 @@ async def describe_image( data: bytes, mime: str, settings: Settings | None = None, + context: str | 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`` @@ -220,11 +221,22 @@ async def describe_image( 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: }}]`` — 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. + ``[{type: "text", text: DESCRIBE_PROMPT + context prefix}, + {type: "image_url", image_url: {url: }}]`` — 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. + + *context* (phase 125, task 03, LOCKED A3): the optional markdown + reference context — the alt text + surrounding paragraphs of the + ``![alt](path)`` references that point at THIS image (the importer's + same-source scan). When provided, it is appended to the text part + AFTER the locked :data:`DESCRIBE_PROMPT` (the marker + instruction + stay byte-identical — the ``IMAGE_DESCRIPTION_MODE`` mock key is + untouched): the vision model is told WHAT THE IMAGE IS FOR so it + can write a context-aware description. ``context=None`` (the + default — standalone images) sends the bare prompt: the message is + byte-identical to the pre-phase-125 shape. Returns the stripped reply cut exactly at ``(settings or llm.settings).summary_max_chars`` (the phase-30 cap — @@ -236,11 +248,16 @@ async def describe_image( continues (LOCKED A3). """ data_url = image_data_url(data, mime) + context_prefix = ( + f"\n\nThis image is referenced in documentation with the following context:\n{context}" + if context + else "" + ) messages: list[dict[str, Any]] = [ { "role": "user", "content": [ - {"type": "text", "text": DESCRIBE_PROMPT}, + {"type": "text", "text": DESCRIBE_PROMPT + context_prefix}, {"type": "image_url", "image_url": {"url": data_url}}, ], } diff --git a/tests/e2e/mock_llm.py b/tests/e2e/mock_llm.py index 740ceae..0d9b46f 100644 --- a/tests/e2e/mock_llm.py +++ b/tests/e2e/mock_llm.py @@ -517,6 +517,28 @@ Implements just enough of the aipi surface: marker question always gets the echo whatever the honesty gate says. Verified 2026-09-26: no existing E2E question or fixture file contains the phrase, so every other suite is unaffected. + - user message containing ``quote the image reference`` + (``CONTEXT_LINE_TRIGGER``, phase 125 task 06 — the image-anchor + context suite ``tests/e2e/test_image_anchor_context.py``) -> the + deterministic ZERO-READ answer that QUOTES THE CONTEXT LINE of the + seeded image doc's ```` block (the same image-doc pick + as ``DIAGRAM_TRIGGER``): an ENRICHED image doc's block (phase 125 + task 05, LOCKED A4) leads with the one-line + ``Referenced in /: ""`` context line BEFORE + the description, so the mock's quote — the block's exact first + line — proves the line WAS in the prompt (and that its text is + the task-05 one-line rendering, not the stored multi-line block). + A non-enriched (standalone) image doc's block is the bare + description (byte-identical pre-phase): its first line never + starts with ``Referenced in `` (the mock's fixed description + cannot begin that way), so the mock answers that the image + carries no reference line — the byte-identity criterion's + deterministic witness. Checked BEFORE the ``DEFLECT_MODE`` + branch (the marker lives in the user message — the + ``TABLE_TRIGGER``/``HISTORY_TRIGGER`` convention), so a marker + question always gets the echo whatever the honesty gate says. + Verified 2026-09-27: no existing E2E question or fixture file + contains the phrase, so every other suite is unaffected. Failure injection (phase 67, LLM retry, TODO.md L3) — deterministic dead-endpoint behavior for the retry E2E suite (``tests/e2e/ @@ -1843,6 +1865,26 @@ SHOW_IMAGE_TRIGGER = "show me the picture of" #: says. DIAGRAM_TRIGGER = "which diagram is in your notes" +#: The phase-125 (task 06, image-anchor context, LOCKED A4) trigger +#: (case-insensitive): the mock QUOTES THE CONTEXT LINE of the seeded +#: image doc's ```` block (the same image-doc pick as +#: :data:`DIAGRAM_TRIGGER`). For an ENRICHED image doc the block +#: leads with the one-line ``Referenced in /: ""`` +#: line (the task-05 rendering of the stored ``image_context``, +#: BEFORE the description) — quoting the block's first line proves +#: the line reached the prompt; for a non-enriched (standalone) doc +#: the block is the bare description (byte-identical pre-phase) and +#: its first line never starts with ``Referenced in `` (the mock's +#: fixed description cannot begin that way) — the mock then answers +#: that the image carries no reference line. Checked BEFORE the +#: ``DEFLECT_MODE`` branch in :func:`compose_answer` (the marker +#: lives in the user message — the TABLE/HISTORY convention), so a +#: marker question always gets the echo whatever the honesty gate +#: says. Disjoint from every other trigger phrase; verified +#: 2026-09-27: no existing E2E question or fixture file contains the +#: phrase, so every other suite is unaffected. +CONTEXT_LINE_TRIGGER = "quote the image reference" + #: The agent's ``show_image`` SUCCESS result's confirming first line #: (``app.rag.agent``'s ``show_image`` branch — the line ``read``'s #: result never carries): the SHOW_IMAGE flow's follow-up request @@ -1878,6 +1920,26 @@ def _seeded_image_doc(system: str) -> tuple[str, str, str] | None: return None +def _seed_context_line(block_content: str) -> str | None: + """The phase-125 context line of a seeded image doc's block + (task 06, LOCKED A4) — or ``None``. + + An ENRICHED image doc's ```` block leads with the + one-line ``Referenced in …`` rendering (the task-05 + :func:`app.rag.prompts._extract_context_line` output, BEFORE the + description): its first line, verbatim, is the line the mock + quotes (the prompt-injection echo convention). A non-enriched + block is the bare description — its first line never starts with + ``Referenced in `` (the mock's fixed + :data:`IMAGE_DESCRIPTION_ANSWER` cannot begin that way), so + ``None`` pins the byte-identical pre-phase shape. + """ + first = block_content.split("\n", 1)[0].strip() + if first.startswith("Referenced in "): + return first + return None + + def _show_image_flow(body: dict[str, Any]) -> tuple[str, ...] | None: """Classify a phase-124 SHOW_IMAGE request (see the module docstring). Stateless over the messages, like the other marker @@ -2467,6 +2529,32 @@ def compose_answer(body: dict[str, Any]) -> str: else "Your notes don't carry that diagram — there is nothing to show. " "(Deterministic mock answer for E2E.)" ) + elif CONTEXT_LINE_TRIGGER in user.lower(): + # Phase 125 (task 06, image-anchor context, LOCKED A4): the + # ZERO-READ context-line echo — the mock QUOTES THE CONTEXT + # LINE of the seeded image doc's block (the block's + # FIRST line — the task-05 one-line rendering for an enriched + # doc; a standalone doc's block is the bare description, whose + # first line never matches — the "no reference line" answer + # is the byte-identity witness). Checked BEFORE the + # DEFLECT_MODE branch (the marker lives in the user message — + # the TABLE/HISTORY convention), so a marker question always + # gets the echo whatever the honesty gate says. + image_doc = _seeded_image_doc(system) + if image_doc is None: + answer = ( + "Your notes don't carry that image — there is nothing to reference. " + "(Deterministic mock answer for E2E.)" + ) + else: + context_line = _seed_context_line(image_doc[2]) + answer = ( + f"The notes reference the image thus: {context_line} " + "(Deterministic mock answer for E2E.)" + if context_line is not None + else "The image stands alone — the notes carry no reference line for it. " + "(Deterministic mock answer for E2E.)" + ) elif "DEFLECT_MODE" in system: answer = ( "Ah — I haven't done anything like that, so I don't want to make stuff up! " diff --git a/tests/e2e/test_image_anchor_context.py b/tests/e2e/test_image_anchor_context.py new file mode 100644 index 0000000..f66ced7 --- /dev/null +++ b/tests/e2e/test_image_anchor_context.py @@ -0,0 +1,623 @@ +"""Phase 125 E2E (Playwright): image-anchor context — the user path. + +Run in isolation (DB must be up: ``podman compose up -d db``): + + uv run pytest tests/e2e/test_image_anchor_context.py -v --no-cov + +The suite's app instance runs with ``BOR_IMAGES=true`` and +``BOR_ANSWER_IMAGES=false`` (module env override — the conftest +per-suite-app pattern, leak guards included): the fixture archive +carries ``docs/guide.md`` — a markdown file whose ``![alt](path)`` +reference NAMES ``docs/diagram.png`` in the SAME source — so the real +upload → sync flow drives the whole phase-125 pipeline (LOCKED +A1/A2): the task-03 disk scan finds the reference at image-index time, +the vision model receives the context after the locked describe prompt, +and the image doc's ``image_context`` (and so its ``content``, +``summary`` and embedded chunks) becomes +``\\n\n``. + +* the sync leaves the image doc ENRICHED (the ``Referenced in`` block + on ``image_context``, the enriched content + chunks, the markdown + doc's record NULL — the image was indexed first, the task-03 path + owns that case); +* a grounded chat turn whose suggested tier carries the enriched + image doc gets the task-05 context line: the mock (the + ``CONTEXT_LINE_TRIGGER`` branch — the prompt parser extended for + this phase) QUOTES THE EXACT LINE of the image doc's ```` + block, so the rendered answer AND the captured wire prompt prove + the block LEADS with the one-line ``Referenced in + /: ""`` rendering before the summary (the stored + block's shape carries the alt on the next line as a bullet — the + quote pins the task-05 rendering, not the stored block); +* a STANDALONE image (a second phase of the same app — the KB is + re-seeded with a single PNG no markdown references) stays + byte-identical to pre-phase: ``image_context`` NULL, the bare + description everywhere, the ```` block the description + alone, and the mock's "no reference line" answer (the deterministic + witness that no context line was in the prompt). +""" +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 Chunk, Document +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" + +#: Phase A — the anchored source: the markdown references the image +#: from the SAME folder (``diagram.png`` next to ``guide.md`` → the +#: resolved in-source path ``docs/diagram.png``). +SOURCE_NAME = "e2e-anchor" # the archive stem (archive_source_name) +MD_PATH = "docs/guide.md" +DOC_PATH = "docs/diagram.png" + +#: Phase B — the standalone source: a single PNG no markdown names. +STANDALONE_SOURCE_NAME = "e2e-anchor-standalone" +LOGO_PATH = "logo.png" + +#: The fixture markdown — the reference on line 5, two context +#: paragraphs (the task-02 default window). +GUIDE_MD = ( + "# Backup runbook\n" + "\n" + "The restore order diagram explains the backup procedure.\n" + "\n" + "![Restore order](diagram.png)\n" + "\n" + "Only run the restore script after the checks pass.\n" +) + +#: The task-03-shape combined context the same-source disk scan builds +#: for ``docs/diagram.png`` (one referring markdown, one reference, +#: the task-02 window as the ``Context:`` body — the ``# Backup +#: runbook`` heading is the first of the two paragraphs before the +#: reference; the N=2 window keeps it). +BLOCK = ( + "Referenced in e2e-anchor/docs/guide.md:\n" + '- Alt text: "Restore order"\n' + " Context: # Backup runbook\n" + "\n" + "The restore order diagram explains the backup procedure.\n" + "\n" + "Only run the restore script after the checks pass." +) + +#: The enriched image doc's stored ``content`` (AND ``summary`` — the +#: phase-122 ``summary = content`` convention): the context FIRST +#: (for retrieval), then the vision description. +ENRICHED = f"{BLOCK}\n\n{IMAGE_DESCRIPTION_ANSWER}" + +#: The content chunk's stored text — the plain-text packer joins the +#: enriched paragraphs with single newlines (the chunker's +#: normalization, PLAN §5); the ``is_summary`` chunk keeps the raw +#: enriched text. +PACKED = "\n".join(ENRICHED.split("\n\n")) + +#: The task-05 ONE-LINE rendering of :data:`BLOCK` (the context line +#: that leads the enriched image doc's ```` block body): +#: the referring markdown's identity + the reference's alt, the +#: ``Context:`` body never rendered. +CONTEXT_LINE = 'Referenced in e2e-anchor/docs/guide.md: "Restore order"' + +#: The grounded question — the mock's ``CONTEXT_LINE_TRIGGER`` marker +#: (the zero-read context-line echo) + the phase-122 grounding phrase +#: (token-overlap cosine ≈0.47 against the enriched diagram doc and +#: ≈0.37 against the standalone logo's bare description — both clear +#: the mock-calibrated gate (0.30), the FTS leg corroborates). +ANCHOR_QUESTION = ( + "quote the image reference — what is shown in the homelab network diagram?" +) + +#: The mock's two deterministic answers for the marker (the exact +#: branch texts — the phase-125 pins key off them). +ANSWER_WITH_LINE = ( + f"The notes reference the image thus: {CONTEXT_LINE} " + "(Deterministic mock answer for E2E.)" +) +ANSWER_STANDALONE = ( + "The image stands alone — the notes carry no reference line for it. " + "(Deterministic mock answer for E2E.)" +) + +#: The honest-deflection opening (the gate must NOT fire on the +#: marker question — a deflected prompt carries no ```` +#: block, so the mock would never see the image doc). +DEFLECT_OPENING = "I haven't done anything like that" + +UPLOAD_TIMEOUT_MS = 30_000 +SYNC_TIMEOUT_MS = 60_000 +SYNCED_LABEL = re.compile(r"^Synced \d{1,2}:\d{2}$") + +#: A real 1×1 transparent PNG (the phase-122 fixture — the pipeline +#: is content-agnostic, the bytes just need to be plausible image +#: bytes). +PNG_1X1 = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII=" +) + + +# --------------------------------------------------------------------------- +# Module app: images ON (the story) — the conftest env pattern with the +# phase-122/125 knobs + leak guards. +# --------------------------------------------------------------------------- + + +def _app_env(mock_llm: int, app_port: int, scratch: Path) -> dict[str, str]: + """The conftest app env (leak guards included) with the phase-122 + knobs: ``BOR_IMAGES`` pinned EXPLICITLY true (the story) and + ``BOR_ANSWER_IMAGES`` pinned EXPLICITLY false (the default — the + auto-cite/figure behavior is phase 124's story, not this phase's, + and an operator's local gitignored ``.env`` cannot leak it in: + both toggles are the contract under test's neighbors), the + image/upload homes in the suite's scratch dir (the app under test + must not write 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 marker question + # grounds on these values (see ANCHOR_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/124 toggles + the suite-private homes. + env["BOR_IMAGES"] = "true" + env["BOR_ANSWER_IMAGES"] = "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_anchor") + port = int(os.environ.get("E2E_APP_PORT_ANCHOR", "8160")) + 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, 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 + + +# --------------------------------------------------------------------------- +# Fixture archives +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def anchor_zip(tmp_path_factory: pytest.TempPathFactory) -> Path: + """Phase A's archive: the markdown that references the image in + the SAME source (``docs/guide.md`` → ``docs/diagram.png``).""" + root = tmp_path_factory.mktemp("bor_anchor_zip") + docs = root / "docs" + docs.mkdir() + (docs / "guide.md").write_text(GUIDE_MD, encoding="utf-8") + (docs / "diagram.png").write_bytes(PNG_1X1) + archive = root / f"{SOURCE_NAME}.zip" + with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf: + zf.write(docs / "guide.md", arcname="docs/guide.md") + zf.write(docs / "diagram.png", arcname="docs/diagram.png") + return archive + + +@pytest.fixture(scope="module") +def standalone_zip(tmp_path_factory: pytest.TempPathFactory) -> Path: + """Phase B's archive: ONE standalone PNG at the source root — no + markdown in the source references it (the byte-identical + pre-phase case).""" + root = tmp_path_factory.mktemp("bor_anchor_standalone_zip") + (root / LOGO_PATH).write_bytes(PNG_1X1) + archive = root / f"{STANDALONE_SOURCE_NAME}.zip" + with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf: + zf.write(root / LOGO_PATH, arcname=LOGO_PATH) + return archive + + +# --------------------------------------------------------------------------- +# DB + UI helpers +# --------------------------------------------------------------------------- + + +def _truncate_all() -> None: + """Fresh KB + registry per phase (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, source: str) -> None: + """The real admin upload flow: form login → the git-sources page → + pick the archive → submit → the terminal result line (phase 90: + "Uploaded — 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} — 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).""" + 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 _captured_systems(mock_llm: int) -> list[str]: + """The system prompts of the captured chat requests (the mock's + ring buffer — the wire-level lens on the EXACT prompt the app + built; only ``/v1/chat/completions`` bodies are captured, so after + a reset + one grounded zero-read turn there is exactly one).""" + bodies = httpx.get(f"http://127.0.0.1:{mock_llm}/v1/e2e/captured", timeout=10).json() + systems: list[str] = [] + for body in bodies: + for msg in body.get("messages", []): + if msg.get("role") == "system" and isinstance(msg.get("content"), str): + systems.append(msg["content"]) + return systems + + +def _reset_capture(mock_llm: int) -> None: + httpx.post(f"http://127.0.0.1:{mock_llm}/v1/e2e/captured/reset", timeout=10) + + +def _ask(page: Page, app_url: str, question: str) -> None: + """The chat user path: login → the question → Send → the final + brain bubble (the Send button re-enabled = the turn is over).""" + login(page, app_url, next="/") + expect(page.locator("#kb-banner")).to_be_hidden() + page.fill("#message-input", question) + page.click("#send-btn") + bubble = page.locator(".msg.brain .bubble").last + expect(page.locator("#send-btn")).to_be_enabled(timeout=60_000) + expect(page.locator("#send-label")).to_have_text("Send") + expect(bubble).to_contain_text("Deterministic mock answer", timeout=30_000) + + +# --------------------------------------------------------------------------- +# The module seeds: phase A (the anchored archive) and phase B (the +# standalone archive) — each owns its truncate/re-import window. +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def seeded( + app_url: str, + browser: Browser, + anchor_zip: Path, +) -> Iterator[dict[str, str]]: + """Phase A: upload the anchored archive through the admin UI, then + scan it with the RAG page's Sync button (the mock's vision model + describes the image in-process, with the markdown context). The + module app's ``/api/config`` flags are asserted first — the env + overrides 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, anchor_zip, SOURCE_NAME) + _sync_via_ui(page, app_url, expected_result="2 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 + finally: + page.close() + yield {"image_url": f"/api/documents/{doc.id}/image"} + _truncate_all() + + +@pytest.fixture(scope="module") +def standalone( + app_url: str, + browser: Browser, + standalone_zip: Path, +) -> Iterator[dict[str, str]]: + """Phase B: a FRESH KB (the phase-A rows are truncated) seeded + with the standalone archive — a single PNG no markdown in the + source references (the byte-identical pre-phase case).""" + _truncate_all() + page = browser.new_page(viewport={"width": 1280, "height": 800}) + try: + _upload_zip(page, app_url, standalone_zip, STANDALONE_SOURCE_NAME) + _sync_via_ui(page, app_url, expected_result="1 added") + with SessionLocal() as db: + doc = db.scalar( + select(Document).where( + Document.source == STANDALONE_SOURCE_NAME, Document.path == LOGO_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 + finally: + page.close() + yield {"image_url": f"/api/documents/{doc.id}/image"} + _truncate_all() + + +# --------------------------------------------------------------------------- +# 1. Sync: the image doc is enriched with the markdown context +# --------------------------------------------------------------------------- + + +def test_sync_enriches_the_image_doc_with_markdown_context( + page: Page, app_url: str, seeded: dict[str, str] +) -> None: + """LOCKED A1/A2 end-to-end: the markdown in the SAME source + references the image → after the sync the image doc's + ``image_context`` is the ``Referenced in`` block, its ``content`` + (AND ``summary`` — the phase-122 convention) is + ``\\n\n`` (context first — for retrieval — + then the vision description), the content chunk is embedded from + the enriched text (the packer's single-newline form) and the + ``is_summary`` chunk mirrors the raw enriched text. The referring + markdown's OWN ``image_context`` stays NULL: the image was indexed + BEFORE the markdown (``docs/diagram.png`` sorts first), so its + reference names an existing image doc — the task-03 path owns that + case, no pending record is written. The Sources page counts the + KB: 2 docs, 4 chunks (one content + one summary chunk per doc).""" + with SessionLocal() as db: + doc = db.scalar( + select(Document).where( + Document.source == SOURCE_NAME, Document.path == DOC_PATH + ) + ) + assert doc is not None + # The stored context (task 05's display source). + assert doc.image_context == BLOCK + # The enriched content — context FIRST, then the description — + # IS the summary (the phase-122 image convention). + assert doc.content == ENRICHED + assert doc.summary == ENRICHED + 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] == [PACKED], ( + "the content chunk is embedded from the ENRICHED text" + ) + 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 == ENRICHED + for c in chunks: + assert c.embedding is not None and len(c.embedding) == 768 + + # The referring markdown — the image doc existed at its + # indexing (sort order), so NO pending record was written. + md = db.scalar( + select(Document).where( + Document.source == SOURCE_NAME, Document.path == MD_PATH + ) + ) + assert md is not None and md.is_image is False + assert md.image_context is None, "existing image: the record stays NULL" + + # The KB on the Sources page: 2 docs (the markdown + the image), + # 4 chunks (one content + one is_summary per doc). + login(page, app_url) + expect(page.locator("#stat-docs")).to_have_text("2") + expect(page.locator("#stat-chunks")).to_have_text("4") + + +# --------------------------------------------------------------------------- +# 2. Chat: the enriched image doc's block leads with the +# context line — the answer AND the wire prompt prove it +# --------------------------------------------------------------------------- + + +def test_chat_answer_quotes_the_context_line( + page: Page, app_url: str, mock_llm: int, seeded: dict[str, str] +) -> None: + """LOCKED A4 end-to-end: a grounded turn whose suggested tier + carries the ENRICHED image doc → the ```` block LEADS + with the ONE-LINE context line (the task-05 rendering of the + stored block: the referring markdown's identity + the reference's + alt, BEFORE the summary — for an enriched image doc the summary + is the enriched content, so the stored block rides in the body + too, but the header is the one-line rendering, never the stored + multi-line header). The mock's marker branch QUOTES THE EXACT + LINE of the block — so the rendered answer proves the one-line + rendering was in the prompt (the quoted alt sits on the header + line — the stored block's shape carries it on the next line as a + bullet), and the captured wire prompt pins the exact block shape: + line, blank line, enriched summary.""" + _reset_capture(mock_llm) + _ask(page, app_url, ANCHOR_QUESTION) + + bubble = page.locator(".msg.brain .bubble").last + # The mock's enriched answer — the EXACT context line, quoted. + expect(bubble).to_contain_text(ANSWER_WITH_LINE, timeout=30_000) + # Grounded (not deflected) — a deflected prompt would carry no + # block at all. + expect(bubble).not_to_contain_text(DEFLECT_OPENING) + + # The wire prompt: exactly one chat request (grounded, zero tool + # calls), and the image doc's block leads with the + # ONE-LINE context line — then a blank line, then the summary + # (for an enriched image doc the summary IS the enriched content — + # the phase-122 ``summary = content`` convention — so the stored + # block rides along in the body, but the block HEADS with the + # one-line rendering, never the stored header line). + systems = _captured_systems(mock_llm) + assert len(systems) == 1, "one grounded zero-read turn → one chat request" + system = systems[0] + start = system.index(f'\n", start) + 2 + body_end = system.index("\n", body_start) + block_body = system[body_start:body_end] + # The exact block shape: the one-line context line (the alt QUOTED + # on the same line — the stored block's shape carries the alt on + # the NEXT line as a bullet, so this pins the task-05 rendering), + # a blank line, then the enriched summary. + assert block_body == f"{CONTEXT_LINE}\n\n{ENRICHED}" + # The line is inside , before the summary's block. + assert system.index("") < system.index(CONTEXT_LINE) + assert system.index(CONTEXT_LINE) < system.index("\n" + BLOCK) + + +# --------------------------------------------------------------------------- +# 3. Standalone image: the byte-identical pre-phase row +# --------------------------------------------------------------------------- + + +def test_standalone_image_keeps_null_context_and_bare_description( + page: Page, app_url: str, standalone: dict[str, str] +) -> None: + """LOCKED A4 (the byte-identity criterion) on the ROW: a standalone + image (no markdown in the source references it) → ``image_context`` + NULL, ``content`` (AND ``summary``) the BARE vision description, + every chunk the bare description — exactly the phase-122 shape. + The Sources page counts 1 doc / 2 chunks (one content + one + summary).""" + with SessionLocal() as db: + doc = db.scalar( + select(Document).where( + Document.source == STANDALONE_SOURCE_NAME, Document.path == LOGO_PATH + ) + ) + assert doc is not None + assert doc.image_context is None, "standalone: the context stays NULL" + assert doc.content == IMAGE_DESCRIPTION_ANSWER, "standalone: the bare description" + assert doc.summary == IMAGE_DESCRIPTION_ANSWER + chunks = db.scalars(select(Chunk).where(Chunk.document_id == doc.id)).all() + assert len(chunks) == 2 + assert all(c.content == IMAGE_DESCRIPTION_ANSWER for c in chunks) + for c in chunks: + assert c.embedding is not None and len(c.embedding) == 768 + + login(page, app_url) + expect(page.locator("#stat-docs")).to_have_text("1") + expect(page.locator("#stat-chunks")).to_have_text("2") + + +# --------------------------------------------------------------------------- +# 4. Standalone image: the block is byte-identical in the +# prompt — no context line, the bare description alone +# --------------------------------------------------------------------------- + + +def test_standalone_answer_carries_no_context_line( + page: Page, app_url: str, mock_llm: int, standalone: dict[str, str] +) -> None: + """LOCKED A4 (the byte-identity criterion) in the PROMPT: the SAME + marker question against the standalone KB → the image doc's + ```` block body is EXACTLY the bare description (no + context line, no stray blank — the pre-phase block), and the mock + — which quotes the block's first line when it is a ``Referenced + in`` line — answers that the image carries no reference line: the + deterministic witness that the line was NOT in the prompt. + ``Referenced in`` does not appear ANYWHERE in the system prompt.""" + _reset_capture(mock_llm) + _ask(page, app_url, ANCHOR_QUESTION) + + bubble = page.locator(".msg.brain .bubble").last + expect(bubble).to_contain_text(ANSWER_STANDALONE, timeout=30_000) + expect(bubble).not_to_contain_text(DEFLECT_OPENING) + + systems = _captured_systems(mock_llm) + assert len(systems) == 1, "one grounded zero-read turn → one chat request" + system = systems[0] + start = system.index( + f'\n", start) + 2 + body_end = system.index("\n", body_start) + block_body = system[body_start:body_end] + # The pre-phase block body: the description alone, verbatim. + assert block_body == IMAGE_DESCRIPTION_ANSWER + assert "Referenced in" not in system diff --git a/tests/integration/test_chat_api.py b/tests/integration/test_chat_api.py index 24388ec..d934371 100644 --- a/tests/integration/test_chat_api.py +++ b/tests/integration/test_chat_api.py @@ -2790,3 +2790,102 @@ def test_zero_read_text_only_turn_still_chips_nothing( assert done["sources"] == [] # phase-119 read-docs-only rule intact for ref in done["sources"] + done["related"]: assert "image_url" not in ref + + +# --------------------------------------------------------------------------- +# Phase 125, task 05 — retrieval display: the suggested tier's ENRICHED +# image doc (non-NULL image_context, the task-03/04 block shape) leads +# its block with the one-line context line (the referring +# markdown's identity + the reference's alt) before the description; +# the non-enriched image doc (NULL image_context) stays byte-identical +# to pre-phase (LOCKED A4). +# --------------------------------------------------------------------------- + + +def test_grounded_turn_suggested_enriched_image_doc_carries_context_line( + client, db, seeded_kb: FakeRagLLM +) -> None: + """LOCKED A4 end-to-end: a grounded turn whose suggested tier + contains an enriched image doc (the task-03/04 ``Referenced in`` + block stored on ``image_context``) → the system prompt's + ```` block for that doc leads with the short context + line, a blank line, then the description — and the ``Context:`` + paragraphs of the stored block never reach the prompt (the line is + the locator + purpose, not a duplicate of the paragraphs).""" + doc = _seed_image_doc_with_chunk(db) + doc.image_context = ( + "Referenced in docs/homelab/kubernetes.md:\n" + '- Alt text: "Cluster layout"\n' + " Context: The diagram shows the three Talos nodes.\n" + "It is referenced again from the runbook." + ) + db.commit() + scripted = FakeRagLLM() # zero-read grounded turn (the image seed block IS its content) + fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted + try: + _, _, frames = _stream_chat(client, QUESTION) + finally: + fastapi_app.dependency_overrides.clear() + + assert frames[-1]["deflected"] is False + system = scripted.seen_messages[0][0]["content"] + # The doc is in the suggested tier (retrieval is unchanged by the + # phase — the line rides on a doc the retrieval already picked). + block_start = system.index(f'", block_start) + len("") + ] + line = 'Referenced in docs/homelab/kubernetes.md: "Cluster layout"' + # The exact block shape: identity attrs (phase 106 date), the line, + # a blank line, the description (the phase-122 content == summary + # == vision description invariant). + assert block == ( + f'\n' + f"{line}\n" + "\n" + f"{doc.summary}\n" + "" + ) + # The line is the FIRST thing in the block body (before the + # description), inside . + assert system.index("") < system.index(line) + assert system.index(line) < system.index(doc.summary or "") + # The Context: body stays out of the whole prompt. + assert "The diagram shows the three Talos nodes." not in system + assert "referenced again from the runbook" not in system + assert "Context:" not in system + + +def test_grounded_turn_suggested_plain_image_doc_block_is_byte_identical( + client, db, seeded_kb: FakeRagLLM +) -> None: + """LOCKED A4 (the byte-identity criterion) end-to-end: the SAME + flow with ``image_context`` left NULL (the standalone / pre-phase + image doc — the phase's default) → the ```` block is + EXACTLY the pre-phase block (the summary alone, no context line, + no stray blank) and the marker string does not appear anywhere in + the system prompt.""" + doc = _seed_image_doc_with_chunk(db) + assert doc.image_context is None # the seed leaves it NULL (standalone) + scripted = FakeRagLLM() + fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted + try: + _, _, frames = _stream_chat(client, QUESTION) + finally: + fastapi_app.dependency_overrides.clear() + + assert frames[-1]["deflected"] is False + system = scripted.seen_messages[0][0]["content"] + block_start = system.index(f'", block_start) + len("") + ] + # The pre-phase block: identity attrs + the description, verbatim. + assert block == ( + f'\n' + f"{doc.summary}\n" + "" + ) + assert "Referenced in" not in system diff --git a/tests/integration/test_docs_api.py b/tests/integration/test_docs_api.py index a350c7c..dc9f76a 100644 --- a/tests/integration/test_docs_api.py +++ b/tests/integration/test_docs_api.py @@ -869,9 +869,13 @@ def _image_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.""" + call — these end-to-end mechanics do not change with it. Phase 125 + (task 03) widened the seam with the optional ``context`` kwarg (the + markdown reference context) — the fake accepts (and ignores) it: + these fixtures carry no image references, so the seam gets + ``context=None``.""" - async def _fake(llm, *, data, source, rel, full_path): + async def _fake(llm, *, data, source, rel, full_path, context=None): return description monkeypatch.setattr(rag_importer, "_describe_or_skip", _fake) diff --git a/tests/integration/test_importer.py b/tests/integration/test_importer.py new file mode 100644 index 0000000..8714e41 --- /dev/null +++ b/tests/integration/test_importer.py @@ -0,0 +1,446 @@ +"""Integration: phase 125, task 03 — the image-anchor enrichment through +the REAL import pipeline (real compose Postgres, the real +``_describe_or_skip`` → ``describe_image`` seam, the mock vision +client — NO seam patch). + +A markdown file referencing an image in the SAME source → the vision +call receives the markdown context (appended after the locked +``DESCRIBE_PROMPT``), the image doc's ``image_context`` is set, its +``content``/``summary`` is ``\\n\\n``, and its +chunks are embedded from the enriched content. A standalone image (no +markdown reference) → ``image_context`` NULL, ``content`` the bare +description, the bare locked prompt — byte-identical to pre-phase-125. + +Phase 125, task 04 (the markdown-FIRST case) — a markdown indexed while +its referenced image has no document yet stores the reference on the +markdown doc's ``image_context`` (the ``IMAGE_CONTEXT:`` record); the +image that appears later picks the context up at index time (the +task-03 disk scan) or, when the markdown file is gone from disk by +then, through the post-index enrichment pass (the stored doc rows — +record as the index, stored content as the source). Pinned here: the +record-then-image sync pair (the record stored, not cleared), the +pass-only path (markdown file deleted from disk, ``prune=False``), and +the existing-image case (the markdown's record stays NULL — the +task-03 path owns that case). + +The unit-level mechanics (the prefix shape, the scan's combined string, +the seam forwarding, the changed-image re-scan, the record formatter + +parser, the pass's idempotency/fail-soft) are pinned in +``tests/unit/test_image_anchor_context.py``; the task-06 story suite +pins the E2E surface. +""" +from __future__ import annotations + +import asyncio +import base64 + +from sqlalchemy import select + +from app.config import Settings +from app.models import Chunk, Document +from app.rag.importer import import_sources +from app.rag.summarizer import DESCRIBE_PROMPT +from tests.fakes import FakeEmbedder + +#: A real 1×1 transparent PNG (the phase-122 fixture — the importer is +#: content-agnostic, the bytes just need to be plausible image bytes). +PNG_1X1 = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII=" +) + +DESCRIPTION = ( + "A network diagram of the homelab VLANs: the core switch, the " + "router, and three labeled subnets." +) + +#: The exact context prefix the task-03 extension appends to the locked +#: prompt (``app.rag.summarizer.describe_image``). +CONTEXT_PREFIX = "\n\nThis image is referenced in documentation with the following context:\n" + + +class _MockVisionLLM(FakeEmbedder): + """The phase-122 mock VISION client: the CHAT model answers the + multimodal describe call with the fixed :data:`DESCRIPTION` (the + text part is recorded — ``text_parts``); text (``lite``) calls keep + the ``FakeEmbedder`` behaviour. The REAL + ``import_sources`` → ``_describe_or_skip`` → ``describe_image`` + chain runs against it (no seam patch).""" + + DESCRIPTION = DESCRIPTION + + def __init__(self) -> None: + super().__init__() + self.chat_models: list[str | None] = [] + self.text_parts: list[str] = [] + + 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): + self.text_parts.append(user[0]["text"]) + return self.DESCRIPTION + first = user.split() + return "Summary of " + (first[0] if first else "") + + +def _image_llm[ + LLM: FakeEmbedder +](tmp_path, llm_cls: type[LLM] = _MockVisionLLM, **kwargs) -> LLM: + """A fake LLM with the phase-122 image knobs (toggle ON; the image + dir defaults under *tmp_path*). *llm_cls* may be the mock vision + client (the default — ``text_parts``/``chat_models`` state).""" + 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 _cleanup_source(db, source: str) -> None: + for doc in db.scalars(select(Document).where(Document.source == source)).all(): + db.delete(doc) + db.commit() + + +#: A markdown file that references ``pic.png`` on line 3 — one context +#: paragraph before and one after (the task-02 window). +GUIDE_MD = ( + "The restore order diagram explains the backup procedure.\n" + "\n" + "![Restore order](pic.png)\n" + "\n" + "Only run the restore script after the checks pass.\n" +) + + +def _expected_block(source: str) -> str: + """The task-03-shape combined string *GUIDE_MD* yields for + ``/guide.md`` (the disk scan AND the task-04 pass build + exactly this).""" + return ( + f"Referenced in {source}/guide.md:\n" + '- Alt text: "Restore order"\n' + " Context: The restore order diagram explains the backup procedure.\n" + "\n" + "Only run the restore script after the checks pass." + ) + + +#: The ``IMAGE_CONTEXT:`` record the markdown-side bookkeeping (phase +#: 125, task 04, work 1) stores for *GUIDE_MD* — the reference on line +#: 3, 2 context paragraphs. +RECORD = ( + "IMAGE_CONTEXT:\n" + '- [pic.png] (line 3): "Restore order" — 2 paragraphs of context' +) + + +def test_markdown_referenced_image_gets_context_and_enriched_chunks( + db, tmp_path +) -> None: + """A markdown file referencing an image in the same source → sync + with ``images=True`` (the REAL seam — no patch) → the vision call + carries the context after the locked prompt, the image doc's + ``image_context`` is set, its ``content`` includes the context + (context first, then the description), and its chunks are embedded + from the enriched content (the ``is_summary`` chunk mirrors it).""" + source_root = tmp_path / "anchoredkb" + source_root.mkdir() + (source_root / "guide.md").write_text( + "The backup procedure diagram below shows the restore order.\n" + "\n" + "![Backup procedure diagram](diagram.png)\n" + "\n" + "Run the restore script only after the diagram's checks pass.\n", + encoding="utf-8", + ) + (source_root / "diagram.png").write_bytes(PNG_1X1) + llm = _image_llm(tmp_path) + try: + summary = asyncio.run(import_sources([source_root], llm, session=db)) + assert (summary.files, summary.added, summary.images_failed) == (2, 2, 0) + + doc = db.scalar( + select(Document).where( + Document.source == source_root.name, Document.path == "diagram.png" + ) + ) + assert doc is not None and doc.is_image is True and doc.image_path is not None + + expected_context = ( + f"Referenced in {source_root.name}/guide.md:\n" + '- Alt text: "Backup procedure diagram"\n' + " Context: The backup procedure diagram below shows the restore order.\n" + "\n" + "Run the restore script only after the diagram's checks pass." + ) + # The vision model received the context — appended AFTER the + # locked prompt (the ``IMAGE_DESCRIPTION_MODE`` marker is + # untouched). The describe call (the ONLY multimodal one) went + # to the CHAT model (``chat_models`` also carries the markdown + # doc's ``lite`` summary call — the phase-30 path). + assert llm.chat_models[0] == llm.settings.llm_chat_model + assert llm.text_parts == [f"{DESCRIBE_PROMPT}{CONTEXT_PREFIX}{expected_context}"] + + # ``image_context`` stores the combined context (task 05's + # retrieval display reads from here). + assert doc.image_context == expected_context + # The enriched content — context FIRST, then the description — + # IS the summary (the phase-122 convention) and the chunks are + # embedded from it (768 dims, the summary chunk mirrors it). The + # plain-text packer joins the enriched paragraphs with single + # newlines (the chunker's normalization, PLAN §5), so the content + # chunk carries the packed form and the summary chunk the raw + # text. + expected_content = f"{expected_context}\n\n{DESCRIPTION}" + assert doc.content == expected_content + assert doc.summary == expected_content + packed_content = "\n".join(expected_content.split("\n\n")) + 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] == [packed_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 == expected_content + # Both enriched forms reached the embedder (the markdown doc's + # two texts complete the four embedded strings). + embedded = [t for batch in llm.calls for t in batch] + assert len(embedded) == 4 + assert packed_content in embedded and expected_content in embedded + for c in chunks: + assert c.embedding is not None and len(c.embedding) == 768 + + # The referencing markdown stays a plain text doc (the + # ``image_context`` column is an image-doc field — NULL). + md = db.scalar( + select(Document).where( + Document.source == source_root.name, Document.path == "guide.md" + ) + ) + assert md is not None and md.is_image is False and md.image_context is None + finally: + _cleanup_source(db, source_root.name) + + +def test_standalone_image_keeps_null_context_and_bare_description(db, tmp_path) -> None: + """A standalone image (no markdown reference in the source — a + sibling markdown exists but references nothing) → ``image_context`` + NULL, ``content`` the bare description, the vision message the bare + locked prompt — byte-identical to pre-phase-125 (the chunk set and + the embedded text are exactly the phase-122 shape).""" + source_root = tmp_path / "standalonekb" + source_root.mkdir() + (source_root / "notes.md").write_text("# Notes\n\nBody text.\n", encoding="utf-8") + (source_root / "diagram.png").write_bytes(PNG_1X1) + llm = _image_llm(tmp_path) + try: + summary = asyncio.run(import_sources([source_root], llm, session=db)) + assert (summary.files, summary.added, summary.images_failed) == (2, 2, 0) + + doc = db.scalar( + select(Document).where( + Document.source == source_root.name, Document.path == "diagram.png" + ) + ) + assert doc is not None and doc.is_image is True + assert doc.image_context is None, "standalone: the context stays NULL" + assert doc.content == DESCRIPTION, "standalone: the bare description" + assert doc.summary == DESCRIPTION + assert llm.text_parts == [DESCRIBE_PROMPT], "standalone: the bare prompt" + chunks = db.scalars(select(Chunk).where(Chunk.document_id == doc.id)).all() + assert all(c.content == DESCRIPTION for c in chunks) + for c in chunks: + assert c.embedding is not None and len(c.embedding) == 768 + finally: + _cleanup_source(db, source_root.name) + + +# --------------------------------------------------------------------------- +# Phase 125, task 04 — the markdown-FIRST case: the ``IMAGE_CONTEXT:`` +# record on the markdown doc + the post-index enrichment pass. +# --------------------------------------------------------------------------- + + +def test_markdown_indexed_before_image_stores_record_and_image_picks_it_up( + db, tmp_path +) -> None: + """Task 04's primary scenario — a markdown is indexed while the + image it references does not yet exist: the reference is stored on + the markdown doc's ``image_context`` (the ``IMAGE_CONTEXT:`` + record). The image file then appears and is synced: the image doc + is enriched with the markdown's context (the task-03 disk scan — + the markdown is still on disk), and the markdown's record is NOT + cleared (ASSUMPTION 4).""" + source_root = tmp_path / "beforekb" + source_root.mkdir() + (source_root / "guide.md").write_text(GUIDE_MD, encoding="utf-8") + llm = _image_llm(tmp_path) + try: + # Run 1 — the markdown alone: the record is stored, no image + # doc exists. + s1 = asyncio.run(import_sources([source_root], llm, session=db)) + assert (s1.files, s1.added) == (1, 1) + md = db.scalar( + select(Document).where( + Document.source == source_root.name, Document.path == "guide.md" + ) + ) + assert md is not None and md.image_context == RECORD + assert ( + db.scalar( + select(Document).where( + Document.source == source_root.name, Document.path == "pic.png" + ) + ) + is None + ) + + # Run 2 — the image appears: enriched at index time (the + # task-03 disk scan sees the on-disk markdown), the record + # survives. + (source_root / "pic.png").write_bytes(PNG_1X1) + s2 = asyncio.run(import_sources([source_root], llm, session=db)) + assert (s2.added, s2.unchanged) == (1, 1) + block = _expected_block(source_root.name) + doc = db.scalar( + select(Document).where( + Document.source == source_root.name, Document.path == "pic.png" + ) + ) + assert doc is not None and doc.is_image is True + assert doc.image_context == block + expected_content = f"{block}\n\n{DESCRIPTION}" + assert doc.content == expected_content + assert doc.summary == expected_content + # The describe call carried the context after the locked prompt. + assert llm.text_parts == [f"{DESCRIBE_PROMPT}{CONTEXT_PREFIX}{block}"] + md2 = db.scalar( + select(Document).where( + Document.source == source_root.name, Document.path == "guide.md" + ) + ) + assert md2 is not None and md2.image_context == RECORD, "the record is NOT cleared" + finally: + _cleanup_source(db, source_root.name) + + +def test_enrichment_pass_enriches_when_markdown_file_is_gone_from_disk( + db, tmp_path +) -> None: + """The post-index enrichment pass (task 04, work 3) — the case the + task-03 disk scan CANNOT cover: the markdown is indexed first (the + record lands on its doc row), its file is then removed from disk + (``prune=False`` keeps the row), and only then does the image + appear. The image is indexed BARE (the disk scan finds no + reference), and the pass enriches it from the markdown doc's + STORED rows: the record identifies the referencing markdown, the + reference is re-extracted from the stored content, and the + re-describe goes out WITH the context (a second vision call — the + first, at index time, was bare).""" + source_root = tmp_path / "passkb" + source_root.mkdir() + (source_root / "guide.md").write_text(GUIDE_MD, encoding="utf-8") + llm = _image_llm(tmp_path) + try: + s1 = asyncio.run(import_sources([source_root], llm, session=db)) + assert s1.added == 1 + md = db.scalar( + select(Document).where( + Document.source == source_root.name, Document.path == "guide.md" + ) + ) + assert md is not None and md.image_context == RECORD + + # The markdown file disappears from disk (``prune=False`` keeps + # the row); the image appears. + (source_root / "guide.md").unlink() + (source_root / "pic.png").write_bytes(PNG_1X1) + s2 = asyncio.run(import_sources([source_root], llm, session=db)) + # The image is added (bare) and then enriched by the pass + # (updated) — both in this run. + assert (s2.files, s2.added, s2.updated) == (1, 1, 1) + block = _expected_block(source_root.name) + doc = db.scalar( + select(Document).where( + Document.source == source_root.name, Document.path == "pic.png" + ) + ) + assert doc is not None and doc.is_image is True + assert doc.image_context == block + expected_content = f"{block}\n\n{DESCRIPTION}" + assert doc.content == expected_content + assert doc.summary == expected_content + # The re-embedded pipeline (the pass replaced the bare chunks). + packed = "\n".join(expected_content.split("\n\n")) + chunks = db.scalars(select(Chunk).where(Chunk.document_id == doc.id)).all() + assert [c.content for c in chunks if not c.is_summary] == [packed] + assert [c.content for c in chunks if c.is_summary] == [expected_content] + # Two vision calls: the bare index-time describe (the disk scan + # saw no reference) and the pass's context-carrying re-describe. + assert llm.text_parts == [DESCRIBE_PROMPT, f"{DESCRIBE_PROMPT}{CONTEXT_PREFIX}{block}"] + # The markdown doc's record survives (ASSUMPTION 4). + md2 = db.scalar( + select(Document).where( + Document.source == source_root.name, Document.path == "guide.md" + ) + ) + assert md2 is not None and md2.image_context == RECORD + finally: + _cleanup_source(db, source_root.name) + + +def test_markdown_referencing_existing_image_keeps_null_record(db, tmp_path) -> None: + """An EXISTING image doc (indexed before the markdown ever + existed): the markdown's record stays NULL when it is indexed (the + task-03 path owns that case — enrichment at IMAGE-index time), the + unchanged image is not re-scanned in that run, and the image picks + up the context on its NEXT re-index (bytes changed) via the + task-03 disk scan — the markdown's record is NULL throughout.""" + source_root = tmp_path / "existingkb" + source_root.mkdir() + (source_root / "pic.png").write_bytes(PNG_1X1) + llm = _image_llm(tmp_path) + try: + s1 = asyncio.run(import_sources([source_root], llm, session=db)) + assert s1.added == 1 + + # Run 2 — the markdown appears (the image is unchanged). + (source_root / "guide.md").write_text(GUIDE_MD, encoding="utf-8") + s2 = asyncio.run(import_sources([source_root], llm, session=db)) + assert (s2.added, s2.unchanged) == (1, 1) + md = db.scalar( + select(Document).where( + Document.source == source_root.name, Document.path == "guide.md" + ) + ) + assert md is not None and md.image_context is None + doc = db.scalar( + select(Document).where( + Document.source == source_root.name, Document.path == "pic.png" + ) + ) + assert doc is not None and doc.image_context is None and doc.content == DESCRIPTION + + # Run 3 — the image changes: the re-index re-scans the on-disk + # markdown (task 03) and enriches; the record is still NULL. + (source_root / "pic.png").write_bytes(PNG_1X1 + b"changed") + s3 = asyncio.run(import_sources([source_root], llm, session=db)) + assert (s3.added, s3.updated, s3.unchanged) == (0, 1, 1) + block = _expected_block(source_root.name) + doc3 = db.scalar( + select(Document).where( + Document.source == source_root.name, Document.path == "pic.png" + ) + ) + assert doc3 is not None and doc3.image_context == block + assert doc3.content == f"{block}\n\n{DESCRIPTION}" + md3 = db.scalar( + select(Document).where( + Document.source == source_root.name, Document.path == "guide.md" + ) + ) + assert md3 is not None and md3.image_context is None + finally: + _cleanup_source(db, source_root.name) diff --git a/tests/integration/test_migration_0023.py b/tests/integration/test_migration_0023.py new file mode 100644 index 0000000..9e6caed --- /dev/null +++ b/tests/integration/test_migration_0023.py @@ -0,0 +1,335 @@ +"""Integration: migration 0023 (documents.image_context) schema contract +(phase 125, task 01). + +Drives the **real Alembic engine** against the live dev database +(``podman compose up -d db``), mirroring the house pattern of +``test_migration_0022.py`` (information_schema assertions on the state +the migration must leave). The tests target the 0022 → 0023 step +explicitly so later migrations cannot break the pins: + +* upgrade 0022 → 0023 → ``image_context`` exists with the full + contract — TEXT, NULLABLE, no server default (NULL is the + standalone-image / pre-phase value, LOCKED A1/A4) — while the 0022 + ``documents`` schema (``is_image`` + ``image_path`` from phase 122, + ``content``/``content_hash`` NOT NULL, the (source, path) unique + constraint) survives; +* a ``documents`` row inserted while the DB is at 0022 (a pre-phase + image doc) reads ``image_context IS NULL`` after the upgrade — the + pre-phase round-trip pin; +* the ORM contract agrees: a freshly inserted image ``Document`` WITH + ``image_context`` round-trips it through a fresh session, and one + WITHOUT (standalone image / text doc) reads ``image_context is None``; +* downgrade to 0022 → the column is GONE (A13) while the row and its + 0022 columns survive; upgrade back to 0023 → the column is 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 = "mig0023" +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`` (0023) 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-0023 column set (the 0022 + shape — ``image_context``, when present, is omitted so its NULL + 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_0023_adds_image_context(db: Session, alembic: Config) -> None: + """Upgrade 0022 → 0023: ``image_context`` exists with the full + contract (TEXT, NULLABLE, no server default — NULL is the + standalone-image / pre-phase value, LOCKED A1) and is ABSENT at + 0022; a pre-0023 row (a phase-122 image doc) backfills to NULL; and + the 0022 table contract survives the additive upgrade.""" + command.downgrade(alembic, "0022") # start from the pre-0023 state + assert _version(db) == "0022" + assert _column(db, "image_context") is None, ( + "image_context must be absent at 0022" + ) + + pre_id = _insert_sql(db, PATH_IMAGE) # the 0022 column set (a pre-125 row) + try: + command.upgrade(alembic, "0023") + assert _version(db) == "0023", "alembic_version must be at 0023" + + context = _column(db, "image_context") + assert context is not None, "documents.image_context is missing" + assert context[0] == "text", "image_context must be TEXT" + assert context[1] == "YES", "image_context must be NULLABLE" + assert context[2] is None, ( + "image_context must carry NO server default — NULL is the" + " standalone-image / pre-phase value (A1, A4)" + ) + + # The pre-0023 row (inserted with the 0022 column set) reads + # NULL — every pre-phase row keeps its byte-identical state. + row = db.execute( + text("SELECT image_context FROM documents WHERE id = :id"), + {"id": pre_id}, + ).fetchone() + assert row is not None, "the pre-0023 row must survive the upgrade" + assert row[0] is None, ( + "a pre-phase image doc must backfill image_context to NULL" + ) + + # A row written without the column reads the same (an omitted + # image_context inserts NULL — the text-doc / standalone value). + new_id = _insert_sql(db, PATH_TEXT) + try: + backfilled = db.execute( + text("SELECT image_context FROM documents WHERE id = :id"), + {"id": new_id}, + ).fetchone() + assert backfilled == (None,), ( + "an omitted image_context must read NULL" + ) + finally: + _delete(db, new_id) + + # The 0022 schema survives the additive upgrade. + is_image = _column(db, "is_image") + assert ( + is_image is not None + and is_image[0] == "boolean" + and is_image[1] == "NO" + and is_image[2] == "false" + ), "documents.is_image (0022) must keep its 0022 contract" + image_path = _column(db, "image_path") + assert ( + image_path is not None + and image_path[0] == "text" + and image_path[1] == "YES" + ), "documents.image_path (0022) must keep its 0022 contract" + content = _column(db, "content") + assert content is not None and content[0] == "text" and content[1] == "NO", ( + "documents.content (0001) must keep its 0022 contract" + ) + 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_context_round_trip(db: Session, alembic: Config) -> None: + """The ORM contract agrees with the column contract: a freshly + inserted image ``Document`` WITH ``image_context`` round-trips the + string through a FRESH session, and one WITHOUT it (a standalone + image, LOCKED A1/A4) reads ``image_context is None``.""" + command.upgrade(alembic, "head") + enriched = Document( + source=SOURCE, + path=PATH_IMAGE, + full_path=f"/tmp/{SOURCE}/{PATH_IMAGE}", + title="diagram", + content="A description of the diagram.", + content_hash="1" * 64, + is_image=True, + image_path="/srv/bor-images/diagram.png", + image_context=( + "![Deployment diagram](diagram.png)\n\nThe diagram\n" + "shows the cluster layout and how the VMs connect." + ), + ) + standalone = Document( + source=SOURCE, + path="standalone.png", + full_path=f"/tmp/{SOURCE}/standalone.png", + title="standalone", + content="A description of a standalone image.", + content_hash="2" * 64, + is_image=True, + image_path="/srv/bor-images/standalone.png", + ) + db.add(enriched) + db.add(standalone) + db.commit() + try: + with SessionLocal() as fresh: + reloaded_enriched = fresh.get(Document, enriched.id) + assert reloaded_enriched is not None, "the image row must be readable" + assert reloaded_enriched.image_context == enriched.image_context, ( + "image_context must round-trip the exact context text" + ) + reloaded_standalone = fresh.get(Document, standalone.id) + assert reloaded_standalone is not None + assert reloaded_standalone.image_context is None, ( + "an omitted image_context must read NULL (standalone)" + ) + finally: + _delete(db, enriched.id) + _delete(db, standalone.id) + + +def test_downgrade_to_0022_drops_the_column(db: Session, alembic: Config) -> None: + """Downgrade 0023 → 0022: the ``image_context`` column is gone + (A13 — fully reversible) while the row, its 0022 columns (including + the phase-122 image pair) and the table's unique constraint + survive.""" + 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", + image_context="![alt](diagram.png)\n\nSurrounding paragraph.", + ) + db.add(row) + db.commit() + try: + command.downgrade(alembic, "0022") + assert _version(db) == "0022" + assert _column(db, "image_context") is None, ( + "image_context must be dropped" + ) + + surviving = db.execute( + text( + "SELECT source, path, content, content_hash, is_image," + " image_path FROM documents WHERE id = :id" + ), + {"id": row.id}, + ).fetchone() + assert surviving is not None, "the row must survive the column drop" + assert surviving[0] == SOURCE and surviving[1] == PATH_IMAGE + assert surviving[2] == "A description of the diagram." + assert surviving[3] == "3" * 64 + assert surviving[4] is True, "is_image (0022) must survive the drop" + assert surviving[5] == "/srv/bor-images/diagram.png" + + 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_column(db: Session, alembic: Config) -> None: + """Downgrade to 0022, then upgrade back to 0023: the column is back + with the full contract (TEXT, NULLABLE, no server default) — a + clean upgrade → downgrade → upgrade round-trip (A13).""" + command.downgrade(alembic, "0022") + command.upgrade(alembic, "head") + assert _version(db) == "0023", ( + "round-trip upgrade must land at head (0023)" + ) + + context = _column(db, "image_context") + assert context is not None, "documents.image_context must be back" + assert context[0] == "text", "image_context must be TEXT after the round-trip" + assert context[1] == "YES", ( + "image_context must be NULLABLE after the round-trip" + ) + assert context[2] is None, ( + "image_context must still carry NO server default" + ) diff --git a/tests/unit/test_image_anchor_context.py b/tests/unit/test_image_anchor_context.py new file mode 100644 index 0000000..2b0f9f3 --- /dev/null +++ b/tests/unit/test_image_anchor_context.py @@ -0,0 +1,1131 @@ +"""Unit: phase 125, task 03 — enriching image descriptions with markdown +context (LOCKED A1/A2/A3). + +Task 03 is the PRIMARY enrichment path (LOCKED A2 — enrichment happens +at image-index time): when an image file is (re)indexed, the same +source's importable markdown files are scanned for ``![alt](path)`` +references that name it (the task-02 +:func:`app.rag.md_images.extract_image_refs`); when found, the combined +context string (``Referenced in /:`` + alt text + +surrounding paragraphs) is (a) appended AFTER the locked +``DESCRIBE_PROMPT`` in the vision call (the model learns what the +image is for — the description remains the primary content, LOCKED +A3) and (b) stored on the doc's ``image_context`` (task 05's retrieval +display), with the doc's ``content`` (and so its ``summary`` and +embedded chunks) becoming ``\\n\\n``. A +standalone image (no markdown reference) stays byte-identical to +pre-phase: ``image_context`` NULL, bare description, bare prompt. + +Pinned here: the ``describe_image`` context extension (``None`` → +byte-identical message; provided → exact prefix after the locked +prompt; the fail-soft contract unchanged); the seam's context +forwarding; the context scan's combined-string shape (single/multiple +references, multiple files, non-markdown + hidden + fenced skips, +filename matching); and the full pipeline mechanics through +``import_sources`` (enriched content + stored context, the +standalone no-op, and the changed-image re-scan that re-scans — and +can drop — the context). + +Phase 125, task 04 (the markdown-FIRST case, LOCKED A1) — a markdown +file (re)indexed while a reference's image has no document yet in the +source records those references on ITS OWN ``image_context`` (the +``IMAGE_CONTEXT:`` record: resolved path + line + alt text + +context-paragraph count), so the post-index enrichment pass can pick +them up when the image is later indexed. Pinned here: the record +formatter (exact shape, the empty-list → ``""`` contract) and its +parser (round-trip, the fail-soft on malformed input); the reference +resolution rule (root-absolute, nested, escaping); the bookkeeping +(no refs / all-exist → ``None``; missing → the record; re-indexed +markdown with the image now existing → back to ``None``); and the +enrichment pass itself (stored-rows context rebuild, the idempotency ++ selectivity, the fail-soft on a missing copy / failed re-description, +the counters). The task-06 story suite pins the E2E surface; the +task-02 extraction unit pins live in +``tests/unit/test_md_images.py``. +""" +from __future__ import annotations + +import asyncio +import base64 +from pathlib import Path +from typing import Any + +import pytest +from sqlalchemy import select + +from app.config import Settings +from app.models import Chunk, Document +from app.rag.importer import ( + IMAGE_CONTEXT_HEADER, + ImportSummary, + _describe_or_skip, + _enrich_pending_images, + _format_missing_image_refs, + _markdown_image_context, + _missing_image_context, + _parse_missing_image_refs, + _pending_image_context, + _resolve_ref_path, + _source_root_for, + import_sources, +) +from app.rag.llm import EmbeddingError, LLMError +from app.rag.md_images import ImageRef +from app.rag.summarizer import DESCRIBE_PROMPT, describe_image +from tests.fakes import FakeEmbedder + +#: A real 1×1 transparent PNG (the phase-122 fixture — the importer is +#: content-agnostic, the bytes just need to be plausible image bytes). +PNG_1X1 = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII=" +) + +DESCRIPTION = "A red square on a white background." + +#: The exact context prefix the task-03 extension appends to the +#: locked prompt (pinned here AND in the summarizer's docstring). +CONTEXT_PREFIX = "\n\nThis image is referenced in documentation with the following context:\n" + + +def _settings(**kwargs: Any) -> Settings: + """Build Settings without reading a .env file (deterministic tests).""" + kwargs.setdefault("_env_file", None) + return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime) + + +def _image_settings(tmp_path: Path, **kwargs: Any) -> Settings: + """Phase-122 image settings (toggle ON; image dir under *tmp_path*).""" + kwargs.setdefault("images", True) + kwargs.setdefault("image_dir", str(tmp_path / "images")) + return _settings(**kwargs) + + +def _image_llm[ + LLM: FakeEmbedder +](tmp_path: Path, llm_cls: type[LLM] = FakeEmbedder, **kwargs: Any) -> LLM: + """A fake LLM whose settings carry the phase-122 image knobs.""" + llm = llm_cls() + llm.settings = _image_settings(tmp_path, **kwargs) + return llm + + +def _cleanup_source(db, source: str) -> None: + for doc in db.scalars(select(Document).where(Document.source == source)).all(): + db.delete(doc) + db.commit() + + +class _VisionMock(FakeEmbedder): + """Chat-model mock for the ``describe_image`` / seam pins: the + multimodal message is recorded (the text part is the assertion + surface for the context prefix) and a fixed reply is returned (or + the exception raised). Subclasses ``FakeEmbedder`` so it satisfies + BOTH the summarizer's ``SummaryLLM`` protocol and the importer's + ``Embedder`` protocol (the ``embed`` surface is inherited, unused + here).""" + + def __init__(self, reply: str | Exception = DESCRIPTION) -> None: + super().__init__() + self.reply = reply + self.messages: list[Any] = [] + self.text_parts: list[str] = [] + self.model: str | None = None + + async def chat( + self, messages: list[dict[str, Any]], model: str | None = None + ) -> str: + self.messages = list(messages) + self.model = model + user = next((m["content"] for m in messages if m.get("role") == "user"), "") + if isinstance(user, list): + self.text_parts = [part["text"] for part in user if part["type"] == "text"] + if isinstance(self.reply, Exception): + raise self.reply + return self.reply + + +class _VisionEmbedder(FakeEmbedder): + """Fake embedder + vision client for the ``import_sources`` pipeline + pins: the multimodal describe call gets the fixed + :data:`DESCRIPTION` (the text part is recorded — ``text_parts``), + text (``lite``) calls keep the ``FakeEmbedder`` behaviour.""" + + DESCRIPTION = DESCRIPTION + + def __init__(self) -> None: + super().__init__() + self.text_parts: list[str] = [] + + async def chat( + 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"), "") + if isinstance(user, list): + self.text_parts.append(user[0]["text"]) + return self.DESCRIPTION + first = user.split() + return "Summary of " + (first[0] if first else "") + + +# --------------------------------------------------------------------------- +# The ``describe_image`` extension (summarizer) — the locked prompt stays +# byte-identical; the context is appended AFTER it. +# --------------------------------------------------------------------------- + + +def test_describe_image_without_context_is_byte_identical() -> None: + """``context=None`` (the default — a standalone image) sends the BARE + multimodal message: the text part is exactly ``DESCRIBE_PROMPT`` and + the image part is unchanged — byte-identical to pre-phase-125.""" + mock = _VisionMock() + assert ( + asyncio.run(describe_image(mock, data=PNG_1X1, mime="image/png")) == DESCRIPTION + ) + assert mock.text_parts == [DESCRIBE_PROMPT] + content = mock.messages[0]["content"] + assert content[0] == {"type": "text", "text": DESCRIBE_PROMPT} + b64 = base64.b64encode(PNG_1X1).decode("ascii") + assert content[1] == { + "type": "image_url", + "image_url": {"url": f"data:image/png;base64,{b64}"}, + } + assert mock.model == mock.settings.llm_chat_model + + +def test_describe_image_with_context_appends_after_the_locked_prompt() -> None: + """``context`` is appended to the text part AFTER the locked + ``DESCRIBE_PROMPT`` (marker + instruction stay byte-identical — the + ``IMAGE_DESCRIPTION_MODE`` mock key is untouched): the exact task-03 + prefix, then the context. The rest of the message (the image part, + the CHAT model) is unchanged.""" + context = 'Referenced in S/guide.md:\n- Alt text: "pic"\n Context: why not' + mock = _VisionMock() + assert ( + asyncio.run( + describe_image(mock, data=PNG_1X1, mime="image/png", context=context) + ) + == DESCRIPTION + ) + (text,) = mock.text_parts + assert text == f"{DESCRIBE_PROMPT}{CONTEXT_PREFIX}{context}" + assert text.startswith(DESCRIBE_PROMPT), "the locked prompt is untouched" + content = mock.messages[0]["content"] + assert content[0] == {"type": "text", "text": text} + assert content[1]["type"] == "image_url" + assert mock.model == mock.settings.llm_chat_model + + +def test_describe_image_context_keeps_the_fail_soft_contract() -> None: + """The context changes ONLY the text part: the reply is still + stripped + capped at ``summary_max_chars``, and a client error still + fails soft (``None``) — the context neither raises nor leaks.""" + long_reply = "x" * 500 + mock = _VisionMock(long_reply) + capped = asyncio.run( + describe_image( + mock, + data=PNG_1X1, + mime="image/png", + context="ctx", + settings=_settings(summary_max_chars=100), + ) + ) + assert capped == "x" * 100 + failing = _VisionMock(LLMError("simulated vision-model failure (test sentinel)")) + assert ( + asyncio.run( + describe_image(failing, data=PNG_1X1, mime="image/png", context="ctx") + ) + is None + ) + blank = _VisionMock(" \n") + assert ( + asyncio.run(describe_image(blank, data=PNG_1X1, mime="image/png", context="ctx")) + is None + ) + + +# --------------------------------------------------------------------------- +# The seam — ``_describe_or_skip`` forwards the context verbatim. +# --------------------------------------------------------------------------- + + +def test_describe_or_skip_forwards_the_context() -> None: + """The seam passes ``context`` through to ``describe_image`` (the + vision message's text part carries the prefix); without it the + message stays the bare locked prompt.""" + ctx = "Referenced in S/guide.md:\n- Alt text: \"pic\"" + mock = _VisionMock() + assert ( + asyncio.run( + _describe_or_skip( + mock, + data=PNG_1X1, + source="S", + rel="pic.png", + full_path=Path("/src/pic.png"), + context=ctx, + ) + ) + == DESCRIPTION + ) + assert mock.text_parts == [f"{DESCRIBE_PROMPT}{CONTEXT_PREFIX}{ctx}"] + + mock_bare = _VisionMock() + asyncio.run( + _describe_or_skip( + mock_bare, + data=PNG_1X1, + source="S", + rel="pic.png", + full_path=Path("/src/pic.png"), + ) + ) + assert mock_bare.text_parts == [DESCRIBE_PROMPT] + + +# --------------------------------------------------------------------------- +# The context scan — ``_source_root_for`` + ``_markdown_image_context``. +# --------------------------------------------------------------------------- + + +def test_source_root_for_top_level_and_nested() -> None: + """The walk's ``full_path == root / rel`` invariant: the root is + *full_path* up by the number of DIRECTORY components of *rel*.""" + assert ( + _source_root_for(Path("/src/pic.png"), "pic.png") == Path("/src") + ) + assert ( + _source_root_for(Path("/src/sub/dir/guide.md"), "sub/dir/guide.md") + == Path("/src") + ) + + +def test_markdown_context_none_when_no_markdown_or_no_reference( + tmp_path: Path, +) -> None: + """No markdown at all / markdown with no references / markdown that + references a DIFFERENT image / a fenced reference (literal text, not + an image) → ``None`` (the standalone no-op).""" + root = tmp_path / "src" + root.mkdir() + + llm = _image_llm(tmp_path) + assert _markdown_image_context(root, llm, source="src", image_rel="pic.png") is None + + (root / "plain.md").write_text("# Plain\n\nNo references here.\n", encoding="utf-8") + assert _markdown_image_context(root, llm, source="src", image_rel="pic.png") is None + + (root / "other.md").write_text("See ![the other one](other.png) here.\n", encoding="utf-8") + assert _markdown_image_context(root, llm, source="src", image_rel="pic.png") is None + + (root / "fenced.md").write_text( + "A fence:\n\n```\n![literal](pic.png)\n```\n", encoding="utf-8" + ) + assert _markdown_image_context(root, llm, source="src", image_rel="pic.png") is None + + +def test_markdown_context_ignores_non_markdown_and_hidden_files( + tmp_path: Path, +) -> None: + """Only the source's importable ``.md``/``.markdown`` files are + scanned: a ``.txt`` reference and a hidden-dir markdown reference + never contribute context (the A9 scope rules apply exactly as to + the walk).""" + root = tmp_path / "src" + (root / ".hidden").mkdir(parents=True) + (root / "notes.txt").write_text("![alt](pic.png)\n", encoding="utf-8") + (root / ".hidden" / "guide.md").write_text("![alt](pic.png)\n", encoding="utf-8") + llm = _image_llm(tmp_path) + assert _markdown_image_context(root, llm, source="src", image_rel="pic.png") is None + + +def test_markdown_context_single_reference_exact_shape(tmp_path: Path) -> None: + """ONE reference → the exact task-03 combined string: the + ``Referenced in /:`` header, the ``- Alt text:`` bullet, + the `` Context:`` line (the task-02 context window: the paragraphs + before + after the reference paragraph — here the heading counts as + a paragraph).""" + root = tmp_path / "src" + root.mkdir() + (root / "guide.md").write_text( + "# Guide\n" + "\n" + "First paragraph explaining the diagram below.\n" + "\n" + "![The network diagram](pic.png)\n" + "\n" + "Second paragraph after the diagram.\n", + encoding="utf-8", + ) + llm = _image_llm(tmp_path) + context = _markdown_image_context(root, llm, source="src", image_rel="pic.png") + assert context == ( + "Referenced in src/guide.md:\n" + '- Alt text: "The network diagram"\n' + " Context: # Guide\n" + "\n" + "First paragraph explaining the diagram below.\n" + "\n" + "Second paragraph after the diagram." + ) + + +def test_markdown_context_multiple_references_and_files_sorted(tmp_path: Path) -> None: + """Multiple references to the SAME image in one markdown → one + bullet pair per reference (in line order); references in MULTIPLE + markdown files → one ``Referenced in`` block per file, in the + walk's sorted order, joined with a blank line. A reference with no + surrounding paragraphs gets NO ``Context:`` line; an empty alt text + pins ``\"\"``.""" + root = tmp_path / "src" + root.mkdir() + (root / "a.md").write_text("![First](pic.png)\n", encoding="utf-8") # no context + (root / "b.md").write_text( + "Intro paragraph.\n" + "\n" + "![Alt one](pic.png)\n" + "\n" + "Middle paragraph.\n" + "\n" + "![](pic.png)\n" + "\n" + "Outro paragraph.\n", + encoding="utf-8", + ) + llm = _image_llm(tmp_path) + context = _markdown_image_context(root, llm, source="src", image_rel="pic.png") + assert context == ( + "Referenced in src/a.md:\n" + '- Alt text: "First"\n' + "\n" + "Referenced in src/b.md:\n" + '- Alt text: "Alt one"\n' + " Context: Intro paragraph.\n" + "\n" + "Middle paragraph.\n" + "\n" + "![](pic.png)\n" + '- Alt text: ""\n' + " Context: ![Alt one](pic.png)\n" + "\n" + "Middle paragraph.\n" + "\n" + "Outro paragraph." + ) + + +def test_markdown_context_resolves_reference_paths_to_the_image(tmp_path: Path) -> None: + """Path-resolution matching: a reference is resolved against the + markdown file's directory (a leading ``/`` = source root) and must + EQUAL the image's source-relative path - a bare reference next to + the markdown (``docs/guide.md`` -> ``pic.png``) and a folder-carrying + one from the root (``other.md`` -> ``docs/pic.png``) both name the + nested image ``docs/pic.png``; neither names a top-level ``pic.png`` + (a reference to a different file that shares the filename is NOT a + match - LOCKED A1).""" + root = tmp_path / "src" + (root / "docs").mkdir(parents=True) + (root / "docs" / "guide.md").write_text( + "Bare reference next to the image: ![alt](pic.png)\n", encoding="utf-8" + ) + (root / "other.md").write_text( + "Folder-carrying reference from the root: ![alt2](docs/pic.png)\n", + encoding="utf-8", + ) + llm = _image_llm(tmp_path) + context = _markdown_image_context(root, llm, source="src", image_rel="docs/pic.png") + assert context == ( + "Referenced in src/docs/guide.md:\n" + '- Alt text: "alt"\n' + "\n" + "Referenced in src/other.md:\n" + '- Alt text: "alt2"' + ) + # The top-level image (a different file with the same name) is never + # matched by references that resolve to ``docs/pic.png``. + assert _markdown_image_context(root, llm, source="src", image_rel="pic.png") is None + + +def test_markdown_context_root_absolute_reference(tmp_path: Path) -> None: + """A reference with a leading ``/`` is source-ROOT-relative (a + root-absolute doc-style reference): ``![alt](/pic.png`` names the + top-level ``pic.png`` from any markdown depth.""" + root = tmp_path / "src" + (root / "sub").mkdir(parents=True) + (root / "sub" / "guide.md").write_text( + "Root-absolute reference: ![alt](/pic.png)\n", encoding="utf-8" + ) + llm = _image_llm(tmp_path) + context = _markdown_image_context(root, llm, source="src", image_rel="pic.png") + assert context == 'Referenced in src/sub/guide.md:\n- Alt text: "alt"' + # And it does NOT name a nested image of the same name. + assert _markdown_image_context(root, llm, source="src", image_rel="sub/pic.png") is None + + +def test_markdown_context_skips_unreadable_markdown( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """An unreadable markdown file (OSError) never contributes context + (fail-soft: the scan continues with the remaining files — the sync + must not break on a flaky source file).""" + root = tmp_path / "src" + root.mkdir() + (root / "bad.md").write_text("![alt](pic.png)\n", encoding="utf-8") + (root / "good.md").write_text("Good ref ![alt2](pic.png)\n", encoding="utf-8") + original = Path.read_text + + def _raising(self: Path, *args: Any, **kwargs: Any) -> str: + if self.name == "bad.md": + raise OSError("simulated unreadable file (test sentinel)") + return original(self, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", _raising) + llm = _image_llm(tmp_path) + context = _markdown_image_context(root, llm, source="src", image_rel="pic.png") + assert context is not None + assert "good.md" in context and "bad.md" not in context + + +# --------------------------------------------------------------------------- +# The pipeline — ``import_sources`` + the real seam (no patch): the +# enriched content, the stored context, and the standalone no-op. +# --------------------------------------------------------------------------- + + +def test_index_image_referenced_by_markdown_enriches_and_stores_context( + db, tmp_path: Path +) -> None: + """LOCKED A2: a markdown file in the same source references the + image → the vision call receives the context (appended after the + locked prompt), the doc's ``content`` (and ``summary``) is + ``\\n\\n``, ``image_context`` stores the + context, and the ONLY embedded text is the enriched content (the + content chunk + the ``is_summary`` chunk).""" + root = tmp_path / "anchored" + root.mkdir() + (root / "guide.md").write_text( + "Why this diagram exists.\n\n![The red square](pic.png)\n\nWhat follows it.\n", + encoding="utf-8", + ) + (root / "pic.png").write_bytes(PNG_1X1) + llm = _image_llm(tmp_path, _VisionEmbedder) + try: + summary = asyncio.run(import_sources([root], llm, session=db)) + assert (summary.files, summary.added, summary.images_failed) == (2, 2, 0) + + doc = db.scalar( + select(Document).where(Document.source == root.name, Document.path == "pic.png") + ) + assert doc is not None and doc.is_image is True + expected_context = ( + f"Referenced in {root.name}/guide.md:\n" + '- Alt text: "The red square"\n' + " Context: Why this diagram exists.\n" + "\n" + "What follows it." + ) + # (a) the vision model learned what the image is for — the text + # part carries the locked prompt + the exact context prefix. + assert llm.text_parts == [f"{DESCRIBE_PROMPT}{CONTEXT_PREFIX}{expected_context}"] + # (b) the context is stored on the doc (task 05's display source). + assert doc.image_context == expected_context + # (c) the enriched content — context first, then the description — + # IS the summary (the phase-122 convention) and the only embedded + # text (the plain-text packer joins the enriched paragraphs with + # single newlines — the chunker's normalization, PLAN §5 — so the + # content chunk carries the packed form, the summary chunk the + # raw text). + expected_content = f"{expected_context}\n\n{DESCRIPTION}" + assert doc.content == expected_content + assert doc.summary == expected_content + packed_content = "\n".join(expected_content.split("\n\n")) + 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] == [packed_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 == expected_content # stored verbatim + # The image doc's embedded texts: the packed content chunk + the + # raw enriched summary chunk (the markdown doc's two texts + # complete the four). + embedded = [t for batch in llm.calls for t in batch] + assert len(embedded) == 4 + assert packed_content in embedded and expected_content in embedded + for c in chunks: + assert c.embedding is not None and len(c.embedding) == 768 + + # Task 04 (the markdown-FIRST bookkeeping): ``guide.md`` is + # sorted BEFORE ``pic.png``, so at the markdown's indexing the + # image doc does not exist yet — the reference is recorded on + # the markdown doc's OWN ``image_context`` (the ``IMAGE_CONTEXT:`` + # record: resolved path + line + alt text + context-paragraph + # count). The image was then enriched in the same run through + # the task-03 disk scan, and the record is NOT cleared after + # enrichment (ASSUMPTION 4 — it stays the retrieval display's + # record, task 05). + md = db.scalar( + select(Document).where(Document.source == root.name, Document.path == "guide.md") + ) + assert md is not None and md.is_image is False + assert md.image_context == ( + IMAGE_CONTEXT_HEADER + "\n" + '- [pic.png] (line 3): "The red square" — 2 paragraphs of context' + ) + finally: + _cleanup_source(db, root.name) + + +def test_standalone_image_keeps_null_context_and_bare_description( + db, tmp_path: Path +) -> None: + """The pre-phase byte-identity: an image with NO markdown reference + (a sibling markdown exists but references nothing) → ``image_context`` + NULL, ``content`` the bare description, the vision message the bare + locked prompt.""" + root = tmp_path / "standalone" + root.mkdir() + (root / "notes.md").write_text("# Notes\n\nBody text.\n", encoding="utf-8") + (root / "pic.png").write_bytes(PNG_1X1) + llm = _image_llm(tmp_path, _VisionEmbedder) + try: + summary = asyncio.run(import_sources([root], llm, session=db)) + assert (summary.files, summary.added, summary.images_failed) == (2, 2, 0) + + doc = db.scalar( + select(Document).where(Document.source == root.name, Document.path == "pic.png") + ) + assert doc is not None and doc.is_image is True + assert doc.image_context is None, "standalone: the context stays NULL" + assert doc.content == DESCRIPTION, "standalone: the bare description" + assert doc.summary == DESCRIPTION + assert llm.text_parts == [DESCRIBE_PROMPT], "standalone: the bare prompt" + chunks = db.scalars(select(Chunk).where(Chunk.document_id == doc.id)).all() + assert all(c.content == DESCRIPTION for c in chunks) + finally: + _cleanup_source(db, root.name) + + +def test_changed_image_re_scans_and_can_drop_the_context( + db, tmp_path: Path +) -> None: + """The UPDATE path re-scans (a content change is a new version): + adding a referencing markdown + changing the bytes enriches the doc; + deleting the markdown + changing the bytes drops ``image_context`` + back to NULL and the content back to the bare description.""" + root = tmp_path / "rescan" + root.mkdir() + (root / "pic.png").write_bytes(PNG_1X1) + llm = _image_llm(tmp_path, _VisionEmbedder) + try: + # 1 — standalone first: NULL context, bare content. + s1 = asyncio.run(import_sources([root], llm, session=db)) + assert s1.added == 1 + doc1 = db.scalar( + select(Document).where(Document.source == root.name, Document.path == "pic.png") + ) + assert doc1 is not None + assert doc1.image_context is None and doc1.content == DESCRIPTION + + # 2 — a markdown appears and the image changes: the update re- + # scans, the context is set, the content is enriched. + (root / "guide.md").write_text( + "The why.\n\n![The red square](pic.png)\n\nThe after.\n", encoding="utf-8" + ) + (root / "pic.png").write_bytes(PNG_1X1 + b"changed") + s2 = asyncio.run(import_sources([root], llm, session=db)) + assert (s2.added, s2.updated, s2.images_failed) == (1, 1, 0) + expected_context = ( + f"Referenced in {root.name}/guide.md:\n" + '- Alt text: "The red square"\n' + " Context: The why.\n" + "\n" + "The after." + ) + doc2 = db.scalar( + select(Document).where(Document.source == root.name, Document.path == "pic.png") + ) + assert doc2 is not None + assert doc2.image_context == expected_context + assert doc2.content == f"{expected_context}\n\n{DESCRIPTION}" + assert doc2.summary == doc2.content + + # 3 — the markdown is gone and the image changes again: the + # context drops back to NULL (the scan is the current source + # state, not a ratchet). + (root / "guide.md").unlink() + (root / "pic.png").write_bytes(PNG_1X1 + b"changed-again") + s3 = asyncio.run(import_sources([root], llm, session=db)) + assert (s3.added, s3.updated, s3.images_failed) == (0, 1, 0) + doc3 = db.scalar( + select(Document).where(Document.source == root.name, Document.path == "pic.png") + ) + assert doc3 is not None + assert doc3.image_context is None + assert doc3.content == DESCRIPTION + finally: + _cleanup_source(db, root.name) + + +# --------------------------------------------------------------------------- +# Task 04 — the markdown-FIRST bookkeeping: the ``IMAGE_CONTEXT:`` record +# (work 1 + 2), the reference-resolution rule, and the post-index +# enrichment pass (work 3). +# --------------------------------------------------------------------------- + +#: A markdown file that references ``pic.png`` on line 3 — one context +#: paragraph before and one after (the task-02 window). +MD_CONTENT = ( + "The restore order diagram explains the backup procedure.\n" + "\n" + "![Restore order](pic.png)\n" + "\n" + "Only run the restore script after the checks pass.\n" +) + + +def _expected_block(source: str) -> str: + """The task-03-shape combined string *MD_CONTENT* yields for + ``/guide.md`` (the pass rebuilds exactly this from the + stored rows).""" + return ( + f"Referenced in {source}/guide.md:\n" + '- Alt text: "Restore order"\n' + " Context: The restore order diagram explains the backup procedure.\n" + "\n" + "Only run the restore script after the checks pass." + ) + + +#: The record work 1 stores on the markdown doc's ``image_context`` for +#: *MD_CONTENT* (the reference on line 3, 2 context paragraphs). +RECORD = ( + "IMAGE_CONTEXT:\n" + '- [pic.png] (line 3): "Restore order" — 2 paragraphs of context' +) + +BLOCK = _expected_block("src") + + +class _FailingVision(FakeEmbedder): + """A vision client whose multimodal call ALWAYS fails (the pass's + fail-soft pin — ``describe_image`` turns the ``LLMError`` into + ``None``).""" + + async def chat(self, messages, model=None): + self.chat_calls.append(list(messages)) + raise LLMError("simulated vision-model failure (test sentinel)") + + +class _EmbedFailingVision(FakeEmbedder): + """Vision OK (the fixed :data:`DESCRIPTION`), the EMBED endpoint + fails (the pass's rollback pin).""" + + 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): + return DESCRIPTION + first = user.split() + return "Summary of " + (first[0] if first else "") + + async def embed(self, texts): + raise EmbeddingError("simulated embed endpoint failure (test sentinel)") + + +def _seed_doc(db, **kwargs) -> Document: + kwargs.setdefault("full_path", "/tmp/seed") + doc = Document(**kwargs) + db.add(doc) + db.commit() + return doc + + +def _seed_image_doc(db, source: str, path: str, **kwargs) -> Document: + return _seed_doc( + db, + source=source, + path=path, + title=path, + content=DESCRIPTION, + content_hash="ab" * 32, + is_image=True, + **kwargs, + ) + + +def _seed_md_doc(db, source: str, path: str, content: str, image_context: str | None) -> Document: + return _seed_doc( + db, + source=source, + path=path, + title=path, + content=content, + content_hash="cd" * 32, + image_context=image_context, + ) + + +# --------------------------------------------------------------------------- +# The record — formatter (work 2) + parser (the "task 04b" shape). +# --------------------------------------------------------------------------- + + +def test_format_missing_image_refs_exact_shape() -> None: + """The exact task format: header + one bullet per reference in + reference order — the RESOLVED path in the brackets, the 1-based + line, the alt between the quotes (``""`` for an empty alt), and the + context-paragraph count (singular/plural, 0 included). Empty refs → + ``""`` (the caller stores ``None`` — the pass's ``IS NOT NULL`` gate + must see "no record" as NULL, not ``""``).""" + refs = [ + ImageRef(path="pic.png", alt_text="The red square", context="a\n\nb", line_number=42), + ImageRef(path="diagram.jpg", alt_text="", context="one", line_number=100), + ImageRef(path="x.gif", alt_text="", context="", line_number=5), + ] + assert _format_missing_image_refs(refs, "src") == ( + "IMAGE_CONTEXT:\n" + '- [pic.png] (line 42): "The red square" — 2 paragraphs of context\n' + '- [diagram.jpg] (line 100): "" — 1 paragraph of context\n' + '- [x.gif] (line 5): "" — 0 paragraphs of context' + ) + assert _format_missing_image_refs([], "src") == "" + + +def test_parse_missing_image_refs_round_trip_and_fail_soft() -> None: + """The parser is the formatter's exact inverse on well-formed + records (path, line, alt — including an alt that itself contains + ``"``) and yields NO references on anything else (the header, an + empty value, owner-edited text — fail-soft, never a raise).""" + refs = [ + ImageRef(path="pic.png", alt_text="The red square", context="a\n\nb", line_number=42), + ImageRef(path="docs/diagram.jpg", alt_text='He said "hi"', context="one", line_number=7), + ] + assert _parse_missing_image_refs(_format_missing_image_refs(refs, "src")) == [ + ("pic.png", 42, "The red square"), + ("docs/diagram.jpg", 7, 'He said "hi"'), + ] + assert _parse_missing_image_refs("IMAGE_CONTEXT:") == [] + assert _parse_missing_image_refs("") == [] + assert _parse_missing_image_refs("owner notes\n- [x.png] (line 1): \"a\" — one paragraph") == [] + + +# --------------------------------------------------------------------------- +# The resolution rule — shared by the scan, the bookkeeping, the pass. +# --------------------------------------------------------------------------- + + +def test_resolve_ref_path_rules() -> None: + """Bare reference → the markdown's directory (the source root for a + top-level markdown); a leading ``/`` → the source root; a + normalized path that still escapes (``..``) or names no file (``.``) + → ``None`` (no in-source image doc can ever match it).""" + assert _resolve_ref_path("pic.png", "guide.md") == "pic.png" + assert _resolve_ref_path("pic.png", "docs/guide.md") == "docs/pic.png" + assert _resolve_ref_path("../pic.png", "sub/docs/guide.md") == "sub/pic.png" + assert _resolve_ref_path("folder/pic.png", "guide.md") == "folder/pic.png" + assert _resolve_ref_path("/pic.png", "sub/guide.md") == "pic.png" + assert _resolve_ref_path("../../other/x.png", "sub/guide.md") is None + assert _resolve_ref_path("../x.png", "guide.md") is None + assert _resolve_ref_path(".", "guide.md") is None + + +# --------------------------------------------------------------------------- +# The markdown bookkeeping (work 1) — ``_missing_image_context``. +# --------------------------------------------------------------------------- + + +def test_missing_image_context_none_without_refs_or_when_all_exist(db) -> None: + """No references → ``None``; a reference that escapes the source + records nothing (it names no in-source image doc, ever); a reference + that DOES resolve to an existing image doc records nothing (the + task-03 path owns that case — the field stays NULL).""" + source = "mdbookkeeping" + try: + assert ( + _missing_image_context( + db, source=source, md_rel="guide.md", content="# Plain\n\nNo refs.\n" + ) + is None + ) + assert ( + _missing_image_context( + db, source=source, md_rel="guide.md", content="![a](../../x.png)\n" + ) + is None + ) + _seed_image_doc(db, source, "pic.png") + assert ( + _missing_image_context(db, source=source, md_rel="guide.md", content=MD_CONTENT) + is None + ), "the image doc exists: the task-03 path owns it, the record stays NULL" + finally: + _cleanup_source(db, source) + + +def test_missing_image_context_records_missing_refs_resolved(db) -> None: + """Every in-source reference without an image doc is recorded — the + RESOLVED source-relative path (a nested markdown resolves against + its own directory, so the record names the nested image path the + pass can match against), the line, the alt, the paragraph count.""" + source = "mdbookkeeping2" + try: + assert ( + _missing_image_context(db, source=source, md_rel="guide.md", content=MD_CONTENT) + == RECORD + ) + record = _missing_image_context( + db, + source=source, + md_rel="docs/guide.md", + content="![alt](pic.png)\n\n![other](other.png)\n", + ) + assert record == ( + "IMAGE_CONTEXT:\n" + '- [docs/pic.png] (line 1): "alt" — 1 paragraph of context\n' + '- [docs/other.png] (line 3): "other" — 1 paragraph of context' + ) + finally: + _cleanup_source(db, source) + + +# --------------------------------------------------------------------------- +# The pass's context rebuild — ``_pending_image_context`` (stored rows). +# --------------------------------------------------------------------------- + + +def test_pending_image_context_from_rows(db) -> None: + """The record is the GATE (the markdown must list the image's + source-relative path), the stored content is the SOURCE (the + reference is re-extracted from it — the task-03-shape block).""" + source = "passcontext" + try: + md = _seed_md_doc(db, source, "guide.md", MD_CONTENT, RECORD) + assert _pending_image_context(source, "pic.png", [md]) == _expected_block(source) + assert _pending_image_context(source, "other.png", [md]) is None + assert _pending_image_context(source, "pic.png", []) is None + finally: + _cleanup_source(db, source) + + +# --------------------------------------------------------------------------- +# The post-index enrichment pass (work 3) — ``_enrich_pending_images``. +# --------------------------------------------------------------------------- + + +def test_enrich_pending_images_enriches_from_stored_rows(db, tmp_path: Path) -> None: + """The markdown-FIRST case closed: the markdown's STORED record + names the image doc (``image_context IS NULL``), whose persistent + copy exists → the pass re-describes WITH the context (appended after + the locked prompt), sets ``image_context``, makes ``content`` (and + so the ``summary``) ``\\n\\n``, re-chunks + + re-embeds, counts ``updated`` + ``chunks`` + ``summaries`` — and + does NOT clear the markdown's record (ASSUMPTION 4).""" + source = "src" + copy = tmp_path / "copy.png" + copy.write_bytes(PNG_1X1) + try: + _seed_md_doc(db, source, "guide.md", MD_CONTENT, RECORD) + image_doc = _seed_image_doc(db, source, "pic.png", image_path=str(copy)) + llm = _image_llm(tmp_path, _VisionEmbedder) + summary = ImportSummary() + asyncio.run(_enrich_pending_images(db, source=source, llm=llm, summary=summary)) + + assert image_doc.image_context == BLOCK + expected_content = f"{BLOCK}\n\n{DESCRIPTION}" + assert image_doc.content == expected_content + assert image_doc.summary == expected_content + assert llm.text_parts == [f"{DESCRIBE_PROMPT}{CONTEXT_PREFIX}{BLOCK}"] + packed = "\n".join(expected_content.split("\n\n")) + chunks = db.scalars(select(Chunk).where(Chunk.document_id == image_doc.id)).all() + assert [c.content for c in chunks if not c.is_summary] == [packed] + assert [c.content for c in chunks if c.is_summary] == [expected_content] + assert all(c.embedding is not None and len(c.embedding) == 768 for c in chunks) + md = db.scalar( + select(Document).where(Document.source == source, Document.path == "guide.md") + ) + assert md is not None and md.image_context == RECORD, "the record is NOT cleared" + assert (summary.updated, summary.chunks, summary.summaries) == (1, 1, 1) + assert (summary.images_failed, summary.errors) == (0, 0) + finally: + _cleanup_source(db, source) + + +def test_enrich_pending_images_is_idempotent_and_selective(db, tmp_path: Path) -> None: + """ASSUMPTION 4's selectivity: an image doc with ``image_context`` + SET is never touched (already enriched — the task-03 path or an + earlier pass); no markdown record at all → no-op; a record that + lists a DIFFERENT image → no-op for this one; a record that lists + the image but whose STORED CONTENT no longer references it (the + record is the index, the content is the source) → no-op. None of + these makes a vision call.""" + copy = tmp_path / "copy.png" + copy.write_bytes(PNG_1X1) + + source_a = "passidempotent" + try: + _seed_md_doc(db, source_a, "guide.md", MD_CONTENT, RECORD) + _seed_image_doc(db, source_a, "pic.png", image_path=str(copy), image_context="set") + llm = _image_llm(tmp_path, _VisionEmbedder) + asyncio.run(_enrich_pending_images(db, source=source_a, llm=llm, summary=ImportSummary())) + assert llm.text_parts == [], "already enriched: no re-describe" + finally: + _cleanup_source(db, source_a) + + source_b = "passnoms" + try: + _seed_image_doc(db, source_b, "pic.png", image_path=str(copy)) + llm = _image_llm(tmp_path, _VisionEmbedder) + asyncio.run(_enrich_pending_images(db, source=source_b, llm=llm, summary=ImportSummary())) + assert llm.text_parts == [], "no markdown record: no-op" + finally: + _cleanup_source(db, source_b) + + source_c = "passotherref" + try: + other = "IMAGE_CONTEXT:\n- [other.png] (line 3): \"other\" — 1 paragraph of context" + _seed_md_doc(db, source_c, "guide.md", MD_CONTENT, other) + _seed_image_doc(db, source_c, "pic.png", image_path=str(copy)) + llm = _image_llm(tmp_path, _VisionEmbedder) + asyncio.run(_enrich_pending_images(db, source=source_c, llm=llm, summary=ImportSummary())) + assert llm.text_parts == [], "the record does not list this image" + finally: + _cleanup_source(db, source_c) + + source_d = "passstale" + try: + _seed_md_doc(db, source_d, "guide.md", "# Plain\n\nNo refs.\n", RECORD) + _seed_image_doc(db, source_d, "pic.png", image_path=str(copy)) + llm = _image_llm(tmp_path, _VisionEmbedder) + asyncio.run(_enrich_pending_images(db, source=source_d, llm=llm, summary=ImportSummary())) + assert llm.text_parts == [], "the stored content no longer references it" + finally: + _cleanup_source(db, source_d) + + +def test_enrich_pending_images_fail_soft(db, tmp_path: Path) -> None: + """A failed re-description, a missing persistent copy (both the + ``image_path`` itself and a file gone from disk), or an embed error + keeps the image doc AS-IS (the next sync retries it) — warned / + counted, never raising, never blocking the sync.""" + source_a = "passfail" + copy = tmp_path / "copy.png" + copy.write_bytes(PNG_1X1) + try: + _seed_md_doc(db, source_a, "guide.md", MD_CONTENT, RECORD) + image_doc = _seed_image_doc(db, source_a, "pic.png", image_path=str(copy)) + llm = _image_llm(tmp_path, _FailingVision) + summary = ImportSummary() + asyncio.run(_enrich_pending_images(db, source=source_a, llm=llm, summary=summary)) + assert image_doc.content == DESCRIPTION and image_doc.image_context is None + assert summary.images_failed == 1 and summary.updated == 0 + finally: + _cleanup_source(db, source_a) + + source_b = "passnobody" + try: + _seed_md_doc(db, source_b, "guide.md", MD_CONTENT, RECORD) + image_doc = _seed_image_doc( + db, source_b, "pic.png", image_path=str(tmp_path / "gone.png") + ) + llm = _image_llm(tmp_path, _VisionEmbedder) + summary = ImportSummary() + asyncio.run(_enrich_pending_images(db, source=source_b, llm=llm, summary=summary)) + assert llm.text_parts == [], "no bytes: no describe call" + assert image_doc.content == DESCRIPTION and image_doc.image_context is None + assert summary.images_failed == 1 + finally: + _cleanup_source(db, source_b) + + source_c = "passnullpath" + try: + _seed_md_doc(db, source_c, "guide.md", MD_CONTENT, RECORD) + image_doc = _seed_image_doc(db, source_c, "pic.png") # image_path None + llm = _image_llm(tmp_path, _VisionEmbedder) + summary = ImportSummary() + asyncio.run(_enrich_pending_images(db, source=source_c, llm=llm, summary=summary)) + assert llm.text_parts == [] + assert image_doc.content == DESCRIPTION and image_doc.image_context is None + assert summary.images_failed == 1 + finally: + _cleanup_source(db, source_c) + + source_d = "passembedfail" + copy2 = tmp_path / "copy2.png" + copy2.write_bytes(PNG_1X1) + try: + _seed_md_doc(db, source_d, "guide.md", MD_CONTENT, RECORD) + image_doc = _seed_image_doc(db, source_d, "pic.png", image_path=str(copy2)) + pre_chunk = Chunk(document_id=image_doc.id, position=0, content=DESCRIPTION) + db.add(pre_chunk) + db.commit() + llm = _image_llm(tmp_path, _EmbedFailingVision) + summary = ImportSummary() + asyncio.run(_enrich_pending_images(db, source=source_d, llm=llm, summary=summary)) + assert image_doc.content == DESCRIPTION and image_doc.image_context is None + assert summary.errors == 1 and summary.updated == 0 + # The rollback left the pre-pass chunk set in place (the pass's + # replaced rows are gone again). + chunks = db.scalars(select(Chunk).where(Chunk.document_id == image_doc.id)).all() + assert [c for c in chunks] == [pre_chunk] + assert chunks[0].content == DESCRIPTION + finally: + _cleanup_source(db, source_d) + + +# --------------------------------------------------------------------------- +# The full pipeline — markdown vs. image ordering through ``import_sources``. +# --------------------------------------------------------------------------- + + +def test_markdown_referencing_existing_image_keeps_null_record(db, tmp_path: Path) -> None: + """The task-03 path owns EXISTING images: with the image doc already + present when the markdown is indexed, the markdown's record stays + NULL (the image is unchanged in that run — no re-scan); the image + then picks up the context on its NEXT (re)index, when the on-disk + markdown is visible to the task-03 scan — and the markdown's record + is NULL throughout (it never recorded anything).""" + root = tmp_path / "existingkb" + root.mkdir() + (root / "pic.png").write_bytes(PNG_1X1) + llm = _image_llm(tmp_path, _VisionEmbedder) + try: + s1 = asyncio.run(import_sources([root], llm, session=db)) + assert s1.added == 1 + doc1 = db.scalar( + select(Document).where(Document.source == root.name, Document.path == "pic.png") + ) + assert doc1 is not None and doc1.image_context is None + + # Run 2 — the markdown appears (the image is unchanged). + (root / "guide.md").write_text(MD_CONTENT, encoding="utf-8") + s2 = asyncio.run(import_sources([root], llm, session=db)) + assert (s2.added, s2.unchanged) == (1, 1) + md = db.scalar( + select(Document).where(Document.source == root.name, Document.path == "guide.md") + ) + assert md is not None and md.image_context is None, "existing image: the record stays NULL" + doc2 = db.scalar( + select(Document).where(Document.source == root.name, Document.path == "pic.png") + ) + assert doc2 is not None + assert doc2.image_context is None and doc2.content == DESCRIPTION + + # Run 3 — the image changes: the re-index re-scans the on-disk + # markdown (task 03) and enriches; the markdown record is still + # NULL (unchanged in this run). + (root / "pic.png").write_bytes(PNG_1X1 + b"changed") + s3 = asyncio.run(import_sources([root], llm, session=db)) + assert (s3.added, s3.updated, s3.unchanged) == (0, 1, 1) + doc3 = db.scalar( + select(Document).where(Document.source == root.name, Document.path == "pic.png") + ) + assert doc3 is not None and doc3.image_context == _expected_block(root.name) + assert doc3.content == f"{_expected_block(root.name)}\n\n{DESCRIPTION}" + md3 = db.scalar( + select(Document).where(Document.source == root.name, Document.path == "guide.md") + ) + assert md3 is not None and md3.image_context is None + finally: + _cleanup_source(db, root.name) diff --git a/tests/unit/test_image_documents.py b/tests/unit/test_image_documents.py index 3a94fa7..43b0042 100644 --- a/tests/unit/test_image_documents.py +++ b/tests/unit/test_image_documents.py @@ -283,11 +283,21 @@ def _patch_description( """Pin the task-02 seam (``importer._describe_or_skip``) to *description* and record the BYTES it is handed (the digest-rule pin). Task 03 replaces the seam's body — the importer-side - mechanics under test here do not change with it.""" + mechanics under test here do not change with it. Phase 125 (task + 03) widened the seam with the optional ``context`` kwarg (the + markdown reference context) — the fake accepts (and ignores) it: + these fixtures carry no image references, so the seam gets + ``context=None``.""" handed: list[bytes] = [] async def _fake( - llm: importer.Embedder, *, data: bytes, source: str, rel: str, full_path: Path + llm: importer.Embedder, + *, + data: bytes, + source: str, + rel: str, + full_path: Path, + context: str | None = None, ) -> str | None: handed.append(data) return description @@ -524,7 +534,13 @@ def test_changed_image_deletes_the_stale_copy_before_replacing( state = {"desc": "v1 description"} async def _fake( - llm: importer.Embedder, *, data: bytes, source: str, rel: str, full_path: Path + llm: importer.Embedder, + *, + data: bytes, + source: str, + rel: str, + full_path: Path, + context: str | None = None, ) -> str | None: handed.append(data) return state["desc"] diff --git a/tests/unit/test_md_images.py b/tests/unit/test_md_images.py new file mode 100644 index 0000000..28a408e --- /dev/null +++ b/tests/unit/test_md_images.py @@ -0,0 +1,275 @@ +"""Unit tests: markdown image-reference extraction (phase 125, task 02). + +``extract_image_refs`` is a pure function (LOCKED A1 — the storage +content of ``Document.image_context``), so the tests pin the exact +contract the enrichment pipeline (tasks 03/04) will rely on: + +* one :class:`ImageRef` per ``![alt](path)`` reference outside code + fences (multiple per line extracted separately, same window); +* the context window — up to *N* paragraphs before and AFTER the + reference paragraph, joined with blank lines, the reference + paragraph itself excluded (the reference is already ``alt_text`` + + ``path``); +* fence semantics identical to the chunker (PLAN §5) — fences are + ATOMIC paragraphs (one context unit, blank lines inside don't split + it) and references inside a fence are literal text — skipped; +* edge cases: empty alt, start/end of file, headings, paths with + spaces, nested parens (accepted miss), empty content, no refs, + 1-based ``line_number``. +""" +from __future__ import annotations + +import pytest + +from app.rag.md_images import CONTEXT_PARAGRAPHS, ImageRef, extract_image_refs + + +def test_default_constant() -> None: + assert CONTEXT_PARAGRAPHS == 2 + doc = "P1.\n\nP2.\n\nP3.\n\n![a](a.png)\n\nQ1.\n\nQ2.\n\nQ3.\n" + assert extract_image_refs(doc) == extract_image_refs(doc, context_paragraphs=2) + + +def test_single_reference_with_surrounding_paragraphs() -> None: + doc = ( + "# Title\n" + "\n" + "First paragraph.\n" + "\n" + "Second paragraph.\n" + "\n" + "![alt](image.png)\n" + "\n" + "Third paragraph.\n" + "\n" + "Fourth paragraph.\n" + ) + refs = extract_image_refs(doc) + assert refs == [ + ImageRef( + path="image.png", + alt_text="alt", + context=( + "First paragraph.\n\n" + "Second paragraph.\n\n" + "Third paragraph.\n\n" + "Fourth paragraph." + ), + line_number=7, + ) + ] + + +def test_multiple_references_on_different_lines() -> None: + doc = ( + "Intro.\n" + "\n" + "Para A.\n" + "\n" + "![one](one.png)\n" + "\n" + "Para B.\n" + "\n" + "Para C.\n" + "\n" + "Para D.\n" + "\n" + "![two](two.png)\n" + "\n" + "Outro.\n" + ) + refs = extract_image_refs(doc) + assert [r.path for r in refs] == ["one.png", "two.png"] + assert [r.alt_text for r in refs] == ["one", "two"] + assert [r.line_number for r in refs] == [5, 13] + # Each gets its OWN window (N=2 around its own paragraph — spaced + # apart so the windows don't contain the other reference line). + assert refs[0].context == "Intro.\n\nPara A.\n\nPara B.\n\nPara C." + assert refs[1].context == "Para C.\n\nPara D.\n\nOutro." + + +def test_multiple_references_on_same_line_share_window() -> None: + doc = "Before.\n\nSee ![a](a.png) and ![b](b.png) here.\n\nAfter.\n" + refs = extract_image_refs(doc) + assert [r.path for r in refs] == ["a.png", "b.png"] + assert [r.alt_text for r in refs] == ["a", "b"] + assert [r.line_number for r in refs] == [3, 3] + # Same reference paragraph → same context, reference line excluded. + assert refs[0].context == "Before.\n\nAfter." + assert refs[1].context == "Before.\n\nAfter." + + +def test_reference_in_code_fence_is_skipped() -> None: + doc = ( + "Intro.\n" + "\n" + "```\n" + "![not real](fenced.png)\n" + "```\n" + "\n" + "![real](real.png)\n" + ) + refs = extract_image_refs(doc) + assert [r.path for r in refs] == ["real.png"] + # The fence is an ATOMIC paragraph and counts as one context unit + # inside the N=2 window (chunker semantics, PLAN §5). + assert refs[0].context == "Intro.\n\n```\n![not real](fenced.png)\n```" + + +def test_fence_with_blank_lines_inside_is_one_atomic_block() -> None: + doc = ( + "P1.\n" + "\n" + "```\n" + "line one\n" + "\n" + "line three\n" + "```\n" + "\n" + "![a](a.png)\n" + ) + refs = extract_image_refs(doc) + assert len(refs) == 1 + # The fence (with its inner blank line) is ONE atomic context unit, + # occupying a single slot of the N=2 window before the reference + # (if it split on the inner blank line, the window would hold only + # "P1" + the fence's first lines instead of P1 + the whole fence). + assert refs[0].context == "P1.\n\n```\nline one\n\nline three\n```" + + +def test_tilde_fence_and_long_fence_markers() -> None: + doc = "~~~\n![skip](skip.png)\n~~~\n\n![keep](keep.png)\n" + assert [r.path for r in extract_image_refs(doc)] == ["keep.png"] + # Four-backtick fence (marker = first three chars), inner blank line. + doc = "````\n\n![skip](skip2.png)\n````\n\n![keep](keep2.png)\n" + assert [r.path for r in extract_image_refs(doc)] == ["keep2.png"] + + +def test_unclosed_fence_skips_rest_of_file() -> None: + doc = "```\n![skip](a.png)\nmore text after an unclosed fence\n" + assert extract_image_refs(doc) == [] + + +def test_reference_with_no_alt_text() -> None: + refs = extract_image_refs("![](image.png)") + assert refs == [ + ImageRef(path="image.png", alt_text="", context="", line_number=1) + ] + + +def test_reference_at_start_of_file() -> None: + doc = "![first](first.png)\n\nP1.\n\nP2.\n\nP3.\n" + refs = extract_image_refs(doc) + assert len(refs) == 1 + # Only paragraphs after exist; the N=2 cap still applies (P3 left out). + assert refs[0].context == "P1.\n\nP2." + assert refs[0].line_number == 1 + + +def test_reference_at_end_of_file() -> None: + doc = "P1.\n\nP2.\n\nP3.\n\nP4.\n\n![last](last.png)\n" + refs = extract_image_refs(doc) + assert len(refs) == 1 + # Only paragraphs before exist; the N=2 window is the two paragraphs + # immediately before the reference (P1 and P2 left out). + assert refs[0].context == "P3.\n\nP4." + assert refs[0].line_number == 9 + + +def test_reference_in_heading_line() -> None: + doc = ( + "Intro paragraph.\n" + "\n" + "## ![alt](image.png)\n" + "\n" + "Outro paragraph.\n" + ) + refs = extract_image_refs(doc) + assert len(refs) == 1 + assert refs[0].alt_text == "alt" + assert refs[0].path == "image.png" + assert refs[0].line_number == 3 + # The heading line is the reference paragraph — excluded from the + # context; only the surrounding text remains. + assert refs[0].context == "Intro paragraph.\n\nOutro paragraph." + assert "##" not in refs[0].context + + +def test_context_paragraphs_zero_and_custom() -> None: + doc = "P1.\n\nP2.\n\nP3.\n\n![a](a.png)\n\nQ1.\n\nQ2.\n\nQ3.\n" + assert extract_image_refs(doc, context_paragraphs=0)[0].context == "" + assert extract_image_refs(doc, context_paragraphs=1)[0].context == "P3.\n\nQ1." + assert ( + extract_image_refs(doc, context_paragraphs=3)[0].context + == "P1.\n\nP2.\n\nP3.\n\nQ1.\n\nQ2.\n\nQ3." + ) + # A window LARGER than the file gets whatever is available. + assert ( + extract_image_refs(doc, context_paragraphs=10)[0].context + == "P1.\n\nP2.\n\nP3.\n\nQ1.\n\nQ2.\n\nQ3." + ) + + +def test_negative_context_paragraphs_raises() -> None: + with pytest.raises(ValueError, match="context_paragraphs must be >= 0"): + extract_image_refs("![a](a.png)", context_paragraphs=-1) + + +def test_empty_and_reference_free_content() -> None: + assert extract_image_refs("") == [] + assert extract_image_refs("\n\n \n") == [] + assert extract_image_refs("# No images here\n\nJust plain text.\n") == [] + # A link is not an image reference; an empty target is not a ref. + assert extract_image_refs("[a link](http://x.example/) ![empty]()\n") == [] + + +def test_image_nested_in_link() -> None: + refs = extract_image_refs("![alt](img.png) wrapped: [![alt](img2.png)](http://x/)\n") + assert [r.path for r in refs] == ["img.png", "img2.png"] + + +def test_alt_text_with_nested_brackets() -> None: + # The alt field is ``[^]]*`` — it may OPEN a bracket (``[``) but + # the first CLOSING bracket (``]``) ends it: an alt with a nested + # ``[`` (``a [1``) still captures the path, while an alt that + # itself CARRIES a ``]`` (``a [1] v2``) breaks the whole match + # (the ``]`` ends the alt field, and no ``(`` follows) — an + # accepted miss, the locked regex's limitation (task 02), the + # alt-side twin of the nested-parens path miss above. + refs = extract_image_refs("![a [1](img.png)\n") + assert refs == [ImageRef(path="img.png", alt_text="a [1", context="", line_number=1)] + assert extract_image_refs("![a [1] v2](img.png)\n") == [] + + +def test_paths_with_spaces_and_parens() -> None: + # Spaces are fine; surrounding whitespace in the target is stripped. + refs = extract_image_refs("![a](diagram v2.png) and ![b]( x.png )\n") + assert [r.path for r in refs] == ["diagram v2.png", "x.png"] + # Nested parens: the regex stops at the first ')' — an accepted + # miss (rare in practice, phase 125 task 02). + refs = extract_image_refs("![a](a(b).png)\n") + assert refs[0].path == "a(b" + + +def test_line_numbers_are_one_based() -> None: + doc = "\n\n\n# Head\n\n\n![a](a.png)\n" + refs = extract_image_refs(doc) + # Three leading blanks + "# Head" + two blanks = lines 1..6; the + # reference is on line 7. + assert refs[0].line_number == 7 + + +def test_multi_line_reference_paragraph_contributes_nothing() -> None: + # The reference sits in the MIDDLE of a paragraph (no blank lines): + # the whole reference paragraph is excluded from the context. + doc = ( + "Before.\n" + "\n" + "Line one.\n" + "Line two with ![alt](img.png)\n" + "Line three.\n" + "\n" + "After.\n" + ) + refs = extract_image_refs(doc) + assert refs[0].context == "Before.\n\nAfter." diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 49ba419..b513f4f 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -69,6 +69,54 @@ def test_chunks_reference_documents_cascade() -> None: assert fkc.ondelete == "CASCADE" +def test_documents_image_context_column_contract() -> None: + """Phase 125 (task 01, LOCKED A1): ``documents.image_context`` — + TEXT, NULLABLE, NO server default. NULL is the standalone-image / + pre-phase-125 value (no backfill — A4 keeps those rows + byte-identical).""" + from sqlalchemy import Text as SAText + + documents = Base.metadata.tables["documents"] + col = documents.c["image_context"] + assert col.nullable is True, "image_context must be NULLABLE (pre-phase rows)" + assert isinstance(col.type, SAText), "image_context must be TEXT" + assert col.server_default is None, ( + "image_context must carry NO server default — NULL is the" + " standalone/pre-phase value" + ) + assert col.default is None, ( + "image_context must carry NO ORM default clause either (an" + " omitted column inserts NULL, the text-doc value)" + ) + + +def test_document_image_context_orm_parses() -> None: + """A freshly constructed ``Document`` (no DB) accepts the field in + both states: a set context string round-trips as-is, and + ``None`` (and the omitted kwarg) read NULL — the standalone-image + / pre-phase state.""" + from app.models import Document + + base = dict( + source="s", + path="p.md", + full_path="/tmp/s/p.md", + title="p", + content="c", + content_hash="1" * 64, + ) + enriched = Document(**base, image_context="some context") + assert enriched.image_context == "some context" + + explicit_null = Document(**base, image_context=None) + assert explicit_null.image_context is None + + omitted = Document(**base) + assert omitted.image_context is None, ( + "an omitted image_context must read NULL (the text-doc value)" + ) + + def test_documents_unique_source_path() -> None: documents = Base.metadata.tables["documents"] uq = [ diff --git a/tests/unit/test_prompts.py b/tests/unit/test_prompts.py index c1eeb7c..01b287f 100644 --- a/tests/unit/test_prompts.py +++ b/tests/unit/test_prompts.py @@ -48,6 +48,8 @@ from app.rag.prompts import ( SUGGEST_INTRO, TOOLS_SECTION, _base, + _document_body, + _extract_context_line, build_deflect_prompt, build_high_prompt, build_kb_section, @@ -74,6 +76,9 @@ def _doc( content: str, title: str, summary: str | None = None, + *, + is_image: bool = False, + image_context: str | None = None, ) -> Document: return Document( id=uuid.uuid4(), @@ -85,6 +90,9 @@ def _doc( content_hash="0" * 64, created_at=_FIXTURE_CREATED_AT, summary=summary, + is_image=is_image, + image_path="/tmp/pic.png" if is_image else None, + image_context=image_context, ) @@ -1141,3 +1149,301 @@ def test_show_image_clause_is_last_for_every_high_shape() -> None: assert on.index(section) < on.index("") assert on.index("K8s deploy notes.") < on.index("") # the summary still seeds assert SHOW_IMAGE_CLAUSE not in off # the toggle-off twin carries none of it + + +# ---------- phase 125 (task 05, LOCKED A4): the image context line ---------- +# An enriched image doc (tasks 03/04: non-NULL ``image_context`` in the +# "Referenced in …" block shape) leads its ```` suggestion +# block with ONE short context line — the referring markdown's identity +# + the reference's alt text — before the description, so the LLM knows +# WHY the image exists, not only what it shows. Every other doc (every +# text doc, every non-enriched image doc) is byte-identical to the +# pre-phase body. + +#: The exact stored block shape (tasks 03/04): one "Referenced in" +#: block per referring markdown file, blank-line joined; the +#: ``Context:`` body may span many lines (the surrounding paragraphs). +CTX_SINGLE = ( + "Referenced in Homelab/homelab/kubernetes.md:\n" + '- Alt text: "Cluster layout"\n' + " Context: The diagram shows the three Talos nodes and Cilium.\n" + "It is referenced again from the runbook." +) +CTX_TWO_BLOCKS = CTX_SINGLE + "\n\nReferenced in Homelab/ops/runbook.md:\n- Alt text: \"\"" +CTX_ALT_EMPTY = "Referenced in Homelab/ops/runbook.md:\n- Alt text: \"\"" + +#: The task-04 pending record — stored on MARKDOWN (text) docs, with no +#: ``Referenced in`` header at all. +MD_PENDING_RECORD = ( + 'IMAGE_CONTEXT:\n- [image.png] (line 42): "Alt text here" — 2 paragraphs of context' +) + + +def _image_doc( + image_context: str | None = None, summary: str | None = "A diagram of the cluster." +) -> Document: + return _doc( + "cluster-diagram.png", + "A diagram of the cluster.", + "cluster-diagram", + summary=summary, + is_image=True, + image_context=image_context, + ) + + +# ---------- _extract_context_line: the parser contract ---------- + + +def test_extract_context_line_single_block_with_alt() -> None: + """One block with an alt → the short one-line locator: the stored + identity (``/`` exactly as the header carries it) + + the reference's alt between quotes.""" + assert _extract_context_line(CTX_SINGLE) == ( + 'Referenced in Homelab/homelab/kubernetes.md: "Cluster layout"' + ) + + +def test_extract_context_line_context_body_never_renders() -> None: + """The ``Context:`` paragraphs (LOCKED A3: they already reached the + vision model at enrichment time) stay out of the line — the line is + the locator + purpose, short enough not to bloat the prompt.""" + line = _extract_context_line(CTX_SINGLE) + assert line is not None + assert "The diagram shows" not in line + assert "referenced again" not in line + assert "Context:" not in line + assert line.count("\n") == 0 # genuinely ONE line + + +def test_extract_context_line_empty_alt_renders_the_bare_identity() -> None: + """A reference without an alt (``![](path)``) → the clause is the + bare identity — nothing to quote after the colon.""" + assert _extract_context_line(CTX_ALT_EMPTY) == "Referenced in Homelab/ops/runbook.md" + + +def test_extract_context_line_two_blocks_join_with_semicolon() -> None: + """One clause per referring file after a single prefix — the second + block's empty alt renders bare.""" + assert _extract_context_line(CTX_TWO_BLOCKS) == ( + 'Referenced in Homelab/homelab/kubernetes.md: "Cluster layout"; ' + "Homelab/ops/runbook.md" + ) + + +def test_extract_context_line_uses_the_first_reference_only() -> None: + """A block with two references (two ``- Alt text:`` bullets) → the + FIRST reference's alt is the purpose; the second stays out.""" + text = ( + "Referenced in Homelab/a.md:\n" + '- Alt text: "first purpose"\n' + " Context: some paragraph.\n" + '- Alt text: "second purpose"' + ) + assert _extract_context_line(text) == 'Referenced in Homelab/a.md: "first purpose"' + assert "second purpose" not in (_extract_context_line(text) or "") + + +def test_extract_context_line_multiline_context_body_is_ignored() -> None: + """The ``Context:`` value is paragraph-joined (blank lines inside) + — the parser walks it without breaking the block.""" + text = ( + "Referenced in Homelab/a.md:\n" + '- Alt text: "purpose"\n' + " Context: para one.\n" + "\n" + "para two, and it contains a line that\n" + "looks like context continuation." + ) + assert _extract_context_line(text) == 'Referenced in Homelab/a.md: "purpose"' + + +def test_extract_context_line_alt_with_inner_quotes_survives() -> None: + """The block writer does not escape quotes: the greedy alt capture + (between the opening quote and the line's final quote) keeps an + alt with inner ``"`` intact.""" + text = 'Referenced in Homelab/x.md:\n- Alt text: "He said "hi""' + assert _extract_context_line(text) == 'Referenced in Homelab/x.md: "He said "hi""' + + +def test_extract_context_line_spurious_header_is_dropped() -> None: + """A look-alike ``Referenced in …:`` line WITHOUT its ``- Alt text:`` + bullet (context text that merely looks like a header) parses to + ``None`` — never a clause from the stray line.""" + assert _extract_context_line( + "Referenced in Homelab/fake.md:\nsome context line that is not a bullet" + ) is None + # A header with NO following line at all (end of value) is spurious too. + assert _extract_context_line("Referenced in Homelab/fake.md:") is None + # Spurious then REAL: only the real block's clause survives. + mixed = ( + "Referenced in Homelab/fake.md:\nsome context line\n\n" + + CTX_SINGLE + ) + assert _extract_context_line(mixed) == ( + 'Referenced in Homelab/homelab/kubernetes.md: "Cluster layout"' + ) + + +def test_extract_context_line_header_after_header_drops_the_bulletless_one() -> None: + """Two headers in a row: the first (never followed by its bullet) is + dropped and the second block parses normally.""" + text = "Referenced in Homelab/fake.md:\nReferenced in Homelab/real.md:\n- Alt text: \"alt\"" + assert _extract_context_line(text) == 'Referenced in Homelab/real.md: "alt"' + + +def test_extract_context_line_markdown_pending_record_is_none() -> None: + """The task-04 ``IMAGE_CONTEXT:`` record (the MARKDOWN doc's stored + ``image_context``) has no ``Referenced in`` header — it parses to + ``None`` (and it rides a text doc, which the ``is_image`` guard + excludes from the line anyway).""" + assert _extract_context_line(MD_PENDING_RECORD) is None + + +def test_extract_context_line_blank_and_empty_are_none() -> None: + assert _extract_context_line("") is None + assert _extract_context_line(" \n \t ") is None + # Garbage that contains the keyword somewhere is not a block. + assert _extract_context_line("I was referenced in Homelab/a.md: yesterday") is None + + +# ---------- _document_body: the block-body contract ---------- + + +def test_document_body_enriched_image_doc_leads_with_the_context_line() -> None: + """The completion criterion at the body level: the context line + leads, a blank line, then the description — the exact two-part + shape.""" + body = _document_body(_image_doc(image_context=CTX_SINGLE)) + assert body == ( + 'Referenced in Homelab/homelab/kubernetes.md: "Cluster layout"\n\n' + "A diagram of the cluster." + ) + + +def test_document_body_image_doc_null_context_is_byte_identical_to_pre_phase() -> None: + """LOCKED A4 (the byte-identity criterion): a standalone / pre-phase + image doc (NULL ``image_context``) → the body is EXACTLY the + stripped summary — no line, no blank, no drift.""" + doc = _image_doc(image_context=None) + assert _document_body(doc) == "A diagram of the cluster." + # And the built prompt matches a text twin (same fields, not an + # image) to the byte. + twin = _doc("cluster-diagram.png", "A diagram of the cluster.", "cluster-diagram") + assert build_high_prompt([doc]) == build_high_prompt([twin]) + + +def test_document_body_text_doc_with_image_context_is_unchanged() -> None: + """The guard is ``is_image``: a TEXT doc whose ``image_context`` + carries the task-04 pending record (the markdown case) keeps the + pre-phase body — the record never renders.""" + doc = _doc("guide.md", "CONTENT", "Guide", summary="Guide summary.", + image_context=MD_PENDING_RECORD) + assert _document_body(doc) == "Guide summary." + assert "IMAGE_CONTEXT" not in build_high_prompt([doc]) + assert "Referenced in" not in build_high_prompt([doc]) + + +def test_document_body_image_doc_unparsable_context_is_unchanged() -> None: + """Graceful degradation: an image doc whose ``image_context`` parses + to nothing (owner-edited / unknown shape) → the description only, + exactly pre-phase — the prompt build never raises.""" + for junk in ("", " ", "referenced in lowercase.md: no header shape", MD_PENDING_RECORD): + assert _document_body(_image_doc(image_context=junk)) == "A diagram of the cluster." + + +def test_document_body_enriched_image_doc_without_summary_precedes_the_preview() -> None: + """The line leads the fallback body too (the preview + marker of + LOCKED A5) — the "context before the description" contract holds on + both body shapes.""" + doc = _image_doc(image_context=CTX_SINGLE, summary=None) + doc.content = "A" * 400 + "B" * 600 # past the 400-char preview cap + body = _document_body(doc) + line = 'Referenced in Homelab/homelab/kubernetes.md: "Cluster layout"' + assert body.startswith(line + "\n\n") + assert body[len(line) + 2 :] == "A" * 400 + "\n" + TRUNCATION_MARKER + assert "B" * 40 not in body # beyond the cut, as pre-phase + + +def test_enriched_image_doc_with_summary_makes_no_settings_call( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """House pattern (the LOCKED A5 preview-cap contract): the + context-line path for a summary-bearing doc is pure — a dead + ``get_settings`` must not break such a build (extraction is pure; + the cap is read on the fallback path only).""" + from app.rag import prompts as prompts_mod + + doc = _image_doc(image_context=CTX_SINGLE) + + def _dead() -> None: # pragma: no cover - must never be called + raise AssertionError("get_settings() called for a summarized doc") + + monkeypatch.setattr(prompts_mod, "get_settings", _dead) + assert _document_body(doc) == ( + 'Referenced in Homelab/homelab/kubernetes.md: "Cluster layout"\n\n' + "A diagram of the cluster." + ) + + +# ---------- build_high_prompt: the line in the built block ---------- + + +def test_high_prompt_enriched_image_doc_block_exact_shape() -> None: + """The full block in the built prompt: identity attributes + unchanged (phase 106 date appended after title), then the context + line, a blank line, the description — the pre-phase block shape + with the line spliced in.""" + doc = _image_doc(image_context=CTX_SINGLE) + prompt = build_high_prompt([doc]) + assert ( + '\n' + 'Referenced in Homelab/homelab/kubernetes.md: "Cluster layout"\n' + "\n" + "A diagram of the cluster.\n" + "" + ) in prompt + # The line is the FIRST thing in the block body (before the + # description), inside . + line = 'Referenced in Homelab/homelab/kubernetes.md: "Cluster layout"' + block_start = prompt.index('") < prompt.index(line) + assert prompt.index(line) < prompt.index("A diagram of the cluster.") + assert block_start < prompt.index(line) < prompt.index("") + # The Context: body stays out of the whole prompt. + assert "The diagram shows the three Talos nodes" not in prompt + + +def test_high_prompt_enriched_image_doc_mixed_with_text_docs() -> None: + """A suggested tier with both text docs and one enriched image doc: + the line rides ONLY the image doc's block; the text blocks are + byte-identical to their summaries (no line, no drift).""" + text_doc = _doc("kubernetes.md", "CONTENT", "K8s", summary="K8s summary.") + img = _image_doc(image_context=CTX_SINGLE) + prompt = build_high_prompt([text_doc, img]) + assert prompt.count('Referenced in Homelab/homelab/kubernetes.md: "Cluster layout"') == 1 + # The text block is the bare summary, exactly as pre-phase. + assert ( + '\nK8s summary.\n' + ) in prompt + # The image block carries the line + description. + assert ( + 'title="cluster-diagram" date="2024-06-15">\n' + 'Referenced in Homelab/homelab/kubernetes.md: "Cluster layout"\n\n' + "A diagram of the cluster.\n" + ) in prompt + # Ordering unchanged: the text block (first doc) still precedes the + # image block (second doc) — the line never re-orders the tier. + assert prompt.index('path="kubernetes.md"') < prompt.index('path="cluster-diagram.png"') + + +def test_high_prompt_image_doc_without_context_is_byte_identical_to_pre_phase() -> None: + """LOCKED A4 at the prompt level: an image doc with NULL + ``image_context`` builds the EXACT pre-phase prompt (the text-twin + equality — no context line anywhere, not even a stray blank).""" + img = _image_doc(image_context=None) + twin = _doc("cluster-diagram.png", "A diagram of the cluster.", "cluster-diagram") + assert build_high_prompt([img]) == build_high_prompt([twin]) + assert "Referenced in" not in build_high_prompt([img])