phase: 98_sync_summary_visibility
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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user