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:
+480
-128
@@ -2,15 +2,20 @@
|
||||
harness-aligned ``ls``/``read``/``grep`` surface, phase 70).
|
||||
|
||||
A scripted fake LLM (canned stream sequences) + monkeypatched
|
||||
``list_catalog`` / ``list_source_names`` / ``find_document`` /
|
||||
``ls_top`` / ``ls_folder`` / ``list_source_names`` / ``find_document`` /
|
||||
``all_documents`` — no database, no network. Covers the loop mechanics:
|
||||
the ls → read (combined ``source/path``) → answer happy path (event
|
||||
order, holder state, the tools staying offered on every request —
|
||||
phase 45 removed the per-tool budgets, the assistant/tool message
|
||||
history), the ``ls`` scoping (no-arg full catalog in the phase-63
|
||||
labeled-field format, a one-source scope, a known source with 0
|
||||
documents → ``0 documents:`` counted, an unknown-source refusal that
|
||||
counts nothing), ``read`` on the canonical combined form (split at the
|
||||
history), the phase-94 drill-down ``ls`` (no-arg top level = the
|
||||
registered sources with counts + stored summaries in the pinned
|
||||
``{N} sources:`` template, a source scope = its root folder —
|
||||
subfolders + capped file lines in the pinned folder template —, a
|
||||
``source/folder`` scope = one level deeper, a registered source with 0
|
||||
documents → the ``… — 0 documents, 0 folders:`` header counted, an
|
||||
unknown-source refusal that counts nothing, the NOT-A-FOLDER teaching
|
||||
with the parent's subfolders, the 50-file cap + grep-pointer note),
|
||||
``read`` on the canonical combined form (split at the
|
||||
FIRST slash, full content, the bare-source-name refusal, the
|
||||
already-in-context dedupe, missing-args refusals), the phase-68 ``grep``
|
||||
contract under its new name (the locked A5 pins: fixed substring,
|
||||
@@ -149,28 +154,35 @@ def test_agent_tools_names_and_parameters() -> None:
|
||||
# Task 05 (live gate iteration 2): the one-call-at-a-time discipline
|
||||
# clause (the harness prior batches calls; the loop executes one
|
||||
# per round — the extras count as unexecuted in the gate).
|
||||
# Phase 94 (task 03): the description is the drill-down tree
|
||||
# contract (pinned copy — the tool-surface revision, owner
|
||||
# permission 2026-09-10, ``TODO.md`` L4): one level per call,
|
||||
# sources at the top, folders + files below, the file-line format
|
||||
# and the combined-identity handoff to read/grep intact.
|
||||
assert ls["description"] == (
|
||||
"List the indexed documents as `source: X | path: Y | "
|
||||
"title: Z` lines. Call one tool at a time — wait for "
|
||||
"this result before your next call."
|
||||
"List the knowledge base as a tree, one level at a time. "
|
||||
"With no path: the synced sources — each with its document "
|
||||
"count and a summary of its contents. With a source name (no "
|
||||
"'/'): that source's top-level folders and files. With a "
|
||||
"`source/folder` path: that folder's subfolders and files. "
|
||||
"Folder lines carry a summary of what the folder contains. "
|
||||
"File lines are `source: X | path: Y | title: Z` — use the "
|
||||
"combined `source/path` with `read` and `grep`. Call one tool "
|
||||
"at a time — wait for this result before your next call."
|
||||
)
|
||||
ls_params = ls["parameters"]
|
||||
assert ls_params["type"] == "object"
|
||||
assert ls_params["required"] == [] # path is optional
|
||||
assert set(ls_params["properties"]) == {"path"}
|
||||
assert ls_params["properties"]["path"]["type"] == "string"
|
||||
# Phase 72: the description states the contract up front — the
|
||||
# 'path' argument is a source name, not a file or directory path.
|
||||
# Task 05 (live gate iteration 5): the cross-tool contrast clause
|
||||
# (ls is the ONLY tool whose path is a source name — the model
|
||||
# kept transferring that scope to grep's document identity).
|
||||
# Phase 94 (task 03): the 'path' argument teaches the drill-down
|
||||
# semantics — a source name lists that source's top level, a
|
||||
# `source/folder` path drills one level deeper, omitted lists
|
||||
# every source (pinned copy).
|
||||
assert ls_params["properties"]["path"]["description"] == (
|
||||
"Source name to list one source's documents (e.g. 'homelab') — "
|
||||
"a source name, not a file or directory path; omit to list "
|
||||
"every document. This is the only tool "
|
||||
"whose `path` is a source name — for "
|
||||
"`read` and `grep` it must be a document's "
|
||||
"combined `source/path`."
|
||||
"Optional — a source name (e.g. 'homelab') to list its top "
|
||||
"level, or a `source/folder` path to drill down (e.g. "
|
||||
"'homelab/active'). Omit it to list every source."
|
||||
)
|
||||
read = by_name["read"]["function"]
|
||||
# Tool-calling fast loop (2026-09-04, controlled fixture gate):
|
||||
@@ -285,16 +297,11 @@ def test_refusal_constants_are_harness_aligned() -> None:
|
||||
assert agent.UNKNOWN_TOOL == "Unknown tool."
|
||||
assert agent.MISSING_READ_ARGS == "read requires a string argument 'path'."
|
||||
assert agent.MISSING_SEARCH_ARGS == "grep requires a string argument 'pattern'."
|
||||
# Phase 72: the ls teaching-refusal templates, pinned byte-for-byte
|
||||
# (task 01 — the read/grep suggestion templates below, task 02).
|
||||
assert agent.LS_PATH_NOT_A_SOURCE == (
|
||||
"'{path}' looks like a document path, not a source name. The "
|
||||
"'path' argument of ls filters by source name (e.g. 'homelab') — "
|
||||
"omit it to list every document, or read a document by its "
|
||||
"combined 'source/path' string."
|
||||
)
|
||||
# The pre-phase-72 no-source line is the byte-identical prefix of
|
||||
# the extended line — only the teaching parenthetical was appended.
|
||||
# Phase 94 (task 03): the phase-72 document-path teaching refusal is
|
||||
# DELETED (a ``/`` now names a folder — the drill-down contract);
|
||||
# the no-source refusal stays byte-identical (the task's "existing
|
||||
# refusal, teaching parenthetical intact" pin).
|
||||
assert not hasattr(agent, "LS_PATH_NOT_A_SOURCE")
|
||||
assert agent.NO_SOURCE_NOT_A_DIRECTORY.startswith(
|
||||
"No source named '{scope}' — check the ls output."
|
||||
)
|
||||
@@ -303,6 +310,11 @@ def test_refusal_constants_are_harness_aligned() -> None:
|
||||
"argument is a source name, not a directory — omit it to list "
|
||||
"every document.)"
|
||||
)
|
||||
# Phase 94 (task 03): the NOT-A-FOLDER drill-down teaching template,
|
||||
# pinned byte-for-byte (argument echoed, parent's subfolders
|
||||
# listed), and the pinned file-line cap constant.
|
||||
assert agent.NOT_A_FOLDER == "'{arg}' is not a folder — {parent} has: {subfolders}"
|
||||
assert agent.LS_MAX_FILE_LINES == 50
|
||||
# Phase 72 (task 02): the read/grep "did you mean …?" suggestion
|
||||
# templates, pinned byte-for-byte, and the suggestion cap.
|
||||
assert agent.NO_DOCUMENT_DID_YOU_MEAN == (
|
||||
@@ -348,11 +360,12 @@ def test_list_source_names_empty_registry(monkeypatch: pytest.MonkeyPatch) -> No
|
||||
|
||||
|
||||
def test_ls_then_read_then_answer(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
catalog = [
|
||||
("Deployments", "backups.md", "Backup Strategy"),
|
||||
("Homelab", "aws-route53.md", "AWS Route53 Records"),
|
||||
]
|
||||
monkeypatch.setattr(agent, "list_catalog", lambda db: catalog)
|
||||
# Phase 94 (task 03): the no-arg ``ls`` is the drill-down TOP level
|
||||
# (the registered sources, registry order) — monkeypatched the way
|
||||
# the phase-70 full-catalog listing used to be.
|
||||
monkeypatch.setattr(
|
||||
agent, "ls_top", lambda db: [("Deployments", 1, None), ("Homelab", 1, None)]
|
||||
)
|
||||
target = _doc("Homelab", "aws-route53.md", "AWS Route53 Records", "R53-CONTENT")
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
@@ -423,11 +436,7 @@ def test_ls_then_read_then_answer(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assert msgs[3] == {
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": (
|
||||
"2 documents:\n"
|
||||
"source: Deployments | path: backups.md | title: Backup Strategy\n"
|
||||
"source: Homelab | path: aws-route53.md | title: AWS Route53 Records"
|
||||
),
|
||||
"content": "2 sources:\n\nDeployments — 1 documents\nHomelab — 1 documents",
|
||||
}
|
||||
# The second follow-up request carries the read call + the FULL text.
|
||||
msgs = llm.requests[2][0]
|
||||
@@ -448,7 +457,7 @@ def test_content_and_tool_call_in_one_stream_keeps_both(
|
||||
) -> None:
|
||||
"""Rare stream with content AND a tool call: the content stays (it was
|
||||
already emitted) and the tool still runs."""
|
||||
monkeypatch.setattr(agent, "list_catalog", lambda db: [])
|
||||
monkeypatch.setattr(agent, "ls_top", lambda db: [])
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
[
|
||||
@@ -460,7 +469,7 @@ def test_content_and_tool_call_in_one_stream_keeps_both(
|
||||
pieces = asyncio.run(_run(llm, holder, _settings()))
|
||||
assert [type(p) for p in pieces] == [StreamPiece, ToolCallPiece, StreamPiece]
|
||||
assert holder.tool_calls == 1 # the tool ran despite the content
|
||||
assert llm.requests[1][0][3]["content"] == "0 documents:\n"
|
||||
assert llm.requests[1][0][3]["content"] == "0 sources:"
|
||||
|
||||
|
||||
# ---------- phase 74: client history between system and user ----------
|
||||
@@ -519,7 +528,7 @@ def test_run_agent_history_survives_a_tool_round(
|
||||
"""The tool rounds append assistant/tool messages to the SAME
|
||||
``messages`` list — the prior history stays in place between the
|
||||
system prompt and the current question on the SECOND request too."""
|
||||
monkeypatch.setattr(agent, "list_catalog", lambda db: [])
|
||||
monkeypatch.setattr(agent, "ls_top", lambda db: [])
|
||||
llm = ScriptedLLM(
|
||||
[ToolCallPiece(id="call_1", name="ls", arguments={})],
|
||||
[StreamPiece("content", "the answer")],
|
||||
@@ -534,22 +543,25 @@ def test_run_agent_history_survives_a_tool_round(
|
||||
]
|
||||
|
||||
|
||||
# ---------- ls: full catalog + scoping ----------
|
||||
# ---------- ls: the drill-down tree (phase 94, task 03) ----------
|
||||
|
||||
|
||||
def test_ls_full_catalog_format(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""No argument: the full catalog in the phase-63 labeled-field format
|
||||
(``source: X | path: Y | title: Z``) — counted; no registry lookup."""
|
||||
catalog = [
|
||||
("Deployments", "backups.md", "Backup Strategy"),
|
||||
("Homelab", "aws-route53.md", "AWS Route53 Records"),
|
||||
]
|
||||
monkeypatch.setattr(agent, "list_catalog", lambda db: catalog)
|
||||
|
||||
def _boom_sources(*_a: Any, **_k: Any) -> None:
|
||||
raise AssertionError("no registry lookup for an unscoped ls")
|
||||
|
||||
monkeypatch.setattr(agent, "list_source_names", _boom_sources)
|
||||
def test_ls_top_level_lists_sources_with_summaries(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""No argument: the TOP level — the registered sources, registry
|
||||
order, each ``{source} — {n} documents`` + the indented summary line
|
||||
only when stored — the pinned template, counted; the registry IS
|
||||
consulted (unlike the phase-70 full catalog, the top level is the
|
||||
registry itself)."""
|
||||
monkeypatch.setattr(
|
||||
agent,
|
||||
"ls_top",
|
||||
lambda db: [
|
||||
("Deployments", 0, None),
|
||||
("Homelab", 1, "The homelab notes."),
|
||||
],
|
||||
)
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
[ToolCallPiece(id="call_1", name="ls", arguments={})],
|
||||
@@ -557,22 +569,27 @@ def test_ls_full_catalog_format(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
)
|
||||
asyncio.run(_run(llm, holder, _settings()))
|
||||
assert llm.requests[1][0][3]["content"] == (
|
||||
"2 documents:\n"
|
||||
"source: Deployments | path: backups.md | title: Backup Strategy\n"
|
||||
"source: Homelab | path: aws-route53.md | title: AWS Route53 Records"
|
||||
"2 sources:\n"
|
||||
"\n"
|
||||
"Deployments — 0 documents\n"
|
||||
"Homelab — 1 documents\n"
|
||||
" The homelab notes."
|
||||
)
|
||||
assert holder.tool_calls == 1
|
||||
|
||||
|
||||
def test_ls_empty_catalog_says_zero_documents(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(agent, "list_catalog", lambda db: [])
|
||||
def test_ls_empty_registry_says_zero_sources(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""No registered sources: the top level is the header line alone —
|
||||
``0 sources:`` (the old ``0 documents:`` behavior preserved in
|
||||
spirit), still a counted result."""
|
||||
monkeypatch.setattr(agent, "ls_top", lambda db: [])
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
[ToolCallPiece(id="call_1", name="ls", arguments={})],
|
||||
[StreamPiece("content", "ans")],
|
||||
)
|
||||
asyncio.run(_run(llm, holder, _settings()))
|
||||
assert llm.requests[1][0][3]["content"] == "0 documents:\n"
|
||||
assert llm.requests[1][0][3]["content"] == "0 sources:"
|
||||
assert holder.tool_calls == 1
|
||||
|
||||
|
||||
@@ -583,13 +600,12 @@ def test_ls_empty_catalog_says_zero_documents(monkeypatch: pytest.MonkeyPatch) -
|
||||
({"path": 7}, "non-string path"),
|
||||
],
|
||||
)
|
||||
def test_ls_blank_path_lists_full_catalog(
|
||||
def test_ls_blank_path_lists_top_level(
|
||||
monkeypatch: pytest.MonkeyPatch, arguments: dict[str, Any], label: str
|
||||
) -> None:
|
||||
"""A blank (or non-string) ``path`` is treated as omitted — the full
|
||||
catalog, counted (no refusal for an empty scope)."""
|
||||
catalog = [("S", "a.md", "A")]
|
||||
monkeypatch.setattr(agent, "list_catalog", lambda db: catalog)
|
||||
"""A blank (or non-string) ``path`` is treated as omitted — the top
|
||||
level (the sources), counted (no refusal for an empty scope)."""
|
||||
monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)])
|
||||
monkeypatch.setattr(agent, "list_source_names", lambda db: ["S"])
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
@@ -597,21 +613,25 @@ def test_ls_blank_path_lists_full_catalog(
|
||||
[StreamPiece("content", "ans")],
|
||||
)
|
||||
asyncio.run(_run(llm, holder, _settings()))
|
||||
assert (
|
||||
llm.requests[1][0][3]["content"] == "1 documents:\nsource: S | path: a.md | title: A"
|
||||
)
|
||||
assert llm.requests[1][0][3]["content"] == "1 sources:\n\nS — 1 documents"
|
||||
assert holder.tool_calls == 1
|
||||
|
||||
|
||||
def test_ls_scoped_to_known_source(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A known source name: the same listing filtered to that source —
|
||||
def test_ls_source_scope_lists_root_folder(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A registered source name (no ``/``): the source's ROOT folder —
|
||||
subfolders (2-space-indented, path order, ``: {summary}`` only when
|
||||
stored) + the root's own file lines in EXACTLY the
|
||||
``source: X | path: Y | title: Z`` format — the pinned template,
|
||||
counted."""
|
||||
catalog = [
|
||||
("Deployments", "backups.md", "Backup Strategy"),
|
||||
("Homelab", "a.md", "A"),
|
||||
("Homelab", "b.md", "B"),
|
||||
]
|
||||
monkeypatch.setattr(agent, "list_catalog", lambda db: catalog)
|
||||
monkeypatch.setattr(
|
||||
agent,
|
||||
"ls_folder",
|
||||
lambda db, source, folder: (
|
||||
[("backups", 2, "Backup notes."), ("networking", 1, None)],
|
||||
[("Homelab", "readme.md", "Readme")],
|
||||
1,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(agent, "list_source_names", lambda db: ["Deployments", "Homelab"])
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
@@ -620,9 +640,50 @@ def test_ls_scoped_to_known_source(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
)
|
||||
asyncio.run(_run(llm, holder, _settings()))
|
||||
assert llm.requests[1][0][3]["content"] == (
|
||||
"2 documents:\n"
|
||||
"source: Homelab | path: a.md | title: A\n"
|
||||
"source: Homelab | path: b.md | title: B"
|
||||
"Homelab — 1 documents, 2 folders:\n"
|
||||
"\n"
|
||||
" backups/ — 2 documents: Backup notes.\n"
|
||||
" networking/ — 1 documents\n"
|
||||
"\n"
|
||||
"source: Homelab | path: readme.md | title: Readme"
|
||||
)
|
||||
assert holder.tool_calls == 1
|
||||
|
||||
|
||||
def test_ls_nested_folder_scope_lists_one_level_deeper(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A ``source/folder`` path: that folder's subfolders + own file
|
||||
lines, identity = ``source/folder`` (the same template as the
|
||||
root), counted; the fetchers are the source-scoped ones."""
|
||||
|
||||
def _rows(db: Any, source: str) -> list[tuple[str, str]]:
|
||||
assert (source, db) == ("Homelab", None)
|
||||
return [
|
||||
("networking/lan.md", "LAN"),
|
||||
("networking/vpn.md", "VPN"),
|
||||
]
|
||||
|
||||
monkeypatch.setattr(agent, "_source_document_rows", _rows)
|
||||
monkeypatch.setattr(
|
||||
agent, "_source_folder_summaries", lambda db, source: {"networking": "Network notes."}
|
||||
)
|
||||
monkeypatch.setattr(agent, "list_source_names", lambda db: ["Homelab"])
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
[
|
||||
ToolCallPiece(
|
||||
id="call_1", name="ls", arguments={"path": "Homelab/networking"}
|
||||
)
|
||||
],
|
||||
[StreamPiece("content", "ans")],
|
||||
)
|
||||
asyncio.run(_run(llm, holder, _settings()))
|
||||
assert llm.requests[1][0][3]["content"] == (
|
||||
"Homelab/networking — 2 documents, 0 folders:\n"
|
||||
"\n"
|
||||
"source: Homelab | path: networking/lan.md | title: LAN\n"
|
||||
"source: Homelab | path: networking/vpn.md | title: VPN"
|
||||
)
|
||||
assert holder.tool_calls == 1
|
||||
|
||||
@@ -631,9 +692,13 @@ def test_ls_scoped_known_source_with_zero_docs_counts(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A registered source with no indexed documents is KNOWN (the
|
||||
registry is the source of truth, not the catalog): it lists as
|
||||
``0 documents:`` — a valid, counted result, not a refusal."""
|
||||
monkeypatch.setattr(agent, "list_catalog", lambda db: [("Other", "a.md", "A")])
|
||||
registry is the source of truth, not the catalog): it lists its
|
||||
header line alone (``… — 0 documents, 0 folders:`` — the old
|
||||
``0 documents:`` behavior preserved in spirit) — a valid, counted
|
||||
result, not a refusal."""
|
||||
monkeypatch.setattr(
|
||||
agent, "ls_folder", lambda db, source, folder: ([], [], 0)
|
||||
)
|
||||
monkeypatch.setattr(agent, "list_source_names", lambda db: ["Homelab", "Other"])
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
@@ -641,7 +706,7 @@ def test_ls_scoped_known_source_with_zero_docs_counts(
|
||||
[StreamPiece("content", "ans")],
|
||||
)
|
||||
asyncio.run(_run(llm, holder, _settings()))
|
||||
assert llm.requests[1][0][3]["content"] == "0 documents:\n"
|
||||
assert llm.requests[1][0][3]["content"] == "Homelab — 0 documents, 0 folders:"
|
||||
assert holder.tool_calls == 1 # an executed ls, not a refusal
|
||||
assert llm.requests[1][1] == AGENT_TOOLS
|
||||
|
||||
@@ -650,7 +715,6 @@ def test_ls_scoped_unknown_source_refused(monkeypatch: pytest.MonkeyPatch) -> No
|
||||
"""A ``path`` without ``/`` matching no source name is a refusal —
|
||||
the extended line with the teaching parenthetical (phase 72), not
|
||||
counted, the round cap bounds its repetition."""
|
||||
monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")])
|
||||
monkeypatch.setattr(agent, "list_source_names", lambda db: ["S"])
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
@@ -666,20 +730,14 @@ def test_ls_scoped_unknown_source_refused(monkeypatch: pytest.MonkeyPatch) -> No
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||||
|
||||
|
||||
def test_ls_path_like_scope_gets_document_path_teaching_refusal(
|
||||
def test_ls_path_like_scope_unknown_source_gets_no_source_refusal(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Phase 72: a stripped scope containing ``/`` looks like a document
|
||||
path (the incident's ``ls(path='app/rag/importer.py')``) — a source
|
||||
name is a directory basename and can never contain one, so this gets
|
||||
the ``LS_PATH_NOT_A_SOURCE`` teaching line with the argument echoed;
|
||||
no registry lookup, counts in nothing, tools stay offered."""
|
||||
monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")])
|
||||
|
||||
def _boom_sources(*_a: Any, **_k: Any) -> None:
|
||||
raise AssertionError("no registry lookup for a path-like scope")
|
||||
|
||||
monkeypatch.setattr(agent, "list_source_names", _boom_sources)
|
||||
"""Phase 94: a ``/`` now names a folder, so the phase-72
|
||||
document-path teaching is DELETED — a ``source/…`` argument whose
|
||||
FIRST segment names no registered source gets the no-source refusal
|
||||
(the segment echoed), counted in nothing, tools stay offered."""
|
||||
monkeypatch.setattr(agent, "list_source_names", lambda db: ["S"])
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
[
|
||||
@@ -695,7 +753,7 @@ def test_ls_path_like_scope_gets_document_path_teaching_refusal(
|
||||
assert holder.tool_calls == 0 # a refusal counts in nothing
|
||||
assert (
|
||||
llm.requests[1][0][3]["content"]
|
||||
== agent.LS_PATH_NOT_A_SOURCE.format(path="app/rag/importer.py")
|
||||
== agent.NO_SOURCE_NOT_A_DIRECTORY.format(scope="app")
|
||||
)
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||||
|
||||
@@ -706,8 +764,7 @@ def test_ls_dot_scope_gets_not_a_directory_teaching_refusal(
|
||||
"""Phase 72: ``ls(path='.')`` (the incident's second round — no
|
||||
``/``, no matching source) gets the extended no-source refusal with
|
||||
the teaching parenthetical, ``'.'`` echoed — not counted, tools stay
|
||||
offered."""
|
||||
monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")])
|
||||
offered (unchanged by phase 94)."""
|
||||
monkeypatch.setattr(agent, "list_source_names", lambda db: ["S"])
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
@@ -723,6 +780,312 @@ def test_ls_dot_scope_gets_not_a_directory_teaching_refusal(
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||||
|
||||
|
||||
def test_ls_unknown_top_level_folder_gets_not_a_folder_teaching(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Phase 94: a ``source/…`` argument whose TOP-LEVEL folder segment
|
||||
matches no indexed prefix gets the NOT-A-FOLDER teaching — the
|
||||
argument echoed, the source named, its direct subfolders listed so
|
||||
the model self-corrects in the next round; not counted, tools stay
|
||||
offered."""
|
||||
monkeypatch.setattr(
|
||||
agent,
|
||||
"_source_document_rows",
|
||||
lambda db, source: [
|
||||
("backups/cron.md", "Cron"),
|
||||
("containers/caddy.md", "Caddy"),
|
||||
("networking/lan.md", "LAN"),
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(agent, "_source_folder_summaries", lambda db, source: {})
|
||||
monkeypatch.setattr(agent, "list_source_names", lambda db: ["Homelab"])
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
[
|
||||
ToolCallPiece(
|
||||
id="call_1", name="ls", arguments={"path": "Homelab/netwoking"}
|
||||
)
|
||||
],
|
||||
[StreamPiece("content", "ans")],
|
||||
)
|
||||
asyncio.run(_run(llm, holder, _settings()))
|
||||
assert holder.tool_calls == 0 # a refusal counts in nothing
|
||||
assert llm.requests[1][0][3]["content"] == (
|
||||
"'Homelab/netwoking' is not a folder — Homelab has: "
|
||||
"backups/ containers/ networking/"
|
||||
)
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||||
|
||||
|
||||
def test_ls_unknown_nested_folder_gets_not_a_folder_with_nested_parent(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Phase 94: a nested miss names the DEEPEST existing ancestor —
|
||||
``source/folder`` — and lists ITS direct subfolders (bounded: the
|
||||
parent's own listing, no new flood path)."""
|
||||
monkeypatch.setattr(
|
||||
agent,
|
||||
"_source_document_rows",
|
||||
lambda db, source: [
|
||||
("networking/lan/a.md", "A"),
|
||||
("networking/vpn/b.md", "B"),
|
||||
("readme.md", "Readme"),
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(agent, "_source_folder_summaries", lambda db, source: {})
|
||||
monkeypatch.setattr(agent, "list_source_names", lambda db: ["Homelab"])
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
[
|
||||
ToolCallPiece(
|
||||
id="call_1", name="ls", arguments={"path": "Homelab/networking/lan/x"}
|
||||
)
|
||||
],
|
||||
[StreamPiece("content", "ans")],
|
||||
)
|
||||
asyncio.run(_run(llm, holder, _settings()))
|
||||
assert holder.tool_calls == 0
|
||||
assert llm.requests[1][0][3]["content"] == (
|
||||
"'Homelab/networking/lan/x' is not a folder — "
|
||||
"Homelab/networking/lan has: none"
|
||||
)
|
||||
|
||||
|
||||
def test_ls_file_path_scope_gets_not_a_folder(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Phase 94: a document's OWN path is never a folder (nothing starts
|
||||
with ``path + '/'``) — ``ls`` of a file path refuses with the
|
||||
NOT-A-FOLDER teaching (the parent's subfolders listed)."""
|
||||
monkeypatch.setattr(
|
||||
agent,
|
||||
"_source_document_rows",
|
||||
lambda db, source: [("notes.md", "Notes"), ("a/b.md", "B")],
|
||||
)
|
||||
monkeypatch.setattr(agent, "_source_folder_summaries", lambda db, source: {})
|
||||
monkeypatch.setattr(agent, "list_source_names", lambda db: ["S"])
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
[
|
||||
ToolCallPiece(id="call_1", name="ls", arguments={"path": "S/notes.md"})
|
||||
],
|
||||
[StreamPiece("content", "ans")],
|
||||
)
|
||||
asyncio.run(_run(llm, holder, _settings()))
|
||||
assert holder.tool_calls == 0
|
||||
assert llm.requests[1][0][3]["content"] == (
|
||||
"'S/notes.md' is not a folder — S has: a/"
|
||||
)
|
||||
|
||||
|
||||
# ---------- ls_top / ls_folder: the drill-down accessors (pure + composed) ----------
|
||||
|
||||
|
||||
def test_ls_top_registry_order_zero_docs_and_summaries(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""``ls_top``: registry order (not catalog order), a 0-document
|
||||
source still lists, the summary is the stored ``(source, "")`` row
|
||||
or ``None`` when absent; an empty registry → ``[]``."""
|
||||
monkeypatch.setattr(agent, "list_source_names", lambda db: ["Zeta", "Alpha"])
|
||||
monkeypatch.setattr(
|
||||
agent, "_source_document_counts", lambda db: [("Zeta", 3), ("Beta", 1)]
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
agent, "_source_root_summaries", lambda db: [("Zeta", "Zeta stuff.")]
|
||||
)
|
||||
assert agent.ls_top(cast("Session", object())) == [
|
||||
("Zeta", 3, "Zeta stuff."),
|
||||
("Alpha", 0, None), # 0 docs (no count row) + no stored summary
|
||||
]
|
||||
|
||||
|
||||
def test_ls_top_empty_registry_is_empty(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(agent, "list_source_names", lambda db: [])
|
||||
|
||||
def _boom(*_a: Any, **_k: Any) -> None:
|
||||
raise AssertionError("no fetches for an empty registry")
|
||||
|
||||
monkeypatch.setattr(agent, "_source_document_counts", _boom)
|
||||
monkeypatch.setattr(agent, "_source_root_summaries", _boom)
|
||||
assert agent.ls_top(cast("Session", object())) == []
|
||||
|
||||
|
||||
def test_ls_folder_composes_the_fetchers(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""``ls_folder`` = the source's document rows + stored summaries
|
||||
through the pure :func:`group_folder_listing` (the fetchers are the
|
||||
monkeypatch surface)."""
|
||||
seen: list[tuple[str, str, str]] = []
|
||||
|
||||
def _rows(db: Any, source: str) -> list[tuple[str, str]]:
|
||||
seen.append(("rows", source, ""))
|
||||
return [("a/b.md", "B"), ("a.md", "A")]
|
||||
|
||||
def _summaries(db: Any, source: str) -> dict[str, str]:
|
||||
seen.append(("summaries", source, ""))
|
||||
return {"a": "A stuff."}
|
||||
|
||||
monkeypatch.setattr(agent, "_source_document_rows", _rows)
|
||||
monkeypatch.setattr(agent, "_source_folder_summaries", _summaries)
|
||||
assert agent.ls_folder(cast("Session", object()), "S", "") == (
|
||||
[("a", 1, "A stuff.")],
|
||||
[("S", "a.md", "A")],
|
||||
1,
|
||||
)
|
||||
assert seen == [("rows", "S", ""), ("summaries", "S", "")]
|
||||
|
||||
|
||||
def test_group_folder_listing_subfolder_recursion_and_counts() -> None:
|
||||
"""The recursive count per subfolder — every path equal to the
|
||||
folder or starting with ``folder + '/'`` (a doc under ``a/b/``
|
||||
counts for BOTH ``a`` and ``a/b``), path order, the stored summary
|
||||
attached or ``None``."""
|
||||
rows = [
|
||||
("a/b/c.md", "C"),
|
||||
("a/b/d.md", "D"),
|
||||
("a/e.md", "E"),
|
||||
("f.md", "F"),
|
||||
]
|
||||
sub, files, total = agent.group_folder_listing(
|
||||
"S", "", rows, {"a": "A subtree.", "a/b": "B subtree."}
|
||||
)
|
||||
# ROOT level: the direct subfolders of "" are the TOP-LEVEL folders
|
||||
# only (a/b is nested under a, not direct) — a's count is its whole
|
||||
# recursive subtree (a/e.md + a/b/c.md + a/b/d.md), the stored
|
||||
# summary attached.
|
||||
assert sub == [("a", 3, "A subtree.")]
|
||||
assert files == [("S", "f.md", "F")]
|
||||
assert total == 1
|
||||
# One level down: a/b is a's direct subfolder with its own count.
|
||||
sub2, _files2, _total2 = agent.group_folder_listing("S", "a", rows, {"a/b": "B subtree."})
|
||||
assert sub2 == [("a/b", 2, "B subtree.")]
|
||||
|
||||
|
||||
def test_group_folder_listing_nested_level_counts_and_membership() -> None:
|
||||
"""One level down: ``a``'s direct subfolder is ``a/b`` (count 2),
|
||||
its own direct file is ``a/e.md`` (``a/b/c.md`` is NOT a direct
|
||||
file of ``a``) — membership is the folder_of rule, order is path
|
||||
order."""
|
||||
rows = [
|
||||
("a/b/c.md", "C"),
|
||||
("a/b/d.md", "D"),
|
||||
("a/e.md", "E"),
|
||||
]
|
||||
sub, files, total = agent.group_folder_listing("S", "a", rows, {})
|
||||
assert sub == [("a/b", 2, None)]
|
||||
assert files == [("S", "a/e.md", "E")]
|
||||
assert total == 1
|
||||
|
||||
|
||||
def test_group_folder_listing_file_path_is_not_a_folder() -> None:
|
||||
"""A document whose path is a prefix of NO other path is a file,
|
||||
never a folder: ``ls`` of it must not list a subfolder (and the
|
||||
``path == folder`` count arm only fires for TRUE folders — a doc
|
||||
sharing a real folder's name counts for that folder, the existence
|
||||
rule intact)."""
|
||||
rows = [
|
||||
("a.md", "A"), # a file at the root, and a folder name? NO —
|
||||
("b/x.md", "X"), # nothing starts with "a.md/"
|
||||
]
|
||||
sub, files, total = agent.group_folder_listing("S", "", rows, {})
|
||||
assert sub == [("b", 1, None)] # "a.md" is NOT a subfolder
|
||||
assert files == [("S", "a.md", "A")] # b/x.md is NOT a direct root file
|
||||
assert total == 1
|
||||
# The path == folder arm: a doc named "a" under a real folder "a/".
|
||||
rows2 = [("a", "FileA"), ("a/c.md", "C")]
|
||||
sub2, files2, total2 = agent.group_folder_listing("S", "", rows2, {})
|
||||
assert sub2 == [("a", 2, None)] # the file "a" counts for folder "a"
|
||||
assert files2 == [("S", "a", "FileA")] # …and is a direct ROOT file
|
||||
assert total2 == 1
|
||||
|
||||
|
||||
def test_group_folder_listing_caps_files_at_fifty_keeps_the_total() -> None:
|
||||
"""The cap: 51 direct files → 50 file lines + the PRE-cap total (51)
|
||||
for the renderer's note; 50 files → 50 lines, no note material.
|
||||
A 500-file folder costs 50 lines, never 500."""
|
||||
rows51 = [(f"big/f{i:03d}.md", f"T{i}") for i in range(51)]
|
||||
sub, files, total = agent.group_folder_listing("S", "big", rows51, {})
|
||||
assert sub == []
|
||||
assert total == 51
|
||||
assert len(files) == 50
|
||||
assert files[0] == ("S", "big/f000.md", "T0")
|
||||
assert files[-1] == ("S", "big/f049.md", "T49")
|
||||
rows50 = [(f"big/f{i:03d}.md", f"T{i}") for i in range(50)]
|
||||
_sub, files50, total50 = agent.group_folder_listing("S", "big", rows50, {})
|
||||
assert total50 == 50 and len(files50) == 50
|
||||
|
||||
|
||||
# ---------- the pinned drill-down templates (byte-for-byte) ----------
|
||||
|
||||
|
||||
def test_render_ls_top_template() -> None:
|
||||
assert (
|
||||
agent.render_ls_top(
|
||||
[("Deployments", 3, None), ("Homelab", 5, "Home lab notes.")]
|
||||
)
|
||||
== "2 sources:\n\n"
|
||||
"Deployments — 3 documents\n"
|
||||
"Homelab — 5 documents\n"
|
||||
" Home lab notes."
|
||||
)
|
||||
assert agent.render_ls_top([]) == "0 sources:"
|
||||
|
||||
|
||||
def test_render_folder_listing_root_template() -> None:
|
||||
assert (
|
||||
agent.render_folder_listing(
|
||||
"Homelab",
|
||||
[("backups", 2, "Backup notes."), ("networking", 1, None)],
|
||||
[("Homelab", "readme.md", "Readme")],
|
||||
1,
|
||||
)
|
||||
== "Homelab — 1 documents, 2 folders:\n"
|
||||
"\n"
|
||||
" backups/ — 2 documents: Backup notes.\n"
|
||||
" networking/ — 1 documents\n"
|
||||
"\n"
|
||||
"source: Homelab | path: readme.md | title: Readme"
|
||||
)
|
||||
|
||||
|
||||
def test_render_folder_listing_empty_level_is_header_alone() -> None:
|
||||
"""A registered source with no documents: the header line alone —
|
||||
the old ``0 documents:`` behavior preserved in spirit."""
|
||||
assert agent.render_folder_listing("Homelab", [], [], 0) == (
|
||||
"Homelab — 0 documents, 0 folders:"
|
||||
)
|
||||
|
||||
|
||||
def test_render_folder_listing_subfolders_only_no_blank_trailer() -> None:
|
||||
"""Subfolders but no own files: header + blank + subfolder lines —
|
||||
no trailing blank line, no file section."""
|
||||
assert (
|
||||
agent.render_folder_listing("S", [("a", 1, None)], [], 0)
|
||||
== "S — 0 documents, 1 folders:\n\n a/ — 1 documents"
|
||||
)
|
||||
|
||||
|
||||
def test_render_folder_listing_cap_note_only_past_fifty() -> None:
|
||||
"""The note appears ONLY when the folder's own files outnumber the
|
||||
cap: 51 → 50 lines + the deterministic grep-pointer note (the
|
||||
``…and 1 more…`` shape — unpluralized, the house pin); 50 → no
|
||||
note."""
|
||||
files51 = [("S", f"f{i:03d}.md", f"T{i}") for i in range(51)]
|
||||
capped = files51[:50]
|
||||
rendered = agent.render_folder_listing("S/big", [], capped, 51)
|
||||
lines = rendered.splitlines()
|
||||
assert lines[0] == "S/big — 51 documents, 0 folders:"
|
||||
assert len(lines) == 1 + 1 + 50 + 1 # header, blank, 50 lines, note
|
||||
assert lines[-1] == (
|
||||
"…and 1 more documents in this folder — use grep (pattern) to "
|
||||
"find a specific one."
|
||||
)
|
||||
files50 = [("S", f"f{i:03d}.md", f"T{i}") for i in range(50)]
|
||||
rendered50 = agent.render_folder_listing("S/big", [], files50, 50)
|
||||
assert rendered50.splitlines()[-1] == "source: S | path: f049.md | title: T49"
|
||||
assert "more documents" not in rendered50
|
||||
|
||||
|
||||
# ---------- read: the canonical combined source/path form ----------
|
||||
|
||||
|
||||
@@ -771,7 +1134,6 @@ def test_read_bare_source_name_refused_without_db(monkeypatch: pytest.MonkeyPatc
|
||||
no-document refusal (the argument echoed as passed), no DB lookup
|
||||
(NOT even the phase-72 candidate lookup — ``all_documents`` must
|
||||
not run either), nothing counted."""
|
||||
monkeypatch.setattr(agent, "list_catalog", lambda db: [("Homelab", "a.md", "A")])
|
||||
|
||||
def _boom(*_a: Any, **_k: Any) -> None:
|
||||
raise AssertionError(
|
||||
@@ -1158,7 +1520,6 @@ def test_reading_an_already_read_doc_is_deduped(monkeypatch: pytest.MonkeyPatch)
|
||||
|
||||
|
||||
def test_unknown_tool_name_refused(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(agent, "list_catalog", lambda db: [])
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
[ToolCallPiece(id="call_1", name="delete_universe", arguments={"x": 1})],
|
||||
@@ -1702,13 +2063,11 @@ def test_grep_counts_but_never_adds_context(monkeypatch: pytest.MonkeyPatch) ->
|
||||
|
||||
|
||||
def test_relist_executes_and_counts(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Re-lists execute — a second ``ls`` in one turn returns the catalog
|
||||
again and counts in ``tool_calls`` (no budget to exhaust)."""
|
||||
catalog = [
|
||||
("Deployments", "backups.md", "Backup Strategy"),
|
||||
("Homelab", "aws-route53.md", "AWS Route53 Records"),
|
||||
]
|
||||
monkeypatch.setattr(agent, "list_catalog", lambda db: catalog)
|
||||
"""Re-lists execute — a second ``ls`` in one turn returns the top
|
||||
level again and counts in ``tool_calls`` (no budget to exhaust)."""
|
||||
monkeypatch.setattr(
|
||||
agent, "ls_top", lambda db: [("Deployments", 1, None), ("Homelab", 1, None)]
|
||||
)
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
[ToolCallPiece(id="call_1", name="ls", arguments={})],
|
||||
@@ -1717,12 +2076,8 @@ def test_relist_executes_and_counts(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
)
|
||||
asyncio.run(_run(llm, holder, _settings()))
|
||||
assert holder.tool_calls == 2 # both re-lists executed and counted
|
||||
listing = (
|
||||
"2 documents:\n"
|
||||
"source: Deployments | path: backups.md | title: Backup Strategy\n"
|
||||
"source: Homelab | path: aws-route53.md | title: AWS Route53 Records"
|
||||
)
|
||||
# The answer request carries the catalog a second time as a tool result.
|
||||
listing = "2 sources:\n\nDeployments — 1 documents\nHomelab — 1 documents"
|
||||
# The answer request carries the listing a second time as a tool result.
|
||||
assert llm.requests[2][0][3]["content"] == listing # first listing
|
||||
assert llm.requests[2][0][5]["content"] == listing # the re-list
|
||||
assert llm.requests[2][1] == AGENT_TOOLS # still offered (no budgets)
|
||||
@@ -1758,8 +2113,8 @@ def test_always_ls_bounded_by_round_cap(monkeypatch: pytest.MonkeyPatch) -> None
|
||||
"""A model that keeps calling ``ls`` gets exactly
|
||||
``agent_max_rounds`` tool rounds, then one forced ``tools=None``
|
||||
request streams the answer — the cap is the only forced exit."""
|
||||
monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")])
|
||||
listing = "1 documents:\nsource: S | path: a.md | title: A"
|
||||
monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)])
|
||||
listing = "1 sources:\n\nS — 1 documents"
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
[ToolCallPiece(id="call_1", name="ls", arguments={})],
|
||||
@@ -1923,7 +2278,7 @@ def test_round_retried_before_first_piece(
|
||||
same messages: the stream carries a RetryPiece BEFORE the tool call,
|
||||
the tool executes, the final answer streams, and the per-call log line
|
||||
is still emitted exactly once (retries are invisible to the loop)."""
|
||||
monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")])
|
||||
monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)])
|
||||
holder = AgentHolder()
|
||||
llm = FailingLLM(
|
||||
[
|
||||
@@ -1961,7 +2316,6 @@ def test_round_failure_after_first_piece_is_terminal(monkeypatch: pytest.MonkeyP
|
||||
the LLMError propagates out of ``run_agent``, no RetryPiece, no
|
||||
sleep, no second request, and the holder is untouched (the tool
|
||||
never ran)."""
|
||||
monkeypatch.setattr(agent, "list_catalog", lambda db: [])
|
||||
holder = AgentHolder()
|
||||
llm = FailingLLM(
|
||||
[([StreamPiece("content", "partial ")], LLMError("mid-stream drop"))]
|
||||
@@ -1994,7 +2348,7 @@ def test_forced_final_no_tools_call_is_retried(monkeypatch: pytest.MonkeyPatch)
|
||||
"""The forced final request (round cap reached) goes through the same
|
||||
retry rule: a failure before its first piece yields a RetryPiece and
|
||||
restarts with ``tools=None``; the answer from the retry streams."""
|
||||
monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")])
|
||||
monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)])
|
||||
holder = AgentHolder()
|
||||
llm = FailingLLM(
|
||||
[
|
||||
@@ -2026,7 +2380,6 @@ def test_zero_retries_is_one_plain_attempt(monkeypatch: pytest.MonkeyPatch) -> N
|
||||
"""The kill-switch path (``llm_retries=0``): a dead round raises
|
||||
immediately — one request, no RetryPiece, no sleep (pre-phase-67
|
||||
behavior)."""
|
||||
monkeypatch.setattr(agent, "list_catalog", lambda db: [])
|
||||
holder = AgentHolder()
|
||||
llm = FailingLLM([([], LLMError("connection refused"))])
|
||||
sleeps = _record_sleeps(monkeypatch)
|
||||
@@ -2065,7 +2418,6 @@ def test_abandon_mid_retry_sleep_leaks_nothing(monkeypatch: pytest.MonkeyPatch)
|
||||
await asyncio.Event().wait() # park until the abandon arrives
|
||||
|
||||
monkeypatch.setattr(asyncio, "sleep", parking_sleep)
|
||||
monkeypatch.setattr(agent, "list_catalog", lambda db: [])
|
||||
holder = AgentHolder()
|
||||
llm = FailingLLM(
|
||||
[([], LLMError("endpoint down")), ([StreamPiece("content", "never")], None)]
|
||||
@@ -2111,7 +2463,7 @@ def test_retries_are_invisible_to_the_round_cap(
|
||||
2, the retried first round and the second tool round fill the cap —
|
||||
the forced final follows the SECOND call, and the log lines read
|
||||
round=1/2 and round=2/2."""
|
||||
monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")])
|
||||
monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)])
|
||||
holder = AgentHolder()
|
||||
llm = FailingLLM(
|
||||
[
|
||||
@@ -2364,7 +2716,7 @@ def test_scaffolding_round_with_tool_calls_needs_no_recovery(
|
||||
ran, and the policy keys on the no-calls exit only — no recovery (the
|
||||
next round is a normal tools-offered round carrying the tool
|
||||
history)."""
|
||||
monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")])
|
||||
monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)])
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
[
|
||||
@@ -2398,7 +2750,7 @@ def test_recovery_after_tool_rounds_keeps_the_history(
|
||||
keeps the SINGLE (folded) system message at the front and the tool
|
||||
history intact behind it — no second system message, no duplicated
|
||||
correction."""
|
||||
monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")])
|
||||
monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)])
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
[ToolCallPiece(id="call_1", name="ls", arguments={})],
|
||||
@@ -2423,7 +2775,7 @@ def test_recovery_after_tool_rounds_keeps_the_history(
|
||||
assert recovered[3] == {
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": "1 documents:\nsource: S | path: a.md | title: A",
|
||||
"content": "1 sources:\n\nS — 1 documents",
|
||||
}
|
||||
assert sum(1 for m in recovered if m["role"] == "system") == 1
|
||||
assert holder.scaffold_stripped == len(_INCIDENT_SPAN)
|
||||
@@ -2436,7 +2788,7 @@ def test_forced_final_scaffolding_only_settles_malformed(
|
||||
scaffolding-only forced answer never reaches the user raw — the turn
|
||||
settles with :class:`MalformedReplyError` (the same terminal
|
||||
semantics; this turn used no recovery, so nothing is doubled up)."""
|
||||
monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")])
|
||||
monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)])
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
[ToolCallPiece(id="call_1", name="ls", arguments={})],
|
||||
|
||||
@@ -0,0 +1,628 @@
|
||||
"""Unit: folder summary storage + generator (phase 94, task 01).
|
||||
|
||||
The prompt/grouping tests are pure (no DB): ``FOLDER_SUMMARY_MODE``
|
||||
system prompt, the ``folder_of`` / ``group_by_folder`` recursive-subtree
|
||||
concept, and the user-message cap with the shared ``[…truncated…]``
|
||||
marker. The generator tests run against the local compose Postgres
|
||||
(preferred — real upsert/prune on the ``folder_summaries`` table),
|
||||
skipping with clear instructions when the stack is not up — same
|
||||
pattern as ``test_overview.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import SessionLocal
|
||||
from app.models import Document, FolderSummary
|
||||
from app.rag.folder_summaries import (
|
||||
FOLDER_HEADER_PREFIX,
|
||||
FOLDER_SUMMARY_INSTRUCTION,
|
||||
FOLDER_SUMMARY_MODE,
|
||||
MIN_DOCS_PER_FOLDER,
|
||||
SYSTEM_PROMPT,
|
||||
build_folder_summary_prompt,
|
||||
folder_of,
|
||||
folder_summary_table_empty,
|
||||
generate_folder_summaries,
|
||||
group_by_folder,
|
||||
summarize_folder,
|
||||
)
|
||||
from app.rag.llm import LLMError
|
||||
from app.rag.retriever import TRUNCATION_MARKER
|
||||
from tests.e2e.mock_llm import compose_answer
|
||||
|
||||
REPLY = "Covers lab automation runbooks: inventories, playbooks, and schedules."
|
||||
|
||||
|
||||
class _FakeLLM:
|
||||
"""Duck-typed stand-in for ``LLMClient`` (``chat`` + ``settings``).
|
||||
|
||||
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
|
||||
(the per-folder fail-soft tests).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
reply: str = REPLY,
|
||||
fail_folders: tuple[str, ...] = (),
|
||||
fail: Exception | None = None,
|
||||
) -> None:
|
||||
self._reply = reply
|
||||
self._fail_folders = tuple(fail_folders)
|
||||
self._fail = fail
|
||||
self.settings = Settings(_env_file=None) # pyright: ignore[reportCallIssue]
|
||||
self.calls = 0
|
||||
self.model: str | None = None
|
||||
self.requests: list[tuple[str, str]] = []
|
||||
|
||||
async def chat(
|
||||
self, messages: list[dict[str, str]], model: str | None = None
|
||||
) -> str:
|
||||
self.calls += 1
|
||||
self.model = model
|
||||
system = messages[0]["content"]
|
||||
user = messages[-1]["content"]
|
||||
self.requests.append((system, user))
|
||||
for folder in self._fail_folders:
|
||||
if FOLDER_HEADER_PREFIX + folder in user:
|
||||
raise LLMError(f"simulated lite-model failure for {folder}")
|
||||
if self._fail is not None:
|
||||
raise self._fail
|
||||
return self._reply
|
||||
|
||||
|
||||
# ---------- folder_of ----------
|
||||
|
||||
|
||||
def test_folder_of_root_level_file_is_empty() -> None:
|
||||
assert folder_of("a.md") == ""
|
||||
|
||||
|
||||
def test_folder_of_one_level() -> None:
|
||||
assert folder_of("a/b.md") == "a"
|
||||
|
||||
|
||||
def test_folder_of_deep_path() -> None:
|
||||
assert folder_of("a/b/c/d.md") == "a/b/c"
|
||||
|
||||
|
||||
def test_folder_of_iterating_walks_prefixes_to_root() -> None:
|
||||
"""Iterating ``folder_of`` over its own result walks the folder
|
||||
prefixes nearest-first, ending at the root (the grouping walk)."""
|
||||
folder = folder_of("a/b/c.md")
|
||||
chain: list[str] = []
|
||||
while folder:
|
||||
chain.append(folder)
|
||||
folder = folder_of(folder)
|
||||
assert chain == ["a/b", "a"] # plus the "" root the grouping adds
|
||||
|
||||
|
||||
# ---------- group_by_folder ----------
|
||||
|
||||
|
||||
def test_group_by_folder_root_file_lands_only_in_source_root() -> None:
|
||||
rows = [("S", "top.md", "T", None)]
|
||||
groups = group_by_folder(rows)
|
||||
assert set(groups) == {("S", "")}
|
||||
assert groups[("S", "")] == rows
|
||||
|
||||
|
||||
def test_group_by_folder_nested_multi_source_recursive_subtree() -> None:
|
||||
"""A doc under ``a/b/`` is present in the ``a``, ``a/b``, and ``""``
|
||||
groups (recursive subtree — the ``ls`` count scope, one concept);
|
||||
per source the candidates are ``""`` + every distinct folder
|
||||
prefix; group lists keep the input (catalogue) order."""
|
||||
rows = [
|
||||
("S", "a/b/c.md", "C", None),
|
||||
("S", "a/b/d.md", "D", None),
|
||||
("S", "a/x.md", "X", None),
|
||||
("S", "top.md", "T", None),
|
||||
("T", "a/b/e.md", "E", None),
|
||||
]
|
||||
groups = group_by_folder(rows)
|
||||
assert set(groups) == {
|
||||
("S", ""),
|
||||
("S", "a"),
|
||||
("S", "a/b"),
|
||||
("T", ""),
|
||||
("T", "a"),
|
||||
("T", "a/b"),
|
||||
}
|
||||
# The recursive-subtree concept: a/b/ docs in the a/, a/b/, and ""
|
||||
# groups alike — exactly the set each level's ls count shows.
|
||||
assert [r[1] for r in groups[("S", "a/b")]] == ["a/b/c.md", "a/b/d.md"]
|
||||
assert [r[1] for r in groups[("S", "a")]] == ["a/b/c.md", "a/b/d.md", "a/x.md"]
|
||||
assert [r[1] for r in groups[("S", "")]] == [
|
||||
"a/b/c.md",
|
||||
"a/b/d.md",
|
||||
"a/x.md",
|
||||
"top.md",
|
||||
]
|
||||
# Multi-source: the same folder prefix under another source is a
|
||||
# separate group (PK is (source, folder_path)).
|
||||
assert [r[1] for r in groups[("T", "a/b")]] == ["a/b/e.md"]
|
||||
assert [r[1] for r in groups[("T", "")]] == ["a/b/e.md"]
|
||||
# Input (catalogue) order is preserved inside each group.
|
||||
assert [r[2] for r in groups[("S", "")]] == ["C", "D", "X", "T"]
|
||||
|
||||
|
||||
def test_group_by_folder_single_doc_folder_is_a_group_too() -> None:
|
||||
"""Grouping is pure subtree membership (≥ 1 docs): the ≥ 2 rule is
|
||||
the GENERATOR's (the recursive count below the minimum yields no
|
||||
row — pinned by the generator tests, not the grouping)."""
|
||||
rows = [("S", "a/only.md", "O", None)]
|
||||
groups = group_by_folder(rows)
|
||||
assert len(groups[("S", "a")]) == 1 # present, but below the minimum
|
||||
|
||||
|
||||
def test_group_by_folder_doc_path_equal_to_a_folder_prefix_counts_for_it() -> None:
|
||||
"""The count rule's ``path == folder`` arm: a document whose path
|
||||
IS one of the source's folder prefixes (a file sharing its name
|
||||
with a directory) belongs to that folder's group too — the grouping
|
||||
stays EXACTLY the set the ``ls`` count rule counts (path equal or
|
||||
starting with ``folder + "/"``), while the returned keys remain the
|
||||
true folder prefixes only (no file-path keys)."""
|
||||
rows = [
|
||||
("S", "a/b", "B", None), # a file named "b" ... (its path is a folder prefix)
|
||||
("S", "a/b/c.md", "C", None), # ... and a real folder "a/b/" holding a doc
|
||||
("S", "a/x.md", "X", None),
|
||||
]
|
||||
groups = group_by_folder(rows)
|
||||
assert set(groups) == {("S", ""), ("S", "a"), ("S", "a/b")}, (
|
||||
"the keys stay the true folder prefixes — the file's own path adds no key"
|
||||
)
|
||||
assert [r[1] for r in groups[("S", "a/b")]] == ["a/b", "a/b/c.md"]
|
||||
assert [r[1] for r in groups[("S", "a")]] == ["a/b", "a/b/c.md", "a/x.md"]
|
||||
assert [r[1] for r in groups[("S", "")]] == ["a/b", "a/b/c.md", "a/x.md"]
|
||||
|
||||
|
||||
def test_group_by_folder_plain_file_path_is_not_a_group_key() -> None:
|
||||
"""A file path that is NO folder prefix (no doc under it) adds no
|
||||
group key of its own — the ``path == folder`` arm only fires when
|
||||
the path really is a prefix of the catalogue."""
|
||||
rows = [("S", "top.md", "T", None), ("S", "a/one.md", "O", None)]
|
||||
groups = group_by_folder(rows)
|
||||
assert set(groups) == {("S", ""), ("S", "a")}
|
||||
assert "top.md" not in [folder for _source, folder in groups]
|
||||
|
||||
|
||||
# ---------- build_folder_summary_prompt: system ----------
|
||||
|
||||
|
||||
def test_system_prompt_has_marker_and_locked_instruction() -> None:
|
||||
assert SYSTEM_PROMPT.startswith(FOLDER_SUMMARY_MODE)
|
||||
assert FOLDER_SUMMARY_INSTRUCTION in SYSTEM_PROMPT
|
||||
for fragment in (
|
||||
"1-3 sentence",
|
||||
"plain-text summary",
|
||||
"natural language",
|
||||
"Do not use markdown",
|
||||
"not in the list",
|
||||
):
|
||||
assert fragment in SYSTEM_PROMPT
|
||||
system, _ = build_folder_summary_prompt("S", "a/b", [])
|
||||
assert system == SYSTEM_PROMPT
|
||||
assert FOLDER_SUMMARY_MODE in system # the marker the E2E mock keys on
|
||||
|
||||
|
||||
# ---------- build_folder_summary_prompt: user ----------
|
||||
|
||||
|
||||
def test_user_prompt_header_names_the_folder() -> None:
|
||||
"""The first line is the ``FOLDER_HEADER_PREFIX`` header the E2E
|
||||
mock parses: ``<source>`` for the root, ``<source>/<folder_path>``
|
||||
for a folder."""
|
||||
_, user = build_folder_summary_prompt("Homelab", "deployments/ansible", [])
|
||||
assert user == FOLDER_HEADER_PREFIX + "Homelab/deployments/ansible"
|
||||
_, user = build_folder_summary_prompt("Homelab", "", [])
|
||||
assert user == FOLDER_HEADER_PREFIX + "Homelab"
|
||||
|
||||
|
||||
def test_user_lines_carry_path_title_and_first_summary_line() -> None:
|
||||
docs = [
|
||||
("S", "a/b/one.md", "One", "First lead.\nSecond line.\nSource: S/a/b/one.md"),
|
||||
("S", "a/b/two.md", "Two", None),
|
||||
]
|
||||
system, user = build_folder_summary_prompt("S", "a/b", docs)
|
||||
assert system == SYSTEM_PROMPT
|
||||
assert user == (
|
||||
"Folder: S/a/b\n"
|
||||
"a/b/one.md — One — First lead.\n"
|
||||
"a/b/two.md — Two"
|
||||
)
|
||||
|
||||
|
||||
def test_user_line_omits_summary_field_when_absent_or_blank() -> None:
|
||||
docs = [
|
||||
("S", "a/x.md", "X", None),
|
||||
("S", "a/y.md", "Y", " \n\t "),
|
||||
]
|
||||
_, user = build_folder_summary_prompt("S", "a", docs)
|
||||
assert user == "Folder: S/a\na/x.md — X\na/y.md — Y"
|
||||
assert " — " in user # the path — title join only
|
||||
assert not any(line.endswith(" — ") for line in user.splitlines())
|
||||
|
||||
|
||||
def test_user_line_uses_only_first_summary_line() -> None:
|
||||
docs = [
|
||||
("S", "a/x.md", "X", "First line.\nSecond line.\nSource: S/a/x.md"),
|
||||
]
|
||||
_, user = build_folder_summary_prompt("S", "a", docs)
|
||||
assert user == "Folder: S/a\na/x.md — X — First line."
|
||||
assert "Second line" not in user
|
||||
assert "Source:" not in user
|
||||
|
||||
|
||||
def test_user_prompt_truncated_with_marker_when_over_custom_cap() -> None:
|
||||
docs = [("S", f"a/f{i}.md", f"T{i}", None) for i in range(10)]
|
||||
_, full = build_folder_summary_prompt("S", "a", docs, max_chars=10_000)
|
||||
cap = 30
|
||||
_, user = build_folder_summary_prompt("S", "a", docs, max_chars=cap)
|
||||
assert user == full[:cap] + "\n" + TRUNCATION_MARKER
|
||||
assert user.endswith(TRUNCATION_MARKER)
|
||||
assert len(user) > cap # the marker makes the cut visible past the cap
|
||||
|
||||
|
||||
def test_user_prompt_at_exact_cap_not_truncated() -> None:
|
||||
docs = [("S", "a/x.md", "X", None)] # "Folder: S/a\na/x.md — X" = 22 chars
|
||||
_, user = build_folder_summary_prompt("S", "a", docs, max_chars=22)
|
||||
assert user == "Folder: S/a\na/x.md — X"
|
||||
assert TRUNCATION_MARKER not in user
|
||||
|
||||
|
||||
def test_user_prompt_truncated_at_default_cap() -> None:
|
||||
"""No explicit cap → ``BOR_FOLDER_SUMMARY_INPUT_MAX_CHARS`` (read
|
||||
from the live settings, so the test holds for any configured
|
||||
value)."""
|
||||
cap = get_settings().folder_summary_input_max_chars
|
||||
docs = [("S", f"a/f{i}.md", "T", None) for i in range(3_000)]
|
||||
_, user = build_folder_summary_prompt("S", "a", docs)
|
||||
assert user.endswith(TRUNCATION_MARKER)
|
||||
body = user.removesuffix("\n" + TRUNCATION_MARKER)
|
||||
assert len(body) == cap # cut exactly at the cap, marker on its own line
|
||||
assert "f2999.md" not in body # the overflow never reaches the model
|
||||
|
||||
|
||||
# ---------- summarize_folder ----------
|
||||
|
||||
|
||||
# ---------- the E2E mock's FOLDER_SUMMARY_MODE branch ----------
|
||||
|
||||
|
||||
def _mock_body(system: str, user: str) -> dict[str, Any]:
|
||||
"""A minimal chat-completion body for the mock's ``compose_answer``."""
|
||||
return {"messages": [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
]}
|
||||
|
||||
|
||||
def test_mock_returns_canned_folder_summary_naming_the_folder() -> None:
|
||||
"""The deterministic E2E mock keys on the ``FOLDER_SUMMARY_MODE``
|
||||
marker in the system prompt and returns the canned one-liner naming
|
||||
the folder from the ``Folder: …`` header — driven through the
|
||||
GENERATOR's real prompt, so the two can never drift (the drill-down
|
||||
E2E asserts on this exact template)."""
|
||||
system, user = build_folder_summary_prompt(
|
||||
"Homelab", "deployments/ansible",
|
||||
[("Homelab", "deployments/ansible/lab-inventory.md", "Lab Inventory", None)],
|
||||
)
|
||||
assert compose_answer(_mock_body(system, user)) == (
|
||||
"Fixture folder summary for Homelab/deployments/ansible."
|
||||
)
|
||||
# The source-root row names the source itself.
|
||||
system, user = build_folder_summary_prompt("Homelab", "",
|
||||
[("Homelab", "top.md", "Top", None)])
|
||||
assert compose_answer(_mock_body(system, user)) == (
|
||||
"Fixture folder summary for Homelab."
|
||||
)
|
||||
|
||||
|
||||
def test_mock_folder_marker_is_not_shadowed_by_the_summary_branch() -> None:
|
||||
"""``FOLDER_SUMMARY_MODE`` contains ``SUMMARY_MODE`` as a substring —
|
||||
the mock must check the folder branch FIRST, or every folder call
|
||||
would land in the document-summary digest (regression pin)."""
|
||||
system, user = build_folder_summary_prompt(
|
||||
"S", "a", [("S", "a/x.md", "X", None)]
|
||||
)
|
||||
assert "SUMMARY_MODE" in system # the shadowing hazard is real
|
||||
answer = compose_answer(_mock_body(system, user))
|
||||
assert answer == "Fixture folder summary for S/a."
|
||||
assert not answer.startswith("This document covers")
|
||||
|
||||
|
||||
def test_summarize_folder_happy_path_returns_trimmed_text() -> None:
|
||||
docs = [("S", "a/x.md", "X", None)]
|
||||
llm = _FakeLLM(reply=f" {REPLY} \n")
|
||||
out = asyncio.run(summarize_folder("S", "a", docs, llm))
|
||||
assert out == REPLY # the model's text, trimmed
|
||||
assert llm.calls == 1
|
||||
|
||||
|
||||
def test_summarize_folder_calls_the_configured_summary_model_with_marker() -> None:
|
||||
docs = [("S", "a/x.md", "X", "X lead.")]
|
||||
llm = _FakeLLM()
|
||||
asyncio.run(summarize_folder("S", "a", docs, llm))
|
||||
assert llm.model == llm.settings.llm_summary_model # the ``lite`` default
|
||||
assert llm.model == "lite"
|
||||
system, user = llm.requests[0]
|
||||
assert FOLDER_SUMMARY_MODE in system
|
||||
assert user.startswith(FOLDER_HEADER_PREFIX + "S/a")
|
||||
assert "a/x.md — X — X lead." in user
|
||||
|
||||
|
||||
def test_summarize_folder_empty_reply_raises_llm_error() -> None:
|
||||
docs = [("S", "a/x.md", "X", None)]
|
||||
for reply in ("", " \n\t "):
|
||||
llm = _FakeLLM(reply=reply)
|
||||
with pytest.raises(LLMError, match="empty content for S/a"):
|
||||
asyncio.run(summarize_folder("S", "a", docs, llm))
|
||||
|
||||
|
||||
def test_summarize_folder_error_propagates() -> None:
|
||||
docs = [("S", "a/x.md", "X", None)]
|
||||
llm = _FakeLLM(fail=LLMError("simulated transport failure"))
|
||||
with pytest.raises(LLMError, match="simulated transport failure"):
|
||||
asyncio.run(summarize_folder("S", "a", docs, llm))
|
||||
|
||||
|
||||
# ---------- generate_folder_summaries (real Postgres) ----------
|
||||
|
||||
|
||||
def _add_doc(
|
||||
db: Session, source: str, path: str, title: str, summary: str | None = None
|
||||
) -> Document:
|
||||
doc = Document(
|
||||
id=uuid.uuid4(),
|
||||
source=source,
|
||||
path=path,
|
||||
full_path=f"/tmp/{path}",
|
||||
title=title,
|
||||
content="body",
|
||||
content_hash="0" * 64,
|
||||
summary=summary,
|
||||
)
|
||||
db.add(doc)
|
||||
db.commit()
|
||||
return doc
|
||||
|
||||
|
||||
def _truncate(db: Session) -> None:
|
||||
db.execute(text("TRUNCATE chunks, documents"))
|
||||
db.execute(text("DELETE FROM folder_summaries"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _rows(db: Session) -> dict[tuple[str, str], str]:
|
||||
"""The stored folder summaries: ``{(source, folder_path): summary}``."""
|
||||
result = db.execute(
|
||||
text("SELECT source, folder_path, summary FROM folder_summaries")
|
||||
).all()
|
||||
return {(source, folder_path): summary for source, folder_path, summary in result}
|
||||
|
||||
|
||||
def _seed_catalogue(db: Session) -> None:
|
||||
"""The shared catalogue: FSU has four docs in three candidate
|
||||
folders (root 4, a 3, a/b 2 — all ≥ the minimum); FSU-solo has one
|
||||
doc (its root folder is below the minimum — no row, no call)."""
|
||||
_add_doc(db, "FSU", "a/b/one.md", "One", "One lead.\nSource: FSU/a/b/one.md")
|
||||
_add_doc(db, "FSU", "a/b/two.md", "Two")
|
||||
_add_doc(db, "FSU", "a/three.md", "Three")
|
||||
_add_doc(db, "FSU", "root.md", "Root")
|
||||
_add_doc(db, "FSU-solo", "solo.md", "Solo")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def clean_tables(db: Session):
|
||||
_truncate(db)
|
||||
yield
|
||||
_truncate(db)
|
||||
|
||||
|
||||
def test_generate_happy_path_upserts_every_candidate_folder(
|
||||
db: Session, clean_tables, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Every folder with ≥ 2 recursive docs gets a row (the source root
|
||||
row included — ``folder_path = ''``); single-doc folders get none;
|
||||
rows are stamped fresh; the stats dict and the log line are right;
|
||||
folders are processed in deterministic (source, folder_path) order."""
|
||||
_seed_catalogue(db)
|
||||
llm = _FakeLLM()
|
||||
with caplog.at_level(logging.INFO, logger="app.rag.folder_summaries"):
|
||||
stats = asyncio.run(generate_folder_summaries(db, llm))
|
||||
assert stats == {"generated": 3, "failed": 0, "pruned": 0}
|
||||
assert llm.calls == 3, "one lite call per candidate folder (the solo folder: none)"
|
||||
|
||||
stored = _rows(db)
|
||||
assert set(stored) == {("FSU", ""), ("FSU", "a"), ("FSU", "a/b")}
|
||||
assert all(summary == REPLY for summary in stored.values())
|
||||
assert ("FSU-solo", "") not in stored, (
|
||||
"a single-doc folder is fully described by its one file line — no row"
|
||||
)
|
||||
|
||||
row = db.get(FolderSummary, ("FSU", "a/b"))
|
||||
assert row is not None
|
||||
assert row.summary == REPLY
|
||||
assert row.updated_at is not None
|
||||
age = datetime.now(UTC) - row.updated_at
|
||||
assert age.total_seconds() < 300, "updated_at must be a fresh UTC timestamp"
|
||||
|
||||
# Deterministic (source, folder_path) order — root before the
|
||||
# nested folders, one header per call.
|
||||
assert [user.splitlines()[0] for _s, user in llm.requests] == [
|
||||
"Folder: FSU",
|
||||
"Folder: FSU/a",
|
||||
"Folder: FSU/a/b",
|
||||
]
|
||||
# The recursive-subtree input: the a/ prompt carries a/b's docs too.
|
||||
a_prompt = llm.requests[1][1]
|
||||
assert "a/b/one.md — One — One lead." in a_prompt
|
||||
assert "a/three.md — Three" in a_prompt
|
||||
assert "root.md — Root" not in a_prompt
|
||||
|
||||
assert (
|
||||
"folder_summaries: generated=3 failed=0 pruned=0" in caplog.text
|
||||
), "the stats line must be greppable (PLAN §9 ample logging)"
|
||||
|
||||
|
||||
def test_generate_per_folder_fail_soft_keeps_previous_and_lands_others(
|
||||
db: Session, clean_tables, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""One folder's lite failure is logged and counted, its PREVIOUS
|
||||
row is kept (an old summary is better than none), and the remaining
|
||||
folders still land — a lite outage never fails the sync."""
|
||||
_seed_catalogue(db)
|
||||
db.add(FolderSummary(source="FSU", folder_path="a/b", summary="old summary"))
|
||||
db.commit()
|
||||
llm = _FakeLLM(fail_folders=("FSU/a/b",))
|
||||
with caplog.at_level(logging.ERROR, logger="app.rag.folder_summaries"):
|
||||
stats = asyncio.run(generate_folder_summaries(db, llm))
|
||||
assert stats == {"generated": 2, "failed": 1, "pruned": 0}
|
||||
assert llm.calls == 3 # the failing folder was attempted too
|
||||
|
||||
stored = _rows(db)
|
||||
assert stored[("FSU", "a/b")] == "old summary", (
|
||||
"the previous row survives the per-folder failure"
|
||||
)
|
||||
assert stored[("FSU", "")] == REPLY and stored[("FSU", "a")] == REPLY, (
|
||||
"the other folders still land"
|
||||
)
|
||||
assert "folder summary failed for FSU/a/b" in caplog.text
|
||||
assert "simulated lite-model failure for FSU/a/b" in caplog.text
|
||||
|
||||
|
||||
def test_generate_per_folder_fail_soft_without_previous_row_creates_nothing(
|
||||
db: Session, clean_tables
|
||||
) -> None:
|
||||
_seed_catalogue(db)
|
||||
llm = _FakeLLM(fail_folders=("FSU/a/b",))
|
||||
stats = asyncio.run(generate_folder_summaries(db, llm))
|
||||
assert stats["failed"] == 1
|
||||
stored = _rows(db)
|
||||
assert ("FSU", "a/b") not in stored, "no row must be invented for a failed folder"
|
||||
assert ("FSU", "") in stored and ("FSU", "a") in stored
|
||||
|
||||
|
||||
def test_generate_prunes_stale_rows_and_keeps_live_ones(db: Session, clean_tables) -> None:
|
||||
"""Rows for folders that dropped below 2 recursive docs are deleted
|
||||
(pruned/renamed — the summary would go stale); rows for folders
|
||||
that still qualify persist (an unchanged folder's summary is still
|
||||
true — regenerated in place)."""
|
||||
_seed_catalogue(db)
|
||||
# A stale row for a folder no longer in the catalogue (3→1 docs /
|
||||
# renamed away) + a live row with old content.
|
||||
db.add(FolderSummary(source="FSU", folder_path="gone/old", summary="stale"))
|
||||
db.add(FolderSummary(source="FSU", folder_path="a", summary="old a summary"))
|
||||
db.add(FolderSummary(source="FSU-solo", folder_path="", summary="solo stale"))
|
||||
db.commit()
|
||||
stats = asyncio.run(generate_folder_summaries(db, _FakeLLM()))
|
||||
assert stats["pruned"] == 2 # gone/old + the FSU-solo root (1 doc)
|
||||
|
||||
stored = _rows(db)
|
||||
assert ("FSU", "gone/old") not in stored, "the stale folder row must be pruned"
|
||||
assert ("FSU-solo", "") not in stored, (
|
||||
"a folder that dropped below 2 docs loses its row"
|
||||
)
|
||||
assert ("FSU", "a") in stored, "the still-qualifying folder keeps its row"
|
||||
assert stored[("FSU", "a")] == REPLY # regenerated, not stale
|
||||
assert stored[("FSU", "")] == REPLY and stored[("FSU", "a/b")] == REPLY
|
||||
|
||||
|
||||
def test_generate_skip_is_a_full_noop(db: Session, clean_tables) -> None:
|
||||
"""``skip=True`` (the ``--limit`` debug run): the LLM is never
|
||||
called, no rows are touched, zero stats."""
|
||||
_seed_catalogue(db)
|
||||
db.add(FolderSummary(source="FSU", folder_path="", summary="existing"))
|
||||
db.commit()
|
||||
llm = _FakeLLM()
|
||||
stats = asyncio.run(generate_folder_summaries(db, llm, skip=True))
|
||||
assert stats == {"generated": 0, "failed": 0, "pruned": 0}
|
||||
assert llm.calls == 0
|
||||
assert _rows(db) == {("FSU", ""): "existing"}
|
||||
|
||||
|
||||
def test_generate_empty_kb_prunes_every_row(db: Session, clean_tables) -> None:
|
||||
"""No documents → no candidate folders → every stored row is
|
||||
pruned, with zero wasted lite calls."""
|
||||
db.add(FolderSummary(source="FSU", folder_path="", summary="old"))
|
||||
db.add(FolderSummary(source="FSU", folder_path="a/b", summary="old"))
|
||||
db.commit()
|
||||
llm = _FakeLLM()
|
||||
stats = asyncio.run(generate_folder_summaries(db, llm))
|
||||
assert stats == {"generated": 0, "failed": 0, "pruned": 2}
|
||||
assert llm.calls == 0
|
||||
assert _rows(db) == {}
|
||||
|
||||
|
||||
def test_generate_summarizes_folder_whose_prefix_is_also_a_doc_path(
|
||||
db: Session, clean_tables
|
||||
) -> None:
|
||||
"""The ``path == folder`` arm end to end: a file sharing its name
|
||||
with a directory counts toward the folder's recursive count (2
|
||||
docs → the folder is summarized, and BOTH docs are in its prompt).
|
||||
"""
|
||||
_add_doc(db, "FSU", "a/b", "B") # a file named "b" (its path is a prefix)
|
||||
_add_doc(db, "FSU", "a/b/c.md", "C") # and a real folder "a/b/"
|
||||
llm = _FakeLLM()
|
||||
stats = asyncio.run(generate_folder_summaries(db, llm))
|
||||
assert stats["generated"] == 3 # root (2), a (2), a/b (2) — all ≥ the minimum
|
||||
stored = _rows(db)
|
||||
assert set(stored) == {("FSU", ""), ("FSU", "a"), ("FSU", "a/b")}
|
||||
a_b_prompt = [
|
||||
user for _system, user in llm.requests if user.startswith("Folder: FSU/a/b\n")
|
||||
][0]
|
||||
assert "a/b — B" in a_b_prompt
|
||||
assert "a/b/c.md — C" in a_b_prompt
|
||||
|
||||
|
||||
def test_generate_only_flushes_caller_commits(db: Session, clean_tables) -> None:
|
||||
"""The generator only flushes — the sync path owns the transaction
|
||||
(the phase-53 ``bump_sources_version`` convention): the catalogue
|
||||
is committed (the real sync path commits the import before the
|
||||
summary hooks run), but a second session sees the generator's rows
|
||||
as NOTHING until the CALLER commits — and sees them after."""
|
||||
_add_doc(db, "FSU", "x/y/one.md", "One")
|
||||
_add_doc(db, "FSU", "x/y/two.md", "Two")
|
||||
stats = asyncio.run(generate_folder_summaries(db, _FakeLLM()))
|
||||
assert stats["generated"] == 3 # root + x + x/y — all 2 recursive docs
|
||||
|
||||
with SessionLocal() as other:
|
||||
n = other.scalar(
|
||||
text("SELECT count(*) FROM folder_summaries WHERE source = 'FSU'")
|
||||
)
|
||||
assert n == 0, "unflushed-by-caller rows must not be visible yet"
|
||||
|
||||
db.commit()
|
||||
with SessionLocal() as other:
|
||||
n = other.scalar(
|
||||
text("SELECT count(*) FROM folder_summaries WHERE source = 'FSU'")
|
||||
)
|
||||
assert n == 3, "the caller's commit makes the flushed rows durable"
|
||||
|
||||
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")
|
||||
asyncio.run(generate_folder_summaries(db, _FakeLLM()))
|
||||
db.commit()
|
||||
assert folder_summary_table_empty(db) is False # rows landed
|
||||
db.execute(text("DELETE FROM folder_summaries"))
|
||||
db.commit()
|
||||
assert folder_summary_table_empty(db) is True # emptied again
|
||||
@@ -4,12 +4,18 @@ The mock (``tests/e2e/mock_llm.py``) classifies marker requests
|
||||
statelessly into one step of the agent tool flow. This file pins the
|
||||
classification at unit speed — no Playwright, no LLM process:
|
||||
|
||||
* the phase-37 SINGLE-READ flow (``TOOLS_TRIGGER`` only) stays
|
||||
byte-identical: list → read (first catalog line, ``call_1``) → answer;
|
||||
* the phase-45 MULTI-READ flow (``TOOLS_TRIGGER`` + ``MULTI_READ_TRIGGER``)
|
||||
classifies by the count of ``tool``-role read results: list → read #1
|
||||
(``call_1``) → read #2 (second catalog line, ``call_2``) → the
|
||||
byte-stable ``multi_answer`` naming both read paths.
|
||||
* the phase-37/94 SINGLE-READ flow (``TOOLS_TRIGGER`` only): list (the
|
||||
top-level source listing) → drill (``ls`` scoped to the first source
|
||||
— phase 94: the top level carries sources only, so the flow drills
|
||||
one level for the file lines) → read (first file line, the combined
|
||||
``source/path``) → answer;
|
||||
* the phase-45 MULTI-READ flow (``TOOLS_TRIGGER`` +
|
||||
``MULTI_READ_TRIGGER``) classifies by the count of ``tool``-role read
|
||||
results: list → drill → read #1 (first file line) → read #2 (second
|
||||
file line) → the byte-stable ``multi_answer`` naming both read paths;
|
||||
* the degenerate empty-KB case: every listed source already drilled
|
||||
with no file lines → the flow falls back to the re-list loop (the
|
||||
round cap settles it — the phase-70 empty-catalog behavior).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -36,28 +42,50 @@ SYSTEM_LOW = "<relevance>LOW</relevance>\n"
|
||||
#: the phase-70 harness-aligned names).
|
||||
TOOLS = [{"type": "function", "function": {"name": "ls"}}]
|
||||
|
||||
#: The agent's ``ls`` output for a two-document KB
|
||||
#: (``app/rag/agent.py`` ``_execute_tool``): one
|
||||
#: ``source: X | path: Y | title: Z`` line per document (phase 63: labeled,
|
||||
#: unambiguous fields), ``(source, path)`` order.
|
||||
CATALOG_2 = (
|
||||
"2 documents:\n"
|
||||
"source: Deployments | path: example-record-file.json | title: Example Record File\n"
|
||||
"source: Homelab | path: aws-route53.md | title: AWS Route 53 Notes"
|
||||
#: The agent's drill-down ``ls`` output for a two-source KB
|
||||
#: (``app/rag/agent.py`` ``render_ls_top`` / ``render_folder_listing``,
|
||||
#: phase 94): the top level lists the registered sources (registry
|
||||
#: order, recursive counts — no file lines); the folder level carries
|
||||
#: the file lines (``source: X | path: Y | title: Z`` — the phase-63
|
||||
#: labeled fields, unchanged), ``path`` order.
|
||||
TOP_LEVEL_2 = (
|
||||
"2 sources:\n"
|
||||
"\n"
|
||||
"Deployments — 1 documents\n"
|
||||
"Homelab — 1 documents"
|
||||
)
|
||||
|
||||
CATALOG_1 = (
|
||||
"1 documents:\n"
|
||||
#: The first source's root folder: one file line (the single-read
|
||||
#: flow's read target).
|
||||
FOLDER_DEPLOYMENTS = (
|
||||
"Deployments — 1 documents, 0 folders:\n"
|
||||
"\n"
|
||||
"source: Deployments | path: example-record-file.json | title: Example Record File"
|
||||
)
|
||||
|
||||
CATALOG_3 = (
|
||||
"3 documents:\n"
|
||||
#: Two file lines in the first source (the multi-read flow's reads).
|
||||
FOLDER_DEPLOYMENTS_2 = (
|
||||
"Deployments — 2 documents, 0 folders:\n"
|
||||
"\n"
|
||||
"source: Deployments | path: aaa.md | title: AAA\n"
|
||||
"source: Deployments | path: bbb.md | title: BBB"
|
||||
)
|
||||
|
||||
#: Three file lines in the first source (the listing-order pin: read
|
||||
#: #2 is the SECOND line, not the last).
|
||||
FOLDER_DEPLOYMENTS_3 = (
|
||||
"Deployments — 3 documents, 0 folders:\n"
|
||||
"\n"
|
||||
"source: Deployments | path: aaa.md | title: AAA\n"
|
||||
"source: Deployments | path: bbb.md | title: BBB\n"
|
||||
"source: Homelab | path: ccc.md | title: CCC"
|
||||
"source: Deployments | path: ccc.md | title: CCC"
|
||||
)
|
||||
|
||||
#: Empty folder levels (a registered source with no documents — the
|
||||
#: header line alone; the drill's degenerate arm).
|
||||
FOLDER_DEPLOYMENTS_EMPTY = "Deployments — 0 documents, 0 folders:"
|
||||
FOLDER_HOMELAB_EMPTY = "Homelab — 0 documents, 0 folders:"
|
||||
|
||||
DOC1_SP = "Deployments/example-record-file.json"
|
||||
DOC1_CONTENT = (
|
||||
"The record file keeps every hosted zone record — first line is longer "
|
||||
@@ -122,10 +150,39 @@ def test_single_flow_list_step() -> None:
|
||||
assert _tool_flow(_body(SINGLE_USER)) == ("list", "", "")
|
||||
|
||||
|
||||
def test_single_flow_read_step_first_catalog_line() -> None:
|
||||
flow = _tool_flow(_body(SINGLE_USER, (CATALOG_3,)))
|
||||
# The FIRST listing line (Deployments/aaa.md), labeled fields.
|
||||
assert flow == ("read", "Deployments", "aaa.md", "call_1")
|
||||
def test_single_flow_drill_step_after_top_level() -> None:
|
||||
# Phase 94: the top level lists SOURCES only — the flow drills one
|
||||
# level into the FIRST source (listing order = registry order).
|
||||
flow = _tool_flow(_body(SINGLE_USER, (TOP_LEVEL_2,)))
|
||||
assert flow == ("drill", "Deployments", "call_1")
|
||||
|
||||
|
||||
def test_single_flow_drill_skips_already_drilled_source() -> None:
|
||||
# The first source's folder level is already in the messages (an
|
||||
# empty listing — header only, no file lines): the drill proceeds
|
||||
# to the NEXT un-drilled source.
|
||||
flow = _tool_flow(_body(SINGLE_USER, (TOP_LEVEL_2, FOLDER_DEPLOYMENTS_EMPTY)))
|
||||
assert flow == ("drill", "Homelab", "call_1")
|
||||
|
||||
|
||||
def test_single_flow_all_sources_drilled_empty_falls_back_to_list() -> None:
|
||||
# Degenerate: every listed source already drilled, no file lines
|
||||
# anywhere — the flow falls back to the re-list loop (settled at the
|
||||
# round cap, the phase-70 empty-catalog behavior).
|
||||
flow = _tool_flow(
|
||||
_body(
|
||||
SINGLE_USER,
|
||||
(TOP_LEVEL_2, FOLDER_DEPLOYMENTS_EMPTY, FOLDER_HOMELAB_EMPTY),
|
||||
)
|
||||
)
|
||||
assert flow == ("list", "", "")
|
||||
|
||||
|
||||
def test_single_flow_read_step_first_file_line() -> None:
|
||||
# The folder level reached: the FIRST file line (Deployments/aaa.md),
|
||||
# labeled fields, the combined ``source/path`` join.
|
||||
flow = _tool_flow(_body(SINGLE_USER, (TOP_LEVEL_2, FOLDER_DEPLOYMENTS_2)))
|
||||
assert flow == ("read", "Deployments", "aaa.md", "call_2")
|
||||
|
||||
|
||||
def test_read_step_nested_path_stays_intact() -> None:
|
||||
@@ -133,19 +190,20 @@ def test_read_step_nested_path_stays_intact() -> None:
|
||||
# ``source/path — title`` + ``rpartition("/")`` parse misread the
|
||||
# split (``source=brain-of-reese-main/homelab``). The labeled fields
|
||||
# recover the nested path intact, however deep.
|
||||
catalog = (
|
||||
"1 documents:\n"
|
||||
listing = (
|
||||
"brain-of-reese-main — 1 documents, 0 folders:\n"
|
||||
"\n"
|
||||
"source: brain-of-reese-main | path: homelab/aws-route53.md | title: aws-route53"
|
||||
)
|
||||
flow = _tool_flow(_body(SINGLE_USER, (catalog,)))
|
||||
assert flow == ("read", "brain-of-reese-main", "homelab/aws-route53.md", "call_1")
|
||||
flow = _tool_flow(_body(SINGLE_USER, (TOP_LEVEL_2, listing)))
|
||||
assert flow == ("read", "brain-of-reese-main", "homelab/aws-route53.md", "call_2")
|
||||
|
||||
|
||||
def test_single_flow_answer_step_with_tools_offered() -> None:
|
||||
# Phase 45: the round cap keeps the tools offered until it is hit —
|
||||
# the answer step fires regardless of the ``tools`` parameter.
|
||||
flow = _tool_flow(
|
||||
_body(SINGLE_USER, (CATALOG_2, _read_result(DOC1_SP, DOC1_CONTENT)))
|
||||
_body(SINGLE_USER, (TOP_LEVEL_2, FOLDER_DEPLOYMENTS, _read_result(DOC1_SP, DOC1_CONTENT)))
|
||||
)
|
||||
assert flow == ("answer", DOC1_SP, DOC1_CONTENT)
|
||||
|
||||
@@ -154,7 +212,7 @@ def test_single_flow_answer_step_without_tools() -> None:
|
||||
flow = _tool_flow(
|
||||
_body(
|
||||
SINGLE_USER,
|
||||
(CATALOG_2, _read_result(DOC1_SP, DOC1_CONTENT)),
|
||||
(TOP_LEVEL_2, FOLDER_DEPLOYMENTS, _read_result(DOC1_SP, DOC1_CONTENT)),
|
||||
tools=None,
|
||||
)
|
||||
)
|
||||
@@ -184,22 +242,39 @@ def test_multi_flow_list_step() -> None:
|
||||
assert _tool_flow(_body(MULTI_USER)) == ("list", "", "")
|
||||
|
||||
|
||||
def test_multi_flow_drill_step_after_top_level() -> None:
|
||||
# Phase 94: the top level lists SOURCES only — the multi flow drills
|
||||
# too, before its first read.
|
||||
flow = _tool_flow(_body(MULTI_USER, (TOP_LEVEL_2,)))
|
||||
assert flow == ("drill", "Deployments", "call_1")
|
||||
|
||||
|
||||
def test_multi_flow_read_first_step() -> None:
|
||||
flow = _tool_flow(_body(MULTI_USER, (CATALOG_2,)))
|
||||
assert flow == ("read", DOC1_SP.split("/", 1)[0], DOC1_SP.rsplit("/", 1)[1], "call_1")
|
||||
flow = _tool_flow(_body(MULTI_USER, (TOP_LEVEL_2, FOLDER_DEPLOYMENTS)))
|
||||
assert flow == ("read", DOC1_SP.split("/", 1)[0], DOC1_SP.rsplit("/", 1)[1], "call_2")
|
||||
|
||||
|
||||
def test_multi_flow_read_second_step_skips_already_read() -> None:
|
||||
flow = _tool_flow(_body(MULTI_USER, (CATALOG_2, _read_result(DOC1_SP, DOC1_CONTENT))))
|
||||
# The second catalog line — the first line differing from DOC1.
|
||||
assert flow == ("read", "Homelab", "aws-route53.md", "call_2")
|
||||
flow = _tool_flow(
|
||||
_body(
|
||||
MULTI_USER,
|
||||
(TOP_LEVEL_2, FOLDER_DEPLOYMENTS_2, _read_result("Deployments/aaa.md", DOC1_CONTENT)),
|
||||
)
|
||||
)
|
||||
# The second file line — the first line differing from the read doc.
|
||||
assert flow == ("read", "Deployments", "bbb.md", "call_3")
|
||||
|
||||
|
||||
def test_multi_flow_read_second_is_listing_order_not_last() -> None:
|
||||
# Three-doc catalog, first doc read: read #2 is the SECOND line
|
||||
# Three-file listing, first file read: read #2 is the SECOND line
|
||||
# (Deployments/bbb.md), not the last one.
|
||||
flow = _tool_flow(_body(MULTI_USER, (CATALOG_3, _read_result("Deployments/aaa.md", "x"))))
|
||||
assert flow == ("read", "Deployments", "bbb.md", "call_2")
|
||||
flow = _tool_flow(
|
||||
_body(
|
||||
MULTI_USER,
|
||||
(TOP_LEVEL_2, FOLDER_DEPLOYMENTS_3, _read_result("Deployments/aaa.md", "x")),
|
||||
)
|
||||
)
|
||||
assert flow == ("read", "Deployments", "bbb.md", "call_3")
|
||||
|
||||
|
||||
def test_multi_flow_answer_step_names_both_paths() -> None:
|
||||
@@ -207,7 +282,8 @@ def test_multi_flow_answer_step_names_both_paths() -> None:
|
||||
_body(
|
||||
MULTI_USER,
|
||||
(
|
||||
CATALOG_2,
|
||||
TOP_LEVEL_2,
|
||||
FOLDER_DEPLOYMENTS_2,
|
||||
_read_result(DOC1_SP, DOC1_CONTENT),
|
||||
_read_result(DOC2_SP, DOC2_CONTENT),
|
||||
),
|
||||
@@ -227,7 +303,8 @@ def test_multi_flow_answer_step_without_tools_offered() -> None:
|
||||
_body(
|
||||
MULTI_USER,
|
||||
(
|
||||
CATALOG_2,
|
||||
TOP_LEVEL_2,
|
||||
FOLDER_DEPLOYMENTS_2,
|
||||
_read_result(DOC1_SP, DOC1_CONTENT),
|
||||
_read_result(DOC2_SP, DOC2_CONTENT),
|
||||
),
|
||||
@@ -238,11 +315,11 @@ def test_multi_flow_answer_step_without_tools_offered() -> None:
|
||||
assert flow[0] == "multi_answer"
|
||||
|
||||
|
||||
def test_multi_flow_one_document_catalog_degenerates_to_single_answer() -> None:
|
||||
def test_multi_flow_one_file_listing_degenerates_to_single_answer() -> None:
|
||||
# Nothing second to read — the single-read answer shape, quoting the
|
||||
# only read result.
|
||||
flow = _tool_flow(
|
||||
_body(MULTI_USER, (CATALOG_1, _read_result(DOC1_SP, DOC1_CONTENT)))
|
||||
_body(MULTI_USER, (TOP_LEVEL_2, FOLDER_DEPLOYMENTS, _read_result(DOC1_SP, DOC1_CONTENT)))
|
||||
)
|
||||
assert flow == ("answer", DOC1_SP, DOC1_CONTENT)
|
||||
|
||||
@@ -320,9 +397,12 @@ def test_search_flow_found_step_without_tools_offered() -> None:
|
||||
|
||||
|
||||
def test_search_flow_ignores_catalog_and_read_results() -> None:
|
||||
# A catalog (labeled lines) and a read result ("Document …" prefix)
|
||||
# are NOT search results — the flow stays at the search step.
|
||||
flow = _search_flow(_body(SEARCH_USER, (CATALOG_2, _read_result(DOC1_SP, DOC1_CONTENT))))
|
||||
# Listings (top-level + folder level, labeled file lines) and a read
|
||||
# result ("Document …" prefix) are NOT search results — the flow
|
||||
# stays at the search step.
|
||||
flow = _search_flow(
|
||||
_body(SEARCH_USER, (TOP_LEVEL_2, FOLDER_DEPLOYMENTS, _read_result(DOC1_SP, DOC1_CONTENT)))
|
||||
)
|
||||
assert flow == ("search",)
|
||||
|
||||
|
||||
|
||||
+30
-22
@@ -11,11 +11,15 @@ And the phase-71 deflection plain-text line (owner-permitted
|
||||
line; the ``DEFLECT_MODE`` marker-keying contract is unchanged and
|
||||
the line never leaks into the HIGH prompt.
|
||||
|
||||
And the phase-72 ``<tools>`` copy: the document-identity contract is
|
||||
stated up front (the ``ls`` source-name scope, the combined
|
||||
``source/path`` identity for ``read``/``grep``) — the same contract
|
||||
the teaching refusals in :mod:`app.rag.agent` re-state; the
|
||||
``<tools>`` marker keying (HIGH only) is unchanged.
|
||||
And the ``<tools>`` copy (phase 72: the document-identity contract
|
||||
stated up front — the combined ``source/path`` identity for
|
||||
``read``/``grep``; phase 94, task 03: the ``ls`` clause rewritten to
|
||||
the drill-down tree contract — one level per call, sources at the
|
||||
top, folders + files below, ``grep`` as the without-listing locator —
|
||||
while the ``read``/``grep`` clauses and the discipline rules are
|
||||
byte-identical): the teaching refusals in :mod:`app.rag.agent`
|
||||
re-state the same contract; the ``<tools>`` marker keying (HIGH
|
||||
only) is unchanged.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -194,20 +198,22 @@ pinned byte-for-byte in
|
||||
|
||||
|
||||
def test_tools_section_phase72_contract_clauses() -> None:
|
||||
"""Phase 72: the two contract clauses the teaching refusals
|
||||
"""Phase 72 + phase 94: the contract clauses the teaching refusals
|
||||
re-state after the fact, pinned byte-for-byte in the constant —
|
||||
the ``ls`` source-name clause (its optional ``path`` is a source
|
||||
name, not a directory or file path; omit it to list every
|
||||
document) and the ``read``/``grep`` combined-identity clause
|
||||
(the combined ``source/path`` string exactly as shown in the
|
||||
``ls`` output, *including the source name*; a bare document path
|
||||
will not resolve)."""
|
||||
# The ls source-name clause.
|
||||
assert (
|
||||
"a source name (e.g. 'homelab'), not a directory or file "
|
||||
"path — omit it to list every document"
|
||||
) in TOOLS_SECTION
|
||||
# The read combined-identity clause.
|
||||
the ``ls`` clause (phase 94: the drill-down tree contract — one
|
||||
level per call, sources at the top, folders + files below, never
|
||||
the whole KB in one call, ``grep`` as the without-listing locator)
|
||||
and the ``read``/``grep`` combined-identity clause (the combined
|
||||
``source/path`` string exactly as shown in the ``ls`` output,
|
||||
*including the source name*; a bare document path will not
|
||||
resolve)."""
|
||||
# The ls drill-down clauses (phase 94, task 03).
|
||||
assert "one level at a time" in TOOLS_SECTION
|
||||
assert "lists every synced source with its document count" in TOOLS_SECTION
|
||||
assert "that source's top-level folders and files" in TOOLS_SECTION
|
||||
assert "never the whole knowledge base in one call" in TOOLS_SECTION
|
||||
assert "to find one specific document without listing, use `grep`" in TOOLS_SECTION
|
||||
# The read combined-identity clause (byte-identical across phases).
|
||||
assert (
|
||||
"combined `source/path` string, exactly as shown in the `ls` "
|
||||
"output — including the source name"
|
||||
@@ -217,23 +223,25 @@ def test_tools_section_phase72_contract_clauses() -> None:
|
||||
"a bare document path (without the source name) will not resolve"
|
||||
) == 2
|
||||
# The pre-phase-70 scope wording is gone — replaced by the
|
||||
# explicit source-name contract.
|
||||
# explicit source-name contract (and the phase-72 source-name-only
|
||||
# clause by the phase-94 drill-down contract).
|
||||
assert "pass a source name as `path`" not in TOOLS_SECTION
|
||||
assert "not a directory or file path" not in TOOLS_SECTION
|
||||
|
||||
|
||||
def test_tools_section_phase72_clauses_in_high_prompt_not_low() -> None:
|
||||
"""Phase 72: the contract clauses ride the HIGH prompt with the
|
||||
"""Phase 72/94: the contract clauses ride the HIGH prompt with the
|
||||
rest of the section and never leak into the LOW/deflection prompt
|
||||
(whose byte-identity is pinned in
|
||||
:func:`test_zero_note_prompt_is_byte_identical_to_pre_steering`)."""
|
||||
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
|
||||
high = build_high_prompt([doc])
|
||||
assert "<tools>" in high
|
||||
assert "not a directory or file path" in high
|
||||
assert "one level at a time" in high # the phase-94 ls clause
|
||||
assert "including the source name" in high
|
||||
for low in (build_deflect_prompt(["T1"]), build_deflect_prompt([])):
|
||||
assert "<tools>" not in low
|
||||
assert "not a directory or file path" not in low
|
||||
assert "one level at a time" not in low
|
||||
assert "including the source name" not in low
|
||||
|
||||
|
||||
|
||||
@@ -752,8 +752,9 @@ def _patch_sync_seams(
|
||||
"""The runner's seams, monkeypatched on ``app.api.sync`` (the house
|
||||
mock-import pattern): fresh settings (no ``.env`` leak), a no-op
|
||||
model probe, a sentinel LLM client, one git row, the gated clone +
|
||||
import, a no-op overview, and the DB-free sources-version step
|
||||
(dummy session + pinned counters)."""
|
||||
import, a no-op overview, a no-op folder-summary step (phase 94 —
|
||||
the sentinel LLM client has no ``chat``), and the DB-free
|
||||
sources-version step (dummy session + pinned counters)."""
|
||||
monkeypatch.setattr(
|
||||
sync_api,
|
||||
"get_settings",
|
||||
@@ -783,6 +784,13 @@ def _patch_sync_seams(
|
||||
|
||||
monkeypatch.setattr(sync_api, "regenerate_overview", fake_overview)
|
||||
|
||||
async def fake_folder_summaries(
|
||||
db: object, llm: object, *, skip: bool = False
|
||||
) -> dict[str, int]:
|
||||
return {"generated": 0, "failed": 0, "pruned": 0}
|
||||
|
||||
monkeypatch.setattr(sync_api, "generate_folder_summaries", fake_folder_summaries)
|
||||
|
||||
class _DummySession:
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
Reference in New Issue
Block a user