phase: 96_oneshot_resilience
All checks complete. Final report: **Phase 96 (one-shot resilience) — final verification pass, all green** (all 4 task files already in `complete/`; verified the working-tree implementation against the design) - `LLMClient.chat()` empty-content retry (D1–D3) via `_chat_once` + `_EmptyContentError` (carries `finish_reason`), under `BOR_LLM_RETRIES`/`BOR_LLM_RETRY_DELAY` — verified in diff - `missing_folder_summaries()` + `generate_folder_summaries(only_missing=…)` — verified; `folder_summary_table_empty` deleted, both sync gates switched to the gap probe - `.env.example` comments updated (chat-turn stream + one-shot summary calls) **Test / lint / coverage results** - `uv run pytest --cov=app --cov-report=term-missing` → **1988 passed**, coverage **99%** (gate >90%) - `uv run pytest tests/e2e/test_oneshot_llm_retry.py -v --no-cov` → **2 passed** (isolated) - Regressions, each isolated: `test_ls_tree_drilldown` 3 passed, `test_sync_button` 3 passed, `test_local_directory_sources` 3 passed, `test_llm_retry` 4 passed - `uv run ruff check . && uv run pyright` → clean (0 errors) **Completion criteria:** retry-then-recover unit-pinned ✓ · exhaustion + `BOR_LLM_RETRIES=0` byte-identical ✓ · streaming path untouched ✓ · gap-fill both sync paths, other rows byte-identical incl. `updated_at` ✓ · no-gap zero-burn ✓ · phase E2E green ✓ · regression E2Es green ✓ · full suite + >90% + lint/types ✓ · no completed-phase behavior change (full suite green) ✓. Commit left to the harness per executor rules (working tree, 16 files). **Deviations:** none. **Next pending phase:** `97_kb_tree_catalog`.
This commit is contained in:
@@ -31,9 +31,9 @@ from app.rag.folder_summaries import (
|
||||
SYSTEM_PROMPT,
|
||||
build_folder_summary_prompt,
|
||||
folder_of,
|
||||
folder_summary_table_empty,
|
||||
generate_folder_summaries,
|
||||
group_by_folder,
|
||||
missing_folder_summaries,
|
||||
summarize_folder,
|
||||
)
|
||||
from app.rag.llm import LLMError
|
||||
@@ -613,16 +613,198 @@ def test_generate_only_flushes_caller_commits(db: Session, clean_tables) -> None
|
||||
assert MIN_DOCS_PER_FOLDER == 2 # the ≥ 2 scope rule, pinned by name
|
||||
|
||||
|
||||
def test_folder_summary_table_empty_gate(db: Session, clean_tables) -> None:
|
||||
"""The sync-path gate probe (phase 94, task 02): empty → True
|
||||
(the first full sync after migration 0017 must still generate),
|
||||
one row → False (a populated table waits for a KB change)."""
|
||||
assert folder_summary_table_empty(db) is True # the truncated table
|
||||
_add_doc(db, "FSU", "a/one.md", "One")
|
||||
_add_doc(db, "FSU", "a/two.md", "Two")
|
||||
# ---------- missing_folder_summaries (phase 96, task 02) ----------
|
||||
|
||||
|
||||
def test_missing_fresh_table_is_exactly_the_candidate_set(
|
||||
db: Session, clean_tables
|
||||
) -> None:
|
||||
"""No stored rows → every candidate folder is a gap, sorted by
|
||||
``(source, folder_path)``; the single-doc FSU-solo root is not a
|
||||
candidate and can never be a gap."""
|
||||
_seed_catalogue(db)
|
||||
assert missing_folder_summaries(db) == [
|
||||
("FSU", ""),
|
||||
("FSU", "a"),
|
||||
("FSU", "a/b"),
|
||||
]
|
||||
assert ("FSU-solo", "") not in missing_folder_summaries(db)
|
||||
|
||||
|
||||
def test_missing_fully_populated_table_is_empty(db: Session, clean_tables) -> None:
|
||||
"""Every candidate row present → no gap (the zero-burn gate case)."""
|
||||
_seed_catalogue(db)
|
||||
asyncio.run(generate_folder_summaries(db, _FakeLLM()))
|
||||
db.commit()
|
||||
assert folder_summary_table_empty(db) is False # rows landed
|
||||
assert missing_folder_summaries(db) == []
|
||||
|
||||
|
||||
def test_missing_one_deleted_row_is_that_folder(db: Session, clean_tables) -> None:
|
||||
_seed_catalogue(db)
|
||||
asyncio.run(generate_folder_summaries(db, _FakeLLM()))
|
||||
db.commit()
|
||||
db.execute(
|
||||
text(
|
||||
"DELETE FROM folder_summaries "
|
||||
"WHERE source = 'FSU' AND folder_path = 'a'"
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
assert missing_folder_summaries(db) == [("FSU", "a")]
|
||||
|
||||
|
||||
def test_missing_empty_kb_empty_table_is_no_gap(db: Session, clean_tables) -> None:
|
||||
"""No catalogue → no candidates → ``[]`` — an empty table over an
|
||||
empty KB is not a gap (there is nothing to fill)."""
|
||||
assert missing_folder_summaries(db) == []
|
||||
|
||||
|
||||
def test_missing_single_doc_folder_is_never_listed(db: Session, clean_tables) -> None:
|
||||
"""A below-minimum folder without a row is NOT a gap — it is not a
|
||||
candidate (its one file line IS its summary)."""
|
||||
_add_doc(db, "FSU", "solo/one.md", "One")
|
||||
assert missing_folder_summaries(db) == []
|
||||
|
||||
|
||||
def test_missing_stale_row_is_not_a_gap(db: Session, clean_tables) -> None:
|
||||
"""A stored row for a folder that dropped below 2 docs is stale,
|
||||
not missing — the prune pass owns it, the gap detector ignores it."""
|
||||
_seed_catalogue(db)
|
||||
asyncio.run(generate_folder_summaries(db, _FakeLLM()))
|
||||
db.commit()
|
||||
db.add(FolderSummary(source="FSU", folder_path="gone/old", summary="stale"))
|
||||
db.commit()
|
||||
assert missing_folder_summaries(db) == []
|
||||
|
||||
|
||||
# ---------- generate_folder_summaries(only_missing=…) (phase 96, 02) ----------
|
||||
|
||||
|
||||
def _updated_at(db: Session, source: str, folder_path: str) -> object:
|
||||
"""The stored row's ``updated_at`` (raw SQL — bypasses the ORM
|
||||
identity map, so the before/after byte-identity comparison is
|
||||
honest)."""
|
||||
return db.execute(
|
||||
text(
|
||||
"SELECT updated_at FROM folder_summaries "
|
||||
"WHERE source = :s AND folder_path = :f"
|
||||
),
|
||||
{"s": source, "f": folder_path},
|
||||
).scalar_one()
|
||||
|
||||
|
||||
def test_only_missing_fills_exactly_the_missing_keys(
|
||||
db: Session, clean_tables
|
||||
) -> None:
|
||||
"""Two missing + two present → exactly the missing keys are
|
||||
generated (sorted order, one lite call each); the present rows are
|
||||
byte-identical after (text AND ``updated_at``); stats right."""
|
||||
_seed_catalogue(db)
|
||||
asyncio.run(generate_folder_summaries(db, _FakeLLM()))
|
||||
db.commit()
|
||||
full = _rows(db)
|
||||
a_stamp = _updated_at(db, "FSU", "a")
|
||||
db.execute(
|
||||
text(
|
||||
"DELETE FROM folder_summaries "
|
||||
"WHERE (source, folder_path) IN (('FSU', ''), ('FSU', 'a/b'))"
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
assert missing_folder_summaries(db) == [("FSU", ""), ("FSU", "a/b")]
|
||||
|
||||
llm = _FakeLLM()
|
||||
stats = asyncio.run(generate_folder_summaries(db, llm, only_missing=True))
|
||||
assert stats == {"generated": 2, "failed": 0, "pruned": 0}
|
||||
assert llm.calls == 2, "one call per MISSING key — zero for present rows"
|
||||
assert [user.splitlines()[0] for _s, user in llm.requests] == [
|
||||
"Folder: FSU",
|
||||
"Folder: FSU/a/b",
|
||||
], "the missing keys in sorted (source, folder_path) order"
|
||||
|
||||
assert _rows(db) == full, "the fill restores exactly the full candidate set"
|
||||
assert _updated_at(db, "FSU", "a") == a_stamp, (
|
||||
"the present row is byte-identical — never re-stamped by the fill"
|
||||
)
|
||||
|
||||
|
||||
def test_only_missing_no_gap_burns_zero_calls(db: Session, clean_tables) -> None:
|
||||
"""No gap → zero lite calls, zero rows touched, zero stats (the
|
||||
zero-burn invariant the unchanged-sync gate relies on)."""
|
||||
_seed_catalogue(db)
|
||||
asyncio.run(generate_folder_summaries(db, _FakeLLM()))
|
||||
db.commit()
|
||||
before = _rows(db)
|
||||
stamps = {f: _updated_at(db, "FSU", f) for f in ("", "a", "a/b")}
|
||||
llm = _FakeLLM()
|
||||
stats = asyncio.run(generate_folder_summaries(db, llm, only_missing=True))
|
||||
assert stats == {"generated": 0, "failed": 0, "pruned": 0}
|
||||
assert llm.calls == 0, "zero-burn: no gap, no lite call"
|
||||
assert _rows(db) == before
|
||||
for folder, stamp in stamps.items():
|
||||
assert _updated_at(db, "FSU", folder) == stamp, "no row re-stamped"
|
||||
|
||||
|
||||
def test_only_missing_still_prunes_stale_rows(db: Session, clean_tables) -> None:
|
||||
"""The prune pass runs in BOTH modes: the manually seeded stale row
|
||||
(folder gone from the catalogue) is pruned while the genuine
|
||||
missing folders are filled, and the present row stays untouched."""
|
||||
_seed_catalogue(db)
|
||||
db.add(FolderSummary(source="FSU", folder_path="a", summary="keep me"))
|
||||
db.add(FolderSummary(source="FSU", folder_path="gone/old", summary="stale"))
|
||||
db.commit()
|
||||
a_stamp = _updated_at(db, "FSU", "a")
|
||||
assert missing_folder_summaries(db) == [("FSU", ""), ("FSU", "a/b")]
|
||||
|
||||
llm = _FakeLLM()
|
||||
stats = asyncio.run(generate_folder_summaries(db, llm, only_missing=True))
|
||||
assert stats == {"generated": 2, "failed": 0, "pruned": 1}
|
||||
assert llm.calls == 2
|
||||
|
||||
stored = _rows(db)
|
||||
assert ("FSU", "gone/old") not in stored, (
|
||||
"the stale row is pruned even under only_missing"
|
||||
)
|
||||
assert stored[("FSU", "a")] == "keep me"
|
||||
assert _updated_at(db, "FSU", "a") == a_stamp
|
||||
assert stored[("FSU", "")] == REPLY and stored[("FSU", "a/b")] == REPLY
|
||||
|
||||
|
||||
def test_only_missing_fail_soft_keeps_prior_and_lands_others(
|
||||
db: Session, clean_tables
|
||||
) -> None:
|
||||
"""Per-folder fail-soft applies under ``only_missing`` too: the
|
||||
failing missing folder is counted and stays absent; the other
|
||||
missing folders still land; the present row is untouched."""
|
||||
_seed_catalogue(db)
|
||||
db.add(FolderSummary(source="FSU", folder_path="a", summary="keep me"))
|
||||
db.commit()
|
||||
llm = _FakeLLM(fail_folders=("FSU/a/b",))
|
||||
stats = asyncio.run(generate_folder_summaries(db, llm, only_missing=True))
|
||||
assert stats == {"generated": 1, "failed": 1, "pruned": 0}
|
||||
assert llm.calls == 2 # both missing folders were attempted
|
||||
|
||||
stored = _rows(db)
|
||||
assert stored[("FSU", "")] == REPLY, "the other missing folder still lands"
|
||||
assert ("FSU", "a/b") not in stored, "the failed folder stays absent"
|
||||
assert stored[("FSU", "a")] == "keep me", "the present row is untouched"
|
||||
|
||||
|
||||
def test_gap_probe_subsumes_the_table_empty_gate(db: Session, clean_tables) -> None:
|
||||
"""The deleted phase-94 table-empty gate probe, re-expressed through
|
||||
``missing_folder_summaries`` (phase 96, task 03 — the probe's unit
|
||||
coverage moved here): an empty table over a populated catalogue
|
||||
means EVERY candidate is missing (the targeted fill over all
|
||||
candidates IS a full generation — the first full sync after
|
||||
migration 0017 must still generate), a populated table means no
|
||||
gap (a populated table waits for a KB change or a gap)."""
|
||||
assert missing_folder_summaries(db) == [] # the truncated table, empty KB
|
||||
_add_doc(db, "FSU", "a/one.md", "One")
|
||||
_add_doc(db, "FSU", "a/two.md", "Two")
|
||||
assert missing_folder_summaries(db) == [("FSU", ""), ("FSU", "a")]
|
||||
asyncio.run(generate_folder_summaries(db, _FakeLLM()))
|
||||
db.commit()
|
||||
assert missing_folder_summaries(db) == [] # rows landed → no gap
|
||||
db.execute(text("DELETE FROM folder_summaries"))
|
||||
db.commit()
|
||||
assert folder_summary_table_empty(db) is True # emptied again
|
||||
assert missing_folder_summaries(db) == [("FSU", ""), ("FSU", "a")] # emptied again
|
||||
|
||||
Reference in New Issue
Block a user