chore(agents): phase roadmap from TODO.md, 3 phases

Converts the three open TODO items into executable phases (Protocol B
append, numbered after the existing 149):

- 150_drop_sync_result — retire span.sync-result; #sync-progress becomes
  the single sync surface and the sole aria-live region (TODO L5)
- 151_pdf_image_extraction — dedupe PDF image occurrences (46 placements
  -> 15 unique in podman-systemd.pdf), composite /SMask so masked images
  are not blank, and skip near-white decorative underlays (TODO L4)
- 152_sync_progress_image_pass — report the PDF vision-image position so
  the sync label stops freezing at the last chunk (TODO L3)

Both reported bugs were reproduced against test_sources/podman-systemd.pdf
before authoring; root causes are recorded in the phase overviews. The
TODO file is cleared now that its items live in .agents/phases/.

LLM-Generated: true
This commit is contained in:
2026-10-01 22:12:10 -04:00
parent dd524246ec
commit 9d4c813cae
21 changed files with 712 additions and 3 deletions
@@ -0,0 +1,47 @@
# Phase 150 — retire `span.sync-result`: `#sync-progress` is the single sync text surface
**Source:** `TODO.md` L5 — "Get rid of the span sync-result now that we have the div sync-progress"
**Story:** n/a (TODO-derived phase, Protocol B append).
**Context:** the Sources page (`frontend/index.html`, the `view-rag` section). Phase 149 added `#sync-progress` (L493–495 — `<div class="sync-progress" id="sync-progress" hidden aria-hidden="true">` + the inner `<span class="sync-progress-text" id="sync-progress-text">`), the bordered panel under the button row that carries the full untruncated live label while a run is in flight. The pre-149 announcer survived beside it: `<span class="sync-result" id="sync-result" role="status" aria-live="polite">` (L511) — today the SOLE aria-live region, which receives (a) the same live label while a run runs (a byte-identical duplicate of the box), (b) the settle counts line (`fmtSyncResult(detail)` — "N added · M updated · …"), (c) the "Sync cancelled" line, (d) the cancel-request failure copy, (e) `""` on the sync-failure / idle / upload-settle paths (plus the settle label "Synced HH:MM" and the idle "Sync sources", written through the shared writer). The single writer is `frontend/assets/sources.js` `setSyncLabel(mainText, countText, chunkText, running)` (L809–843): it writes the button parts, the `title`, the box text + `hidden = !running`, the button `aria-label`, AND `syncResult.textContent = full` (L834). The `syncResult` element handle is L750; the other write sites are L1092 (`applySyncSuccess` — the counts line), L1107 (`applySyncIdle` — clear), L1125 (`applySyncCancelled` — "Sync cancelled"), L1213/L1224 (the upload settles — clear), L1291/L1298/L1310 (the cancel paths — clear + "Couldn't cancel the sync — try again."). CSS: `.sync-progress` L2252, `.sync-progress-text` L2259, `.sync-result` L2284–2290, the mobile `.sync-result` override at L5639, and the "the sync-result shape" comments at L2938 / L3659 (those rules belong to OTHER elements — the upload success line and the action feedback line — and keep their own classes).
**Locked (owner decision, chat — this phase):** `#sync-progress` becomes the **single** sync text surface AND the **sole** aria-live region: `role="status" aria-live="polite"` moves onto the box, `aria-hidden` is dropped, and `span.sync-result` is **deleted** from the template. The box keeps the phase-149 running behavior AND takes over the announcer's content: it is visible while a run is in flight (the live label) and stays visible after a settle to carry the result line (the counts / "Sync cancelled" / cancel-failure copy), hiding only when its text is empty (idle, upload settles, failures — exactly the states where `#sync-result` is empty today). Every string that reaches `#sync-result` today reaches the box instead — no copy changes.
## Objective
One element, one live region: the sync/upload live label, the settle result line, the cancel line and the click copy all render in `#sync-progress`, which announces them exactly once; `#sync-result` no longer exists in the DOM, the JS, or the CSS.
## Dependencies
- `149_sync_progress_box` (complete) — the box, its single-writer wiring, and its CSS are what this phase folds the announcer into. Pipeline order only: no phase is pending in `todo/`.
## Design (shared by all tasks — the executor reads this, not the chat)
- **The element (task 01):** `#sync-progress` keeps its id, class, and inner `#sync-progress-text` span (the textContent target — the house rule: text only, never HTML) and gains `role="status" aria-live="polite`; `aria-hidden="true"` is REMOVED (an aria-live region that is aria-hidden announces nothing — the two attributes are mutually exclusive by intent). It stays ship-hidden (`hidden`) — the anonymous-safe/idle state. `span.sync-result` (L511) and its explanatory comment block (L496–510) are deleted; the box's comment (L483–492) is rewritten to say it is now the SOLE announcer (the phase-149 "presentational (aria-hidden)" sentence is retired — leave a one-line note that phase 150 folded the announcer in, the file's pinned-comment convention).
- **The writer (task 02):** introduce ONE function that owns the box — `setSyncProgress(text)` — which sets `syncProgressText.textContent = text` and `syncProgress.hidden = (text === "")` (the global `[hidden]` rule does the display). Two content modes, decided at the callsites (the same "the callsites are the only decision sites" rule phase 149 established):
* **running mode** — `setSyncLabel(main, count, chunk, running)` keeps writing the box with `full` **only when `running === true`** (exactly today's reveal rule). When `running === false` it does NOT touch the box: the idle/settle button labels ("Sync sources", "Synced HH:MM") must not fill a big panel (today they land on the small `#sync-result` line where they read as a duplicate of the button — that artifact is retired, not migrated).
* **result mode** — the settle paths call `setSyncProgress(text)` explicitly: `applySyncSuccess` → `fmtSyncResult(status.detail)` (the counts line), `applySyncCancelled` → `"Sync cancelled"`, `applySyncFailure` → `""` (the banner + modal carry the error), `applySyncIdle` → `""`, the two upload settles → `""` (the upload result lives on the Sources page — phase 90 A3), `requestCancel`'s network/non-2xx branches → `"Couldn't cancel the sync — try again."` (string unchanged). `requestCancel`'s opening clear (L1291, which today blanks the announcer while the run is still in flight) becomes a NO-OP on the box — the live label stays up until the poll's `cancelled` branch settles it (blanking would hide the running surface for up to one 2 s poll).
The `syncResult` handle (L750) and every `if (syncResult)` guard are deleted. There is no 202/409 click copy: `startSync`'s 202/409 branch enters the running state (L1255–1262), so the box reveals the bare "Syncing…" label through `setSyncLabel` as today.
- **The CSS (task 03):** delete the `.sync-result` rule (L2284–2290) and the mobile `.sync-result` override (L5639); the two "the sync-result shape" comments (L2938, L3659) are reworded to reference `.sync-progress-text` (they describe the mono/soft-ink language those other elements share — the rules themselves and their selectors are untouched). `.sync-progress` needs NO new rule for the settled state (the panel already reads as a calm box; the global `[hidden] { display: none !important }` rule keeps it gone when empty — verify it still applies, the box sets no `display` of its own). Contrast stays AA: `.sync-progress-text` uses `--ink-soft` on `--surface` (8.2:1 built-in dark, AA in both built-ins).
- **The test migration (tasks 04/05):** mechanical selector migration — `#sync-result` → `#sync-progress` (assert on the box's text, i.e. `#sync-progress-text` for textContent, `#sync-progress` for `hidden`/attributes). Settled-state copy assertions must stay byte-identical (the counts line string itself does not change).
- **ASSUMPTION A1 (owner-locked):** the box stays visible after settle to hold the counts line and hides only when its text is empty. The alternative (box running-only, result line elsewhere) was rejected.
- **ASSUMPTION A2:** the settle label "Synced HH:MM" is no longer written to the live region (it stays on the button, visible). The announcer hears the counts line that superseded it in the same tick a moment ago — the merge makes one surface, and a panel reading "Synced HH:MM" under a button reading "Synced HH:MM" is the duplicate this retirement exists to remove.
- **ASSUMPTION A3:** the box's settled appearance needs no new CSS (the same bordered panel reads fine for a result line); only the running-only min-height stays, so the panel does not jump between modes.
## Tasks
1. `01_html_single_surface.md` — delete `span#sync-result`, move `role=status aria-live=polite` onto `#sync-progress`, drop `aria-hidden`, rewrite the two comment blocks.
2. `02_js_single_writer.md` — `sources.js`: add `setSyncProgress`, route all 9 announcer sites through it, delete the `syncResult` handle, change the box visibility rule to text-driven.
3. `03_css_retire_sync_result.md` — `styles.css`: remove `.sync-result` + its mobile override, reword the two shape comments.
4. `04_unit_test_migration.md` — `tests/unit/test_sync_button.py` (20 refs) + `tests/unit/test_frontend_sync_upload.py` (26 refs) migrated to the box.
5. `05_e2e_sync_family.md` — the 12 sync-family E2E files' selectors migrated + the dedicated `tests/e2e/test_sync_progress_box.py` extended with the single-live-region + settled-line assertions.
6. `06_e2e_image_pdf_family.md` — the 6 image/PDF E2E files migrated (their sync-done barriers move to the box) + the a11y/no-CDN/full-gate pass and the phase commit.
## Testing & Quality
- Unit: the two frontend-parsing suites (`test_sync_button.py`, `test_frontend_sync_upload.py`) pin the writer contract on the box — running label visible + announced, settle counts line, "Sync cancelled", the idle/upload clears, the "Couldn't cancel" copy — and assert `#sync-result` is ABSENT from the template (the retirement is pinned, not implied).
- E2E: `uv run pytest tests/e2e/test_sync_progress_box.py -v --no-cov` in isolation (the slow-LLM proxy recipe already in that file) proves in-flight → settle → idle on the real sync path; every pre-existing sync/image/PDF E2E that touched the announcer passes after migration (12 sync-family files + 6 image/PDF files).
- Coverage: **>90%** on `app/` (this phase touches no `app/` logic — the floor must simply not regress).
- Accessibility gate (AGENTS.md rule 5): exactly ONE `aria-live` region in the sync area; the box is a `status` region (polite — no alert spam); the button keeps its accessible name in both states; focus-visible and contrast unchanged.
## Completion Criteria
- [ ] `grep -rn "sync-result\|syncResult" frontend/ tests/` returns NOTHING (the element, its class, and its JS handle are gone from source and tests)
- [ ] `#sync-progress` carries `role="status" aria-live="polite"` and no `aria-hidden`; it is the only `aria-live` element in the sync area
- [ ] in flight: the box shows the full untruncated live label (wrapping, never clipped); at settle: the box shows the counts line byte-identical to the pre-phase `#sync-result` string ("N added · …") and "Sync cancelled" for a cancel; at idle / upload-settle / sync-failure: the box is `hidden` and empty
- [ ] the button is still the icon-only spinner while running and restores "Sync sources" on settle (phase 149 behavior unchanged)
- [ ] `uv run pytest` green; `uv run pytest tests/e2e/test_sync_progress_box.py -v --no-cov` green in isolation; `uv run ruff check . && uv run pyright` clean; `app/` coverage >90%
- [ ] no behavior change in completed phases (the cancel control, the 409 double-trigger, the error banner/modal, the upload run's status, the sync status API)
- [ ] one atomic commit per AGENTS.md rule 8 (`--no-gpg-sign`)
@@ -0,0 +1,28 @@
# Task 01 — `index.html`: delete `span#sync-result`, make `#sync-progress` the live region
**Phase:** `150_drop_sync_result` · **Source:** `TODO.md:5` — "Get rid of the span sync-result now that we have the div sync-progress"
**Story:** n/a
## Objective
The template ships ONE sync text surface: `#sync-progress`, which is now the `role="status"` `aria-live="polite"` announcer; `span.sync-result` no longer exists in the DOM.
## Work
1. `frontend/index.html` (the `view-rag` Sources section, L483–511):
- `#sync-progress` (L493) becomes
`<div class="sync-progress" id="sync-progress" role="status" aria-live="polite" hidden>`
— `aria-hidden="true"` is REMOVED (an aria-live region must not be aria-hidden), `role`/`aria-live` are ADDED, `hidden` stays (ship-hidden = the idle state, anonymous-safe). The inner `<span class="sync-progress-text" id="sync-progress-text"></span>` (L494) is unchanged (it stays the textContent target).
- DELETE the `#sync-result` span (L511) and its comment block (L496–510, the "#sync-result is the aria-live announcer …" paragraph).
- REWRITE the box's own comment (L483–492) so it states the phase-150 contract: the box carries the full untruncated live label while a run is in flight AND the settle result line after it; it is the SOLE aria-live announcer for the sync area (one live region, no double announce); the button is the spinner while running (`.is-running` hides the label parts). Keep the file's comment voice (phase-prefixed, rationale-first).
2. Nothing else in the template changes: `#sync-btn` and its label parts, `#sync-cancel-btn`, `#sync-error-banner` / `#sync-error-text`, the sync modal, and the Sources gate are untouched.
3. No CDN/external references are introduced (AGENTS.md rule 6 — the page stays fully self-hosted).
4. ASSUMPTION: the box keeps its current position (between `.page-head` and the error banner) — the owner asked to remove the span, not to move the box.
## Testing & Quality
- Unit: this task's own gate is the template parse — the pre-existing suites that read `index.html` still find `#sync-progress` (with the new attributes) and no longer find `#sync-result`. Tasks 04/05 do the full migration; if a suite fails ONLY on the missing `#sync-result`, note it for task 04 rather than editing test expectations here beyond what the parse requires.
- Coverage: n/a (template task — `app/` coverage floor unchanged).
## Completion Criteria
- [ ] `grep -n "sync-result" frontend/index.html` returns nothing
- [ ] `#sync-progress` has `role="status"`, `aria-live="polite"`, `hidden`, and NO `aria-hidden`
- [ ] the sync area contains exactly one `aria-live` element
- [ ] `uv run pytest tests/unit -q` is green or failing ONLY on the `#sync-result` expectations tasks 04/05 own
@@ -0,0 +1,40 @@
# Task 02 — `sources.js`: one box writer (`setSyncProgress`), the announcer handle deleted
**Phase:** `150_drop_sync_result` · **Source:** `TODO.md:5` — "Get rid of the span sync-result now that we have the div sync-progress"
**Story:** n/a
## Objective
Every string that used to land on `#sync-result` lands on `#sync-progress` instead, written by one function; the `syncResult` handle is gone and the box's visibility is owned by that single writer.
## Work
1. `frontend/assets/sources.js` — add the box writer next to `setSyncLabel` (L809):
```js
function setSyncProgress(text) {
if (!syncProgressText || !syncProgress) return;
syncProgressText.textContent = text;
syncProgress.hidden = text === "";
}
```
(textContent only — the house rule, never HTML; the `hidden` attribute drives `display:none` through the global `[hidden]` rule.)
2. `setSyncLabel` (L809–843): replace the phase-149 pair `syncProgressText.textContent = full;` + `syncProgress.hidden = !running;` (L838–839) with `if (running) setSyncProgress(full);` — the running label still flows through the single writer, and a non-running call leaves the box alone (the idle/settle button labels "Sync sources" / "Synced HH:MM" must NOT fill the panel; the panel is the live label + the result line, nothing else). The `title`, the `aria-label` (`running ? full : "Sync sources"`), and the label parts are untouched. Delete `if (syncResult) syncResult.textContent = full;` (L834) — the box IS the announcer now.
3. Delete the element handle `const syncResult = root.querySelector("#sync-result");` (L750) and every `if (syncResult)` guard. Migrate the eight remaining announcer sites to `setSyncProgress(...)`, at the SAME position in the same order:
- `applySyncSuccess` (L1092) → `setSyncProgress(fmtSyncResult(status.detail))` (the counts line — the string builder is unchanged);
- `applySyncFailure` (L1107) → `setSyncProgress("")` (the banner + modal carry the error; the box hides);
- `applySyncCancelled` (L1125) → `setSyncProgress("Sync cancelled")`;
- `applySyncIdle` (L~1130) → `setSyncProgress("")` (the true idle — retry-ready, nothing to announce);
- the upload-success settle (L1213) and the upload-failed settle (L1224) → `setSyncProgress("")` (the upload's result line lives on the Sources page — phase 90 A3);
- `requestCancel`'s network-failure branch (L1298) and its non-2xx tail (L1310) → `setSyncProgress("Couldn't cancel the sync — try again.")` (string byte-identical);
- `requestCancel`'s opening clear (L1291) becomes a **no-op on the box** — the run is still in flight at that moment, so blanking would hide the running surface for up to one 2 s poll; the poll's `cancelled` branch settles it. Leave a short comment saying why the clear was dropped (the phase-134 "never stale" contract is kept by the control's immediate disable).
4. Update the module/function comments that name `#sync-result` as the announcer (the file cites it in ~15 comment lines — L160, 321, 375, 670, 677, 700, 706, 723, 744, 789, 859, 894, 949, 1006, 1120, 1209, 1256, 1274, 1286): rename the references to `#sync-progress` and keep the rationale (one live region, the untruncated label, A4). Do not change any behavior while doing it.
5. ASSUMPTION A2 (owner-visible): the settle label "Synced HH:MM" is no longer written to the live region — it stays on the button; the counts line is the announced result.
6. ASSUMPTION: the re-attach path (`initSyncButton`, the page-load mid-run entry) needs no new code — it goes through `enterSyncRunningState` → `setSyncLabel(..., true)`, which reveals the box (the never-stale contract holds).
## Testing & Quality
- Unit: task 04 migrates the two frontend suites; this task's own gate is `uv run pytest tests/unit -q` failing only on the expectations task 04 owns, and `grep -n "syncResult" frontend/assets/sources.js` returning nothing.
- Coverage: n/a (frontend task).
## Completion Criteria
- [ ] `grep -n "syncResult\|sync-result" frontend/assets/sources.js` returns nothing
- [ ] `setSyncProgress` is the only writer of the box's text/visibility; `setSyncLabel` writes it only when `running`
- [ ] the counts line, "Sync cancelled", and "Couldn't cancel the sync — try again." all render in `#sync-progress`; the idle/upload-settle/sync-failure states leave it hidden and empty
- [ ] the running label, the button `title`, and the button `aria-label` behavior are byte-identical to pre-phase
@@ -0,0 +1,27 @@
# Task 03 — `styles.css`: retire `.sync-result`, keep the box correct in both modes
**Phase:** `150_drop_sync_result` · **Source:** `TODO.md:5` — "Get rid of the span sync-result now that we have the div sync-progress"
**Story:** n/a
## Objective
No CSS rule targets the deleted element, and `#sync-progress` reads correctly as both the running label and the settled result line.
## Work
1. `frontend/assets/styles.css`:
- DELETE the `.sync-result` rule (L2284–2290 — `display:block; color:var(--ink-soft); font-family:var(--mono); font-size:0.8rem; white-space:nowrap; padding-block:0.5rem;`) and its "Last-result announcer" comment.
- DELETE the mobile `.sync-result` override inside the phone media query (L5639) — read it first and confirm it only tweaks the deleted element (if it also styles a surviving selector, keep that part).
- REWORD the two comments that reference "the sync-result shape" (L2938 — the upload success line `role=status`; L3659 — the action feedback line `role=status`) to name `.sync-progress-text` as the shared mono/soft-ink language. The RULES themselves (their own selectors) are untouched — only the cross-reference text changes.
- `.sync-progress` (L2252) / `.sync-progress-text` (L2259) need no new rule: the panel already carries the border/surface/radius/padding and the `min-height: 4rem`; the box sets no `display` of its own, so the global `[hidden] { display: none !important }` rule keeps it gone when empty (verify by reading that global rule — if it is scoped to something narrower, add `.sync-progress[hidden] { display: none; }`).
- Update the `.sync-progress-text` comments that say "the .sync-result mono language / size / ink" (L2261–2266) so they no longer cite a dead class (keep the contrast note: `--ink-soft` on `--surface` is a pinned AA pair).
2. No new colors, no new fonts, no external assets (AGENTS.md rule 6). Contrast stays ≥4.5:1 in both built-in themes (the ink/surface pairs are unchanged).
3. ASSUMPTION A3: the settled result line uses the same panel styling as the running label (no `.is-settled` variant) — the owner asked for one surface, not two looks.
## Testing & Quality
- Unit/E2E: the migrated suites (tasks 04/05) assert the box's visibility states; the theme suites (`tests/unit/test_theme*.py`, the custom-theme contrast gate) pass unchanged — no theme token changed.
- Coverage: n/a (CSS task).
## Completion Criteria
- [ ] `grep -n "sync-result" frontend/assets/styles.css` returns nothing
- [ ] `#sync-progress` is `display:none` while `hidden` and a bordered panel when shown, at desktop and 360 px
- [ ] the upload success line and the action feedback line still render exactly as before (their own rules untouched, only comments reworded)
- [ ] `uv run pytest tests/unit -q` green
@@ -0,0 +1,29 @@
# Task 04 — unit suites: migrate the `#sync-result` source pins to the box
**Phase:** `150_drop_sync_result` · **Source:** `TODO.md:5` — "Get rid of the span sync-result now that we have the div sync-progress"
**Story:** n/a
## Objective
The source-level suites that pin the sync UI no longer reference `#sync-result` / `.sync-result`, and they pin the new contract instead: one live region, box-driven.
## Work
1. `tests/unit/test_sync_button.py` (20 references) — the pins are regex/string reads of `frontend/index.html`, `frontend/assets/sources.js`, `frontend/assets/styles.css`:
- L232–241 (`test …sync-result…`): the "polite live region right after the button" pin moves to `#sync-progress` — assert the div carries `role="status"` and `aria-live="polite"`, does NOT carry `aria-hidden`, and sits after the button row; rename the test accordingly;
- ADD a pin that the retired element is gone: `assert 'id="sync-result"' not in index_html` (the retirement is pinned, not implied);
- L747–751 (the `.sync-result` CSS block pin): retarget to `.sync-progress-text` (assert the mono family, the `--ink-soft` color, the `overflow-wrap: anywhere`) and assert `.sync-result` has no rule in `styles.css`;
- L1984 (the DOM-order pin `main_end < cancel_start < sync-result`): re-anchor on `id="sync-progress"`;
- L371, 384, 465, 652, 2067 and the module docstring (L11, 43, 56): reword the "title + #sync-result" language to "title + #sync-progress" (the A4 untruncated-label contract is unchanged — it just names the surviving surface);
- the cancel-request pin (~L2067) that asserted the announcer clears on click: update to the new contract — the box is NOT blanked at cancel-request time (the live label stays until the poll settles), the control disables immediately.
2. `tests/unit/test_frontend_sync_upload.py` (26 references) — same migration: the `fmtSyncLabel` / `enterSyncRunningState` pins that assert the full untruncated label reaches the announcer now assert it reaches `#sync-progress` (via `setSyncProgress` / the `running` branch of `setSyncLabel`); the A3 pin ("the settle never renders upload counts into #sync-result") becomes "the upload settles write an empty string to the box" (`setSyncProgress("")`); the docstring references (L9, 12, 61, 79) are reworded.
3. Keep every assertion's INTENT: same strings, same states, one different selector. Do not weaken a pin to make it pass — if a behavior genuinely changed (the "Synced HH:MM" announcer write, the cancel-request clear), pin the NEW behavior explicitly and say why in the test docstring.
4. ASSUMPTION: no new test file is created for the unit layer — the retirement is a migration of existing pins plus the two "element is gone" assertions.
## Testing & Quality
- `uv run pytest tests/unit/test_sync_button.py tests/unit/test_frontend_sync_upload.py -v` green.
- `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage stays >90% (no `app/` change in this phase).
- Coverage: n/a for frontend source; the two suites must not lose assertions (compare the collected test count before/after — it must not drop).
## Completion Criteria
- [ ] `grep -rn "sync-result\|syncResult" tests/unit/` returns nothing
- [ ] both suites assert the box is the `role="status" aria-live="polite"` region and that `#sync-result` is absent from the template and from `styles.css`
- [ ] test count is not lower than before the migration; both files green
@@ -0,0 +1,31 @@
# Task 05 — E2E migration (sync family) + the dedicated `test_sync_progress_box.py` assertions
**Phase:** `150_drop_sync_result` · **Source:** `TODO.md:5` — "Get rid of the span sync-result now that we have the div sync-progress"
**Story:** n/a
## Objective
Every sync-flow Playwright suite drives and asserts the box instead of the deleted span, and the phase's dedicated suite proves the merged contract end to end (one live region, running label + settled result line).
## Work
1. Migrate the sync-family suites (the `sync-result` reference count in parentheses) — replace the selector `#sync-result` with the box (`#sync-progress` for visibility/attributes, `#sync-progress-text` for `text_content()`), keeping every expected STRING byte-identical:
- `tests/e2e/test_sync_button.py` (2), `tests/e2e/test_sync_cancel.py` (2 — the "Sync cancelled" line and the cancel lifecycle), `tests/e2e/test_sync_model_down.py` (2), `tests/e2e/test_sync_upload_progress.py` (2), `tests/e2e/test_upload_no_scan.py` (2), `tests/e2e/test_sync_summary_visibility.py` (3), `tests/e2e/test_sync_changed_only.py` (3), `tests/e2e/test_sync_label_mobile.py` (1), `tests/e2e/test_git_source_dates.py` (1), `tests/e2e/test_git_source_branch.py` (4), `tests/e2e/test_force_push_resync.py` (4), `tests/e2e/test_sync_chunk_progress.py` (8 — the "· chunk n/m" announcer pins).
- Where a suite asserted "the announcer is empty" it now asserts `expect(page.locator("#sync-progress")).to_be_hidden()`; where it asserted announcer text it asserts the box's text. A suite that asserted the announcer was VISIBLE while running keeps that assertion on the box (it is visible while running — phase 149).
2. `tests/e2e/test_sync_progress_box.py` (11 references) — the dedicated suite for this phase (A16: one file per story, run in isolation). Extend it, keeping the existing running-state proofs:
- **template pin:** exactly one `[aria-live]` element in the sync area, and it is `#sync-progress`; `#sync-result` is absent (`page.locator("#sync-result").count() == 0`);
- **running:** unchanged (icon-only button + the full untruncated label in the box, the long `source/relative/path` wrapping at 360 px and desktop);
- **settled success:** the box is VISIBLE and its text equals the counts line the pre-phase announcer produced (the `fmtSyncResult` shape — "N added · …"), the button reads "Synced HH:MM";
- **settled cancel:** the box reads "Sync cancelled" (the phase-134 neutral result — no banner, no modal);
- **settled failure:** the box is hidden/empty and the error banner + modal carry the error (unchanged);
- **idle re-attach:** a page load with no run leaves the box hidden.
3. Keep the suites MOCK-ONLY (the `tests/e2e/slow_llm.py` delay-proxy recipe already in the file) and run each migrated file in isolation while editing it: `uv run pytest tests/e2e/test_<name>.py -v --no-cov` (DB up: `podman compose up -d db`).
4. ASSUMPTION: no new E2E file — the phase's behavioral proof belongs to the existing `test_sync_progress_box.py` (the phase-149 dedicated suite is the box's suite; this phase extends it rather than forking a near-duplicate).
## Testing & Quality
- `uv run pytest tests/e2e/test_sync_progress_box.py -v --no-cov` green IN ISOLATION (the phase's gate file).
- Every migrated sync-family file green in isolation; then the full `uv run pytest` green.
- Coverage: >90% on `app/` (unchanged — no `app/` edit).
## Completion Criteria
- [ ] `grep -rln "sync-result" tests/e2e/` returns nothing after task 06 also lands (this task clears the sync family)
- [ ] the dedicated suite proves: one live region, running label in the box, counts line visible at settle, "Sync cancelled" on cancel, hidden on failure/idle
- [ ] `uv run pytest tests/e2e/test_sync_progress_box.py -v --no-cov` green in isolation
@@ -0,0 +1,30 @@
# Task 06 — E2E migration (image / PDF family) + the no-CDN and a11y gate pass
**Phase:** `150_drop_sync_result` · **Source:** `TODO.md:5` — "Get rid of the span sync-result now that we have the div sync-progress"
**Story:** n/a
## Objective
The remaining suites that touch the announcer — the image and PDF stories, which wait on sync result text — drive the box, and the whole gate passes with the span gone.
## Work
1. Migrate the image/PDF-family suites (reference count in parentheses), same rule as task 05 — selector changes only, expected strings byte-identical:
- `tests/e2e/test_image_documents.py` (2), `tests/e2e/test_image_anchor_context.py` (2), `tests/e2e/test_answer_images.py` (2), `tests/e2e/test_pdf_support.py` (2), `tests/e2e/test_pdf_image_context.py` (2), `tests/e2e/test_pictorial_pdf.py` (2).
- These use the announcer mostly as a SYNC-DONE barrier (wait for the counts line before querying the KB). Keep them as barriers, just on the box: wait for `#sync-progress-text` to contain the expected result text (or for the box to become visible with the counts line) — do NOT replace them with `wait_for_timeout`.
2. Confirm the no-CDN integration test still passes (AGENTS.md rule 6 — the index page has no external `<script src=>`/`<link href=>`): the template edit added no external reference.
3. Accessibility pass on the merged surface (AGENTS.md rule 5, PLAN §7): exactly one `aria-live` region in the sync area; `role="status"` (polite — the failure path keeps its `role="alert"` banner); the button keeps an accessible name in both states (`aria-label` full label while running, "Sync sources" otherwise); `:focus-visible` on the button + Cancel untouched; `--ink-soft` on `--surface` contrast ≥4.5:1 in both built-in themes (unchanged tokens).
4. Full gate: `uv run pytest` (unit + integration), `uv run pytest --cov=app --cov-report=term-missing` (>90%), `uv run ruff check . && uv run pyright` clean, and the phase's E2E file in isolation.
5. Commit the phase per AGENTS.md rule 8 — one atomic Conventional Commit, `--no-gpg-sign`, e.g.
`feat(ui): retire #sync-result; #sync-progress is the single sync surface + live region`
(includes `.agents/phases/todo/150_drop_sync_result/` moved to `.agents/phases/complete/`).
## Testing & Quality
- `uv run pytest tests/e2e/test_sync_progress_box.py -v --no-cov` green in isolation (the phase gate file).
- The six migrated image/PDF suites green in isolation, then the full suite green.
- Coverage: `app/` >90% (no `app/` change).
## Completion Criteria
- [ ] `grep -rn "sync-result\|syncResult" frontend/ tests/ app/` returns NOTHING anywhere in the project
- [ ] the six image/PDF suites still gate on a real sync-done signal (no sleeps introduced)
- [ ] one `aria-live` region in the sync area; contrast/focus-visible unchanged; no external CDN references
- [ ] `uv run pytest` green, `uv run ruff check . && uv run pyright` clean, coverage >90%
- [ ] the phase directory is in `.agents/phases/complete/` and committed with `--no-gpg-sign`
@@ -0,0 +1,56 @@
# Phase 151 — no more blank white image documents from PDFs (dedupe + soft masks + a blank gate)
**Source:** `TODO.md` L4 — "For the same podman pdf document, it extracted a series of blank white images for some reason. There don't appear to be any blank images in the document, is this a bug?"
**Story:** n/a (TODO-derived phase, Protocol B append).
**Context:** `app/rag/pdf.py` — `extract_pdf_images()` (the module's image extractor: `page.get_image_info(xrefs=True)` per page via `_page_image_entries`, then `doc.extract_image(xref)` per entry, one `PdfImage(page, index, width, height, data, mime)` per **occurrence**) and its consumer `app/rag/importer.py` `_index_pdf_embedded_images()` (L1924 — the per-PDF embedded-image pass: `_pdf_image_context_inputs` once, then `extract_pdf_images(full_path)` and one `_index_one_embedded_image()` (L2030) per image: mime→ext gate through `_MIME_TO_EXT` (L1340 — png/jpg/gif/webp/bmp/tiff; anything else maps to `.bin` and is skipped), sha256-digest skip, one vision describe call (`_describe_or_skip`, L2907), the `image_dir` copy, chunk + embed, one transaction per image). The `images` toggle (`BOR_IMAGES`, `settings.images`, default OFF) gates the whole pass (LOCKED A3). `ImportSummary` (L390–475) carries the log-only counters (`images_failed`, `pdfs_screenshotted`, `pdfs_pictorial`, `pdf_pictorial_pages`, `image_context_backfilled`) — the phase-133 stats log-only convention: counters land in the `import: summary …` line, never in the sync `detail` API shape.
**Reproduced on the owner's file (`test_sources/podman-systemd.pdf`, 50 pages) — three distinct defects:**
1. **Duplicates:** `get_image_info` reports one entry per *placement*, so the file yields **46 occurrences of only 15 unique xrefs** (each image is placed 2–4 times). Every occurrence becomes its own document (`…#page=N#img=i.<ext>`), so the same image is vision-described and embedded up to 4×.
2. **Soft masks ignored:** every one of the 15 images carries an `/SMask` (`extract_image` reports `smask=<xref>`, `get_image_info` reports `has-mask=True`). `extract_image()` returns the **base** sample stream only — the mask (which carries the visible shape) is dropped, so the stored bytes are not what a PDF viewer shows.
3. **Decorative underlays indexed:** those images are table-shading underlays — `DeviceGray`, `mean=238/255`, **8 unique sample values**, **99.5% of samples within ±8 of the mean** (verified: composited with their mask they are still a near-white block; the table text a viewer shows is real PDF text drawn on top). They are indexed as documents, vision-described, and returned as image sources.
Measured comparison (the same probe, `fitz.Pixmap(...).samples`): the podman underlays `uniq=8`, near-uniform(±8) `0.95–0.995`; `test_sources/pdf_horse_test.pdf`'s photo `uniq=256`, `0.14`; `test_sources/players_handbook.pdf` page art `uniq≥188`, `0.10–0.59`. The gate separates them cleanly.
**Locked (owner decisions, chat):** blank/decorative images are **skipped entirely** (never indexed, never described — not stored-and-flagged); JPEG2000 (`jpx`) images stay **skipped** as they are today (the unsupported-extension gate keeps running on the RAW embedded format, so compositing to PNG must not newly admit the 586 `jpx` images in `players_handbook.pdf`).
## Objective
A PDF's embedded-image pass yields one document per genuinely distinct, non-decorative image, with the bytes a viewer would show (soft mask applied) — no blank white documents, no triplicate images, and far fewer vision calls per PDF.
## Dependencies
- `150_drop_sync_result` (todo) — pipeline order only (that phase touches the Sources-page sync UI; this phase touches `app/rag/pdf.py` + the importer's image pass). No shared files.
## Design (shared by all tasks — the executor reads this, not the chat)
- **Dedupe (task 01):** in `extract_pdf_images`, keep the FIRST occurrence of each distinct image and skip the repeats — key = the `xref` when non-zero, else the sha256 of the final `data` (a digest set also catches byte-identical images stored under different xrefs). The kept `PdfImage` carries the first occurrence's `page`/`index` (the synthetic path `…#page=N#img=i.<ext>` is therefore stable for the first placement). One INFO line per PDF when duplicates were dropped (`pdf: deduped N duplicate image occurrence(s) in <path>`), DEBUG per image.
- **Soft masks (task 02):** when the info/`extract_image` dict reports a non-zero `smask`, build the VISIBLE image instead of the raw base: `base = fitz.Pixmap(doc, xref)`, `mask = fitz.Pixmap(doc, smask_xref)`, `visible = fitz.Pixmap(base, mask)` (PyMuPDF's alpha-combining constructor) → `data = visible.tobytes("png")`, `mime = "image/png"`. Fail-soft: any exception (or a `Pixmap` with a colorspace PyMuPDF cannot combine) falls back to the raw `extract_image` bytes + the raw mime and logs a warning — the image is still indexed, exactly as today. **Ordering rule (owner-locked A4):** the raw-format gate runs FIRST — an image whose raw format is unsupported (`jpx` → `.bin`) is left exactly as today (raw bytes, raw mime, skipped downstream by the importer's ext gate); compositing happens only for images whose raw mime already maps through `_MIME_TO_EXT`. A `jpx` image with an smask therefore stays skipped.
- **Blank gate (task 03):** a NEW pure predicate in `app/rag/pdf.py`, `is_blank_image(samples: bytes, *, mean_min: int = 235, tolerance: int = 12, fraction: float = 0.98) -> bool` — pure (no PDF, no I/O — the `is_usable_pdf_text` / `pictorial_pages` house style, so it is unit-testable with hand-built sample buffers): blank when `len(samples)` is non-empty AND `mean(samples) >= mean_min` AND at least `fraction` of the samples lie within `±tolerance` of the mean. Applied in `extract_pdf_images` to the samples of the image that will actually be stored (the composited pixmap when a mask was applied, else the base pixmap): a blank image is **skipped** (not returned), with one INFO line per skipped image (`pdf: skipped blank/decorative image page=%d index=%d of %s — mean=%.0f uniform=%.3f`) and one summary line per PDF. Fail-soft: a pixmap that cannot be built skips the gate (the image is kept — never lose a real image to a decode problem).
- **The knob (task 04):** `Settings.pdf_image_skip_blank: bool = True` (`BOR_PDF_IMAGE_SKIP_BLANK`) — the kill switch, exactly the `pdf_pictorial` (L433) pattern: a `bool` with a `#:` docstring naming the env var, read by the importer and passed down, `False` restores the pre-phase behavior (every image kept, gate off). Dedupe and mask compositing are NOT knobed (they are correctness fixes). `.env.example` gains the documented entry next to the other PDF/image vars; `README.md` gains one line in the PDF/image settings section.
- **Counters (task 03):** two log-only `ImportSummary` fields — `pdf_images_deduped` and `pdf_images_blank_skipped` — appended to `ImportSummary.log()`'s line (the phase-133 stats log-only convention: the sync `detail` API shape is untouched). They count occurrences dropped per run across all PDFs.
- **ASSUMPTION A1 (owner-locked):** blank images are skipped entirely (no row, no copy, no vision call) rather than indexed-and-flagged.
- **ASSUMPTION A2 (owner-locked):** thresholds `mean ≥ 235`, `≥98%` of samples within `±12` — pinned by unit tests against the three real PDFs in `test_sources/` (podman underlays skipped; the horse photo and the handbook page art kept).
- **ASSUMPTION A3:** the dedupe changes the synthetic path for repeat occurrences, so image docs created pre-phase for a 2nd/3rd placement are pruned on the next sync and the first placement's doc is kept (idempotent from then on). No migration code.
- **ASSUMPTION A4 (owner-locked):** `jpx` (JPEG2000) images stay unsupported/skipped; compositing never newly admits them.
- **ASSUMPTION A5:** the gate lives in the parser (`extract_pdf_images`), not the importer, so every caller of the parser gets the fix and the parser stays the single place that decides what "an embedded image" is.
## Tasks
1. `01_dedupe_image_occurrences.md` — one `PdfImage` per distinct image (xref, else digest); duplicate-drop log + counter.
2. `02_composite_soft_masks.md` — `/SMask` composited to an alpha PNG (raw-format gate first, fail-soft to raw bytes).
3. `03_blank_image_gate.md` — the pure `is_blank_image()` predicate + the skip in the extractor + the two log-only counters.
4. `04_settings_knob.md` — `BOR_PDF_IMAGE_SKIP_BLANK` (default on) threaded parser → importer, `.env.example` + README.
5. `05_unit_tests.md` — PyMuPDF-built fixtures for dedupe/mask/blank + the real-PDF regression pins.
6. `06_e2e_pdf_image_extraction.md` — the dedicated Playwright suite (upload a PDF with a duplicate placement + a blank underlay + a real image → exactly one image doc, no blank docs).
## Testing & Quality
- Unit (`tests/unit/test_pdf.py` + a new `tests/unit/test_pdf_image_filter.py`): dedupe (N placements of one image → 1 result, first occurrence's page/index kept, counter), digest-level dedupe across xrefs, smask compositing (an RGBA image round-trips with alpha; the raw-format gate leaves `jpx`-style unsupported formats untouched; the fail-soft path returns raw bytes), `is_blank_image` as a pure function (uniform white → blank; noise → not blank; dark-uniform → NOT blank because the mean gate is about near-white underlays; empty samples → not blank), the knob off → every image kept.
- Real-file pins (skip-if-missing, the `HANDBOOK`/`HORSE_PDF` pattern already in `tests/unit/test_pdf.py` L1734–1737): `podman-systemd.pdf` → 15 unique images in, **0 image docs out** (all are decorative underlays); `pdf_horse_test.pdf` → its photo kept; `players_handbook.pdf` → its images stay skipped by the ext gate (unchanged, `jpx`).
- Integration: the importer's embedded-image pass indexes exactly the extractor's returned list (a blank/duplicate image never reaches `_index_one_embedded_image` — assert with a fake `Embedder` counting vision calls, the existing image-test idiom).
- Coverage: **>90%** on new/modified code (`app/rag/pdf.py`, the importer's pass, `app/config.py`).
- E2E: one dedicated Playwright file, run in isolation (`uv run pytest tests/e2e/test_pdf_image_extraction.py -v --no-cov`).
## Completion Criteria
- [ ] syncing a PDF like `podman-systemd.pdf` creates **no** blank white image documents and **no** duplicate image documents (verified by the E2E and the real-file unit pins)
- [ ] an image with an `/SMask` is stored as the composited image (alpha PNG), not the blank base; an unsupported raw format (`jpx`) is still skipped
- [ ] `BOR_PDF_IMAGE_SKIP_BLANK=false` restores the pre-phase behavior (every extracted image indexed) — pinned by a unit test
- [ ] `import: summary … pdf_images_deduped=N pdf_images_blank_skipped=M …` appears in the sync log; the sync `detail` API shape is unchanged
- [ ] no behavior change in completed phases (the phase-137 document-context block, the phase-125 markdown enrichment, the digest skip, the `images` toggle semantics, the phase-133 fallback, the phase-140 pictorial pass)
- [ ] `uv run pytest` green; the dedicated E2E green in isolation; `uv run ruff check . && uv run pyright` clean; `app/` coverage >90%
- [ ] one atomic commit per AGENTS.md rule 8 (`--no-gpg-sign`)
@@ -0,0 +1,29 @@
# Task 01 — one `PdfImage` per distinct image (duplicate placements dropped)
**Phase:** `151_pdf_image_extraction` · **Source:** `TODO.md:4` — "For the same podman pdf document, it extracted a series of blank white images for some reason. There don't appear to be any blank images in the document, is this a bug?"
**Story:** n/a
## Objective
`extract_pdf_images()` returns one entry per DISTINCT image, so a PDF that places the same image 4× produces one document instead of four.
## Work
1. `app/rag/pdf.py` — `extract_pdf_images()`: keep the per-page/occurrence walk (it is what gives `page`/`index`), but skip anything already seen:
- maintain `seen_xrefs: set[int]` and `seen_digests: set[str]` (sha256 hex of the final `data`) outside the page loop;
- after `extract_image` succeeds and `data` is non-empty: `if xref in seen_xrefs: dropped += 1; continue`, then `digest = hashlib.sha256(data).hexdigest()`; `if digest in seen_digests: dropped += 1; continue`; record both before appending (the digest set catches byte-identical images stored under different xrefs);
- the FIRST occurrence wins: the appended `PdfImage` keeps that occurrence's `page` (1-based) and `index` (0-based on the page) — the importer's synthetic path `…#page=N#img=i.<ext>` therefore stays stable for the first placement (ASSUMPTION A3: repeat-placement docs created pre-phase are pruned on the next sync, no migration code);
- when `dropped`: one INFO line after the page loop — `pdf: dropped %d duplicate image occurrence(s) from %s (%d unique)` — and a DEBUG line per drop (`pdf: duplicate image xref=%d page=%d index=%d of %s — skipped`);
- `hashlib` is already imported in this module? If not, add `import hashlib` (check first — `app/rag/pdf.py` currently imports `logging`, `re`, `collections.abc`, `dataclasses`, `pathlib`, `typing`, `pymupdf`).
2. Update the module docstring's `extract_pdf_images` bullet: "one :class:`PdfImage` per DISTINCT embedded image (page order, first placement wins) — repeated placements and byte-identical copies are deduplicated (phase 151 task 01)".
3. `app/rag/importer.py` — `_index_pdf_embedded_images()` needs no logic change (it indexes exactly what the extractor returns), but its docstring's "each embedded image … in page order then 0-based index" sentence gains the dedupe note. Add the two `ImportSummary` counters' plumbing HERE only if task 03 has not landed yet — otherwise leave the counters to task 03 (avoid a duplicate field).
4. ASSUMPTION: dedupe is per-PDF (the sets are local to one `extract_pdf_images` call), never across PDFs — two PDFs sharing a logo still each get their own image doc (the synthetic path is per-PDF, so cross-PDF dedupe would break the upsert key).
## Testing & Quality
- Unit (`tests/unit/test_pdf.py`, the PyMuPDF-built-fixture convention): a PDF with the SAME image inserted on 3 pages (or 3× on one page) → `len(extract_pdf_images(...)) == 1` and it carries the first occurrence's `page`/`index`; a PDF with two genuinely different images → 2; two different xrefs holding byte-identical bytes → 1 (digest path); the INFO line emitted (caplog) only when a duplicate was dropped.
- Real-file pin (skip-if-missing): `test_sources/podman-systemd.pdf` → the extractor returns **15** entries (46 placements pre-phase).
- Coverage: **>90%** on the new branches (both dedupe paths + the no-duplicate path).
## Completion Criteria
- [ ] `extract_pdf_images(Path("test_sources/podman-systemd.pdf"))` returns 15 entries (was 46)
- [ ] the first occurrence's page/index are preserved; the digest path is covered by a unit test
- [ ] the duplicate-drop INFO line appears once per PDF with duplicates
- [ ] `uv run pytest tests/unit/test_pdf.py -q` green; `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,37 @@
# Task 02 — apply the `/SMask`: store the image a viewer would show
**Phase:** `151_pdf_image_extraction` · **Source:** `TODO.md:4` — "For the same podman pdf document, it extracted a series of blank white images for some reason. There don't appear to be any blank images in the document, is this a bug?"
**Story:** n/a
## Objective
An image stored in the PDF with a soft mask is extracted as the composited (masked) image, not as the blank base sample stream `extract_image()` returns today.
## Work
1. `app/rag/pdf.py` — `extract_pdf_images()`, inside the per-image block, AFTER the raw-format decision and BEFORE appending:
- read the mask reference: `smask = int(extracted.get("smask") or 0)` (the `extract_image` dict reports it; `get_image_info`'s `has-mask` is the cross-check, not the source of truth);
- **raw-format gate FIRST (owner-locked A4):** compute `mime` from the raw format exactly as today (`_mime_from_image_info({**info, "ext": extracted.get("ext") or ""})`). If that mime is `application/octet-stream` (i.e. a format the importer's `_MIME_TO_EXT` gate will reject — `jpx`/JPEG2000, the 586 images in `players_handbook.pdf`), DO NOT composite: keep the raw bytes + raw mime, so the behavior is byte-identical to pre-phase for unsupported formats (compositing must never newly admit them);
- otherwise, when `smask` is non-zero: build the visible image
```python
base = fitz.Pixmap(doc, xref)
mask = fitz.Pixmap(doc, smask)
visible = fitz.Pixmap(base, mask) # base + alpha-from-mask
data = visible.tobytes("png")
mime = "image/png"
```
wrapped in `try/except Exception` → on failure log a warning (`pdf: could not composite smask=%d for image page=%d index=%d of %s — %s; using raw bytes`) and fall back to the raw `extracted["image"]` bytes + the raw mime (fail-soft: the image is still indexed, exactly as pre-phase);
- keep `width`/`height` from `extracted` (unchanged — the composited pixmap has the same dimensions);
- a DEBUG line when a mask was applied (`pdf: composited smask=%d for image page=%d index=%d of %s`).
2. `app/rag/pdf.py` module docstring: extend the `extract_pdf_images` bullet — masked images are composited to an alpha PNG (phase 151 task 02), unsupported raw formats are left untouched so the importer's extension gate still decides.
3. `app/rag/importer.py`: no code change — `_index_one_embedded_image()` already derives the copy extension from `img.mime` via `_MIME_TO_EXT`, and `image/png` → `.png` is in `settings.image_extension_set` by default. Verify the digest-skip consequence in a comment: a masked image's bytes change, so pre-phase rows for masked images re-describe ONCE on the first post-phase sync (the sha256 digest differs) — expected, self-limiting, no migration.
4. ASSUMPTION: `fitz.Pixmap(base, mask)` is the correct combinator (verified against `podman-systemd.pdf` xrefs 202/744/1633 — it produces an alpha PNG of the same dimensions); a CMYK/Indexed base that PyMuPDF refuses to combine takes the fail-soft path.
## Testing & Quality
- Unit (`tests/unit/test_pdf.py`): build a PDF with an RGBA PNG inserted (`page.insert_image(rect, stream=png_bytes)` — PyMuPDF writes the `/SMask`) and assert (a) `extract_image`'s raw bytes ≠ the extractor's `data`, (b) the extractor's `mime == "image/png"`, (c) re-opening the extractor's bytes as a `fitz.Pixmap` reports `alpha == 1` (the mask survived), (d) a PDF whose image has no mask is returned byte-identical to pre-phase (no regression for the common case); the fail-soft path via a monkeypatched `fitz.Pixmap` that raises → raw bytes returned + the warning logged (caplog).
- Real-file pin (skip-if-missing): every image extracted from `test_sources/podman-systemd.pdf` carries an smask pre-phase; post-phase the extractor's output for it is either composited PNG or (blank-gated away by task 03) absent — assert the composited bytes differ from the raw base for at least one xref when the gate is off.
- Coverage: **>90%** on the new branches (mask applied / mask failed / unsupported format untouched / no mask).
## Completion Criteria
- [ ] a masked image extracted through `extract_pdf_images` yields alpha-PNG bytes (`mime="image/png"`), not the blank base stream
- [ ] an unsupported raw format (`jpx`) is untouched — raw bytes + raw mime, still skipped downstream
- [ ] a compositing failure falls back to the raw bytes with a warning (no image lost, no exception raised)
- [ ] `uv run pytest tests/unit/test_pdf.py -q` green; `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,42 @@
# Task 03 — the blank/decorative gate: never index a near-white underlay
**Phase:** `151_pdf_image_extraction` · **Source:** `TODO.md:4` — "For the same podman pdf document, it extracted a series of blank white images for some reason. There don't appear to be any blank images in the document, is this a bug?"
**Story:** n/a
## Objective
A deterministic, pure predicate decides "this image carries no visible content" and the extractor drops those images — no row, no `image_dir` copy, no vision call.
## Work
1. `app/rag/pdf.py` — add the pure predicate (the `is_usable_pdf_text` / `pictorial_pages` house style: no PDF, no I/O, no LLM):
```python
BLANK_IMAGE_MEAN = 235 #: near-white floor
BLANK_IMAGE_TOLERANCE = 12 #: "same as the mean" band
BLANK_IMAGE_FRACTION = 0.98 #: fraction of samples that must sit in the band
def is_blank_image(samples: bytes, *, mean_min: int = BLANK_IMAGE_MEAN,
tolerance: int = BLANK_IMAGE_TOLERANCE,
fraction: float = BLANK_IMAGE_FRACTION) -> bool:
...
```
Blank when `samples` is non-empty AND `sum(samples)/len(samples) >= mean_min` AND `sum(1 for s in samples if abs(s - mean) <= tolerance) / len(samples) >= fraction`. Empty samples → `False` (never blank a thing we cannot measure). Document the measured basis in the docstring (podman underlays: mean 238, uniq 8, uniform(±8) 0.95–0.995 → blank; the horse photo: mean 123, uniq 256, uniform 0.14 → kept; handbook page art: mean 24–221, uniq ≥188, uniform 0.10–0.59 → kept). Note explicitly that a DARK uniform block is NOT blank by this rule (the gate targets near-white page underlays — a solid dark block is unusual and cheaper to keep than to explain away).
2. `app/rag/pdf.py` — `extract_pdf_images()`: after the dedupe + mask steps, decide blank-ness on the samples of the image that would actually be stored:
- build `gate_pixmap` = the composited pixmap when a mask was applied, else `fitz.Pixmap(doc, xref)`; when `gate_pixmap.alpha`, drop the alpha first (`fitz.Pixmap(gate_pixmap, 0)`) so the mean is over COLOR samples only (an alpha channel would drag the mean and produce false blanks);
- `if settings-gate and is_blank_image(gate_pixmap.samples): blank_skipped += 1; continue` (the gate is skipped entirely when the caller passes `skip_blank=False` — task 04's knob);
- one INFO line per skipped image: `pdf: skipped blank/decorative image page=%d index=%d of %s — mean=%.1f uniform=%.3f`, and one INFO per PDF when `blank_skipped` (`pdf: skipped %d blank/decorative image(s) in %s`);
- fail-soft: if the pixmap cannot be built for the gate, DO NOT skip the image (keep it, DEBUG why) — a decode problem must never delete a real image;
- the extractor signature gains `skip_blank: bool = True` (keyword, default on) so callers/tests can turn it off without settings; the dedupe and mask steps are NOT knobed.
3. `app/rag/importer.py` — `ImportSummary` gains two log-only counters (the phase-133 stats log-only convention: the sync `detail` API shape is untouched), each with a `#:` docstring citing this phase: `pdf_images_deduped: int = 0` and `pdf_images_blank_skipped: int = 0`, appended to `ImportSummary.log()`'s format string (`… pdf_images_deduped=%d pdf_images_blank_skipped=%d formats=%s`). The extractor returns the counts alongside the images so the importer can record them — the minimal clean shape is a small result object or a 2-tuple; prefer keeping `extract_pdf_images` returning `list[PdfImage]` for its other callers and add the counters via an optional out-param-free route: give the function a sibling `extract_pdf_images_with_stats(pdf_path, *, skip_blank=True) -> tuple[list[PdfImage], PdfImageStats]` (a `@dataclass(frozen=True) PdfImageStats` with `duplicates`, `blank_skipped`, `total_found`) and make `extract_pdf_images` a thin wrapper returning `stats[0]`. `_index_pdf_embedded_images()` calls the `_with_stats` variant and adds to `summary.pdf_images_deduped` / `summary.pdf_images_blank_skipped`.
4. ASSUMPTION: the counters are log-only (no API change) — the owner reads them in the sync log; the UI's sync result line is untouched.
## Testing & Quality
- Unit (`tests/unit/test_pdf_image_filter.py`, new file): `is_blank_image` as a pure function — all-250 bytes → blank; random noise → not blank; all-0 (dark) → NOT blank; empty → not blank; a 97%-uniform-with-a-few-dark-pixels buffer → not blank (just under the fraction); the boundary values pinned exactly at `mean_min` and `fraction`; the extractor-level behavior with PyMuPDF-built PDFs — a near-white 200×200 image inserted → 0 results + the INFO line + `stats.blank_skipped == 1`; a colorful noise image → 1 result; `skip_blank=False` → both returned.
- Integration: the importer's pass with a fake `Embedder` (the existing image-test idiom) — a PDF with one blank + one real image produces exactly ONE image document and ONE vision call; `import: summary … pdf_images_blank_skipped=1` in the log (caplog).
- Real-file pin (skip-if-missing): `test_sources/podman-systemd.pdf` → `extract_pdf_images` returns `[]` (all 15 unique images are decorative underlays) with `stats.duplicates == 31` and `stats.blank_skipped == 15`; `test_sources/pdf_horse_test.pdf` → its photo is returned.
- Coverage: **>90%** on the new code.
## Completion Criteria
- [ ] `extract_pdf_images(Path("test_sources/podman-systemd.pdf"))` returns no images (pre-phase: 46 blank-white docs)
- [ ] `extract_pdf_images(..., skip_blank=False)` still returns them (the kill switch at the function level)
- [ ] `import: summary … pdf_images_deduped=… pdf_images_blank_skipped=…` appears in the sync log; the `/api/sync/status` `detail` shape is unchanged
- [ ] a dark-uniform image and a real photo are both kept (unit-pinned)
- [ ] `uv run pytest tests/unit/test_pdf_image_filter.py -q` green; `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,29 @@
# Task 04 — `BOR_PDF_IMAGE_SKIP_BLANK`: the kill switch, wired end to end
**Phase:** `151_pdf_image_extraction` · **Source:** `TODO.md:4` — "For the same podman pdf document, it extracted a series of blank white images for some reason. There don't appear to be any blank images in the document, is this a bug?"
**Story:** n/a
## Objective
The blank gate is a documented setting, default ON, with a supported way to turn it off — the `pdf_pictorial` pattern.
## Work
1. `app/config.py` — add to `Settings`, next to `pdf_pictorial` / `pdf_pictorial_min_chars` (L433–443):
```python
#: Skip blank/decorative PDF-embedded images (phase 151, task 04,
#: ``BOR_PDF_IMAGE_SKIP_BLANK``; ``0``/``false`` = off) …
pdf_image_skip_blank: bool = True
```
The docstring names the env var, states the default, and says what turning it off means (every extracted image is indexed again — the pre-phase behavior; dedupe and soft-mask compositing are unaffected, they are correctness fixes). No validator needed (a plain bool like `pdf_pictorial` / `images`).
2. `app/rag/importer.py` — `_index_pdf_embedded_images()`: pass the setting into the extractor call (`extract_pdf_images_with_stats(full_path, skip_blank=settings.pdf_image_skip_blank)`), so the knob is read in exactly one place. Update the pass's docstring bullet list with the gate + the knob.
3. `.env.example` — add the entry in the PDF/image block (next to `BOR_PDF_PICTORIAL` / `BOR_IMAGES`), commented in the file's existing voice: what it does, the default, and when to set it to `false` (a KB of scanned/decorative pages where a near-white image still matters).
4. `README.md` — one line in the PDF/image settings list (the settings table/section the phase-126/140 entries live in): `BOR_PDF_IMAGE_SKIP_BLANK` (default `true`) — skip blank/decorative images embedded in PDFs.
5. ASSUMPTION: no admin-UI toggle (the phase-91 Theme Tab precedent shows knobs live in `.env`; the owner asked for the bug fixed, not a settings page).
## Testing & Quality
- Unit: `Settings` default is `True`; `BOR_PDF_IMAGE_SKIP_BLANK=false` (env override, the existing config-test idiom) yields `False`; an integration test with the fake `Embedder` proves the off path indexes a blank image again (the inverse of task 03's assertion) — the kill switch is proven, not assumed.
- Coverage: **>90%** on the touched lines.
## Completion Criteria
- [ ] `BOR_PDF_IMAGE_SKIP_BLANK=false` restores indexing of blank PDF-embedded images (test-proven)
- [ ] the setting is documented in `.env.example` and `README.md`
- [ ] `uv run pytest tests/unit -q` green; `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,31 @@
# Task 05 — unit + integration tests for dedupe, masks, and the blank gate
**Phase:** `151_pdf_image_extraction` · **Source:** `TODO.md:4` — "For the same podman pdf document, it extracted a series of blank white images for some reason. There don't appear to be any blank images in the document, is this a bug?"
**Story:** n/a
## Objective
Every new branch of the extractor is pinned without a browser: PyMuPDF-built fixtures for the synthetic cases, the three real PDFs in `test_sources/` for the regression proof.
## Work
1. `tests/unit/test_pdf_image_filter.py` (new file; the `tests/unit/test_pdf.py` conventions — PyMuPDF-built temp PDFs, no binary fixtures committed, `tmp_path`):
- **dedupe:** one image inserted on 3 pages → 1 result with the FIRST page/index; two distinct images → 2; byte-identical bytes under two xrefs → 1 (digest path); `stats.duplicates` counts; the INFO line via `caplog`;
- **soft mask:** an RGBA PNG inserted (`page.insert_image(rect, stream=…)`) → the extractor's `mime == "image/png"`, its bytes ≠ `doc.extract_image(xref)["image"]`, and `fitz.Pixmap(stream=extracted_bytes).alpha == 1`; an unmasked image → byte-identical to pre-phase; a monkeypatched `fitz.Pixmap` that raises → raw bytes returned + the warning (fail-soft);
- **blank gate:** `is_blank_image` as a pure function (uniform white → True; noise → False; uniform dark → False; empty → False; the exact `mean_min`/`fraction` boundaries); extractor-level — a near-white 200×200 image → 0 results + `stats.blank_skipped == 1` + the INFO line; a colorful noise image → 1 result; `skip_blank=False` → both returned;
- **unsupported format untouched:** an image whose raw ext is unsupported (build one by monkeypatching `extract_image` to report `ext="jpx"` + a non-zero `smask`) → the extractor returns raw bytes + the unmapped mime (no compositing) — the owner-locked A4 pin.
2. `tests/unit/test_pdf.py` — extend the existing extractor tests (they already cover `extract_pdf_images` and the fail-soft contract): the dedupe/mask steps must not break the "unreadable file → []", "no xref → skipped", "empty data → skipped" pins; add the real-file pins with the file's existing skip-if-missing idiom (see `HANDBOOK` / `HORSE_PDF`, L1734–1737):
- `test_sources/podman-systemd.pdf` → `extract_pdf_images(...) == []`, `stats.duplicates == 31`, `stats.blank_skipped == 15` (the owner's exact file — the bug's tombstone test);
- `test_sources/pdf_horse_test.pdf` → exactly 1 image, not blank;
- `test_sources/players_handbook.pdf` → images are returned by the parser but their raw mime is unmapped (`jpx`) → the importer's ext gate still skips them (assert the mime mapping, not the importer).
Each guarded by `pytest.mark.skipif(not path.exists(), …)` — `test_sources/*.pdf` are untracked fixtures.
3. Integration (`tests/unit/test_image_documents.py` or a new `tests/integration/test_pdf_image_pass.py`, whichever matches the existing image-pass tests' home): with `tests/fakes.py::FakeEmbedder` (the `_VisionMockLLM` idiom — a vision-capable fake that counts `describe_image` calls), a PDF containing one blank underlay + one real image + a duplicate placement indexes EXACTLY ONE image document and makes exactly ONE vision describe call; `summary.pdf_images_deduped` / `summary.pdf_images_blank_skipped` carry the counts; the `import: summary …` line includes them (caplog).
4. Run the whole suite after each addition: `uv run pytest -q`, then `uv run pytest --cov=app --cov-report=term-missing` (>90% on `app/rag/pdf.py`, `app/rag/importer.py`, `app/config.py`).
## Testing & Quality
- Coverage: **>90%** on the new/modified code paths (dedupe ×2, mask applied/failed/unsupported, blank skip/keep/knob-off, the stats plumbing).
- No network, no real LLM (the fakes only); the real-PDF pins are pure CPU (PyMuPDF) and must stay under a few seconds each.
## Completion Criteria
- [ ] `uv run pytest tests/unit/test_pdf_image_filter.py tests/unit/test_pdf.py -v` green
- [ ] the podman-systemd pin asserts 0 images out of 46 placements (the reported bug, pinned)
- [ ] the `skip_blank=False` and unsupported-format (A4) paths are both covered
- [ ] `uv run pytest -q` fully green; `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,36 @@
# Task 06 — the dedicated E2E: a PDF sync produces no blank, no duplicate image docs
**Phase:** `151_pdf_image_extraction` · **Source:** `TODO.md:4` — "For the same podman pdf document, it extracted a series of blank white images for some reason. There don't appear to be any blank images in the document, is this a bug?"
**Story:** n/a
## Objective
Through the real product path (upload a PDF → press Sync → inspect the KB), a PDF containing a decorative underlay, a duplicate placement, and one real image yields exactly one image document — the owner's complaint, proven fixed in the browser.
## Work
1. `tests/e2e/test_pdf_image_extraction.py` (new; the `tests/e2e/test_pdf_support.py` / `test_pdf_image_context.py` per-suite-app pattern — a module-scoped app with `BOR_IMAGES=true` + the `pdf` import token, the shared E2E Postgres, the autouse truncate, the mock LLM only):
- **fixture PDF built in the suite with PyMuPDF** (the house convention — no binary committed): 2 text pages, page 1 carrying (a) a near-white 200×200 "decorative underlay" image, (b) the SAME real image inserted twice (the duplicate placement), (c) one genuinely distinct colorful image; page 2 carrying one more placement of the real image (a cross-page duplicate — the podman case);
- **seed** it through the real admin upload flow + the RAG page's **Sync** button (the same user path the owner used), then wait for the sync to settle on the sync result surface (post-phase-150: `#sync-progress-text`);
- **assertions:**
* the catalog contains exactly ONE image document for the PDF (the distinct real image, first placement) — count the `is_image` docs whose path starts with the PDF's synthetic path;
* NO document's stored image bytes are a blank white image (fetch the served copy / read the row's `image_path` and assert the file's decoded pixels are not near-uniform — the same predicate the app uses, imported from `app.rag.pdf.is_blank_image`, so the test cannot drift from the product);
* the synthetic path of the kept doc is the FIRST placement's (`…#page=1#img=<i>.png`) — the dedupe's stability contract;
* the mock's vision call count for image description is 1 (the duplicate and the blank never reached the model — the cost the owner paid in "several minutes");
* a follow-up re-sync (nothing changed) reports `unchanged` and creates nothing new (idempotence after the fix).
- **negative control:** a second module app (or a second seed source) with `BOR_PDF_IMAGE_SKIP_BLANK=false` — the same PDF now yields the blank doc too (the knob is proven live in the product, not just in unit tests). If a second app is too heavy for the file's structure, drive the knob through the same env-fallback mechanism the other suites use for per-suite settings.
2. Run it in isolation until green: `uv run pytest tests/e2e/test_pdf_image_extraction.py -v --no-cov` (DB up: `podman compose up -d db`).
3. Regression run: the pre-existing PDF/image suites must stay green unedited (they pin the behaviors this phase must not disturb): `tests/e2e/test_pdf_support.py`, `test_pdf_image_context.py`, `test_pictorial_pdf.py`, `test_image_documents.py`, `test_image_anchor_context.py`, `test_answer_images.py`.
4. Commit the phase per AGENTS.md rule 8 — one atomic Conventional Commit with `--no-gpg-sign`, e.g.
`fix(rag): dedupe PDF images, apply soft masks, skip blank/decorative images`
(moves `.agents/phases/todo/151_pdf_image_extraction/` to `complete/`).
## Testing & Quality
- The dedicated E2E green IN ISOLATION (the phase gate) and the six pre-existing PDF/image E2Es green.
- `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
- Mock-only suite (no real vision calls), deterministic (no sleeps as barriers — wait on the sync result surface).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_pdf_image_extraction.py -v --no-cov` green in isolation
- [ ] exactly one image document per distinct real image; zero blank-white documents; one vision call
- [ ] `BOR_PDF_IMAGE_SKIP_BLANK=false` demonstrably restores the old behavior in the product
- [ ] the six pre-existing PDF/image E2Es green unedited; full suite green; coverage >90%
- [ ] the phase directory moved to `.agents/phases/complete/` and committed with `--no-gpg-sign`
@@ -0,0 +1,48 @@
# Phase 152 — the sync label keeps moving while images are described and embedded
**Source:** `TODO.md` L3 — "The sync status on the RAG page gets \"stuck\" when it's embedding images. For example, when syncing test_sources/podman-systemd.pdf the status was stuck on `Syncing… podman-systemd.unit — Podman documentation/podman-systemd.unit — Podman documentation.pdf (2540/2540) · chunk 125/126` for several minutes for some reason"
**Story:** n/a (TODO-derived phase, Protocol B append).
**Context:** the sync status machine `app/api/sync.py` — `SyncStatus` (L276–300: `state`, `current_file`/`files_done`/`files_total` (phase 64), `phase`/`current_summary`/`summaries_done`/`summaries_total` (phase 98), `current_chunk`/`chunks_total` (phase 129)), the `GET /api/sync/status` payload (L330–360), and `_run_sync`'s paired progress closures `_hook` (L553 — names the file) and `_chunk_hook` (L563 — the in-flight file's embed-batch position, fed by `LLMClient.embed`'s `on_progress`). The importer threads the chunk hook as `import_sources(on_chunk_progress=…)` → `_index_file(on_chunk=…)` → `_index_pdf_file(on_chunk=…)` → `_replace_content_chunks(on_chunk=…)`. The importer's own docstring states the gap this phase closes (L684–690): *"Only the content-chunk embeds report … the PDF's embedded-image docs (separate documents, not the walked file) do not report either."*
**Reproduced:** the owner's `podman-systemd.pdf` is the LAST file of a 2,540-file run. Its text chunks finish (the label's last write: `· chunk 125/126`), then the PDF's vision spans run — `_index_pdf_embedded_images` (46 image occurrences pre-phase-151, each a `describe_image` chat call + an embed + a summary), `_pictorial_pdf_pass` (L1715 — one render + one describe call per sparse page), `_fallback_page_screenshots` (L1635 — one describe call per page of a garbage-text PDF). **None of them reports progress**, so the label sits frozen on the last chunk position for minutes while the run is very much alive. The phase-149 box made the freeze plainly visible (it is now the big surface the user reads).
**Locked (owner decision, chat):** the fix is a real progress signal for the vision spans (not a spinner/heartbeat that hides the stall), reported as a per-image position alongside the existing chunk position.
## Objective
During a PDF's image spans the status reports `image n/m`, the UI renders it, and the label advances once per vision call — a long sync is visibly progressing, and a genuinely stalled run is distinguishable from a busy one.
## Dependencies
- `151_pdf_image_extraction` (todo) — its dedupe + blank gate shrink the span this phase reports on, and its `extract_pdf_images_with_stats` is the call that establishes the span's `total`. Report order matters: the totals this phase reports are post-dedupe/post-gate counts.
- `150_drop_sync_result` (todo) — the label surface is `#sync-progress` (the phase-149 box is the only place the running label renders; the button is the spinner).
## Design (shared by all tasks — the executor reads this, not the chat)
- **The hook (task 02):** `import_sources(..., on_image_progress: Callable[[int, int], None] | None = None)`, threaded exactly like `on_chunk_progress`: `import_sources` → `_index_file(on_image=…)` → `_index_pdf_file(on_image=…)` → the three vision spans (`_index_pdf_embedded_images`, `_pictorial_pdf_pass`, `_fallback_page_screenshots`). Contract (the phase-129 pairing rule, extended): the hook is called **immediately before each vision describe call** with `(position_1based, total)` where `total` is that span's count — the extracted-image count for the embedded pass (post-dedupe, post-blank-gate: the work actually about to happen), the candidate-page count for the pictorial pass, the rendered-page count for the screenshot fallback. The three spans of ONE file are reported as ONE continuous counter: the pass that runs second starts where the first ended (`base = images_done_so_far`), so the label climbs `1/N … N/N` across the file's whole vision span instead of restarting. `None` (every existing caller — the CLI, the chat path) is a zero-cost guarded no-op and the run is byte-identical (the phase-129 rule).
- **The status fields (task 01):** `SyncStatus` gains `images_done: int = 0` / `images_total: int = 0`, reported by `GET /api/sync/status` as `images_done` / `images_total`. Clear/reset rules mirror the chunk position (a sub-position of a file): cleared at the `phase = "overview"` / `phase = "summaries"` transitions and in every terminal (success/failed/cancelled), and **reset to 0/0 by the per-file `_hook`** — the pairing rule is "a `progress` call names the file and clears the image position; every image position until the next `progress` call belongs to it". (The chunk position does not need this because every file embeds chunks; a file with no images must not inherit the previous PDF's position.)
- **The label (task 03):** `sources.js` `fmtSyncPhaseLabel` appends `· image n/m` when `status.images_total > 0` (the phase-129 chunk-part language: a position reads as data, mono in the box). `setSyncLabel` gains a fifth parameter `imageText = ""` appended to `full` (after the chunk part) — it rides the title, the box, and the running `aria-label`. **No new button span** is added: while running the label parts are hidden (phase 149 makes the button the spinner), so the parts DOM stays as-is and the position lives only in the composed `full` string. The chunk part keeps its own slot (the two positions are distinct and both may show: `… (2540/2540) · chunk 125/126 · image 3/15`).
- **ASSUMPTION A1 (owner-locked):** a real per-image position, not an elapsed-time heartbeat.
- **ASSUMPTION A2:** counts only, not the image identity — the file label already names the PDF and the synthetic `…#page=N#img=i.png` path is noise in a label.
- **ASSUMPTION A3:** the three PDF vision spans share ONE counter per file (continuous, not three restarts) — the user cares that the file is progressing, not which span it is in.
- **ASSUMPTION A4:** standalone image files (one image per file) get no sub-position — `1/1` per file would be noise; the file count already covers them.
- **ASSUMPTION A5:** no new `phase` value — the image spans are inside `phase: "import"` (the phase machine stays as phase 98 defined it).
## Tasks
1. `01_status_image_fields.md` — `SyncStatus.images_done/images_total`, the reset/clear rules, the `/api/sync/status` payload + docstring.
2. `02_importer_image_hook.md` — `on_image_progress` threaded through the importer's three PDF vision spans with the continuous per-file counter.
3. `03_label_and_wiring.md` — the `_image_hook` closure in `_run_sync`, the `sources.js` label part + title/aria, the phase-129-style comment updates.
4. `04_unit_tests.md` — the status machine pins (idle 0/0, mid-run position, the per-file reset, every terminal clears) + the importer hook pairing.
5. `05_e2e_image_progress.md` — the dedicated Playwright suite: the label advances through a PDF's image span (slow-LLM proxy), never frozen.
## Testing & Quality
- Unit: `tests/unit/test_sync_button.py`'s full-status-dict pin gains the two keys (idle `0/0`); the terminal-clears and per-file-reset rules pinned through the runner's own closures (the phase-64/129 idiom); the importer hook pinned with `tests/fakes.py::FakeEmbedder` — a PDF with N images calls the hook N times with `(1,N)…(N,N)`, the three spans continue one counter, `None` costs nothing (the hook-less run is byte-identical).
- Integration: a sync run over a fixture PDF reports a monotonically increasing `images_done` across polls (no backwards jumps, never above `images_total`).
- Coverage: **>90%** on new/modified code (`app/api/sync.py`, `app/rag/importer.py`, the JS is pinned by the source-level suites + E2E).
- E2E: `uv run pytest tests/e2e/test_sync_image_progress.py -v --no-cov` in isolation (the `tests/e2e/slow_llm.py` delay proxy — every describe call outlives the 2 s poll, so each position is observed deterministically).
## Completion Criteria
- [ ] during a PDF's image span the status reports `images_done`/`images_total` climbing 1→N and the UI shows `· image n/m` in the box (and the button `title`/`aria-label`)
- [ ] the label is never frozen for more than one vision call's duration on the fixture PDF (the E2E asserts at least 3 distinct positions observed)
- [ ] the image position clears at the overview/summaries transitions and in every terminal; a file with no images never shows a stale position
- [ ] `GET /api/sync/status` is additive only — every pre-existing key unchanged (the full-dict pin)
- [ ] a hook-less caller (the CLI import path) is byte-identical to pre-phase
- [ ] `uv run pytest` green; the dedicated E2E green in isolation; `uv run ruff check . && uv run pyright` clean; `app/` coverage >90%
- [ ] one atomic commit per AGENTS.md rule 8 (`--no-gpg-sign`)
@@ -0,0 +1,34 @@
# Task 01 — `SyncStatus` gains `images_done` / `images_total`
**Phase:** `152_sync_progress_image_pass` · **Source:** `TODO.md:3` — "The sync status on the RAG page gets \"stuck\" when it's embedding images … the status was stuck on `Syncing… … (2540/2540) · chunk 125/126` for several minutes for some reason"
**Story:** n/a
## Objective
The sync status carries the in-flight file's vision-image position, with the same clear/reset discipline the chunk position already has.
## Work
1. `app/api/sync.py` — `SyncStatus` (L276–300): add
```python
# Phase 152 (task 01): the in-flight file's VISION-image position —
# the 1-based index of the image about to be described and the
# file's total images for this run (the importer's
# on_image_progress hook; the three PDF vision spans share ONE
# continuous counter per file). 0/0 before the first image, during
# the prelude/overview/summaries phases, and in terminal states.
images_done: int = 0
images_total: int = 0
```
and extend the dataclass docstring with a phase-152 paragraph in the file's established voice (it is a SUB-position of a file, like the chunk position: the keep-final-counts convention covers FILES only, so this clears with the file).
2. Clear/reset rules — exactly the points `current_chunk`/`chunks_total` clear today (grep `_status.current_chunk = 0` — the `phase = "overview"` transition ~L597, the `phase = "summaries"` transitions ~L650/671, the terminal paths ~L713/717/752/755/770/774, and the run start ~L462): set the two new fields to `0` at the SAME sites. In addition, the per-file `_hook` closure (L553) resets them to `0/0` — the pairing rule is "a `progress` call names the file and clears the image position; every image position until the next `progress` call belongs to it" (unlike the chunk position, a file may have NO images at all, and it must not inherit the previous PDF's position).
3. `GET /api/sync/status` (the payload dict, L330–360): add `"images_done"` / `"images_total"` beside `current_chunk`/`chunks_total`, with the same style of inline comment; extend the endpoint docstring with the phase-152 sentence (additive only — every pre-existing key keeps its name, type, and meaning).
4. Module docstring: add the phase-152 paragraph after the phase-129 chunk-position paragraph (the file documents each phase's contribution in order).
5. ASSUMPTION A5: no new `phase` value — the image spans live inside `phase: "import"`.
## Testing & Quality
- Unit: the full idle-dict pin in `tests/unit/test_sync_button.py` gains the two keys (`0`/`0`); a terminal-clears test and a per-file-reset test (drive the runner's closures directly, the phase-64/129 idiom).
- Coverage: **>90%** on the new lines.
## Completion Criteria
- [ ] `GET /api/sync/status` returns `images_done` / `images_total` (idle `0`/`0`) and every pre-existing key is unchanged
- [ ] the fields clear at the overview/summaries transitions and in success/failed/cancelled, and reset per file
- [ ] `uv run pytest tests/unit/test_sync_button.py -q` green; `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,32 @@
# Task 02 — the importer's `on_image_progress` hook across the PDF vision spans
**Phase:** `152_sync_progress_image_pass` · **Source:** `TODO.md:3` — "The sync status on the RAG page gets \"stuck\" when it's embedding images … the status was stuck on `Syncing… … (2540/2540) · chunk 125/126` for several minutes for some reason"
**Story:** n/a
## Objective
Every vision describe call in a file's import reports `(position, total)` before it happens, so the caller can show the span moving.
## Work
1. `app/rag/importer.py` — thread the callback exactly like `on_chunk_progress` (the phase-129 route):
- `import_sources(..., on_image_progress: Callable[[int, int], None] | None = None)` (L636–650) — document it in the docstring next to the `on_chunk_progress` paragraph, and CORRECT that paragraph's closing sentence ("the PDF's embedded-image docs … do not report either") — it no longer holds;
- `_index_file(..., on_image: Callable[[int, int], None] | None = None)` (L1050) → `_index_pdf_file(..., on_image=…)` (L1372);
- the three spans, each called with the file's continuous counter (ASSUMPTION A3 — one counter per file, spans continue each other):
* `_index_pdf_embedded_images(..., on_image=…)` (L1924): `total = len(images)` from `extract_pdf_images_with_stats` (phase 151 — post-dedupe, post-blank-gate: the work actually about to happen); call `on_image(base + i + 1, base + total)` **immediately before** each `_describe_or_skip` (so the label moves at the START of each slow call, not after it), where `base` is the count already reported for this file;
* `_pictorial_pdf_pass(..., on_image=…)` (L1715): `total = len(candidate_pages)` (the deterministic sparse-page gate's list, already computed before the loop); same call site rule;
* `_fallback_page_screenshots(..., on_image=…)` (L1635): `total = len(rendered pages)`; same rule;
- the counter handoff: `_index_pdf_file` keeps a small local `reported = 0` and passes it down / adds each span's returned count (each span returns the number of images it reported — reuse its existing return shape or return the count; the executor picks the minimal clean shape). A span that is skipped (toggle off, no candidates, usable text) reports nothing and does not advance the counter;
- `None` stays a zero-cost guarded no-op: every call site is `if on_image is not None:` (the phase-129 rule — a hook-less run is byte-identical, so the CLI and chat paths are untouched).
2. Update the module docstring's progress paragraph (L287–291) to name the image hook, and the `_index_pdf_embedded_images` docstring's bullet list.
3. ASSUMPTION A4: standalone image files (`_index_image_file`, L2989) get NO sub-position — one image per file, `1/1` would be noise; the file count already covers them.
4. ASSUMPTION A2: counts only — the hook carries no image identity (the file label already names the PDF).
## Testing & Quality
- Unit (`tests/unit/test_importer.py` / the image-pass tests' `tests/fakes.py::FakeEmbedder` idiom): a fixture PDF with N embedded images → the hook fires N times with `(1,N)…(N,N)`; a PDF with a pictorial span + an embedded span → ONE continuous counter (no restart, never above the combined total); a text-only PDF → the hook never fires; `on_image_progress=None` → the import result is byte-identical to pre-phase (the regression pin).
- The hook never raises into the pipeline: a hook that raises is a caller bug (the phase-64 rule — "the hooks in this repo only assign fields", no try/except), so do NOT add defensive handling; keep the closure assignment-only.
- Coverage: **>90%** on the new branches (each span, the skip paths, the `None` path).
## Completion Criteria
- [ ] the hook fires once per vision describe call, 1-based, with the span's total, before the call
- [ ] the three PDF spans share one continuous per-file counter
- [ ] a hook-less import is byte-identical to pre-phase
- [ ] `uv run pytest tests/unit -q` green; `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,35 @@
# Task 03 — wire the hook to the status and render `· image n/m` in the label
**Phase:** `152_sync_progress_image_pass` · **Source:** `TODO.md:3` — "The sync status on the RAG page gets \"stuck\" when it's embedding images … the status was stuck on `Syncing… … (2540/2540) · chunk 125/126` for several minutes for some reason"
**Story:** n/a
## Objective
The importer's image hook lands on the status object, and the Sources page renders the position — the label advances once per vision call.
## Work
1. `app/api/sync.py` — `_run_sync`: add the third paired closure beside `_hook` (L553) and `_chunk_hook` (L563):
```python
def _image_hook(done: int, total: int) -> None:
_status.images_done = done
_status.images_total = total
```
pass it into the import call (L580): `import_sources(..., on_image_progress=_image_hook)`; comment it in the file's established voice (the three hooks are paired: `progress` names the file and resets the image position, `on_chunk_progress` reports the embed-batch position, `on_image_progress` reports the vision-image position).
2. `frontend/assets/sources.js`:
- `fmtSyncPhaseLabel(status)` (L896–919): the import branch gains `image` — `status.images_total > 0 ? \`· image ${status.images_done}/${status.images_total}\` : ""` — and the `overview` / `summaries` branches return `image: ""` (the position belongs to the import phase only);
- `enterSyncRunningState` (L964): destructure `{ main, count, chunk = "", image = "" }` and pass the new argument to the writer;
- `setSyncLabel(mainText, countText = "", chunkText = "", running = false, imageText = "")` (L809, post-phase-150): the composed `full` string becomes `main + (count ? " " + count : "") + (chunk ? " " + chunk : "") + (image ? " " + image : "")` — so the box, the button `title`, and the running `aria-label` all carry `Syncing… <file> (2540/2540) · chunk 125/126 · image 3/15` (the phase-149 box is the surface that shows it; the button's label parts stay as they are — no new span, the running button is the spinner);
- update the function's docstring + the phase-129/149 comment blocks that enumerate the label parts (the file cites the "· chunk n/m" contract in ~10 comments — name the image part beside it, do not restate the whole contract).
3. No CSS change: the box already wraps mono text with `overflow-wrap: anywhere`; the extra part is just more text.
4. ASSUMPTION A2/A3: counts only, one continuous per-file counter (the importer owns the continuity — the UI just renders what it is told).
5. ASSUMPTION: no new button label span — while running the parts are hidden (phase 149), so the position rides the composed string only.
## Testing & Quality
- Unit: the source-level pins in `tests/unit/test_sync_button.py` / `test_frontend_sync_upload.py` gain the image part (the `fmtSyncPhaseLabel` contract: `images_total > 0` → the part present, `0` → absent; the `overview`/`summaries` branches never carry it; the composed `full` string pinned byte-for-byte for a status with both positions).
- Integration: a mocked sync run over a fixture PDF shows `images_done` increasing across two polls of `GET /api/sync/status` (the phase-129 mid-run pin idiom).
- Coverage: **>90%** on the `app/` change.
## Completion Criteria
- [ ] while a PDF's images are described, `GET /api/sync/status` reports the climbing position and `#sync-progress-text` shows `· image n/m`
- [ ] the chunk part and the image part can show together and each disappears independently
- [ ] the prelude / overview / summaries labels are byte-identical to pre-phase
- [ ] `uv run pytest tests/unit -q` green; `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,34 @@
# Task 04 — unit + integration tests for the image progress contract
**Phase:** `152_sync_progress_image_pass` · **Source:** `TODO.md:3` — "The sync status on the RAG page gets \"stuck\" when it's embedding images … the status was stuck on `Syncing… … (2540/2540) · chunk 125/126` for several minutes for some reason"
**Story:** n/a
## Objective
The new position is pinned at both ends: the importer emits it, the status machine carries and clears it, the label formats it.
## Work
1. `tests/unit/test_sync_button.py` — the status-machine pins (the phase-64/98/129 idiom: drive the runner's closures, assert the endpoint dict):
- idle: the full-dict pin includes `images_done: 0` / `images_total: 0` and every pre-existing key unchanged;
- mid-run: after `_image_hook(3, 15)` the endpoint reports `3/15` while `current_file` / the chunk position keep their own values (the three positions are independent);
- per-file reset: a `_hook(...)` call for the NEXT file resets the image position to `0/0` (the pairing rule — a file with no images never shows a stale position);
- every terminal (success / failed / cancelled) clears the image position while the FILE counts keep their final values (the phase-64 convention, restated for the new sub-position);
- the `phase = "overview"` and `phase = "summaries"` transitions clear it (the same sites as the chunk position).
2. `tests/unit/test_importer.py` (or the image-pass home that already uses `tests/fakes.py::FakeEmbedder`) — the hook contract:
- a fixture PDF with N embedded images → the hook fires exactly N times, `(1,N) … (N,N)`, each call BEFORE the corresponding describe call (assert the interleaving by recording events in the fake);
- a PDF with a pictorial span AND an embedded span → one continuous counter across both spans, never exceeding the combined total, never restarting;
- a text-only PDF → the hook never fires;
- `on_image_progress=None` → the `ImportSummary` and the stored rows are identical to the hook-less baseline (the byte-identical regression pin);
- the blank-gate interaction (phase 151): images skipped by the dedupe/blank gate are NOT counted in `total` (the total is the work about to happen).
3. `tests/unit/test_sync_button.py` (frontend-source pins) — `fmtSyncPhaseLabel`'s image part: present iff `images_total > 0`; the composed `full` string pinned for a status carrying both positions (`… (2540/2540) · chunk 125/126 · image 3/15`); the `overview`/`summaries` labels unchanged.
4. Integration (`tests/integration/`): a full sync run over a fixture KB containing one image-bearing PDF, polling `GET /api/sync/status` from the test while the mock import runs — assert the observed `images_done` sequence is monotonically non-decreasing and ends at `images_total` (no backwards jumps, no overshoot).
5. Run the gates: `uv run pytest -q`, then `uv run pytest --cov=app --cov-report=term-missing` (>90%).
## Testing & Quality
- Coverage: **>90%** on `app/api/sync.py` + `app/rag/importer.py` new lines (every clear/reset site, each span, the `None` path).
- Deterministic: no sleeps, no real LLM (the fakes), no reliance on wall-clock timing.
## Completion Criteria
- [ ] the status pins cover idle / mid-run / per-file reset / all three terminals / both phase transitions
- [ ] the importer hook pins cover the N-image case, the continuous multi-span counter, the no-image case, and the hook-less byte-identical baseline
- [ ] the label formatter's image part is pinned byte-for-byte
- [ ] `uv run pytest -q` green; coverage >90%; `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,37 @@
# Task 05 — the dedicated E2E: the label advances through a PDF's image span
**Phase:** `152_sync_progress_image_pass` · **Source:** `TODO.md:3` — "The sync status on the RAG page gets \"stuck\" when it's embedding images … the status was stuck on `Syncing… … (2540/2540) · chunk 125/126` for several minutes for some reason"
**Story:** n/a
## Objective
In the browser, during a PDF sync whose images take time to describe, the live label shows the image position MOVING — the owner's "stuck for several minutes" is impossible on the fixture.
## Work
1. `tests/e2e/test_sync_image_progress.py` (new; the `tests/e2e/test_sync_chunk_progress.py` recipe — a module-scoped app on its own port, `BOR_IMAGES=true` + the `pdf` import token, the `tests/e2e/slow_llm.py` delay proxy in front of the mock so EVERY LLM call (vision describes included) takes `SLOW_DELAY_S = 1.5 s` ≫ the UI's 2 s poll, the autouse truncate, mock-only — `E2E_REAL_LLM` unsupported):
- **fixture:** a source (local dir or `file://` git repo, the suite's existing idiom) with ONE PyMuPDF-built PDF carrying 4 genuinely distinct, non-blank images (post-phase-151 they all survive the gate) and enough text to produce ≥2 embed batches (so the chunk part and the image part are both exercised);
- **the observation loop:** press Sync, then poll the page (not the API) and collect the DISTINCT `#sync-progress-text` values seen while the run is in flight;
- **assertions:**
* at least three distinct labels containing `· image ` are observed (e.g. `· image 1/4`, `· image 2/4`, `· image 3/4`) — the freeze the owner reported is a single repeated label, so "≥3 distinct image positions" IS the bug's negation;
* the observed image positions are non-decreasing and never exceed the total;
* the chunk part appeared earlier in the run and the image part appears during the image span (the two positions are distinct);
* the button `title` / `aria-label` carry the same full string (the A4 untruncated contract);
* at settle the box shows the counts line and the image part is gone (no stale position in a terminal state);
* a page reload mid-run re-attaches with the current image position visible (the never-stale contract).
2. Keep the suite deterministic: the slow proxy makes each position outlive the poll; no `wait_for_timeout` as a barrier — wait on the observed text values.
3. Run in isolation until green: `uv run pytest tests/e2e/test_sync_image_progress.py -v --no-cov` (DB up: `podman compose up -d db`).
4. Regression run of the neighbouring label suites (they must stay green unedited): `tests/e2e/test_sync_progress_box.py`, `test_sync_chunk_progress.py`, `test_sync_label_mobile.py`, `test_pdf_support.py`, `test_image_documents.py`.
5. Commit the phase per AGENTS.md rule 8 — one atomic Conventional Commit with `--no-gpg-sign`, e.g.
`feat(sync): report PDF image-describe progress so the sync label never stalls`
(moves `.agents/phases/todo/152_sync_progress_image_pass/` to `complete/`).
## Testing & Quality
- The dedicated E2E green IN ISOLATION (the phase gate) + the four neighbouring suites green.
- `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
- Mock-only, deterministic, no real vision calls.
## Completion Criteria
- [ ] ≥3 distinct `· image n/m` label values observed during one sync (the anti-freeze proof)
- [ ] positions are monotonic, cleared at settle, and re-attached on a mid-run reload
- [ ] the chunk part's behavior is unchanged
- [ ] full suite green, coverage >90%, lint + types clean
- [ ] the phase directory moved to `.agents/phases/complete/` and committed with `--no-gpg-sign`
-3
View File
@@ -1,4 +1 @@
# TODO
- [ ] The sync status on the RAG page gets "stuck" when it's embedding images. For example, when syncing test_sources/podman-systemd.pdf the status was stuck on `Syncing… podman-systemd.unit — Podman documentation/podman-systemd.unit — Podman documentation.pdf (2540/2540) · chunk 125/126` for several minutes for some reason
- [ ] For the same podman pdf document, it extracted a series of blank white images for some reason. There don't appear to be any blank images in the document, is this a bug?