phase: 94_ls_tree_drilldown
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 25s

All green. Verification complete.

**Phase 94 — `ls` drill-down tree: final verification pass (all 5 tasks were already complete; verified, nothing to fix)**

- Verified `ls` 3-level tree (`app/rag/agent.py`): `ls()` sources + summaries, `ls(source)`/`ls(source/folder)` drill-down, 50-line file cap + grep-pointer note, NOT-A-FOLDER teaching refusal
- Verified `folder_summaries` (migration 0017, model, `app/rag/folder_summaries.py` generator: `FOLDER_SUMMARY_MODE` marker, fail-soft per folder, ≥2-doc scope + prune) wired change-gated in both sync paths
- Verified 10-turn fixture battery verdict recorded in `TOOL_CALLING_TESTING.md` §9 (2026-09-11): turbo PASS 19/19 contract, 98.7 s (−12.5…−13.2 % vs baseline); lite PASS 18/18, 43.6 s (+7.7 %) — accuracy at/above baseline, gate met
- `uv run pytest --cov=app --cov-report=term-missing` → 1939 passed, 0 failed; TOTAL coverage **99 %** (folder_summaries.py 100 %)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings
- E2E in isolation: `test_ls_tree_drilldown.py` 3 passed; `test_agent_document_tools` 4, `test_agent_unlimited_tools` 4, `test_harness_aligned_tools` 3, `test_search_tool` 3, `test_grep_regex_teaching` 2, `test_response_to_docs` 4 — all passed (read/grep contracts untouched)
- Dedicated folder-summary tests (fail-soft, prune, both sync paths, migration): 46 passed
- Completion criteria: all 6 met; working tree holds only phase-94 changes (commit left to harness per protocol)

**Next pending phase:** `95_read_truncation_cap`
This commit is contained in:
2026-09-11 00:59:35 -04:00
parent 9188be259b
commit d4943b4822
61 changed files with 6289 additions and 666 deletions
+63 -8
View File
@@ -82,17 +82,18 @@ from pathlib import Path
from typing import Any
from dotenv import load_dotenv
from sqlalchemy import select
from app.api.chat import plan_turn
from app.api.steering import load_steering_notes
from app.config import Settings, get_settings
from app.db import SessionLocal, db_available
from app.models import Document
from app.rag.agent import (
CORRECTION_INSTRUCTION,
AgentHolder,
MalformedReplyError,
find_document,
list_catalog,
list_source_names,
run_agent,
)
@@ -113,6 +114,19 @@ logger = logging.getLogger("agent_realmodel_check")
#: Repo-relative fixture dump (written by scripts.load_test_kb).
DEFAULT_DUMP_PATH = Path("tests/fixtures/test_kb.dump.sql")
def _catalog(db) -> list[tuple[str, str, str]]:
"""The full catalog as ``(source, path, title)`` in ``(source, path)``
order — the battery builder's pre-phase-94 ``list_catalog`` contract,
kept local to this script (phase 94 deleted the agent accessor: the
drill-down ``ls`` lists one bounded source level at a time)."""
rows = db.execute(
select(Document.source, Document.path, Document.title).order_by(
Document.source, Document.path
)
).all()
return [(source, path, title) for source, path, title in rows]
#: The per-turn line truncates the question at this width (the locked
#: format prints ``turn NN | emitted=E executed=X cap=Y|N | <question>``).
QUESTION_DISPLAY_WIDTH = 40
@@ -263,7 +277,7 @@ def check_preconditions(
return 2
return None
with SessionLocal() as db:
catalog = list_catalog(db)
catalog = _catalog(db)
if len(catalog) < 2:
print(
f"precondition failed: catalog holds {len(catalog)} document(s) "
@@ -443,8 +457,33 @@ async def _run_deflected(
)
def _ls_folder_prefixes(catalog: set[tuple[str, str]]) -> dict[str, set[str]]:
"""The existing ``ls`` folders per source (the phase-94 drill-down
contract): for each source, the set of source-relative folder
prefixes — every slash-boundary prefix of its indexed paths.
Mirrors the ``00_phase.md`` existence rule that
``app.rag.agent._execute_tool`` applies: a folder ``F`` exists ⟺
some indexed path of the source starts with ``F + "/"`` — a
document's OWN path is never a folder, so the document's path itself
is not in the set (``ls`` of a file path is a refusal, as is a bare
folder name missing its source prefix).
"""
folders: dict[str, set[str]] = {}
for source, path in catalog:
folder = path[: path.rfind("/")] if "/" in path else ""
while folder:
folders.setdefault(source, set()).add(folder)
folder = folder[: folder.rfind("/")] if "/" in folder else ""
return folders
def classify_call(
name: str, args: dict[str, Any], catalog: set[tuple[str, str]], sources: set[str]
name: str,
args: dict[str, Any],
catalog: set[tuple[str, str]],
sources: set[str],
ls_folders: dict[str, set[str]],
) -> bool:
"""Contract correctness of ONE emitted call (the tool-calling
accuracy metric, 2026-09-04 controlled methodology).
@@ -460,12 +499,25 @@ def classify_call(
re-read 15/15 across copy variants — is documented in
``TOOL_CALLING_TESTING.md``). The classification mirrors
``app.rag.agent._execute_tool``'s resolution rules gate-side (no
app-code changes for measurement).
app-code changes for measurement) — including the phase-94 ``ls``
revision (owner-permitted tool-surface change, ``00_phase.md``):
``ls(path)`` is contract-correct for ``""`` (the synced sources),
a registered source name (its root folder), or a ``source/folder``
path naming an EXISTING folder (``ls_folders`` — the drill-down);
an unknown first segment, a bare folder name (no source prefix), or
a folder matching no indexed prefix is the violation.
"""
if name == "ls":
raw = args.get("path")
scope = raw.strip() if isinstance(raw, str) else ""
return scope == "" or scope in sources
if not scope:
return True # the top level (the synced sources)
source, _, rest = scope.partition("/")
if source not in sources:
return False # unknown first segment (the phase-72 incident class)
if not rest:
return True # a registered source's root folder
return rest in ls_folders.get(source, ())
if name == "read":
raw = args.get("path")
arg = raw.strip() if isinstance(raw, str) else ""
@@ -498,10 +550,13 @@ def score_contract(
and source names. Per-turn counts are attached on each
:class:`TurnResult` as ``_contract_ok`` (measurement state, not a
dataclass field — the display line stays the locked format)."""
ls_folders = _ls_folder_prefixes(catalog)
ok = 0
for turn in turns:
turn_ok = sum(
1 for name, args in turn.calls if classify_call(name, args, catalog, sources)
1
for name, args in turn.calls
if classify_call(name, args, catalog, sources, ls_folders)
)
turn._contract_ok = turn_ok # type: ignore[attr-defined]
ok += turn_ok
@@ -763,7 +818,7 @@ def main(argv: list[str] | None = None) -> int:
)
else:
with SessionLocal() as db:
catalog = list_catalog(db)
catalog = _catalog(db)
s2, p2, _t2 = catalog[1]
d2 = find_document(db, s2, p2)
d2_content = d2.content if d2 is not None else ""
@@ -794,7 +849,7 @@ def main(argv: list[str] | None = None) -> int:
# source names (the KB is static across the run — the restore, when
# any, happened before the battery).
with SessionLocal() as db:
catalog_set = set((s, p) for s, p, _t in list_catalog(db))
catalog_set = set((s, p) for s, p, _t in _catalog(db))
sources_set = set(list_source_names(db))
score_contract(turns, catalog_set, sources_set)
passed, conditions = evaluate(