phase: 94_ls_tree_drilldown
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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user