phase: 98_sync_summary_visibility
Build and Push Containers / build-and-push-app (push) Successful in 1m51s
Build and Push Containers / build-and-push-db (push) Successful in 11s

All verification complete. Final report:

**Phase 98 — Sync summary visibility: final verification pass** (all 5 tasks already complete; implementation verified against the design, no defects found, no code changes needed)

- **Implementation checked:** `SyncStatus` phase machine (4 new keys, terminal-keep counts), `on_progress` hook in `generate_folder_summaries`, `summary_pending` on `KbTreeSource`/`KbTreeFolder` + D3 rule in `build_kb_tree`, phase-aware sync labels + pending UI in `sources.js`, `.kb-summary-pending` CSS — all match decisions D1–D5.
- **Unit + integration:** `uv run pytest` → 2184 tests, 0 failed/errors (exit 0)
- **Coverage:** `uv run pytest --cov=app --cov-report=term-missing` → **99%** on `app/` (criterion >90% ✓; `app/api/sync.py` and `app/rag/folder_summaries.py` at 100%)
- **Lint/types:** `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings
- **Phase E2E (isolation):** `uv run pytest tests/e2e/test_sync_summary_visibility.py -v --no-cov` → **3 passed** (phase machine, live label, pending markers + gap-fill self-heal)
- **Regression suites (each isolated, `--no-cov`):** test_kb_tree ✓, test_ls_tree_drilldown 3 ✓, test_sync_button 3 ✓, test_sync_upload_progress 4 ✓, test_oneshot_llm_retry 2 ✓, test_local_directory_sources 3 ✓
- **Completion criteria:** all 7 verified green — status phase fields + terminal semantics; `Writing KB overview…`/`Summarizing folders… (n/m)` labels (title + aria-live); pending set == `missing_folder_summaries` (integration cross-check pinned at `test_docs_api.py:428`); CLI/`ls` byte-identity (no changes to those paths, pins green); suite/coverage/lint gates; dedicated + regression E2E. Commit left to the harness per protocol (no `git add`/`commit` run).
- **Decisions/deviations:** none — no fixes were required this pass.
- **Next pending phase:** `99_kb_tree_table_and_back_nav`.
This commit is contained in:
2026-09-13 00:23:05 -04:00
parent 909c96c7bc
commit f665a83b1a
39 changed files with 3265 additions and 112 deletions
+146 -2
View File
@@ -48,7 +48,9 @@ class _FakeLLM:
Records each ``(system, user)`` request and the ``model`` kwarg;
returns the canned reply, or raises — either a fixed exception or a
per-folder failure keyed on the user message's ``Folder: …`` header
per-folder failure keyed on the user message's FIRST LINE (the
``Folder: …`` header) — EXACT match, so a nested folder's header
(``Folder: S/a/b``) can never shadow its parent's (``Folder: S/a``)
(the per-folder fail-soft tests).
"""
@@ -74,8 +76,9 @@ class _FakeLLM:
system = messages[0]["content"]
user = messages[-1]["content"]
self.requests.append((system, user))
header = user.splitlines()[0] if user else ""
for folder in self._fail_folders:
if FOLDER_HEADER_PREFIX + folder in user:
if header == FOLDER_HEADER_PREFIX + folder:
raise LLMError(f"simulated lite-model failure for {folder}")
if self._fail is not None:
raise self._fail
@@ -916,3 +919,144 @@ def test_gap_probe_subsumes_the_table_empty_gate(db: Session, clean_tables) -> N
db.execute(text("DELETE FROM folder_summaries"))
db.commit()
assert missing_folder_summaries(db) == [("FSU", ""), ("FSU", "a")] # emptied again
# ---------- on_progress hook (phase 98, task 01) ----------
def _record_progress() -> tuple[list[tuple[int, int, str, str]], Any]:
"""A fresh event list + the ``on_progress`` recorder that appends
every ``(done, total, source, folder_path)`` event it receives."""
events: list[tuple[int, int, str, str]] = []
def record(done: int, total: int, source: str, folder_path: str) -> None:
events.append((done, total, source, folder_path))
return events, record
def test_on_progress_fires_once_per_candidate_in_sorted_key_order(
db: Session, clean_tables
) -> None:
"""Phase 98 (task 01): the hook fires once per candidate, in the
same sorted ``(source, folder_path)`` order the folders are
attempted — done climbs 1..total, total = the candidate count
(the single-doc FSU-solo root is not a candidate — no event)."""
_seed_catalogue(db)
llm = _FakeLLM()
events, record = _record_progress()
stats = asyncio.run(generate_folder_summaries(db, llm, on_progress=record))
assert events == [
(1, 3, "FSU", ""),
(2, 3, "FSU", "a"),
(3, 3, "FSU", "a/b"),
]
assert llm.calls == 3 # one event per attempt, in the same order
assert stats["generated"] == 3
def test_on_progress_manual_skip_still_advances(db: Session, clean_tables) -> None:
"""A manual-skip key is an INSTANT skip — no ``lite`` call burns,
but the counter still advances for it (D5: the UI's position moves
on either outcome)."""
_seed_catalogue(db)
db.add(
FolderSummary(
source="FSU", folder_path="a", summary="owner text",
manually_edited=True,
)
)
db.commit()
llm = _FakeLLM()
events, record = _record_progress()
stats = asyncio.run(generate_folder_summaries(db, llm, on_progress=record))
assert events == [
(1, 3, "FSU", ""),
(2, 3, "FSU", "a"), # the instant manual skip still advances
(3, 3, "FSU", "a/b"),
]
assert llm.calls == 2, "the skip itself burns no call"
assert stats["kept_manual"] == 1 and stats["generated"] == 2
def test_on_progress_failed_key_still_advances(db: Session, clean_tables) -> None:
"""A failed (``LLMError``) key still advances — the hook fires
BEFORE the attempt, so a fail-soft miss is visible to the UI as a
completed step (the run never flips to failed, neither does the
counter stall)."""
_seed_catalogue(db)
llm = _FakeLLM(fail_folders=("FSU/a",))
events, record = _record_progress()
stats = asyncio.run(generate_folder_summaries(db, llm, on_progress=record))
assert events == [
(1, 3, "FSU", ""),
(2, 3, "FSU", "a"), # the failed attempt still advances
(3, 3, "FSU", "a/b"),
]
assert stats["failed"] == 1 and stats["generated"] == 2
def test_on_progress_only_missing_reports_the_missing_count(
db: Session, clean_tables
) -> None:
"""Under ``only_missing=True`` ``total`` is the MISSING count (the
loop-start count after the missing filter, not the full candidate
count) and the events name exactly the missing keys, sorted."""
_seed_catalogue(db)
asyncio.run(generate_folder_summaries(db, _FakeLLM()))
db.commit()
db.execute(
text(
"DELETE FROM folder_summaries"
" WHERE (source, folder_path) IN (('FSU', ''), ('FSU', 'a/b'))"
)
)
db.commit()
llm = _FakeLLM()
events, record = _record_progress()
stats = asyncio.run(
generate_folder_summaries(db, llm, only_missing=True, on_progress=record)
)
assert events == [
(1, 2, "FSU", ""),
(2, 2, "FSU", "a/b"),
]
assert llm.calls == 2
assert stats["generated"] == 2
def test_on_progress_skip_true_fires_zero_calls(db: Session, clean_tables) -> None:
"""``skip=True`` (the ``--limit`` debug run) returns before the
loop — the hook never fires."""
_seed_catalogue(db)
llm = _FakeLLM()
events, record = _record_progress()
stats = asyncio.run(generate_folder_summaries(db, llm, skip=True, on_progress=record))
assert events == []
assert llm.calls == 0
assert stats == {"generated": 0, "failed": 0, "pruned": 0, "kept_manual": 0}
def test_on_progress_none_is_a_zero_cost_noop(db: Session, clean_tables) -> None:
"""``on_progress=None`` (the ``scripts/import_docs.py`` CLI path)
behaves byte-identically to today: the fake LLM's call log, the
stats dict, and the stored rows are identical across a hooked and
a hook-less run."""
_seed_catalogue(db)
hooked = _FakeLLM()
events, record = _record_progress()
stats_hooked = asyncio.run(
generate_folder_summaries(db, hooked, on_progress=record)
)
requests_hooked = hooked.requests.copy()
assert len(events) == 3, "the hooked run fired (the contrast is real)"
db.rollback() # the generator only flushes — drop the uncommitted rows
plain = _FakeLLM()
stats_plain = asyncio.run(generate_folder_summaries(db, plain, on_progress=None))
assert plain.requests == requests_hooked, "the fake LLM's call log is unchanged"
assert stats_plain == stats_hooked
assert _rows(db) == {
("FSU", ""): REPLY, ("FSU", "a"): REPLY, ("FSU", "a/b"): REPLY
}