Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16f1cfbcaf | ||
|
|
0bf96f22e1 |
+11
@@ -0,0 +1,11 @@
|
|||||||
|
Working tree is clean and committed. Task 03 complete.
|
||||||
|
|
||||||
|
**Report — Phase 69, Task 03 (E2E suite, regression update, README, commit)**
|
||||||
|
|
||||||
|
- New `tests/e2e/test_source_removal_cleanup.py` — 6 mapped tests: upload→modal→total removal (disk folder + index + one DELETE + "Removing…" in-flight state, deterministic via request-holding), git checkout removal from disk, no-checkout no-op, foreign local dir never touched (dir + marker survive), Cancel/Escape with zero DELETEs + focus return, modal a11y (aria wiring, 3px focus-visible on both buttons, ≥44px, announcer success line) + no-CDN.
|
||||||
|
- `tests/e2e/test_git_sources_admin.py` — test 4 reworked from the Playwright `dialog` handler to the modal (`#remove-confirm-remove`/`#remove-confirm-cancel`); `Dialog` import removed; docstring updated.
|
||||||
|
- `README.md` — git-sources + local-sources sections rewritten to the total-removal contract; no "next sync" removal semantics left.
|
||||||
|
- Defects fixed: task-02 bug where `loadSources()`' "N sources listed." immediately overwrote the removal announcement (success path reordered close→reload→announce; task-02 unit order-pin updated to match), and an un-indented docstring in `app/api/git_sources.py`.
|
||||||
|
- Gates: `uv run pytest --cov=app --cov-report=term-missing` → 1364 passed, TOTAL **99%**; `uv run ruff check . && uv run pyright` → clean (0 errors); `uv run pytest tests/e2e/test_source_removal_cleanup.py -v --no-cov` → 6 passed; `test_git_sources_admin.py` → 6 passed; `test_archive_upload_sources.py` → 5 passed (all in isolation, DB up).
|
||||||
|
- Committed `137d5fa --no-gpg-sign` (24 files, incl. the uncommitted phase-68 test-pin sync in `test_agent.py` needed to keep HEAD green); phase dir merged into `.agent/phases/complete/69_source_removal_cleanup/` (harness had pre-created it, so a plain `mv` would have nested).
|
||||||
|
- Next pending task: none in this phase — `todo/` is empty; phase 69 complete.
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
........................................................................ [ 5%]
|
||||||
|
........................................................................ [ 10%]
|
||||||
|
........................................................................ [ 15%]
|
||||||
|
........................................................................ [ 21%]
|
||||||
|
........................................................................ [ 26%]
|
||||||
|
........................................................................ [ 31%]
|
||||||
|
........................................................................ [ 36%]
|
||||||
|
........................................................................ [ 42%]
|
||||||
|
........................................................................ [ 47%]
|
||||||
|
........................................................................ [ 52%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 63%]
|
||||||
|
........................................................................ [ 68%]
|
||||||
|
........................................................................ [ 73%]
|
||||||
|
........................................................................ [ 79%]
|
||||||
|
........................................................................ [ 84%]
|
||||||
|
........................................................................ [ 89%]
|
||||||
|
........................................................................ [ 95%]
|
||||||
|
.................................................................... [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover
|
||||||
|
-----------------------------------------------
|
||||||
|
app/__init__.py 1 0 100%
|
||||||
|
app/api/__init__.py 0 0 100%
|
||||||
|
app/api/auth.py 22 0 100%
|
||||||
|
app/api/chat.py 153 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 7 0 100%
|
||||||
|
app/api/doc_drafts.py 93 0 100%
|
||||||
|
app/api/docs.py 50 0 100%
|
||||||
|
app/api/git_sources.py 212 0 100%
|
||||||
|
app/api/health.py 10 0 100%
|
||||||
|
app/api/steering.py 42 0 100%
|
||||||
|
app/api/suggestions.py 8 0 100%
|
||||||
|
app/api/sync.py 101 0 100%
|
||||||
|
app/config.py 126 0 100%
|
||||||
|
app/core/__init__.py 0 0 100%
|
||||||
|
app/core/auth.py 20 0 100%
|
||||||
|
app/core/caching.py 108 0 100%
|
||||||
|
app/core/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/db.py 21 0 100%
|
||||||
|
app/main.py 52 0 100%
|
||||||
|
app/models.py 86 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 120 0 100%
|
||||||
|
app/rag/archive_upload.py 128 0 100%
|
||||||
|
app/rag/chunker.py 206 4 98%
|
||||||
|
app/rag/git_sources.py 14 0 100%
|
||||||
|
app/rag/importer.py 180 3 98%
|
||||||
|
app/rag/llm.py 213 0 100%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/prompts.py 65 0 100%
|
||||||
|
app/rag/retriever.py 94 3 97%
|
||||||
|
app/rag/source_removal.py 41 0 100%
|
||||||
|
app/rag/sources_meta.py 16 0 100%
|
||||||
|
app/rag/suggestions.py 27 0 100%
|
||||||
|
app/rag/summarizer.py 24 0 100%
|
||||||
|
app/schemas.py 205 0 100%
|
||||||
|
-----------------------------------------------
|
||||||
|
TOTAL 2707 12 99%
|
||||||
|
coverage gate: app/ 99% (>90%) OK
|
||||||
|
All checks passed!
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
validation OK
|
||||||
Vendored
+24
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "Python Debugger: Remote Attach",
|
||||||
|
"type": "debugpy",
|
||||||
|
"request": "attach",
|
||||||
|
"connect": {
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 5678
|
||||||
|
},
|
||||||
|
"pathMappings": [
|
||||||
|
{
|
||||||
|
"localRoot": "${workspaceFolder}",
|
||||||
|
"remoteRoot": "."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+105
-39
@@ -45,7 +45,13 @@ task 04):
|
|||||||
half-specified ``source``/``path`` target; a document already in
|
half-specified ``source``/``path`` target; a document already in
|
||||||
context (seed or previously read) → ``"Already in your
|
context (seed or previously read) → ``"Already in your
|
||||||
context."``; an unknown ``source/path`` (read or scoped search) →
|
context."``; an unknown ``source/path`` (read or scoped search) →
|
||||||
``"No document at …"``. A search that ran but found nothing is NOT a
|
``"No document at …"``. A ``source`` argument containing a ``'/'``
|
||||||
|
(the model passed the combined ``source/path`` form) is first
|
||||||
|
self-corrected by splitting at the first slash (see
|
||||||
|
:func:`_resolve_document` — source names are directory basenames and
|
||||||
|
can never contain ``'/'``); if the split still matches nothing, the
|
||||||
|
refusal teaches the split instead of repeating the combined form.
|
||||||
|
A search that ran but found nothing is NOT a
|
||||||
rejection — its ``"No matches for …"`` line is a (counted) result.
|
rejection — its ``"No matches for …"`` line is a (counted) result.
|
||||||
A rejected call still consumes a *round* in the loop, so a
|
A rejected call still consumes a *round* in the loop, so a
|
||||||
pathological stream that keeps emitting rejected calls is bounded by
|
pathological stream that keeps emitting rejected calls is bounded by
|
||||||
@@ -104,6 +110,33 @@ from app.rag.llm import (
|
|||||||
|
|
||||||
logger = logging.getLogger("app.agent")
|
logger = logging.getLogger("app.agent")
|
||||||
|
|
||||||
|
#: Parameter descriptions shared by ``read_document`` and
|
||||||
|
#: ``search_documents``. The model repeatedly conflated the two fields —
|
||||||
|
#: passing the combined ``source/path`` string (as printed in search
|
||||||
|
#: result lines, read-result headers and refusals) as ``source`` — so
|
||||||
|
#: the descriptions define the split explicitly: ``source`` is the part
|
||||||
|
#: BEFORE the first ``'/'``, ``path`` the part after it, with a worked
|
||||||
|
#: example in the ``read_document`` description itself.
|
||||||
|
_SOURCE_PARAM: dict[str, Any] = {
|
||||||
|
"type": "string",
|
||||||
|
"description": (
|
||||||
|
"Top-level source name only (e.g. 'homelab') — the part BEFORE "
|
||||||
|
"the first '/' of a combined 'source/path' string, exactly as "
|
||||||
|
"shown after 'source: ' in the list_documents output. Must not "
|
||||||
|
"contain '/' itself — do not pass the full source/path here."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
_PATH_PARAM: dict[str, Any] = {
|
||||||
|
"type": "string",
|
||||||
|
"description": (
|
||||||
|
"File path relative to the source directory (e.g. "
|
||||||
|
"'active/container_caddy/caddy.md') — the part AFTER the first "
|
||||||
|
"'/' of a combined 'source/path' string, exactly as shown after "
|
||||||
|
"'path: ' in the list_documents output. Must not start with the "
|
||||||
|
"source name."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
#: The three agent tools (phase 37; ``search_documents`` added in phase
|
#: The three agent tools (phase 37; ``search_documents`` added in phase
|
||||||
#: 68): OpenAI function definitions passed as ``tools=AGENT_TOOLS`` to
|
#: 68): OpenAI function definitions passed as ``tools=AGENT_TOOLS`` to
|
||||||
#: ``chat_stream`` for the whole grounded turn — phase 45 removed the
|
#: ``chat_stream`` for the whole grounded turn — phase 45 removed the
|
||||||
@@ -127,26 +160,21 @@ AGENT_TOOLS: list[dict[str, Any]] = [
|
|||||||
"name": "read_document",
|
"name": "read_document",
|
||||||
"description": (
|
"description": (
|
||||||
"Add the full content of one more indexed document "
|
"Add the full content of one more indexed document "
|
||||||
"to your context"
|
"to your context. A document is identified by the "
|
||||||
|
"(source, path) pair exactly as shown in the "
|
||||||
|
"list_documents output: 'source' is the top-level "
|
||||||
|
"source name only (e.g. 'homelab'), 'path' is the file "
|
||||||
|
"path inside that source (e.g. "
|
||||||
|
"'active/container_caddy/caddy.md'). If you only have a "
|
||||||
|
"combined 'source/path' string (as in search_documents "
|
||||||
|
"results), split it at the FIRST '/': the part before "
|
||||||
|
"is the source, the part after is the path. Example: "
|
||||||
|
"read_document(source='homelab', "
|
||||||
|
"path='active/container_caddy/caddy.md')."
|
||||||
),
|
),
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {"source": _SOURCE_PARAM, "path": _PATH_PARAM},
|
||||||
"source": {
|
|
||||||
"type": "string",
|
|
||||||
"description": (
|
|
||||||
"The document's source, as shown after 'source: ' in the "
|
|
||||||
"list_documents output."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
"path": {
|
|
||||||
"type": "string",
|
|
||||||
"description": (
|
|
||||||
"The document's path, as shown after 'path: ' in the "
|
|
||||||
"list_documents output."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["source", "path"],
|
"required": ["source", "path"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -159,9 +187,11 @@ AGENT_TOOLS: list[dict[str, Any]] = [
|
|||||||
"Search every indexed document for an exact string "
|
"Search every indexed document for an exact string "
|
||||||
"(case-insensitive) and return up to 20 matching lines as "
|
"(case-insensitive) and return up to 20 matching lines as "
|
||||||
"'source/path:line: text' — use this to locate content, "
|
"'source/path:line: text' — use this to locate content, "
|
||||||
"then read_document the winner. Optionally pass 'source' "
|
"then read_document the winner (each result line's "
|
||||||
"and 'path' (as shown in list_documents) to search one "
|
"'source/path' splits at the first '/': the part before "
|
||||||
"document only."
|
"is the source, the part after is the path). Optionally "
|
||||||
|
"pass 'source' and 'path' (as shown in list_documents) "
|
||||||
|
"to search one document only."
|
||||||
),
|
),
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -173,20 +203,8 @@ AGENT_TOOLS: list[dict[str, Any]] = [
|
|||||||
"substring, not a regex)"
|
"substring, not a regex)"
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"source": {
|
"source": _SOURCE_PARAM,
|
||||||
"type": "string",
|
"path": _PATH_PARAM,
|
||||||
"description": (
|
|
||||||
"The document's source, as shown after 'source: ' in the "
|
|
||||||
"list_documents output."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
"path": {
|
|
||||||
"type": "string",
|
|
||||||
"description": (
|
|
||||||
"The document's path, as shown after 'path: ' in the "
|
|
||||||
"list_documents output."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"required": ["pattern"],
|
"required": ["pattern"],
|
||||||
},
|
},
|
||||||
@@ -239,6 +257,36 @@ def find_document(db: Session, source: str, path: str) -> Document | None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_document(
|
||||||
|
db: Session, source: str, path: str
|
||||||
|
) -> tuple[Document | None, str, str]:
|
||||||
|
"""``(source, path)`` → document, with combined-form self-correction.
|
||||||
|
|
||||||
|
The exact pair is tried first. If it misses and *source* contains a
|
||||||
|
``'/'``, the model passed the combined ``source/path`` form — search
|
||||||
|
result lines, read-result headers and the generic refusal all print
|
||||||
|
that form, so the model treats it as the document's identity. Source
|
||||||
|
names are directory basenames (``app.rag.importer``: ``source =
|
||||||
|
root.name``) and can never contain a ``'/'``, so the pair is retried
|
||||||
|
at the FIRST slash: the part before is the source name, the part
|
||||||
|
after is the path. A second candidate covers a split at a LATER
|
||||||
|
slash (``source`` carried source + leading directories, ``path`` the
|
||||||
|
remainder).
|
||||||
|
|
||||||
|
Returns ``(doc, src, p)`` where ``(src, p)`` is the first-slash
|
||||||
|
split when one was attempted (so a refusal can teach it), else the
|
||||||
|
original pair.
|
||||||
|
"""
|
||||||
|
doc = find_document(db, source, path)
|
||||||
|
if doc is not None or "/" not in source:
|
||||||
|
return doc, source, path
|
||||||
|
split_source, _, split_path = source.partition("/")
|
||||||
|
doc = find_document(db, split_source, split_path)
|
||||||
|
if doc is None and path and path != split_path:
|
||||||
|
doc = find_document(db, split_source, f"{split_path}/{path}")
|
||||||
|
return doc, split_source, split_path
|
||||||
|
|
||||||
|
|
||||||
def all_documents(db: Session) -> list[Document]:
|
def all_documents(db: Session) -> list[Document]:
|
||||||
"""Every indexed document (full rows), ordered by ``(source, path)``
|
"""Every indexed document (full rows), ordered by ``(source, path)``
|
||||||
— catalog order.
|
— catalog order.
|
||||||
@@ -300,7 +348,8 @@ def _execute_tool(
|
|||||||
is a locator, locked A5); rejected calls return their refusal line
|
is a locator, locked A5); rejected calls return their refusal line
|
||||||
and count in nothing. A search that ran but found nothing is still a
|
and count in nothing. A search that ran but found nothing is still a
|
||||||
successful (counted) call — its no-match line is a result, not a
|
successful (counted) call — its no-match line is a result, not a
|
||||||
refusal.
|
refusal. A combined-form ``source`` (containing a ``'/'``) is
|
||||||
|
self-corrected through :func:`_resolve_document` before any refusal.
|
||||||
"""
|
"""
|
||||||
if call.name == "list_documents":
|
if call.name == "list_documents":
|
||||||
rows = list_catalog(db)
|
rows = list_catalog(db)
|
||||||
@@ -320,14 +369,26 @@ def _execute_tool(
|
|||||||
known = {(doc.source, doc.path) for doc in (*seed_docs, *holder.read_docs)}
|
known = {(doc.source, doc.path) for doc in (*seed_docs, *holder.read_docs)}
|
||||||
if (source, path) in known:
|
if (source, path) in known:
|
||||||
return ALREADY_IN_CONTEXT
|
return ALREADY_IN_CONTEXT
|
||||||
doc = find_document(db, source, path)
|
doc, split_source, split_path = _resolve_document(db, source, path)
|
||||||
if doc is None:
|
if doc is None:
|
||||||
|
if "/" in source:
|
||||||
|
# Educational refusal: the combined form is the model's
|
||||||
|
# mistake — teach the split instead of repeating it.
|
||||||
|
return (
|
||||||
|
f"source must not contain '/': for '{source}' call "
|
||||||
|
f"read_document(source='{split_source}', "
|
||||||
|
f"path='{split_path}')."
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
f"No document at {source}/{path} — check the list_documents output."
|
f"No document at {source}/{path} — check the list_documents output."
|
||||||
)
|
)
|
||||||
|
if (doc.source, doc.path) in known:
|
||||||
|
# A self-corrected combined form for a document already in
|
||||||
|
# context (the raw pair above cannot have matched it).
|
||||||
|
return ALREADY_IN_CONTEXT
|
||||||
holder.read_docs.append(doc)
|
holder.read_docs.append(doc)
|
||||||
holder.tool_calls += 1
|
holder.tool_calls += 1
|
||||||
return f"Document {source}/{path}:\n{doc.content}"
|
return f"Document {doc.source}/{doc.path}:\n{doc.content}"
|
||||||
if call.name == "search_documents":
|
if call.name == "search_documents":
|
||||||
raw_pattern = call.arguments.get("pattern")
|
raw_pattern = call.arguments.get("pattern")
|
||||||
pattern = raw_pattern.strip() if isinstance(raw_pattern, str) else ""
|
pattern = raw_pattern.strip() if isinstance(raw_pattern, str) else ""
|
||||||
@@ -343,8 +404,13 @@ def _execute_tool(
|
|||||||
# whole-KB search (house style).
|
# whole-KB search (house style).
|
||||||
return MISSING_SEARCH_ARGS
|
return MISSING_SEARCH_ARGS
|
||||||
if source:
|
if source:
|
||||||
target = find_document(db, source, path)
|
target, split_source, split_path = _resolve_document(db, source, path)
|
||||||
if target is None:
|
if target is None:
|
||||||
|
if "/" in source:
|
||||||
|
return (
|
||||||
|
f"source must not contain '/': for '{source}' use "
|
||||||
|
f"source='{split_source}', path='{split_path}'."
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
f"No document at {source}/{path} — check the list_documents output."
|
f"No document at {source}/{path} — check the list_documents output."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ document row (content included, for the never-truncated read) and return
|
|||||||
``search_documents`` tool is pinned here too — its locked parameter
|
``search_documents`` tool is pinned here too — its locked parameter
|
||||||
shape in ``AGENT_TOOLS``, and a scripted ``ToolCallPiece`` executed
|
shape in ``AGENT_TOOLS``, and a scripted ``ToolCallPiece`` executed
|
||||||
through ``run_agent`` against the real DB (``all_documents`` for a
|
through ``run_agent`` against the real DB (``all_documents`` for a
|
||||||
whole-KB search, ``find_document`` for a scoped one).
|
whole-KB search, ``find_document`` for a scoped one). The
|
||||||
|
combined-form self-correction (a ``source`` argument carrying
|
||||||
|
``source/path``) is pinned here as well, through ``run_agent``:
|
||||||
|
the split read executes against the real table, and a still-unknown
|
||||||
|
split gets the educational refusal.
|
||||||
|
|
||||||
Requires: podman compose up -d db
|
Requires: podman compose up -d db
|
||||||
"""
|
"""
|
||||||
@@ -164,6 +168,18 @@ def _run_search(
|
|||||||
return holder, llm
|
return holder, llm
|
||||||
|
|
||||||
|
|
||||||
|
def _run_read(
|
||||||
|
db: Session, arguments: dict[str, Any]
|
||||||
|
) -> tuple[AgentHolder, ScriptedToolLLM]:
|
||||||
|
"""Drive one scripted ``read_document`` call through ``run_agent``."""
|
||||||
|
holder = AgentHolder()
|
||||||
|
llm = ScriptedToolLLM(
|
||||||
|
ToolCallPiece(id="call_1", name="read_document", arguments=arguments)
|
||||||
|
)
|
||||||
|
asyncio.run(_consume(llm, db, holder))
|
||||||
|
return holder, llm
|
||||||
|
|
||||||
|
|
||||||
async def _consume(
|
async def _consume(
|
||||||
llm: ScriptedToolLLM, db: Session, holder: AgentHolder
|
llm: ScriptedToolLLM, db: Session, holder: AgentHolder
|
||||||
) -> list[StreamPiece | ToolCallPiece | RetryPiece]:
|
) -> list[StreamPiece | ToolCallPiece | RetryPiece]:
|
||||||
@@ -229,6 +245,67 @@ def test_search_scoped_missing_doc_refused_through_run_agent(kb, db) -> None:
|
|||||||
assert holder.tool_calls == 0 and holder.read_docs == []
|
assert holder.tool_calls == 0 and holder.read_docs == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- combined 'source/path' self-correction (read_document) ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_combined_source_self_corrects_through_run_agent(kb, db) -> None:
|
||||||
|
"""The model's combined 'source' ('Alpha/deep/nested/doc.md') resolves
|
||||||
|
through the first-slash split against the REAL table: the read
|
||||||
|
executes, the holder records the row, the result header carries the
|
||||||
|
true source/path."""
|
||||||
|
created = _doc(db, "Alpha", "deep/nested/doc.md", "The Doc", "FULL-TEXT")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
holder, llm = _run_read(
|
||||||
|
db,
|
||||||
|
{
|
||||||
|
"source": "Alpha/deep/nested/doc.md",
|
||||||
|
"path": "deep/nested/doc.md",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert llm.requests[1][0][3]["content"] == (
|
||||||
|
"Document Alpha/deep/nested/doc.md:\nFULL-TEXT"
|
||||||
|
)
|
||||||
|
assert holder.tool_calls == 1
|
||||||
|
assert holder.read_docs == [created]
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_combined_source_later_slash_split_through_run_agent(kb, db) -> None:
|
||||||
|
"""source='Alpha/deep' + path='nested/doc.md' (a split at a LATER
|
||||||
|
slash) resolves via the continuation candidate against the real
|
||||||
|
table."""
|
||||||
|
created = _doc(db, "Alpha", "deep/nested/doc.md", "The Doc", "FULL-TEXT")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
holder, llm = _run_read(
|
||||||
|
db, {"source": "Alpha/deep", "path": "nested/doc.md"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert llm.requests[1][0][3]["content"] == (
|
||||||
|
"Document Alpha/deep/nested/doc.md:\nFULL-TEXT"
|
||||||
|
)
|
||||||
|
assert holder.tool_calls == 1
|
||||||
|
assert holder.read_docs == [created]
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_combined_source_refusal_teaches_split(kb, db) -> None:
|
||||||
|
"""A combined source that matches nothing (even split) gets the
|
||||||
|
educational refusal naming the corrected arguments."""
|
||||||
|
_doc(db, "Alpha", "x.md", "X", "X-CONTENT")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
holder, llm = _run_read(
|
||||||
|
db, {"source": "Alpha/nope/deep.md", "path": "nope/deep.md"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert llm.requests[1][0][3]["content"] == (
|
||||||
|
"source must not contain '/': for 'Alpha/nope/deep.md' call "
|
||||||
|
"read_document(source='Alpha', path='nope/deep.md')."
|
||||||
|
)
|
||||||
|
assert holder.tool_calls == 0 and holder.read_docs == []
|
||||||
|
|
||||||
|
|
||||||
def test_search_no_matches_through_run_agent(kb, db) -> None:
|
def test_search_no_matches_through_run_agent(kb, db) -> None:
|
||||||
_doc(db, "Alpha", "a/one.md", "One", "nothing matching")
|
_doc(db, "Alpha", "a/one.md", "One", "nothing matching")
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
+219
-24
@@ -112,32 +112,49 @@ def test_agent_tools_names_and_parameters() -> None:
|
|||||||
read_params = by_name["read_document"]["function"]["parameters"]
|
read_params = by_name["read_document"]["function"]["parameters"]
|
||||||
assert read_params["required"] == ["source", "path"]
|
assert read_params["required"] == ["source", "path"]
|
||||||
assert set(read_params["properties"]) == {"source", "path"}
|
assert set(read_params["properties"]) == {"source", "path"}
|
||||||
# Phase 45: the per-tool budgets are gone — "exactly one more"
|
# The model repeatedly conflated the two fields — passing the
|
||||||
# dropped out of the read_document description.
|
# combined 'source/path' string as 'source' — so the read_document
|
||||||
|
# description pins the split rule with a worked example.
|
||||||
assert by_name["read_document"]["function"]["description"] == (
|
assert by_name["read_document"]["function"]["description"] == (
|
||||||
"Add the full content of one more indexed document to your context"
|
"Add the full content of one more indexed document to your "
|
||||||
|
"context. A document is identified by the (source, path) pair "
|
||||||
|
"exactly as shown in the list_documents output: 'source' is "
|
||||||
|
"the top-level source name only (e.g. 'homelab'), 'path' is "
|
||||||
|
"the file path inside that source (e.g. "
|
||||||
|
"'active/container_caddy/caddy.md'). If you only have a "
|
||||||
|
"combined 'source/path' string (as in search_documents "
|
||||||
|
"results), split it at the FIRST '/': the part before is the "
|
||||||
|
"source, the part after is the path. Example: "
|
||||||
|
"read_document(source='homelab', "
|
||||||
|
"path='active/container_caddy/caddy.md')."
|
||||||
)
|
)
|
||||||
# Phase 63 (A2): the parameter descriptions point the LLM at the
|
# The parameter descriptions define the split: source = before the
|
||||||
# labeled `source:` / `path:` fields of the list_documents output
|
# first '/', path = after it.
|
||||||
# (the example was dropped by the phase-68 description fix — the
|
|
||||||
# wording stays pinned, the model saw invented paths in calls).
|
|
||||||
assert read_params["properties"]["source"]["description"] == (
|
assert read_params["properties"]["source"]["description"] == (
|
||||||
"The document's source, as shown after 'source: ' in the "
|
"Top-level source name only (e.g. 'homelab') — the part BEFORE "
|
||||||
"list_documents output."
|
"the first '/' of a combined 'source/path' string, exactly as "
|
||||||
|
"shown after 'source: ' in the list_documents output. Must not "
|
||||||
|
"contain '/' itself — do not pass the full source/path here."
|
||||||
)
|
)
|
||||||
assert read_params["properties"]["path"]["description"] == (
|
assert read_params["properties"]["path"]["description"] == (
|
||||||
"The document's path, as shown after 'path: ' in the "
|
"File path relative to the source directory (e.g. "
|
||||||
"list_documents output."
|
"'active/container_caddy/caddy.md') — the part AFTER the first "
|
||||||
|
"'/' of a combined 'source/path' string, exactly as shown "
|
||||||
|
"after 'path: ' in the list_documents output. Must not start "
|
||||||
|
"with the source name."
|
||||||
)
|
)
|
||||||
# Phase 68: search_documents — the third tool, a locator (locked A5).
|
# Phase 68: search_documents — the third tool, a locator (locked
|
||||||
|
# A5); its description maps result lines back onto the split.
|
||||||
search = by_name["search_documents"]["function"]
|
search = by_name["search_documents"]["function"]
|
||||||
assert search["description"] == (
|
assert search["description"] == (
|
||||||
"Search every indexed document for an exact string "
|
"Search every indexed document for an exact string "
|
||||||
"(case-insensitive) and return up to 20 matching lines as "
|
"(case-insensitive) and return up to 20 matching lines as "
|
||||||
"'source/path:line: text' — use this to locate content, "
|
"'source/path:line: text' — use this to locate content, "
|
||||||
"then read_document the winner. Optionally pass 'source' "
|
"then read_document the winner (each result line's "
|
||||||
"and 'path' (as shown in list_documents) to search one "
|
"'source/path' splits at the first '/': the part before "
|
||||||
"document only."
|
"is the source, the part after is the path). Optionally "
|
||||||
|
"pass 'source' and 'path' (as shown in list_documents) "
|
||||||
|
"to search one document only."
|
||||||
)
|
)
|
||||||
search_params = search["parameters"]
|
search_params = search["parameters"]
|
||||||
assert search_params["type"] == "object"
|
assert search_params["type"] == "object"
|
||||||
@@ -146,15 +163,10 @@ def test_agent_tools_names_and_parameters() -> None:
|
|||||||
assert search_params["properties"]["pattern"]["description"] == (
|
assert search_params["properties"]["pattern"]["description"] == (
|
||||||
"The exact text to search for (a plain substring, not a regex)"
|
"The exact text to search for (a plain substring, not a regex)"
|
||||||
)
|
)
|
||||||
# Phase 63 labeled-field wording, same as read_document's parameters.
|
# Shared constants: search's source/path params ARE read_document's
|
||||||
assert search_params["properties"]["source"]["description"] == (
|
# (one definition, no drift between the two tools).
|
||||||
"The document's source, as shown after 'source: ' in the "
|
assert search_params["properties"]["source"] is read_params["properties"]["source"]
|
||||||
"list_documents output."
|
assert search_params["properties"]["path"] is read_params["properties"]["path"]
|
||||||
)
|
|
||||||
assert search_params["properties"]["path"]["description"] == (
|
|
||||||
"The document's path, as shown after 'path: ' in the "
|
|
||||||
"list_documents output."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------- happy path: list → read → answer ----------
|
# ---------- happy path: list → read → answer ----------
|
||||||
@@ -574,6 +586,189 @@ def test_read_document_missing_arguments_refused(
|
|||||||
assert llm.requests[1][1] == AGENT_TOOLS
|
assert llm.requests[1][1] == AGENT_TOOLS
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- combined 'source/path' self-correction ----------
|
||||||
|
# The model treats the combined 'source/path' string (search result
|
||||||
|
# lines, read-result headers, refusals) as the document's identity and
|
||||||
|
# sometimes passes it as 'source'. _resolve_document splits it at the
|
||||||
|
# first slash (source names are directory basenames — they can never
|
||||||
|
# contain '/'); a refusal for a still-unknown split teaches the split.
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_combined_source_is_split_and_read(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""source='S/a/b.md' (the combined form) + path='a/b.md': the exact
|
||||||
|
lookup misses, the first-slash split hits — the read executes, the
|
||||||
|
holder records the document, and the result header carries the TRUE
|
||||||
|
source/path (not the model's raw arguments)."""
|
||||||
|
doc = _doc("S", "a/b.md", "B", "B-CONTENT")
|
||||||
|
calls: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
def _find(db: Any, source: str, path: str) -> Document | None:
|
||||||
|
calls.append((source, path))
|
||||||
|
return doc if (source, path) == ("S", "a/b.md") else None
|
||||||
|
|
||||||
|
monkeypatch.setattr(agent, "find_document", _find)
|
||||||
|
holder = AgentHolder()
|
||||||
|
llm = ScriptedLLM(
|
||||||
|
[
|
||||||
|
ToolCallPiece(
|
||||||
|
id="call_1",
|
||||||
|
name="read_document",
|
||||||
|
arguments={"source": "S/a/b.md", "path": "a/b.md"},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[StreamPiece("content", "ans")],
|
||||||
|
)
|
||||||
|
asyncio.run(_run(llm, holder, _settings()))
|
||||||
|
# Exact pair first, then the first-slash split (no third attempt).
|
||||||
|
assert calls == [("S/a/b.md", "a/b.md"), ("S", "a/b.md")]
|
||||||
|
assert holder.read_docs == [doc]
|
||||||
|
assert holder.tool_calls == 1
|
||||||
|
assert llm.requests[1][0][3]["content"] == "Document S/a/b.md:\nB-CONTENT"
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_combined_source_split_at_later_slash(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""source='S/a' + path='b.md' — a split at a LATER slash (source
|
||||||
|
carried source + leading directory, path the remainder): the exact
|
||||||
|
lookup and the first-slash split miss, the continuation candidate
|
||||||
|
(source, split/path) hits."""
|
||||||
|
doc = _doc("S", "a/b.md", "B", "B-CONTENT")
|
||||||
|
calls: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
def _find(db: Any, source: str, path: str) -> Document | None:
|
||||||
|
calls.append((source, path))
|
||||||
|
return doc if (source, path) == ("S", "a/b.md") else None
|
||||||
|
|
||||||
|
monkeypatch.setattr(agent, "find_document", _find)
|
||||||
|
holder = AgentHolder()
|
||||||
|
llm = ScriptedLLM(
|
||||||
|
[
|
||||||
|
ToolCallPiece(
|
||||||
|
id="call_1",
|
||||||
|
name="read_document",
|
||||||
|
arguments={"source": "S/a", "path": "b.md"},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[StreamPiece("content", "ans")],
|
||||||
|
)
|
||||||
|
asyncio.run(_run(llm, holder, _settings()))
|
||||||
|
assert calls == [("S/a", "b.md"), ("S", "a"), ("S", "a/b.md")]
|
||||||
|
assert holder.read_docs == [doc]
|
||||||
|
assert holder.tool_calls == 1
|
||||||
|
assert llm.requests[1][0][3]["content"] == "Document S/a/b.md:\nB-CONTENT"
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_combined_source_unknown_teaches_the_split(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""A combined source that matches nothing — even split — gets the
|
||||||
|
EDUCATIONAL refusal: it names the corrected arguments instead of
|
||||||
|
repeating the combined form (the old generic line reinforced the
|
||||||
|
mistake)."""
|
||||||
|
monkeypatch.setattr(agent, "find_document", lambda db, source, path: None)
|
||||||
|
holder = AgentHolder()
|
||||||
|
llm = ScriptedLLM(
|
||||||
|
[
|
||||||
|
ToolCallPiece(
|
||||||
|
id="call_1",
|
||||||
|
name="read_document",
|
||||||
|
arguments={"source": "S/a/b.md", "path": "a/b.md"},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[StreamPiece("content", "ans")],
|
||||||
|
)
|
||||||
|
asyncio.run(_run(llm, holder, _settings()))
|
||||||
|
assert holder.read_docs == [] and holder.tool_calls == 0
|
||||||
|
assert llm.requests[1][0][3]["content"] == (
|
||||||
|
"source must not contain '/': for 'S/a/b.md' call "
|
||||||
|
"read_document(source='S', path='a/b.md')."
|
||||||
|
)
|
||||||
|
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_combined_source_for_seed_doc_is_already_in_context(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""The combined form of a document ALREADY in context: the raw pair
|
||||||
|
cannot match the dedupe set, so the split resolves it — and it is
|
||||||
|
still rejected as already-in-context (no duplicate read_docs entry,
|
||||||
|
no re-read into the context)."""
|
||||||
|
seed = [_doc("S", "a.md", "A", "A-CONTENT")]
|
||||||
|
|
||||||
|
def _find(db: Any, source: str, path: str) -> Document | None:
|
||||||
|
return seed[0] if (source, path) == ("S", "a.md") else None
|
||||||
|
|
||||||
|
monkeypatch.setattr(agent, "find_document", _find)
|
||||||
|
holder = AgentHolder()
|
||||||
|
llm = ScriptedLLM(
|
||||||
|
[
|
||||||
|
ToolCallPiece(
|
||||||
|
id="call_1",
|
||||||
|
name="read_document",
|
||||||
|
arguments={"source": "S/a.md", "path": "a.md"},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[StreamPiece("content", "ans")],
|
||||||
|
)
|
||||||
|
asyncio.run(_run(llm, holder, _settings(), seed_docs=seed))
|
||||||
|
assert holder.read_docs == [] and holder.tool_calls == 0
|
||||||
|
assert llm.requests[1][0][3]["content"] == agent.ALREADY_IN_CONTEXT
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_scoped_combined_source_is_split(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""A scoped search whose 'source' carries the combined form resolves
|
||||||
|
through the split — the search runs on the right document."""
|
||||||
|
doc = _doc("S", "a.md", "A", "needle here")
|
||||||
|
|
||||||
|
def _find(db: Any, source: str, path: str) -> Document | None:
|
||||||
|
return doc if (source, path) == ("S", "a.md") else None
|
||||||
|
|
||||||
|
monkeypatch.setattr(agent, "find_document", _find)
|
||||||
|
holder = AgentHolder()
|
||||||
|
llm = ScriptedLLM(
|
||||||
|
[
|
||||||
|
ToolCallPiece(
|
||||||
|
id="call_1",
|
||||||
|
name="search_documents",
|
||||||
|
arguments={"pattern": "needle", "source": "S/a.md", "path": "a.md"},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[StreamPiece("content", "ans")],
|
||||||
|
)
|
||||||
|
asyncio.run(_run(llm, holder, _settings()))
|
||||||
|
assert llm.requests[1][0][3]["content"] == "S/a.md:1: needle here"
|
||||||
|
assert holder.tool_calls == 1
|
||||||
|
assert holder.read_docs == [] # searched doc did not enter the context
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_scoped_combined_source_unknown_teaches_the_split(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(agent, "find_document", lambda db, source, path: None)
|
||||||
|
holder = AgentHolder()
|
||||||
|
llm = ScriptedLLM(
|
||||||
|
[
|
||||||
|
ToolCallPiece(
|
||||||
|
id="call_1",
|
||||||
|
name="search_documents",
|
||||||
|
arguments={"pattern": "x", "source": "S/ghost.md", "path": "ghost.md"},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[StreamPiece("content", "ans")],
|
||||||
|
)
|
||||||
|
asyncio.run(_run(llm, holder, _settings()))
|
||||||
|
assert llm.requests[1][0][3]["content"] == (
|
||||||
|
"source must not contain '/': for 'S/ghost.md' use "
|
||||||
|
"source='S', path='ghost.md'."
|
||||||
|
)
|
||||||
|
assert holder.tool_calls == 0 and holder.read_docs == [] # a refusal
|
||||||
|
|
||||||
|
|
||||||
# ---------- search_documents (phase 68, locked A5/A6) ----------
|
# ---------- search_documents (phase 68, locked A5/A6) ----------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user