phase: 131_read_line_ranges
All 44 progress lines are pure dots, final line at 100%, exit 0. Everything is verified. Final report: **Phase 131 — final verification pass (all 4 tasks already complete in working tree)** - **Verified implemented:** `read(offset, limit)` schema + teaching descriptions; rewritten truncation notice; full 6-row dedupe matrix + range validation + `lines: a-b of N` header + slice cap in `_execute_tool`; wire-level `offset`/`limit` on `ChatToolEvent` (absent keys dropped → non-range frames byte-identical); mock-LLM `read the lines` flow; handbook repro (wording + line numbers derived from the real PDF at test time) - **Tests:** `uv run pytest --no-cov` → 3097 passed, exit 0; `uv run pytest --cov=app` → **99% on app/** (>90%); `uv run pytest tests/e2e/test_read_line_ranges.py -v --no-cov` → 2/2 in isolation (47.6 s); `tests/integration/test_read_line_ranges_handbook.py` → 2/2 (3.5 s); 10-file targeted E2E batch pinning pre-phase surfaces (grep/ls/plain-read/ALREADY_IN_CONTEXT/truncation) → 31/31; `ruff check .` clean; `pyright` 0 errors - **Completion criteria:** all 5 green — (1) exact range lines + `lines:` header, plain reads byte-identical (existing pins pass unchanged) ✅ (2) truncated-then-range succeeds end-to-end on the real handbook; other re-reads get teaching refusals ✅ (3) find-traps answered from real PDF text, no hardcoded wording ✅ (4) coverage + isolated E2E ✅ (5) no behavior change in completed phases ✅ - **Docs:** README + AGENTS reviewed in full. Fixed 3 inconsistencies: README missing `BOR_HISTORY_MAX_TURNS`/`BOR_HISTORY_MAX_CHARS` rows (pre-existing phase-74 gap — env-tunable, unit-pinned, undocumented); `.env.example` missing the same two vars (added); `.env.example` stale `BOR_READ_MAX_CHARS` comment (still said "grep-pointer notice (phase 95)" — updated to the phase-131 workflow notice). AGENTS.md verified consistent — no changes needed. All 67 `.env.example` vars documented, all defaults match `app/config.py`, pages/auth tables match live routes - **Note:** PLAN.md §7.1 still states the retired 46rem chat column (superseded by phase 100, which AGENTS.md rule 5 records correctly) — PLAN.md is off-limits for this pass, flagged only - **Next pending phase:** `132_image_upload_feedback`
This commit is contained in:
+239
-2
@@ -539,6 +539,50 @@ Implements just enough of the aipi surface:
|
||||
question always gets the echo whatever the honesty gate says.
|
||||
Verified 2026-09-27: no existing E2E question or fixture file
|
||||
contains the phrase, so every other suite is unaffected.
|
||||
- user message containing ``read the lines``
|
||||
(``READ_LINES_TRIGGER``, phase 131 task 04 — the read
|
||||
line-ranges' dedicated story suite
|
||||
``tests/e2e/test_read_line_ranges.py``) **and** the system
|
||||
prompt carries the ``<tools>`` section -> the deterministic
|
||||
grep -> read(offset/limit) DANCE (the owner's stuck scenario,
|
||||
TODO.md L7, end to end): classified statelessly by the tool
|
||||
results in the messages (the house count pattern — streaming
|
||||
only):
|
||||
* request 1 (``tools`` offered, no tool results yet): stream
|
||||
ONLY ``tool_calls`` deltas — ``grep`` with
|
||||
``{"pattern": READ_LINES_PATTERN}`` (id ``call_0``), no
|
||||
content, no reasoning;
|
||||
* a ``tool``-role grep result with a ``source/path:line: text``
|
||||
match line is in the messages (the FIRST match line's line
|
||||
number is parsed): the RANGE READ — a ``tool_calls`` delta —
|
||||
``read`` on the match line's combined ``source/path`` with
|
||||
``offset`` = the parsed line number (an int) and ``limit`` =
|
||||
``READ_LINES_LIMIT`` (30) (id ``call_1``) — the owner's exact
|
||||
dance: the range the model names is the part it does NOT
|
||||
have;
|
||||
* a ``tool``-role read result (content starting with the
|
||||
agent's ``"Document "`` prefix) is in the messages: the
|
||||
forced answer, byte-stable: ``Found it at line N. <first 80
|
||||
chars of the read result's content>`` — N = the offset the
|
||||
model passed (parsed from the read call's arguments in the
|
||||
assistant tool-call message — the agent loop echoes every
|
||||
executed call back into the messages), the quote = the read
|
||||
slice with the header lines (the phase-106 D5 ``date:`` line
|
||||
+ the phase-131 ``lines: a-b of N`` extent) skipped, so the
|
||||
quote is the pure range content (the phase-94 read-echo
|
||||
convention — the RANGE reached the model's context);
|
||||
* a tool result is in the messages that is neither a read
|
||||
result nor a grep match (the grep found nothing — the
|
||||
premise broke): the deterministic terminal answer ``No
|
||||
matches — the knowledge base has no such text.`` (the
|
||||
grep-teach ``nomatch`` convention — the suite fails loudly
|
||||
on the missing marker).
|
||||
Checked BEFORE the plain ``TOOLS_TRIGGER`` flow (disjoint
|
||||
trigger phrases — the phase-71/72/94/118 ordering convention;
|
||||
the trigger needs the ``<tools>`` section, so deflected turns
|
||||
never hit it); verified 2026-09-28: no existing E2E question or
|
||||
fixture file contains the phrase, so every other suite is
|
||||
unaffected.
|
||||
|
||||
Failure injection (phase 67, LLM retry, TODO.md L3) — deterministic
|
||||
dead-endpoint behavior for the retry E2E suite (``tests/e2e/
|
||||
@@ -638,6 +682,7 @@ test_llm_retry.py``). The mock is single-conversation per e2e server, so
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
@@ -2020,6 +2065,144 @@ def _summary_seed_read_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
|
||||
return ("echo", results[-1])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 131 (task 04, the read line-ranges' dedicated story suite):
|
||||
# the deterministic grep -> read(offset/limit) dance — see the module
|
||||
# docstring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: A user message containing this substring (case-insensitive) —
|
||||
#: combined with the ``<tools>`` section in the system prompt — drives
|
||||
#: the deterministic RANGE-READ flow (the read line-ranges' story
|
||||
#: suite, ``tests/e2e/test_read_line_ranges.py``): the owner's stuck
|
||||
#: scenario (TODO.md L7) — the huge document: ``grep`` finds the
|
||||
#: line, the ``read`` lands exactly that part with ``offset`` /
|
||||
#: ``limit``, and the answer echoes the read slice. Checked BEFORE
|
||||
#: the plain ``TOOLS_TRIGGER`` flow (disjoint trigger phrases — the
|
||||
#: phase-71/72/94/118 ordering convention); verified 2026-09-28: no
|
||||
#: existing E2E question or fixture file contains the phrase, so
|
||||
#: every other suite is unaffected.
|
||||
READ_LINES_TRIGGER = "read the lines"
|
||||
|
||||
#: The canned pattern the flow greps for (the suite's fixture document
|
||||
#: carries exactly one line containing it — the flow's read target
|
||||
#: line; the sentinel convention of ``SEARCH_PATTERN``).
|
||||
READ_LINES_PATTERN = "LINE_MARKER"
|
||||
|
||||
#: The line count the flow's range read requests (a PART of the
|
||||
#: document, not the whole — the owner's dance, TODO.md L7).
|
||||
READ_LINES_LIMIT = 30
|
||||
|
||||
|
||||
def _read_call_offset(body: dict[str, Any]) -> int:
|
||||
"""The offset the model's ``read`` call passed (phase 131, task 04).
|
||||
|
||||
The agent loop echoes every executed call back into the messages
|
||||
(the assistant ``tool_calls`` message, the arguments as a JSON
|
||||
string — ``app/rag/agent.py``), so the range-read request
|
||||
(request 3 of the flow) carries the call it was made from: this
|
||||
parses the FIRST assistant ``read`` call's ``offset``. ``0`` when
|
||||
absent or unparseable (the premise broke — the suite fails
|
||||
loudly on ``line 0``).
|
||||
"""
|
||||
for m in _messages(body):
|
||||
if m.get("role") != "assistant":
|
||||
continue
|
||||
# The agent loop's echoed call (the ``tool_calls`` LIST on the
|
||||
# assistant message — ``app/rag/agent.py``; :func:`_messages`
|
||||
# is typed for string content, so the list comes back as
|
||||
# ``Any`` here — the house mock convention over the wire
|
||||
# shape).
|
||||
raw_calls: Any = m.get("tool_calls")
|
||||
for call in (raw_calls if isinstance(raw_calls, list) else []):
|
||||
if not isinstance(call, dict):
|
||||
continue
|
||||
fn = call.get("function") or {}
|
||||
if not isinstance(fn, dict) or fn.get("name") != "read":
|
||||
continue
|
||||
try:
|
||||
args = json.loads(fn.get("arguments") or "{}")
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
offset = args.get("offset") if isinstance(args, dict) else None
|
||||
if isinstance(offset, int) and not isinstance(offset, bool):
|
||||
return offset
|
||||
return 0
|
||||
|
||||
|
||||
def _read_lines_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
|
||||
"""Classify a phase-131 range-read request (see the module
|
||||
docstring). Classified statelessly by the tool results in the
|
||||
messages (the house count pattern), like the other marker flows:
|
||||
|
||||
* ``("answer", offset, quote)`` — a ``tool``-role read result
|
||||
(content starting with the agent's ``"Document "`` prefix) is in
|
||||
the messages: the forced answer, byte-stable — ``Found it at
|
||||
line N. <quote>`` with N the offset the model passed (parsed
|
||||
from the read call's arguments in the assistant tool-call
|
||||
message — a string on this return for the homogeneous
|
||||
``tuple[str, ...]`` shape; the dispatch formats it into the
|
||||
answer, the display surface) and quote the read result's
|
||||
content with the header lines (the phase-106 D5 ``date:`` line
|
||||
+ the phase-131 ``lines: a-b of N`` extent) skipped — the pure
|
||||
read slice, first 80 chars (the phase-94 read-echo convention:
|
||||
the RANGE reached the model's context).
|
||||
* ``("read", combined, line, "call_1")`` — a ``tool``-role grep
|
||||
result carrying ``source/path:line: text`` match lines is in the
|
||||
messages: the RANGE READ on the FIRST match line's combined
|
||||
``source/path`` — ``offset`` = the parsed line number (the
|
||||
grep's own string here — the dispatch parses the int for the
|
||||
wire) and ``limit`` = :data:`READ_LINES_LIMIT`.
|
||||
* ``("nomatch",)`` — a tool result is in the messages but it is
|
||||
neither a read result nor a grep match (the grep found nothing
|
||||
— the premise broke): the deterministic terminal answer (the
|
||||
grep-teach ``nomatch`` line — the suite fails loudly on the
|
||||
missing marker).
|
||||
* ``("grep",)`` — ``tools`` are offered and no tool results are in
|
||||
the messages yet: the first step — ``grep`` with
|
||||
``{"pattern": READ_LINES_PATTERN}`` (id ``call_0``).
|
||||
* ``None`` — not the flow: the trigger is absent, the ``<tools>``
|
||||
section is missing (deflected turns never carry it), or
|
||||
``tools`` are not offered and no tool results are in the
|
||||
messages yet (e.g. ``agent_max_rounds=0``).
|
||||
"""
|
||||
if READ_LINES_TRIGGER not in _user(body).lower():
|
||||
return None
|
||||
if "<tools>" not in _system(body):
|
||||
return None
|
||||
reads = _read_results(body)
|
||||
if reads:
|
||||
_sp, content = reads[-1]
|
||||
# The read result's header lines sit between the ``Document
|
||||
# …:`` header and the document text — the phase-106 D5
|
||||
# ``date:`` line always, the phase-131 ``lines: a-b of N``
|
||||
# extent on a range read (a plain read carries no extent
|
||||
# line) — skipped (the drill-flow convention) so the quote is
|
||||
# the pure read content (the suite's marker-line assertion).
|
||||
if content.startswith("date: "):
|
||||
content = content.partition("\n")[2]
|
||||
if content.startswith("lines: "):
|
||||
content = content.partition("\n")[2]
|
||||
return ("answer", str(_read_call_offset(body)), content[:80])
|
||||
for result in _tool_results(body):
|
||||
if result.startswith(_READ_RESULT_PREFIX):
|
||||
continue
|
||||
for line in result.splitlines():
|
||||
match = _SEARCH_LINE_RE.match(line)
|
||||
if match:
|
||||
return (
|
||||
"read",
|
||||
match.group("sp"),
|
||||
match.group("line"),
|
||||
"call_1",
|
||||
)
|
||||
if _tool_results(body):
|
||||
return ("nomatch",)
|
||||
if not body.get("tools"):
|
||||
return None
|
||||
return ("grep",)
|
||||
|
||||
|
||||
def long_answer() -> str:
|
||||
"""~900-word deterministic walkthrough (phase 11): numbered steps plus
|
||||
a unique final line that must survive the stream untruncated."""
|
||||
@@ -2844,8 +3027,6 @@ def _sse_stream(
|
||||
|
||||
|
||||
def json_dumps(obj: dict[str, Any]) -> str:
|
||||
import json
|
||||
|
||||
return json.dumps(obj)
|
||||
|
||||
|
||||
@@ -3186,6 +3367,62 @@ def chat_completions(body: dict[str, Any]) -> Any:
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
# Phase 131 (task 04): the deterministic grep ->
|
||||
# read(offset/limit) DANCE (the read line-ranges' story suite,
|
||||
# tests/e2e/test_read_line_ranges.py — the owner's stuck
|
||||
# scenario, TODO.md L7): grep finds the line, the range read
|
||||
# lands the part the model does NOT have, the answer echoes
|
||||
# the read slice. Checked BEFORE the plain TOOLS_TRIGGER flow
|
||||
# (disjoint trigger phrases — the phase-71/72/94/118 ordering
|
||||
# convention; the trigger needs the ``<tools>`` section, so
|
||||
# deflected turns never hit it).
|
||||
read_lines = _read_lines_flow(body)
|
||||
if read_lines is not None:
|
||||
if read_lines[0] == "grep":
|
||||
stream = _tool_call_stream(
|
||||
"grep", {"pattern": READ_LINES_PATTERN}, "call_0"
|
||||
)
|
||||
elif read_lines[0] == "read":
|
||||
# The owner's dance, deterministic: ``offset`` = the
|
||||
# grep-reported line number (parsed from the FIRST
|
||||
# match line — the classifier keeps it as the grep's
|
||||
# own string for the homogeneous return; the int
|
||||
# here is the wire's), ``limit`` = READ_LINES_LIMIT
|
||||
# (a part of the document, not the whole).
|
||||
stream = _tool_call_stream(
|
||||
"read",
|
||||
{
|
||||
"path": read_lines[1],
|
||||
"offset": int(read_lines[2]),
|
||||
"limit": READ_LINES_LIMIT,
|
||||
},
|
||||
read_lines[3],
|
||||
)
|
||||
elif read_lines[0] == "nomatch":
|
||||
# The premise broke (the grep found nothing) — the
|
||||
# deterministic terminal answer (the grep-teach
|
||||
# ``nomatch`` convention — the suite fails loudly on
|
||||
# the missing marker).
|
||||
stream = _sse_stream(
|
||||
_apply_max_tokens(
|
||||
"No matches — the knowledge base has no such text.",
|
||||
body.get("max_tokens"),
|
||||
),
|
||||
0.0,
|
||||
)
|
||||
else: # "answer" — the forced read-echo (phase-94 shape)
|
||||
stream = _sse_stream(
|
||||
_apply_max_tokens(
|
||||
f"Found it at line {read_lines[1]}. {read_lines[2]}",
|
||||
body.get("max_tokens"),
|
||||
),
|
||||
0.0,
|
||||
)
|
||||
return StreamingResponse(
|
||||
stream,
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
# Phase 124 (task 07, image chips and answers, TODO L3): the
|
||||
# scripted SHOW_IMAGE flow — the mock shows the seeded image
|
||||
# document (the ``<documents>`` block with the image-
|
||||
|
||||
Reference in New Issue
Block a user