Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62e4f6b572 | ||
|
|
7bd6335929 | ||
|
|
a6293f739b |
@@ -0,0 +1,56 @@
|
|||||||
|
# Phase 124 — Image chips and answers: retrieved images earn their source chips, and the LLM can show a supporting image in the answer
|
||||||
|
|
||||||
|
**Source:** `TODO.md` L3 — "Image support is working but images don't show up as sources in the sources chips. I also want the chat to be able to show images as answers, so if there's a supporting image with the answer rather than summarizing it alone the supporting image should be shown in the chat if the model thinks it would be effective. Note this will require some relevancy tuning since we don't want to just show every image that seems relevant. The LLM will need to decide when to show an image based on whether it contains the answer. Summaries should still be provided, and showing images as answers in chat should be a feature toggle in .env."
|
||||||
|
**Story:** n/a (feature request + bug report; completes the phase-122 image capability on the chat side).
|
||||||
|
**Context:** Phase 122 (`122_image_documents`, complete) — the image-doc pipeline: `BOR_IMAGES` toggle (`app/config.py` L379), `Document.is_image`/`image_path` (L153), the vision description as content+summary, `GET /api/documents/{id}/image` bytes route (`app/api/docs.py` L247), the ext→mime map, and `source_ref_with_image` (`app/rag/retriever.py` L945 — the shared ref builder that already puts `image_url` on image docs' cited + related refs). Phase 119 (complete) — the cited tier is the agent's READ DOCS only (`app/api/chat.py` L1040-1046, the deduped `holder.read_docs`). Phase 118 — the `<documents>` seed blocks carry the top-N suggested docs' SUMMARIES (`app/rag/prompts.py` `build_high_prompt`, `SUGGEST_INTRO`); for an image doc the summary IS the content (the vision description) — a seeded image doc is already 100% in the prompt. The agent surface: `AGENT_TOOLS` (the harness-aligned `ls`/`read`/`grep`, `app/rag/agent.py` L264), `_execute_tool` (L1193 — the read branch L1257-1328: `_resolve_path` L1037, the phase-72 `_no_document_refusal` L1098, the `IMAGE_DOC_MARKER` L438, the `Document …`/`date:` result header contract), `AgentHolder` (L1144 — `read_docs`/`tool_calls`/`read_truncations`), `run_agent` (L~1393 — the tools wiring L1490: `tools = AGENT_TOOLS if max_rounds > 0 else None`). The frames: `ChatDoneEvent` (`app/schemas.py` L236 — `deflected`/`sources`/`related`/`suggestions`; `SourceRef` L124 with the None-`image_url`-omitting serializer L159-164), the done-frame build in `app/api/chat.py` L1119-1133. Persistence: `ChatMessage` (L892, `extra="forbid"` — the brain-record meta keys, `sources`/`related` capped lists, the phase-113 lesson that a done-time auto-save key missing from the model 422s silently). Frontend: `frontend/assets/app.js` — the `done` handler (L2981+: `appendSources(wrap, ev.sources)` L2999, the `rememberBrainTurn` save point L3015+), `renderStoredMessage` (L1856 — the brain branch's `appendSources(wrap, m.sources)` L1904), `appendSourceImageFigure` (L1677 — the phase-122 chip figure, the collapse-on-error + `fetchContentSummary` alt pattern to reuse, L1716), `addMessage` (L948). `frontend/assets/shared.js` — the shared page's fragment builders (the brain branch L354+, `addSources` L274 — the phase-123 user-image renderer L329+ is the closest precedent). `frontend/assets/styles.css` — `.source-image` family (L784-815) the answer-figure styling extends. E2E tooling: `tests/e2e/mock_llm.py` (the marker-driven mock — `IMAGE_DESCRIPTION_MODE` L~33, the `use your tools` scripted-`read` trigger L~100, the `<documents>` block tail-echo parser), `tests/e2e/test_image_documents.py` (the module-apps pattern with `BOR_IMAGES` on/off L88-186, the fixture archive L201+, the upload/sync helpers L237+, and — the diagnosis pin — the comment at L62-64: a zero-read grounded turn chips NOTHING, so the suite had to script a mock `read` to earn the image doc's chip).
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Two gaps in the phase-122/123 image capability, closed in one phase: (1) the **bug** — a retrieved image document effectively never shows up as a source chip, because the cited tier is read-docs-only and a real model never `read`s an image doc (its seed block already carries its entire content); fix it deterministically by auto-citing suggested-tier image docs. (2) the **feature** — the LLM can show a standalone image document as part of its answer: a new `show_image` tool it calls only when the image itself contains the answer (the relevance gate is the model's decision, taught in the tool description + prompt clause — never every merely-relevant image), the text answer/summary is still provided in full, one image per answer, all behind a new `BOR_ANSWER_IMAGES` `.env` toggle that defaults to false.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- `123_chat_image_questions` (complete) — pipeline predecessor (execution order) and code dependency: the `BOR_IMAGES`/image-extension/mime conventions (this phase's toggle and tool follow them), `Document.is_image`, the image bytes route, and `source_ref_with_image` (the answer-image ref is built by it).
|
||||||
|
- Code dependencies (all complete): phase 118 summary seeding, phase 119 read-docs citation tier, phase 37/70 the agent tool loop, phase 113 the brain-record meta persistence, phase 51 shared chats, phase 122 the image-doc pipeline.
|
||||||
|
|
||||||
|
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||||
|
- **Root cause (task 05, the bug):** since phase 119 (LOCKED A1) `done.sources` = the agent's `holder.read_docs` deduped by `(source, path)` — a zero-read grounded turn chips nothing. For an image document the seed block (`SUGGEST_INTRO` + `<document>` block) carries its summary — which IS its entire content (the vision description; content == summary by construction, phase 122 task 03). Reading it would add only the marker line. A real model therefore never reads it → the chip never appears (the phase-122 E2E had to script a mock `read` — `test_image_documents.py` L62-64 — the live-app confirmation the owner saw). The fix is deterministic and server-side: **an image doc in the suggested (seed) tier is auto-cited** — in `app/api/chat.py`'s `cited_docs` build (L1040-1046), after the read-docs loop, merge the `is_image` members of `plan.suggested_docs` into `cited_docs` (deduped against the read docs via `cited_seen`, suggestion order; grounded turns only — a deflected turn cites nothing, unchanged). Rationale: for an image doc "suggested" ≡ "read" — the model already has 100% of its content, so the phase-119 cite discipline ("cite the document you answered from") is honored, not weakened. Consequences: auto-cited image docs leave the related tier (`related_refs` already excludes `cited_seen` members, L1123-1127 — no duplicate figure); the `query_log`/log-line `sources=` field is untouched (it logs `record_docs`, which already includes suggested docs). An image doc ranking beyond the suggested set is unchanged — it stays in the "Nearby docs" row, where its figure already renders (phase 122 task 05).
|
||||||
|
- **Toggle (task 01, LOCKED A2):** one new `Settings` field — `answer_images: bool = False` (`BOR_ANSWER_IMAGES`, the phase-67 `llm_retries`/phase-122 `images` bool style; `1`/`true` on). A SEPARATE knob from `images` (`BOR_IMAGES`): indexing image docs and showing them in answers are independent owner decisions — `BOR_ANSWER_IMAGES=true` with `BOR_IMAGES=false` is a coherent (if image-less) state: the tool is offered, finds no image docs, and always refuses. `.env.example` documents it beside the existing image block (L124-136). Off = byte-identical everywhere: no 4th tool offered, no prompt clause, no `answer_image` key in any frame or record.
|
||||||
|
- **The tool (task 02, LOCKED A3/A5):** a 4th agent tool `show_image(path)` — same harness shape as the other three (one string argument, the combined `source/path` identity). It is OFFERED only when `settings.answer_images` is true: `run_agent`'s tools wiring (L1490) becomes `tools = agent_tools(settings) if max_rounds > 0 else None`, where `agent_tools` returns the three base tools + `show_image` when the toggle is on (the base three stay a byte-identical list — the E2E mock does not inspect the tools array, but the offer gating is the feature's server-side gate). The description carries the RELEVANCE GATE (A5 — "we don't want to just show every image that seems relevant"): call it ONLY when the image itself contains the answer — a diagram, screenshot, or photo of the thing the user asked about — never for mere topical relevance; at most ONE image per answer; the text answer is still given in full (the image supplements, never replaces — "summaries should still be provided"). Handler (a `call.name == "show_image"` branch in `_execute_tool`, before the unknown-tool fallback): missing/empty `path` → the `MISSING_SHOW_ARGS` refusal (the `MISSING_READ_ARGS` shape); unresolvable arg → the phase-72 `_no_document_refusal` teaching (same as read); a resolvable NON-image doc → the `NOT_AN_IMAGE_DOC` refusal ("not an image document — only standalone image documents can be shown" — a text doc's `show_image` is refused, not silently converted to a read); a second call in the same turn → the `ALREADY_SHOWING` refusal (A3 — one image per answer, enforced server-side, not just taught). Refusals count in nothing and consume a round, exactly like every other refusal. Success: `holder.tool_calls += 1`, the doc appended to the NEW `AgentHolder.shown_images: list[Document]` field (the API layer's read-after-stream signal, the `read_docs` precedent), and a result that CONFIRMS the show and carries the doc block so the model verifies before finalizing — a first line `Image shown to the user — it is now part of your answer.`, then the read-result document block for the image doc (`Document {source}/{path}:` header + `date:` line + `IMAGE_DOC_MARKER` + the description — an image doc's content is the summary-capped description, always short, so no truncation machinery; the mock's `_READ_RESULT_PREFIX` header contract is unaffected because that contract is pinned on `read` results).
|
||||||
|
- **Prompt (task 03, LOCKED A5):** the `TOOLS_SECTION` constant stays BYTE-IDENTICAL; `build_high_prompt` gains the clause only when its caller passes the toggle on — a new `show_image: bool = False` parameter (default keeps every existing call byte-identical; `app/api/chat.py` passes `settings.answer_images`). The clause is appended INSIDE the `<tools>` section (after the existing three-tool copy, before `</tools>`), so the E2E mock's `<tools>`-presence keying is untouched and the clause rides the grounded HIGH prompt only — the deflection prompt (no tools, ever) is unchanged. The clause teaches the same gate as the tool description, plus the image-doc nature: the text of an image doc in a `<documents>` block or a `read` result is a description GENERATED from the image; show the image when THAT is what the user actually wants to see (the thing they asked about, in picture form); one image per answer; answer fully in text either way.
|
||||||
|
- **Frame + persistence (task 04):** `ChatDoneEvent` gains `answer_image: SourceRef | None = None` — the shown doc's ref, built by the SAME `source_ref_with_image` (source/path/title + `image_url`). A `model_serializer(mode="wrap")` on `ChatDoneEvent` DROPS `answer_image` from the dumped dict when it is `None` (the `SourceRef._serialize` None-omission precedent) — without it, `model_dump()` would add `"answer_image": null` to every turn's frame and break the byte-identical-off contract. `ChatMessage` gains `answer_image: SourceRef | None = None` (brain records only — the user record's phase-123 `image` key, the stored PATH string, is untouched and distinct). This is the phase-113 lesson applied up front: the done-time auto-save carries the key, and a `extra="forbid"` model missing it would 422 every grounded brain record silently (the A2 quiet-failure path) — the integration suite pins the round-trip. `app/api/chat.py`'s done-frame build (L1119-1133) passes `answer_image=source_ref_with_image(holder.shown_images[0]) if holder.shown_images else None`.
|
||||||
|
- **UI (task 06):** the `done` handler (app.js L2981+) renders `ev.answer_image` as a prominent ANSWER FIGURE in the brain bubble's `.msg-body` — appended right BEFORE `appendSources` so it sits below the answer text, above the source-chip row (a new `appendAnswerImage(wrap, ref)` helper, distinct from the compact chip figure `appendSourceImageFigure` — the answer image is a first-class part of the answer, styled larger). The figure: `<figure class="answer-image">` with a lazy `<img>` (`loading="lazy"`, `src = ref.image_url`, the public bytes route), `alt` = `ref.title` immediately and swapped to the doc's summary via the SAME `fetchContentSummary` pattern (L1716 — the phase-122 WCAG alt contract); an `img` error REMOVES the figure (the collapse-to-nothing rule — an answer with a lost image renders as a normal answer, never a broken icon; the chip row below still cites the doc). The figure reuses the chip navigation affordance (the `documentUrl` href as the no-JS escape hatch; left-click → the same-page document modal, phase 26) — it is a link to the image document. Persistence: the `rememberBrainTurn` meta gains `answer_image: ev.answer_image || undefined` (the house undefined-drops-the-key pattern — pre-phase and image-less turns store byte-identical records). Restore: `renderStoredMessage`'s brain branch renders `m.answer_image` through the SAME helper before `appendSources(wrap, m.sources)` (L1904) — the refresh lands pixel-identical. Shared page: `shared.js`'s brain fragment builder renders it the same way (the bytes route is public — a shared chat is faithful, the phase-123 user-image precedent). CSS: `.answer-image` / `.answer-image-img` in the `.source-image` family's language (`.source-image-img` L793: the bordered-image treatment) but larger — the answer figure is not a chip accessory: max-width 100% of the bubble, a capped max-height (≈320px), `object-fit: contain`, theme-aware border/background, a caption line (the summary, the `.source-image-caption` styling) — the theme's image treatment, WCAG contrast per PLAN §7.
|
||||||
|
- **NOT touched:** the image-INGEST pipeline (phase 122), question-image attachment (phase 123), the suggestion/related TIERING math (only the cited-tier composition changes), the honesty gate, the deflection path (no tools, no `answer_image` — a deflected answer never shows an image), history trimming (an `answer_image` rides the stored record, never the model's history — the model's image input remains the phase-123 turn-local contract), and the agent round cap.
|
||||||
|
- **Locked assumptions:** **A1** — auto-cite scope = suggested-tier image docs (deeper-ranked ones keep the related-row figure). **A2** — `BOR_ANSWER_IMAGES`, default false, independent of `BOR_IMAGES`. **A3** — one image per answer (server-enforced refusal). **A4** — the answer figure renders in the brain bubble below the text, above the chips; visible on restore and the shared page. **A5** — the relevance gate is the LLM's decision (tool description + prompt clause), no server-side image scoring.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. `01_answer_images_toggle.md` — `Settings.answer_images` (`BOR_ANSWER_IMAGES`, default false) + `.env.example` doc; off = byte-identical.
|
||||||
|
2. `02_show_image_tool.md` — `agent_tools` gating + the `show_image` handler (refusals, `holder.shown_images`, the confirming result).
|
||||||
|
3. `03_prompt_clause.md` — the toggle-gated `show_image` clause in the HIGH prompt's `<tools>` section (base byte-identical).
|
||||||
|
4. `04_frame_and_persistence.md` — `ChatDoneEvent.answer_image` (None-omitting serializer) + `ChatMessage.answer_image` + the chat.py done-frame wiring.
|
||||||
|
5. `05_auto_cite_image_docs.md` — the bug fix: suggested-tier image docs merge into `cited_docs`.
|
||||||
|
6. `06_chat_ui_answer_image.md` — the answer figure: live `done` render, persistence, restore, shared page, CSS.
|
||||||
|
7. `07_answer_image_tests.md` — the mock-LLM `show_image` trigger + isolated E2E `test_answer_images.py` + the final coverage gate.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: `tests/unit/test_image_chips_and_answers.py` (new, across tasks 01-05) — settings parsing (default false, truthy parsing, independent of `images`); `agent_tools` (off → the three base tools byte-identical; on → + `show_image`); the `show_image` handler (success → `shown_images` appended + `tool_calls` bumped + the confirming result containing the doc block; each refusal: missing args, no-doc teaching, non-image doc, already-showing — none counted); the prompt clause (off → `build_high_prompt` byte-identical to pre-phase; on → the clause inside `<tools>`, base copy byte-identical; the deflection prompt unchanged either way); the `ChatDoneEvent` serializer (`answer_image=None` → key ABSENT from the dump, not null; present → the full ref incl. `image_url`); the auto-cite merge (a suggested image doc + read text docs → cited = reads first then the image doc, deduped; a suggested image doc the agent also read → once; deflected → `[]`; a non-image suggested doc → never auto-cited).
|
||||||
|
- Integration: `tests/integration/test_chat_api.py` (extend, task 04) + `tests/integration/test_chats_api.py` (extend, task 04) — a grounded turn whose mock client emits a scripted `show_image` tool call on an image doc → the done frame carries `answer_image` (source/path/title/`image_url`) and the cited tier includes the doc; a toggle-off turn (mock emits the same call — the tool is not offered, so in the mock-driven integration test the frame simply has no `answer_image` and no such call occurs) → the frame dump has NO `answer_image` key and the offer list is the three base tools; the auto-cite integration: a zero-read grounded turn (no tool calls at all) whose suggested tier contains an image doc → the done frame's `sources` include the image ref WITH `image_url` (the bug-fix pin end-to-end) and the related tier excludes it; a saved-chat round-trip: a brain record with `answer_image` persists and serves back (the phase-113 422-silence regression pinned).
|
||||||
|
- E2E: `tests/e2e/test_answer_images.py` (new, task 07) — isolated run per AGENTS.md §4; module apps on the `test_image_documents.py` pattern: `BOR_IMAGES=true` + `BOR_ANSWER_IMAGES=true` (the feature) and a second app with `BOR_IMAGES=true` + toggle unset (the default-off contract). Feature flow: upload the fixture image archive → sync → ask the mock's trigger question → the mock scripts ONLY a `show_image` call (NO `read` — the zero-read turn) → the brain bubble shows the ANSWER FIGURE (an `<img>` whose src is the doc's bytes route, visible, not collapsed) AND the source-chip row carries the image doc's chip + figure (the bug fix — a zero-read turn chips it) → reload → the restored bubble shows the figure again → the shared link shows it. Default-off flow: the same question → no answer figure in the DOM, the done frame (via the saved record) carries no `answer_image`, and the mock never sees the tool (assert the recorded request's `tools` has 3 entries).
|
||||||
|
- Coverage: **>90%** on `app/` (validate.sh gate); `uv run ruff check . && uv run pyright` clean.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] With `BOR_IMAGES=true` (any `BOR_ANSWER_IMAGES`): a retrieved image doc in the suggested tier appears in the sources chips WITH its figure on a zero-read grounded turn — no scripted `read` needed (the phase-122 E2E's scripted `read` remains valid but is no longer load-bearing for the chip).
|
||||||
|
- [ ] With `BOR_IMAGES=true` + `BOR_ANSWER_IMAGES=true`: the LLM can call `show_image` on an image doc; the answer figure renders in the bubble (below the text, above the chips) with the summary as alt/caption; the answer text is still streamed in full; a second `show_image` call is refused; the record persists the figure and restore + the shared page render it.
|
||||||
|
- [ ] With `BOR_ANSWER_IMAGES` unset (the default): no `show_image` tool offered, no prompt clause, no `answer_image` key in any done frame or saved record — every byte-identical contract of completed phases holds.
|
||||||
|
- [ ] `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 — auto-cite scope: suggested-tier (seeded) image docs become citation chips; image docs ranked beyond the suggested set stay in the "Nearby docs" row (their figure already renders there) (owner-confirmed 2026-09-26, roadmap confirmation).**
|
||||||
|
- **A2 — `BOR_ANSWER_IMAGES`, default false, a SEPARATE toggle from `BOR_IMAGES` (indexing) — showing answer images requires image docs to exist but the knobs stay independent (owner-confirmed 2026-09-26).**
|
||||||
|
- **A3 — one image per answer: a second `show_image` call in a turn is refused server-side (owner-confirmed 2026-09-26).**
|
||||||
|
- **A4 — the answer figure renders in the brain bubble below the answer text, above the source chips; lazy-loaded; collapses on load failure (never a broken icon); alt = the doc's summary; visible on refresh restore and the shared page (owner-confirmed 2026-09-26).**
|
||||||
|
- **A5 — the relevance gate is the LLM's decision, taught in the tool description + the prompt clause (show only when the image itself contains the answer — a diagram/screenshot/photo of the thing asked about — never mere topical relevance; the text answer is always provided in full); no server-side image scoring (owner-confirmed 2026-09-26).**
|
||||||
|
|
||||||
|
## Commit
|
||||||
|
```bash
|
||||||
|
git add app/ frontend/ tests/ .env.example .agents/phases/ && git commit --no-gpg-sign -m "feat(chat): image source chips + LLM-chosen answer images behind BOR_ANSWER_IMAGES"
|
||||||
|
```
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Task 01 — The `BOR_ANSWER_IMAGES` feature toggle
|
||||||
|
|
||||||
|
**Phase:** `124_image_chips_and_answers` · **Source:** `TODO.md:3` — "…and showing images as answers in chat should be a feature toggle in .env."
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
A new `Settings.answer_images` field (`BOR_ANSWER_IMAGES`, default **false**) — the single knob that turns the whole answer-image feature on or off (task 02's tool offer, task 03's prompt clause, task 04's frame field all key on it). Off (the default) = byte-identical behavior everywhere.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `app/config.py` — add `answer_images: bool = False` to `Settings` inside the existing image block (after `chat_image_max_mb`, L413 — the phase-122 `images` field L379 is the style precedent: a docstring stating it gates the ANSWER side only, that it is INDEPENDENT of `images`/`BOR_IMAGES` (indexing image docs vs showing them — A2), and that off = no `show_image` tool, no prompt clause, no `answer_image` frame key). `BOR_`-prefixed, so `BOR_ANSWER_IMAGES=1`/`true` enables it.
|
||||||
|
2. `.env.example` — document it beside the existing image block (L124-136): a commented `# BOR_ANSWER_IMAGES=0` line + a two-line comment (off by default; enable alongside `BOR_IMAGES` to let the model show a supporting image in its answer).
|
||||||
|
3. ASSUMPTION: none beyond the phase's locked A2 (name, default, independence — owner-confirmed 2026-09-26). Do NOT add an `answer_images` key to `GET /api/config` — the UI is purely reactive (it renders what the done frame carries); there is no client-side affordance to gate, so the phase-122 config-flag precedent does not apply here.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: `tests/unit/test_config.py` (extend) or the new `tests/unit/test_image_chips_and_answers.py` (create it here if absent) — `answer_images` defaults to `False`; `BOR_ANSWER_IMAGES=1` / `=true` (the settings loader's accepted truthy forms — mirror the existing `images`/`llm_retries` bool test) parse to `True`; `=0`/`=false`/unset → `False`; setting it does not affect `images` and vice versa.
|
||||||
|
- Coverage: **>90%** on this task's new/modified code (a config field — the parsing tests carry it).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `Settings().answer_images is False` with no env; `BOR_ANSWER_IMAGES=1` yields `True`.
|
||||||
|
- [ ] `.env.example` documents the new var with the "off by default" comment.
|
||||||
|
- [ ] Full test suite green; no behavior change in completed work (no other file reads the field yet).
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Task 02 — The `show_image` agent tool (toggle-gated)
|
||||||
|
|
||||||
|
**Phase:** `124_image_chips_and_answers` · **Source:** `TODO.md:3` — "The LLM will need to decide when to show an image based on whether it contains the answer." + "…we don't want to just show every image that seems relevant." + "Summaries should still be provided"
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The 4th agent tool `show_image(path)` — offered ONLY when `settings.answer_images` is true — by which the LLM decides to show a standalone image document as part of its answer. The tool's description IS the relevance gate (A5); the handler enforces the contract server-side (image docs only, one per answer) and records the shown doc in `holder.shown_images` for task 04's frame.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `app/rag/agent.py` — the tool definition: keep `AGENT_TOOLS` (L264) as the three base tools, UNCHANGED (byte-identical — existing tests pin its shape); add a new module constant `SHOW_IMAGE_TOOL: dict[str, Any]` (the same OpenAI function shape: `name: "show_image"`, one required string arg `path`) and a function `agent_tools(settings: Settings) -> list[dict[str, Any]]` returning `[...AGENT_TOOLS]` + `SHOW_IMAGE_TOOL` when `settings.answer_images` else `[...AGENT_TOOLS]` (a fresh list each call — never mutate the constant).
|
||||||
|
- The description carries the full gate (A5) in the other tools' voice: "Show a standalone image document to the user as part of your answer. Call it only when the image itself contains the answer — a diagram, screenshot, or photo of the thing the user asked about — never for a merely related image. Show at most one image per answer. The image supplements your text answer — answer fully in text either way. Its `path` is the combined `source/path` string, exactly as shown in the `ls` output or the `<documents>` blocks — a bare document path will not resolve. Call one tool at a time — wait for this result before your next call."
|
||||||
|
- The `path` parameter description mirrors `read`'s (the combined `source/path` contract, the bare-path-will-not-resolve teaching).
|
||||||
|
2. `app/rag/agent.py` — the offer gating: `run_agent`'s wiring (L1490) changes from `tools: list[dict[str, Any]] | None = AGENT_TOOLS if max_rounds > 0 else None` to `tools = agent_tools(settings) if max_rounds > 0 else None` (the `max_rounds == 0` kill switch still yields `tools=None`, unchanged; `settings` is already a `run_agent` parameter).
|
||||||
|
3. `app/rag/agent.py` — the handler: a `if call.name == "show_image":` branch in `_execute_tool` (L1193), placed after the `read` branch and before the unknown-tool fallback (L1388):
|
||||||
|
- `raw_path = call.arguments.get("path")`; stripped-arg empty → return `MISSING_SHOW_ARGS` (new constant, the `MISSING_READ_ARGS` L388 shape: "show_image requires a string argument 'path'.").
|
||||||
|
- `doc, _source, _path = _resolve_path(db, arg)` (L1037); `doc is None` → `return _no_document_refusal(db, arg)` (the phase-72 "did you mean …?" teaching — same as read).
|
||||||
|
- `not doc.is_image` → `return NOT_AN_IMAGE_DOC` (new constant: "`{arg}` is not an image document — only standalone image documents can be shown as answers." with the arg echo — a text doc is refused, never converted to a read).
|
||||||
|
- `holder.shown_images` non-empty → `return ALREADY_SHOWING` (new constant, the `ALREADY_IN_CONTEXT` L384 shape: "Already showing an image this answer — do not show another one. Answer from what you have." — A3, server-enforced).
|
||||||
|
- Success: `holder.shown_images.append(doc)`; `holder.tool_calls += 1`; return the confirming result — a FIRST LINE `Image shown to the user — it is now part of your answer.` followed by the same document block `read` returns for an image doc at-or-under-cap (L1323-1328): `Document {doc.source}/{doc.path}:\n` + `date: {doc.created_at:%Y-%m-%d}\n` + `IMAGE_DOC_MARKER` + `\n`-handling as there + `{doc.content}` (an image doc's content is the summary-capped vision description — always under `read_max_chars`, so NO truncation machinery; if the content ever exceeded the cap, the plain uncapped block is still correct for this purpose — the description is short by construction, phase 122 task 03). The confirming line is NEW (not in `read`'s result — the model must know the show registered); the `Document …` header contract (the E2E mock's `_READ_RESULT_PREFIX`) is pinned on `read` results and this block deliberately reuses its exact shape.
|
||||||
|
4. `app/rag/agent.py` — `AgentHolder` (L1144): add `shown_images: list[Document] = field(default_factory=list)` with a docstring entry (the shown docs, in call order — at most one in practice due to the `ALREADY_SHOWING` refusal; read by `app.api.chat` after the stream for the `answer_image` frame, the `read_docs` precedent).
|
||||||
|
5. ASSUMPTION: none beyond the phase's locked A3/A5 (owner-confirmed 2026-09-26). The per-turn log line (PLAN §9) needs no new field: a successful `show_image` is a tool call (`tool_calls=N` already counts it); the `answer_image` frame is task 04's job.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit (new `tests/unit/test_image_chips_and_answers.py` if task 01 didn't create it, else extend): `agent_tools` — toggle off → exactly the three base tools, equal to `AGENT_TOOLS` element-for-element (and NOT the same list object mutated); toggle on → the fourth tool present with name `show_image`, required `["path"]`, description containing the gate copy ("at most one image", "never for a merely related"); the handler matrix via `_execute_tool` with a temp-DB fixture and a mock `ToolCallPiece` (the existing agent unit-test fixtures): (a) empty/missing `path` → `MISSING_SHOW_ARGS`, `tool_calls` unchanged, `shown_images` empty; (b) unresolvable arg → the `_no_document_refusal` text, nothing counted; (c) a resolved TEXT doc → `NOT_AN_IMAGE_DOC`, nothing counted; (d) a resolved image doc → `shown_images == [doc]`, `tool_calls == 1`, result starts with the confirming line then the `Document {source}/{path}:` header + `date:` line + `IMAGE_DOC_MARKER` + the description; (e) a second call after a success → `ALREADY_SHOWING`, `shown_images` still length 1.
|
||||||
|
- Coverage: **>90%** on this task's new/modified code (the handler matrix + `agent_tools` tests carry it).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] With `answer_images=False`: `agent_tools(settings)` returns exactly the three base tools and a grounded turn offers no `show_image` (the `run_agent` wiring passes the gated list).
|
||||||
|
- [ ] With `answer_images=True`: a scripted `show_image` call on an image doc appends to `holder.shown_images`, bumps `tool_calls`, and returns the confirming result with the doc block; every refusal path counts in nothing and leaves `shown_images` empty/unchanged.
|
||||||
|
- [ ] Full test suite green; `AGENT_TOOLS` itself byte-identical (no existing test diffs); no behavior change in completed work.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Task 03 — The `show_image` prompt clause (HIGH prompt, toggle-gated)
|
||||||
|
|
||||||
|
**Phase:** `124_image_chips_and_answers` · **Source:** `TODO.md:3` — "Note this will require some relevancy tuning since we don't want to just show every image that seems relevant. The LLM will need to decide when to show an image based on whether it contains the answer."
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
When the toggle is on, the grounded HIGH prompt teaches the model the `show_image` contract — the relevance gate, the image-doc nature (its text is a GENERATED description), the one-image cap, and "summaries still provided" — as a clause appended inside the locked `<tools>` section. The `TOOLS_SECTION` constant and the whole prompt stay BYTE-IDENTICAL when the toggle is off (every existing `tests/unit/test_prompts.py` pin holds untouched).
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `app/rag/prompts.py` — add a new module constant `SHOW_IMAGE_CLAUSE: str` (the appended copy — written in the `TOOLS_SECTION` voice, referencing the tools by backtick names):
|
||||||
|
"`show_image` displays a standalone image document as part of your answer. The text of an image document — in a <documents> block or a `read` result — is a description GENERATED from the image, not the image's own words. Call `show_image` only when that image is what the user actually wants to see: a diagram, screenshot, or photo of the very thing you are answering about — never for a merely related image, and never to pad an answer. Show at most one image per answer. The image supplements your text — answer fully in text either way."
|
||||||
|
2. `app/rag/prompts.py` — `build_high_prompt` (L~437) gains a keyword parameter `show_image: bool = False` (the default keeps EVERY existing call byte-identical — update the docstring: section order unchanged, the clause rides inside `<tools>`). Implementation: the final return currently concatenates `… + "</documents>\n" + TOOLS_SECTION` — with `show_image=False` keep EXACTLY that; with `show_image=True` build the tools section as `TOOLS_SECTION`'s body + the clause: i.e. replace the trailing `TOOLS_SECTION` with the same text minus its final `</tools>`, then the clause, then `</tools>` (the clause lands AFTER the existing three-tool copy and BEFORE the closing tag — do NOT edit `TOOLS_SECTION` itself).
|
||||||
|
3. `app/api/chat.py` — the `build_high_prompt` call site (find it where `plan.system_prompt` is assembled for the grounded plan — the retrieval-plan construction; it is the single HIGH-prompt build site) passes `show_image=settings.answer_images`. No other call site changes (tests' direct calls keep the default).
|
||||||
|
4. The deflection prompt (`build_deflect_prompt`) is UNTOUCHED — a deflected turn has no tools and never shows an image (the phase's locked design).
|
||||||
|
5. ASSUMPTION: none beyond the phase's locked A5 (owner-confirmed 2026-09-26). The clause copy above is the starting text — it must state all four gate elements (image-doc nature / "contains the answer" bar / one per answer / text answer in full); exact wording is the executor's within that constraint (the E2E mock keys on `<tools>` PRESENCE, not wording — `tests/e2e/mock_llm.py`'s documented contract — so a re-wording is safe, but the four elements are not).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: `tests/unit/test_prompts.py` (extend) — (a) `build_high_prompt(docs)` (no new arg) → byte-identical to the pre-task output (the existing pins already assert the `TOOLS_SECTION` tail — they must pass UNMODIFIED); (b) `build_high_prompt(docs, show_image=True)` → the output ends with the clause then `</tools>`, the clause contains the four gate elements (assert on stable phrases: "description GENERATED from the image", "at most one image per answer", "answer fully in text"), and the prefix up to the clause is byte-identical to the toggle-off output (the three-tool copy untouched); (c) the clause appears ONLY in the HIGH prompt — `build_deflect_prompt` output is byte-identical with and without the flag's existence (it takes no such flag); (d) with empty `documents` (the "(no documents matched…)" body) + `show_image=True` → the clause still lands inside `<tools>` (the grounded turn may still `ls`/`grep` its way to an image doc and show it).
|
||||||
|
- Coverage: **>90%** on this task's new/modified code (the prompt tests carry it — pure string logic).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `build_high_prompt(..., show_image=False)` (and all pre-existing calls) → byte-identical output (existing `test_prompts.py` pins pass unmodified).
|
||||||
|
- [ ] `show_image=True` → the clause is inside `<tools>`, after the existing copy, with all four gate elements; the deflection prompt is unchanged.
|
||||||
|
- [ ] The grounded plan carries the clause exactly when `settings.answer_images` is true.
|
||||||
|
- [ ] Full test suite green; no behavior change in completed work.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Task 04 — The `answer_image` done frame + saved-chat persistence
|
||||||
|
|
||||||
|
**Phase:** `124_image_chips_and_answers` · **Source:** `TODO.md:3` — "…the supporting image should be shown in the chat if the model thinks it would be effective."
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The shown image reaches the client and survives a refresh: `ChatDoneEvent.answer_image` (the shown doc's `SourceRef`, OMITTED from the frame when absent — every toggle-off / image-less turn stays byte-identical) and `ChatMessage.answer_image` (the brain-record key the done-time auto-save persists, restored by task 06).
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `app/schemas.py` — `ChatDoneEvent` (L236): add `answer_image: SourceRef | None = None` with a docstring entry (the shown image document — the LLM's `show_image` pick, phase 124; the brain answer's first-class image, distinct from the user record's question attachment `ChatMessage.image`). Add a `model_serializer(mode="wrap")` to `ChatDoneEvent` (the `SourceRef._serialize` L159-164 precedent): `data = handler(self)`; `if self.answer_image is None: data.pop("answer_image", None)`; return `data` — WITHOUT this, `model_dump()` emits `"answer_image": null` on every frame and the byte-identical-off contract (task 01) is broken.
|
||||||
|
2. `app/schemas.py` — `ChatMessage` (L892, `extra="forbid"`): add `answer_image: SourceRef | None = None` (on BRAIN records only — the user record's phase-123 `image: str | None` stored-path field is untouched and semantically distinct; the docstring must say so). This is the phase-113 lesson applied up front: the done-time auto-save (task 06's `rememberBrainTurn`) will carry the key, and a missing field would 422 every grounded brain record on the public `POST/PUT /api/chats` boundary (the A2 quiet-failure path swallows it → grounded turns silently unsaved). No length cap needed — it is a single ref (`SourceRef`'s own field caps bind).
|
||||||
|
3. `app/api/chat.py` — the done-frame build (L1119-1133): pass `answer_image=source_ref_with_image(holder.shown_images[0]) if holder.shown_images else None` into `ChatDoneEvent` (the shared ref builder — source/path/title + `image_url` for the image doc; `holder.shown_images` is always empty on deflected turns — they run no agent — so deflected frames omit the key automatically).
|
||||||
|
4. ASSUMPTION: none beyond the phase's locked design (owner-confirmed 2026-09-26). The `query_log` row and the per-turn log line are UNCHANGED (the shown doc is a suggested/read doc already inside `record_docs`; `tool_calls` already counts the call — task 02).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: `tests/unit/test_image_chips_and_answers.py` (extend) — the `ChatDoneEvent` serializer: `answer_image=None` → `model_dump()` has NO `answer_image` key (assert `"answer_image" not in dump`, and the JSON has no `null` field) and the dump is byte-identical to the pre-phase shape; `answer_image=SourceRef(source=…, path=…, title=…, image_url=…)` → the key present with all four fields; the existing `sources`/`related`/`suggestions` serialization is untouched in both cases. `ChatMessage` boundary: a brain record dict with `answer_image` (full ref shape) parses; with `answer_image=None`/absent parses (pre-phase records); the user record's `image` (str path) still parses independently; an `answer_image` missing `image_url` parses (the field is optional on `SourceRef`) — and the phase-83 value bounds still bind the ref's fields.
|
||||||
|
- Integration: `tests/integration/test_chat_api.py` (extend) — (a) a grounded turn whose mock chat client scripts a `show_image` tool call on a seeded image doc (the mock's scripted-tool mechanism, the `use your tools` precedent) → the final SSE `done` frame JSON carries `answer_image` with `image_url == /api/documents/<id>/image` and the cited `sources` include the same doc; (b) the SAME scripted flow with `answer_images=False` settings → no `show_image` in the offered tools (assert on the mock's recorded request `tools` list: 3 entries) and the `done` frame has NO `answer_image` key; (c) a zero-tool grounded turn (no scripted calls) → `done` frame has NO `answer_image` key (byte-identical to pre-phase). `tests/integration/test_chats_api.py` (extend) — the phase-113 regression pin: `POST /api/chats` with a brain record carrying `answer_image` → 201 (NOT a silent 422) and the served chat round-trips the ref byte-for-byte; a record without the key round-trips unchanged.
|
||||||
|
- Coverage: **>90%** on this task's new/modified code (serializer + boundary + turn tests carry it).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] A turn whose agent called `show_image` → the `done` frame's JSON contains `answer_image` (source/path/title/image_url); every other turn's `done` frame is byte-identical to pre-phase (no key, no null).
|
||||||
|
- [ ] A saved chat persists and serves back a brain record with `answer_image`; pre-phase records (no key) round-trip byte-identically.
|
||||||
|
- [ ] Full test suite green; no behavior change in completed work.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Task 05 — Bug fix: suggested-tier image docs earn their source chips
|
||||||
|
|
||||||
|
**Phase:** `124_image_chips_and_answers` · **Source:** `TODO.md:3` — "Image support is working but images don't show up as sources in the sources chips."
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Close the observed bug deterministically: a retrieved image document in the suggested (seed) tier becomes a citation chip — WITHOUT the agent having to `read` it — because for an image doc the seed block already carries its ENTIRE content (summary == content == the vision description, phase 122 task 03), so "suggested" ≡ "read" and the phase-119 cite discipline is honored, not weakened.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `app/api/chat.py` — the `cited_docs` build (L1040-1046, currently the deduped `holder.read_docs` loop, the phase-119 read-docs-only contract per the L1027-1038 comment): AFTER that loop, add the auto-cite merge —
|
||||||
|
```python
|
||||||
|
# Phase 124 (TODO L3): a suggested-tier IMAGE doc's seed block IS its
|
||||||
|
# entire content (content == summary == the vision description) —
|
||||||
|
# "suggested" is equivalent to "read" for it, so it is auto-cited
|
||||||
|
# (the model never needs a read that would add only the marker line).
|
||||||
|
# Grounded turns only — a deflected answer cites nothing (unchanged).
|
||||||
|
if not plan.deflected:
|
||||||
|
for doc in plan.suggested_docs:
|
||||||
|
if not doc.is_image:
|
||||||
|
continue
|
||||||
|
key = (doc.source, doc.path)
|
||||||
|
if key not in cited_seen:
|
||||||
|
cited_seen.add(key)
|
||||||
|
cited_docs.append(doc)
|
||||||
|
```
|
||||||
|
Order: read docs first (read order, as today), then the auto-cited image docs in SUGGESTION order. Dedup: an image doc the agent also explicitly read is already in `cited_seen` from the first loop — it appears ONCE (at its read position). Update the L1027-1038 comment block to state the phase-124 addition (the read-docs-only rule gains its single documented exception: suggested-tier image docs).
|
||||||
|
2. Consequences to VERIFY (assert in tests, not code): (a) the related tier already excludes `cited_seen` members (`related_refs` filter, L1123-1127) — an auto-cited image doc leaves the "Nearby docs" row (no duplicate figure); (b) `record_docs` (L1049-1053, the `query_log.sources` + log-line `sources=` field) already includes `plan.suggested_docs` — the observability fields are UNCHANGED by this task; (c) a non-image suggested doc is never auto-cited (the `is_image` guard — the phase-119 read-docs rule stays intact for text docs: a zero-read text turn still chips nothing).
|
||||||
|
3. ASSUMPTION: none beyond the phase's locked A1 (scope = suggested tier only; deeper-ranked image docs keep the related-row figure — owner-confirmed 2026-09-26). No retriever change: `select_suggested`/`select_related` are untouched (the tiering math is out of scope — the fix is in the cited-tier COMPOSITION only).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: `tests/unit/test_image_chips_and_answers.py` (extend) — factor the cited-docs merge into a small pure helper if the inline version is untestable in place (the executor's call — prefer a module-level `def merge_cited_docs(read_docs, suggested_docs, deflected: bool) -> list[Document]` in `app/api/chat.py` taking exactly those args, called from the turn pipeline; test the helper directly): (a) suggested image doc + zero reads → it is cited (with its position after reads); (b) suggested image doc + agent also read it → exactly once; (c) suggested TEXT doc + zero reads → NOT cited (phase-119 contract intact); (d) `deflected=True` → `[]` (weak hits cite nothing); (e) two suggested image docs → both, in suggestion order; (f) read order preserved for mixed read text docs.
|
||||||
|
- Integration: `tests/integration/test_chat_api.py` (extend) — the end-to-end pin of the bug fix: a grounded turn with NO scripted tool calls (the mock answers straight from the seed) whose suggested tier contains an image doc (seed the KB with the fixture image doc — the image-ON app settings + a mock vision description, the `test_image_documents.py` fixture pattern) → the `done` frame's `sources` include the image ref WITH `image_url` set (the chip + figure data), and `related` does NOT contain the same (source, path). A text-only KB turn → byte-identical `sources` to pre-phase (no auto-cite without image docs).
|
||||||
|
- Coverage: **>90%** on this task's new/modified code (the helper matrix + the integration pin carry it).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] A zero-read grounded turn whose suggested tier holds an image doc → the sources chips (the `done` frame's `sources`) include the image doc with `image_url` — the phase-122 E2E's scripted `read` is no longer load-bearing for the chip.
|
||||||
|
- [ ] Text docs are unaffected: a zero-read text turn chips nothing (phase-119 contract); a deflected turn cites nothing.
|
||||||
|
- [ ] No duplicate: an auto-cited image doc does not also appear in the related row.
|
||||||
|
- [ ] Full test suite green; `query_log`/log-line `sources=` unchanged; no behavior change in completed work.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Task 06 — The answer figure: live render, persistence, restore, shared page
|
||||||
|
|
||||||
|
**Phase:** `124_image_chips_and_answers` · **Source:** `TODO.md:3` — "…the supporting image should be shown in the chat if the model thinks it would be effective." + "Summaries should still be provided"
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The client renders `answer_image` as a first-class figure in the brain bubble — below the answer text, above the source-chip row (A4) — persists it in the brain record, and restores it on refresh and on the shared page. The answer text is untouched and always rendered (the figure is ADDITIVE — "summaries should still be provided").
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/assets/app.js` — a new `appendAnswerImage(wrap, ref)` helper (place it near `appendSourceImageFigure`, L1677 — it is its visual sibling but a different surface): build `<figure class="answer-image">` containing a lazy `<img>` (`loading="lazy"`, `src = ref.image_url`, the public bytes route) + a visible caption `<span class="answer-image-caption">`. `alt` = `ref.title` immediately, swapped to the doc's summary via the SAME `fetchContentSummary` pattern (L1716 — the phase-122 WCAG alt contract: a caption is ALWAYS visible, the summary arrives by fetch, any fetch failure keeps the title). An `img` `error` listener REMOVES the whole figure (the collapse rule — an answer whose image bytes are gone renders as a normal answer, never a broken icon; the chip row below still cites the doc). Navigation reuses the chip affordance: the `<figure>` is wrapped/behaves as the `documentUrl(ref.source, ref.path, "/")` href (the no-JS / context-menu escape hatch) with the left-click → same-page document modal (phase 26, the `openDocumentModal` pattern from `appendSources`/`appendSourceImageFigure`).
|
||||||
|
2. `frontend/assets/app.js` — the `done` handler (L2981+): right BEFORE `appendSources(wrap, ev.sources)` (L2999), `if (ev.answer_image) appendAnswerImage(wrap, ev.answer_image);` (the figure lands in `.msg-body` above the `.msg-meta` chip row — position per A4). The text bubble is untouched (the `delta`-accumulated markdown stays — the figure is appended, never replaces).
|
||||||
|
3. `frontend/assets/app.js` — the `rememberBrainTurn` call in the `done` handler (L3015+): the meta gains `answer_image: ev.answer_image || undefined` (the house undefined-drops-the-key pattern — the `related: ev.related?.length ? ev.related : undefined` line two above is the precedent; image-less / pre-phase turns store byte-identical records).
|
||||||
|
4. `frontend/assets/app.js` — `renderStoredMessage` (L1856), the BRAIN branch: right BEFORE `appendSources(wrap, m.sources)` (L1904), `if (m.answer_image) appendAnswerImage(wrap, m.answer_image);` (a record without the key renders byte-identically — pre-phase chats restore unchanged).
|
||||||
|
5. `frontend/assets/shared.js` — the shared page's brain message fragment builder (L354+, the phase-123 user-image renderer at L329-352 is the closest precedent): the same `if (m.answer_image)` figure render (the shared page uses the chat page's fragment builders per its L188 comment — wire it where the brain branch appends sources, mirroring app.js's ordering: figure before `addSources`). The bytes route is PUBLIC — the shared view is faithful (the phase-123 user-image contract).
|
||||||
|
6. `frontend/assets/styles.css` — the `.answer-image` family in the `.source-image` language (L784-815) but at answer scale: `figure.answer-image` — margin within the bubble's flow, max-width 100% of the bubble; `.answer-image img` — max-width 100%, max-height ≈320px (the bubble is the 46rem chat column — a full-width image would blow the turn's height; cap it), `object-fit: contain`, the theme's bordered-image treatment (`.source-image-img`'s border/background, L793), `display: block`; `.answer-image figcaption`/caption span — the `.source-image-caption` styling (L804) at answer size (the visible caption = the summary). Dark/light theme parity (the semantic color tokens the theme system uses — no hardcoded colors outside the token system, PLAN §7); contrast ≥4.5:1 for the caption (WCAG AA per AGENTS.md rule 5).
|
||||||
|
7. ASSUMPTION: none beyond the phase's locked A4 (owner-confirmed 2026-09-26). The max-height cap value (≈320px) is the one free number — the executor matches the theme's existing image scaling; the COLLAPSE-on-error rule and the alt=summary contract are locked, not free.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- The UI is covered end-to-end by task 07's E2E suite (the house pattern — no separate JS unit suite; the E2E asserts the DOM): the live figure's presence/position (in `.msg-body`, before the `.msg-meta` chip row), its `src` (the bytes route) and lazy attribute, the caption/alt behavior, the collapse on a failed image load (assert the figure is ABSENT from the DOM, the answer text intact), persistence (the saved record carries `answer_image` — via the served saved chat), the restore render, and the shared-page render.
|
||||||
|
- Coverage: **>90%** on `app/` overall (this task is frontend — it must not regress the `app/` gate; the E2E in task 07 exercises it).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] Live: a `done` frame with `answer_image` → the brain bubble shows the figure below the text, above the chips; the text is fully rendered; a frame without it renders byte-identically (no figure DOM).
|
||||||
|
- [ ] Restore: a saved chat with a brain record carrying `answer_image` renders the figure after reload; pre-phase records render unchanged.
|
||||||
|
- [ ] Shared: the shared page renders the figure for such a record.
|
||||||
|
- [ ] A lost image (bytes route 404) → the figure collapses (removed), never a broken-image icon; the answer + chips are intact.
|
||||||
|
- [ ] Full test suite green; no behavior change in completed work (the chip figure `appendSourceImageFigure` is untouched).
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Task 07 — The mock-LLM `show_image` trigger + isolated E2E suite
|
||||||
|
|
||||||
|
**Phase:** `124_image_chips_and_answers` · **Source:** `TODO.md:3` — the whole item (the E2E proves both halves: the chips fix and the answer-image feature, plus the toggle-off contract)
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
A deterministic E2E proof of the phase, run in isolation per AGENTS.md §4: the mock LLM learns to script a `show_image` call (and to NOT `read` — the zero-read turn is what makes the bug-fix assertion meaningful), and `tests/e2e/test_answer_images.py` drives the real user path on a toggle-ON app and a default-OFF app.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/e2e/mock_llm.py` — a new scripted trigger (the documented marker-driven contract — extend the module docstring's trigger list):
|
||||||
|
- **Trigger:** the user message contains `show me the picture of` AND the system prompt contains `show_image` (the tool clause's presence — the toggle-ON gate: on a default-OFF app the clause is absent, so the trigger NEVER fires there and the mock behaves exactly as pre-phase for every other suite).
|
||||||
|
- **Flow:** on the FIRST matching request, stream ONLY `tool_calls` deltas — `show_image` with `path` = the combined `source/path` of the image document seeded in the `<documents>` blocks (the mock picks the seeded block whose `path` attribute ends in an image extension — `.png/.jpg/.jpeg/.webp/.gif/.bmp` — the fixture guarantees exactly one; the block-parsing machinery of the `use your tools` / tail-echo triggers is the precedent). Synthetic id `call_show`, `finish_reason: "tool_calls"`, no content. On the follow-up request (the tool result in context — recognize the `Image shown to the user` confirming line), stream the final answer: a short deterministic sentence ending with the mock's tail-echo of the `<documents>` block (the existing echo convention — it proves the seed was there) — NO further tool calls, NO `read` (the zero-read turn).
|
||||||
|
- **The bug-fix-only trigger:** the user message contains `which diagram is in your notes` (a second trigger, same image-doc pick, but NO `show_image` — the mock answers straight from the seed with ZERO tool calls; its answer quotes the tail of the seeded image doc's block — proving the description was in the prompt and that the chip appears with NO tool calls at all).
|
||||||
|
2. `tests/e2e/test_answer_images.py` (new) — module apps on the `test_image_documents.py` pattern (L88-186: dedicated ports, scratch dirs, `BOR_IMAGES=true`):
|
||||||
|
- **App A (feature ON):** `BOR_IMAGES=true` + `BOR_ANSWER_IMAGES=true`.
|
||||||
|
- **App B (default off):** `BOR_IMAGES=true`, `BOR_ANSWER_IMAGES` unset (the production default — the toggle-off contract).
|
||||||
|
- **KB fixture (both apps):** upload the single-PNG fixture archive (the `test_image_documents.py` archive builder, L201+) via the real admin upload flow → "Sync sources" → the image doc exists (assert on the Sources page, as that suite does). The mock's `IMAGE_DESCRIPTION_MODE` serves the description (the fixture doc's content/summary — already byte-stable).
|
||||||
|
- **Feature flow (App A):** open the chat → ask `<trigger question with "show me the picture of …">` → assert: (1) the brain bubble's `.msg-body` contains `.answer-image` with an `<img>` whose `src` is `/api/documents/<id>/image` (fetch the served bytes — 200, image Content-Type) and `loading="lazy"`; (2) the figure sits BEFORE the `.msg-meta` source-chip row (the A4 position); (3) the answer TEXT is non-empty (the "summaries still provided" contract); (4) the source-chip row carries the image doc's chip (the AUTO-CITE proof — the mock made ZERO `read` calls; only `show_image`) with its phase-122 figure; (5) the "Nearby docs" row does NOT repeat the image doc. Reload the page → the restored brain bubble shows the figure again (the `answer_image` persistence). Open the shared link (the existing share flow) → the shared page shows the figure.
|
||||||
|
- **Bug-fix-only flow (App A):** a fresh chat → ask the `which diagram is in your notes` trigger → ZERO tool calls → the source-chip row STILL carries the image doc's chip + figure (the bug fix on the pure zero-read path — the assertion the owner's report describes), the answer text quotes the seeded description tail.
|
||||||
|
- **Toggle-off flow (App B):** the same feature trigger question → the mock never sees `show_image` in the offered tools (assert via the mock's request log, the suite's existing mechanism where present — else assert the DOM: NO `.answer-image` in the brain bubble) and the `done` frame carries no `answer_image` (the saved chat's brain record has no `answer_image` key — fetch the saved chat via the API); the bug-fix trigger still chips the image doc (the chips fix is NOT gated by `BOR_ANSWER_IMAGES` — it belongs to `BOR_IMAGES`-indexed docs, per the phase design).
|
||||||
|
3. Run the suite IN ISOLATION: `uv run pytest tests/e2e/test_answer_images.py -v --no-cov` (AGENTS.md §4/§9 — one file, its own app instances on dedicated ports; no cross-suite state).
|
||||||
|
4. ASSUMPTION: none beyond the phase's locked decisions (owner-confirmed 2026-09-26). The two trigger phrases are new and carry no collision with existing triggers (the mock's docstring lists every trigger — verify against it when writing; pick differently on collision).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- This task IS the E2E suite; it also runs the final phase gate: `uv run pytest` (unit + integration) green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL **>90%** on `app/` (the validate.sh gate — if the new `app/` code (agent branch, prompts clause, schemas serializer, chat.py merge) dips the total, add unit pins, do NOT lower the bar); `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- The mock change must not perturb any existing suite: run at least `tests/e2e/test_image_documents.py` and `tests/e2e/test_agent_document_tools.py` after the mock edit (the trigger is clause-gated, so pre-phase prompts never fire it — verify, don't assume).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `uv run pytest tests/e2e/test_answer_images.py -v --no-cov` passes in isolation (feature flow, bug-fix-only flow, toggle-off flow — all assertions above).
|
||||||
|
- [ ] The mock's new trigger is inert for every existing suite (the two spot-checked suites pass unmodified).
|
||||||
|
- [ ] `uv run pytest` green; coverage TOTAL >90%; `ruff` + `pyright` clean.
|
||||||
|
- [ ] One atomic `--no-gpg-sign` commit for the phase code (per AGENTS.md §8 — the commit block in `00_phase.md`); the pipeline gate then moves the phase dir to `.agents/phases/complete/` (the house `phase: NN_name` bookkeeping commit, as for every completed phase).
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
All green. Final report:
|
||||||
|
|
||||||
|
**Phase 124 final verification pass — all criteria verified, no defects found (nothing to fix)**
|
||||||
|
- Verified the implemented phase (all 7 tasks done, changes in working tree): `BOR_ANSWER_IMAGES` toggle, `show_image` tool + refusals + `holder.shown_images`, toggle-gated prompt clause, `answer_image` frame field (None-omitting serializer) + `ChatMessage` persistence, `merge_cited_docs` auto-cite bug fix, answer-figure UI (live/restore/shared/CSS), mock triggers + E2E suite. Also confirmed the bytes route → public change matches the phase-123 shared-chat precedent (task 06 design).
|
||||||
|
- `uv run pytest --cov=app --cov-report=term-missing` → **2848 passed**, TOTAL **99%** (>90% gate)
|
||||||
|
- `bash .agents/validate.sh` → **validation OK** (tests + coverage + ruff + pyright)
|
||||||
|
- `uv run ruff check . && uv run pyright` → clean, 0 errors
|
||||||
|
- `uv run pytest tests/e2e/test_answer_images.py -v --no-cov` (isolated) → **5/5 passed**
|
||||||
|
- Mock-inert spot checks: `tests/e2e/test_image_documents.py` 4/4, `tests/e2e/test_agent_document_tools.py` 4/4
|
||||||
|
|
||||||
|
**Completion criteria:** ① zero-read suggested-tier image doc chips with figure — verified (unit matrix, integration `test_zero_read_grounded_turn_auto_cites_suggested_image_doc`, E2E bug-fix flows); ② show_image → answer figure below text/above chips, second call refused, persists + restore + shared — verified (unit handler matrix, integration round-trip, E2E feature flow); ③ default-off byte-identical (3 tools, no clause, no key) — verified (unit + integration + E2E toggle-off); ④ full suite green / >90% / lint clean — verified above; ⑤ commit + phase move — harness responsibility (changes left uncommitted in working tree, no live-infrastructure changes made).
|
||||||
|
|
||||||
|
**Next pending phase:** none — `todo/` holds only this phase; the pipeline is clear once the harness moves it.
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
........................................................................ [ 2%]
|
||||||
|
........................................................................ [ 5%]
|
||||||
|
........................................................................ [ 7%]
|
||||||
|
........................................................................ [ 10%]
|
||||||
|
........................................................................ [ 12%]
|
||||||
|
........................................................................ [ 15%]
|
||||||
|
........................................................................ [ 17%]
|
||||||
|
........................................................................ [ 20%]
|
||||||
|
........................................................................ [ 22%]
|
||||||
|
........................................................................ [ 25%]
|
||||||
|
........................................................................ [ 27%]
|
||||||
|
........................................................................ [ 30%]
|
||||||
|
........................................................................ [ 32%]
|
||||||
|
........................................................................ [ 35%]
|
||||||
|
........................................................................ [ 37%]
|
||||||
|
........................................................................ [ 40%]
|
||||||
|
........................................................................ [ 42%]
|
||||||
|
........................................................................ [ 45%]
|
||||||
|
........................................................................ [ 48%]
|
||||||
|
........................................................................ [ 50%]
|
||||||
|
........................................................................ [ 53%]
|
||||||
|
........................................................................ [ 55%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 60%]
|
||||||
|
........................................................................ [ 63%]
|
||||||
|
........................................................................ [ 65%]
|
||||||
|
........................................................................ [ 68%]
|
||||||
|
........................................................................ [ 70%]
|
||||||
|
........................................................................ [ 73%]
|
||||||
|
........................................................................ [ 75%]
|
||||||
|
........................................................................ [ 78%]
|
||||||
|
........................................................................ [ 80%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 85%]
|
||||||
|
........................................................................ [ 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 131 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 4669 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
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
That's an empty harness session log — not mine, leaving it untouched. Task 01 is complete and all gates are green.
|
||||||
|
|
||||||
|
## Report
|
||||||
|
|
||||||
|
**Implemented (task 01 — `BOR_ANSWER_IMAGES` feature toggle):**
|
||||||
|
- `app/config.py`: new `Settings.answer_images: bool = False` in a new "Answer images (phase 124)" block after `chat_image_max_mb` — docstring per task spec (gates the ANSWER side only; INDEPENDENT of `images`/`BOR_IMAGES` per LOCKED A2; off = no `show_image` tool, no prompt clause, no `answer_image` frame key)
|
||||||
|
- `.env.example`: commented `# BOR_ANSWER_IMAGES=0` + "off by default; enable alongside BOR_IMAGES" comment beside the existing image block
|
||||||
|
- `tests/unit/test_image_chips_and_answers.py` (new, created per phase overview for tasks 01–05): default-off (unset/`0`/`false`), truthy parsing (`1`/`true`, mirroring the `stream_thinking` bool-test precedent), and bidirectional independence from `images`
|
||||||
|
- Per task instruction, no `answer_images` key in `GET /api/config` (UI is purely reactive)
|
||||||
|
|
||||||
|
**Test / lint / coverage results:**
|
||||||
|
- `uv run pytest` → **2799 passed** (51 s, full unit + integration suite)
|
||||||
|
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90% gate)
|
||||||
|
- `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors, 0 warnings
|
||||||
|
- New-file run: `uv run pytest tests/unit/test_image_chips_and_answers.py tests/unit/test_config.py -v --no-cov` → 63 passed
|
||||||
|
|
||||||
|
**Decisions / deviations:** none — followed the task file exactly; verified via grep no other code reads the field yet (off-state byte-identical). Left changes uncommitted per harness protocol.
|
||||||
|
|
||||||
|
**Next pending task:** `02_show_image_tool.md` (`agent_tools` gating + `show_image` handler).
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
........................................................................ [ 2%]
|
||||||
|
........................................................................ [ 5%]
|
||||||
|
........................................................................ [ 7%]
|
||||||
|
........................................................................ [ 10%]
|
||||||
|
........................................................................ [ 12%]
|
||||||
|
........................................................................ [ 15%]
|
||||||
|
........................................................................ [ 18%]
|
||||||
|
........................................................................ [ 20%]
|
||||||
|
........................................................................ [ 23%]
|
||||||
|
........................................................................ [ 25%]
|
||||||
|
........................................................................ [ 28%]
|
||||||
|
........................................................................ [ 30%]
|
||||||
|
........................................................................ [ 33%]
|
||||||
|
........................................................................ [ 36%]
|
||||||
|
........................................................................ [ 38%]
|
||||||
|
........................................................................ [ 41%]
|
||||||
|
........................................................................ [ 43%]
|
||||||
|
........................................................................ [ 46%]
|
||||||
|
........................................................................ [ 48%]
|
||||||
|
........................................................................ [ 51%]
|
||||||
|
........................................................................ [ 54%]
|
||||||
|
........................................................................ [ 56%]
|
||||||
|
........................................................................ [ 59%]
|
||||||
|
........................................................................ [ 61%]
|
||||||
|
........................................................................ [ 64%]
|
||||||
|
........................................................................ [ 66%]
|
||||||
|
........................................................................ [ 69%]
|
||||||
|
........................................................................ [ 72%]
|
||||||
|
........................................................................ [ 74%]
|
||||||
|
........................................................................ [ 77%]
|
||||||
|
........................................................................ [ 79%]
|
||||||
|
........................................................................ [ 82%]
|
||||||
|
........................................................................ [ 84%]
|
||||||
|
........................................................................ [ 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 248 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 131 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 349 1 99%
|
||||||
|
app/rag/archive_upload.py 134 0 100%
|
||||||
|
app/rag/chunker.py 206 4 98%
|
||||||
|
app/rag/doc_dates.py 18 0 100%
|
||||||
|
app/rag/folder_summaries.py 123 0 100%
|
||||||
|
app/rag/git_sources.py 38 0 100%
|
||||||
|
app/rag/importer.py 316 16 95%
|
||||||
|
app/rag/llm.py 244 1 99%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/prompts.py 102 0 100%
|
||||||
|
app/rag/retriever.py 248 3 99%
|
||||||
|
app/rag/scaffolding.py 55 0 100%
|
||||||
|
app/rag/source_removal.py 41 0 100%
|
||||||
|
app/rag/sources_meta.py 16 0 100%
|
||||||
|
app/rag/suggestions.py 27 0 100%
|
||||||
|
app/rag/summarizer.py 48 0 100%
|
||||||
|
app/schemas.py 390 0 100%
|
||||||
|
--------------------------------------------------
|
||||||
|
TOTAL 4616 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
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
All gates green. Final report:
|
||||||
|
|
||||||
|
**Task 02 — `show_image` agent tool (toggle-gated): complete**
|
||||||
|
|
||||||
|
- `app/rag/agent.py`: new `SHOW_IMAGE_TOOL` constant (gate copy per LOCKED A5 in the description) + `agent_tools(settings)` returning a fresh `[...AGENT_TOOLS]` (+ `show_image` only when `answer_images` on); new refusal constants `MISSING_SHOW_ARGS` / `NOT_AN_IMAGE_DOC` (arg-echoed) / `ALREADY_SHOWING`; `show_image` branch in `_execute_tool` (after `read`, before the unknown-tool fallback) — missing-arg → refusal, unresolvable → phase-72 `_no_document_refusal`, text doc → refused (never converted to read), second call → `ALREADY_SHOWING` (A3, server-enforced), success → `holder.shown_images.append` + `tool_calls += 1` + confirming line and the exact read-style image doc block (`Document …` / `date:` / `IMAGE_DOC_MARKER` / description, no truncation machinery); `AgentHolder.shown_images` field + docstring; `run_agent` wiring now `agent_tools(settings) if max_rounds > 0 else None` (kill switch unchanged). `AGENT_TOOLS` itself is byte-identical (verified by diff + all existing agent pins pass).
|
||||||
|
- `tests/unit/test_image_chips_and_answers.py`: 9 new tests — offer gating (off = 3 base, element-for-element + fresh-list mutation safety; on = +`show_image` with gate copy), tool-definition shape, `run_agent` offer wiring on/off, refusal-text pins, and the full handler matrix (a–e) against real Postgres rows.
|
||||||
|
- Results: `uv run pytest --cov=app` → 2809 passed, TOTAL **99%** (>90%); `uv run ruff check .` → clean; `uv run pyright` → 0 errors; regression `uv run pytest tests/e2e/test_image_documents.py -v --no-cov` → 4 passed.
|
||||||
|
- Deviations: none; task 02 is unit-scoped (integration pins are task 04, E2E is task 07).
|
||||||
|
- Next pending task: `03_prompt_clause.md` (the toggle-gated `<tools>` clause in the HIGH prompt).
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
........................................................................ [ 2%]
|
||||||
|
........................................................................ [ 5%]
|
||||||
|
........................................................................ [ 7%]
|
||||||
|
........................................................................ [ 10%]
|
||||||
|
........................................................................ [ 12%]
|
||||||
|
........................................................................ [ 15%]
|
||||||
|
........................................................................ [ 17%]
|
||||||
|
........................................................................ [ 20%]
|
||||||
|
........................................................................ [ 23%]
|
||||||
|
........................................................................ [ 25%]
|
||||||
|
........................................................................ [ 28%]
|
||||||
|
........................................................................ [ 30%]
|
||||||
|
........................................................................ [ 33%]
|
||||||
|
........................................................................ [ 35%]
|
||||||
|
........................................................................ [ 38%]
|
||||||
|
........................................................................ [ 41%]
|
||||||
|
........................................................................ [ 43%]
|
||||||
|
........................................................................ [ 46%]
|
||||||
|
........................................................................ [ 48%]
|
||||||
|
........................................................................ [ 51%]
|
||||||
|
........................................................................ [ 53%]
|
||||||
|
........................................................................ [ 56%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 61%]
|
||||||
|
........................................................................ [ 64%]
|
||||||
|
........................................................................ [ 66%]
|
||||||
|
........................................................................ [ 69%]
|
||||||
|
........................................................................ [ 71%]
|
||||||
|
........................................................................ [ 74%]
|
||||||
|
........................................................................ [ 76%]
|
||||||
|
........................................................................ [ 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 248 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 131 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 102 0 100%
|
||||||
|
app/rag/retriever.py 248 3 99%
|
||||||
|
app/rag/scaffolding.py 55 0 100%
|
||||||
|
app/rag/source_removal.py 41 0 100%
|
||||||
|
app/rag/sources_meta.py 16 0 100%
|
||||||
|
app/rag/suggestions.py 27 0 100%
|
||||||
|
app/rag/summarizer.py 48 0 100%
|
||||||
|
app/schemas.py 390 0 100%
|
||||||
|
--------------------------------------------------
|
||||||
|
TOTAL 4641 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
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
Task 03 complete. All criteria verified.
|
||||||
|
|
||||||
|
**Implemented**
|
||||||
|
- `app/rag/prompts.py`: new `SHOW_IMAGE_CLAUSE` constant (all four A5 gate elements: GENERATED-description nature / "actually wants to see" bar / one image per answer / answer fully in text); `build_high_prompt` gained `show_image: bool = False` — the clause rides inside `<tools>` (after the three-tool copy, before `</tools>`); `TOOLS_SECTION` and the toggle-off output untouched.
|
||||||
|
- `app/api/chat.py`: the single HIGH-prompt build site (`plan_turn`) passes `show_image=settings.answer_images`; deflection prompt untouched.
|
||||||
|
- Tests: 6 new in `tests/unit/test_prompts.py` (byte-identity off, structural equation on, four phrases, clause HIGH-only + `build_deflect_prompt` has no flag, empty-docs build, all-sections shape) + 2 `plan_turn` wiring pins in `tests/unit/test_image_chips_and_answers.py` (grounded plan carries the clause exactly when the toggle is on; deflected never).
|
||||||
|
|
||||||
|
**Results**
|
||||||
|
- `uv run pytest --cov=app --cov-report=term-missing` → 2817 passed, 0 failed; TOTAL 99% (prompts.py 100%, chat.py 99%)
|
||||||
|
- `uv run ruff check . && uv run pyright` → clean (fixed one import-sort + one pyright `**kwargs` inference)
|
||||||
|
- Pre-existing `test_prompts.py`/`test_prompt_lock.py` pins pass unmodified (62 passed)
|
||||||
|
|
||||||
|
**Decisions**
|
||||||
|
- Clause constant carries no trailing newline; the built section inserts one before `</tools>` (house style: closing tag on its own line) — literal task construction otherwise.
|
||||||
|
|
||||||
|
**Next pending task:** `04_frame_and_persistence.md` (ChatDoneEvent/ChatMessage `answer_image` + done-frame wiring).
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
........................................................................ [ 2%]
|
||||||
|
........................................................................ [ 5%]
|
||||||
|
........................................................................ [ 7%]
|
||||||
|
........................................................................ [ 10%]
|
||||||
|
........................................................................ [ 12%]
|
||||||
|
........................................................................ [ 15%]
|
||||||
|
........................................................................ [ 17%]
|
||||||
|
........................................................................ [ 20%]
|
||||||
|
........................................................................ [ 23%]
|
||||||
|
........................................................................ [ 25%]
|
||||||
|
........................................................................ [ 28%]
|
||||||
|
........................................................................ [ 30%]
|
||||||
|
........................................................................ [ 33%]
|
||||||
|
........................................................................ [ 35%]
|
||||||
|
........................................................................ [ 38%]
|
||||||
|
........................................................................ [ 40%]
|
||||||
|
........................................................................ [ 43%]
|
||||||
|
........................................................................ [ 46%]
|
||||||
|
........................................................................ [ 48%]
|
||||||
|
........................................................................ [ 51%]
|
||||||
|
........................................................................ [ 53%]
|
||||||
|
........................................................................ [ 56%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 61%]
|
||||||
|
........................................................................ [ 63%]
|
||||||
|
........................................................................ [ 66%]
|
||||||
|
........................................................................ [ 69%]
|
||||||
|
........................................................................ [ 71%]
|
||||||
|
........................................................................ [ 74%]
|
||||||
|
........................................................................ [ 76%]
|
||||||
|
........................................................................ [ 79%]
|
||||||
|
........................................................................ [ 81%]
|
||||||
|
........................................................................ [ 84%]
|
||||||
|
........................................................................ [ 86%]
|
||||||
|
........................................................................ [ 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 248 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 131 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 390 0 100%
|
||||||
|
--------------------------------------------------
|
||||||
|
TOTAL 4645 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
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
Everything is green. The previous attempt's code was intact; the rejection was a child-run reporting glitch, not a test/lint failure. All gates re-verified.
|
||||||
|
|
||||||
|
**Report**
|
||||||
|
|
||||||
|
- Implemented: `SHOW_IMAGE_CLAUSE` constant in `app/rag/prompts.py` (all four A5 gate elements); `build_high_prompt` gained `show_image: bool = False` — clause lands inside `<tools>` after the three-tool copy, before `</tools>`; `TOOLS_SECTION` and the toggle-off output byte-identical.
|
||||||
|
- `app/api/chat.py`: the single HIGH-prompt build site (`plan_turn`) passes `show_image=settings.answer_images`; deflection prompt untouched.
|
||||||
|
- Tests: 6 new in `tests/unit/test_prompts.py` (byte-identity off, structural equation on, four stable phrases, clause HIGH-only + `build_deflect_prompt` takes no flag, empty-docs build, full-sections shape) + 2 `plan_turn` wiring pins in `tests/unit/test_image_chips_and_answers.py` (grounded plan carries the clause exactly when the toggle is on; deflected never).
|
||||||
|
- `uv run pytest --cov=app --cov-report=term-missing` → 2817 passed, 0 failed; TOTAL 99% (prompts.py 100%, chat.py 99%).
|
||||||
|
- `uv run ruff check . && uv run pyright` → all checks passed, 0 errors.
|
||||||
|
- Pre-existing `test_prompts.py`/`test_prompt_lock.py` pins pass unmodified (62 passed).
|
||||||
|
- Decisions: clause constant carries no trailing newline; the built section inserts one before `</tools>` (house style: closing tag on its own line) — literal task construction otherwise.
|
||||||
|
- Next pending task: `04_frame_and_persistence.md` (ChatDoneEvent/ChatMessage `answer_image` + done-frame wiring).
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
........................................................................ [ 2%]
|
||||||
|
........................................................................ [ 5%]
|
||||||
|
........................................................................ [ 7%]
|
||||||
|
........................................................................ [ 10%]
|
||||||
|
........................................................................ [ 12%]
|
||||||
|
........................................................................ [ 15%]
|
||||||
|
........................................................................ [ 17%]
|
||||||
|
........................................................................ [ 20%]
|
||||||
|
........................................................................ [ 23%]
|
||||||
|
........................................................................ [ 25%]
|
||||||
|
........................................................................ [ 28%]
|
||||||
|
........................................................................ [ 30%]
|
||||||
|
........................................................................ [ 33%]
|
||||||
|
........................................................................ [ 35%]
|
||||||
|
........................................................................ [ 38%]
|
||||||
|
........................................................................ [ 40%]
|
||||||
|
........................................................................ [ 43%]
|
||||||
|
........................................................................ [ 46%]
|
||||||
|
........................................................................ [ 48%]
|
||||||
|
........................................................................ [ 51%]
|
||||||
|
........................................................................ [ 53%]
|
||||||
|
........................................................................ [ 56%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 61%]
|
||||||
|
........................................................................ [ 63%]
|
||||||
|
........................................................................ [ 66%]
|
||||||
|
........................................................................ [ 69%]
|
||||||
|
........................................................................ [ 71%]
|
||||||
|
........................................................................ [ 74%]
|
||||||
|
........................................................................ [ 76%]
|
||||||
|
........................................................................ [ 79%]
|
||||||
|
........................................................................ [ 81%]
|
||||||
|
........................................................................ [ 84%]
|
||||||
|
........................................................................ [ 86%]
|
||||||
|
........................................................................ [ 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 248 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 131 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 390 0 100%
|
||||||
|
--------------------------------------------------
|
||||||
|
TOTAL 4645 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
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
All green. Final report:
|
||||||
|
|
||||||
|
**Implemented (task 04 — frame + persistence):**
|
||||||
|
- `app/schemas.py`: `ChatDoneEvent.answer_image: SourceRef | None` + wrap serializer dropping the key when `None` (byte-identical-off); `ChatMessage.answer_image` (brain records; docstring pins it distinct from the phase-123 user `image` path) + extended the wrap serializer to drop it when `None`
|
||||||
|
- `app/api/chat.py`: done frame passes `source_ref_with_image(holder.shown_images[0]) if holder.shown_images else None`
|
||||||
|
- `tests/conftest.py`: `BOR_ANSWER_IMAGES` leak guard (phase-106/122 house pattern)
|
||||||
|
- 14 unit tests (`test_image_chips_and_answers.py`): serializer omission/presence, pre-phase parse, full-ref, phase-83 bounds; 4 integration chat tests (show_image turn → `answer_image` + cited source + 4-tool offer; toggle-off → 3 tools + no key; zero-tool + deflected-toggle-on → no key on raw wire); 3 chats tests (201 round-trip incl. raw JSONB — phase-113 422 pin, no-key unchanged, shared-serve fidelity)
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- `uv run pytest --cov=app --cov-report=term-missing` → 2834 passed, TOTAL **99%** (>90%; `schemas.py` 100%, `chat.py` 99% — sole miss is pre-existing line 521)
|
||||||
|
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors
|
||||||
|
- Regression check: `tests/e2e/test_share_chat.py` (4 passed) + `tests/e2e/test_image_documents.py` (4 passed) in isolation
|
||||||
|
|
||||||
|
**Decisions:** extended the single existing `ChatMessage` wrap serializer (Pydantic allows one per model) so pre-phase records round-trip byte-identically — existing `FULL_BRAIN`/`_expect` pins pass unmodified. Test (a) scripts read+show_image (cited tier = read docs until task 05's auto-cite); (b) omits the show_image call per the phase overview ("no such call occurs"). No live infra touched; no commits made.
|
||||||
|
|
||||||
|
**Next pending task:** `.agents/phases/todo/124_image_chips_and_answers/05_auto_cite_image_docs.md`
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
........................................................................ [ 2%]
|
||||||
|
........................................................................ [ 5%]
|
||||||
|
........................................................................ [ 7%]
|
||||||
|
........................................................................ [ 10%]
|
||||||
|
........................................................................ [ 12%]
|
||||||
|
........................................................................ [ 15%]
|
||||||
|
........................................................................ [ 17%]
|
||||||
|
........................................................................ [ 20%]
|
||||||
|
........................................................................ [ 22%]
|
||||||
|
........................................................................ [ 25%]
|
||||||
|
........................................................................ [ 27%]
|
||||||
|
........................................................................ [ 30%]
|
||||||
|
........................................................................ [ 33%]
|
||||||
|
........................................................................ [ 35%]
|
||||||
|
........................................................................ [ 38%]
|
||||||
|
........................................................................ [ 40%]
|
||||||
|
........................................................................ [ 43%]
|
||||||
|
........................................................................ [ 45%]
|
||||||
|
........................................................................ [ 48%]
|
||||||
|
........................................................................ [ 50%]
|
||||||
|
........................................................................ [ 53%]
|
||||||
|
........................................................................ [ 55%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 60%]
|
||||||
|
........................................................................ [ 63%]
|
||||||
|
........................................................................ [ 66%]
|
||||||
|
........................................................................ [ 68%]
|
||||||
|
........................................................................ [ 71%]
|
||||||
|
........................................................................ [ 73%]
|
||||||
|
........................................................................ [ 76%]
|
||||||
|
........................................................................ [ 78%]
|
||||||
|
........................................................................ [ 81%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 86%]
|
||||||
|
........................................................................ [ 88%]
|
||||||
|
........................................................................ [ 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 249 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 131 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 4656 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
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
Everything is green. The prior rejection was a harness report-format issue, not a code failure — all work was already in the working tree and remains intact. Final verification complete.
|
||||||
|
|
||||||
|
**Implemented (task 04 — the `answer_image` done frame + saved-chat persistence):**
|
||||||
|
- `app/schemas.py`: `ChatDoneEvent.answer_image: SourceRef | None` + a `model_serializer(mode="wrap")` that drops the key when `None` (byte-identical-off — absent, never `null`). `ChatMessage.answer_image` (brain records; docstring marks it distinct from the phase-123 user `image` path) + extended the wrap serializer to drop it when `None`.
|
||||||
|
- `app/api/chat.py`: done frame passes `source_ref_with_image(holder.shown_images[0]) if holder.shown_images else None`.
|
||||||
|
- `tests/conftest.py`: `BOR_ANSWER_IMAGES` leak guard (phase-106/122 house pattern).
|
||||||
|
- 14 unit tests + 4 chat-integration tests + 3 chats-integration tests (phase-113 422 round-trip pin, no-key unchanged, shared-serve fidelity).
|
||||||
|
|
||||||
|
**Results (all re-run this session):**
|
||||||
|
- `uv run pytest --cov=app --cov-report=term-missing` → **2834 passed**, TOTAL **99%** (>90%; `schemas.py` 100%)
|
||||||
|
- `uv run ruff check .` → All checks passed
|
||||||
|
- `uv run pyright` → 0 errors, 0 warnings
|
||||||
|
- task-04 files in isolation → 147 passed
|
||||||
|
|
||||||
|
**Notable decisions:** extended the single existing `ChatMessage` wrap serializer (Pydantic allows one per model) so pre-phase records round-trip byte-identically; existing `FULL_BRAIN`/`_expect` pins pass unmodified. Test (a) scripts read+show_image (cited tier = read docs until task 05); (b) omits the show_image call per the phase overview ("no such call occurs"). No live infra touched; no commits made (left in working tree for the harness).
|
||||||
|
|
||||||
|
**Next pending task:** `.agents/phases/todo/124_image_chips_and_answers/05_auto_cite_image_docs.md`
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
........................................................................ [ 2%]
|
||||||
|
........................................................................ [ 5%]
|
||||||
|
........................................................................ [ 7%]
|
||||||
|
........................................................................ [ 10%]
|
||||||
|
........................................................................ [ 12%]
|
||||||
|
........................................................................ [ 15%]
|
||||||
|
........................................................................ [ 17%]
|
||||||
|
........................................................................ [ 20%]
|
||||||
|
........................................................................ [ 22%]
|
||||||
|
........................................................................ [ 25%]
|
||||||
|
........................................................................ [ 27%]
|
||||||
|
........................................................................ [ 30%]
|
||||||
|
........................................................................ [ 33%]
|
||||||
|
........................................................................ [ 35%]
|
||||||
|
........................................................................ [ 38%]
|
||||||
|
........................................................................ [ 40%]
|
||||||
|
........................................................................ [ 43%]
|
||||||
|
........................................................................ [ 45%]
|
||||||
|
........................................................................ [ 48%]
|
||||||
|
........................................................................ [ 50%]
|
||||||
|
........................................................................ [ 53%]
|
||||||
|
........................................................................ [ 55%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 60%]
|
||||||
|
........................................................................ [ 63%]
|
||||||
|
........................................................................ [ 66%]
|
||||||
|
........................................................................ [ 68%]
|
||||||
|
........................................................................ [ 71%]
|
||||||
|
........................................................................ [ 73%]
|
||||||
|
........................................................................ [ 76%]
|
||||||
|
........................................................................ [ 78%]
|
||||||
|
........................................................................ [ 81%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 86%]
|
||||||
|
........................................................................ [ 88%]
|
||||||
|
........................................................................ [ 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 249 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 131 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 4656 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
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
Working tree intact, everything green. Task complete.
|
||||||
|
|
||||||
|
**Report — Task 05: auto-cite suggested-tier image docs**
|
||||||
|
|
||||||
|
- `app/api/chat.py`: new module-level `merge_cited_docs(read_docs, suggested_docs, deflected)` — phase-119 read-docs tier + the single documented exception: suggested-tier `is_image` docs auto-cited in suggestion order after reads, deduped by `(source, path)`; deflected → `[]`. Pipeline calls it; `cited_seen` derived from the result (related-tier exclusion and `record_docs` untouched). Comment block updated per spec.
|
||||||
|
- Unit: 6 new tests in `tests/unit/test_image_chips_and_answers.py` covering cases (a)–(f) (zero-read image cited, read-image once at read position, text never auto-cited, deflected `[]`, two images in suggestion order, read order preserved/deduped).
|
||||||
|
- Integration: `tests/integration/test_chat_api.py` — zero-read grounded turn with a seeded image doc (content==summary==description + question-overlapping chunk, phase-122 invariant) → `done.sources` carries the image ref with `image_url`, `related` excludes it, `query_log.sources` unchanged; text-only KB turn → `sources == []` byte-identical.
|
||||||
|
- Tests: `uv run pytest` → **2842 passed**; `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (chat.py 99%); `uv run ruff check . && uv run pyright` → **clean (0 errors)**.
|
||||||
|
- Predecessor E2E regression checks (in isolation): `test_image_documents.py` 4 passed, `test_chat_image_questions.py` 5 passed — scripted `read` still valid, no double chips.
|
||||||
|
- Notable: image-doc summaries carry no `Source:` pointer line (verbatim description, phase-122 task 03) — seed pin asserts the `<document source=… path=…>` block instead.
|
||||||
|
- Next pending task: `.agents/phases/todo/124_image_chips_and_answers/06_chat_ui_answer_image.md`.
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
........................................................................ [ 2%]
|
||||||
|
........................................................................ [ 5%]
|
||||||
|
........................................................................ [ 7%]
|
||||||
|
........................................................................ [ 10%]
|
||||||
|
........................................................................ [ 12%]
|
||||||
|
........................................................................ [ 15%]
|
||||||
|
........................................................................ [ 17%]
|
||||||
|
........................................................................ [ 20%]
|
||||||
|
........................................................................ [ 22%]
|
||||||
|
........................................................................ [ 25%]
|
||||||
|
........................................................................ [ 27%]
|
||||||
|
........................................................................ [ 30%]
|
||||||
|
........................................................................ [ 32%]
|
||||||
|
........................................................................ [ 35%]
|
||||||
|
........................................................................ [ 38%]
|
||||||
|
........................................................................ [ 40%]
|
||||||
|
........................................................................ [ 43%]
|
||||||
|
........................................................................ [ 45%]
|
||||||
|
........................................................................ [ 48%]
|
||||||
|
........................................................................ [ 50%]
|
||||||
|
........................................................................ [ 53%]
|
||||||
|
........................................................................ [ 55%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 60%]
|
||||||
|
........................................................................ [ 63%]
|
||||||
|
........................................................................ [ 65%]
|
||||||
|
........................................................................ [ 68%]
|
||||||
|
........................................................................ [ 70%]
|
||||||
|
........................................................................ [ 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 131 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 4669 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
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
**Report — Task 05: auto-cite suggested-tier image docs (re-verified after harness rejection)**
|
||||||
|
|
||||||
|
- The rejection was process-level ("child run ended without a clean final report") — the work was complete and green; no code defects were found. Working tree re-verified intact, all gates re-run from scratch.
|
||||||
|
- `app/api/chat.py`: module-level `merge_cited_docs(read_docs, suggested_docs, deflected)` — phase-119 read-docs tier + the single documented exception: suggested-tier `is_image` docs auto-cited in suggestion order after reads, deduped by `(source, path)`; deflected → `[]`. Pipeline calls it; `cited_seen` derived from the result; `record_docs`/related-tier exclusion untouched.
|
||||||
|
- Unit: 6 new helper-matrix tests in `tests/unit/test_image_chips_and_answers.py` (zero-read image cited, read-image once at read position, text never auto-cited, deflected `[]`, two images in suggestion order, read order preserved/deduped).
|
||||||
|
- Integration: `tests/integration/test_chat_api.py` — zero-read grounded turn with seeded image doc (content==summary==description + question-overlapping chunk) → `done.sources` carries the image ref with `image_url`, `related` excludes it, `query_log.sources` unchanged; text-only KB turn → `sources == []` byte-identical.
|
||||||
|
- Tests: `uv run pytest --no-cov` → **2842 passed**; task-specific subset → 8 passed.
|
||||||
|
- Coverage: `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (gate >90%; chat.py 99%, sole miss is the pre-existing SEC-14-04 503 fast path).
|
||||||
|
- Lint/types: `uv run ruff check .` → all passed; `uv run pyright` → **0 errors, 0 warnings**.
|
||||||
|
- Next pending task: `.agents/phases/todo/124_image_chips_and_answers/06_chat_ui_answer_image.md`.
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
........................................................................ [ 2%]
|
||||||
|
........................................................................ [ 5%]
|
||||||
|
........................................................................ [ 7%]
|
||||||
|
........................................................................ [ 10%]
|
||||||
|
........................................................................ [ 12%]
|
||||||
|
........................................................................ [ 15%]
|
||||||
|
........................................................................ [ 17%]
|
||||||
|
........................................................................ [ 20%]
|
||||||
|
........................................................................ [ 22%]
|
||||||
|
........................................................................ [ 25%]
|
||||||
|
........................................................................ [ 27%]
|
||||||
|
........................................................................ [ 30%]
|
||||||
|
........................................................................ [ 32%]
|
||||||
|
........................................................................ [ 35%]
|
||||||
|
........................................................................ [ 38%]
|
||||||
|
........................................................................ [ 40%]
|
||||||
|
........................................................................ [ 43%]
|
||||||
|
........................................................................ [ 45%]
|
||||||
|
........................................................................ [ 48%]
|
||||||
|
........................................................................ [ 50%]
|
||||||
|
........................................................................ [ 53%]
|
||||||
|
........................................................................ [ 55%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 60%]
|
||||||
|
........................................................................ [ 63%]
|
||||||
|
........................................................................ [ 65%]
|
||||||
|
........................................................................ [ 68%]
|
||||||
|
........................................................................ [ 70%]
|
||||||
|
........................................................................ [ 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 131 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 4669 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
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
All green. Final verification summary:
|
||||||
|
|
||||||
|
- **Unit pins added** (`tests/unit/test_image_chips_and_answers.py`, task-06 section): `appendAnswerImage` contract (lazy img, bytes route, title→summary alt/caption swap via `fetchContentSummary`, collapse-on-error `fig.remove()`, chip navigation), the three wiring points (done handler renders before chip row, `answer_image: ev.answer_image || undefined` in the save meta, restore renders before chip row), the shared page's non-interactive `<figure>` copy (zero controls, no guest fetch of the user-gated content endpoint, wired before `addSources`), and the answer-scale themed CSS (320px cap, contain, `var(--surface)`/`var(--line)`/`var(--ink-soft)` tokens only, zero hex literals, hover scoped to `a.answer-image`).
|
||||||
|
|
||||||
|
**Implemented (task 06 — the answer figure UI):**
|
||||||
|
- `frontend/assets/app.js`: new `appendAnswerImage(wrap, ref)` helper (lazy `<img>` on the public bytes route, visible caption, alt/caption = title → summary swap, collapse-to-nothing on img error, `documentUrl` href + left-click same-page modal); wired into the `done` handler right before `appendSources` (A4: below text, above chips), the `rememberBrainTurn` meta (`answer_image: ev.answer_image || undefined`), and `renderStoredMessage`'s brain branch (before `appendSources`).
|
||||||
|
- `frontend/assets/shared.js`: `addAnswerImage` — a plain non-interactive `<figure>` (owner-locked zero controls; public bytes route makes the guest view faithful; caption keeps the title — the summary endpoint is user-gated for guests), wired in `renderSharedMessage` before `addSources`.
|
||||||
|
- `frontend/assets/styles.css`: `.answer-image` family — `.source-image` language at answer scale: full-column width, 320px max-height cap, `object-fit: contain`, theme bordered-image treatment, AA-safe muted caption (3-line clamp; full text in alt/title), hover underline scoped to the chat page's anchor only.
|
||||||
|
|
||||||
|
**Test / lint / coverage results:**
|
||||||
|
- `uv run pytest` → **2848 passed** (unit + integration, exit 0)
|
||||||
|
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90% gate)
|
||||||
|
- `uv run ruff check . && uv run pyright` → **All checks passed / 0 errors**
|
||||||
|
- JS syntax: `node --check` clean on `app.js` + `shared.js` (ES module)
|
||||||
|
|
||||||
|
**Notable decisions:**
|
||||||
|
- Chat-page figure is an `<a class="answer-image">` (the exact `appendSourceImageFigure` anchor pattern — the codebase calls that a "figure" too); shared page uses a plain `<figure>` (zero controls) — the `a.` CSS prefix keeps the hover out of the guest view without extra scoping.
|
||||||
|
- Shared page renders the title as the always-visible caption and does **not** fetch `/api/documents/content` (it is `require_user`-gated; a guest fetch would 401 — the WCAG alt contract holds with the title standing in).
|
||||||
|
- Caption line-clamped at 3 lines (summaries can be 12k chars; full text carried by `alt` + caption `title`).
|
||||||
|
- No live infrastructure touched; no `app/` code changes (server-side work was tasks 01–05, already in the working tree).
|
||||||
|
|
||||||
|
**Next pending task:** `07_answer_image_tests.md` — the mock-LLM `show_image` trigger + isolated E2E `test_answer_images.py` (it exercises this UI end-to-end: live figure, restore, shared page, collapse, toggle-off contract).
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
........................................................................ [ 2%]
|
||||||
|
........................................................................ [ 5%]
|
||||||
|
........................................................................ [ 7%]
|
||||||
|
........................................................................ [ 10%]
|
||||||
|
........................................................................ [ 12%]
|
||||||
|
........................................................................ [ 15%]
|
||||||
|
........................................................................ [ 17%]
|
||||||
|
........................................................................ [ 20%]
|
||||||
|
........................................................................ [ 22%]
|
||||||
|
........................................................................ [ 25%]
|
||||||
|
........................................................................ [ 27%]
|
||||||
|
........................................................................ [ 30%]
|
||||||
|
........................................................................ [ 32%]
|
||||||
|
........................................................................ [ 35%]
|
||||||
|
........................................................................ [ 37%]
|
||||||
|
........................................................................ [ 40%]
|
||||||
|
........................................................................ [ 42%]
|
||||||
|
........................................................................ [ 45%]
|
||||||
|
........................................................................ [ 48%]
|
||||||
|
........................................................................ [ 50%]
|
||||||
|
........................................................................ [ 53%]
|
||||||
|
........................................................................ [ 55%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 60%]
|
||||||
|
........................................................................ [ 63%]
|
||||||
|
........................................................................ [ 65%]
|
||||||
|
........................................................................ [ 68%]
|
||||||
|
........................................................................ [ 70%]
|
||||||
|
........................................................................ [ 73%]
|
||||||
|
........................................................................ [ 75%]
|
||||||
|
........................................................................ [ 78%]
|
||||||
|
........................................................................ [ 80%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 85%]
|
||||||
|
........................................................................ [ 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 131 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 4669 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
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
All gates green. The prior rejection was a harness interruption (child run killed before reporting — empty `a2.err`, no `a2.md`); the work was already complete and validated (`a1.validate` = "validation OK").
|
||||||
|
|
||||||
|
**Implemented (task 06 — the answer figure UI):**
|
||||||
|
- `app.js`: `appendAnswerImage(wrap, ref)` (lazy `<img>` on public bytes route, visible caption, title→summary swap, collapse-to-nothing on img error, chip navigation); wired into the `done` handler (before `appendSources`, A4: below text, above chips), `rememberBrainTurn` meta (`answer_image: ev.answer_image || undefined`), and `renderStoredMessage` restore (before `appendSources`).
|
||||||
|
- `shared.js`: `addAnswerImage` — plain non-interactive `<figure>` (zero controls; public bytes route), wired in `renderSharedMessage` before `addSources`.
|
||||||
|
- `styles.css`: `.answer-image` family — answer scale (320px cap, contain, theme bordered-image, AA muted caption, hover scoped to `a.` only).
|
||||||
|
|
||||||
|
**Test / lint / coverage:**
|
||||||
|
- `uv run pytest --cov=app --cov-report=term-missing` → **2848 passed**, **TOTAL 99%** (>90% gate)
|
||||||
|
- `uv run ruff check . && uv run pyright` → **All checks passed / 0 errors**
|
||||||
|
- JS syntax: `node --check` clean on `app.js` + `shared.js`
|
||||||
|
|
||||||
|
**Notable decisions:**
|
||||||
|
- Chat figure is `<a class="answer-image">` (chip-anchor pattern); shared page uses plain `<figure>` (zero controls) — `a.` CSS prefix keeps hover out of guest view.
|
||||||
|
- Shared page renders title caption, no `/api/documents/content` fetch (user-gated → would 401; alt contract holds with title).
|
||||||
|
- No `app/` code changes (server work was tasks 01–05); no live infra touched.
|
||||||
|
|
||||||
|
**Next pending task:** `07_answer_image_tests.md` — mock-LLM `show_image` trigger + isolated E2E `test_answer_images.py`.
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
........................................................................ [ 2%]
|
||||||
|
........................................................................ [ 5%]
|
||||||
|
........................................................................ [ 7%]
|
||||||
|
........................................................................ [ 10%]
|
||||||
|
........................................................................ [ 12%]
|
||||||
|
........................................................................ [ 15%]
|
||||||
|
........................................................................ [ 17%]
|
||||||
|
........................................................................ [ 20%]
|
||||||
|
........................................................................ [ 22%]
|
||||||
|
........................................................................ [ 25%]
|
||||||
|
........................................................................ [ 27%]
|
||||||
|
........................................................................ [ 30%]
|
||||||
|
........................................................................ [ 32%]
|
||||||
|
........................................................................ [ 35%]
|
||||||
|
........................................................................ [ 37%]
|
||||||
|
........................................................................ [ 40%]
|
||||||
|
........................................................................ [ 42%]
|
||||||
|
........................................................................ [ 45%]
|
||||||
|
........................................................................ [ 48%]
|
||||||
|
........................................................................ [ 50%]
|
||||||
|
........................................................................ [ 53%]
|
||||||
|
........................................................................ [ 55%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 60%]
|
||||||
|
........................................................................ [ 63%]
|
||||||
|
........................................................................ [ 65%]
|
||||||
|
........................................................................ [ 68%]
|
||||||
|
........................................................................ [ 70%]
|
||||||
|
........................................................................ [ 73%]
|
||||||
|
........................................................................ [ 75%]
|
||||||
|
........................................................................ [ 78%]
|
||||||
|
........................................................................ [ 80%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 85%]
|
||||||
|
........................................................................ [ 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 131 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 4669 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
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
All gates are green. Final report:
|
||||||
|
|
||||||
|
**Task 07 complete — mock `show_image` trigger + isolated E2E suite**
|
||||||
|
|
||||||
|
- `tests/e2e/mock_llm.py`: two new documented triggers — `SHOW_IMAGE_TRIGGER` ("show me the picture of", clause-gated: scripted `show_image` only, zero `read`s, follow-up keyed on the confirming line, final answer = sentence + `<documents>` tail echo) and `DIAGRAM_TRIGGER` ("which diagram is in your notes", zero tool calls, quotes the seeded image doc's block tail; works on toggle-off apps too). Image-doc pick imports `IMAGE_MIMES` (no drift).
|
||||||
|
- `tests/e2e/test_answer_images.py` (new, 5 tests, ports 8162/8163): App A (feature ON) — feature flow (figure src/lazy/position A4, answer text, auto-cited chip + figure, no Nearby-docs repeat, wire pin: 4 tools offered, exactly one `show_image` executed, saved record carries `answer_image`), reload restore + shared-page render (fresh anonymous context), bug-fix-only zero-read chip flow; App B (default off) — 3 base tools only, no figure, no `answer_image` key in the record, bug-fix trigger still chips.
|
||||||
|
- **Defect fixed from task 06's work:** the document image bytes route was `require_user` (phase 122/79 posture) while task 06's shared-page renderer assumed it public — guest shared chats 401'd and the figure collapsed, contradicting locked A4. Made the route public (phase-123 chat-image precedent: unguessable uuid4 = credential; document TEXT stays gated) in `app/api/docs.py` and rewrote the one auth pin in `tests/integration/test_docs_api.py`.
|
||||||
|
|
||||||
|
**Gates:** `uv run pytest tests/e2e/test_answer_images.py -v --no-cov` → 5 passed (twice — re-runnable; suite cleans its `saved_chats` rows); spot-checks `test_image_documents.py` 4 passed, `test_agent_document_tools.py` 4 passed (unmodified); `uv run pytest` → 2848 passed; `uv run pytest --cov=app --cov-report=term-missing` → TOTAL 99% (>90%); `uv run ruff check .` clean; `uv run pyright` → 0 errors.
|
||||||
|
|
||||||
|
**Decisions:** App B pins `BOR_ANSWER_IMAGES=false` explicitly (process env beats an operator's `.env` — phase-122 default-app convention; unset is the same `False`). No live-infrastructure changes. No commits made (harness owns the phase commit).
|
||||||
|
|
||||||
|
Next pending task: none in this phase — task 07 is the last of 124; the pipeline gate should move the phase dir to `complete/`.
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
........................................................................ [ 2%]
|
||||||
|
........................................................................ [ 5%]
|
||||||
|
........................................................................ [ 7%]
|
||||||
|
........................................................................ [ 10%]
|
||||||
|
........................................................................ [ 12%]
|
||||||
|
........................................................................ [ 15%]
|
||||||
|
........................................................................ [ 17%]
|
||||||
|
........................................................................ [ 20%]
|
||||||
|
........................................................................ [ 22%]
|
||||||
|
........................................................................ [ 25%]
|
||||||
|
........................................................................ [ 27%]
|
||||||
|
........................................................................ [ 30%]
|
||||||
|
........................................................................ [ 32%]
|
||||||
|
........................................................................ [ 35%]
|
||||||
|
........................................................................ [ 37%]
|
||||||
|
........................................................................ [ 40%]
|
||||||
|
........................................................................ [ 42%]
|
||||||
|
........................................................................ [ 45%]
|
||||||
|
........................................................................ [ 48%]
|
||||||
|
........................................................................ [ 50%]
|
||||||
|
........................................................................ [ 53%]
|
||||||
|
........................................................................ [ 55%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 60%]
|
||||||
|
........................................................................ [ 63%]
|
||||||
|
........................................................................ [ 65%]
|
||||||
|
........................................................................ [ 68%]
|
||||||
|
........................................................................ [ 70%]
|
||||||
|
........................................................................ [ 73%]
|
||||||
|
........................................................................ [ 75%]
|
||||||
|
........................................................................ [ 78%]
|
||||||
|
........................................................................ [ 80%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 85%]
|
||||||
|
........................................................................ [ 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 131 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 4669 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
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
All gates green. Clean final report:
|
||||||
|
|
||||||
|
**Task 07 complete — mock-LLM `show_image` trigger + isolated E2E suite**
|
||||||
|
|
||||||
|
- `tests/e2e/mock_llm.py`: two new documented triggers — `SHOW_IMAGE_TRIGGER` ("show me the picture of", clause-gated on the `show_image` prompt clause: streams only a `show_image` tool call on the seeded image-extension `<documents>` block, zero `read`s; follow-up keyed on the "Image shown to the user" line streams the final answer + the `<documents>` tail echo) and `DIAGRAM_TRIGGER` ("which diagram is in your notes": zero tool calls, quotes the seeded image doc's block tail; fires on toggle-off apps too). Image-doc pick imports `IMAGE_MIMES` (drift-proof); both phrases verified collision-free.
|
||||||
|
- `tests/e2e/test_answer_images.py` (new, 5 tests, ports 8162/8163): App A (feature ON) — feature flow (figure src/`loading="lazy"`/A4 position, answer text, auto-cited chip + figure, no Nearby-docs repeat, wire pin: 4 tools offered, exactly one `show_image` executed, saved record carries `answer_image`), reload restore + shared-page render in a fresh anonymous context, bug-fix-only zero-read chip flow; App B (default off) — offer list is exactly 3 base tools, no figure, no `answer_image` key in the record, bug-fix trigger still chips.
|
||||||
|
- **Defect fixed (prior task 06 premise):** the image bytes route was `require_user` (phase 122/79) so guest shared chats 401'd and the figure collapsed, contradicting locked A4 — made the route public (phase-123 chat-image precedent: unguessable uuid4 = credential; document text stays gated) in `app/api/docs.py` and rewrote the single auth pin in `tests/integration/test_docs_api.py`.
|
||||||
|
|
||||||
|
**Gates:** `uv run pytest tests/e2e/test_answer_images.py -v --no-cov` → 5 passed (in isolation, re-run confirmed); `tests/e2e/test_image_documents.py` + `test_agent_document_tools.py` → 8 passed unmodified; `uv run pytest` → 2848 passed; `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (>90%); `uv run ruff check .` clean; `uv run pyright` → 0 errors.
|
||||||
|
|
||||||
|
**Decisions:** App B pins `BOR_ANSWER_IMAGES=false` explicitly (process env beats an operator's `.env`; unset = the same `False`). Suite cleans its own `saved_chats` rows (shared-DB re-runnability). No live-infrastructure changes; no commits (harness owns the phase commit).
|
||||||
|
|
||||||
|
**Next pending task:** none in this phase — task 07 is the last of 124; the pipeline gate should move the phase dir to `complete/`.
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
........................................................................ [ 2%]
|
||||||
|
........................................................................ [ 5%]
|
||||||
|
........................................................................ [ 7%]
|
||||||
|
........................................................................ [ 10%]
|
||||||
|
........................................................................ [ 12%]
|
||||||
|
........................................................................ [ 15%]
|
||||||
|
........................................................................ [ 17%]
|
||||||
|
........................................................................ [ 20%]
|
||||||
|
........................................................................ [ 22%]
|
||||||
|
........................................................................ [ 25%]
|
||||||
|
........................................................................ [ 27%]
|
||||||
|
........................................................................ [ 30%]
|
||||||
|
........................................................................ [ 32%]
|
||||||
|
........................................................................ [ 35%]
|
||||||
|
........................................................................ [ 37%]
|
||||||
|
........................................................................ [ 40%]
|
||||||
|
........................................................................ [ 42%]
|
||||||
|
........................................................................ [ 45%]
|
||||||
|
........................................................................ [ 48%]
|
||||||
|
........................................................................ [ 50%]
|
||||||
|
........................................................................ [ 53%]
|
||||||
|
........................................................................ [ 55%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 60%]
|
||||||
|
........................................................................ [ 63%]
|
||||||
|
........................................................................ [ 65%]
|
||||||
|
........................................................................ [ 68%]
|
||||||
|
........................................................................ [ 70%]
|
||||||
|
........................................................................ [ 73%]
|
||||||
|
........................................................................ [ 75%]
|
||||||
|
........................................................................ [ 78%]
|
||||||
|
........................................................................ [ 80%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 85%]
|
||||||
|
........................................................................ [ 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 131 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 4669 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
|
||||||
@@ -126,6 +126,12 @@ BOR_IMPORT_EXTENSIONS=md,markdown,txt,yaml,yml,json,py,container,network,volume,
|
|||||||
# BOR_IMAGE_DIR=~/bor-sources/images # persistent home for the served image bytes
|
# BOR_IMAGE_DIR=~/bor-sources/images # persistent home for the served image bytes
|
||||||
# (uploads are replaced, checkouts re-cloned)
|
# (uploads are replaced, checkouts re-cloned)
|
||||||
|
|
||||||
|
# Phase 124 (answer images): off by default; enable alongside
|
||||||
|
# BOR_IMAGES to let the model show a supporting image in its answer
|
||||||
|
# (independent of it — BOR_IMAGES indexes image docs, this one shows
|
||||||
|
# them in answers).
|
||||||
|
# BOR_ANSWER_IMAGES=0
|
||||||
|
|
||||||
# --- Chat image questions (phase 123: attach an image to a question) ---
|
# --- Chat image questions (phase 123: attach an image to a question) ---
|
||||||
# Gated by the SAME BOR_IMAGES toggle above (enable only when the chat
|
# Gated by the SAME BOR_IMAGES toggle above (enable only when the chat
|
||||||
# model supports vision). The question image is stored on the server —
|
# model supports vision). The question image is stored on the server —
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ streams a grounded answer back.
|
|||||||
If it doesn't have notes for your question, it admits it — and offers
|
If it doesn't have notes for your question, it admits it — and offers
|
||||||
2-3 concrete alternative questions about things it **does** have notes on.
|
2-3 concrete alternative questions about things it **does** have notes on.
|
||||||
|
|
||||||
|
Image support is opt-in (`BOR_IMAGES`): standalone images become
|
||||||
|
retrievable documents, and you can attach an image to a question —
|
||||||
|
see [Image support](#image-support).
|
||||||
|
|
||||||
> **Updated your notes?** Re-run the import — it's idempotent and only
|
> **Updated your notes?** Re-run the import — it's idempotent and only
|
||||||
> re-embeds what changed:
|
> re-embeds what changed:
|
||||||
> ```bash
|
> ```bash
|
||||||
@@ -200,6 +204,9 @@ reflect the new files.
|
|||||||
`__pycache__`, etc.) are skipped.
|
`__pycache__`, etc.) are skipped.
|
||||||
- Non-markdown files get format-aware chunking (YAML keys, JSON keys,
|
- Non-markdown files get format-aware chunking (YAML keys, JSON keys,
|
||||||
Python classes/defs).
|
Python classes/defs).
|
||||||
|
- With `BOR_IMAGES=1`, standalone images (`png, jpg, jpeg, webp, gif,
|
||||||
|
bmp`) are indexed as documents too — see
|
||||||
|
[Image support](#image-support).
|
||||||
|
|
||||||
### Git-based sources
|
### Git-based sources
|
||||||
|
|
||||||
@@ -375,6 +382,78 @@ import summary line (`summaries=N summary_errors=N`).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Image support
|
||||||
|
|
||||||
|
One toggle gates both image capabilities: `BOR_IMAGES` (default
|
||||||
|
**off**). Enable it **only when your chat model
|
||||||
|
(`BOR_LLM_CHAT_MODEL`) supports vision** — the chat model is the vision
|
||||||
|
model: it describes image documents and answers questions with attached
|
||||||
|
images. The embedding model never sees pixels in either flow.
|
||||||
|
|
||||||
|
### Image documents
|
||||||
|
|
||||||
|
With `BOR_IMAGES=1`, a standalone image file — inside an uploaded
|
||||||
|
source archive, or as a file in a git/local source walk — becomes a
|
||||||
|
first-class document:
|
||||||
|
|
||||||
|
- **Described by the chat model.** On first index, a single multimodal
|
||||||
|
chat-model call describes the image. The description becomes the
|
||||||
|
document's `content` **and** `summary` — and is the *only* text that
|
||||||
|
gets embedded, so hybrid search has a natural-language target to hit
|
||||||
|
instead of pixels.
|
||||||
|
- **The bytes persist.** Each ingested image is copied to
|
||||||
|
`BOR_IMAGE_DIR/<uuid>.<ext>` and served from that copy (uploads are
|
||||||
|
replaced and checkouts re-cloned, so the served image outlives its
|
||||||
|
source file).
|
||||||
|
- **Retrieved & displayed.** Image docs appear in the Sources catalog
|
||||||
|
with a small lazy-loaded thumbnail (progressive enhancement — a fetch
|
||||||
|
failure falls back to the doc icon), the document viewer renders the
|
||||||
|
image with the description below it, and a cited image doc renders
|
||||||
|
**inline in the chat's sources block** (alt text = the description).
|
||||||
|
The agent's `read` tool returns the description with a marker telling
|
||||||
|
the model it is reading an image document.
|
||||||
|
- **Prune guard.** While `BOR_IMAGES` is **off**, syncs never prune
|
||||||
|
existing image documents — an image doc is invisible to the walk, not
|
||||||
|
a deleted file. With the toggle on, a deleted image file prunes its
|
||||||
|
doc and its copy.
|
||||||
|
- **Fail-soft.** A failed or empty description skips that image (no
|
||||||
|
row, no copy), logs a warning, and counts in the import summary line
|
||||||
|
(`images_failed=N`) — the sync continues.
|
||||||
|
|
||||||
|
Accepted formats are `png, jpg, jpeg, webp, gif, bmp`
|
||||||
|
(`BOR_IMAGE_EXTENSIONS`, case-insensitive) — a separate set from
|
||||||
|
`BOR_IMPORT_EXTENSIONS`; images are never added via that variable, the
|
||||||
|
toggle is the single knob.
|
||||||
|
|
||||||
|
### Question images
|
||||||
|
|
||||||
|
With the toggle on, the composer gains a **paperclip** button. Attach
|
||||||
|
one image to a question and the chat model answers with text **and**
|
||||||
|
image:
|
||||||
|
|
||||||
|
- **Server-stored, path-persisted.** On send the file is first uploaded
|
||||||
|
to `POST /api/chat-images` (capped at `BOR_CHAT_IMAGE_MAX_MB`,
|
||||||
|
default 10 MiB — over is a 413) and stored under `BOR_CHAT_IMAGE_DIR`.
|
||||||
|
Saved and shared chats carry the returned *path* — never base64.
|
||||||
|
- **Current turn only.** The model receives a multimodal message for
|
||||||
|
this question; prior turns' images are never replayed into history
|
||||||
|
(the text of a prior turn stands alone).
|
||||||
|
- **Rendered everywhere.** The image renders in the user bubble, after
|
||||||
|
a page reload, and on the shared chat page; a failed load degrades to
|
||||||
|
a small "image unavailable" note (never a broken-image icon).
|
||||||
|
- **Blocked on failure.** If the image upload fails, the send is
|
||||||
|
blocked with a banner — the question is never sent without the image
|
||||||
|
the user attached.
|
||||||
|
- **Toggle off** ⇒ the attach control is absent, and an API request
|
||||||
|
carrying an image gets an error frame with the hint: *Enable
|
||||||
|
`BOR_IMAGES` in the server's `.env` (and restart) to ask with an
|
||||||
|
image.*
|
||||||
|
|
||||||
|
A question image is **never indexed as a document** — it is turn-local
|
||||||
|
storage, not a source.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Caching & deploys
|
## Caching & deploys
|
||||||
|
|
||||||
A deploy is a commit — and the browser must see it **without a hard refresh**.
|
A deploy is a commit — and the browser must see it **without a hard refresh**.
|
||||||
@@ -402,40 +481,131 @@ asset references of the known pages in flight.
|
|||||||
|
|
||||||
## Configuration reference
|
## Configuration reference
|
||||||
|
|
||||||
|
Everything is an environment variable (the `BOR_` prefix, plus
|
||||||
|
`DEBUGPY*`) read from `.env`. **Every variable in
|
||||||
|
[`.env.example`](.env.example) is documented below** — copy that file
|
||||||
|
to `.env` and adjust.
|
||||||
|
|
||||||
|
### App & branding
|
||||||
|
|
||||||
| Env | Default | Meaning |
|
| Env | Default | Meaning |
|
||||||
|-----|---------|---------|
|
|-----|---------|---------|
|
||||||
| `BOR_APP_NAME` | `Brain of Reese` | Display name everywhere (page titles, header brand, status labels) |
|
| `BOR_APP_NAME` | `Brain of Reese` | Display name everywhere (page titles, header brand, status labels) |
|
||||||
| `BOR_INPUT_PLACEHOLDER` | `Ask me anything…` | Chat composer placeholder |
|
| `BOR_INPUT_PLACEHOLDER` | `Ask me anything…` | Chat composer placeholder |
|
||||||
| `BOR_FOOTER_TEXT` | `Powered by self-hosted models` | Footer line on every page |
|
| `BOR_FOOTER_TEXT` | `Powered by self-hosted models` | Footer line on every page |
|
||||||
|
| `BOR_ENVIRONMENT` | `development` | `development` or `production` (the prod compose profile sets `production`) |
|
||||||
|
| `BOR_LOG_LEVEL` | `INFO` | App log level |
|
||||||
|
| `BOR_STATIC_DIR` | `frontend` | Where static files are served from (the container image sets `/app/static`) |
|
||||||
|
|
||||||
|
### Database & concurrency
|
||||||
|
|
||||||
|
| Env | Default | Meaning |
|
||||||
|
|-----|---------|---------|
|
||||||
| `BOR_DATABASE_URL` | local compose URL | SQLAlchemy URL (psycopg) |
|
| `BOR_DATABASE_URL` | local compose URL | SQLAlchemy URL (psycopg) |
|
||||||
|
| `BOR_DB_POOL_SIZE` | `5` | DB connection pool size |
|
||||||
|
| `BOR_DB_POOL_MAX_OVERFLOW` | `10` | Overflow connections above `BOR_DB_POOL_SIZE` |
|
||||||
|
| `BOR_DB_POOL_RECYCLE` | `3600` | Recycle pool connections after N seconds |
|
||||||
|
| `BOR_CHAT_MAX_CONCURRENT` | `10` | Max concurrent `/api/chat` turns |
|
||||||
|
|
||||||
|
### LLM (aipi)
|
||||||
|
|
||||||
|
| Env | Default | Meaning |
|
||||||
|
|-----|---------|---------|
|
||||||
| `BOR_LLM_BASE_URL` | `https://aipi.reeseapps.com/v1` | OpenAI-compatible endpoint |
|
| `BOR_LLM_BASE_URL` | `https://aipi.reeseapps.com/v1` | OpenAI-compatible endpoint |
|
||||||
| `BOR_LLM_API_KEY` | — (falls back to `$AIPI_KEY`) | aipi API key |
|
| `BOR_LLM_API_KEY` | — (falls back to `$AIPI_KEY`) | aipi API key |
|
||||||
| `BOR_LLM_CHAT_MODEL` | `turbo` | Chat model |
|
| `BOR_LLM_CHAT_MODEL` | `turbo` | Chat model — must support vision when `BOR_IMAGES=1` |
|
||||||
| `BOR_LLM_EMBED_MODEL` | `embed` | Embedding model |
|
| `BOR_LLM_EMBED_MODEL` | `embed` | Embedding model |
|
||||||
| `BOR_LLM_SUMMARY_MODEL` | `lite` | One-shot completions: document summaries at import, KB overview |
|
| `BOR_LLM_SUMMARY_MODEL` | `lite` | One-shot completions: document summaries at import, KB overview |
|
||||||
| `BOR_EMBEDDING_DIM` | `768` | Vector dimension (fixed at table creation) |
|
| `BOR_EMBEDDING_DIM` | `768` | Vector dimension (fixed at table creation) |
|
||||||
| `BOR_TOP_N_DOCS` | `2` | Full documents fed to the LLM |
|
| `BOR_LLM_RETRIES` | `3` | Retries: chat-turn stream before the first token + empty one-shot summary replies; `0` = off |
|
||||||
|
| `BOR_LLM_RETRY_DELAY` | `5` | Seconds between LLM retries |
|
||||||
|
| `BOR_LLM_TIMEOUT` | `120` | HTTP timeout (s) for LLM API calls |
|
||||||
|
| `BOR_STREAM_THINKING` | `1` | Stream the model's thinking as `thinking` SSE events (`0` = suppress) |
|
||||||
|
|
||||||
|
### RAG & prompt budgets
|
||||||
|
|
||||||
|
| Env | Default | Meaning |
|
||||||
|
|-----|---------|---------|
|
||||||
|
| `BOR_TOP_N_DOCS` | `2` | Ceiling for the cited tier, not a quota (phase 113) — a single strong doc yields one citation |
|
||||||
| `BOR_RELEVANCE_THRESHOLD` | `0.62` | Answer when best cosine ≥ this **or** a lexical hit corroborated by cosine ≥ `BOR_LEXICAL_SUPPORT_FLOOR`; otherwise honest deflection |
|
| `BOR_RELEVANCE_THRESHOLD` | `0.62` | Answer when best cosine ≥ this **or** a lexical hit corroborated by cosine ≥ `BOR_LEXICAL_SUPPORT_FLOOR`; otherwise honest deflection |
|
||||||
| `BOR_LEXICAL_SUPPORT_FLOOR` | `0.35` | Best-cosine floor an FTS hit must clear to flip the gate HIGH (A8 revised 2026-09-14) |
|
| `BOR_LEXICAL_SUPPORT_FLOOR` | `0.35` | Best-cosine floor an FTS hit must clear to flip the gate HIGH (A8 revised 2026-09-14) |
|
||||||
|
| `BOR_SOURCE_USEFULNESS_FLOOR` | `0.35` | Cosine bar for a citation slot (phase 113): a doc earns the cited tier only when its best-chunk cosine clears it; `0` = no bar |
|
||||||
|
| `BOR_RELATED_MAX_DOCS` | `2` | Cap on the related-doc tier (the de-emphasized "nearby docs" row — never a citation chip); `0` = no related docs |
|
||||||
|
| `BOR_SUGGESTED_DOCS` | `5` | Cap on the "start here" suggestion tier (phase 118): top-N doc summaries seeded into the grounded prompt; no cosine floor; ≥ 1 |
|
||||||
|
| `BOR_SUGGESTION_PREVIEW_CHARS` | `400` | NULL-summary fallback preview in a suggestion block (phase 118): first N content chars + truncation marker; > 0 |
|
||||||
|
| `BOR_MAX_OUTPUT_TOKENS` | `32768` | Max answer length in tokens (answers must not be cut off) |
|
||||||
|
| `BOR_STEERING_MAX_CHARS` | `8000` | Char budget for the `<tuning>` (steering notes) prompt section |
|
||||||
|
| `BOR_SUMMARY_MAX_CHARS` | `12000` | Cap on document content sent to the `lite` summary model |
|
||||||
|
| `BOR_KB_OVERVIEW_MAX_CHARS` | `4000` | Char budget for the `<knowledge_base>` prompt section |
|
||||||
|
| `BOR_OVERVIEW_INPUT_MAX_CHARS` | `40000` | Cap on the document list sent to `lite` for KB overview generation |
|
||||||
|
| `BOR_FOLDER_SUMMARY_INPUT_MAX_CHARS` | `8000` | Cap on a folder's document list sent to `lite` for folder summaries (phase 94) |
|
||||||
|
|
||||||
|
### Chunking & embedding
|
||||||
|
|
||||||
|
| Env | Default | Meaning |
|
||||||
|
|-----|---------|---------|
|
||||||
|
| `BOR_CHUNK_TARGET_CHARS` | `2000` | Target chunk size (chars) |
|
||||||
|
| `BOR_CHUNK_OVERLAP_CHARS` | `200` | Overlap between adjacent chunks (chars) |
|
||||||
|
| `BOR_EMBED_BATCH_SIZE` | `16` | Chunks per embedding API call |
|
||||||
|
| `BOR_EMBED_QUESTION_MAX_CHARS` | `1200` | Chat question-embed prefix cap (phase 114): the embed step embeds at most this many chars of the question — the full question still reaches the LLM prompt |
|
||||||
|
|
||||||
|
### Hybrid retrieval
|
||||||
|
|
||||||
|
| Env | Default | Meaning |
|
||||||
|
|-----|---------|---------|
|
||||||
| `BOR_HYBRID_VECTOR_CANDIDATES` | `100` | Cosine list width for the RRF fusion |
|
| `BOR_HYBRID_VECTOR_CANDIDATES` | `100` | Cosine list width for the RRF fusion |
|
||||||
| `BOR_HYBRID_LEXICAL_CANDIDATES` | `30` | FTS list width for the RRF fusion |
|
| `BOR_HYBRID_LEXICAL_CANDIDATES` | `30` | FTS list width for the RRF fusion |
|
||||||
| `BOR_RRF_K` | `60` | RRF damping constant (`1/(k + rank)`) |
|
| `BOR_RRF_K` | `60` | RRF damping constant (`1/(k + rank)`) |
|
||||||
|
| `BOR_RECENCY_BOOST` | `0.0007` | Max additive fused-score bonus a zero-age doc gets (phase 106); `0` = off |
|
||||||
|
| `BOR_RECENCY_HALF_LIFE_DAYS` | `365` | Recency decay timescale, days (phase 106) — the boost decays by e^-1 (~0.37x) per timescale of age |
|
||||||
|
| `BOR_NAME_HIT_BONUS` | `0.005` | Selection-time bonus a name-hit doc's best fused score gets (phase 119); `0` = off |
|
||||||
|
|
||||||
|
### Agent tools
|
||||||
|
|
||||||
|
| Env | Default | Meaning |
|
||||||
|
|-----|---------|---------|
|
||||||
| `BOR_AGENT_MAX_ROUNDS` | `10` | Hard cap on agent tool rounds per grounded turn (0 = no tools) |
|
| `BOR_AGENT_MAX_ROUNDS` | `10` | Hard cap on agent tool rounds per grounded turn (0 = no tools) |
|
||||||
| `BOR_IMPORT_EXTENSIONS` | csv (see below) | Importable formats (may only narrow the A9 set) |
|
| `BOR_READ_MAX_CHARS` | `128000` | Cap on a `read` tool result (chars); over-cap reads truncate + a grep-pointer notice (phase 95) |
|
||||||
|
|
||||||
|
### Import & sources
|
||||||
|
|
||||||
|
| Env | Default | Meaning |
|
||||||
|
|-----|---------|---------|
|
||||||
|
| `BOR_IMPORT_EXTENSIONS` | A9 family | Importable text formats — the A9 default is `md,markdown,txt,yaml,yml,json,py,container,network,volume,image,pod,kube,swap,os,endpoint,j2`; any well-formed extension is allowed. Images are **not** added here — see `BOR_IMAGES` |
|
||||||
| `BOR_GIT_SOURCES` | — (empty) | CSV of git repo URLs — **fallback while the admin Git sources page's list is empty** |
|
| `BOR_GIT_SOURCES` | — (empty) | CSV of git repo URLs — **fallback while the admin Git sources page's list is empty** |
|
||||||
| `BOR_SOURCES_DIR` | `~/bor-sources` | Where git repos are cloned/pulled |
|
| `BOR_SOURCES_DIR` | `~/bor-sources` | Where git repos are cloned/pulled |
|
||||||
| `BOR_UPLOAD_DIR` | `~/bor-sources/uploads` | Where uploaded source archives are unpacked |
|
| `BOR_UPLOAD_DIR` | `~/bor-sources/uploads` | Where uploaded source archives are unpacked |
|
||||||
| `BOR_UPLOAD_MAX_MB` | `512` | Cap (MiB) for uploaded source archives (zip-bomb guard) |
|
| `BOR_UPLOAD_MAX_MB` | `512` | Cap (MiB) for uploaded source archives (zip-bomb guard) |
|
||||||
| `BOR_STEERING_MAX_CHARS` | `8000` | Char budget for the `<tuning>` prompt section |
|
|
||||||
| `BOR_SUMMARY_MAX_CHARS` | `12000` | Cap on document content sent to the `lite` summary model |
|
|
||||||
| `BOR_KB_OVERVIEW_MAX_CHARS` | `4000` | Char budget for the `<knowledge_base>` prompt section |
|
|
||||||
| `BOR_OVERVIEW_INPUT_MAX_CHARS` | `40000` | Cap on the document list sent to `lite` for KB overview generation |
|
|
||||||
| `BOR_SUGGESTIONS` | built-in list | JSON seed for onboarding chips (shown before the first saved question) |
|
| `BOR_SUGGESTIONS` | built-in list | JSON seed for onboarding chips (shown before the first saved question) |
|
||||||
|
|
||||||
|
### Image support (phases 122/123)
|
||||||
|
|
||||||
|
| Env | Default | Meaning |
|
||||||
|
|-----|---------|---------|
|
||||||
|
| `BOR_IMAGES` | `0` | Master switch for image support — **off by default; enable only when `BOR_LLM_CHAT_MODEL` supports vision**. Gates both image documents and question images |
|
||||||
|
| `BOR_IMAGE_EXTENSIONS` | `png,jpg,jpeg,webp,gif,bmp` | Extensions indexed as image documents (case-insensitive; separate from `BOR_IMPORT_EXTENSIONS`) |
|
||||||
|
| `BOR_IMAGE_DIR` | `~/bor-sources/images` | Persistent home for the served image bytes (uploads are replaced, checkouts re-cloned) |
|
||||||
|
| `BOR_CHAT_IMAGE_DIR` | `~/bor-sources/chat-images` | Where question images are stored (saved/shared chats carry the path, never base64) |
|
||||||
|
| `BOR_CHAT_IMAGE_MAX_MB` | `10` | Question-image upload cap (MiB; must be > 0) |
|
||||||
|
|
||||||
|
### Docs push
|
||||||
|
|
||||||
|
| Env | Default | Meaning |
|
||||||
|
|-----|---------|---------|
|
||||||
|
| `BOR_DOCS_REPO` | — (empty) | The git repo chat answers can be committed to (URL or local path); while empty "Save as doc" is hidden and the push endpoint 409s |
|
||||||
|
| `BOR_DOCS_BRANCH` | `bor-docs` | Branch pushes land on (`git push --ff-only` — you open the PR yourself) |
|
||||||
|
| `BOR_DOCS_BASE_BRANCH` | `main` | Branch each push is cut fresh from |
|
||||||
|
| `BOR_DOCS_WORK_DIR` | `~/bor-docs` | Where `BOR_DOCS_REPO` is checked out on the server |
|
||||||
|
|
||||||
|
### Admin & debugging
|
||||||
|
|
||||||
|
| Env | Default | Meaning |
|
||||||
|
|-----|---------|---------|
|
||||||
| `BOR_ADMIN_PASSWORD` | *(required)* | The single admin's password — app refuses to start when empty |
|
| `BOR_ADMIN_PASSWORD` | *(required)* | The single admin's password — app refuses to start when empty |
|
||||||
| `BOR_SESSION_SECRET` | *(required)* | Signing key for the `bor_session` cookie |
|
| `BOR_SESSION_SECRET` | *(required)* | Signing key for the `bor_session` cookie |
|
||||||
| `BOR_SESSION_MAX_AGE` | `43200` | Session-cookie lifetime in seconds (12 h, sliding) |
|
| `BOR_SESSION_MAX_AGE` | `43200` | Session-cookie lifetime in seconds (12 h, sliding) |
|
||||||
| `DEBUGPY` | `0` | `1` ⇒ attach-on-demand debugpy on `DEBUGPY_PORT` (default 5678) |
|
| `DEBUGPY` | `0` | `1` ⇒ attach-on-demand debugpy on `DEBUGPY_PORT` |
|
||||||
| `BOR_LOG_LEVEL` | `INFO` | App log level |
|
| `DEBUGPY_PORT` | `5678` | debugpy listen port |
|
||||||
|
|
||||||
### Customizing the look
|
### Customizing the look
|
||||||
|
|
||||||
@@ -576,6 +746,10 @@ served locally (no CDN), `BOR_ENVIRONMENT=production`.
|
|||||||
- **Embedding dimension mismatch** — aipi changed models; run
|
- **Embedding dimension mismatch** — aipi changed models; run
|
||||||
`uv run python -m scripts.llm_probe`, update `BOR_EMBEDDING_DIM`, then
|
`uv run python -m scripts.llm_probe`, update `BOR_EMBEDDING_DIM`, then
|
||||||
drop + recreate the chunks table.
|
drop + recreate the chunks table.
|
||||||
|
- **No attach button / images ignored on import** — image support is
|
||||||
|
off (the default). Set `BOR_IMAGES=1` in `.env` and restart — only
|
||||||
|
when `BOR_LLM_CHAT_MODEL` supports vision. Turning the toggle off
|
||||||
|
never destroys existing image documents (syncs prune-guard them).
|
||||||
- **Honest deflection** (the amber deflection bubble) — every question
|
- **Honest deflection** (the amber deflection bubble) — every question
|
||||||
passes the honesty gate: deflection happens
|
passes the honesty gate: deflection happens
|
||||||
when the best cosine similarity is below `BOR_RELEVANCE_THRESHOLD`
|
when the best cosine similarity is below `BOR_RELEVANCE_THRESHOLD`
|
||||||
|
|||||||
+82
-8
@@ -462,7 +462,14 @@ def plan_turn(
|
|||||||
fts_hits,
|
fts_hits,
|
||||||
False,
|
False,
|
||||||
build_high_prompt(
|
build_high_prompt(
|
||||||
suggested, notes=steering, kb_overview=kb_text, folder_lines=folder_lines
|
suggested,
|
||||||
|
notes=steering,
|
||||||
|
kb_overview=kb_text,
|
||||||
|
folder_lines=folder_lines,
|
||||||
|
# Phase 124 (task 03, LOCKED A5): the grounded HIGH prompt
|
||||||
|
# carries the show_image clause exactly when the
|
||||||
|
# BOR_ANSWER_IMAGES toggle is on (off = byte-identical).
|
||||||
|
show_image=settings.answer_images,
|
||||||
),
|
),
|
||||||
suggested,
|
suggested,
|
||||||
related_docs,
|
related_docs,
|
||||||
@@ -486,6 +493,51 @@ def plan_turn(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def merge_cited_docs(
|
||||||
|
read_docs: list[Document],
|
||||||
|
suggested_docs: list[Document],
|
||||||
|
deflected: bool,
|
||||||
|
) -> list[Document]:
|
||||||
|
"""Compose the citation tier (the done frame's ``sources``, PLAN §9).
|
||||||
|
|
||||||
|
Phase 119 (LOCKED A1): on grounded turns the agent's READ DOCS —
|
||||||
|
deduped by ``(source, path)``, read order; on deflected turns
|
||||||
|
nothing (a weak-hit answer is not a citation). Phase 124 (TODO L3)
|
||||||
|
adds the rule's single documented exception: a suggested-tier
|
||||||
|
IMAGE doc's seed block IS its entire content (content == summary
|
||||||
|
== the vision description, phase 122 task 03) — a ``read`` would
|
||||||
|
add only the marker line, so a real model never performs one and
|
||||||
|
the doc's chip never appeared. "Suggested" is therefore equivalent
|
||||||
|
to "read" for it, and it is auto-cited — in SUGGESTION order AFTER
|
||||||
|
the read docs, deduped against them (an image doc the agent also
|
||||||
|
explicitly read appears once, at its read position). Non-image
|
||||||
|
suggested docs are never auto-cited: the phase-119 read-docs-only
|
||||||
|
rule stays intact for text docs (a zero-read text turn still chips
|
||||||
|
nothing).
|
||||||
|
"""
|
||||||
|
if deflected:
|
||||||
|
return []
|
||||||
|
cited: list[Document] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
for doc in read_docs:
|
||||||
|
key = (doc.source, doc.path)
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
cited.append(doc)
|
||||||
|
# Phase 124 (TODO L3): the suggested-tier IMAGE docs join the
|
||||||
|
# citation tier — "suggested" is equivalent to "read" for them
|
||||||
|
# (their seed block is their entire content), so a zero-read
|
||||||
|
# grounded turn chips them too. Suggestion order, deduped.
|
||||||
|
for doc in suggested_docs:
|
||||||
|
if not doc.is_image:
|
||||||
|
continue
|
||||||
|
key = (doc.source, doc.path)
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
cited.append(doc)
|
||||||
|
return cited
|
||||||
|
|
||||||
|
|
||||||
@router.post("/chat")
|
@router.post("/chat")
|
||||||
async def chat(
|
async def chat(
|
||||||
request: ChatRequest,
|
request: ChatRequest,
|
||||||
@@ -1035,15 +1087,21 @@ async def chat(
|
|||||||
# phase-119 cite discipline, A5) — and [] on
|
# phase-119 cite discipline, A5) — and [] on
|
||||||
# deflected ones (a deflected answer cites nothing;
|
# deflected ones (a deflected answer cites nothing;
|
||||||
# the weak hits stay in the durable record).
|
# the weak hits stay in the durable record).
|
||||||
|
# Phase 124 (TODO L3) gives that rule its single
|
||||||
|
# documented exception — the suggested-tier IMAGE
|
||||||
|
# docs (``merge_cited_docs``, below): a seed block
|
||||||
|
# IS an image doc's entire content, so "suggested"
|
||||||
|
# is equivalent to "read" for it and it is
|
||||||
|
# auto-cited in suggestion order AFTER the read
|
||||||
|
# docs, deduped (a read image doc stays at its read
|
||||||
|
# position, once). Text docs keep the read-docs-only
|
||||||
|
# rule — a zero-read text turn still chips nothing.
|
||||||
# A cancelled turn (the generator closed by the
|
# A cancelled turn (the generator closed by the
|
||||||
# consumer) never reaches this step — no query_log row.
|
# consumer) never reaches this step — no query_log row.
|
||||||
cited_docs: list[Document] = []
|
cited_docs = merge_cited_docs(
|
||||||
cited_seen: set[tuple[str, str]] = set()
|
holder.read_docs, plan.suggested_docs, plan.deflected
|
||||||
for doc in holder.read_docs:
|
)
|
||||||
key = (doc.source, doc.path)
|
cited_seen = {(d.source, d.path) for d in cited_docs}
|
||||||
if key not in cited_seen:
|
|
||||||
cited_seen.add(key)
|
|
||||||
cited_docs.append(doc)
|
|
||||||
record_docs: list[Document] = []
|
record_docs: list[Document] = []
|
||||||
seen: set[tuple[str, str]] = set()
|
seen: set[tuple[str, str]] = set()
|
||||||
for doc in [*plan.suggested_docs, *plan.related_docs, *holder.read_docs]:
|
for doc in [*plan.suggested_docs, *plan.related_docs, *holder.read_docs]:
|
||||||
@@ -1124,12 +1182,28 @@ async def chat(
|
|||||||
for d in plan.related_docs
|
for d in plan.related_docs
|
||||||
if (d.source, d.path) not in cited_seen
|
if (d.source, d.path) not in cited_seen
|
||||||
]
|
]
|
||||||
|
# Phase 124 (task 04): the answer's first-class image —
|
||||||
|
# the shown document's ref (the LLM's ``show_image``
|
||||||
|
# pick, task 02's ``holder.shown_images``), built by the
|
||||||
|
# SAME shared ref builder (source/path/title +
|
||||||
|
# ``image_url``). ``None`` on every other turn — a
|
||||||
|
# deflected turn runs no agent (``shown_images`` is
|
||||||
|
# always empty) and an image-less grounded turn never
|
||||||
|
# called the tool — and ``None`` is DROPPED on
|
||||||
|
# serialization, so those frames stay byte-identical to
|
||||||
|
# pre-phase (no key, no null).
|
||||||
|
answer_ref = (
|
||||||
|
source_ref_with_image(holder.shown_images[0])
|
||||||
|
if holder.shown_images
|
||||||
|
else None
|
||||||
|
)
|
||||||
yield sse_event(
|
yield sse_event(
|
||||||
ChatDoneEvent(
|
ChatDoneEvent(
|
||||||
deflected=plan.deflected,
|
deflected=plan.deflected,
|
||||||
sources=cited_refs,
|
sources=cited_refs,
|
||||||
related=related_refs,
|
related=related_refs,
|
||||||
suggestions=plan.suggestions,
|
suggestions=plan.suggestions,
|
||||||
|
answer_image=answer_ref,
|
||||||
).model_dump()
|
).model_dump()
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
+22
-10
@@ -28,12 +28,17 @@ BYTES route: the persistent copy behind an ``is_image`` document's
|
|||||||
``image_path``, served with the extension's ``Content-Type`` (the
|
``image_path``, served with the extension's ``Content-Type`` (the
|
||||||
``app.rag.summarizer.IMAGE_MIMES`` map — one map, one truth) and a
|
``app.rag.summarizer.IMAGE_MIMES`` map — one map, one truth) and a
|
||||||
``Cache-Control: private, max-age=3600`` header (the bytes are
|
``Cache-Control: private, max-age=3600`` header (the bytes are
|
||||||
content-hashed — long enough, bustable by re-upload). User-gated like
|
content-hashed — long enough, bustable by re-upload). PUBLIC (phase
|
||||||
the content endpoint (phase 79 — the ONLY anonymous surface is the
|
124 — the phase-123 chat-image precedent: the image is part of a
|
||||||
shared chats): a missing doc, a non-image doc, a doc whose
|
shared chat's rendered content, and the shared chat — the app's ONLY
|
||||||
|
anonymous surface — must render the answer figure faithfully for
|
||||||
|
guests; the unguessable ``uuid4`` document id IS the credential, no
|
||||||
|
enumeration value): a missing doc, a non-image doc, a doc whose
|
||||||
``image_path`` is NULL, or a row whose copy was lost all map to 404
|
``image_path`` is NULL, or a row whose copy was lost all map to 404
|
||||||
``document not found`` (the router's unknown-document shape —
|
``document not found`` (the router's unknown-document shape —
|
||||||
traversal/UUID-guessing has no filesystem surface to hit).
|
traversal/UUID-guessing has no filesystem surface to hit). The
|
||||||
|
document's TEXT (the content endpoint) stays user-gated — only the
|
||||||
|
bytes are public.
|
||||||
|
|
||||||
PATCH /api/folders/summary — the admin folder-description editor
|
PATCH /api/folders/summary — the admin folder-description editor
|
||||||
(phase 97, task 03): update / create / clear a stored
|
(phase 97, task 03): update / create / clear a stored
|
||||||
@@ -212,16 +217,23 @@ def get_document_content(
|
|||||||
def get_document_image(
|
def get_document_image(
|
||||||
doc_id: str,
|
doc_id: str,
|
||||||
db: Session = Depends(get_db), # noqa: B008
|
db: Session = Depends(get_db), # noqa: B008
|
||||||
_user: None = Depends(require_user), # noqa: B008 # phase 79 posture (see docstring)
|
|
||||||
) -> FileResponse:
|
) -> FileResponse:
|
||||||
"""The phase-122 (task 04) image BYTES route — the persistent copy
|
"""The phase-122 (task 04) image BYTES route — the persistent copy
|
||||||
behind an ``is_image`` document's ``image_path``.
|
behind an ``is_image`` document's ``image_path``.
|
||||||
|
|
||||||
Same auth posture as the document content endpoint (phase 79 —
|
PUBLIC (phase 124, LOCKED A4 — "visible … on the shared page"): a
|
||||||
``require_user``: admin OR live token holder; the ONLY anonymous
|
guest opening a shared chat sees the brain answer's image figure,
|
||||||
surface is the shared chats — the image is part of a document's
|
so the bytes must serve ANONYMOUSLY. The trust model is the
|
||||||
content, so it travels under the same gate): anonymous callers get
|
phase-123 chat-image precedent (the question attachment travels in
|
||||||
401 ``authentication required`` before any row is read.
|
the SAME shared content): the document id is an unguessable
|
||||||
|
server-minted ``uuid4`` — IT IS the credential, with no
|
||||||
|
enumeration value — exactly like the saved-chat id (phase 55) and
|
||||||
|
the chat-image filename (phase 123). (The phase-122/79
|
||||||
|
``require_user`` posture was set before the image became part of a
|
||||||
|
shared chat's rendered content; the shared page's figure — built
|
||||||
|
on this route per the phase-124 design — 401'd and collapsed for
|
||||||
|
every guest, contradicting the owner-locked A4. The document's
|
||||||
|
TEXT stays user-gated: only the bytes are public.)
|
||||||
|
|
||||||
404 ``document not found`` (the router's unknown-document shape) in
|
404 ``document not found`` (the router's unknown-document shape) in
|
||||||
every non-servable case — a missing id (an unparseable string maps
|
every non-servable case — a missing id (an unparseable string maps
|
||||||
|
|||||||
@@ -412,6 +412,23 @@ class Settings(BaseSettings):
|
|||||||
#: (the ``upload_max_mb`` pattern).
|
#: (the ``upload_max_mb`` pattern).
|
||||||
chat_image_max_mb: int = 10
|
chat_image_max_mb: int = 10
|
||||||
|
|
||||||
|
# --- Answer images (phase 124: the LLM shows a supporting image in the answer) ---
|
||||||
|
#: Feature toggle for the answer-image capability (phase 124,
|
||||||
|
#: ``BOR_ANSWER_IMAGES``; ``0``/``false`` = off — the DEFAULT,
|
||||||
|
#: LOCKED A2). The single knob that turns the whole feature on or
|
||||||
|
#: off. Gates the ANSWER side ONLY: while on, the grounded agent is
|
||||||
|
#: offered the ``show_image`` tool, the HIGH prompt carries its
|
||||||
|
#: teaching clause, and a shown doc rides the done frame's
|
||||||
|
#: ``answer_image`` key (and the saved record's). While off,
|
||||||
|
#: behavior is byte-identical everywhere — no ``show_image`` tool
|
||||||
|
#: offered, no prompt clause, no ``answer_image`` key in any frame
|
||||||
|
#: or record. INDEPENDENT of ``images``/``BOR_IMAGES`` (LOCKED A2):
|
||||||
|
#: that toggle indexes image documents, this one decides whether
|
||||||
|
#: the model may show one in an answer — ``on`` with ``images`` off
|
||||||
|
#: is a coherent (if image-less) state: the tool is offered, finds
|
||||||
|
#: no image docs, and always refuses.
|
||||||
|
answer_images: bool = False
|
||||||
|
|
||||||
# --- Docs push (phase 59: save a chat answer as documentation) ---
|
# --- Docs push (phase 59: save a chat answer as documentation) ---
|
||||||
#: The git repo a saved chat answer is committed to (phase 59, D3):
|
#: The git repo a saved chat answer is committed to (phase 59, D3):
|
||||||
#: **any** remote — a URL (``https://``, ``ssh://``, ``git@``) or a
|
#: **any** remote — a URL (``https://``, ``ssh://``, ``git@``) or a
|
||||||
|
|||||||
+143
-2
@@ -390,6 +390,71 @@ AGENT_TOOLS: list[dict[str, Any]] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
#: The 4th agent tool (phase 124, task 02; LOCKED A3/A5) — offered
|
||||||
|
#: ONLY when ``settings.answer_images`` (``BOR_ANSWER_IMAGES``) is true:
|
||||||
|
#: :func:`agent_tools` composes the offer list (the three base tools
|
||||||
|
#: stay :data:`AGENT_TOOLS`, byte-identical). The description IS the
|
||||||
|
#: relevance gate (A5 — "we don't want to just show every image that
|
||||||
|
#: seems relevant"): call it only when the image itself contains the
|
||||||
|
#: answer, at most ONE image per answer, the text answer is always
|
||||||
|
#: given in full (the image supplements, never replaces). Same OpenAI
|
||||||
|
#: function shape as the other three (one required string arg ``path``,
|
||||||
|
#: the combined ``source/path`` identity — the bare-path-will-not-
|
||||||
|
#: resolve teaching mirrored from ``read``).
|
||||||
|
SHOW_IMAGE_TOOL: dict[str, Any] = {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "show_image",
|
||||||
|
"description": (
|
||||||
|
"Show a standalone image document to the user as part of "
|
||||||
|
"your answer. Call it only when the image itself contains "
|
||||||
|
"the answer — a diagram, screenshot, or photo of the thing "
|
||||||
|
"the user asked about — never for a merely related image. "
|
||||||
|
"Show at most one image per answer. The image supplements "
|
||||||
|
"your text answer — answer fully in text either way. Its "
|
||||||
|
"`path` is the combined `source/path` string, exactly as "
|
||||||
|
"shown in the `ls` output or the `<documents>` blocks — a "
|
||||||
|
"bare document path will not resolve. Call one tool at a "
|
||||||
|
"time — wait for this result before your next call."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": (
|
||||||
|
"The image document to show, as the combined "
|
||||||
|
"`source/path` string exactly as shown in the "
|
||||||
|
"`ls` output (e.g. "
|
||||||
|
"'homelab/docs/architecture.png'). A bare "
|
||||||
|
"document path (without the source name) will "
|
||||||
|
"not resolve. Show at most one image per "
|
||||||
|
"answer — a second call is refused."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["path"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def agent_tools(settings: Settings) -> list[dict[str, Any]]:
|
||||||
|
"""The grounded turn's tool offer list (phase 124, task 02).
|
||||||
|
|
||||||
|
The three base tools (:data:`AGENT_TOOLS`, byte-identical) plus
|
||||||
|
:data:`SHOW_IMAGE_TOOL` when ``settings.answer_images``
|
||||||
|
(``BOR_ANSWER_IMAGES``) is true — otherwise exactly the three base
|
||||||
|
tools (the pre-phase-124 offer, byte-identical: no 4th tool in the
|
||||||
|
wire ``tools`` array). A FRESH list on every call — callers never
|
||||||
|
mutate the result or the constants (``run_agent`` passes it straight
|
||||||
|
to the LLM request).
|
||||||
|
"""
|
||||||
|
tools: list[dict[str, Any]] = list(AGENT_TOOLS)
|
||||||
|
if settings.answer_images:
|
||||||
|
tools.append(SHOW_IMAGE_TOOL)
|
||||||
|
return tools
|
||||||
|
|
||||||
#: Tool refusal texts (phase 37): rejected calls count in nothing
|
#: Tool refusal texts (phase 37): rejected calls count in nothing
|
||||||
#: (``holder.tool_calls`` tracks executed calls); the round cap bounds
|
#: (``holder.tool_calls`` tracks executed calls); the round cap bounds
|
||||||
#: their pathological repetition (phase 45). The in-context line is a
|
#: their pathological repetition (phase 45). The in-context line is a
|
||||||
@@ -407,6 +472,24 @@ ALREADY_IN_CONTEXT = (
|
|||||||
UNKNOWN_TOOL = "Unknown tool."
|
UNKNOWN_TOOL = "Unknown tool."
|
||||||
MISSING_READ_ARGS = "read requires a string argument 'path'."
|
MISSING_READ_ARGS = "read requires a string argument 'path'."
|
||||||
MISSING_SEARCH_ARGS = "grep requires a string argument 'pattern'."
|
MISSING_SEARCH_ARGS = "grep requires a string argument 'pattern'."
|
||||||
|
MISSING_SHOW_ARGS = "show_image requires a string argument 'path'."
|
||||||
|
|
||||||
|
#: The ``show_image`` refusals (phase 124, task 02): the same contract
|
||||||
|
#: as every other refusal — counts in nothing, consumes a round,
|
||||||
|
#: changes no context. ``NOT_AN_IMAGE_DOC``: one ``{arg}`` field, the
|
||||||
|
#: argument echoed as passed — a text doc is refused, never silently
|
||||||
|
#: converted to a read (LOCKED A5). ``ALREADY_SHOWING``: the LOCKED A3
|
||||||
|
#: one-image-per-answer gate, enforced SERVER-side (the model is taught
|
||||||
|
#: it in the tool description + the prompt clause; the handler is the
|
||||||
|
#: actual gate).
|
||||||
|
NOT_AN_IMAGE_DOC = (
|
||||||
|
"'{arg}' is not an image document — only standalone image "
|
||||||
|
"documents can be shown as answers."
|
||||||
|
)
|
||||||
|
ALREADY_SHOWING = (
|
||||||
|
"Already showing an image this answer — do not show another one. "
|
||||||
|
"Answer from what you have."
|
||||||
|
)
|
||||||
|
|
||||||
#: The read-truncation notice (phase 95, task 01) — appended, after the
|
#: The read-truncation notice (phase 95, task 01) — appended, after the
|
||||||
#: shared :data:`TRUNCATION_MARKER`, to a ``read`` result whose document
|
#: shared :data:`TRUNCATION_MARKER`, to a ``read`` result whose document
|
||||||
@@ -1166,12 +1249,19 @@ class AgentHolder:
|
|||||||
as today — this list only carries the truncation signal the agent
|
as today — this list only carries the truncation signal the agent
|
||||||
loop turns into :class:`app.rag.llm.ToolResultPiece` values (task 02
|
loop turns into :class:`app.rag.llm.ToolResultPiece` values (task 02
|
||||||
surfaces them to the UI).
|
surfaces them to the UI).
|
||||||
|
``shown_images`` (phase 124, task 02): the documents ``show_image``
|
||||||
|
showed, in call order — at most one in practice (LOCKED A3: a
|
||||||
|
second call is refused with :data:`ALREADY_SHOWING`, which appends
|
||||||
|
nothing). Read by ``app.api.chat`` after the stream for the done
|
||||||
|
frame's ``answer_image`` field (task 04) — the ``read_docs``
|
||||||
|
precedent (an API-layer read-after-stream signal).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
read_docs: list[Document] = field(default_factory=list)
|
read_docs: list[Document] = field(default_factory=list)
|
||||||
tool_calls: int = 0
|
tool_calls: int = 0
|
||||||
scaffold_stripped: int = 0
|
scaffold_stripped: int = 0
|
||||||
read_truncations: list[tuple[str, int, int]] = field(default_factory=list)
|
read_truncations: list[tuple[str, int, int]] = field(default_factory=list)
|
||||||
|
shown_images: list[Document] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
def _execute_tool(
|
def _execute_tool(
|
||||||
@@ -1327,6 +1417,51 @@ def _execute_tool(
|
|||||||
f"{marker}"
|
f"{marker}"
|
||||||
f"{doc.content}"
|
f"{doc.content}"
|
||||||
)
|
)
|
||||||
|
if call.name == "show_image":
|
||||||
|
# Phase 124 (task 02; LOCKED A3/A5): the 4th agent tool — the
|
||||||
|
# model shows a standalone image document as part of its
|
||||||
|
# answer. Offered only when ``settings.answer_images``
|
||||||
|
# (``agent_tools``); the handler enforces the contract
|
||||||
|
# server-side — image docs only, ONE image per answer. Every
|
||||||
|
# refusal below counts in nothing and consumes a round, like
|
||||||
|
# every other refusal.
|
||||||
|
raw_path = call.arguments.get("path")
|
||||||
|
arg = raw_path.strip() if isinstance(raw_path, str) else ""
|
||||||
|
if not arg:
|
||||||
|
return MISSING_SHOW_ARGS
|
||||||
|
doc, _source, _path = _resolve_path(db, arg)
|
||||||
|
if doc is None:
|
||||||
|
# The same phase-72 "did you mean …?" teaching as the read
|
||||||
|
# branch (a refusal — not counted, no context).
|
||||||
|
return _no_document_refusal(db, arg)
|
||||||
|
if not doc.is_image:
|
||||||
|
# LOCKED A5: a text doc is refused, never silently
|
||||||
|
# converted to a read — the tool shows IMAGE documents
|
||||||
|
# only.
|
||||||
|
return NOT_AN_IMAGE_DOC.format(arg=arg)
|
||||||
|
if holder.shown_images:
|
||||||
|
# LOCKED A3: one image per answer — server-enforced, not
|
||||||
|
# just taught.
|
||||||
|
return ALREADY_SHOWING
|
||||||
|
holder.shown_images.append(doc)
|
||||||
|
holder.tool_calls += 1
|
||||||
|
# The confirming result: a NEW first line (absent from ``read``
|
||||||
|
# — the model must know the show registered) followed by the
|
||||||
|
# exact document block ``read`` returns for an image doc at-or-
|
||||||
|
# under-cap (the ``Document …`` header + phase-106 date line +
|
||||||
|
# :data:`IMAGE_DOC_MARKER` — the header contract the E2E mock
|
||||||
|
# pins on ``read`` results is deliberately reused here). An
|
||||||
|
# image doc's content is the summary-capped vision description
|
||||||
|
# — short by construction (phase 122, task 03), so NO
|
||||||
|
# truncation machinery; the plain block stays correct even if
|
||||||
|
# it ever exceeded the cap.
|
||||||
|
return (
|
||||||
|
"Image shown to the user — it is now part of your answer.\n"
|
||||||
|
f"Document {doc.source}/{doc.path}:\n"
|
||||||
|
f"date: {doc.created_at:%Y-%m-%d}\n"
|
||||||
|
f"{IMAGE_DOC_MARKER}\n"
|
||||||
|
f"{doc.content}"
|
||||||
|
)
|
||||||
if call.name == "grep":
|
if call.name == "grep":
|
||||||
raw_pattern = call.arguments.get("pattern")
|
raw_pattern = call.arguments.get("pattern")
|
||||||
pattern = raw_pattern.strip() if isinstance(raw_pattern, str) else ""
|
pattern = raw_pattern.strip() if isinstance(raw_pattern, str) else ""
|
||||||
@@ -1485,9 +1620,15 @@ async def run_agent(
|
|||||||
# Phase 45: no per-tool budgets — the tools stay offered for the
|
# Phase 45: no per-tool budgets — the tools stay offered for the
|
||||||
# whole turn, bounded by the round cap. ``0`` is the no-tools kill
|
# whole turn, bounded by the round cap. ``0`` is the no-tools kill
|
||||||
# switch: exactly one request with ``tools=None`` (the pre-phase-37
|
# switch: exactly one request with ``tools=None`` (the pre-phase-37
|
||||||
# path).
|
# path). Phase 124 (task 02): the offer list is :func:`agent_tools`
|
||||||
|
# — the three base tools (byte-identical) plus ``show_image`` only
|
||||||
|
# when ``settings.answer_images`` (``BOR_ANSWER_IMAGES``) is on;
|
||||||
|
# off (the default) the offer is element-for-element the
|
||||||
|
# pre-phase-124 list.
|
||||||
max_rounds = settings.agent_max_rounds
|
max_rounds = settings.agent_max_rounds
|
||||||
tools: list[dict[str, Any]] | None = AGENT_TOOLS if max_rounds > 0 else None
|
tools: list[dict[str, Any]] | None = (
|
||||||
|
agent_tools(settings) if max_rounds > 0 else None
|
||||||
|
)
|
||||||
rounds = 0
|
rounds = 0
|
||||||
while True:
|
while True:
|
||||||
calls: list[ToolCallPiece] = []
|
calls: list[ToolCallPiece] = []
|
||||||
|
|||||||
+67
-2
@@ -258,6 +258,43 @@ TOOLS_SECTION: str = (
|
|||||||
"</tools>"
|
"</tools>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#: The ``show_image`` contract clause (phase 124, task 03, LOCKED A5) —
|
||||||
|
#: appended INSIDE the ``<tools>`` section of the HIGH prompt, after the
|
||||||
|
#: existing three-tool copy and before the closing tag, ONLY when
|
||||||
|
#: :func:`build_high_prompt` is called with ``show_image=True`` (the
|
||||||
|
#: ``BOR_ANSWER_IMAGES`` toggle, via ``app.api.chat``). It teaches the
|
||||||
|
#: same relevance gate as the ``show_image`` tool description in
|
||||||
|
#: :mod:`app.rag.agent` (LOCKED A5: the gate is the LLM's decision — no
|
||||||
|
#: server-side image scoring), plus the image-doc nature the tool
|
||||||
|
#: description cannot fully carry: the text of an image document (in a
|
||||||
|
#: ``<documents>`` block or a ``read`` result) is a description
|
||||||
|
#: GENERATED from the image, not the image's own words. The four gate
|
||||||
|
#: elements, all stated here: (1) the image-doc nature; (2) the
|
||||||
|
#: "contains the answer" bar — call it only when THAT image is what the
|
||||||
|
#: user actually wants to see, never a merely related image, never to
|
||||||
|
#: pad an answer; (3) at most ONE image per answer (also enforced
|
||||||
|
#: server-side, task 02's ``ALREADY_SHOWING`` refusal); (4) the text
|
||||||
|
#: answer is still provided in full — the image supplements, never
|
||||||
|
#: replaces. ``TOOLS_SECTION`` itself stays BYTE-IDENTICAL (the
|
||||||
|
#: pre-phase pins in :mod:`tests.unit.test_prompt_lock` hold); a
|
||||||
|
#: ``show_image=False`` build (the default — every pre-phase call site)
|
||||||
|
#: is byte-identical to the pre-task output. The ``<tools>`` marker the
|
||||||
|
#: E2E mock keys on is untouched either way — the clause rides on its
|
||||||
|
#: presence, not the wording. The LOW/deflection prompt never carries
|
||||||
|
#: the clause (a deflected turn has no tools and never shows an image —
|
||||||
|
#: the phase's locked design).
|
||||||
|
SHOW_IMAGE_CLAUSE: str = (
|
||||||
|
"`show_image` displays a standalone image document as part of your "
|
||||||
|
"answer. The text of an image document — in a <documents> block or "
|
||||||
|
"a `read` result — is a description GENERATED from the image, not "
|
||||||
|
"the image's own words. Call `show_image` only when that image is "
|
||||||
|
"what the user actually wants to see: a diagram, screenshot, or "
|
||||||
|
"photo of the very thing you are answering about — never for a "
|
||||||
|
"merely related image, and never to pad an answer. Show at most one "
|
||||||
|
"image per answer. The image supplements your text — answer fully "
|
||||||
|
"in text either way."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def history_to_messages(
|
def history_to_messages(
|
||||||
history: Sequence[HistoryTurn],
|
history: Sequence[HistoryTurn],
|
||||||
@@ -426,6 +463,7 @@ def build_high_prompt(
|
|||||||
notes: Sequence[str] | None = None,
|
notes: Sequence[str] | None = None,
|
||||||
kb_overview: str | None = None,
|
kb_overview: str | None = None,
|
||||||
folder_lines: Sequence[str] = (),
|
folder_lines: Sequence[str] = (),
|
||||||
|
show_image: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Grounded turn: locked persona (+ steering, + KB overview) + the
|
"""Grounded turn: locked persona (+ steering, + KB overview) + the
|
||||||
top-ranked documents' SUMMARY blocks + the ``<tools>`` instructions
|
top-ranked documents' SUMMARY blocks + the ``<tools>`` instructions
|
||||||
@@ -434,7 +472,8 @@ def build_high_prompt(
|
|||||||
phase 118, LOCKED A6 re-revising A7: the ``<documents>`` section
|
phase 118, LOCKED A6 re-revising A7: the ``<documents>`` section
|
||||||
seeds SUMMARIES, never full texts — full text enters the context
|
seeds SUMMARIES, never full texts — full text enters the context
|
||||||
only through the capped ``read`` tool; phase 119, LOCKED A4: the
|
only through the capped ``read`` tool; phase 119, LOCKED A4: the
|
||||||
suggested-folder context lines, below).
|
suggested-folder context lines, below; phase 124, task 03, LOCKED
|
||||||
|
A5: the optional :data:`SHOW_IMAGE_CLAUSE`, below).
|
||||||
|
|
||||||
Section order: ``<relevance>`` → ``<knowledge_base>`` → ``<tuning>``
|
Section order: ``<relevance>`` → ``<knowledge_base>`` → ``<tuning>``
|
||||||
→ ``<documents>`` → ``<tools>``; empty steering/overview omit their
|
→ ``<documents>`` → ``<tools>``; empty steering/overview omit their
|
||||||
@@ -442,6 +481,23 @@ def build_high_prompt(
|
|||||||
cap — not the prompt — decides whether the tools are actually
|
cap — not the prompt — decides whether the tools are actually
|
||||||
offered to the model, see :mod:`app.rag.agent`).
|
offered to the model, see :mod:`app.rag.agent`).
|
||||||
|
|
||||||
|
*show_image* (phase 124, task 03, LOCKED A5): when true (the
|
||||||
|
caller passes the ``BOR_ANSWER_IMAGES`` toggle —
|
||||||
|
``app.api.chat``'s grounded plan is the only call site that does),
|
||||||
|
the :data:`SHOW_IMAGE_CLAUSE` rides INSIDE the ``<tools>`` section
|
||||||
|
— appended after the existing three-tool copy and before the
|
||||||
|
closing ``</tools>`` tag, so the ``<tools>`` marker the E2E mock
|
||||||
|
keys on is untouched and the clause reaches the model together with
|
||||||
|
the tool teaching. The section order is otherwise UNCHANGED and
|
||||||
|
:data:`TOOLS_SECTION` itself is never edited: the built section is
|
||||||
|
``TOOLS_SECTION``'s text minus its final ``</tools>`` + the clause
|
||||||
|
+ the closing tag. The default ``False`` keeps EVERY pre-existing
|
||||||
|
call byte-identical (the pre-task pins in
|
||||||
|
:mod:`tests.unit.test_prompts` / :mod:`tests.unit.test_prompt_lock`
|
||||||
|
hold untouched). The deflection prompt (:func:`build_deflect_prompt`)
|
||||||
|
never carries the clause — a deflected turn has no tools and never
|
||||||
|
shows an image.
|
||||||
|
|
||||||
When at least one block is present, the section leads with the
|
When at least one block is present, the section leads with the
|
||||||
start-here :data:`SUGGEST_INTRO` line (before the first block — the
|
start-here :data:`SUGGEST_INTRO` line (before the first block — the
|
||||||
phase-15 ``_STEERING_INTRO`` / phase-31 ``_KB_INTRO`` precedent): the
|
phase-15 ``_STEERING_INTRO`` / phase-31 ``_KB_INTRO`` precedent): the
|
||||||
@@ -506,7 +562,16 @@ def build_high_prompt(
|
|||||||
for part in (build_kb_section(kb_overview or ""), build_steering_section(notes or [])):
|
for part in (build_kb_section(kb_overview or ""), build_steering_section(notes or [])):
|
||||||
if part:
|
if part:
|
||||||
prompt += "\n" + part
|
prompt += "\n" + part
|
||||||
return prompt + "\n<documents>\n" + body + "\n</documents>\n" + TOOLS_SECTION
|
if show_image:
|
||||||
|
# Phase 124 (task 03, LOCKED A5): the clause rides inside
|
||||||
|
# <tools> — TOOLS_SECTION's text minus its final </tools>, the
|
||||||
|
# clause (on its own line, after the existing three-tool copy),
|
||||||
|
# then the closing tag. TOOLS_SECTION itself is unedited and the
|
||||||
|
# show_image=False path keeps the exact pre-task concatenation.
|
||||||
|
tools_section = TOOLS_SECTION[: -len("</tools>")] + SHOW_IMAGE_CLAUSE + "\n</tools>"
|
||||||
|
else:
|
||||||
|
tools_section = TOOLS_SECTION
|
||||||
|
return prompt + "\n<documents>\n" + body + "\n</documents>\n" + tools_section
|
||||||
|
|
||||||
|
|
||||||
def build_deflect_prompt(
|
def build_deflect_prompt(
|
||||||
|
|||||||
+69
-10
@@ -242,6 +242,21 @@ class ChatDoneEvent(BaseModel):
|
|||||||
citation chip — while ``sources`` stays the citation surface. The
|
citation chip — while ``sources`` stays the citation surface. The
|
||||||
field is ADDITIVE: old clients ignore unknown fields (PLAN §4 house
|
field is ADDITIVE: old clients ignore unknown fields (PLAN §4 house
|
||||||
contract) and old frames without it parse with the default ``[]``.
|
contract) and old frames without it parse with the default ``[]``.
|
||||||
|
|
||||||
|
Phase 124 (task 04): ``answer_image`` — the brain answer's
|
||||||
|
FIRST-CLASS image: the standalone image document the LLM chose to
|
||||||
|
show as part of its answer (its ``show_image`` pick, behind
|
||||||
|
``BOR_ANSWER_IMAGES``; LOCKED A5 — the relevance gate is the model's
|
||||||
|
decision). It is the shown document's :class:`SourceRef` built by
|
||||||
|
the shared :func:`app.rag.retriever.source_ref_with_image` (source /
|
||||||
|
path / title + the ``image_url`` bytes route), distinct from a
|
||||||
|
citation in ``sources`` — the document may be cited as well, but
|
||||||
|
this key IS the answer's image (the UI's answer figure, task 06).
|
||||||
|
``None`` (every deflected turn — they run no agent — every
|
||||||
|
image-less turn, and the whole pre-phase) is DROPPED on
|
||||||
|
serialization — ABSENT, never ``null`` — so those frames stay
|
||||||
|
byte-identical to pre-phase (the byte-identical-off contract, task
|
||||||
|
01; the :class:`SourceRef.image_url` omission precedent).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
type: str = "done"
|
type: str = "done"
|
||||||
@@ -249,6 +264,25 @@ class ChatDoneEvent(BaseModel):
|
|||||||
sources: list[SourceRef]
|
sources: list[SourceRef]
|
||||||
related: list[SourceRef] = []
|
related: list[SourceRef] = []
|
||||||
suggestions: list[str] = []
|
suggestions: list[str] = []
|
||||||
|
#: Phase 124 (task 04) — see the class docstring. A single
|
||||||
|
#: :class:`SourceRef` (no list — LOCKED A3: one image per answer);
|
||||||
|
#: the ref's own phase-83 field caps bind.
|
||||||
|
answer_image: SourceRef | None = None
|
||||||
|
|
||||||
|
@model_serializer(mode="wrap")
|
||||||
|
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
|
||||||
|
"""The phase-124 answer-image omission rule: ``answer_image``
|
||||||
|
``None`` (every deflected / image-less / pre-phase turn)
|
||||||
|
serializes WITHOUT the key — ABSENT, never ``null`` — so those
|
||||||
|
done frames stay byte-identical to pre-phase (the
|
||||||
|
:class:`SourceRef.image_url` / phase-123 ``image`` omission
|
||||||
|
precedents). A shown ref keeps all its fields (its own
|
||||||
|
serializer drops a ``None`` ``image_url`` — a shown doc is an
|
||||||
|
image doc, so in practice it always carries it)."""
|
||||||
|
data = handler(self)
|
||||||
|
if self.answer_image is None:
|
||||||
|
data.pop("answer_image", None)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
class ChatErrorEvent(BaseModel):
|
class ChatErrorEvent(BaseModel):
|
||||||
@@ -967,22 +1001,47 @@ class ChatMessage(BaseModel):
|
|||||||
# omission precedent). The path is ≤ 500 chars — no phase-83
|
# omission precedent). The path is ≤ 500 chars — no phase-83
|
||||||
# cap pressure (it is never a data URL, LOCKED A5).
|
# cap pressure (it is never a data URL, LOCKED A5).
|
||||||
image: str | None = Field(default=None, max_length=500)
|
image: str | None = Field(default=None, max_length=500)
|
||||||
|
# Phase 124 (task 04): the brain answer's FIRST-CLASS image — the
|
||||||
|
# shown document's :class:`SourceRef` (the LLM's ``show_image``
|
||||||
|
# pick, ``BOR_ANSWER_IMAGES``), on the BRAIN record only. It is a
|
||||||
|
# DIFFERENT thing from the user record's phase-123 ``image`` (the
|
||||||
|
# stored PATH STRING of the question's attachment): that field is
|
||||||
|
# untouched and the two are semantically distinct — the attachment
|
||||||
|
# belongs to the question, the answer image to the brain's reply
|
||||||
|
# (a user record never carries ``answer_image``, a brain record
|
||||||
|
# never carries ``image``). A single ref — no length cap needed
|
||||||
|
# (the ref's own phase-83 field caps bind). The done-time
|
||||||
|
# auto-save (``rememberBrainTurn``, task 06) persists the done
|
||||||
|
# frame's ``answer_image`` into this key, and the restore path
|
||||||
|
# re-renders the answer figure from it — the field must exist so
|
||||||
|
# the ``extra="forbid"`` boundary does NOT 422 those saves (the
|
||||||
|
# phase-113 lesson, applied up front: a missing key silently
|
||||||
|
# unsaved every grounded brain record). ``None`` (every pre-phase
|
||||||
|
# record, every image-less turn) serializes WITHOUT the key —
|
||||||
|
# ABSENT, never ``null`` (the :meth:`_drop_image_when_absent`
|
||||||
|
# serializer below), so pre-phase and image-less chats round-trip
|
||||||
|
# byte-identically (the phase-50 contract).
|
||||||
|
answer_image: SourceRef | None = None
|
||||||
|
|
||||||
@model_serializer(mode="wrap")
|
@model_serializer(mode="wrap")
|
||||||
def _drop_image_when_absent(self, handler: SerializerFunctionWrapHandler) -> Any:
|
def _drop_image_when_absent(self, handler: SerializerFunctionWrapHandler) -> Any:
|
||||||
"""The phase-123 image omission rule: ``image: None`` (every
|
"""The image omission rules: ``image: None`` (every text-only
|
||||||
text-only record, and every pre-phase-123 record) serializes
|
record, and every pre-phase-123 record) and ``answer_image:``
|
||||||
WITHOUT the key — ABSENT, never ``null`` — so the stored
|
``None`` (every record without a shown answer image, and every
|
||||||
``bor.chat.v1`` JSONB and the saved/shared wire shape stay
|
pre-phase-124 record) serialize WITHOUT the keys — ABSENT,
|
||||||
byte-identical to pre-phase for text-only chats (the phase-50
|
never ``null`` — so the stored ``bor.chat.v1`` JSONB and the
|
||||||
round-trip contract). A record WITH an image keeps the path —
|
saved/shared wire shape stay byte-identical to pre-phase for
|
||||||
the user bubble, the refreshed page, and the shared chat all
|
those records (the phase-50 round-trip contract; the
|
||||||
render it from the served route (the image is part of the
|
phase-122 ``SourceRef.image_url`` omission precedent). A
|
||||||
chat's content, so it rides the same public/credential-
|
record WITH one keeps it — the user bubble, the refreshed page,
|
||||||
is-the-id trust model)."""
|
and the shared chat all render the image from the served route
|
||||||
|
(the image is part of the chat's content, so it rides the same
|
||||||
|
public/credential-is-the-id trust model)."""
|
||||||
data = handler(self)
|
data = handler(self)
|
||||||
if self.image is None:
|
if self.image is None:
|
||||||
data.pop("image", None)
|
data.pop("image", None)
|
||||||
|
if self.answer_image is None:
|
||||||
|
data.pop("answer_image", None)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1726,6 +1726,81 @@ function fetchContentSummary(source, path) {
|
|||||||
.then((doc) => (doc && typeof doc.summary === "string" ? doc.summary : null));
|
.then((doc) => (doc && typeof doc.summary === "string" ? doc.summary : null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Phase 124 (task 06, TODO L3): the LLM's ANSWER IMAGE — the brain
|
||||||
|
* answer's first-class figure (the `done` frame's `answer_image` ref,
|
||||||
|
* task 04). A4: it sits in the .msg-body BELOW the answer text and
|
||||||
|
* ABOVE the source-chip row — the callers append it right BEFORE
|
||||||
|
* appendSources creates the chip row, so it lands in between. The
|
||||||
|
* text bubble is untouched: the figure is ADDITIVE ("summaries
|
||||||
|
* should still be provided").
|
||||||
|
*
|
||||||
|
* The visual sibling of appendSourceImageFigure (the compact chip
|
||||||
|
* figure) but a different surface: the answer figure is the answer's
|
||||||
|
* own image, not a chip accessory — .answer-image scales it up (the
|
||||||
|
* .source-image language at answer size, styles.css). Same WCAG alt
|
||||||
|
* contract as the chip figure: alt + the VISIBLE caption = the
|
||||||
|
* document's SUMMARY (the vision description); the frame carries no
|
||||||
|
* summary (the ref is source/path/title/image_url), so the figure
|
||||||
|
* fetches the content endpoint the chip's modal already uses and
|
||||||
|
* swaps the summary in; until it settles — and when it fails — the
|
||||||
|
* title stands in (a caption is ALWAYS visible). A FAILED IMAGE LOAD
|
||||||
|
* REMOVES the whole figure (the collapse rule — the answer figure has
|
||||||
|
* no chip of its own to fall back to; the chip row below still cites
|
||||||
|
* the doc, the phase-124 auto-cite): an answer whose image bytes are
|
||||||
|
* gone renders as a normal answer, never a broken-image icon.
|
||||||
|
* Navigation reuses the chip affordance: the same documentUrl href
|
||||||
|
* (the /document.html escape hatch) + left-click → same-page modal
|
||||||
|
* (phase 26, the appendSources/appendSourceImageFigure pattern). */
|
||||||
|
function appendAnswerImage(wrap, ref) {
|
||||||
|
if (
|
||||||
|
!ref ||
|
||||||
|
typeof ref.source !== "string" ||
|
||||||
|
typeof ref.path !== "string" ||
|
||||||
|
typeof ref.image_url !== "string" ||
|
||||||
|
!ref.image_url
|
||||||
|
)
|
||||||
|
return; // no ref (or a malformed one) → no figure DOM at all
|
||||||
|
const body = wrap.querySelector(".msg-body");
|
||||||
|
const label = `${ref.source}/${ref.path}`;
|
||||||
|
const fig = document.createElement("a");
|
||||||
|
fig.className = "answer-image";
|
||||||
|
fig.href = documentUrl(ref.source, ref.path, "/"); // back → the chat page
|
||||||
|
fig.title = label; // full path as the native tooltip (chip pattern)
|
||||||
|
fig.setAttribute("aria-label", label); // the accessible name is the full path
|
||||||
|
fig.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault(); // no new tab (phase 26) — the modal takes over
|
||||||
|
e.stopPropagation();
|
||||||
|
openDocumentModal(ref.source, ref.path, fig);
|
||||||
|
});
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.className = "answer-image-img";
|
||||||
|
img.src = ref.image_url; // the public bytes route (task 04's ref)
|
||||||
|
img.loading = "lazy"; // A4: lazy-loaded
|
||||||
|
img.alt = ref.title || label; // the summary arrives via the fetch below
|
||||||
|
img.addEventListener("error", () => {
|
||||||
|
// Collapse to NOTHING (there is no chip inside the figure to fall
|
||||||
|
// back to) — the answer renders normally, the chip row below
|
||||||
|
// still cites the doc.
|
||||||
|
fig.remove();
|
||||||
|
});
|
||||||
|
const caption = document.createElement("span");
|
||||||
|
caption.className = "answer-image-caption";
|
||||||
|
caption.textContent = ref.title || label; // visible caption: the title first…
|
||||||
|
fig.append(img, caption);
|
||||||
|
body.appendChild(fig);
|
||||||
|
// …and the document's summary once the content fetch settles (the
|
||||||
|
// chip figure's SAME pattern — fetchContentSummary fails soft to
|
||||||
|
// null: the title stays).
|
||||||
|
fetchContentSummary(ref.source, ref.path).then((summary) => {
|
||||||
|
if (!fig.isConnected) return; // collapsed (img error) or bubble cleared
|
||||||
|
if (summary && summary.trim() !== "") {
|
||||||
|
img.alt = summary;
|
||||||
|
caption.textContent = summary;
|
||||||
|
caption.title = summary; // full text (the caption is line-clamped)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* "Maybe try:" chips under a deflected bubble (honesty gate, phase 04,
|
/* "Maybe try:" chips under a deflected bubble (honesty gate, phase 04,
|
||||||
shared component + one-tap submit, phase 05). The group is accessible
|
shared component + one-tap submit, phase 05). The group is accessible
|
||||||
(role=list + aria-label) and wraps cleanly at every width. */
|
(role=list + aria-label) and wraps cleanly at every width. */
|
||||||
@@ -1906,6 +1981,11 @@ function renderStoredMessage(m) {
|
|||||||
wrap.classList.add("is-deflected");
|
wrap.classList.add("is-deflected");
|
||||||
appendMaybeTry(wrap, m.suggestions);
|
appendMaybeTry(wrap, m.suggestions);
|
||||||
}
|
}
|
||||||
|
// Phase 124 (task 06, TODO L3): the answer image restores with the
|
||||||
|
// bubble — the SAME helper + the SAME A4 position as the live done
|
||||||
|
// path (below the text, above the chip row). A record without the
|
||||||
|
// key renders byte-identically (pre-phase chats restore unchanged).
|
||||||
|
if (m.answer_image) appendAnswerImage(wrap, m.answer_image);
|
||||||
appendSources(wrap, m.sources);
|
appendSources(wrap, m.sources);
|
||||||
// Phase 120: a failed turn (m.failed) is a note, not an answer — no
|
// Phase 120: a failed turn (m.failed) is a note, not an answer — no
|
||||||
// Tune (the "note, not an answer" scope the m.stopped exclusion
|
// Tune (the "note, not an answer" scope the m.stopped exclusion
|
||||||
@@ -2985,6 +3065,13 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
|||||||
wrap.classList.add("is-deflected");
|
wrap.classList.add("is-deflected");
|
||||||
appendMaybeTry(wrap, ev.suggestions);
|
appendMaybeTry(wrap, ev.suggestions);
|
||||||
}
|
}
|
||||||
|
// Phase 124 (task 06, TODO L3): the LLM's answer image — the
|
||||||
|
// answer's first-class figure (A4): appended to .msg-body
|
||||||
|
// right BEFORE the chip row, so it lands below the answer
|
||||||
|
// text and above the sources. The text is untouched (the
|
||||||
|
// figure is additive — "summaries should still be provided");
|
||||||
|
// a frame without it renders byte-identically (no figure DOM).
|
||||||
|
if (ev.answer_image) appendAnswerImage(wrap, ev.answer_image);
|
||||||
appendSources(wrap, ev.sources);
|
appendSources(wrap, ev.sources);
|
||||||
// Phase 113 (task 02): the cited tier stays the citation
|
// Phase 113 (task 02): the cited tier stays the citation
|
||||||
// surface (the chips above); the related tier (scored docs
|
// surface (the chips above); the related tier (scored docs
|
||||||
@@ -3019,6 +3106,12 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
|||||||
tools: toolAcc.length ? toolAcc : undefined,
|
tools: toolAcc.length ? toolAcc : undefined,
|
||||||
deflected: !!ev.deflected,
|
deflected: !!ev.deflected,
|
||||||
sources: ev.sources,
|
sources: ev.sources,
|
||||||
|
// Phase 124 (task 06, TODO L3): the answer image persists
|
||||||
|
// with the turn (undefined drops the key from the JSON — the
|
||||||
|
// house optional-meta pattern, the `related` line below is
|
||||||
|
// the precedent): an image-less / pre-phase turn stores a
|
||||||
|
// byte-identical record.
|
||||||
|
answer_image: ev.answer_image || undefined,
|
||||||
// Phase 113 (task 02): the related tier persists with the
|
// Phase 113 (task 02): the related tier persists with the
|
||||||
// turn (undefined drops the key from the JSON — the house
|
// turn (undefined drops the key from the JSON — the house
|
||||||
// optional-meta pattern), so the restore path re-renders the
|
// optional-meta pattern), so the restore path re-renders the
|
||||||
|
|||||||
@@ -349,6 +349,50 @@ function addBubbleImage(wrap, src, alt) {
|
|||||||
bubble.prepend(img);
|
bubble.prepend(img);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The brain answer's image (phase 124, task 06, TODO L3) — the local
|
||||||
|
* copy of the chat page's appendAnswerImage (the per-page duplication
|
||||||
|
* house style: this file keeps its own small copies of the chat
|
||||||
|
* page's message-fragment builders). NON-INTERACTIVE like every other
|
||||||
|
* shared-page fragment (owner-locked zero controls: no link, no
|
||||||
|
* modal, no click handler — a plain <figure>, unlike the chat page's
|
||||||
|
* anchor). The bytes route is PUBLIC (the token is the shared chat's
|
||||||
|
* credential, like the phase-123 user-image contract), so the img
|
||||||
|
* loads for guests exactly as it does for the owner. The visible
|
||||||
|
* caption keeps the document's TITLE: the content endpoint the chat
|
||||||
|
* page fetches for the summary is user-gated (a guest fetch would
|
||||||
|
* just 401), and a caption is ALWAYS visible either way (the WCAG
|
||||||
|
* alt contract holds with the title standing in). A failed load
|
||||||
|
* REMOVES the figure (the collapse rule — never a broken-image
|
||||||
|
* icon). */
|
||||||
|
function addAnswerImage(wrap, ref) {
|
||||||
|
if (
|
||||||
|
!ref ||
|
||||||
|
typeof ref.source !== "string" ||
|
||||||
|
typeof ref.path !== "string" ||
|
||||||
|
typeof ref.image_url !== "string" ||
|
||||||
|
!ref.image_url
|
||||||
|
) {
|
||||||
|
return; // no ref (or a malformed one) → no figure DOM at all
|
||||||
|
}
|
||||||
|
const body = wrap?.querySelector?.(".msg-body");
|
||||||
|
if (!body) return;
|
||||||
|
const title =
|
||||||
|
typeof ref.title === "string" && ref.title ? ref.title : "image";
|
||||||
|
const fig = document.createElement("figure");
|
||||||
|
fig.className = "answer-image";
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.className = "answer-image-img";
|
||||||
|
img.src = ref.image_url; // the public bytes route
|
||||||
|
img.loading = "lazy";
|
||||||
|
img.alt = title;
|
||||||
|
img.addEventListener("error", () => fig.remove()); // collapse: never a broken icon
|
||||||
|
const caption = document.createElement("span");
|
||||||
|
caption.className = "answer-image-caption";
|
||||||
|
caption.textContent = title; // the visible caption (always)
|
||||||
|
fig.append(img, caption);
|
||||||
|
body.appendChild(fig);
|
||||||
|
}
|
||||||
|
|
||||||
/* One stored record through the SAME .msg structure the chat page
|
/* One stored record through the SAME .msg structure the chat page
|
||||||
* uses (pixel-parity with the chat page's restore path): user → the
|
* uses (pixel-parity with the chat page's restore path): user → the
|
||||||
* .msg.user bubble (with the question's attached image when the
|
* .msg.user bubble (with the question's attached image when the
|
||||||
@@ -356,9 +400,11 @@ function addBubbleImage(wrap, src, alt) {
|
|||||||
* .msg.brain bubble with the optional
|
* .msg.brain bubble with the optional
|
||||||
* thinking block (restored COLLAPSED — phase 17), the tool lines,
|
* thinking block (restored COLLAPSED — phase 17), the tool lines,
|
||||||
* the deflection treatment + the plain-text "Maybe try" chips, the
|
* the deflection treatment + the plain-text "Maybe try" chips, the
|
||||||
* plain-text source chips, and the stopped note. NO interactive
|
* brain answer's image figure (phase 124, task 06 — a plain
|
||||||
* markup is ever created here — no buttons, no forms, no links, no
|
* non-interactive <figure>), the plain-text source chips, and the
|
||||||
* click handlers (owner-locked: zero controls). Markdown goes
|
* stopped note. NO interactive markup is ever created here — no
|
||||||
|
* buttons, no forms, no links, no click handlers (owner-locked:
|
||||||
|
* zero controls). Markdown goes
|
||||||
* through the global escape-first renderMarkdown (markdown.js): the
|
* through the global escape-first renderMarkdown (markdown.js): the
|
||||||
* stored payloads are raw text, so the renderer's XSS safety applies
|
* stored payloads are raw text, so the renderer's XSS safety applies
|
||||||
* unchanged. */
|
* unchanged. */
|
||||||
@@ -385,6 +431,11 @@ function renderSharedMessage(m) {
|
|||||||
wrap.classList.add("is-deflected");
|
wrap.classList.add("is-deflected");
|
||||||
addMaybeTry(wrap, m.suggestions);
|
addMaybeTry(wrap, m.suggestions);
|
||||||
}
|
}
|
||||||
|
// Phase 124 (task 06, TODO L3): the answer image renders with the
|
||||||
|
// brain bubble — the SAME A4 position as the chat page's render
|
||||||
|
// (below the text, above the chip row). A record without the key
|
||||||
|
// renders byte-identically (no figure DOM).
|
||||||
|
addAnswerImage(wrap, m.answer_image);
|
||||||
addSources(wrap, m.sources);
|
addSources(wrap, m.sources);
|
||||||
if (m.stopped) addStoppedNote(wrap);
|
if (m.stopped) addStoppedNote(wrap);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -814,6 +814,53 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
}
|
}
|
||||||
.source-image:hover { text-decoration: underline; } /* flat — the caption underlines, the chip pattern */
|
.source-image:hover { text-decoration: underline; } /* flat — the caption underlines, the chip pattern */
|
||||||
|
|
||||||
|
/* Phase 124 (task 06, TODO L3): the LLM's ANSWER IMAGE — the brain
|
||||||
|
answer's first-class figure (A4: below the text, above the chips).
|
||||||
|
The .source-image language at ANSWER scale — the figure is the
|
||||||
|
answer's own image, not a chip accessory: max-width 100% of the
|
||||||
|
.msg-body column, a 320px height cap (the 46rem chat column — a
|
||||||
|
full-height image would blow the turn's height), contain on the
|
||||||
|
theme's surface with the bordered-image treatment
|
||||||
|
(.source-image-img's border/background/radius), the visible
|
||||||
|
caption in the AA-safe muted ink (the same --ink-soft the chip
|
||||||
|
caption uses — the row's page background, 8.6:1). Token-only (the
|
||||||
|
zero-literal invariant — the monochrome themes carry it). The
|
||||||
|
hover underline applies to the chat page's ANCHOR figure only (the
|
||||||
|
shared page's figure is a plain <figure> — zero controls, the
|
||||||
|
element-type scope keeps it out). */
|
||||||
|
.answer-image {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.answer-image-img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 320px; /* the one free number (task 06): the answer scale */
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.answer-image-caption {
|
||||||
|
max-width: 100%;
|
||||||
|
color: var(--ink-soft); /* 8.6:1 on --bg (AA) — the row's page background */
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
text-align: center;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3; /* the summary can be long; the alt + title carry the full text */
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
a.answer-image { text-decoration: none; }
|
||||||
|
a.answer-image:hover { text-decoration: underline; } /* flat — the caption underlines, the chip pattern */
|
||||||
|
|
||||||
/* Phase 113 (task 02): the related-docs row — the SECONDARY tier of
|
/* Phase 113 (task 02): the related-docs row — the SECONDARY tier of
|
||||||
scored docs (phase 113 task 01's usefulness bar demotes the
|
scored docs (phase 113 task 01's usefulness bar demotes the
|
||||||
sub-floor hits out of the citation surface; on a deflected turn the
|
sub-floor hits out of the citation surface; on a deflected turn the
|
||||||
|
|||||||
@@ -78,6 +78,13 @@ os.environ["BOR_IMAGES"] = str(_Settings.model_fields["images"].default)
|
|||||||
os.environ["BOR_IMAGE_EXTENSIONS"] = str(_Settings.model_fields["image_extensions"].default)
|
os.environ["BOR_IMAGE_EXTENSIONS"] = str(_Settings.model_fields["image_extensions"].default)
|
||||||
os.environ["BOR_IMAGE_DIR"] = str(_Settings.model_fields["image_dir"].default)
|
os.environ["BOR_IMAGE_DIR"] = str(_Settings.model_fields["image_dir"].default)
|
||||||
|
|
||||||
|
# Phase 124 (task 04): the same leak class for the answer-image toggle
|
||||||
|
# — an operator's local ``.env`` may legitimately carry
|
||||||
|
# ``BOR_ANSWER_IMAGES`` (the dev environment runs the feature on), and
|
||||||
|
# the byte-identical-off pins (the ``show_image`` offer absent, the
|
||||||
|
# ``answer_image`` done-frame key absent) must see the code default.
|
||||||
|
os.environ["BOR_ANSWER_IMAGES"] = str(_Settings.model_fields["answer_images"].default)
|
||||||
|
|
||||||
from app.db import SessionLocal, db_available # noqa: E402
|
from app.db import SessionLocal, db_available # noqa: E402
|
||||||
from app.main import app as fastapi_app # noqa: E402
|
from app.main import app as fastapi_app # noqa: E402
|
||||||
|
|
||||||
|
|||||||
@@ -467,6 +467,56 @@ Implements just enough of the aipi surface:
|
|||||||
the trigger phrase is disjoint from every other trigger's; verified
|
the trigger phrase is disjoint from every other trigger's; verified
|
||||||
2026-09-16: no existing E2E question or fixture file contains the
|
2026-09-16: no existing E2E question or fixture file contains the
|
||||||
phrase, so every other suite is unaffected.
|
phrase, so every other suite is unaffected.
|
||||||
|
- user message containing ``show me the picture of``
|
||||||
|
(``SHOW_IMAGE_TRIGGER``, phase 124 task 07 — the image-chips-and-
|
||||||
|
answers suite ``tests/e2e/test_answer_images.py``) **and** the
|
||||||
|
system prompt contains ``show_image`` (the tool clause's presence
|
||||||
|
— the ``BOR_ANSWER_IMAGES`` toggle's server-side marker: the
|
||||||
|
clause rides inside the HIGH prompt's ``<tools>`` section ONLY
|
||||||
|
when the toggle is on, so on a default-OFF app the trigger NEVER
|
||||||
|
fires and the mock behaves exactly as pre-phase for every other
|
||||||
|
suite) -> the deterministic SHOW_IMAGE flow, discriminated
|
||||||
|
statelessly from the tool results (streaming only): the mock
|
||||||
|
picks the IMAGE document seeded in the ``<documents>`` blocks —
|
||||||
|
the block whose ``path`` attribute ends in an image extension
|
||||||
|
(``app.rag.summarizer.IMAGE_MIMES``' family — the suite's
|
||||||
|
single-PNG fixture guarantees exactly one such block):
|
||||||
|
* request 1 (``tools`` offered, no confirmed show yet): stream
|
||||||
|
ONLY ``tool_calls`` deltas — ``show_image`` with
|
||||||
|
``{"path": <combined source/path>}`` (synthetic id
|
||||||
|
``call_show``, ``finish_reason: "tool_calls"``, no content) —
|
||||||
|
NO ``read`` (the ZERO-READ turn: the image doc's seed block
|
||||||
|
already carries its entire content, so a real model never
|
||||||
|
reads it — the zero-read shape is what makes the bug-fix's
|
||||||
|
chip assertion meaningful);
|
||||||
|
* the follow-up request (a ``tool``-role result carrying the
|
||||||
|
agent's confirming line ``Image shown to the user`` —
|
||||||
|
``_SHOW_CONFIRM_MARKER``) in the messages: the deterministic
|
||||||
|
FINAL answer — a short sentence ending with the ``<documents>``
|
||||||
|
block's tail echo (the phase-24 END_OF_NOTES echo convention —
|
||||||
|
it proves the seed was there), NO further tool calls.
|
||||||
|
Checked BEFORE the plain ``TOOLS_TRIGGER`` flow (disjoint trigger
|
||||||
|
phrase — the phase-71/72/94/118 ordering convention); verified
|
||||||
|
2026-09-26: no existing E2E question or fixture file contains the
|
||||||
|
phrase, so every other suite is unaffected.
|
||||||
|
- user message containing ``which diagram is in your notes``
|
||||||
|
(``DIAGRAM_TRIGGER``, phase 124 task 07 — the BUG-FIX-ONLY
|
||||||
|
trigger, same suite) -> the deterministic ZERO-READ answer: the
|
||||||
|
mock answers straight from the seed with NO tool calls at all
|
||||||
|
(no ``show_image``, no ``read`` — unlike the ``SHOW_IMAGE_
|
||||||
|
TRIGGER`` flow it needs NO clause, so it also fires on a default-
|
||||||
|
OFF app: the chips fix is not gated by ``BOR_ANSWER_IMAGES``,
|
||||||
|
it belongs to the ``BOR_IMAGES``-indexed docs) and QUOTES THE
|
||||||
|
TAIL of the seeded image doc's ``<document>`` block (the same
|
||||||
|
image-doc pick as above): proving the description WAS in the
|
||||||
|
prompt (for an image doc the seed block IS its entire content)
|
||||||
|
and that its chip appears with NO tool calls at all (the phase-
|
||||||
|
124 auto-cite — the fix for the owner's report). 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-26: 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
|
Failure injection (phase 67, LLM retry, TODO.md L3) — deterministic
|
||||||
dead-endpoint behavior for the retry E2E suite (``tests/e2e/
|
dead-endpoint behavior for the retry E2E suite (``tests/e2e/
|
||||||
@@ -580,6 +630,7 @@ from fastapi.responses import JSONResponse, StreamingResponse
|
|||||||
|
|
||||||
from app.rag.agent import ALREADY_IN_CONTEXT, CORRECTION_INSTRUCTION
|
from app.rag.agent import ALREADY_IN_CONTEXT, CORRECTION_INSTRUCTION
|
||||||
from app.rag.folder_summaries import FOLDER_HEADER_PREFIX # phase 94: the mock's key
|
from app.rag.folder_summaries import FOLDER_HEADER_PREFIX # phase 94: the mock's key
|
||||||
|
from app.rag.summarizer import IMAGE_MIMES # phase 124: the image-doc extension family
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
@@ -1756,6 +1807,117 @@ _SUMMARY_SEED_READ_CALL_RE = re.compile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 124 (task 07, image chips and answers, TODO L3): the scripted
|
||||||
|
# SHOW_IMAGE flow + the zero-read bug-fix trigger — see the module
|
||||||
|
# docstring
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: A user message containing this substring (case-insensitive) —
|
||||||
|
#: combined with the ``show_image`` prompt clause in the system
|
||||||
|
#: prompt (the ``BOR_ANSWER_IMAGES`` toggle's server-side marker —
|
||||||
|
#: the clause rides inside the HIGH prompt's ``<tools>`` section ONLY
|
||||||
|
#: when the toggle is on) — drives the deterministic SHOW_IMAGE flow
|
||||||
|
#: (the mock shows the seeded image document and answers straight
|
||||||
|
#: from the seed — ZERO ``read`` calls). On a default-OFF app the
|
||||||
|
#: clause is absent, so the trigger NEVER fires there and the mock
|
||||||
|
#: behaves exactly as pre-phase for every other suite. Disjoint from
|
||||||
|
#: every other trigger phrase — the phase-71/72/94/118 ordering
|
||||||
|
#: convention; verified 2026-09-26: no existing E2E question or
|
||||||
|
#: fixture file contains the phrase, so every other suite is
|
||||||
|
#: unaffected.
|
||||||
|
SHOW_IMAGE_TRIGGER = "show me the picture of"
|
||||||
|
|
||||||
|
#: The phase-124 BUG-FIX-ONLY trigger (case-insensitive): the mock
|
||||||
|
#: answers straight from the seed with ZERO tool calls and QUOTES THE
|
||||||
|
#: TAIL of the seeded image doc's ``<document>`` block — proving the
|
||||||
|
#: description was in the prompt (for an image doc the seed block IS
|
||||||
|
#: its entire content) and that its chip appears with NO tool calls
|
||||||
|
#: at all (the phase-124 auto-cite). Unlike ``SHOW_IMAGE_TRIGGER``
|
||||||
|
#: it needs NO clause — the chips fix is not gated by
|
||||||
|
#: ``BOR_ANSWER_IMAGES`` (it belongs to the ``BOR_IMAGES``-indexed
|
||||||
|
#: docs), so it also fires on a default-OFF app. 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.
|
||||||
|
DIAGRAM_TRIGGER = "which diagram is in your notes"
|
||||||
|
|
||||||
|
#: 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
|
||||||
|
#: keys on it in a ``tool``-role result (stateless discrimination —
|
||||||
|
#: the house marker-flow convention).
|
||||||
|
_SHOW_CONFIRM_MARKER = "Image shown to the user"
|
||||||
|
|
||||||
|
#: The image-doc extension family (``app.rag.summarizer.IMAGE_MIMES``
|
||||||
|
#: — phase 122's ext→mime map, imported so the mock can never drift
|
||||||
|
#: from the app's image family): a seeded ``<documents>`` block is
|
||||||
|
#: the IMAGE document when its ``path`` attribute ends in one of
|
||||||
|
#: these (the suite's single-PNG fixture guarantees exactly one such
|
||||||
|
#: block — the pick is deterministic).
|
||||||
|
_IMAGE_PATH_SUFFIXES = tuple(IMAGE_MIMES)
|
||||||
|
|
||||||
|
|
||||||
|
def _seeded_image_doc(system: str) -> tuple[str, str, str] | None:
|
||||||
|
"""The seeded image doc's ``(source, path, content)`` — or ``None``.
|
||||||
|
|
||||||
|
Phase 124 (task 07): the ``<documents>`` block (the
|
||||||
|
:data:`_DOCUMENT_BLOCK_RE` machinery) whose ``path`` attribute
|
||||||
|
ends in an image extension (:data:`_IMAGE_PATH_SUFFIXES`) is the
|
||||||
|
seeded image document — for an image doc the block's content IS
|
||||||
|
the document's entire text (the vision description — content ==
|
||||||
|
summary, phase 122 task 03). Both of this phase's triggers pick
|
||||||
|
the doc this way (the suite's single-PNG fixture guarantees
|
||||||
|
exactly one such block; ``None`` = a text-only KB, the premise
|
||||||
|
broke — the caller degrades loudly).
|
||||||
|
"""
|
||||||
|
for block in _DOCUMENT_BLOCK_RE.finditer(system):
|
||||||
|
if block.group("path").lower().endswith(_IMAGE_PATH_SUFFIXES):
|
||||||
|
return (block.group("source"), block.group("path"), block.group("content"))
|
||||||
|
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
|
||||||
|
flows:
|
||||||
|
|
||||||
|
* ``("call", combined)`` — the system prompt carries the
|
||||||
|
``show_image`` clause (the toggle-ON gate) and no ``tool``-role
|
||||||
|
result has confirmed the show yet: stream ONLY the
|
||||||
|
``show_image`` tool-call delta on the seeded image doc's
|
||||||
|
combined ``source/path`` (synthetic id ``call_show``,
|
||||||
|
``finish_reason: "tool_calls"``, no content) — NO ``read`` (the
|
||||||
|
zero-read turn).
|
||||||
|
* ``("answer",)`` — a ``tool``-role result carrying the agent's
|
||||||
|
confirming line (:data:`_SHOW_CONFIRM_MARKER`) is in the
|
||||||
|
messages: the deterministic FINAL answer — a short sentence
|
||||||
|
ending with the ``<documents>`` block's tail echo (the phase-24
|
||||||
|
END_OF_NOTES echo convention — it proves the seed was there),
|
||||||
|
NO further tool calls.
|
||||||
|
* ``None`` — not the flow: the trigger phrase is absent, the
|
||||||
|
clause is absent (a default-OFF app — the mock behaves exactly
|
||||||
|
as pre-phase), the turn is deflected (no ``<documents>`` seed
|
||||||
|
— a deflection prompt never carries one), or no seeded image
|
||||||
|
doc exists (a text-only KB — the premise broke; the suite fails
|
||||||
|
loudly on the missing figure).
|
||||||
|
"""
|
||||||
|
if SHOW_IMAGE_TRIGGER not in _user(body).lower():
|
||||||
|
return None
|
||||||
|
system = _system(body)
|
||||||
|
if "show_image" not in system:
|
||||||
|
return None
|
||||||
|
doc = _seeded_image_doc(system)
|
||||||
|
if doc is None:
|
||||||
|
return None
|
||||||
|
if any(_SHOW_CONFIRM_MARKER in r for r in _tool_results(body)):
|
||||||
|
return ("answer",)
|
||||||
|
if not body.get("tools"):
|
||||||
|
return None
|
||||||
|
return ("call", f"{doc[0]}/{doc[1]}")
|
||||||
|
|
||||||
|
|
||||||
def _summary_seed_read_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
|
def _summary_seed_read_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
|
||||||
"""Classify a phase-118 scripted summary-read request (see the
|
"""Classify a phase-118 scripted summary-read request (see the
|
||||||
module docstring). The question carries the scripted call (``read
|
module docstring). The question carries the scripted call (``read
|
||||||
@@ -2280,6 +2442,31 @@ def compose_answer(body: dict[str, Any]) -> str:
|
|||||||
# message, a deflection prompt never carries it, so a marker
|
# message, a deflection prompt never carries it, so a marker
|
||||||
# question always gets the echo whatever the gate says.
|
# question always gets the echo whatever the gate says.
|
||||||
answer = _history_echo(body)
|
answer = _history_echo(body)
|
||||||
|
elif DIAGRAM_TRIGGER in user.lower():
|
||||||
|
# Phase 124 (task 07): the BUG-FIX-ONLY trigger — the mock
|
||||||
|
# answers straight from the seed with ZERO tool calls (no
|
||||||
|
# ``show_image``, no ``read`` — the clause is NOT required,
|
||||||
|
# so the flow also fires on a default-OFF app: the chips fix
|
||||||
|
# belongs to the ``BOR_IMAGES``-indexed docs) and quotes the
|
||||||
|
# TAIL of the seeded image doc's ``<document>`` block (the
|
||||||
|
# same pick as the SHOW_IMAGE flow — the block's content is
|
||||||
|
# the image doc's entire text, the vision description):
|
||||||
|
# proving the description WAS in the prompt and that the chip
|
||||||
|
# appears with NO tool calls at all (the phase-124 auto-cite
|
||||||
|
# — the fix for the owner's report). 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. A text-only KB
|
||||||
|
# (no seeded image doc) degrades to the plain line — the
|
||||||
|
# suite's premise broke; it fails loudly on the missing quote.
|
||||||
|
image_doc = _seeded_image_doc(system)
|
||||||
|
answer = (
|
||||||
|
f"Your notes carry that diagram — it reads: “{image_doc[2][-160:]}” "
|
||||||
|
"(Deterministic mock answer for E2E.)"
|
||||||
|
if image_doc
|
||||||
|
else "Your notes don't carry that diagram — there is nothing to show. "
|
||||||
|
"(Deterministic mock answer for E2E.)"
|
||||||
|
)
|
||||||
elif "DEFLECT_MODE" in system:
|
elif "DEFLECT_MODE" in system:
|
||||||
answer = (
|
answer = (
|
||||||
"Ah — I haven't done anything like that, so I don't want to make stuff up! "
|
"Ah — I haven't done anything like that, so I don't want to make stuff up! "
|
||||||
@@ -2911,6 +3098,42 @@ def chat_completions(body: dict[str, Any]) -> Any:
|
|||||||
media_type="text/event-stream",
|
media_type="text/event-stream",
|
||||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||||
)
|
)
|
||||||
|
# Phase 124 (task 07, image chips and answers, TODO L3): the
|
||||||
|
# scripted SHOW_IMAGE flow — the mock shows the seeded image
|
||||||
|
# document (the ``<documents>`` block with the image-
|
||||||
|
# extension path) and answers straight from the seed: ZERO
|
||||||
|
# ``read`` calls (the zero-read turn that makes the bug-fix's
|
||||||
|
# chip assertion meaningful). Clause-gated — the ``show_image``
|
||||||
|
# teaching line in the system prompt is the
|
||||||
|
# ``BOR_ANSWER_IMAGES`` toggle's presence marker, so on a
|
||||||
|
# default-OFF app the trigger never fires and the mock stays
|
||||||
|
# byte-identical to pre-phase for every other suite. Checked
|
||||||
|
# BEFORE the plain TOOLS_TRIGGER flow (disjoint trigger
|
||||||
|
# phrase — the phase-71/72/94/118 ordering convention; the
|
||||||
|
# trigger needs the clause, so deflected turns never hit it).
|
||||||
|
show_image = _show_image_flow(body)
|
||||||
|
if show_image is not None:
|
||||||
|
if show_image[0] == "call":
|
||||||
|
stream = _tool_call_stream(
|
||||||
|
"show_image", {"path": show_image[1]}, "call_show"
|
||||||
|
)
|
||||||
|
else: # "answer" — the final answer + the tail echo
|
||||||
|
block = _DOCUMENTS_BLOCK_RE.search(_system(body))
|
||||||
|
tail = (block.group(0) if block else _context(body))[-160:]
|
||||||
|
stream = _sse_stream(
|
||||||
|
_apply_max_tokens(
|
||||||
|
"Here is the picture you asked about — …and the very "
|
||||||
|
f"end of my notes reads: “{tail}” "
|
||||||
|
"(Deterministic mock answer for E2E.)",
|
||||||
|
body.get("max_tokens"),
|
||||||
|
),
|
||||||
|
0.0,
|
||||||
|
)
|
||||||
|
return StreamingResponse(
|
||||||
|
stream,
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
# Phase 109 (task 03, never-frozen-turn story suite): the
|
# Phase 109 (task 03, never-frozen-turn story suite): the
|
||||||
# deterministic REPORTED-REPRO turn (delta → tool →
|
# deterministic REPORTED-REPRO turn (delta → tool →
|
||||||
# thinking-after-delta — TODO.md L3): the scripted first answer,
|
# thinking-after-delta — TODO.md L3): the scripted first answer,
|
||||||
|
|||||||
@@ -0,0 +1,971 @@
|
|||||||
|
"""Phase 124 E2E (Playwright): image source chips + LLM-chosen answer images.
|
||||||
|
|
||||||
|
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||||
|
|
||||||
|
uv run pytest tests/e2e/test_answer_images.py -v --no-cov
|
||||||
|
|
||||||
|
The suite proves BOTH halves of TODO.md L3 on the real user path, plus
|
||||||
|
the toggle-off contract — two module apps on dedicated ports (the
|
||||||
|
phase-122 ``test_image_documents.py`` pattern), sharing one deterministic
|
||||||
|
mock LLM and the shared e2e Postgres (truncate/re-import per app):
|
||||||
|
|
||||||
|
* **App A (feature ON)** — ``BOR_IMAGES=true`` +
|
||||||
|
``BOR_ANSWER_IMAGES=true``: the standalone PNG arrives through the
|
||||||
|
REAL admin upload flow + the RAG page's "Sync sources" (the mock's
|
||||||
|
vision model writes the byte-stable description), then:
|
||||||
|
- **Feature flow** — the ``show me the picture of …`` trigger (the
|
||||||
|
mock's phase-124 scripted trigger, ``tests/e2e/mock_llm.py``): the
|
||||||
|
mock scripts ONLY a ``show_image`` call (NO ``read`` — the ZERO-READ
|
||||||
|
turn, the shape that makes the chip assertion meaningful). The
|
||||||
|
brain bubble shows the ANSWER FIGURE (``.answer-image`` — ``<img>``
|
||||||
|
from the bytes route, ``loading="lazy"``, below the text and above
|
||||||
|
the chip row per A4, alt/caption settling to the summary), the
|
||||||
|
answer TEXT is still streamed in full (the tail echo of the
|
||||||
|
``<documents>`` seed proves the description was in the prompt), the
|
||||||
|
source-chip row carries the image doc's chip + its phase-122
|
||||||
|
figure (the AUTO-CITE proof — the mock made zero ``read`` calls,
|
||||||
|
asserted on the mock's request capture), and the "Nearby docs" row
|
||||||
|
does not repeat the doc. The wire pin: the first model request
|
||||||
|
offered FOUR tools (the three base + ``show_image``) and the turn
|
||||||
|
executed exactly one ``show_image`` — the saved brain record
|
||||||
|
persists the ``answer_image`` ref.
|
||||||
|
- **Restore + share** — a second feature turn, then a page RELOAD:
|
||||||
|
the restored brain bubble shows the figure again (the
|
||||||
|
``answer_image`` persistence through the ``bor.chat.v1`` restore
|
||||||
|
path), and the shared link (the existing chat-page Share flow, a
|
||||||
|
fresh anonymous context) renders the figure on the shared page.
|
||||||
|
- **Bug-fix-only flow** — the ``which diagram is in your notes``
|
||||||
|
trigger (the mock answers straight from the seed with ZERO tool
|
||||||
|
calls — no ``show_image``, no ``read``): the source-chip row STILL
|
||||||
|
carries the image doc's chip + figure (the bug fix on the pure
|
||||||
|
zero-read path — the assertion the owner's report describes), the
|
||||||
|
answer text quotes the seeded description's tail, and NO answer
|
||||||
|
figure renders (the model never called the tool).
|
||||||
|
* **App B (default off)** — ``BOR_IMAGES=true``, ``BOR_ANSWER_IMAGES``
|
||||||
|
pinned to the code default ``false`` (EXPLICITLY — process env ranks
|
||||||
|
above an operator's local gitignored ``.env``, so the default-off
|
||||||
|
contract under test cannot leak in, the phase-122 default-app
|
||||||
|
convention; the production default is unset = the same ``False``):
|
||||||
|
the same feature trigger question → the clause is absent from the
|
||||||
|
prompt, so the mock's trigger NEVER fires — the mock never sees
|
||||||
|
``show_image`` among the offered tools (the request capture carries
|
||||||
|
exactly the THREE base tools), the brain bubble has NO answer
|
||||||
|
figure, and the saved brain record carries NO ``answer_image`` key
|
||||||
|
(the byte-identical-off contract). The bug-fix trigger STILL chips
|
||||||
|
the image doc (the chips fix is NOT gated by ``BOR_ANSWER_IMAGES`` —
|
||||||
|
it belongs to the ``BOR_IMAGES``-indexed docs, per the phase design).
|
||||||
|
|
||||||
|
DB isolation: the suites share one Postgres; ``saved_chats`` survives
|
||||||
|
the truncation (the house pattern), so every question text here is
|
||||||
|
UNIQUE (its auto-title is therefore unique) and the row lookups key on
|
||||||
|
it. App B's module seed truncates the KB tables App A's tests used —
|
||||||
|
App B's tests run AFTER App A's (file order) and never look back.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import zipfile
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from playwright.sync_api import Browser, Page, expect
|
||||||
|
from sqlalchemy import select, text
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.models import Document
|
||||||
|
from e2e.auth_helpers import login
|
||||||
|
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
|
||||||
|
from e2e.mock_llm import IMAGE_DESCRIPTION_ANSWER
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
|
GIT_SOURCES_URL = "/git-sources.html"
|
||||||
|
SOURCE_NAME = "e2e-answer-img" # the archive stem (archive_source_name)
|
||||||
|
DOC_PATH = "pic.png"
|
||||||
|
|
||||||
|
#: The mock's deterministic answer tail (every composed answer ends
|
||||||
|
#: with it — the turn-settled wait signal, the house convention).
|
||||||
|
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
|
||||||
|
#: The TAIL of the fixture description (``IMAGE_DESCRIPTION_ANSWER`` —
|
||||||
|
#: the image doc's entire content): both phase-124 mock answers quote
|
||||||
|
#: it (the feature flow's ``<documents>`` tail echo, the bug-fix flow's
|
||||||
|
#: block-content tail) — asserting it proves the SEED was in the
|
||||||
|
#: prompt and reached the rendered answer.
|
||||||
|
DESCRIPTION_TAIL = "Title: Homelab Network Map."
|
||||||
|
|
||||||
|
#: App A (feature ON) — the ``show_image`` trigger question (cosine
|
||||||
|
#: 0.4552 against the mock's token-overlap embeddings, well over the
|
||||||
|
#: mock-calibrated 0.30 gate). Unique auto-title (the DB is shared).
|
||||||
|
FEATURE_QUESTION = "Show me the picture of the homelab network diagram?"
|
||||||
|
#: The restore/share flow's variant (the trigger substring is kept —
|
||||||
|
#: cosine 0.4358, still over the gate).
|
||||||
|
RESTORE_QUESTION = "Show me the picture of the homelab network diagram? (restore)"
|
||||||
|
#: App B (default off) — the feature trigger's variant (the clause is
|
||||||
|
#: absent, so the mock answers with the plain composed answer — cosine
|
||||||
|
#: 0.4187, the turn still grounds).
|
||||||
|
FEATURE_OFF_QUESTION = (
|
||||||
|
"Show me the picture of the homelab network diagram? (toggle off)"
|
||||||
|
)
|
||||||
|
#: The bug-fix-only trigger question (cosine 0.3410): the mock answers
|
||||||
|
#: straight from the seed with ZERO tool calls and quotes the image
|
||||||
|
#: doc's block tail — the chip must appear anyway (the auto-cite).
|
||||||
|
DIAGRAM_QUESTION = "Which diagram is in your notes about the homelab network?"
|
||||||
|
#: App B's bug-fix variant (the chips fix is not toggle-gated — cosine
|
||||||
|
#: 0.3113, still over the mock-calibrated gate).
|
||||||
|
DIAGRAM_OFF_QUESTION = (
|
||||||
|
"Which diagram is in your notes about the homelab network? (default off)"
|
||||||
|
)
|
||||||
|
|
||||||
|
#: A real 1×1 transparent PNG (the unit/integration suites' fixture —
|
||||||
|
#: the pipeline is content-agnostic, the well-formed bytes keep the
|
||||||
|
#: upload + serve + render path honest).
|
||||||
|
PNG_1X1 = base64.b64decode(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
|
||||||
|
"AAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
|
||||||
|
)
|
||||||
|
|
||||||
|
UPLOAD_TIMEOUT_MS = 30_000
|
||||||
|
SYNC_TIMEOUT_MS = 60_000
|
||||||
|
SYNCED_LABEL = re.compile(r"^Synced \d{1,2}:\d{2}$")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Module apps: feature ON (App A) and default-OFF (App B). Each owns
|
||||||
|
# its port + its scratch upload/source/image dirs.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _app_env(
|
||||||
|
mock_llm: int, app_port: int, *, answer_images: str, scratch: Path
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""The conftest app env (leak guards included) with the phase-124
|
||||||
|
knobs: BOTH apps index images (``BOR_IMAGES=true`` — the fixture's
|
||||||
|
PNG must become a document on either), and ``BOR_ANSWER_IMAGES``
|
||||||
|
is pinned EXPLICITLY per app (``true`` for App A, ``false`` for
|
||||||
|
App B — process env ranks above an operator's local gitignored
|
||||||
|
``.env``, so the contract under test cannot leak in either
|
||||||
|
direction, the phase-122 default-app convention; App B's explicit
|
||||||
|
``false`` IS the production default — unset is ``False``). The
|
||||||
|
image/upload homes are in the suite's scratch dir (the app under
|
||||||
|
test must not write image copies into the owner's real
|
||||||
|
``~/bor-sources``)."""
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.pop("DEBUGPY", None)
|
||||||
|
env["BOR_ENVIRONMENT"] = "e2e"
|
||||||
|
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
|
||||||
|
env["BOR_LLM_BASE_URL"] = (
|
||||||
|
"https://aipi.reeseapps.com/v1"
|
||||||
|
if USE_REAL_LLM
|
||||||
|
else f"http://127.0.0.1:{mock_llm}/v1"
|
||||||
|
)
|
||||||
|
# Mock-calibrated gate (conftest pattern) — both trigger questions
|
||||||
|
# ground on these values (see the question constants above).
|
||||||
|
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 124: both apps index images; only the ANSWER side differs.
|
||||||
|
env["BOR_IMAGES"] = "true"
|
||||||
|
env["BOR_ANSWER_IMAGES"] = answer_images
|
||||||
|
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]:
|
||||||
|
"""App A (feature ON) — ``BOR_IMAGES=true`` +
|
||||||
|
``BOR_ANSWER_IMAGES=true`` (the conftest session app is never
|
||||||
|
started in this isolated run, so no port clash)."""
|
||||||
|
scratch = tmp_path_factory.mktemp("bor_answer_img_on")
|
||||||
|
port = int(os.environ.get("E2E_APP_PORT_ANSWER_IMG_ON", "8162"))
|
||||||
|
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, answer_images="true", scratch=scratch),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_wait_http(f"http://127.0.0.1:{port}/api/health")
|
||||||
|
yield f"http://127.0.0.1:{port}"
|
||||||
|
finally:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app_url(app_server: str) -> str:
|
||||||
|
return app_server
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def default_app_server(
|
||||||
|
mock_llm: int, tmp_path_factory: pytest.TempPathFactory
|
||||||
|
) -> Iterator[str]:
|
||||||
|
"""App B (default off) — ``BOR_IMAGES=true`` +
|
||||||
|
``BOR_ANSWER_IMAGES=false`` pinned explicitly (the production
|
||||||
|
default — the toggle-off contract; only the negative tests start
|
||||||
|
it, and they run AFTER App A's — file order)."""
|
||||||
|
scratch = tmp_path_factory.mktemp("bor_answer_img_off")
|
||||||
|
port = int(os.environ.get("E2E_APP_PORT_ANSWER_IMG_OFF", "8163"))
|
||||||
|
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, answer_images="false", scratch=scratch),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_wait_http(f"http://127.0.0.1:{port}/api/health")
|
||||||
|
yield f"http://127.0.0.1:{port}"
|
||||||
|
finally:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def default_app_url(default_app_server: str) -> str:
|
||||||
|
return default_app_server
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def zip_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||||
|
"""The fixture archive: ONE standalone PNG at the root — source
|
||||||
|
``e2e-answer-img``, document path ``pic.png`` (the mock's seeded-
|
||||||
|
image-doc pick is deterministic: the only image-extension block)."""
|
||||||
|
root = tmp_path_factory.mktemp("bor_answer_img_zip")
|
||||||
|
(root / DOC_PATH).write_bytes(PNG_1X1)
|
||||||
|
archive = root / f"{SOURCE_NAME}.zip"
|
||||||
|
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.write(root / DOC_PATH, arcname=DOC_PATH)
|
||||||
|
return archive
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DB + UI + mock-capture helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_all() -> None:
|
||||||
|
"""Fresh KB + registry (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. ``saved_chats`` is never touched by
|
||||||
|
the truncation — the unique question texts keep the row lookups
|
||||||
|
unambiguous, and :func:`_cleanup_saved_rows` owns this suite's
|
||||||
|
rows (re-runnable on the shared DB)."""
|
||||||
|
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 _mock_capture(mock_llm: int) -> list[dict[str, Any]]:
|
||||||
|
"""The mock's recent chat-completions request bodies (oldest
|
||||||
|
first) — the wire lens on the app's tool OFFER and the turn's
|
||||||
|
tool CALLS (the mock is the only E2E lens on the LLM's context)."""
|
||||||
|
r = httpx.get(f"http://127.0.0.1:{mock_llm}/v1/e2e/captured", timeout=5)
|
||||||
|
assert r.status_code == 200
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_capture_reset(mock_llm: int) -> None:
|
||||||
|
r = httpx.post(f"http://127.0.0.1:{mock_llm}/v1/e2e/captured/reset", timeout=5)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def _executed_tool_calls(captured: list[dict[str, Any]]) -> list[str]:
|
||||||
|
"""Every tool call the MODEL made across *captured* requests (the
|
||||||
|
assistant ``tool_calls`` messages), in order — the executed-call
|
||||||
|
lens (a refusal still appears here; it is the model's call)."""
|
||||||
|
return [
|
||||||
|
tc["function"]["name"]
|
||||||
|
for body in captured
|
||||||
|
for m in body["messages"]
|
||||||
|
if m.get("role") == "assistant" and m.get("tool_calls")
|
||||||
|
for tc in m["tool_calls"]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _offered_tool_names(body: dict[str, Any]) -> list[str]:
|
||||||
|
return [t["function"]["name"] for t in (body.get("tools") or [])]
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_zip(page: Page, app_url: str, archive: Path) -> None:
|
||||||
|
"""The real admin upload flow: form login → the git-sources page →
|
||||||
|
pick the archive → submit → the terminal result line (phase 90:
|
||||||
|
"Uploaded <source> — press Sync sources to import it.")."""
|
||||||
|
login(page, app_url, next=GIT_SOURCES_URL)
|
||||||
|
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
|
||||||
|
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||||
|
page.set_input_files("#archive-upload-file", str(archive))
|
||||||
|
page.click("#archive-upload-btn")
|
||||||
|
result = page.locator("#archive-upload-result")
|
||||||
|
expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS)
|
||||||
|
expect(result).to_have_text(
|
||||||
|
f"Uploaded {SOURCE_NAME} — press Sync sources to import it.",
|
||||||
|
timeout=UPLOAD_TIMEOUT_MS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
|
||||||
|
"""The RAG page's "Sync sources" button (the scan — phase 90 A3):
|
||||||
|
click → "Syncing…" → the terminal "Synced HH:MM" label + the fresh
|
||||||
|
counts in #sync-result (the never-stale contract)."""
|
||||||
|
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 _seed_image_doc(
|
||||||
|
app_url: str, browser: Browser, zip_path: Path
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Upload the single-PNG zip through the admin UI, then scan it
|
||||||
|
with the RAG page's Sync button (the mock's vision model describes
|
||||||
|
the image in-process). Returns the seeded doc's identity (the
|
||||||
|
bytes route + the description + the title) for the render
|
||||||
|
assertions. The app's ``/api/config`` flag is asserted first — the
|
||||||
|
env override must have reached the app under test, or the whole
|
||||||
|
premise of the suite is void."""
|
||||||
|
cfg = httpx.get(f"{app_url}/api/config", timeout=5).json()
|
||||||
|
assert cfg["images"] is True, "the module app must run with BOR_IMAGES=true"
|
||||||
|
_truncate_all()
|
||||||
|
page = browser.new_page(viewport={"width": 1280, "height": 800})
|
||||||
|
try:
|
||||||
|
_upload_zip(page, app_url, zip_path)
|
||||||
|
_sync_via_ui(page, app_url, expected_result="1 added")
|
||||||
|
with SessionLocal() as db:
|
||||||
|
doc = db.scalar(
|
||||||
|
select(Document).where(
|
||||||
|
Document.source == SOURCE_NAME, Document.path == DOC_PATH
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert doc is not None, "the uploaded image must become a document"
|
||||||
|
assert doc.is_image is True and doc.image_path is not None
|
||||||
|
# content == summary == the mock's description (the ONLY
|
||||||
|
# embedded text of the doc — the pipeline contract, phase 122).
|
||||||
|
assert doc.content == IMAGE_DESCRIPTION_ANSWER
|
||||||
|
assert doc.summary == IMAGE_DESCRIPTION_ANSWER
|
||||||
|
return {
|
||||||
|
"image_url": f"/api/documents/{doc.id}/image",
|
||||||
|
"description": IMAGE_DESCRIPTION_ANSWER,
|
||||||
|
"title": doc.title,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def seeded(
|
||||||
|
app_url: str,
|
||||||
|
browser: Browser,
|
||||||
|
zip_path: Path,
|
||||||
|
) -> Iterator[dict[str, str]]:
|
||||||
|
"""App A's KB: the fixture image doc through the real UI path."""
|
||||||
|
if not _health_db(app_url):
|
||||||
|
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
||||||
|
_cleanup_saved_rows() # a previous run's rows would be stale (new doc id)
|
||||||
|
yield _seed_image_doc(app_url, browser, zip_path)
|
||||||
|
_cleanup_saved_rows()
|
||||||
|
_truncate_all()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def seeded_off(
|
||||||
|
default_app_url: str,
|
||||||
|
browser: Browser,
|
||||||
|
zip_path: Path,
|
||||||
|
) -> Iterator[dict[str, str]]:
|
||||||
|
"""App B's KB: the SAME fixture image doc through the SAME real
|
||||||
|
UI path (images indexed, the answer feature OFF). Runs AFTER all
|
||||||
|
of App A's tests (file order) — its truncation wipes App A's KB
|
||||||
|
tables, and App B's tests never look back."""
|
||||||
|
if not _health_db(default_app_url):
|
||||||
|
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
||||||
|
_cleanup_saved_rows()
|
||||||
|
yield _seed_image_doc(default_app_url, browser, zip_path)
|
||||||
|
_cleanup_saved_rows()
|
||||||
|
_truncate_all()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Saved-chat + share helpers (the test_share_chat.py conventions)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_cookies(page: Page) -> dict[str, str]:
|
||||||
|
"""The signed session cookies the browser holds after a form login
|
||||||
|
— used to call the admin API with plain httpx (the test's API side
|
||||||
|
sees exactly what the signed-in browser sees)."""
|
||||||
|
return {
|
||||||
|
c["name"]: c["value"]
|
||||||
|
for c in page.context.cookies()
|
||||||
|
if "name" in c and "value" in c
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_title(question: str) -> str:
|
||||||
|
"""The phase-50 auto-title convention: the first question,
|
||||||
|
whitespace-collapsed, capped at 120 chars."""
|
||||||
|
return " ".join(question.split())[:120]
|
||||||
|
|
||||||
|
|
||||||
|
#: This suite's auto-titles (the shared e2e Postgres keeps
|
||||||
|
#: ``saved_chats`` across runs — the test_share_chat cleanup
|
||||||
|
#: concern): a re-run must not find the previous run's row (same
|
||||||
|
#: title, but the KB re-seed minted a NEW doc id — the row's
|
||||||
|
#: ``answer_image.image_url`` would no longer match the seed).
|
||||||
|
_SUITE_TITLES = tuple(
|
||||||
|
_auto_title(q)
|
||||||
|
for q in (
|
||||||
|
FEATURE_QUESTION,
|
||||||
|
RESTORE_QUESTION,
|
||||||
|
FEATURE_OFF_QUESTION,
|
||||||
|
DIAGRAM_QUESTION,
|
||||||
|
DIAGRAM_OFF_QUESTION,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_saved_rows() -> None:
|
||||||
|
"""Delete THIS suite's saved rows by title (the house pattern —
|
||||||
|
test_share_chat deletes its own rows; here the module-boundary
|
||||||
|
fixture calls it, so a re-run starts clean). Best-effort: a
|
||||||
|
failed delete (DB unreachable — the skip case) never masks the
|
||||||
|
skip; a failed delete with the DB up just re-creates the stale-
|
||||||
|
row premise, and the suite fails loudly on the row assertion."""
|
||||||
|
try:
|
||||||
|
with SessionLocal() as db:
|
||||||
|
db.execute(
|
||||||
|
text("DELETE FROM saved_chats WHERE title = ANY(:titles)"),
|
||||||
|
{"titles": list(_SUITE_TITLES)},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except Exception: # noqa: BLE001 — DB unreachable: the skip handles it
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_saved_row(
|
||||||
|
app_url: str,
|
||||||
|
cookies: dict[str, str],
|
||||||
|
title: str,
|
||||||
|
messages: int = 2,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Wait for the auto-saved row (phase 55: auto-saves are SILENT —
|
||||||
|
A2 — so there is no status line to wait on). The upsert is
|
||||||
|
fire-and-forget from the UI's point of view, so poll the admin
|
||||||
|
list until the row with the conversation's auto-title appears with
|
||||||
|
the expected message count."""
|
||||||
|
deadline = time.monotonic() + 15
|
||||||
|
last: dict[str, Any] | None = None
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
|
||||||
|
assert r.status_code == 200
|
||||||
|
rows = r.json()["chats"]
|
||||||
|
last = next((c for c in rows if c["title"] == title), None)
|
||||||
|
if last is not None and last["message_count"] >= messages:
|
||||||
|
return last
|
||||||
|
time.sleep(0.2)
|
||||||
|
raise AssertionError(f"no auto-saved row for {title!r} (last: {last!r})")
|
||||||
|
|
||||||
|
|
||||||
|
def _chat_messages(
|
||||||
|
app_url: str, cookies: dict[str, str], chat_id: str
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
r = httpx.get(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
|
||||||
|
assert r.status_code == 200
|
||||||
|
return r.json()["messages"]
|
||||||
|
|
||||||
|
|
||||||
|
def _brain_record(messages: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
|
brains = [m for m in messages if m.get("who") == "brain"]
|
||||||
|
assert brains, f"no brain record in the saved conversation: {messages!r}"
|
||||||
|
return brains[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def _grant_clipboard(page: Page, app_url: str) -> None:
|
||||||
|
"""Grant the async-clipboard permissions on the admin context
|
||||||
|
(test_share_chat.py — headless Chromium still requires the grant
|
||||||
|
before ``writeText`` resolves on a secure origin)."""
|
||||||
|
page.context.grant_permissions(
|
||||||
|
["clipboard-read", "clipboard-write"], origin=app_url
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _click_share(app_url: str, page: Page) -> None:
|
||||||
|
"""Press the chat page's Share pill and pin the owner-locked
|
||||||
|
outcome on the live region (the test_share_chat.py branch —
|
||||||
|
clipboard success vs the inline-link fallback)."""
|
||||||
|
page.locator("#share-chat-btn").click()
|
||||||
|
if page.evaluate("() => !!navigator.clipboard"):
|
||||||
|
expect(page.locator("#send-status")).to_have_text(
|
||||||
|
"Share link copied.", timeout=15_000
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
expect(page.locator("#send-status")).to_have_text(
|
||||||
|
"Share link ready — copy it from the field.", timeout=15_000
|
||||||
|
)
|
||||||
|
expect(page.locator(".share-link-fallback")).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_answer_figure(
|
||||||
|
page: Page,
|
||||||
|
app_url: str,
|
||||||
|
seeded: dict[str, str],
|
||||||
|
*,
|
||||||
|
caption: str,
|
||||||
|
last_brain: str = ".msg.brain",
|
||||||
|
) -> None:
|
||||||
|
"""The A4 answer-figure contract in one place (the live, restore,
|
||||||
|
and shared surfaces share the shape — the shared page's figure is
|
||||||
|
a plain ``<figure>`, the chat page's an anchor, but the inner
|
||||||
|
``img``/caption classes are the same): exactly ONE ``.answer-image``
|
||||||
|
in the brain bubble, its ``<img>`` from the bytes route
|
||||||
|
(``loading="lazy"``, visible — the bytes really serve), and the
|
||||||
|
caption/alt settled to *caption* (the summary on the admin
|
||||||
|
surfaces, the title on the guest one)."""
|
||||||
|
fig = page.locator(f"{last_brain} .answer-image")
|
||||||
|
expect(fig).to_have_count(1)
|
||||||
|
img = fig.locator("img")
|
||||||
|
expect(img).to_have_count(1)
|
||||||
|
expect(img).to_be_visible(timeout=15_000)
|
||||||
|
expect(img).to_have_attribute("src", seeded["image_url"])
|
||||||
|
expect(img).to_have_attribute("loading", "lazy")
|
||||||
|
expect(fig.locator(".answer-image-caption")).to_have_text(
|
||||||
|
caption, timeout=15_000
|
||||||
|
)
|
||||||
|
# The served bytes are a real image (the public bytes route).
|
||||||
|
resp = httpx.get(app_url + seeded["image_url"], timeout=10)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers["content-type"].startswith("image/")
|
||||||
|
assert resp.content == PNG_1X1
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_image_chip(
|
||||||
|
page: Page, seeded: dict[str, str], *, last_brain: str = ".msg.brain"
|
||||||
|
) -> None:
|
||||||
|
"""The citation chip the AUTO-CITE earns (phase 124 task 05): the
|
||||||
|
image doc's text chip + its phase-122 compact figure (``<img>``
|
||||||
|
from the bytes route, alt settling to the summary — the admin
|
||||||
|
surface; the shared page's chip is plain text and asserted
|
||||||
|
separately)."""
|
||||||
|
chip = page.locator(f"{last_brain} .source-chip")
|
||||||
|
expect(chip).to_have_count(1)
|
||||||
|
expect(chip).to_have_text(f"{SOURCE_NAME}/{DOC_PATH}")
|
||||||
|
fig = page.locator(f"{last_brain} .source-image")
|
||||||
|
expect(fig).to_have_count(1)
|
||||||
|
img = fig.locator(".source-image-img")
|
||||||
|
expect(img).to_be_visible(timeout=15_000)
|
||||||
|
expect(img).to_have_attribute("src", seeded["image_url"])
|
||||||
|
expect(img).to_have_attribute("alt", seeded["description"], timeout=15_000)
|
||||||
|
|
||||||
|
|
||||||
|
def _body_child_classes(page: Page) -> list[str]:
|
||||||
|
"""The LAST brain bubble's ``.msg-body`` direct children's class
|
||||||
|
names, in DOM order — the A4 position pin (bubble < answer-image
|
||||||
|
< msg-meta)."""
|
||||||
|
return page.evaluate(
|
||||||
|
"""() => {
|
||||||
|
const body = [...document.querySelectorAll(".msg.brain")].pop()
|
||||||
|
.querySelector(".msg-body");
|
||||||
|
return [...body.children].map((el) => el.className);
|
||||||
|
}"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. App A (feature ON) — the feature flow: the scripted show_image
|
||||||
|
# turn (ZERO reads) renders the answer figure + the auto-cited chip
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_feature_flow_answer_figure_chips_and_zero_reads(
|
||||||
|
page: Page, app_url: str, seeded: dict[str, str], mock_llm: int
|
||||||
|
) -> None:
|
||||||
|
"""The ``show me the picture of …`` trigger: the mock scripts ONLY
|
||||||
|
a ``show_image`` call (NO ``read`` — the zero-read turn). Asserts:
|
||||||
|
(1) the brain bubble's ``.msg-body`` carries ``.answer-image`` with
|
||||||
|
an ``<img>`` from the bytes route (200, image Content-Type) and
|
||||||
|
``loading="lazy"``; (2) the figure sits BEFORE the ``.msg-meta``
|
||||||
|
source-chip row (A4); (3) the answer TEXT is non-empty and quotes
|
||||||
|
the seed's tail (the "summaries still provided" contract + the
|
||||||
|
echo proving the seed was in the prompt); (4) the source-chip row
|
||||||
|
carries the image doc's chip + its figure (the AUTO-CITE proof —
|
||||||
|
the mock made ZERO ``read`` calls: only ``show_image`` — asserted
|
||||||
|
on the wire); (5) the "Nearby docs" row does NOT repeat the doc.
|
||||||
|
Plus the persistence pin: the saved brain record carries the
|
||||||
|
``answer_image`` ref (the task-04 frame field, round-tripped)."""
|
||||||
|
login(page, app_url, next="/")
|
||||||
|
expect(page.locator("#kb-banner")).to_be_hidden()
|
||||||
|
|
||||||
|
_mock_capture_reset(mock_llm)
|
||||||
|
page.fill("#message-input", FEATURE_QUESTION)
|
||||||
|
page.click("#send-btn")
|
||||||
|
|
||||||
|
bubble = page.locator(".msg.brain .bubble").last
|
||||||
|
# The answer TEXT is non-empty (the "summaries still provided"
|
||||||
|
# contract) — the tail echo lands LAST (the terminal signal): it
|
||||||
|
# quotes the <documents> seed's tail, i.e. the description.
|
||||||
|
expect(bubble).to_contain_text(DESCRIPTION_TAIL, timeout=30_000)
|
||||||
|
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
|
||||||
|
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
|
||||||
|
expect(page.locator("#send-label")).to_have_text("Send")
|
||||||
|
|
||||||
|
# (1) The answer figure: the bytes route, lazy, alt/caption the
|
||||||
|
# summary (the fetchContentSummary pattern settles both).
|
||||||
|
_assert_answer_figure(page, app_url, seeded, caption=seeded["description"])
|
||||||
|
|
||||||
|
# (2) A4 position: below the answer text, above the chip row.
|
||||||
|
classes = _body_child_classes(page)
|
||||||
|
assert "bubble" in classes, f"no answer bubble in .msg-body: {classes}"
|
||||||
|
assert "answer-image" in classes, f"no .answer-image in .msg-body: {classes}"
|
||||||
|
assert "msg-meta" in classes, f"no chip row in .msg-body: {classes}"
|
||||||
|
assert (
|
||||||
|
classes.index("bubble")
|
||||||
|
< classes.index("answer-image")
|
||||||
|
< classes.index("msg-meta")
|
||||||
|
), f"A4 order violated (bubble < answer-image < msg-meta): {classes}"
|
||||||
|
|
||||||
|
# (4) The chip row carries the image doc's chip + its figure —
|
||||||
|
# the AUTO-CITE (the model never read it; the seed IS its
|
||||||
|
# entire content, so suggested == read for it).
|
||||||
|
_assert_image_chip(page, seeded)
|
||||||
|
|
||||||
|
# (5) The "Nearby docs" row does NOT repeat the image doc — the
|
||||||
|
# related tier excludes cited members (the single-doc KB has
|
||||||
|
# no related tier at all: the row is absent).
|
||||||
|
expect(page.locator(".msg.brain .related-docs")).to_have_count(0)
|
||||||
|
|
||||||
|
# The WIRE pin: exactly two model calls (the show_image round +
|
||||||
|
# the final answer), the FIRST request offered the FOUR tools
|
||||||
|
# (three base + show_image — the toggle-ON offer gate), and the
|
||||||
|
# turn executed EXACTLY one tool call: show_image — NO read (the
|
||||||
|
# zero-read turn that makes the chip assertion meaningful).
|
||||||
|
captured = _mock_capture(mock_llm)
|
||||||
|
assert len(captured) == 2, (
|
||||||
|
f"expected exactly 2 model calls (call + answer), got {len(captured)}"
|
||||||
|
)
|
||||||
|
assert _offered_tool_names(captured[0]) == ["ls", "read", "grep", "show_image"]
|
||||||
|
assert _executed_tool_calls(captured) == ["show_image"]
|
||||||
|
tool_results = [
|
||||||
|
m.get("content") or ""
|
||||||
|
for m in captured[1]["messages"]
|
||||||
|
if m.get("role") == "tool"
|
||||||
|
]
|
||||||
|
assert any("Image shown to the user" in r for r in tool_results), (
|
||||||
|
f"the confirming tool result never reached the model: {tool_results}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The persistence pin: the auto-saved brain record carries the
|
||||||
|
# answer_image ref (source/path/title/image_url — the task-04
|
||||||
|
# frame field; the restore + shared flows assert on its render).
|
||||||
|
row = _wait_saved_row(app_url, _admin_cookies(page), _auto_title(FEATURE_QUESTION))
|
||||||
|
record = _brain_record(_chat_messages(app_url, _admin_cookies(page), str(row["id"])))
|
||||||
|
assert record.get("answer_image") == {
|
||||||
|
"source": SOURCE_NAME,
|
||||||
|
"path": DOC_PATH,
|
||||||
|
"title": seeded["title"],
|
||||||
|
"image_url": seeded["image_url"],
|
||||||
|
}, f"the brain record's answer_image is wrong: {record.get('answer_image')!r}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. App A — reload restores the figure; the shared page shows it
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_reload_restores_figure_and_shared_page_shows_it(
|
||||||
|
page: Page,
|
||||||
|
app_url: str,
|
||||||
|
seeded: dict[str, str],
|
||||||
|
mock_llm: int,
|
||||||
|
browser: Browser,
|
||||||
|
) -> None:
|
||||||
|
"""A second feature turn (a unique question — unique auto-title on
|
||||||
|
the shared DB), then a page RELOAD: the restored brain bubble
|
||||||
|
shows the figure again (the ``answer_image`` persistence through
|
||||||
|
the ``bor.chat.v1`` restore path — pixel-parity with the live
|
||||||
|
render), and the existing Share flow's link — opened in a FRESH
|
||||||
|
anonymous context — renders the figure on the shared page (the
|
||||||
|
bytes route is public; the guest caption keeps the title — the
|
||||||
|
content endpoint is user-gated, so the summary fetch is skipped
|
||||||
|
there)."""
|
||||||
|
login(page, app_url, next="/")
|
||||||
|
expect(page.locator("#kb-banner")).to_be_hidden()
|
||||||
|
|
||||||
|
_mock_capture_reset(mock_llm)
|
||||||
|
page.fill("#message-input", RESTORE_QUESTION)
|
||||||
|
page.click("#send-btn")
|
||||||
|
|
||||||
|
bubble = page.locator(".msg.brain .bubble").last
|
||||||
|
expect(bubble).to_contain_text(DESCRIPTION_TAIL, timeout=30_000)
|
||||||
|
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
|
||||||
|
expect(page.locator("#send-label")).to_have_text("Send")
|
||||||
|
_assert_answer_figure(page, app_url, seeded, caption=seeded["description"])
|
||||||
|
_assert_image_chip(page, seeded)
|
||||||
|
|
||||||
|
# The auto-save settled (the row exists with both messages) before
|
||||||
|
# the reload — the Share click later shares the linked row, or
|
||||||
|
# re-creates it unlinked (either path is owner-locked; the row
|
||||||
|
# lookup keys on the unique auto-title).
|
||||||
|
cookies = _admin_cookies(page)
|
||||||
|
_wait_saved_row(app_url, cookies, _auto_title(RESTORE_QUESTION))
|
||||||
|
|
||||||
|
# RELOAD: the restored brain bubble shows the figure again — the
|
||||||
|
# localStorage restore (rememberBrainTurn's save point ran
|
||||||
|
# synchronously in the done handler, before the send button
|
||||||
|
# re-enabled, so the record is durable before we reload).
|
||||||
|
page.reload()
|
||||||
|
expect(page.locator("#kb-banner")).to_be_hidden()
|
||||||
|
restored = page.locator(".msg.brain .bubble").last
|
||||||
|
expect(restored).to_contain_text(DESCRIPTION_TAIL, timeout=30_000)
|
||||||
|
expect(restored).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
|
||||||
|
_assert_answer_figure(page, app_url, seeded, caption=seeded["description"])
|
||||||
|
_assert_image_chip(page, seeded)
|
||||||
|
|
||||||
|
# SHARE: the existing chat-page Share flow → a fresh anonymous
|
||||||
|
# context sees the figure (and the plain-text chip).
|
||||||
|
_grant_clipboard(page, app_url)
|
||||||
|
_click_share(app_url, page)
|
||||||
|
# The share landed on the pre-existing row (the linked branch —
|
||||||
|
# the idempotent share) or a fresh row (the unlinked save-and-
|
||||||
|
# share) — either carries the token; the unique auto-title keeps
|
||||||
|
# the lookup unambiguous on the shared DB.
|
||||||
|
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
|
||||||
|
rows = [
|
||||||
|
c for c in r.json()["chats"]
|
||||||
|
if c["title"] == _auto_title(RESTORE_QUESTION)
|
||||||
|
]
|
||||||
|
token_row = next((c for c in rows if c.get("share_url")), None)
|
||||||
|
assert token_row is not None and token_row.get("share_url"), (
|
||||||
|
f"no share_url on any shared row: {rows!r}"
|
||||||
|
)
|
||||||
|
share_link = str(token_row["share_url"])
|
||||||
|
assert share_link.startswith("/shared/")
|
||||||
|
|
||||||
|
anon = browser.new_page(viewport={"width": 1280, "height": 800})
|
||||||
|
try:
|
||||||
|
anon.goto(app_url + share_link)
|
||||||
|
expect(anon.locator("#shared-title")).to_have_text(
|
||||||
|
_auto_title(RESTORE_QUESTION), timeout=30_000
|
||||||
|
)
|
||||||
|
expect(anon.locator(".msg.brain .bubble").last).to_contain_text(
|
||||||
|
DESCRIPTION_TAIL, timeout=30_000
|
||||||
|
)
|
||||||
|
# The shared page renders the figure — the guest caption keeps
|
||||||
|
# the TITLE (no summary fetch — the content endpoint is
|
||||||
|
# user-gated), the img is from the public bytes route.
|
||||||
|
_assert_answer_figure(anon, app_url, seeded, caption=seeded["title"])
|
||||||
|
# The shared chip is PLAIN TEXT (guests cannot open documents):
|
||||||
|
chip = anon.locator(".msg.brain .source-chip")
|
||||||
|
expect(chip).to_have_count(1)
|
||||||
|
expect(chip).to_have_text(f"{SOURCE_NAME}/{DOC_PATH}")
|
||||||
|
# The shared page is zero-controls: the figure is a plain
|
||||||
|
# <figure>, never an anchor (the owner-locked contract).
|
||||||
|
assert anon.locator(".msg.brain a.answer-image").count() == 0
|
||||||
|
finally:
|
||||||
|
anon.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. App A — the bug-fix-only flow: ZERO tool calls, the chip STILL
|
||||||
|
# appears (the pure zero-read path — the owner's report)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_bugfix_only_flow_zero_tool_calls_still_chips(
|
||||||
|
page: Page, app_url: str, seeded: dict[str, str], mock_llm: int
|
||||||
|
) -> None:
|
||||||
|
"""The ``which diagram is in your notes`` trigger: the mock
|
||||||
|
answers straight from the seed with ZERO tool calls (no
|
||||||
|
``show_image`` — the clause's flow is not triggered — and no
|
||||||
|
``read``): the source-chip row STILL carries the image doc's chip
|
||||||
|
+ figure (the bug fix on the pure zero-read path — the assertion
|
||||||
|
the owner's report describes), the answer text quotes the seeded
|
||||||
|
description's tail (the description WAS in the prompt), and NO
|
||||||
|
answer figure renders (the model never called the tool)."""
|
||||||
|
login(page, app_url, next="/")
|
||||||
|
expect(page.locator("#kb-banner")).to_be_hidden()
|
||||||
|
|
||||||
|
_mock_capture_reset(mock_llm)
|
||||||
|
page.fill("#message-input", DIAGRAM_QUESTION)
|
||||||
|
page.click("#send-btn")
|
||||||
|
|
||||||
|
bubble = page.locator(".msg.brain .bubble").last
|
||||||
|
# The answer quotes the seeded image doc's block tail (the
|
||||||
|
# description's end — the seed was in the prompt) and settles.
|
||||||
|
expect(bubble).to_contain_text(DESCRIPTION_TAIL, timeout=30_000)
|
||||||
|
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
|
||||||
|
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
|
||||||
|
expect(page.locator("#send-label")).to_have_text("Send")
|
||||||
|
|
||||||
|
# ZERO tool calls — on the wire (exactly ONE model call, no
|
||||||
|
# tool_calls message at all) and in the DOM (no tool record).
|
||||||
|
captured = _mock_capture(mock_llm)
|
||||||
|
assert len(captured) == 1, (
|
||||||
|
f"the bug-fix flow must make exactly 1 model call, got {len(captured)}"
|
||||||
|
)
|
||||||
|
assert _executed_tool_calls(captured) == [], (
|
||||||
|
f"the bug-fix flow must make ZERO tool calls: {_executed_tool_calls(captured)}"
|
||||||
|
)
|
||||||
|
expect(page.locator(".msg.brain .tool-calls-disclosure")).to_have_count(0)
|
||||||
|
|
||||||
|
# NO answer figure (the model never called show_image) — but the
|
||||||
|
# chip row STILL carries the image doc's chip + figure (the auto-
|
||||||
|
# cite: suggested == read for an image doc, no read performed).
|
||||||
|
expect(page.locator(".msg.brain .answer-image")).to_have_count(0)
|
||||||
|
_assert_image_chip(page, seeded)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. App B (default off) — the toggle-off contract: no show_image
|
||||||
|
# offered, no answer figure, no answer_image key in the record
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_off_flow_no_tool_no_answer_image_key(
|
||||||
|
page: Page,
|
||||||
|
default_app_url: str,
|
||||||
|
seeded_off: dict[str, str],
|
||||||
|
mock_llm: int,
|
||||||
|
) -> None:
|
||||||
|
"""The same feature trigger question on the default-OFF app: the
|
||||||
|
prompt clause is ABSENT, so the mock's trigger never fires (the
|
||||||
|
plain composed answer streams instead) — and the contract holds
|
||||||
|
at every surface: the mock NEVER sees ``show_image`` among the
|
||||||
|
offered tools (the request capture carries exactly the THREE base
|
||||||
|
tools), the brain bubble has NO ``.answer-image``, and the saved
|
||||||
|
brain record carries NO ``answer_image`` key (the byte-identical-
|
||||||
|
off contract — the key is absent, never null)."""
|
||||||
|
login(page, default_app_url, next="/")
|
||||||
|
expect(page.locator("#kb-banner")).to_be_hidden()
|
||||||
|
|
||||||
|
_mock_capture_reset(mock_llm)
|
||||||
|
page.fill("#message-input", FEATURE_OFF_QUESTION)
|
||||||
|
page.click("#send-btn")
|
||||||
|
|
||||||
|
bubble = page.locator(".msg.brain .bubble").last
|
||||||
|
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
|
||||||
|
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
|
||||||
|
expect(page.locator("#send-label")).to_have_text("Send")
|
||||||
|
|
||||||
|
# No answer figure anywhere in the brain bubble.
|
||||||
|
expect(page.locator(".msg.brain .answer-image")).to_have_count(0)
|
||||||
|
|
||||||
|
# The WIRE pin: the offer list is EXACTLY the three base tools
|
||||||
|
# (no show_image — the toggle-OFF gate on the offer), and the
|
||||||
|
# turn made no tool calls at all (the mock's feature trigger is
|
||||||
|
# clause-gated — it never fired, so the plain answer streamed in
|
||||||
|
# one call).
|
||||||
|
captured = _mock_capture(mock_llm)
|
||||||
|
assert len(captured) == 1, (
|
||||||
|
f"expected exactly 1 model call (no tool round), got {len(captured)}"
|
||||||
|
)
|
||||||
|
assert _offered_tool_names(captured[0]) == ["ls", "read", "grep"], (
|
||||||
|
f"show_image leaked into the default-off offer: "
|
||||||
|
f"{_offered_tool_names(captured[0])}"
|
||||||
|
)
|
||||||
|
assert _executed_tool_calls(captured) == []
|
||||||
|
|
||||||
|
# The saved brain record carries NO answer_image key (ABSENT — the
|
||||||
|
# byte-identical-off contract; the phase-113 422-silence pin is
|
||||||
|
# the positive half of this, in the App A flow + the integration
|
||||||
|
# suite). The chip, though, IS still auto-cited (the chips fix is
|
||||||
|
# not toggle-gated — asserted in the next test).
|
||||||
|
row = _wait_saved_row(
|
||||||
|
default_app_url,
|
||||||
|
_admin_cookies(page),
|
||||||
|
_auto_title(FEATURE_OFF_QUESTION),
|
||||||
|
)
|
||||||
|
record = _brain_record(
|
||||||
|
_chat_messages(default_app_url, _admin_cookies(page), str(row["id"]))
|
||||||
|
)
|
||||||
|
assert "answer_image" not in record, (
|
||||||
|
f"the default-off brain record must NOT carry answer_image: "
|
||||||
|
f"{record.get('answer_image')!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. App B (default off) — the bug-fix trigger STILL chips the image
|
||||||
|
# doc (the chips fix belongs to BOR_IMAGES, not BOR_ANSWER_IMAGES)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_off_bugfix_trigger_still_chips(
|
||||||
|
page: Page, default_app_url: str, seeded_off: dict[str, str], mock_llm: int
|
||||||
|
) -> None:
|
||||||
|
"""The bug-fix trigger on the default-OFF app (the clause is
|
||||||
|
absent — the trigger does NOT require it): ZERO tool calls, the
|
||||||
|
answer quotes the seeded description's tail, and the source-chip
|
||||||
|
row STILL carries the image doc's chip + figure — the chips fix
|
||||||
|
is NOT gated by ``BOR_ANSWER_IMAGES`` (it belongs to the
|
||||||
|
``BOR_IMAGES``-indexed docs, per the phase design)."""
|
||||||
|
login(page, default_app_url, next="/")
|
||||||
|
expect(page.locator("#kb-banner")).to_be_hidden()
|
||||||
|
|
||||||
|
_mock_capture_reset(mock_llm)
|
||||||
|
page.fill("#message-input", DIAGRAM_OFF_QUESTION)
|
||||||
|
page.click("#send-btn")
|
||||||
|
|
||||||
|
bubble = page.locator(".msg.brain .bubble").last
|
||||||
|
expect(bubble).to_contain_text(DESCRIPTION_TAIL, timeout=30_000)
|
||||||
|
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
|
||||||
|
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
|
||||||
|
expect(page.locator("#send-label")).to_have_text("Send")
|
||||||
|
|
||||||
|
captured = _mock_capture(mock_llm)
|
||||||
|
assert len(captured) == 1
|
||||||
|
assert _executed_tool_calls(captured) == []
|
||||||
|
expect(page.locator(".msg.brain .tool-calls-disclosure")).to_have_count(0)
|
||||||
|
expect(page.locator(".msg.brain .answer-image")).to_have_count(0)
|
||||||
|
_assert_image_chip(page, seeded_off)
|
||||||
@@ -2507,3 +2507,286 @@ def test_shared_chat_serves_the_user_image_path(
|
|||||||
finally:
|
finally:
|
||||||
db.execute(text("TRUNCATE saved_chats"))
|
db.execute(text("TRUNCATE saved_chats"))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 124, task 04 — the done frame's OPTIONAL answer_image (the
|
||||||
|
# shown document's ref: the LLM's ``show_image`` pick) + the byte-
|
||||||
|
# identical-off contract (every other turn: no key, no null).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def answer_images_on(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""The chat endpoint's settings view with ``answer_images`` ON
|
||||||
|
(the ``BOR_ANSWER_IMAGES`` feature path; the monkeypatch fixture
|
||||||
|
reverts the patch, the conftest leak-guard pins the default OFF
|
||||||
|
for every other test in the module)."""
|
||||||
|
settings = Settings(
|
||||||
|
_env_file=None, # pyright: ignore[reportCallIssue]
|
||||||
|
answer_images=True,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(chat_api, "get_settings", lambda: settings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_image_turn_done_frame_carries_answer_image(
|
||||||
|
client, db, seeded_kb: FakeRagLLM, answer_images_on: None
|
||||||
|
) -> None:
|
||||||
|
"""A grounded turn whose mock client scripts a ``show_image`` tool
|
||||||
|
call on a seeded image doc (the scripted-tool mechanism — the
|
||||||
|
``use your tools`` precedent) → the final SSE ``done`` frame JSON
|
||||||
|
carries ``answer_image`` with ``image_url ==
|
||||||
|
/api/documents/<id>/image`` and the cited ``sources`` include the
|
||||||
|
same doc (its scripted read — the phase-119 citation tier)."""
|
||||||
|
doc = _seed_image_doc(db)
|
||||||
|
scripted = FakeRagLLM(
|
||||||
|
tool_script=[
|
||||||
|
[
|
||||||
|
ToolCallPiece(
|
||||||
|
id="call_1",
|
||||||
|
name="read",
|
||||||
|
arguments={"path": "docs/pic.png"},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
ToolCallPiece(
|
||||||
|
id="call_2",
|
||||||
|
name="show_image",
|
||||||
|
arguments={"path": "docs/pic.png"},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
|
||||||
|
try:
|
||||||
|
_, _, frames = _stream_chat(client, QUESTION)
|
||||||
|
finally:
|
||||||
|
fastapi_app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
done = frames[-1]
|
||||||
|
assert done["deflected"] is False
|
||||||
|
assert done["answer_image"] == {
|
||||||
|
"source": "docs",
|
||||||
|
"path": "pic.png",
|
||||||
|
"title": "pic",
|
||||||
|
"image_url": f"/api/documents/{doc.id}/image",
|
||||||
|
}
|
||||||
|
# The cited tier includes the same doc (the scripted read).
|
||||||
|
assert ("docs", "pic.png") in [(s["source"], s["path"]) for s in done["sources"]]
|
||||||
|
# The toggle was honoured on the wire: the first (agent) request
|
||||||
|
# offered the 4th tool.
|
||||||
|
offered = scripted.seen_tools[0]
|
||||||
|
assert offered is not None
|
||||||
|
assert [t["function"]["name"] for t in offered] == [
|
||||||
|
"ls",
|
||||||
|
"read",
|
||||||
|
"grep",
|
||||||
|
"show_image",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_off_same_flow_offers_three_tools_and_no_answer_image_key(
|
||||||
|
client, db, seeded_kb: FakeRagLLM
|
||||||
|
) -> None:
|
||||||
|
"""The SAME scripted flow with ``answer_images=False`` (the
|
||||||
|
default) → ``show_image`` is NOT in the offered tools (the mock's
|
||||||
|
recorded request carries exactly the three base tools) and the
|
||||||
|
``done`` frame has NO ``answer_image`` key — the feature's
|
||||||
|
server-side gate is the OFFER (a model that was never offered the
|
||||||
|
tool never calls it, so no such call occurs in the flow)."""
|
||||||
|
_seed_image_doc(db) # resolvable, but the tool is never offered
|
||||||
|
scripted = FakeRagLLM(
|
||||||
|
tool_script=[
|
||||||
|
[
|
||||||
|
ToolCallPiece(
|
||||||
|
id="call_1",
|
||||||
|
name="read",
|
||||||
|
arguments={"path": "docs/pic.png"},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
|
||||||
|
try:
|
||||||
|
_, _, frames = _stream_chat(client, QUESTION)
|
||||||
|
finally:
|
||||||
|
fastapi_app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
done = frames[-1]
|
||||||
|
assert done["deflected"] is False
|
||||||
|
assert "answer_image" not in done # ABSENT, not null
|
||||||
|
# The offer: exactly the three base tools — no ``show_image``.
|
||||||
|
offered = scripted.seen_tools[0]
|
||||||
|
assert offered is not None
|
||||||
|
assert [t["function"]["name"] for t in offered] == ["ls", "read", "grep"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_tool_grounded_turn_done_frame_has_no_answer_image_key(
|
||||||
|
client, db, seeded_kb: FakeRagLLM, answer_images_on: None
|
||||||
|
) -> None:
|
||||||
|
"""A zero-tool grounded turn (no scripted calls — the toggle is ON,
|
||||||
|
so the offer carries ``show_image``, but the model never calls it)
|
||||||
|
→ the ``done`` frame has NO ``answer_image`` key — byte-identical
|
||||||
|
to pre-phase, checked on the raw wire text (not a re-serialized
|
||||||
|
dict)."""
|
||||||
|
scripted = FakeRagLLM() # no tool_script: a zero-tool turn
|
||||||
|
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
|
||||||
|
try:
|
||||||
|
with client.stream("POST", "/api/chat", json={"message": QUESTION}) as r:
|
||||||
|
assert r.status_code == 200
|
||||||
|
buf = ""
|
||||||
|
raw_frames: list[str] = []
|
||||||
|
for part in r.iter_text():
|
||||||
|
buf += part
|
||||||
|
while "\n\n" in buf:
|
||||||
|
frame, buf = buf.split("\n\n", 1)
|
||||||
|
frame = frame.strip()
|
||||||
|
if frame.startswith("data:"):
|
||||||
|
raw_frames.append(frame)
|
||||||
|
finally:
|
||||||
|
fastapi_app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
done_raw = [f for f in raw_frames if '"type": "done"' in f]
|
||||||
|
assert len(done_raw) == 1
|
||||||
|
# The BYTE check: the key is absent from the wire text itself.
|
||||||
|
assert "answer_image" not in done_raw[0]
|
||||||
|
done = json.loads(done_raw[0].removeprefix("data:").strip())
|
||||||
|
assert done["deflected"] is False
|
||||||
|
assert "answer_image" not in done
|
||||||
|
|
||||||
|
|
||||||
|
def test_deflected_turn_done_frame_has_no_answer_image_key_even_toggle_on(
|
||||||
|
client, db, seeded_kb: FakeRagLLM, answer_images_on: None
|
||||||
|
) -> None:
|
||||||
|
"""A deflected turn runs no agent and never shows an image (the
|
||||||
|
phase's locked design): even with the toggle ON, the ``done``
|
||||||
|
frame has NO ``answer_image`` key — byte-identical to pre-phase."""
|
||||||
|
scripted = FakeRagLLM()
|
||||||
|
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
|
||||||
|
try:
|
||||||
|
_, _, frames = _stream_chat(client, OFF_TOPIC)
|
||||||
|
finally:
|
||||||
|
fastapi_app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
done = frames[-1]
|
||||||
|
assert done["deflected"] is True
|
||||||
|
assert "answer_image" not in done
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 124, task 05 — bug fix: a suggested-tier IMAGE doc earns its
|
||||||
|
# source chip WITHOUT a ``read`` (its seed block IS its entire content —
|
||||||
|
# content == summary == the vision description, phase 122 task 03 — so
|
||||||
|
# "suggested" ≡ "read" for it and the phase-119 cite discipline is
|
||||||
|
# honored, not weakened). Text docs keep the read-docs-only rule.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_image_doc_with_chunk(db) -> Document:
|
||||||
|
"""An ``is_image`` documents row WITH a retrievable chunk — the
|
||||||
|
phase-122 pipeline embeds the vision description as the image doc's
|
||||||
|
content (the only embedded text of the document), so the fixture
|
||||||
|
chunk mirrors that: a description tuned to overlap ``QUESTION``
|
||||||
|
(the mock embeddings are token-overlap vectors, as in the E2E
|
||||||
|
mock), which lands the image doc in the SUGGESTED tier (top-5
|
||||||
|
fused rank, no floor — A3) where the auto-cite merge operates.
|
||||||
|
The ``tsv`` column is generated (``to_tsvector`` of the content),
|
||||||
|
so the chunk also hits the FTS half. The fixture's teardown
|
||||||
|
truncates the tables."""
|
||||||
|
doc = _seed_image_doc(db)
|
||||||
|
# The phase-122 invariant for image docs: content == summary == the
|
||||||
|
# vision description (the ONE embedded text of the document — no
|
||||||
|
# pointer line on the summary). The description is tuned to overlap
|
||||||
|
# ``QUESTION`` (the mock embeddings are token-overlap vectors, as in
|
||||||
|
# the E2E mock), which lands the image doc in the SUGGESTED tier
|
||||||
|
# (top-5 fused rank, no floor — A3) where the auto-cite merge
|
||||||
|
# operates.
|
||||||
|
description = "Diagram of how my Kubernetes cluster is set up"
|
||||||
|
doc.content = description
|
||||||
|
doc.summary = description
|
||||||
|
db.add(
|
||||||
|
Chunk(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
document_id=doc.id,
|
||||||
|
position=0,
|
||||||
|
content=description,
|
||||||
|
embedding=_token_vec(description),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_read_grounded_turn_auto_cites_suggested_image_doc(
|
||||||
|
client, db, seeded_kb: FakeRagLLM
|
||||||
|
) -> None:
|
||||||
|
"""The bug fix pinned end-to-end (TODO L3): a grounded turn with
|
||||||
|
NO scripted tool calls (the mock answers straight from the seed —
|
||||||
|
a real model never ``read``s an image doc: its seed block already
|
||||||
|
carries 100% of its content) whose suggested tier contains an
|
||||||
|
image doc → the done frame's ``sources`` include the image ref
|
||||||
|
WITH ``image_url`` set (the chip + figure data — pre-phase-124 a
|
||||||
|
zero-read turn chipped NOTHING, the phase-122 E2E had to script a
|
||||||
|
``read`` to earn the chip), and ``related`` does NOT contain the
|
||||||
|
same ``(source, path)`` (the auto-cited doc leaves the "Nearby
|
||||||
|
docs" row — no duplicate figure)."""
|
||||||
|
doc = _seed_image_doc_with_chunk(db)
|
||||||
|
scripted = FakeRagLLM() # no tool_script: a zero-read grounded turn
|
||||||
|
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
|
||||||
|
try:
|
||||||
|
_, _, frames = _stream_chat(client, QUESTION)
|
||||||
|
finally:
|
||||||
|
fastapi_app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
done = frames[-1]
|
||||||
|
assert done["deflected"] is False
|
||||||
|
# The image doc IS a citation chip now — and its ref (and ONLY its
|
||||||
|
# ref) carries the bytes route the figure renders from.
|
||||||
|
sources = done["sources"]
|
||||||
|
img_refs = [s for s in sources if (s["source"], s["path"]) == ("docs", "pic.png")]
|
||||||
|
assert len(img_refs) == 1 # once — not duplicated by the read merge
|
||||||
|
assert img_refs[0]["image_url"] == f"/api/documents/{doc.id}/image"
|
||||||
|
for ref in sources:
|
||||||
|
if ref is not img_refs[0]:
|
||||||
|
assert "image_url" not in ref # the text refs stay pre-122-shaped
|
||||||
|
# Consequence (a): the related tier already excludes cited-seen
|
||||||
|
# members — the auto-cited image doc is NOT also a "nearby doc".
|
||||||
|
related_keys = {(s["source"], s["path"]) for s in done["related"]}
|
||||||
|
assert ("docs", "pic.png") not in related_keys
|
||||||
|
# Consequence (b): the observability fields are UNCHANGED — the
|
||||||
|
# durable record (query_log.sources) carried the suggested image
|
||||||
|
# doc before the phase (it was suggested, not read); auto-citation
|
||||||
|
# touches the citation tier only, not the record.
|
||||||
|
row = db.scalars(select(QueryLog)).one()
|
||||||
|
assert "docs/pic.png" in row.sources
|
||||||
|
assert "docs/homelab/kubernetes.md" in row.sources
|
||||||
|
# The seed contract held: the HIGH prompt seeded the image doc's
|
||||||
|
# summary — its vision description, the ENTIRE content (image docs
|
||||||
|
# store no ``Source:`` pointer line — the summary mirrors the
|
||||||
|
# description verbatim, phase 122 task 03) — as a ``<document>``
|
||||||
|
# block. This is WHY "suggested" ≡ "read" for the doc: a ``read``
|
||||||
|
# would add only the marker line.
|
||||||
|
system = scripted.seen_messages[0][0]["content"]
|
||||||
|
assert '<document source="docs" path="pic.png"' in system
|
||||||
|
assert "Diagram of how my Kubernetes cluster is set up" in system
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_read_text_only_turn_still_chips_nothing(
|
||||||
|
client, db, seeded_kb: FakeRagLLM
|
||||||
|
) -> None:
|
||||||
|
"""Text docs are unaffected (consequence c): a zero-read grounded
|
||||||
|
turn on the plain text-only KB (no image docs anywhere in the
|
||||||
|
retrieval) → ``sources`` is ``[]`` — byte-identical to pre-phase
|
||||||
|
(no auto-cite without image docs) and no ``image_url`` key on any
|
||||||
|
ref (the omission rule at the frame level)."""
|
||||||
|
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
||||||
|
try:
|
||||||
|
_, _, frames = _stream_chat(client, QUESTION)
|
||||||
|
finally:
|
||||||
|
fastapi_app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
done = frames[-1]
|
||||||
|
assert done["deflected"] is False
|
||||||
|
assert done["sources"] == [] # phase-119 read-docs-only rule intact
|
||||||
|
for ref in done["sources"] + done["related"]:
|
||||||
|
assert "image_url" not in ref
|
||||||
|
|||||||
@@ -899,6 +899,112 @@ def test_shared_serve_includes_the_user_image_path(
|
|||||||
assert "image" not in messages[2]
|
assert "image" not in messages[2]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 124 (task 04): the brain answer's first-class image — the
|
||||||
|
# shown document's ref (the LLM's ``show_image`` pick) rides the BRAIN
|
||||||
|
# record's ``answer_image`` key (distinct from the user record's
|
||||||
|
# phase-123 ``image`` attachment path — the attachment belongs to the
|
||||||
|
# question, the answer image to the brain's reply). The phase-113
|
||||||
|
# lesson applied up front: the done-time auto-save (``rememberBrainTurn``)
|
||||||
|
# carries the key, and the ``extra="forbid"`` boundary silently 422'd
|
||||||
|
# any key the model was missing (the A2 quiet-failure path swallowed
|
||||||
|
# it — grounded turns silently unsaved). These round-trips are that
|
||||||
|
# regression pin.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: The shown image doc's full ref — the done frame's ``answer_image``
|
||||||
|
#: shape (source/path/title + the ``image_url`` bytes route).
|
||||||
|
ANSWER_IMAGE_REF: dict[str, Any] = {
|
||||||
|
"source": "Homelab",
|
||||||
|
"path": "kubernetes-cluster.png",
|
||||||
|
"title": "Kubernetes Cluster Diagram",
|
||||||
|
"image_url": "/api/documents/0e8a6e5d-7b6c-4f2a-9c1d-3e5f7a9b1c2d/image",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_round_trips_the_brain_answer_image_ref(
|
||||||
|
admin_client: TestClient, db: Session
|
||||||
|
) -> None:
|
||||||
|
"""The phase-113 regression pin (the A2 quiet-failure path): a
|
||||||
|
brain record carrying ``answer_image`` → ``POST /api/chats`` is
|
||||||
|
201 (NOT a silent 422) and the served chat round-trips the ref
|
||||||
|
BYTE-FOR-BYTE (the restore path re-renders the answer figure from
|
||||||
|
it). The user record carries NO ``answer_image`` key, and the
|
||||||
|
user's phase-123 ``image`` attachment key stays distinct."""
|
||||||
|
brain = {**FULL_BRAIN, "answer_image": ANSWER_IMAGE_REF}
|
||||||
|
r = admin_client.post(
|
||||||
|
"/api/chats",
|
||||||
|
json={"messages": [_user(FIRST_QUESTION), brain]},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, f"expected 201, got {r.status_code}: {r.text}"
|
||||||
|
body = r.json()
|
||||||
|
assert body["messages"][1]["answer_image"] == ANSWER_IMAGE_REF
|
||||||
|
assert "answer_image" not in body["messages"][0]
|
||||||
|
assert "image" not in body["messages"][1] # the brain's image is a REF
|
||||||
|
|
||||||
|
# The RAW stored JSONB (the DB is the durable boundary): the full
|
||||||
|
# ref, byte-for-byte.
|
||||||
|
raw = db.execute(
|
||||||
|
text("SELECT messages FROM saved_chats WHERE id = :id"),
|
||||||
|
{"id": body["id"]},
|
||||||
|
).scalar_one()
|
||||||
|
assert raw[1]["answer_image"] == ANSWER_IMAGE_REF
|
||||||
|
assert "answer_image" not in raw[0]
|
||||||
|
|
||||||
|
# The admin GET round-trips the ref losslessly.
|
||||||
|
got = admin_client.get(f"/api/chats/{body['id']}")
|
||||||
|
assert got.status_code == 200
|
||||||
|
assert got.json()["messages"][1]["answer_image"] == ANSWER_IMAGE_REF
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_without_answer_image_round_trips_unchanged(
|
||||||
|
admin_client: TestClient,
|
||||||
|
) -> None:
|
||||||
|
"""A record WITHOUT the key (every pre-phase-124 chat, every
|
||||||
|
image-less turn) round-trips UNCHANGED: the response carries no
|
||||||
|
``answer_image`` key at all — ABSENT, never ``null`` (the
|
||||||
|
byte-identical-off contract; the phase-50 round-trip contract).
|
||||||
|
``_expect`` (the stored shape) is unchanged by phase 124 — the
|
||||||
|
omission serializer inherits it on every surface."""
|
||||||
|
brain = {**FULL_BRAIN}
|
||||||
|
r = admin_client.post(
|
||||||
|
"/api/chats",
|
||||||
|
json={"messages": [_user(FIRST_QUESTION), brain]},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["messages"] == _expect([_user(FIRST_QUESTION), brain])
|
||||||
|
assert "answer_image" not in body["messages"][0]
|
||||||
|
assert "answer_image" not in body["messages"][1]
|
||||||
|
|
||||||
|
got = admin_client.get(f"/api/chats/{body['id']}")
|
||||||
|
assert got.status_code == 200
|
||||||
|
assert "answer_image" not in got.json()["messages"][1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_serve_includes_the_brain_answer_image_ref(
|
||||||
|
admin_client: TestClient,
|
||||||
|
) -> None:
|
||||||
|
"""The shared view is faithful (task 06's render depends on it):
|
||||||
|
the PUBLIC snapshot's brain record carries the answer ref (the
|
||||||
|
bytes route is public — a shared chat is faithful, the phase-123
|
||||||
|
user-image precedent), and the user record stays WITHOUT the key."""
|
||||||
|
brain = {**FULL_BRAIN, "answer_image": ANSWER_IMAGE_REF}
|
||||||
|
r = admin_client.post(
|
||||||
|
"/api/chats",
|
||||||
|
json={"messages": [_user(FIRST_QUESTION), brain]},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
share_url = _share(admin_client, r.json()["id"])["share_url"]
|
||||||
|
|
||||||
|
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
|
||||||
|
got = anon.get(f"/api{share_url}")
|
||||||
|
assert got.status_code == 200
|
||||||
|
messages = got.json()["messages"]
|
||||||
|
assert messages[1]["answer_image"] == ANSWER_IMAGE_REF
|
||||||
|
assert "answer_image" not in messages[0]
|
||||||
|
|
||||||
|
|
||||||
def test_public_read_wrong_and_revoked_tokens_404_with_one_detail(
|
def test_public_read_wrong_and_revoked_tokens_404_with_one_detail(
|
||||||
admin_client: TestClient,
|
admin_client: TestClient,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -1305,24 +1305,30 @@ def test_image_route_404_matrix(admin_client: TestClient, db, tmp_path: Path) ->
|
|||||||
_cleanup_kb(db)
|
_cleanup_kb(db)
|
||||||
|
|
||||||
|
|
||||||
def test_image_route_requires_user_like_the_content_endpoint(
|
def test_image_route_is_public_like_the_shared_chat_content(
|
||||||
admin_client: TestClient, db, tmp_path: Path
|
admin_client: TestClient, db, tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Phase 79 posture (the task's "PUBLIC, like the document content
|
"""Phase 124 (LOCKED A4 — the answer figure is "visible … on the
|
||||||
endpoint" — the content endpoint has been user-gated since phase
|
shared page"): the BYTES route is PUBLIC (the phase-123
|
||||||
79; the ONLY anonymous surface is the shared chats, PLAN A10): an
|
chat-image precedent — the image is part of a shared chat's
|
||||||
anonymous caller gets 401 ``authentication required`` before any
|
rendered content, and the shared chat is the app's ONLY anonymous
|
||||||
row is read (a FRESH client — the module's fixture ``client``
|
surface, PLAN A10; the unguessable ``uuid4`` document id IS the
|
||||||
stays unsigned here), a signed-in caller gets the bytes."""
|
credential, no enumeration value): an ANONYMOUS caller (a FRESH
|
||||||
|
client — no session) gets the bytes with the image Content-Type,
|
||||||
|
and a signed-in caller gets the same. The document's TEXT (the
|
||||||
|
content endpoint) stays user-gated — only the bytes are public."""
|
||||||
_cleanup_kb(db)
|
_cleanup_kb(db)
|
||||||
try:
|
try:
|
||||||
doc = _seed_image_doc(db, tmp_path)
|
doc = _seed_image_doc(db, tmp_path)
|
||||||
anonymous = TestClient(fastapi_app)
|
anonymous = TestClient(fastapi_app)
|
||||||
r = anonymous.get(f"/api/documents/{doc.id}/image")
|
r = anonymous.get(f"/api/documents/{doc.id}/image")
|
||||||
assert r.status_code == 401
|
assert r.status_code == 200
|
||||||
assert r.json() == {"detail": "authentication required"}
|
assert r.headers["content-type"].startswith("image/")
|
||||||
# The signed-in client (admin) passes.
|
assert r.content == PNG_1X1
|
||||||
assert admin_client.get(f"/api/documents/{doc.id}/image").status_code == 200
|
# The signed-in client (admin) gets the same bytes.
|
||||||
|
r_admin = admin_client.get(f"/api/documents/{doc.id}/image")
|
||||||
|
assert r_admin.status_code == 200
|
||||||
|
assert r_admin.content == PNG_1X1
|
||||||
finally:
|
finally:
|
||||||
_cleanup_kb(db)
|
_cleanup_kb(db)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,971 @@
|
|||||||
|
"""Unit tests: phase 124 — image chips and answers.
|
||||||
|
|
||||||
|
Task 01 covers the ``BOR_ANSWER_IMAGES`` feature toggle
|
||||||
|
(``Settings.answer_images``). Later tasks in this phase extend this
|
||||||
|
file (the ``show_image`` tool, the prompt clause, the ``answer_image``
|
||||||
|
frame key, the auto-cite merge).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(**kwargs: Any) -> Settings:
|
||||||
|
"""Build Settings without reading a .env file (deterministic tests).
|
||||||
|
|
||||||
|
Mirrors ``tests/unit/test_config.py::_settings``.
|
||||||
|
"""
|
||||||
|
kwargs.setdefault("_env_file", None)
|
||||||
|
return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime)
|
||||||
|
|
||||||
|
|
||||||
|
def test_answer_images_defaults_off(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""LOCKED A2: ``BOR_ANSWER_IMAGES`` gates the whole answer-image
|
||||||
|
feature and is OFF by default — unset, ``=0``, and ``=false`` all
|
||||||
|
parse to ``False`` (off = byte-identical behavior everywhere)."""
|
||||||
|
monkeypatch.delenv("BOR_ANSWER_IMAGES", raising=False)
|
||||||
|
assert _settings().answer_images is False
|
||||||
|
monkeypatch.setenv("BOR_ANSWER_IMAGES", "0")
|
||||||
|
assert _settings().answer_images is False
|
||||||
|
monkeypatch.setenv("BOR_ANSWER_IMAGES", "false")
|
||||||
|
assert _settings().answer_images is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_answer_images_truthy_env_parse(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""The settings loader's accepted truthy forms (the
|
||||||
|
``stream_thinking``/``images`` bool-test precedent): ``1`` and
|
||||||
|
``true`` enable the toggle."""
|
||||||
|
monkeypatch.setenv("BOR_ANSWER_IMAGES", "1")
|
||||||
|
assert _settings().answer_images is True
|
||||||
|
monkeypatch.setenv("BOR_ANSWER_IMAGES", "true")
|
||||||
|
assert _settings().answer_images is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_answer_images_independent_of_images(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""LOCKED A2: the answer-side toggle is a SEPARATE knob from
|
||||||
|
``images``/``BOR_IMAGES`` — indexing image docs and showing them in
|
||||||
|
answers are independent owner decisions. Setting one never moves
|
||||||
|
the other, in either direction."""
|
||||||
|
monkeypatch.delenv("BOR_ANSWER_IMAGES", raising=False)
|
||||||
|
monkeypatch.delenv("BOR_IMAGES", raising=False)
|
||||||
|
|
||||||
|
# Default: both off.
|
||||||
|
s = _settings()
|
||||||
|
assert s.answer_images is False
|
||||||
|
assert s.images is False
|
||||||
|
|
||||||
|
# Enabling the answer toggle alone leaves indexing off — a
|
||||||
|
# coherent (if image-less) state: the tool is offered, finds no
|
||||||
|
# image docs, and always refuses.
|
||||||
|
monkeypatch.setenv("BOR_ANSWER_IMAGES", "1")
|
||||||
|
s = _settings()
|
||||||
|
assert s.answer_images is True
|
||||||
|
assert s.images is False
|
||||||
|
|
||||||
|
# Enabling indexing alone leaves the answer side off (byte-
|
||||||
|
# identical pre-phase-124 behavior).
|
||||||
|
monkeypatch.delenv("BOR_ANSWER_IMAGES")
|
||||||
|
monkeypatch.setenv("BOR_IMAGES", "1")
|
||||||
|
s = _settings()
|
||||||
|
assert s.answer_images is False
|
||||||
|
assert s.images is True
|
||||||
|
|
||||||
|
# Both on — the full feature state.
|
||||||
|
monkeypatch.setenv("BOR_ANSWER_IMAGES", "true")
|
||||||
|
s = _settings()
|
||||||
|
assert s.answer_images is True
|
||||||
|
assert s.images is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Task 02 — the ``show_image`` agent tool (toggle-gated)
|
||||||
|
|
||||||
|
import asyncio # noqa: E402
|
||||||
|
import uuid # noqa: E402
|
||||||
|
from collections.abc import AsyncIterator # noqa: E402
|
||||||
|
from copy import deepcopy # noqa: E402
|
||||||
|
from datetime import UTC, datetime # noqa: E402
|
||||||
|
from typing import cast # noqa: E402
|
||||||
|
from unittest.mock import MagicMock # noqa: E402
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session # noqa: E402
|
||||||
|
|
||||||
|
from app.models import Document # noqa: E402
|
||||||
|
from app.rag.agent import ( # noqa: E402
|
||||||
|
AGENT_TOOLS,
|
||||||
|
ALREADY_SHOWING,
|
||||||
|
IMAGE_DOC_MARKER,
|
||||||
|
MISSING_SHOW_ARGS,
|
||||||
|
NOT_AN_IMAGE_DOC,
|
||||||
|
SHOW_IMAGE_TOOL,
|
||||||
|
AgentHolder,
|
||||||
|
_execute_tool,
|
||||||
|
_no_document_refusal,
|
||||||
|
agent_tools,
|
||||||
|
run_agent,
|
||||||
|
)
|
||||||
|
from app.rag.llm import LLMClient, StreamPiece, ToolCallPiece # noqa: E402
|
||||||
|
|
||||||
|
#: The fixture documents' fixed creation date (phase 106, D5): the
|
||||||
|
#: ``show_image`` result formats ``doc.created_at`` on its date line —
|
||||||
|
#: a pinned value keeps the result-string assertions exact.
|
||||||
|
_SHOW_FIXTURE_CREATED_AT = datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
# --- the offer list: toggle-gated, the base three byte-identical ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_tools_off_is_the_three_base_tools() -> None:
|
||||||
|
"""LOCKED A2: toggle off (the default) → exactly the three base
|
||||||
|
tools, element-for-element equal to ``AGENT_TOOLS`` — and a FRESH
|
||||||
|
list (not the constant), so a caller mutating the offer can never
|
||||||
|
mutate the constant."""
|
||||||
|
off = agent_tools(_settings())
|
||||||
|
assert [t["function"]["name"] for t in off] == ["ls", "read", "grep"]
|
||||||
|
assert off == AGENT_TOOLS # element-for-element
|
||||||
|
assert off is not AGENT_TOOLS # a fresh list, never the constant itself
|
||||||
|
off.append({"junk": True}) # mutating the offer must not touch the constant
|
||||||
|
assert len(AGENT_TOOLS) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_tools_on_appends_show_image() -> None:
|
||||||
|
"""Toggle on → the three base tools (byte-identical) plus the 4th
|
||||||
|
tool, ``show_image`` — the offer list the grounded turn passes to
|
||||||
|
the model request (LOCKED A2: ``BOR_ANSWER_IMAGES`` gates ONLY the
|
||||||
|
offer; the base three are unchanged)."""
|
||||||
|
on = agent_tools(_settings(answer_images=True))
|
||||||
|
assert [t["function"]["name"] for t in on] == ["ls", "read", "grep", "show_image"]
|
||||||
|
assert on[:3] == AGENT_TOOLS # the base three byte-identical
|
||||||
|
assert on[3] == SHOW_IMAGE_TOOL
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_image_tool_definition_carries_the_relevance_gate() -> None:
|
||||||
|
"""The tool definition is the same OpenAI function shape as the
|
||||||
|
other three (one required string arg ``path``, the combined
|
||||||
|
``source/path`` identity — the bare-path-will-not-resolve teaching
|
||||||
|
mirrored from ``read``), and its description IS the relevance gate
|
||||||
|
(LOCKED A5: never a merely related image, at most ONE image, the
|
||||||
|
full text answer always stands)."""
|
||||||
|
fn = SHOW_IMAGE_TOOL["function"]
|
||||||
|
assert SHOW_IMAGE_TOOL["type"] == "function"
|
||||||
|
assert fn["name"] == "show_image"
|
||||||
|
assert fn["parameters"]["required"] == ["path"]
|
||||||
|
assert fn["parameters"]["properties"]["path"]["type"] == "string"
|
||||||
|
desc = fn["description"]
|
||||||
|
assert "at most one image" in desc
|
||||||
|
assert "never for a merely related" in desc
|
||||||
|
assert "answer fully in text either way" in desc
|
||||||
|
assert "bare document path will not resolve" in desc
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_agent_gates_the_show_image_offer() -> None:
|
||||||
|
"""The wiring pin (completion criteria): a grounded turn's offer
|
||||||
|
(the ``tools`` on the model request, ``max_rounds`` > 0) is the
|
||||||
|
three base tools when the toggle is off — element-for-element the
|
||||||
|
pre-phase-124 list — and gains ``show_image`` only when it is on."""
|
||||||
|
|
||||||
|
class _ScriptedLLM:
|
||||||
|
"""One canned content-only round; records the ``tools`` every
|
||||||
|
request carried (the DB is never touched — no tool calls)."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.requests: list[
|
||||||
|
tuple[list[dict[str, Any]], list[dict[str, Any]] | None]
|
||||||
|
] = []
|
||||||
|
|
||||||
|
async def chat_stream(
|
||||||
|
self,
|
||||||
|
messages: list[dict[str, str]],
|
||||||
|
tools: list[dict[str, Any]] | None = None,
|
||||||
|
scaffolding: object | None = None,
|
||||||
|
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
|
||||||
|
self.requests.append((deepcopy(messages), tools))
|
||||||
|
yield StreamPiece("content", "Done!")
|
||||||
|
|
||||||
|
def _mock_session() -> Session:
|
||||||
|
mock = cast("Session", MagicMock())
|
||||||
|
mock.__enter__ = MagicMock(return_value=mock)
|
||||||
|
mock.__exit__ = MagicMock(return_value=False)
|
||||||
|
mock.scalar = MagicMock(return_value=None)
|
||||||
|
mock.execute = MagicMock(return_value=MagicMock(scalars=MagicMock(return_value=[])))
|
||||||
|
return mock
|
||||||
|
|
||||||
|
async def _consume(llm: _ScriptedLLM, settings: Settings) -> None:
|
||||||
|
async for _piece in run_agent(
|
||||||
|
cast("LLMClient", llm),
|
||||||
|
_mock_session,
|
||||||
|
system_prompt="SYSTEM_PROMPT",
|
||||||
|
user_message="QUESTION",
|
||||||
|
seed_docs=[],
|
||||||
|
settings=settings,
|
||||||
|
holder=AgentHolder(),
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
llm_off = _ScriptedLLM()
|
||||||
|
asyncio.run(_consume(llm_off, _settings()))
|
||||||
|
assert len(llm_off.requests) == 1
|
||||||
|
assert llm_off.requests[0][1] == AGENT_TOOLS # off: element-for-element pre-124
|
||||||
|
|
||||||
|
llm_on = _ScriptedLLM()
|
||||||
|
asyncio.run(_consume(llm_on, _settings(answer_images=True)))
|
||||||
|
sent = llm_on.requests[0][1]
|
||||||
|
assert sent is not None # the grounded turn (max_rounds > 0) offers tools
|
||||||
|
assert [t["function"]["name"] for t in sent] == [
|
||||||
|
"ls",
|
||||||
|
"read",
|
||||||
|
"grep",
|
||||||
|
"show_image",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# --- the handler: the refusal matrix + the confirming success ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_image_refusal_texts_are_pinned() -> None:
|
||||||
|
"""The refusal texts (the ``MISSING_READ_ARGS`` /
|
||||||
|
``ALREADY_IN_CONTEXT`` shapes) are pinned — the model's
|
||||||
|
self-correction relies on them being stable."""
|
||||||
|
assert MISSING_SHOW_ARGS == "show_image requires a string argument 'path'."
|
||||||
|
assert ALREADY_SHOWING == (
|
||||||
|
"Already showing an image this answer — do not show another one. "
|
||||||
|
"Answer from what you have."
|
||||||
|
)
|
||||||
|
assert NOT_AN_IMAGE_DOC.format(arg="s/p.png") == (
|
||||||
|
"'s/p.png' is not an image document — only standalone image "
|
||||||
|
"documents can be shown as answers."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _show_call(arguments: dict[str, Any]) -> ToolCallPiece:
|
||||||
|
return ToolCallPiece(id="call_show", name="show_image", arguments=arguments)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_show_doc(
|
||||||
|
db: Session, *, is_image: bool, content: str, path: str
|
||||||
|
) -> Document:
|
||||||
|
"""One real documents row ``show_image`` can resolve (source
|
||||||
|
``showsrc`` — no fixture collision); cleaned up by the caller."""
|
||||||
|
doc = Document(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
source="showsrc",
|
||||||
|
path=path,
|
||||||
|
full_path=f"/showsrc/{path}",
|
||||||
|
title="diagram" if is_image else "Notes",
|
||||||
|
content=content,
|
||||||
|
content_hash="0" * 64,
|
||||||
|
created_at=_SHOW_FIXTURE_CREATED_AT,
|
||||||
|
is_image=is_image,
|
||||||
|
)
|
||||||
|
if is_image:
|
||||||
|
doc.image_path = f"/tmp/{doc.id}.png"
|
||||||
|
db.add(doc)
|
||||||
|
db.commit()
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
def _show(db: Session, settings: Settings, holder: AgentHolder, arguments: dict[str, Any]) -> str:
|
||||||
|
return _execute_tool(db, _show_call(arguments), [], holder, settings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_image_missing_or_empty_path_is_refused(db: Session) -> None:
|
||||||
|
"""(a) missing/empty/whitespace/non-string ``path`` →
|
||||||
|
``MISSING_SHOW_ARGS`` — counts in nothing, nothing shown."""
|
||||||
|
holder = AgentHolder()
|
||||||
|
for arguments in ({}, {"path": " "}, {"path": None}):
|
||||||
|
assert _show(db, _settings(), holder, arguments) == MISSING_SHOW_ARGS
|
||||||
|
assert holder.tool_calls == 0
|
||||||
|
assert holder.shown_images == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_image_unresolvable_arg_gets_the_no_document_teaching(
|
||||||
|
db: Session,
|
||||||
|
) -> None:
|
||||||
|
"""(b) an unresolvable argument → the phase-72
|
||||||
|
``_no_document_refusal`` text (the bare-argument line, and the
|
||||||
|
did-you-mean combined-identity for a path-like argument) — counts
|
||||||
|
in nothing, nothing shown."""
|
||||||
|
holder = AgentHolder()
|
||||||
|
# A bare argument (no ``/``) can never be a document — the
|
||||||
|
# no-DB-lookup refusal, byte-identical to the read branch's line.
|
||||||
|
assert _show(db, _settings(), holder, {"path": "nosuchdoc"}) == (
|
||||||
|
"No document at 'nosuchdoc' — check the ls output."
|
||||||
|
)
|
||||||
|
assert holder.tool_calls == 0
|
||||||
|
assert holder.shown_images == []
|
||||||
|
# A path-like argument matching exactly ONE indexed document's path
|
||||||
|
# gets the combined-identity teaching.
|
||||||
|
doc = _seed_show_doc(db, is_image=True, content="A red square.", path="phase124/diagram.png")
|
||||||
|
try:
|
||||||
|
result = _show(db, _settings(), holder, {"path": "phase124/diagram.png"})
|
||||||
|
assert result == _no_document_refusal(db, "phase124/diagram.png")
|
||||||
|
assert "showsrc/phase124/diagram.png" in result # the combined identity named
|
||||||
|
assert holder.tool_calls == 0
|
||||||
|
assert holder.shown_images == []
|
||||||
|
finally:
|
||||||
|
db.delete(doc)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_image_of_a_text_doc_is_refused_not_converted(db: Session) -> None:
|
||||||
|
"""(c) a resolved TEXT document → ``NOT_AN_IMAGE_DOC`` (the argument
|
||||||
|
echoed) — a text doc is refused, NEVER silently converted to a
|
||||||
|
read; counts in nothing, nothing shown or read."""
|
||||||
|
doc = _seed_show_doc(
|
||||||
|
db, is_image=False, content="just text.", path="phase124/notes.md"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
holder = AgentHolder()
|
||||||
|
result = _show(db, _settings(), holder, {"path": "showsrc/phase124/notes.md"})
|
||||||
|
assert result == NOT_AN_IMAGE_DOC.format(arg="showsrc/phase124/notes.md")
|
||||||
|
assert holder.tool_calls == 0
|
||||||
|
assert holder.shown_images == []
|
||||||
|
assert holder.read_docs == [] # never converted to a read
|
||||||
|
finally:
|
||||||
|
db.delete(doc)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_image_of_an_image_doc_confirms_and_records(db: Session) -> None:
|
||||||
|
"""(d) a resolved IMAGE document → the confirming result — the NEW
|
||||||
|
first line (absent from ``read``) followed by the exact
|
||||||
|
read-style document block (the ``Document …`` header + date line +
|
||||||
|
:data:`IMAGE_DOC_MARKER` + the description), ``shown_images ==
|
||||||
|
[doc]``, and ``tool_calls`` bumped (an executed call — it feeds the
|
||||||
|
per-turn ``tool_calls=N``)."""
|
||||||
|
doc = _seed_show_doc(
|
||||||
|
db,
|
||||||
|
is_image=True,
|
||||||
|
content="A red square on a white background.",
|
||||||
|
path="phase124/diagram.png",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
holder = AgentHolder()
|
||||||
|
result = _show(db, _settings(), holder, {"path": "showsrc/phase124/diagram.png"})
|
||||||
|
assert result == (
|
||||||
|
"Image shown to the user — it is now part of your answer.\n"
|
||||||
|
"Document showsrc/phase124/diagram.png:\n"
|
||||||
|
"date: 2024-06-15\n"
|
||||||
|
f"{IMAGE_DOC_MARKER}\n"
|
||||||
|
"A red square on a white background."
|
||||||
|
)
|
||||||
|
assert holder.shown_images == [doc]
|
||||||
|
assert holder.tool_calls == 1
|
||||||
|
finally:
|
||||||
|
db.delete(doc)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_image_second_call_is_refused(db: Session) -> None:
|
||||||
|
"""(e) a second ``show_image`` in the same turn →
|
||||||
|
``ALREADY_SHOWING`` (LOCKED A3, server-enforced) — appends nothing,
|
||||||
|
counts in nothing; the first document stays the one shown."""
|
||||||
|
doc = _seed_show_doc(
|
||||||
|
db, is_image=True, content="A red square.", path="phase124/diagram2.png"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
holder = AgentHolder()
|
||||||
|
first = _show(db, _settings(), holder, {"path": "showsrc/phase124/diagram2.png"})
|
||||||
|
assert first.startswith("Image shown to the user — it is now part of your answer.")
|
||||||
|
second = _show(db, _settings(), holder, {"path": "showsrc/phase124/diagram2.png"})
|
||||||
|
assert second == ALREADY_SHOWING
|
||||||
|
assert holder.shown_images == [doc] # still exactly one shown
|
||||||
|
assert holder.tool_calls == 1 # the refusal counts in nothing
|
||||||
|
finally:
|
||||||
|
db.delete(doc)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Task 03 — the ``show_image`` prompt clause (toggle-gated)
|
||||||
|
#
|
||||||
|
# The clause body itself is pinned in ``tests/unit/test_prompts.py``
|
||||||
|
# (``SHOW_IMAGE_CLAUSE`` + the ``build_high_prompt`` builds). This
|
||||||
|
# section pins the chat-side wiring: the grounded plan carries the
|
||||||
|
# clause EXACTLY when ``settings.answer_images`` is true, and a
|
||||||
|
# deflected plan never does.
|
||||||
|
|
||||||
|
from app.api.chat import plan_turn # noqa: E402
|
||||||
|
from app.rag.prompts import SHOW_IMAGE_CLAUSE, build_high_prompt # noqa: E402
|
||||||
|
from app.rag.retriever import RetrievedChunk # noqa: E402
|
||||||
|
|
||||||
|
#: A detached document row + its chunk — ``plan_turn`` is pure (no DB),
|
||||||
|
#: so the wiring is pinned without a database (the cosine 0.9 clears
|
||||||
|
#: the 0.62 default ``relevance_threshold`` → HIGH/grounded; the
|
||||||
|
#: 0.2 / no-fts chunk stays LOW/deflected — the gate is strict).
|
||||||
|
_P3_DOC = Document(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
source="Homelab",
|
||||||
|
path="kubernetes.md",
|
||||||
|
full_path="/tmp/kubernetes.md",
|
||||||
|
title="Kubernetes Homelab Cluster",
|
||||||
|
content="Talos Linux on three nodes.",
|
||||||
|
content_hash="0" * 64,
|
||||||
|
created_at=_SHOW_FIXTURE_CREATED_AT,
|
||||||
|
summary="A Talos Linux cluster on three nodes.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _plan3(cosine: float, settings: Settings):
|
||||||
|
chunk = RetrievedChunk(
|
||||||
|
chunk_id=uuid.uuid4(),
|
||||||
|
position=0,
|
||||||
|
content="kubernetes",
|
||||||
|
score=1.0,
|
||||||
|
document=_P3_DOC,
|
||||||
|
cosine=cosine,
|
||||||
|
)
|
||||||
|
return plan_turn([chunk], settings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_turn_grounded_carries_clause_exactly_when_toggle_on() -> None:
|
||||||
|
"""Completion pin: the grounded plan's system prompt is BYTE-
|
||||||
|
IDENTICAL to the pre-task build when ``answer_images`` is off (the
|
||||||
|
default — the pre-phase pins pass untouched) and gains the clause
|
||||||
|
(inside ``<tools>``) exactly when it is on."""
|
||||||
|
off = _plan3(0.9, _settings())
|
||||||
|
assert off.deflected is False
|
||||||
|
assert off.system_prompt == build_high_prompt([_P3_DOC])
|
||||||
|
assert SHOW_IMAGE_CLAUSE not in off.system_prompt
|
||||||
|
|
||||||
|
on = _plan3(0.9, _settings(answer_images=True))
|
||||||
|
assert on.deflected is False
|
||||||
|
assert on.system_prompt == build_high_prompt([_P3_DOC], show_image=True)
|
||||||
|
assert SHOW_IMAGE_CLAUSE in on.system_prompt
|
||||||
|
assert on.system_prompt.index("<tools>") < on.system_prompt.index(SHOW_IMAGE_CLAUSE)
|
||||||
|
assert on.system_prompt.index(SHOW_IMAGE_CLAUSE) < on.system_prompt.index("</tools>")
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_turn_deflected_never_carries_the_clause() -> None:
|
||||||
|
"""A deflected turn has no tools and never shows an image (the
|
||||||
|
phase's locked design): the deflection prompt takes no such flag,
|
||||||
|
so its build is identical — and clause-free — whether the toggle
|
||||||
|
is off or on."""
|
||||||
|
off = _plan3(0.2, _settings())
|
||||||
|
assert off.deflected is True
|
||||||
|
assert SHOW_IMAGE_CLAUSE not in off.system_prompt
|
||||||
|
assert "<tools>" not in off.system_prompt
|
||||||
|
|
||||||
|
on = _plan3(0.2, _settings(answer_images=True))
|
||||||
|
assert on.deflected is True
|
||||||
|
assert on.system_prompt == off.system_prompt # toggle has no effect at all
|
||||||
|
assert SHOW_IMAGE_CLAUSE not in on.system_prompt
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Task 04 — the ``answer_image`` done frame + saved-chat persistence
|
||||||
|
#
|
||||||
|
# The wire contract: ``ChatDoneEvent.answer_image`` (the shown doc's
|
||||||
|
# :class:`SourceRef`, OMITTED from the frame when absent — every
|
||||||
|
# toggle-off / image-less turn stays byte-identical) and
|
||||||
|
# ``ChatMessage.answer_image`` (the brain-record key the done-time
|
||||||
|
# auto-save persists).
|
||||||
|
#
|
||||||
|
from json import dumps as _json_dumps # noqa: E402
|
||||||
|
|
||||||
|
from pydantic import ValidationError # noqa: E402
|
||||||
|
|
||||||
|
from app.schemas import ChatDoneEvent, ChatMessage, SourceRef # noqa: E402
|
||||||
|
|
||||||
|
#: The shown image doc's full ref (all four fields — a shown doc is an
|
||||||
|
#: image doc, so its ref always carries the ``image_url`` bytes route).
|
||||||
|
_SHOWN_REF = {
|
||||||
|
"source": "docs",
|
||||||
|
"path": "pic.png",
|
||||||
|
"title": "pic",
|
||||||
|
"image_url": "/api/documents/0e8a6e5d-7b6c-4f2a-9c1d-3e5f7a9b1c2d/image",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_done_event_without_answer_image_drops_the_key() -> None:
|
||||||
|
"""``answer_image=None`` (every deflected / image-less / pre-phase
|
||||||
|
turn) → ``model_dump()`` has NO ``answer_image`` key — ABSENT, never
|
||||||
|
``null`` — and the dump is byte-identical to the pre-phase frame
|
||||||
|
shape (the byte-identical-off contract, task 01). Checked on the
|
||||||
|
dict AND on the raw JSON (no ``"answer_image"`` token, no
|
||||||
|
``null`` field)."""
|
||||||
|
event = ChatDoneEvent(
|
||||||
|
deflected=False,
|
||||||
|
sources=[SourceRef(source="docs", path="a.md", title="A")],
|
||||||
|
related=[SourceRef(source="docs", path="b.md", title="B")],
|
||||||
|
suggestions=["Maybe try X?"],
|
||||||
|
)
|
||||||
|
dump = event.model_dump()
|
||||||
|
assert "answer_image" not in dump
|
||||||
|
# The pre-phase-124 key set, exactly.
|
||||||
|
assert set(dump) == {"type", "deflected", "sources", "related", "suggestions"}
|
||||||
|
assert _json_dumps(dump, sort_keys=True) == _json_dumps(
|
||||||
|
{
|
||||||
|
"type": "done",
|
||||||
|
"deflected": False,
|
||||||
|
"sources": [{"source": "docs", "path": "a.md", "title": "A"}],
|
||||||
|
"related": [{"source": "docs", "path": "b.md", "title": "B"}],
|
||||||
|
"suggestions": ["Maybe try X?"],
|
||||||
|
},
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
raw = _json_dumps(dump)
|
||||||
|
assert "answer_image" not in raw
|
||||||
|
|
||||||
|
|
||||||
|
def test_done_event_without_answer_image_parses_a_pre_phase_frame() -> None:
|
||||||
|
"""Back-compat: a pre-phase-124 done frame (no ``answer_image``
|
||||||
|
key) parses with the default ``None`` and re-dumps byte-identically
|
||||||
|
(PLAN §4 — the field is additive in both directions)."""
|
||||||
|
old_payload = {
|
||||||
|
"type": "done",
|
||||||
|
"deflected": True,
|
||||||
|
"sources": [],
|
||||||
|
"related": [],
|
||||||
|
"suggestions": [],
|
||||||
|
}
|
||||||
|
event = ChatDoneEvent(**old_payload)
|
||||||
|
assert event.answer_image is None
|
||||||
|
assert event.model_dump() == old_payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_done_event_with_answer_image_carries_the_full_ref() -> None:
|
||||||
|
"""``answer_image`` set (the shown doc) → the key is present with
|
||||||
|
ALL FOUR ref fields (source/path/title + the ``image_url`` bytes
|
||||||
|
route), and the existing ``sources``/``related``/``suggestions``
|
||||||
|
serialization is untouched."""
|
||||||
|
event = ChatDoneEvent(
|
||||||
|
deflected=False,
|
||||||
|
sources=[SourceRef(source="docs", path="pic.png", title="pic")],
|
||||||
|
related=[],
|
||||||
|
suggestions=[],
|
||||||
|
answer_image=SourceRef(**_SHOWN_REF),
|
||||||
|
)
|
||||||
|
dump = event.model_dump()
|
||||||
|
assert dump["answer_image"] == _SHOWN_REF
|
||||||
|
assert dump["sources"] == [{"source": "docs", "path": "pic.png", "title": "pic"}]
|
||||||
|
assert dump["related"] == []
|
||||||
|
assert dump["suggestions"] == []
|
||||||
|
assert dump["type"] == "done"
|
||||||
|
assert dump["deflected"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_done_event_answer_image_ref_drops_its_own_null_image_url() -> None:
|
||||||
|
"""The ref's OWN serializer still applies inside the frame: a
|
||||||
|
text-shaped ref (``image_url=None`` — a corner that cannot occur
|
||||||
|
server-side, since only image docs are shown, but a client-built
|
||||||
|
frame could carry it) keeps the key and drops the null field
|
||||||
|
(the :class:`SourceRef.image_url` omission rule)."""
|
||||||
|
event = ChatDoneEvent(
|
||||||
|
deflected=False,
|
||||||
|
sources=[],
|
||||||
|
answer_image=SourceRef(source="s", path="p.png", title="t"),
|
||||||
|
)
|
||||||
|
dump = event.model_dump()
|
||||||
|
assert dump["answer_image"] == {"source": "s", "path": "p.png", "title": "t"}
|
||||||
|
assert "image_url" not in dump["answer_image"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- the ``ChatMessage`` boundary (the saved-chat record key) ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_message_brain_record_with_answer_image_parses() -> None:
|
||||||
|
"""A brain record carrying ``answer_image`` (the shown doc's full
|
||||||
|
ref shape) parses — the phase-113 regression: without the field,
|
||||||
|
the ``extra="forbid"`` boundary would 422 every done-time auto-save
|
||||||
|
that carries the key (the A2 quiet-failure path)."""
|
||||||
|
msg = ChatMessage.model_validate(
|
||||||
|
{"who": "brain", "text": "Here's the diagram…", "answer_image": _SHOWN_REF}
|
||||||
|
)
|
||||||
|
assert msg.answer_image is not None
|
||||||
|
assert msg.answer_image.source == "docs"
|
||||||
|
assert msg.answer_image.path == "pic.png"
|
||||||
|
assert msg.answer_image.title == "pic"
|
||||||
|
assert msg.answer_image.image_url == _SHOWN_REF["image_url"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_message_without_or_null_answer_image_parses() -> None:
|
||||||
|
"""``answer_image`` ABSENT (every pre-phase-124 record) and explicit
|
||||||
|
``None`` both parse (the default) — and neither serializes the key
|
||||||
|
(ABSENT, never ``null`` — pre-phase and image-less records
|
||||||
|
round-trip byte-identically, the phase-50 contract)."""
|
||||||
|
absent = ChatMessage.model_validate({"who": "brain", "text": "hi"})
|
||||||
|
assert absent.answer_image is None
|
||||||
|
assert "answer_image" not in absent.model_dump()
|
||||||
|
|
||||||
|
explicit_null = ChatMessage.model_validate(
|
||||||
|
{"who": "brain", "text": "hi", "answer_image": None}
|
||||||
|
)
|
||||||
|
assert explicit_null.answer_image is None
|
||||||
|
assert "answer_image" not in explicit_null.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_message_with_answer_image_serializes_the_ref() -> None:
|
||||||
|
"""A record WITH a shown ref keeps it in the dump — the restore
|
||||||
|
path re-renders the answer figure from it (the round-trip)."""
|
||||||
|
msg = ChatMessage.model_validate(
|
||||||
|
{"who": "brain", "text": "Here's the diagram…", "answer_image": _SHOWN_REF}
|
||||||
|
)
|
||||||
|
dump = msg.model_dump()
|
||||||
|
assert dump["answer_image"] == _SHOWN_REF
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_message_user_image_field_still_parses_independently() -> None:
|
||||||
|
"""The user record's phase-123 ``image`` (the stored PATH string of
|
||||||
|
the question's attachment) is untouched and semantically distinct:
|
||||||
|
it still parses on a user record, still serializes the path, and is
|
||||||
|
independent of ``answer_image`` (a brain record carries the shown
|
||||||
|
ref, never the attachment path)."""
|
||||||
|
user = ChatMessage.model_validate(
|
||||||
|
{
|
||||||
|
"who": "user",
|
||||||
|
"text": "What is in this image?",
|
||||||
|
"image": "/api/chat-images/" + "a" * 32 + ".png",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert user.image == "/api/chat-images/" + "a" * 32 + ".png"
|
||||||
|
assert user.answer_image is None
|
||||||
|
dump = user.model_dump()
|
||||||
|
assert dump["image"] == user.image
|
||||||
|
assert "answer_image" not in dump
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_message_answer_image_without_image_url_parses() -> None:
|
||||||
|
"""``answer_image`` missing ``image_url`` parses — the field is
|
||||||
|
optional on :class:`SourceRef` (its own serializer then drops the
|
||||||
|
null on the way out)."""
|
||||||
|
msg = ChatMessage.model_validate(
|
||||||
|
{
|
||||||
|
"who": "brain",
|
||||||
|
"text": "hi",
|
||||||
|
"answer_image": {"source": "s", "path": "p.png", "title": "t"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert msg.answer_image is not None
|
||||||
|
assert msg.answer_image.image_url is None
|
||||||
|
assert msg.model_dump()["answer_image"] == {"source": "s", "path": "p.png", "title": "t"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_message_phase83_bounds_still_bind_the_answer_image_ref() -> None:
|
||||||
|
"""The phase-83 value bounds bind the ref's fields EXACTLY like
|
||||||
|
every other :class:`SourceRef` (``source`` ≤ 120, ``path`` ≤ 1000,
|
||||||
|
``title`` ≤ 500 — the ``documents`` column lengths): one over any
|
||||||
|
cap is a 422 at the boundary."""
|
||||||
|
for bad in (
|
||||||
|
{"source": "s" * 121, "path": "p", "title": "t"},
|
||||||
|
{"source": "s", "path": "p" * 1001, "title": "t"},
|
||||||
|
{"source": "s", "path": "p", "title": "t" * 501},
|
||||||
|
):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
ChatMessage.model_validate(
|
||||||
|
{"who": "brain", "text": "hi", "answer_image": bad}
|
||||||
|
)
|
||||||
|
# Exactly AT the caps: parses (a server-built ref from a full-length
|
||||||
|
# row fits by construction — the same pin as the done-event ref).
|
||||||
|
at_caps = ChatMessage.model_validate(
|
||||||
|
{
|
||||||
|
"who": "brain",
|
||||||
|
"text": "hi",
|
||||||
|
"answer_image": {"source": "s" * 120, "path": "p" * 1000, "title": "t" * 500},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert at_caps.answer_image is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Task 05 — bug fix: suggested-tier image docs earn their source chips
|
||||||
|
#
|
||||||
|
# ``merge_cited_docs`` (``app/api/chat.py``) composes the citation tier
|
||||||
|
# (the done frame's ``sources``): the phase-119 read-docs-only rule
|
||||||
|
# plus phase 124's single documented exception — a suggested-tier image
|
||||||
|
# doc's seed block IS its entire content (content == summary == the
|
||||||
|
# vision description, phase 122 task 03), so "suggested" ≡ "read" for
|
||||||
|
# it and it is auto-cited even on a zero-read grounded turn (a ``read``
|
||||||
|
# would add only the marker line — the bug: the chip never appeared).
|
||||||
|
|
||||||
|
from app.api.chat import merge_cited_docs # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _uc_doc(source: str, path: str, *, is_image: bool = False) -> Document:
|
||||||
|
"""A detached documents row (no DB) — ``merge_cited_docs`` touches
|
||||||
|
only the ``(source, path)`` identity and ``is_image``."""
|
||||||
|
return Document(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
source=source,
|
||||||
|
path=path,
|
||||||
|
full_path=f"/{source}/{path}",
|
||||||
|
title=path.rsplit("/", 1)[-1],
|
||||||
|
content="A red square." if is_image else "just text.",
|
||||||
|
content_hash="0" * 64,
|
||||||
|
created_at=_SHOW_FIXTURE_CREATED_AT,
|
||||||
|
is_image=is_image,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_cited_docs_zero_read_suggested_image_doc_is_cited() -> None:
|
||||||
|
"""(a) The bug fix: a suggested-tier IMAGE doc + zero reads → it
|
||||||
|
IS cited (the zero-read grounded turn — the phase-119 "chips
|
||||||
|
nothing" fast path — chips it; pre-phase-124 this returned
|
||||||
|
``[]``)."""
|
||||||
|
img = _uc_doc("docs", "pic.png", is_image=True)
|
||||||
|
assert merge_cited_docs([], [img], deflected=False) == [img]
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_cited_docs_read_image_doc_appears_once_at_read_position() -> None:
|
||||||
|
"""(b) Dedup: an image doc the agent ALSO explicitly read appears
|
||||||
|
exactly ONCE — at its READ position (the read loop sees it first;
|
||||||
|
the suggestion merge must not re-add or relocate it)."""
|
||||||
|
img = _uc_doc("docs", "pic.png", is_image=True)
|
||||||
|
text = _uc_doc("docs", "notes.md")
|
||||||
|
merged = merge_cited_docs([img, text], [img, text], deflected=False)
|
||||||
|
assert merged == [img, text] # once each, read order
|
||||||
|
assert merged.count(img) == 1
|
||||||
|
# Read position preserved even when the suggestion order differs:
|
||||||
|
# the image doc read SECOND stays second (the suggested tier does
|
||||||
|
# not move read docs).
|
||||||
|
merged = merge_cited_docs([text, img], [img], deflected=False)
|
||||||
|
assert merged == [text, img]
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_cited_docs_zero_read_suggested_text_doc_not_cited() -> None:
|
||||||
|
"""(c) The phase-119 contract intact: a suggested TEXT doc + zero
|
||||||
|
reads → NOT cited (auto-citation is image docs only — a zero-read
|
||||||
|
text turn still chips nothing)."""
|
||||||
|
text = _uc_doc("docs", "notes.md")
|
||||||
|
assert merge_cited_docs([], [text], deflected=False) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_cited_docs_deflected_cites_nothing() -> None:
|
||||||
|
"""(d) A deflected turn cites NOTHING (phase 112) — even when the
|
||||||
|
suggested tier (the weak hits, A3) holds image docs and even when
|
||||||
|
``read_docs`` is non-empty by construction (deflected turns run no
|
||||||
|
agent — the guard makes the contract explicit)."""
|
||||||
|
img = _uc_doc("docs", "pic.png", is_image=True)
|
||||||
|
assert merge_cited_docs([], [img], deflected=True) == []
|
||||||
|
assert merge_cited_docs([img], [img], deflected=True) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_cited_docs_two_suggested_image_docs_in_suggestion_order() -> None:
|
||||||
|
"""(e) Two suggested image docs (zero reads, text docs interleaved
|
||||||
|
in the tier) → BOTH are cited, in SUGGESTION order (the retrieval
|
||||||
|
rank is the chip order) — the text members are skipped."""
|
||||||
|
text1 = _uc_doc("docs", "a.md")
|
||||||
|
second = _uc_doc("docs", "b.png", is_image=True)
|
||||||
|
text2 = _uc_doc("docs", "c.md")
|
||||||
|
first = _uc_doc("docs", "a.png", is_image=True)
|
||||||
|
merged = merge_cited_docs([], [text1, second, text2, first], deflected=False)
|
||||||
|
assert merged == [second, first] # suggestion order, images only
|
||||||
|
# With a read doc present, the reads come first, then the image
|
||||||
|
# docs in suggestion order.
|
||||||
|
read = _uc_doc("docs", "read.md")
|
||||||
|
merged = merge_cited_docs([read], [second, first], deflected=False)
|
||||||
|
assert merged == [read, second, first]
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_cited_docs_read_order_preserved_for_mixed_read_docs() -> None:
|
||||||
|
"""(f) Read order is preserved for mixed read text docs and
|
||||||
|
duplicate reads collapse (dedupe by ``(source, path)``, first
|
||||||
|
occurrence wins) — with the suggested image doc appended AFTER
|
||||||
|
the reads."""
|
||||||
|
a = _uc_doc("docs", "a.md")
|
||||||
|
b = _uc_doc("docs", "b.md")
|
||||||
|
img = _uc_doc("docs", "pic.png", is_image=True)
|
||||||
|
merged = merge_cited_docs([b, a, b], [img, a], deflected=False)
|
||||||
|
assert merged == [b, a, img]
|
||||||
|
# A duplicate image read collapses too (once, at its read
|
||||||
|
# position) — the suggestion merge adds nothing for it.
|
||||||
|
merged = merge_cited_docs([img, img, a], [img], deflected=False)
|
||||||
|
assert merged == [img, a]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Task 06 — the answer figure: live render, persistence, restore, shared
|
||||||
|
#
|
||||||
|
# House-style frontend source contracts (the phase-122/123 pattern —
|
||||||
|
# the browser-level scenario is task 07's isolated E2E): the chat
|
||||||
|
# page's ``appendAnswerImage`` helper (lazy img, the public bytes
|
||||||
|
# route, the visible caption, the collapse-on-error rule, the chip
|
||||||
|
# navigation affordance), its three wiring points (the ``done``
|
||||||
|
# handler BEFORE the chip row, the ``rememberBrainTurn`` meta key, the
|
||||||
|
# ``renderStoredMessage`` restore BEFORE the chip row), the shared
|
||||||
|
# page's NON-INTERACTIVE local copy (zero controls), and the
|
||||||
|
# answer-scale themed CSS (token-only, the 320px cap).
|
||||||
|
|
||||||
|
import re # noqa: E402
|
||||||
|
from pathlib import Path # noqa: E402
|
||||||
|
|
||||||
|
FRONTEND = Path(__file__).resolve().parents[2] / "frontend" # noqa: E402
|
||||||
|
APP_JS = FRONTEND / "assets" / "app.js" # noqa: E402
|
||||||
|
SHARED_JS = FRONTEND / "assets" / "shared.js" # noqa: E402
|
||||||
|
STYLES_CSS = FRONTEND / "assets" / "styles.css" # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _function_body(js: str, header: str) -> str:
|
||||||
|
"""The full text of the function whose header is ``header`` — from
|
||||||
|
the header to its brace-matched closing ``}`` (the
|
||||||
|
``test_image_documents.py`` house helper; the pinned functions' code
|
||||||
|
carries balanced braces)."""
|
||||||
|
start = js.index(header)
|
||||||
|
body_open = js.index("{", start)
|
||||||
|
depth = 0
|
||||||
|
for j in range(body_open, len(js)):
|
||||||
|
if js[j] == "{":
|
||||||
|
depth += 1
|
||||||
|
elif js[j] == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return js[start : j + 1]
|
||||||
|
raise AssertionError(f"unbalanced braces for {header!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _js() -> str:
|
||||||
|
return APP_JS.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _shared_js() -> str:
|
||||||
|
return SHARED_JS.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _css() -> str:
|
||||||
|
return STYLES_CSS.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_answer_image_helper_builds_the_lazy_capturing_figure() -> None:
|
||||||
|
"""The chat page's ``appendAnswerImage`` (the visual sibling of
|
||||||
|
``appendSourceImageFigure`` — the house createElement style, no
|
||||||
|
HTML strings): the figure anchors on the chip's navigation
|
||||||
|
affordance (the ``documentUrl`` href + the phase-26 same-page
|
||||||
|
modal), the img is LAZY on the public bytes route, alt + the
|
||||||
|
visible caption start at the TITLE and swap to the document's
|
||||||
|
SUMMARY via the SAME ``fetchContentSummary`` pattern (fails soft —
|
||||||
|
the title stands), and a FAILED IMAGE LOAD removes the whole
|
||||||
|
figure (the collapse rule — never a broken icon)."""
|
||||||
|
body = _function_body(_js(), "function appendAnswerImage(wrap, ref) {")
|
||||||
|
assert 'fig.className = "answer-image";' in body
|
||||||
|
assert 'fig.href = documentUrl(ref.source, ref.path, "/");' in body
|
||||||
|
assert "openDocumentModal(ref.source, ref.path, fig);" in body
|
||||||
|
assert "e.preventDefault();" in body # no new tab (phase 26)
|
||||||
|
assert 'img.className = "answer-image-img";' in body
|
||||||
|
assert "img.src = ref.image_url;" in body # the public bytes route
|
||||||
|
assert 'img.loading = "lazy";' in body # A4: lazy-loaded
|
||||||
|
assert "img.alt = ref.title || label;" in body # title first (always visible)
|
||||||
|
assert 'caption.className = "answer-image-caption";' in body
|
||||||
|
assert "caption.textContent = ref.title || label;" in body # visible caption
|
||||||
|
assert "fetchContentSummary(ref.source, ref.path)" in body
|
||||||
|
assert "img.alt = summary;" in body # the swap once it settles
|
||||||
|
assert "caption.textContent = summary;" in body
|
||||||
|
assert "if (!fig.isConnected) return;" in body # collapsed/cleared → no swap
|
||||||
|
assert "fig.remove();" in body # the collapse-on-error rule
|
||||||
|
# The guard: no ref (or a malformed one) → no figure DOM at all.
|
||||||
|
assert "!ref" in body
|
||||||
|
assert 'typeof ref.image_url !== "string"' in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_done_handler_renders_the_figure_before_the_chips() -> None:
|
||||||
|
"""A4 position, live path: the ``done`` handler appends the figure
|
||||||
|
to ``.msg-body`` right BEFORE ``appendSources`` creates the chip
|
||||||
|
row (below the text, above the chips) — and only when the frame
|
||||||
|
carries the key (a frame without it renders byte-identically,
|
||||||
|
no figure DOM)."""
|
||||||
|
js = _js()
|
||||||
|
call = "if (ev.answer_image) appendAnswerImage(wrap, ev.answer_image);"
|
||||||
|
assert call in js
|
||||||
|
done = js.index('} else if (ev.type === "done") {')
|
||||||
|
next_branch = js.index('} else if (ev.type === "tool_result") {')
|
||||||
|
handler = js[done:next_branch]
|
||||||
|
assert call in handler
|
||||||
|
assert handler.index(call) < handler.index("appendSources(wrap, ev.sources);")
|
||||||
|
|
||||||
|
|
||||||
|
def test_done_handler_persists_the_answer_image_meta() -> None:
|
||||||
|
"""The done-time save point carries the key (the house
|
||||||
|
undefined-drops-the-key pattern — the ``related`` line is the
|
||||||
|
precedent): image-less / pre-phase turns store byte-identical
|
||||||
|
records; a record WITH the key round-trips through the task-04
|
||||||
|
boundary (``ChatMessage.answer_image``)."""
|
||||||
|
js = _js()
|
||||||
|
done = js.index('} else if (ev.type === "done") {')
|
||||||
|
next_branch = js.index('} else if (ev.type === "tool_result") {')
|
||||||
|
handler = js[done:next_branch]
|
||||||
|
assert "answer_image: ev.answer_image || undefined," in handler
|
||||||
|
|
||||||
|
|
||||||
|
def test_stored_message_restore_renders_the_figure_before_the_chips() -> None:
|
||||||
|
"""The refresh landing: ``renderStoredMessage``'s brain branch
|
||||||
|
renders ``m.answer_image`` through the SAME helper at the SAME A4
|
||||||
|
position (before the chip row) — a record without the key renders
|
||||||
|
byte-identically (pre-phase chats restore unchanged)."""
|
||||||
|
body = _function_body(_js(), "function renderStoredMessage(m) {")
|
||||||
|
call = "if (m.answer_image) appendAnswerImage(wrap, m.answer_image);"
|
||||||
|
assert call in body
|
||||||
|
assert body.index(call) < body.index("appendSources(wrap, m.sources);")
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_page_figure_is_non_interactive() -> None:
|
||||||
|
"""The shared page's local copy (the per-page duplication house
|
||||||
|
style): a PLAIN non-interactive ``<figure>`` (owner-locked zero
|
||||||
|
controls — no anchor, no href, no click handler), the LAZY img on
|
||||||
|
the PUBLIC bytes route (the guest view is faithful — the
|
||||||
|
phase-123 user-image contract), the title as the always-visible
|
||||||
|
caption (the content endpoint is user-gated — a guest fetch would
|
||||||
|
401; no fetch, the title stands), and the same collapse-on-error
|
||||||
|
rule. Wired where the brain branch appends sources (figure
|
||||||
|
BEFORE the chips)."""
|
||||||
|
shared = _shared_js()
|
||||||
|
body = _function_body(shared, "function addAnswerImage(wrap, ref) {")
|
||||||
|
assert 'document.createElement("figure")' in body # plain figure, no anchor
|
||||||
|
assert 'fig.className = "answer-image";' in body
|
||||||
|
assert 'document.createElement("a")' not in body # zero controls
|
||||||
|
assert ".href" not in body
|
||||||
|
assert 'addEventListener("click"' not in body
|
||||||
|
assert 'img.className = "answer-image-img";' in body
|
||||||
|
assert "img.src = ref.image_url;" in body # the public bytes route
|
||||||
|
assert 'img.loading = "lazy";' in body
|
||||||
|
assert "img.alt = title;" in body
|
||||||
|
assert 'caption.className = "answer-image-caption";' in body
|
||||||
|
assert "caption.textContent = title;" in body
|
||||||
|
assert "fig.remove()" in body # the collapse-on-error rule
|
||||||
|
assert "fetchContentSummary" not in body # user-gated endpoint — no guest fetch
|
||||||
|
# The brain branch wiring: figure BEFORE the chips.
|
||||||
|
msg = _function_body(shared, "function renderSharedMessage(m) {")
|
||||||
|
assert "addAnswerImage(wrap, m.answer_image);" in msg
|
||||||
|
assert msg.index("addAnswerImage(wrap, m.answer_image);") < msg.index(
|
||||||
|
"addSources(wrap, m.sources);"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_answer_image_css_is_answer_scale_and_themed() -> None:
|
||||||
|
"""The ``.answer-image`` family (the ``.source-image`` language at
|
||||||
|
answer scale, the zero-literal invariant so the monochrome themes
|
||||||
|
carry it): the img is block, full-column-width safe, CAPPED at
|
||||||
|
320px, contain on the theme's surface with the bordered-image
|
||||||
|
treatment; the caption is the AA-safe muted ink (8.6:1 on the
|
||||||
|
row's page background); the hover underline is scoped to the chat
|
||||||
|
page's ANCHOR figure only (the shared page's plain ``<figure>`` is
|
||||||
|
out of scope by element type)."""
|
||||||
|
css = _css()
|
||||||
|
for selector in (".answer-image", ".answer-image-img", ".answer-image-caption"):
|
||||||
|
assert re.search(rf"^{re.escape(selector)} \{{", css, re.M), f"missing rule: {selector}"
|
||||||
|
img = re.search(r"^\.answer-image-img \{\n([\s\S]*?)\n\}", css, re.M)
|
||||||
|
assert img, "missing .answer-image-img rule"
|
||||||
|
img_body = img.group(1)
|
||||||
|
assert "max-height: 320px" in img_body # the answer-scale cap
|
||||||
|
assert "object-fit: contain" in img_body
|
||||||
|
assert "display: block" in img_body
|
||||||
|
assert "background: var(--surface)" in img_body # the theme's surface
|
||||||
|
assert "border: 1px solid var(--line)" in img_body
|
||||||
|
assert "max-width: 100%" in img_body
|
||||||
|
cap = re.search(r"^\.answer-image-caption \{\n([\s\S]*?)\n\}", css, re.M)
|
||||||
|
assert cap, "missing .answer-image-caption rule"
|
||||||
|
assert "color: var(--ink-soft)" in cap.group(1) # AA: 8.6:1 on --bg
|
||||||
|
base = re.search(r"^\.answer-image \{\n([\s\S]*?)\n\}", css, re.M)
|
||||||
|
assert base and "max-width: 100%" in base.group(1) # full bubble width
|
||||||
|
hover = re.search(r"^a\.answer-image:hover \{([^}]*)\}", css, re.M)
|
||||||
|
assert hover and "text-decoration: underline" in hover.group(1), (
|
||||||
|
"the chat-page anchor figure keeps the flat-underline hover"
|
||||||
|
)
|
||||||
|
# Zero literals: the whole phase-124 block is token-only (no hex
|
||||||
|
# colors — the monochrome theme grays it automatically).
|
||||||
|
start = css.index("/* Phase 124 (task 06")
|
||||||
|
end = css.index("/* Phase 113 (task 02): the related-docs row")
|
||||||
|
assert start != -1 and end > start
|
||||||
|
assert re.search(r"#[0-9a-fA-F]{3,6}\b", css[start:end]) is None
|
||||||
@@ -34,6 +34,7 @@ the LOW prompt and ``PERSONA`` / ``TOOLS_SECTION`` stay byte-identical
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ from app.config import Settings
|
|||||||
from app.models import Document
|
from app.models import Document
|
||||||
from app.rag.prompts import (
|
from app.rag.prompts import (
|
||||||
PERSONA,
|
PERSONA,
|
||||||
|
SHOW_IMAGE_CLAUSE,
|
||||||
SUGGEST_INTRO,
|
SUGGEST_INTRO,
|
||||||
TOOLS_SECTION,
|
TOOLS_SECTION,
|
||||||
_base,
|
_base,
|
||||||
@@ -1015,3 +1017,127 @@ def test_plain_text_line_never_leaks_into_high_prompt() -> None:
|
|||||||
high = build_high_prompt([doc], notes=notes, kb_overview=kb)
|
high = build_high_prompt([doc], notes=notes, kb_overview=kb)
|
||||||
assert PLAIN_TEXT_ONLY_LINE not in high
|
assert PLAIN_TEXT_ONLY_LINE not in high
|
||||||
assert "you have no tools" not in high
|
assert "you have no tools" not in high
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- phase 124 (task 03, LOCKED A5): the show_image prompt clause ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_image_clause_states_the_four_gate_elements() -> None:
|
||||||
|
"""LOCKED A5: the clause states ALL FOUR gate elements, pinned on
|
||||||
|
stable phrases, and references the tool by its backticked name (the
|
||||||
|
``TOOLS_SECTION`` voice). (1) the image-doc nature — its text is a
|
||||||
|
description GENERATED from the image; (2) the "contains the answer"
|
||||||
|
bar — never a merely related image, never padding; (3) at most one
|
||||||
|
image per answer; (4) the text answer is still provided in full."""
|
||||||
|
assert "`show_image`" in SHOW_IMAGE_CLAUSE # backticked, like the other tools
|
||||||
|
assert "description GENERATED from the image" in SHOW_IMAGE_CLAUSE # (1)
|
||||||
|
assert "what the user actually wants to see" in SHOW_IMAGE_CLAUSE # (2)
|
||||||
|
assert "never for a merely related image" in SHOW_IMAGE_CLAUSE # (2)
|
||||||
|
assert "never to pad an answer" in SHOW_IMAGE_CLAUSE # (2)
|
||||||
|
assert "at most one image per answer" in SHOW_IMAGE_CLAUSE # (3)
|
||||||
|
assert "answer fully in text" in SHOW_IMAGE_CLAUSE # (4)
|
||||||
|
# It is a clause, not a section: no ``<tools>`` markup of its own
|
||||||
|
# (the ``<documents>`` mention is plain text, as in the section copy).
|
||||||
|
assert "<tools>" not in SHOW_IMAGE_CLAUSE
|
||||||
|
assert "</tools>" not in SHOW_IMAGE_CLAUSE
|
||||||
|
|
||||||
|
|
||||||
|
def test_high_prompt_default_stays_byte_identical_to_pre_task() -> None:
|
||||||
|
"""Completion pin: ``build_high_prompt(docs)`` (no new arg) and the
|
||||||
|
explicit ``show_image=False`` are byte-identical to each other and
|
||||||
|
end with the untouched ``TOOLS_SECTION`` — the pre-task pins above
|
||||||
|
(and the sha pins in :mod:`tests.unit.test_prompt_lock`) pass
|
||||||
|
UNMODIFIED, with or without documents."""
|
||||||
|
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
|
||||||
|
plain = build_high_prompt([doc])
|
||||||
|
assert build_high_prompt([doc], show_image=False) == plain
|
||||||
|
assert plain.endswith(TOOLS_SECTION)
|
||||||
|
assert SHOW_IMAGE_CLAUSE not in plain
|
||||||
|
empty = build_high_prompt([])
|
||||||
|
assert build_high_prompt([], show_image=False) == empty
|
||||||
|
assert empty.endswith(TOOLS_SECTION)
|
||||||
|
assert SHOW_IMAGE_CLAUSE not in empty
|
||||||
|
|
||||||
|
|
||||||
|
def test_high_prompt_show_image_clause_lands_inside_tools_after_the_copy() -> None:
|
||||||
|
"""Phase 124 (task 03): ``show_image=True`` builds EXACTLY the
|
||||||
|
toggle-off prompt with its final ``</tools>`` replaced by the clause
|
||||||
|
(on its own line) + the closing tag — the prefix up to the clause
|
||||||
|
is byte-identical to the toggle-off output (the three-tool copy
|
||||||
|
untouched), the clause lands AFTER the existing copy and BEFORE the
|
||||||
|
closing tag, and appears exactly once."""
|
||||||
|
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
|
||||||
|
off = build_high_prompt([doc])
|
||||||
|
on = build_high_prompt([doc], show_image=True)
|
||||||
|
# The full structural equation (the clause is the ONLY difference).
|
||||||
|
assert on == off[: -len("</tools>")] + SHOW_IMAGE_CLAUSE + "\n</tools>"
|
||||||
|
clause_start = on.index(SHOW_IMAGE_CLAUSE)
|
||||||
|
# The prefix up to the clause is byte-identical to the toggle-off
|
||||||
|
# build minus its closing tag.
|
||||||
|
assert on[:clause_start] == off[: -len("</tools>")]
|
||||||
|
# From the <tools> opener to the clause is TOOLS_SECTION's own text
|
||||||
|
# minus its final </tools> — the constant itself is unedited (sha-
|
||||||
|
# pinned in test_prompt_lock) and the three-tool copy is intact.
|
||||||
|
assert on[on.index("<tools>") : clause_start] == TOOLS_SECTION[: -len("</tools>")]
|
||||||
|
assert "Answer as soon as you have what you need." in on[:clause_start]
|
||||||
|
# The clause (then the closing tag) is the tail of the prompt, after
|
||||||
|
# the existing copy and before the closing tag — exactly once.
|
||||||
|
assert on.endswith(SHOW_IMAGE_CLAUSE + "\n</tools>")
|
||||||
|
assert on.index("Answer as soon as you have what you need.") < clause_start
|
||||||
|
assert clause_start < on.index("</tools>")
|
||||||
|
assert on.count(SHOW_IMAGE_CLAUSE) == 1
|
||||||
|
assert on.count("<tools>") == 1 and on.count("</tools>") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_image_clause_rides_only_the_high_prompt() -> None:
|
||||||
|
"""The clause appears ONLY in the HIGH prompt: the deflection prompt
|
||||||
|
takes NO ``show_image`` flag at all and its output never carries the
|
||||||
|
clause (byte-identical builds with/without notes/overview — a
|
||||||
|
deflected turn has no tools and never shows an image, the phase's
|
||||||
|
locked design)."""
|
||||||
|
assert "show_image" not in inspect.signature(build_deflect_prompt).parameters
|
||||||
|
for prompt in (
|
||||||
|
build_deflect_prompt(["T1", "T2"]),
|
||||||
|
build_deflect_prompt(["T1"], notes=["be concise"], kb_overview=OVERVIEW),
|
||||||
|
build_deflect_prompt([]),
|
||||||
|
):
|
||||||
|
assert SHOW_IMAGE_CLAUSE not in prompt
|
||||||
|
assert "`show_image`" not in prompt
|
||||||
|
assert "at most one image per answer" not in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_high_prompt_empty_documents_show_image_clause_still_in_tools() -> None:
|
||||||
|
"""The "(no documents matched…)" body + ``show_image=True``: the
|
||||||
|
clause still lands inside ``<tools>`` — the grounded turn may still
|
||||||
|
``ls``/``grep`` its way to an image doc and show it."""
|
||||||
|
off = build_high_prompt([])
|
||||||
|
on = build_high_prompt([], show_image=True)
|
||||||
|
assert on == off[: -len("</tools>")] + SHOW_IMAGE_CLAUSE + "\n</tools>"
|
||||||
|
assert "(no documents matched — do not invent specifics)" in on
|
||||||
|
assert on.index("<tools>") < on.index(SHOW_IMAGE_CLAUSE) < on.index("</tools>")
|
||||||
|
assert on.endswith(SHOW_IMAGE_CLAUSE + "\n</tools>")
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_image_clause_is_last_for_every_high_shape() -> None:
|
||||||
|
"""Steering + KB overview + folder lines + documents all present:
|
||||||
|
the section order is UNCHANGED (``<relevance>`` →
|
||||||
|
``<knowledge_base>`` → ``<tuning>`` → ``<documents>`` → ``<tools>``)
|
||||||
|
and the clause is still the last thing the model reads, inside
|
||||||
|
``<tools>`` after the three-tool copy."""
|
||||||
|
doc = _doc("deploy/k8s.md", "CONTENT_SENTINEL", "K8s Deploy", summary="K8s deploy notes.")
|
||||||
|
lines = ["Homelab/deploy/: k8s.md (1 doc)"]
|
||||||
|
off = build_high_prompt([doc], notes=["be concise"], kb_overview=OVERVIEW, folder_lines=lines)
|
||||||
|
on = build_high_prompt(
|
||||||
|
[doc],
|
||||||
|
notes=["be concise"],
|
||||||
|
kb_overview=OVERVIEW,
|
||||||
|
folder_lines=lines,
|
||||||
|
show_image=True,
|
||||||
|
)
|
||||||
|
assert on == off[: -len("</tools>")] + SHOW_IMAGE_CLAUSE + "\n</tools>"
|
||||||
|
clause_start = on.index(SHOW_IMAGE_CLAUSE)
|
||||||
|
assert on[on.index("<tools>") : clause_start] == TOOLS_SECTION[: -len("</tools>")]
|
||||||
|
for section in ("<relevance>", "<knowledge_base>", "<tuning>", "<documents>"):
|
||||||
|
assert on.index(section) < on.index("<tools>")
|
||||||
|
assert on.index("K8s deploy notes.") < on.index("<tools>") # the summary still seeds
|
||||||
|
assert SHOW_IMAGE_CLAUSE not in off # the toggle-off twin carries none of it
|
||||||
|
|||||||
Reference in New Issue
Block a user