phase: 182_verbatim_show_text
**Phase 182 — final verification pass (all 8 tasks already complete; this was verification + docs + one defect fix).**
- Verified: `app/rag/verbatim.py` (locator grammar, `splitlines()` slice, line-boundary cap, `resolve_answer_blocks`), `SHOW_TEXT_TOOL` + `shown_texts`, `SHOW_TEXT_CLAUSE`, `done.answer_text` (9 fields, key dropped when empty) + `ChatMessageRecord.answer_text` (≤50, 600 000-char cap counted in the 8 M budget), `verbatim.js` + 4 `{verbatim:true}` renderers, `Containerfile` minify step, `.env.example` 3 vars.
- `uv run python -c "…answer_text, show_text_max_chars, show_text_max_blocks"` → `True 12000 5`; each negative refuses startup naming its field; `0` accepted as inert ✅
- `bash .agents/validate.sh` → **validation OK**: 4320 unit+integration tests green, `TOTAL 6849 17 → 99%` (gate app/ >90% OK), `ruff check .` "All checks passed!", `pyright` 0 errors (re-run after my edits, 08:28).
- E2E, each in isolation `--no-cov`: `test_verbatim_show_text.py` 13 passed (panel bytes == `documents.content`, server caption, clipboard == server string, `/document.html?source=…&path=…`, honest unresolved state, reload+share, `show_image` regression, flag-off app) ✅; green: `test_answer_images` `test_harness_aligned_tools` `test_agent_document_tools` `test_markdown_tables` `test_markdown_inline_links_images` `test_svg_document_preview` `test_smoke` `test_document_viewer` `test_markdown_blockquotes` `test_chat_persistence` `test_thinking_timeline` `test_chat_rag` `test_settings_mobile_value_width`.
- **Defect found & fixed (pre-existing, not phase 182):** `tests/e2e/test_admin_settings_panel.py::test_admin_sees_groups_and_source_badges` failed — its whole-page `assert "reese" not in page.content()` tripped on the shell's onboarding chips, which follow the newest saved chats in the one shared E2E DB (a leftover `reeselink.json` question from a tool suite). Scoped the strict sweep to `.settings-shell` and kept a whole-page `reese:reese` credential needle → 12 passed with the leftovers still present.
- Docs: README fixed 2 inconsistencies — Quick Start omitted the **required** `BOR_ADMIN_PASSWORD`/`BOR_SESSION_SECRET` (the app fail-loud refuses boot) and the broken anchor `#chat--` → `#chat-`; full re-read of README (1816 lines) + AGENTS confirmed the phase-182 feature/settings/log/UI-contract text matches the code, and every `.env.example` var + every `Settings` field is documented.
- Notable: kept `resolve_answer_blocks` synchronous on the SSE terminal path (bounded indexed SELECTs, matching the existing `query_log` precedent and the phase's documented fence-lookup bound); no live-infra changes made.
- Next pending phase: none — `todo/` holds only 182 (its 8 task files are in `complete/`; the harness commits + moves `00_phase.md`).
This commit is contained in:
+292
-13
@@ -211,6 +211,29 @@ task 04):
|
||||
— its ``"No matches for …"`` line is a (counted) result. A rejected
|
||||
call still consumes a *round* in the loop, so a pathological stream
|
||||
that keeps emitting rejected calls is bounded by the cap (point 4).
|
||||
|
||||
Display tools (phase 124 ``show_image``, phase 182 ``show_text`` —
|
||||
both composed by :func:`agent_tools`, each behind its own flag): they
|
||||
display content FOR THE USER. ``show_image`` never adds context; the
|
||||
5th tool ``show_text`` (phase 182, task 03) returns the resolved
|
||||
slice back to the model ONLY as the truth of what was displayed
|
||||
(LOCKED A3 — with a do-NOT-retype instruction: the model describes
|
||||
the block, the user copies the server's bytes), and its block rides
|
||||
``holder.shown_texts``, never ``read_docs``. The LOCKED core contract
|
||||
A1–A3/A6: **the model names a location, the server shows the bytes**
|
||||
— the handler resolves the slice through
|
||||
:func:`app.rag.verbatim.block_for` (same ``(source, path)`` identity,
|
||||
same 1-based ``splitlines()`` indexing as ``read``/``grep``, same
|
||||
char-budget-cut-at-a-line-boundary), the DISPLAYED EXTENT is the
|
||||
server's (an overshooting ``limit`` clamps into the result's ``lines:``
|
||||
header, an ``offset`` past the end is the :data:`OFFSET_PAST_END`
|
||||
teaching naming the true line count, the char cap shrinks the extent
|
||||
— the same strings ``read`` uses, one copy each), images belong to
|
||||
``show_image`` (a show of an ``is_image`` row is the
|
||||
:data:`SHOW_TEXT_NOT_A_TEXT_DOC` refusal, the exact mirror of
|
||||
:data:`NOT_AN_IMAGE_DOC` — never a silent conversion), and the inline
|
||||
``` ```bor ``` locator-fence form of the same contract (phase 182 D2)
|
||||
is resolved at the API layer (task 05), NOT here.
|
||||
4. Every call the model emits is appended back to the message history as
|
||||
the assistant tool-call message + the tool result (refusals included),
|
||||
consumes one round, and the model is called again. Every tool result
|
||||
@@ -317,6 +340,7 @@ from app.rag.llm import (
|
||||
from app.rag.retriever import TRUNCATION_MARKER, _fold
|
||||
from app.rag.scaffolding import ScaffoldingFilter
|
||||
from app.rag.source_removal import resolve_source_name
|
||||
from app.rag.verbatim import VerbatimBlock, block_for
|
||||
|
||||
logger = logging.getLogger("app.agent")
|
||||
|
||||
@@ -535,20 +559,104 @@ SHOW_IMAGE_TOOL: dict[str, Any] = {
|
||||
}
|
||||
|
||||
|
||||
def agent_tools(settings: Settings) -> list[dict[str, Any]]:
|
||||
"""The grounded turn's tool offer list (phase 124, task 02).
|
||||
#: The 5th agent tool (phase 182, task 03; LOCKED A1–A3/A6) — offered
|
||||
#: ONLY when ``settings.answer_text`` (``BOR_ANSWER_TEXT``, default ON):
|
||||
#: :func:`agent_tools` composes the offer list (the three base tools stay
|
||||
#: :data:`AGENT_TOOLS`, byte-identical). The description IS the behavior
|
||||
#: contract (the phase-124 discipline): the model NAMES a location, the
|
||||
#: SERVER shows the bytes verbatim — the user copies what is displayed,
|
||||
#: so the model must NOT retype it. Same OpenAI function shape as the
|
||||
#: other tools: one required string ``path`` (the combined
|
||||
#: ``source/path`` identity — the bare-path-will-not-resolve teaching
|
||||
#: mirrored from ``read``) plus the OPTIONAL ``offset``/``limit`` line
|
||||
#: range (the ``read`` wording reused, so every line-number surface in
|
||||
#: the app teaches one grammar).
|
||||
SHOW_TEXT_TOOL: dict[str, Any] = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "show_text",
|
||||
"description": (
|
||||
"Display part of a document to the user EXACTLY as stored "
|
||||
"— the user sees the server's bytes and can copy them. "
|
||||
"Call it when the user wants to see, copy or run a file (a "
|
||||
"quadlet, a config, a script): they copy from what is "
|
||||
"displayed, so do NOT retype the content yourself after "
|
||||
"calling it — describe it instead. `path` is the combined "
|
||||
"`source/path` string exactly as shown in `ls`. `offset` "
|
||||
"(1-based first line) and `limit` (line count) are "
|
||||
"optional — omit them to display the whole document; use "
|
||||
"them when `grep`/`read` told you which lines matter. The "
|
||||
"displayed line numbers come from the server, not from "
|
||||
"your arguments: an overshooting `limit` is clamped and an "
|
||||
"`offset` past the end is refused with the true line "
|
||||
"count. Only text documents — for an image use "
|
||||
"`show_image`. Call one tool at a time — wait for this "
|
||||
"result before your next call."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"The document to display, as the combined "
|
||||
"`source/path` string exactly as shown in the "
|
||||
"`ls` output (e.g. "
|
||||
"'homelab/active/container_caddy/caddy.md'). "
|
||||
"A bare document path (without the source "
|
||||
"name) will not resolve. Only text documents "
|
||||
"— for an image use `show_image`."
|
||||
),
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"description": (
|
||||
"The first line to display, 1-based. Omit it "
|
||||
"to display from the beginning of the "
|
||||
"document. Use it to display a specific part "
|
||||
"of a large document — grep reports the line "
|
||||
"numbers of what you found."
|
||||
),
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": (
|
||||
"The maximum number of lines to display, "
|
||||
"starting at offset. Omit it to display as "
|
||||
"many lines as fit under the display cap. Use "
|
||||
"it to show the part the user asked about — "
|
||||
"you are showing a part, not the document."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["path"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
The three base tools (:data:`AGENT_TOOLS`, byte-identical) plus
|
||||
|
||||
def agent_tools(settings: Settings) -> list[dict[str, Any]]:
|
||||
"""The grounded turn's tool offer list (phase 124, task 02; the 5th
|
||||
tool, phase 182, task 03).
|
||||
|
||||
The three base tools (:data:`AGENT_TOOLS`, byte-identical) plus the
|
||||
TWO display tools, each gated by its own flag:
|
||||
:data:`SHOW_IMAGE_TOOL` when ``settings.answer_images``
|
||||
(``BOR_ANSWER_IMAGES``) is true — otherwise exactly the three base
|
||||
tools (the pre-phase-124 offer, byte-identical: no 4th tool in the
|
||||
wire ``tools`` array). A FRESH list on every call — callers never
|
||||
mutate the result or the constants (``run_agent`` passes it straight
|
||||
to the LLM request).
|
||||
(``BOR_ANSWER_IMAGES``) and :data:`SHOW_TEXT_TOOL` when
|
||||
``settings.answer_text`` (``BOR_ANSWER_TEXT``) — the offer order is
|
||||
``ls``, ``read``, ``grep``, ``show_image``, ``show_text``. With both
|
||||
flags off: exactly the three base tools (the pre-phase-124 offer,
|
||||
byte-identical: no display tool in the wire ``tools`` array); with
|
||||
only ``answer_text`` off it is the pre-phase-182 offer
|
||||
byte-identical. A FRESH list on every call — callers never mutate
|
||||
the result or the constants (``run_agent`` passes it straight to the
|
||||
LLM request).
|
||||
"""
|
||||
tools: list[dict[str, Any]] = list(AGENT_TOOLS)
|
||||
if settings.answer_images:
|
||||
tools.append(SHOW_IMAGE_TOOL)
|
||||
if settings.answer_text:
|
||||
tools.append(SHOW_TEXT_TOOL)
|
||||
return tools
|
||||
|
||||
#: Tool refusal texts (phase 37): rejected calls count in nothing
|
||||
@@ -587,6 +695,56 @@ ALREADY_SHOWING = (
|
||||
"Answer from what you have."
|
||||
)
|
||||
|
||||
#: The ``show_text`` refusals (phase 182, task 03): the same contract as
|
||||
#: every other refusal — counts in nothing, consumes a round, changes no
|
||||
#: context. ``MISSING_SHOW_TEXT_ARGS`` mirrors ``MISSING_SHOW_ARGS``.
|
||||
#: ``SHOW_TEXT_NOT_A_TEXT_DOC``: one ``{arg}`` field, the argument echoed
|
||||
#: as passed — the row is a standalone IMAGE; image display belongs to
|
||||
#: ``show_image`` (LOCKED A6 — the exact mirror of
|
||||
#: :data:`NOT_AN_IMAGE_DOC`, never a silent conversion). The range
|
||||
#: problems REUSE :data:`BAD_RANGE_ARGS` and :data:`OFFSET_PAST_END`
|
||||
#: verbatim — the same teaching, the same echoed values, one copy of
|
||||
#: each string in the codebase.
|
||||
MISSING_SHOW_TEXT_ARGS = "show_text requires a string argument 'path'."
|
||||
SHOW_TEXT_NOT_A_TEXT_DOC = (
|
||||
"'{arg}' is a standalone image document — use show_image to display "
|
||||
"images."
|
||||
)
|
||||
#: One ``{max}`` field (``settings.show_text_max_blocks``): the
|
||||
#: per-answer block budget, enforced SERVER-side BEFORE the resolve work
|
||||
#: (the model is taught the budget in the description; the handler is
|
||||
#: the actual gate). ``0`` therefore disables the tool's effect while
|
||||
#: keeping it offered (the house kill-switch pattern).
|
||||
SHOW_TEXT_BLOCK_CAP = (
|
||||
"{max} text blocks are already displayed this answer — describe them "
|
||||
"instead of showing more."
|
||||
)
|
||||
#: One ``{locator}`` field — the canonical ``source/path[#first-last]``
|
||||
#: the model just asked for: the dedupe mirror of
|
||||
#: :data:`ALREADY_SHOWING`, but per BLOCK under the block budget (the
|
||||
#: budget allows SEVERAL different blocks; the same block twice teaches).
|
||||
SHOW_TEXT_ALREADY_SHOWN = (
|
||||
"{locator} is already on screen this answer — say so instead of "
|
||||
"showing it again."
|
||||
)
|
||||
|
||||
#: The ``show_text`` truncation notice (phase 182, task 03) — appended as
|
||||
#: the result's LAST line when the per-block char budget
|
||||
#: (``settings.show_text_max_chars``) cut the displayed slice (the block
|
||||
#: text already ends with the shared :data:`TRUNCATION_MARKER` on its own
|
||||
#: line, and the ``lines:`` extent already shrank to the last line
|
||||
#: actually displayed). In the :data:`READ_TRUNCATION_NOTICE` spirit it
|
||||
#: names the truth the model must not paper over — the rest is NOT
|
||||
#: displayed — and the way the user gets it (open the document; NO
|
||||
#: ``offset``/``limit`` re-show loop is implied, only the displayed
|
||||
#: slice is what the user copies). Fields: ``{source}``/``{path}``, the
|
||||
#: RESOLVED identity, plain (no ``!r``).
|
||||
SHOWN_TEXT_TRUNCATION_NOTICE = (
|
||||
"TRUNCATED — the rest of {source}/{path} is NOT displayed. The user "
|
||||
"can open the document to read it in full; describe the displayed "
|
||||
"lines instead of showing it again."
|
||||
)
|
||||
|
||||
#: The read-truncation notice (phase 95, task 01; REWRITTEN phase 131,
|
||||
#: task 01) — appended, after the shared :data:`TRUNCATION_MARKER`, to
|
||||
#: a ``read`` result whose document is LONGER than
|
||||
@@ -1642,6 +1800,17 @@ class AgentHolder:
|
||||
nothing). Read by ``app.api.chat`` after the stream for the done
|
||||
frame's ``answer_image`` field (task 04) — the ``read_docs``
|
||||
precedent (an API-layer read-after-stream signal).
|
||||
``shown_texts`` (phase 182, task 03): the verbatim blocks
|
||||
``show_text`` displayed, in CALL order, capped by
|
||||
``settings.show_text_max_blocks`` (a call over the budget is refused
|
||||
with :data:`SHOW_TEXT_BLOCK_CAP` and a duplicate of a shown locator
|
||||
with :data:`SHOW_TEXT_ALREADY_SHOWN` — both append nothing). Each
|
||||
entry is the RESOLVED
|
||||
:class:`app.rag.verbatim.VerbatimBlock` — the server's bytes plus
|
||||
the server's extent (the canonical ``locator`` is the dedupe key and
|
||||
the client's matching key, D7). Read by ``app.api.chat`` after the
|
||||
stream for the done frame's ``answer_text`` array (task 05) — the
|
||||
``shown_images``/``read_docs`` read-after-stream precedent.
|
||||
``read_coverage`` (phase 141, task 01): one ``(source, path,
|
||||
first_line, last_line)`` record per covered span — INCLUSIVE line
|
||||
numbers over the document's ``content.splitlines()``, in read
|
||||
@@ -1676,6 +1845,7 @@ class AgentHolder:
|
||||
read_truncations: list[tuple[str, int, int]] = field(default_factory=list)
|
||||
shown_images: list[Document] = field(default_factory=list)
|
||||
read_coverage: list[tuple[str, str, int, int]] = field(default_factory=list)
|
||||
shown_texts: list[VerbatimBlock] = field(default_factory=list)
|
||||
|
||||
|
||||
def _execute_tool(
|
||||
@@ -2065,6 +2235,113 @@ def _execute_tool(
|
||||
f"{IMAGE_DOC_MARKER}\n"
|
||||
f"{doc.content}"
|
||||
)
|
||||
if call.name == "show_text":
|
||||
# Phase 182 (task 03; LOCKED A1–A3/A6): the 5th agent tool — the
|
||||
# model NAMES a location, the SERVER shows the bytes verbatim.
|
||||
# Offered only when ``settings.answer_text`` (``agent_tools``);
|
||||
# the handler enforces the contract server-side. Every refusal
|
||||
# below appends nothing, increments nothing and consumes a
|
||||
# round, like every other refusal.
|
||||
raw_path = call.arguments.get("path")
|
||||
arg = raw_path.strip() if isinstance(raw_path, str) else ""
|
||||
if not arg:
|
||||
return MISSING_SHOW_TEXT_ARGS
|
||||
if not settings.answer_text:
|
||||
# Defensive — the tool is NOT OFFERED while the flag is off,
|
||||
# so from the model's side this is a tool that does not
|
||||
# exist: the same refusal an un-offered name gets (the
|
||||
# tools-off posture — "Unknown tool."), counts in nothing.
|
||||
return UNKNOWN_TOOL
|
||||
doc, _source, _path = _resolve_path(db, arg)
|
||||
if doc is None:
|
||||
# The same phase-72 "did you mean …?" teaching as the read
|
||||
# branch (a refusal — not counted, no context, nothing
|
||||
# displayed).
|
||||
return _no_document_refusal(db, arg)
|
||||
if doc.is_image:
|
||||
# LOCKED A6: an image row is refused, NEVER silently
|
||||
# converted — image display is ``show_image``'s job (the
|
||||
# mirror of the show_image branch's NOT_AN_IMAGE_DOC).
|
||||
return SHOW_TEXT_NOT_A_TEXT_DOC.format(arg=arg)
|
||||
# The OPTIONAL line range — the SAME validator, the SAME echoed
|
||||
# BAD_RANGE_ARGS teaching as ``read`` (one copy of each string);
|
||||
# ``offset`` without ``limit`` displays to the end, ``limit``
|
||||
# without ``offset`` counts from line 1 — ``slice_lines`` applies
|
||||
# both one-sided semantics with ``read``'s exact indexing.
|
||||
ok, range_error, offset, limit = _read_range_args(
|
||||
call.arguments.get("offset"), call.arguments.get("limit")
|
||||
)
|
||||
if not ok:
|
||||
return range_error
|
||||
# The per-answer block budget — checked BEFORE the resolve work
|
||||
# (and, with a 0 char budget, before ``cap_slice`` could raise):
|
||||
# either zeroed kill switch refuses like a spent budget, so
|
||||
# ``0`` disables the tool's EFFECT while it stays offered (D8).
|
||||
block_budget = (
|
||||
settings.show_text_max_blocks
|
||||
if settings.show_text_max_chars > 0
|
||||
else 0
|
||||
)
|
||||
if len(holder.shown_texts) >= block_budget:
|
||||
return SHOW_TEXT_BLOCK_CAP.format(max=block_budget)
|
||||
block = block_for(
|
||||
db,
|
||||
arg,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
cap=settings.show_text_max_chars,
|
||||
doc=doc,
|
||||
)
|
||||
if block is None:
|
||||
# An empty document or an ``offset`` past the last line —
|
||||
# the SAME OFFSET_PAST_END teaching as ``read``, the true
|
||||
# line count named (the same ``len(content.splitlines())``
|
||||
# expression ``read`` uses). Counts in nothing; a corrected
|
||||
# retry still proceeds.
|
||||
return OFFSET_PAST_END.format(
|
||||
source=doc.source,
|
||||
path=doc.path,
|
||||
total_lines=len(doc.content.splitlines()),
|
||||
offset=offset if offset is not None else 1,
|
||||
)
|
||||
if any(shown.locator == block.locator for shown in holder.shown_texts):
|
||||
# The canonical-locator dedupe (D7): the identical block is
|
||||
# already on screen — say so instead of painting it twice.
|
||||
return SHOW_TEXT_ALREADY_SHOWN.format(locator=block.locator)
|
||||
# Success: the block rides the holder for the done frame
|
||||
# (task 05) and the call counts (executed-only, the house rule).
|
||||
holder.shown_texts.append(block)
|
||||
holder.tool_calls += 1
|
||||
# The result contract (D5 — the ``read``/``show_image`` header
|
||||
# contract the E2E mock's ``_READ_RESULT_PREFIX`` parses): a NEW
|
||||
# first line (absent from ``read`` — the model must know the
|
||||
# show registered AND must not retype), then the exact document
|
||||
# headers ``read`` established (date line, phase-145 origin
|
||||
# lines), then the SERVER's resolved extent — clamped ``limit``
|
||||
# and cap-shrunk end included, the caption never lies — and the
|
||||
# verbatim slice itself (A21: LLM calls are free, and returning
|
||||
# the text means the model's commentary can never disagree with
|
||||
# the panel).
|
||||
origin = origin_for_source(db, doc.source)
|
||||
result = (
|
||||
"Text shown to the user — these lines are displayed "
|
||||
"verbatim; describe them, do NOT retype them.\n"
|
||||
f"Document {doc.source}/{doc.path}:\n"
|
||||
f"date: {doc.created_at:%Y-%m-%d}\n"
|
||||
f"{_origin_lines(origin)}"
|
||||
f"lines: {block.start_line}-{block.end_line} of "
|
||||
f"{block.total_lines}\n"
|
||||
f"{block.text}"
|
||||
)
|
||||
if block.truncated:
|
||||
# The char cap cut the slice: the block already ends with
|
||||
# the shared marker on its own line — append the pinned
|
||||
# truth (the rest is NOT displayed; the user opens the
|
||||
# document; no re-show loop implied).
|
||||
result += "\n" + SHOWN_TEXT_TRUNCATION_NOTICE.format(
|
||||
source=doc.source, path=doc.path
|
||||
)
|
||||
return result
|
||||
if call.name == "grep":
|
||||
raw_pattern = call.arguments.get("pattern")
|
||||
pattern = raw_pattern.strip() if isinstance(raw_pattern, str) else ""
|
||||
@@ -2229,11 +2506,13 @@ async def run_agent(
|
||||
# Phase 45: no per-tool budgets — the tools stay offered for the
|
||||
# whole turn, bounded by the round cap. ``0`` is the no-tools kill
|
||||
# switch: exactly one request with ``tools=None`` (the pre-phase-37
|
||||
# path). Phase 124 (task 02): the offer list is :func:`agent_tools`
|
||||
# — the three base tools (byte-identical) plus ``show_image`` only
|
||||
# when ``settings.answer_images`` (``BOR_ANSWER_IMAGES``) is on;
|
||||
# off (the default) the offer is element-for-element the
|
||||
# pre-phase-124 list.
|
||||
# path). Phase 124 (task 02) / 182 (task 03): the offer list is
|
||||
# :func:`agent_tools` — the three base tools (byte-identical) plus
|
||||
# ``show_image`` only when ``settings.answer_images``
|
||||
# (``BOR_ANSWER_IMAGES``) and ``show_text`` only when
|
||||
# ``settings.answer_text`` (``BOR_ANSWER_TEXT``, default ON); both
|
||||
# off: the offer is element-for-element the pre-phase-124 list,
|
||||
# ``answer_text`` alone off: the pre-phase-182 list.
|
||||
max_rounds = settings.agent_max_rounds
|
||||
tools: list[dict[str, Any]] | None = (
|
||||
agent_tools(settings) if max_rounds > 0 else None
|
||||
|
||||
+105
-7
@@ -66,6 +66,20 @@ backstop). The ``DEFLECT_MODE`` marker and everything else in the
|
||||
prompt stay put — the E2E mock LLM keys on the marker's *presence*,
|
||||
not the wording, so that contract is unchanged.
|
||||
|
||||
Display-tool clauses (phase 124 ``show_image``; phase 182 ``show_text``):
|
||||
the **HIGH** prompt's ``<tools>`` section can carry two extra clauses
|
||||
before its closing tag, each appended only when its own setting is on —
|
||||
:data:`SHOW_IMAGE_CLAUSE` when ``BOR_ANSWER_IMAGES`` (default OFF) and
|
||||
:data:`SHOW_TEXT_CLAUSE` when ``BOR_ANSWER_TEXT`` (default ON). The text
|
||||
clause teaches the two things the phase-182 incident needed said: the
|
||||
user copies from what is DISPLAYED (so never retype a file), and the
|
||||
inline `` ```bor <source/path> [range] ``` `` locator fence whose EMPTY
|
||||
body the app replaces with the server-resolved bytes. Both clauses
|
||||
compose through one mechanism (section minus its final tag + the present
|
||||
clauses + the tag), so a build with neither clause is byte-identical to
|
||||
pre-phase and a ``show_image``-only build reproduces the phase-124 bytes
|
||||
exactly; the LOW prompt carries neither.
|
||||
|
||||
Disclosed-answer contract (phase 112, task 03; owner decision
|
||||
2026-09-14, roadmap confirmation — option (iii) of the three the
|
||||
2026-09-15 interactive deflection test raised): that test found the
|
||||
@@ -388,6 +402,61 @@ SHOW_IMAGE_CLAUSE: str = (
|
||||
"in text either way."
|
||||
)
|
||||
|
||||
#: The ``show_text`` contract clause (phase 182, task 04, LOCKED A1–A3/A6) —
|
||||
#: appended INSIDE the ``<tools>`` section of the HIGH prompt, after the
|
||||
#: existing three-tool copy (and after :data:`SHOW_IMAGE_CLAUSE` when that
|
||||
#: rides too), ONLY when :func:`build_high_prompt` is called with
|
||||
#: ``show_text=True`` (the ``BOR_ANSWER_TEXT`` master switch, via
|
||||
#: ``app.api.chat``'s grounded plan — the only call site that passes it).
|
||||
#: It exists because the incident this phase fixes is a PROMPT gap, not a
|
||||
#: tool gap: nothing ever told the model that the user copies from what is
|
||||
#: DISPLAYED, so it retyped files out of context and every retype differed
|
||||
#: from the stored bytes by a character. What each sentence pins, in order:
|
||||
#: (1) the display contract — the tool shows the document EXACTLY as stored
|
||||
#: and the copy source is the display, not the prose; (2) when to call it —
|
||||
#: the user wants to SEE, COPY or RUN a file (the owner's quadlet / config /
|
||||
#: script shapes) rather than read content out of the answer; (3) the do-
|
||||
#: NOT-retype rule with its reason (anything the model types may differ by
|
||||
#: one character and the user cannot tell — the phase's reason for
|
||||
#: existing); (4) the placement syntax — the inline `` ```bor <source/path>
|
||||
#: [range] ``` `` locator fence with an EMPTY body (D1/D7: a fence is a
|
||||
#: resolution request, the app supplies the text, and D3 keeps the model's
|
||||
#: body out of the DOM); (5) the honesty-about-numbers rule — line numbers
|
||||
#: come from ``grep``/``read``, and when unsure the range is OMITTED
|
||||
#: because the displayed extent is the server's (A2); (6) the failure rule
|
||||
#: — refuse out loud, never reconstruct from memory (the persona's radical
|
||||
#: honesty, D3's posture on the model side); (7) the image pointer
|
||||
#: (LOCKED A6: images belong to ``show_image``, never silently converted).
|
||||
#: ``TOOLS_SECTION`` itself stays BYTE-IDENTICAL (the sha pins in
|
||||
#: :mod:`tests.unit.test_prompt_lock` hold); a ``show_text=False`` build
|
||||
#: (the default — every pre-phase call site) is byte-identical to the
|
||||
#: pre-task output, and a ``show_image=True, show_text=False`` build
|
||||
#: reproduces the phase-124 bytes EXACTLY. The ``<tools>`` marker the E2E
|
||||
#: mock keys on is untouched either way — both clauses ride on its
|
||||
#: presence, not its wording. The LOW/deflection prompt never carries the
|
||||
#: clause: a deflected turn has no tools, and phase 182's D9 forbids
|
||||
#: resolving a locator on a turn that just disclaimed the knowledge base.
|
||||
SHOW_TEXT_CLAUSE: str = (
|
||||
"`show_text` displays part of a document to the user EXACTLY as stored "
|
||||
"— the user copies from what is displayed, not from your prose. Call "
|
||||
"it whenever the user wants to SEE, COPY or RUN a file — a quadlet, a "
|
||||
"config, a script, a unit file — instead of writing the content into "
|
||||
"your answer yourself. After calling it, do NOT repeat the displayed "
|
||||
"lines in your answer — describe them, explain them, point at them. "
|
||||
"Anything you type yourself may differ from the file by a character, "
|
||||
"and the user cannot tell. To put a displayed block exactly where you "
|
||||
"want it in your answer, write a locator line on its own: three "
|
||||
"backticks followed by `bor`, a space, the combined `source/path`, and "
|
||||
"optionally the line range (` 12-40`). Leave its body EMPTY — the app "
|
||||
"replaces it with the exact stored text. Never type the file's content "
|
||||
"inside a `bor` block. Never guess line numbers — use the numbers "
|
||||
"`grep` and `read` reported. When you are not sure which lines, OMIT "
|
||||
"the range and display the whole document: the numbers you display are "
|
||||
"the server's, never yours. If `show_text` refuses, say what you could "
|
||||
"not display — do not reconstruct it from memory. For an image use "
|
||||
"`show_image` instead."
|
||||
)
|
||||
|
||||
|
||||
def history_to_messages(
|
||||
history: Sequence[HistoryTurn],
|
||||
@@ -762,6 +831,7 @@ def build_high_prompt(
|
||||
kb_overview: str | None = None,
|
||||
folder_lines: Sequence[str] = (),
|
||||
show_image: bool = False,
|
||||
show_text: bool = False,
|
||||
) -> str:
|
||||
"""Grounded turn: locked persona (+ steering, + KB overview) + the
|
||||
top-ranked documents' SUMMARY blocks + the ``<tools>`` instructions
|
||||
@@ -771,7 +841,8 @@ def build_high_prompt(
|
||||
seeds SUMMARIES, never full texts — full text enters the context
|
||||
only through the capped ``read`` tool; phase 119, LOCKED A4: the
|
||||
suggested-folder context lines, below; phase 124, task 03, LOCKED
|
||||
A5: the optional :data:`SHOW_IMAGE_CLAUSE`, below).
|
||||
A5: the optional :data:`SHOW_IMAGE_CLAUSE`, below; phase 182, task 04,
|
||||
LOCKED A1–A3: the optional :data:`SHOW_TEXT_CLAUSE`, below).
|
||||
|
||||
Section order: ``<relevance>`` → ``<knowledge_base>`` → ``<tuning>``
|
||||
→ ``<documents>`` → ``<tools>``; empty steering/overview omit their
|
||||
@@ -796,6 +867,22 @@ def build_high_prompt(
|
||||
never carries the clause — a deflected turn has no tools and never
|
||||
shows an image.
|
||||
|
||||
*show_text* (phase 182, task 04, LOCKED A1–A3): when true (the caller
|
||||
passes the ``BOR_ANSWER_TEXT`` master switch — the same single grounded
|
||||
call site), the :data:`SHOW_TEXT_CLAUSE` rides INSIDE the same
|
||||
``<tools>`` section, AFTER :data:`SHOW_IMAGE_CLAUSE` when that is
|
||||
present too and always before the closing tag. The two flags compose
|
||||
independently through one mechanism (the built section is
|
||||
``TOOLS_SECTION`` minus its final ``</tools>`` + the present clauses,
|
||||
blank-line separated, + the closing tag): ``show_image`` only
|
||||
reproduces the phase-124 bytes exactly, ``show_text`` only is the
|
||||
three-tool copy plus the text clause, both is the image clause then
|
||||
the text clause, neither is byte-identical to pre-phase. The default
|
||||
``False`` keeps every pre-existing call byte-identical. The deflection
|
||||
prompt NEVER carries it — a deflected turn has no tools, and phase
|
||||
182's D9 forbids resolving a ``bor`` locator on a turn whose answer
|
||||
just disclaimed the knowledge base.
|
||||
|
||||
When at least one block is present, the section leads with the
|
||||
start-here :data:`SUGGEST_INTRO` line (before the first block — the
|
||||
phase-15 ``_STEERING_INTRO`` / phase-31 ``_KB_INTRO`` precedent): the
|
||||
@@ -861,13 +948,24 @@ def build_high_prompt(
|
||||
for part in (build_kb_section(kb_overview or ""), build_steering_section(notes or [])):
|
||||
if part:
|
||||
prompt += "\n" + part
|
||||
# Phase 124 (task 03, LOCKED A5) / phase 182 (task 04, LOCKED A1): the
|
||||
# display-tool clauses ride inside <tools> — TOOLS_SECTION's text minus
|
||||
# its final </tools>, the clauses offered for this build (each on its
|
||||
# own line, blank-line separated, after the existing three-tool copy),
|
||||
# then the closing tag. TOOLS_SECTION itself is unedited, the
|
||||
# no-clause path keeps the exact pre-task concatenation, and a
|
||||
# show_image-only build reproduces the phase-124 bytes exactly.
|
||||
clauses: list[str] = []
|
||||
if show_image:
|
||||
# Phase 124 (task 03, LOCKED A5): the clause rides inside
|
||||
# <tools> — TOOLS_SECTION's text minus its final </tools>, the
|
||||
# clause (on its own line, after the existing three-tool copy),
|
||||
# then the closing tag. TOOLS_SECTION itself is unedited and the
|
||||
# show_image=False path keeps the exact pre-task concatenation.
|
||||
tools_section = TOOLS_SECTION[: -len("</tools>")] + SHOW_IMAGE_CLAUSE + "\n</tools>"
|
||||
clauses.append(SHOW_IMAGE_CLAUSE)
|
||||
if show_text:
|
||||
clauses.append(SHOW_TEXT_CLAUSE)
|
||||
if clauses:
|
||||
tools_section = (
|
||||
TOOLS_SECTION[: -len("</tools>")]
|
||||
+ "\n\n".join(clauses)
|
||||
+ "\n</tools>"
|
||||
)
|
||||
else:
|
||||
tools_section = TOOLS_SECTION
|
||||
return prompt + "\n<documents>\n" + body + "\n</documents>\n" + tools_section
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
"""Phase 182 (task 02): the verbatim locator grammar, the slicer, the resolver.
|
||||
|
||||
The contract in one line: **the model names a location, this module supplies
|
||||
the bytes** (PLAN §6's tool-surface extension, phase 182 D1–D7). An answer
|
||||
never carries a model-retyped copy of a source file; it carries a ``bor``
|
||||
locator fence (````` ```bor homelab/runbooks/nginx.md#12-40 `````), and the
|
||||
bytes shown to the human are read out of the indexed ``documents.content``
|
||||
row by :func:`block_for` / :func:`resolve_answer_blocks`. Because the
|
||||
displayed text is never generated, it cannot be paraphrased, "corrected",
|
||||
or hallucinated — the phase's whole point — and the same resolver serves
|
||||
both entry points (the ``show_text`` tool's own block, task 03, and the
|
||||
``done``-frame pass over the finished answer, task 05).
|
||||
|
||||
The module is deliberately split in two halves:
|
||||
|
||||
* the **pure** half — :func:`canonical_locator`, :func:`fences_in`,
|
||||
:func:`slice_lines`, :func:`cap_slice` — no DB, no settings, no I/O;
|
||||
* the **resolver** half — :func:`document_by_identity`, :func:`block_for`,
|
||||
:func:`resolve_answer_blocks` — one bare ``SELECT`` per locator and the
|
||||
kill-switch gate.
|
||||
|
||||
**Import rule (one-way, checkable with
|
||||
``grep -n "import" app/rag/verbatim.py``):** this module must NEVER import
|
||||
the agent module (:mod:`app.rag.agent`) — ``agent.py`` already depends on
|
||||
the pure half of this module for the ``show_text`` tool branch, so a
|
||||
reverse dependency cycles. Allowed here: ``sqlalchemy``,
|
||||
:class:`app.models.Document`,
|
||||
:data:`app.rag.retriever.TRUNCATION_MARKER`, :class:`app.config.Settings`,
|
||||
stdlib.
|
||||
|
||||
Nothing calls this module yet (tasks 03 and 05 wire it in), so landing it
|
||||
changes no behavior anywhere.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.models import Document
|
||||
from app.rag.retriever import TRUNCATION_MARKER
|
||||
|
||||
__all__ = [
|
||||
"LOCATOR_LANG",
|
||||
"VerbatimBlock",
|
||||
"SliceResult",
|
||||
"block_for",
|
||||
"cap_slice",
|
||||
"canonical_locator",
|
||||
"document_by_identity",
|
||||
"fences_in",
|
||||
"resolve_answer_blocks",
|
||||
"slice_lines",
|
||||
]
|
||||
|
||||
#: The fence info-string token that marks a locator fence (D2). The
|
||||
#: markdown renderer (task 06) and the copy-path builder (task 07) key on
|
||||
#: this SAME constant — one grammar, one spelling, no drifting copy.
|
||||
LOCATOR_LANG = "bor"
|
||||
|
||||
#: The range separators a locator may use between the identity and the
|
||||
#: line range: a space, a colon, a bare ``#``, or ``#L`` (D7 — the grammar
|
||||
#: is TOLERANT on input because the string is model-written).
|
||||
_SEP = r"[ \t:#]+"
|
||||
|
||||
#: A line range: ``12-40``, ``12–40`` (en dash), ``L12-L40``, or a bare
|
||||
#: ``12`` (one line). An optional ``L`` prefixes either bound; whitespace
|
||||
#: around the dash is tolerated. Anchored at the end of the string by the
|
||||
#: caller, so a range-shaped tail followed by more text is junk, not a
|
||||
#: range.
|
||||
_RANGE = r"(?P<range>L?[0-9]+(?:[ \t]*[-\u2013][ \t]*L?[0-9]+)?)"
|
||||
|
||||
#: The trailing range of a locator string (see :data:`_SEP` /
|
||||
#: :data:`_RANGE`). Case-insensitive only for the ``L`` prefix.
|
||||
_TRAILING_RANGE_RE = re.compile(f"{_SEP}{_RANGE}$", re.IGNORECASE)
|
||||
|
||||
#: The opening line of a ``bor`` locator fence: the language token,
|
||||
#: whitespace, then a NON-EMPTY remainder (the locator). A bare
|
||||
#: `` ```bor `` carries no locator, so it never matches and stays an
|
||||
#: ordinary code block.
|
||||
_FENCE_OPEN_RE = re.compile(
|
||||
rf"^```{re.escape(LOCATOR_LANG)}[ \t]+(?P<locator>.*[^ \t])[ \t]*$"
|
||||
)
|
||||
|
||||
#: The two characters a bound may use as its line-number prefix.
|
||||
_LINE_PREFIX = "Ll"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerbatimBlock:
|
||||
"""One displayed verbatim block — the bytes plus the truth about them.
|
||||
|
||||
``start_line`` / ``end_line`` are **1-based and INCLUSIVE** and describe
|
||||
EXACTLY the lines :attr:`text` contains *after* any character cap — a
|
||||
capped block's ``end_line`` is the last line actually shown, never the
|
||||
line the locator asked for (the caption must not claim lines nobody
|
||||
sees). ``total_lines`` is the whole document's line count, so the
|
||||
caption can say ``lines 12-40 of 512``. :attr:`text` never carries a
|
||||
trailing newline (lines are joined with ``"\\n"``), and
|
||||
:attr:`truncated` says whether :data:`~app.rag.retriever.TRUNCATION_MARKER`
|
||||
was appended on its own last line.
|
||||
|
||||
:attr:`locator` is the CANONICAL form (D7) built by the factory
|
||||
(:func:`block_for`), never passed in by a caller — it is the dedupe key
|
||||
of :func:`resolve_answer_blocks` and the matching key the copy-path
|
||||
builder uses (task 07).
|
||||
"""
|
||||
|
||||
source: str
|
||||
path: str
|
||||
title: str
|
||||
start_line: int
|
||||
end_line: int
|
||||
total_lines: int
|
||||
text: str
|
||||
truncated: bool = False
|
||||
locator: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SliceResult:
|
||||
"""The outcome of one line-range slice (see :func:`slice_lines`).
|
||||
|
||||
``first_line`` / ``last_line`` are 1-based and inclusive; ``past_end``
|
||||
is the caller's refusal signal and always travels with the true
|
||||
``total_lines`` (the teaching refusal names the real line count).
|
||||
"""
|
||||
|
||||
text: str
|
||||
first_line: int
|
||||
last_line: int
|
||||
total_lines: int
|
||||
past_end: bool
|
||||
|
||||
|
||||
def canonical_locator(raw: str) -> str | None:
|
||||
"""Normalize one model-written locator string, or refuse it (D7).
|
||||
|
||||
Accepted forms — ``source/path`` (the ``read``/``grep`` identity
|
||||
contract, split at the FIRST ``'/'``) followed by an optional separator
|
||||
and line range:
|
||||
|
||||
========================== ==========================
|
||||
input canonical output
|
||||
========================== ==========================
|
||||
``homelab/a.md`` ``homelab/a.md``
|
||||
``homelab/a.md 12-40`` ``homelab/a.md#12-40``
|
||||
``homelab/a.md:12-40`` ``homelab/a.md#12-40``
|
||||
``homelab/a.md#12-40`` ``homelab/a.md#12-40``
|
||||
``homelab/a.md#L12-L40`` ``homelab/a.md#12-40``
|
||||
``homelab/a.md 12\u201340`` ``homelab/a.md#12-40``
|
||||
``homelab/a.md 12`` ``homelab/a.md#12-12``
|
||||
========================== ==========================
|
||||
|
||||
The canonical form is always ``source/path`` + ``#start-end`` with
|
||||
plain digits — never the en dash, never the ``L``, so the fence, the
|
||||
tool block and the UI share ONE string (D7).
|
||||
|
||||
Returns ``None`` (NOT a locator) for: empty/whitespace input, a value
|
||||
with no ``'/'`` in the path part, a non-numeric or non-positive bound,
|
||||
``start > end``, trailing junk after the range, or a leading ``bor``
|
||||
token the caller left in. No DB access, no case folding (paths are
|
||||
case-sensitive row values). A locator carrying a range also refuses an
|
||||
identity containing whitespace: the fence's info string is ONE
|
||||
whitespace-free identity plus the range, so a path that contains a
|
||||
space is expressed as a whole-document locator (no range) or not at
|
||||
all — the documented limit of the tolerant grammar.
|
||||
"""
|
||||
text = raw.strip()
|
||||
if not text:
|
||||
return None
|
||||
# A caller that pasted the fence's language token in has already lost
|
||||
# the identity — refuse rather than guess which token was the path.
|
||||
if text.split(" ", 1)[0] == LOCATOR_LANG:
|
||||
return None
|
||||
|
||||
match = _TRAILING_RANGE_RE.search(text)
|
||||
if match is None:
|
||||
identity, range_part = text, None
|
||||
else:
|
||||
# ``match.start()`` is where the separator begins — the identity
|
||||
# is everything before it (a multi-character separator, e.g. two
|
||||
# spaces, is consumed whole).
|
||||
identity, range_part = text[: match.start()], match.group("range")
|
||||
|
||||
# The identity is one whitespace-free ``source/path`` token, and it
|
||||
# never ends in a dangling separator (``a.md#`` with no range is a
|
||||
# typo, not a whole document).
|
||||
if not identity or any(c.isspace() for c in identity):
|
||||
return None
|
||||
if identity.endswith(("#", ":", "-")):
|
||||
return None
|
||||
if "/" not in identity:
|
||||
return None
|
||||
if range_part is None:
|
||||
return identity
|
||||
|
||||
bounds = [b.strip().lstrip(_LINE_PREFIX) for b in re.split(r"[-\u2013]", range_part)]
|
||||
# Defensive: :data:`_RANGE` already guarantees digit bounds, so this
|
||||
# only fires if the grammar above is ever loosened — it keeps
|
||||
# ``canonical_locator`` total (never an ``int()`` ValueError to a
|
||||
# caller that hands it model-written text).
|
||||
if any(not b.isdigit() for b in bounds):
|
||||
return None
|
||||
start = int(bounds[0])
|
||||
end = int(bounds[1]) if len(bounds) == 2 else start
|
||||
if start < 1 or end < 1 or start > end:
|
||||
return None
|
||||
return f"{identity}#{start}-{end}"
|
||||
|
||||
|
||||
def fences_in(answer: str) -> list[str]:
|
||||
"""Every ``bor`` locator fence's RAW locator string, in answer order.
|
||||
|
||||
Rules (the same shape ``markdown.js`` applies, task 06):
|
||||
|
||||
* the opening line is a fence whose info string is exactly
|
||||
`` ```bor `` + whitespace + a NON-empty remainder — a bare
|
||||
`` ```bor `` with no path is an ordinary code block, not a locator,
|
||||
and is not swallowed;
|
||||
* the fence closes at the next line that is nothing but backticks;
|
||||
* an **unclosed** fence contributes nothing (defensive: a half-streamed
|
||||
answer must not resolve a locator the model never finished) and ends
|
||||
the scan, since its body runs to the end of the answer;
|
||||
* the body is read and DISCARDED — this module never looks at what the
|
||||
model typed inside the fence (the bytes come from the row, D1);
|
||||
* nested `` ``` `` inside a body is NOT supported: the fence ends at
|
||||
the first closer (documented limitation, same rule as the renderer).
|
||||
|
||||
Duplicates collapse keeping the FIRST occurrence.
|
||||
"""
|
||||
lines = answer.splitlines()
|
||||
locators: list[str] = []
|
||||
index = 0
|
||||
while index < len(lines):
|
||||
match = _FENCE_OPEN_RE.match(lines[index].strip())
|
||||
if match is None:
|
||||
index += 1
|
||||
continue
|
||||
closer = _find_fence_close(lines, index + 1)
|
||||
if closer is None:
|
||||
# Unclosed: the body runs to the end of the answer, so there
|
||||
# is nothing further to scan and this fence yields nothing.
|
||||
break
|
||||
locator = match.group("locator").strip()
|
||||
if locator and locator not in locators:
|
||||
locators.append(locator)
|
||||
index = closer + 1
|
||||
return locators
|
||||
|
||||
|
||||
def _find_fence_close(lines: Sequence[str], start: int) -> int | None:
|
||||
"""Index of the fence's closing line at or after ``start``, ``None`` if unclosed."""
|
||||
for offset in range(start, len(lines)):
|
||||
stripped = lines[offset].strip()
|
||||
if stripped.startswith("```") and not stripped.strip("`"):
|
||||
return offset
|
||||
return None
|
||||
|
||||
|
||||
def slice_lines(
|
||||
content: str, offset: int | None = None, limit: int | None = None
|
||||
) -> SliceResult:
|
||||
"""Cut ``content`` to a 1-based line range with ``read``'s exact semantics.
|
||||
|
||||
Same indexing (``content.splitlines()``) and the same range arithmetic
|
||||
as the ``read`` tool (``app/rag/agent.py``) and ``grep``, so a line
|
||||
number the model got from ``grep`` names the same line here — the one
|
||||
shared coordinate system the whole phase depends on:
|
||||
|
||||
* ``offset`` omitted → 1; ``limit`` omitted → read to the end;
|
||||
* ``end = min(offset + limit - 1, total_lines)`` — an overshooting
|
||||
``limit`` CLAMPS (``last_line`` is the document's last line);
|
||||
* ``offset`` past the end, an empty document, or a ``limit`` below 1 →
|
||||
``past_end=True`` with empty text and the TRUE ``total_lines`` (the
|
||||
caller builds the teaching refusal).
|
||||
|
||||
Never raises for a range problem: the refusal is the caller's, the
|
||||
total always travels with the result.
|
||||
"""
|
||||
lines = content.splitlines()
|
||||
total_lines = len(lines)
|
||||
first = 1 if offset is None else offset
|
||||
end = total_lines if limit is None else min(first + limit - 1, total_lines)
|
||||
if total_lines == 0 or first > total_lines or end < first:
|
||||
return SliceResult(
|
||||
text="",
|
||||
first_line=first,
|
||||
last_line=0,
|
||||
total_lines=total_lines,
|
||||
past_end=True,
|
||||
)
|
||||
return SliceResult(
|
||||
text="\n".join(lines[first - 1 : end]),
|
||||
first_line=first,
|
||||
last_line=end,
|
||||
total_lines=total_lines,
|
||||
past_end=False,
|
||||
)
|
||||
|
||||
|
||||
def cap_slice(text: str, first_line: int, cap: int) -> tuple[str, int, bool]:
|
||||
"""Apply the character budget to one slice AT A LINE boundary.
|
||||
|
||||
Returns ``(text_to_show, last_line_shown, truncated)``. The whole
|
||||
point of cutting at a line boundary is that the caption's line range
|
||||
stays true: ``last_line_shown`` is the last line the reader actually
|
||||
sees, so a capped block never claims lines it did not show.
|
||||
|
||||
* ``cap <= 0`` raises :class:`ValueError` — a mis-wired caller must be
|
||||
loud. The ``show_text_max_chars == 0`` kill switch is checked by
|
||||
:func:`resolve_answer_blocks`, never by reaching here;
|
||||
* ``len(text) <= cap`` → the text untouched, its own last line,
|
||||
``truncated=False``;
|
||||
* otherwise cut to the last WHOLE line that fits and append the shared
|
||||
:data:`~app.rag.retriever.TRUNCATION_MARKER` on its own line — the
|
||||
marker is not content and is not counted in ``last_line_shown`` (a
|
||||
line counts as fitting only when its terminator is inside the
|
||||
budget, or sits exactly at the budget's edge);
|
||||
* a first line LONGER than ``cap`` cuts that one line mid-way (the only
|
||||
sub-line cut, documented) and still ends with the marker.
|
||||
"""
|
||||
if cap <= 0:
|
||||
raise ValueError(f"cap_slice needs a positive cap, got {cap!r}")
|
||||
if len(text) <= cap:
|
||||
if not text:
|
||||
return text, first_line, False
|
||||
# ``split("\\n")``, NOT ``splitlines()``: a slice that ends on a
|
||||
# BLANK line legitimately ends with ``"\\n"`` (line 5 of
|
||||
# ``"a\nb\n\n"`` is empty and present), and ``splitlines()`` drops
|
||||
# that final empty element — which would report an extent one
|
||||
# line short of the text actually shown (a caption that lies, and
|
||||
# a canonical locator that no longer matches the same range
|
||||
# resolved by another entry point).
|
||||
return text, first_line + len(text.split("\n")) - 1, False
|
||||
cut = text[:cap]
|
||||
if text[cap] == "\n":
|
||||
# The budget lands exactly on a line end — every line of the cut
|
||||
# is whole, nothing to trim.
|
||||
return (
|
||||
f"{cut}\n{TRUNCATION_MARKER}",
|
||||
first_line + len(cut.splitlines()) - 1,
|
||||
True,
|
||||
)
|
||||
boundary = cut.rfind("\n")
|
||||
if boundary <= 0:
|
||||
# No whole line fits — the single documented mid-line cut.
|
||||
return f"{cut}\n{TRUNCATION_MARKER}", first_line, True
|
||||
whole = cut[:boundary]
|
||||
return (
|
||||
f"{whole}\n{TRUNCATION_MARKER}",
|
||||
first_line + len(whole.splitlines()) - 1,
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
def document_by_identity(db: Session, combined: str) -> Document | None:
|
||||
"""The indexed row at the combined ``source/path`` identity, or ``None``.
|
||||
|
||||
Split at the FIRST ``'/'`` — source names are directory basenames and
|
||||
can never contain one, so the part before is the source and everything
|
||||
after is the path (the same identity contract as
|
||||
``app.rag.agent._resolve_path``).
|
||||
|
||||
This is that contract **reimplemented as a bare lookup**, deliberately
|
||||
NOT a call into ``agent.py``: the tool branch already has
|
||||
``_resolve_path`` (with its did-you-mean suggestions), the fence
|
||||
resolver only needs yes/no, and importing the agent from here would
|
||||
cycle (``agent.py`` imports this module). A traversal string
|
||||
(``../../etc/passwd``) is simply a row that does not exist — there is
|
||||
no file access here to guard, only a ``WHERE`` clause.
|
||||
"""
|
||||
if "/" not in combined:
|
||||
return None
|
||||
source, _, path = combined.partition("/")
|
||||
return db.scalar(
|
||||
select(Document).where(Document.source == source, Document.path == path)
|
||||
)
|
||||
|
||||
|
||||
def block_for(
|
||||
db: Session,
|
||||
combined: str,
|
||||
*,
|
||||
offset: int | None = None,
|
||||
limit: int | None = None,
|
||||
cap: int,
|
||||
doc: Document | None = None,
|
||||
) -> VerbatimBlock | None:
|
||||
"""Build the one block a locator names, or ``None`` (nothing displays).
|
||||
|
||||
Resolves ``combined`` (or takes the caller's already-resolved ``doc``)
|
||||
and refuses ``None`` for: no such document (including every
|
||||
traversal-shaped identity), an ``is_image`` document (image display
|
||||
belongs to ``show_image`` — LOCKED A6), and an empty or past-the-end
|
||||
slice. Otherwise ``slice_lines`` → ``cap_slice`` → the block, with the
|
||||
canonical :attr:`~VerbatimBlock.locator` built here (``source/path``,
|
||||
plus ``#first-last`` when the request named a range **or** the cap cut
|
||||
the slice — the form task 07 matches on).
|
||||
"""
|
||||
if doc is None:
|
||||
doc = document_by_identity(db, combined)
|
||||
if doc is None or doc.is_image:
|
||||
return None
|
||||
sliced = slice_lines(doc.content, offset, limit)
|
||||
if sliced.past_end or not sliced.text:
|
||||
return None
|
||||
text, last_line, truncated = cap_slice(sliced.text, sliced.first_line, cap)
|
||||
locator = f"{doc.source}/{doc.path}"
|
||||
if offset is not None or limit is not None or truncated:
|
||||
locator = f"{locator}#{sliced.first_line}-{last_line}"
|
||||
return VerbatimBlock(
|
||||
source=doc.source,
|
||||
path=doc.path,
|
||||
title=doc.title,
|
||||
start_line=sliced.first_line,
|
||||
end_line=last_line,
|
||||
total_lines=sliced.total_lines,
|
||||
text=text,
|
||||
truncated=truncated,
|
||||
locator=locator,
|
||||
)
|
||||
|
||||
|
||||
def resolve_answer_blocks(
|
||||
db: Session,
|
||||
answer: str,
|
||||
shown: Sequence[VerbatimBlock],
|
||||
settings: Settings,
|
||||
) -> list[VerbatimBlock]:
|
||||
"""The blocks an answer displays, in DISPLAY ORDER (the done-time pass).
|
||||
|
||||
Fuses the two ways a block enters an answer — the `` ```bor `` locator
|
||||
fences the model wrote (resolved here, from the row) and the blocks the
|
||||
``show_text`` tool already showed during the turn (``shown``) — into
|
||||
the ONE list task 05 puts on the ``done`` frame and persists with the
|
||||
record.
|
||||
|
||||
**Kill switches first** (checked HERE so no caller has to remember
|
||||
them): ``[]`` when ``settings.answer_text`` is off, or
|
||||
``settings.show_text_max_blocks == 0``, or
|
||||
``settings.show_text_max_chars == 0``.
|
||||
|
||||
**Ordering rule** (task 07's DOM order and the E2E assertions depend on
|
||||
it): answer order first — one block per fence, in the order the fences
|
||||
appear — then the ``shown`` blocks the answer never located, in their
|
||||
original call order. Dedupe is by canonical locator, and the **fence
|
||||
wins** over a same-locator entry from ``shown`` because the fence
|
||||
controls placement. A fence that does not resolve (unknown path, image
|
||||
document, past-the-end range, malformed locator) is simply absent: the
|
||||
caller sees "not among the blocks", which is what task 07 renders as
|
||||
the unresolved state — the answer's own fence text is left alone, so
|
||||
nothing is silently deleted. The list is finally truncated to
|
||||
``settings.show_text_max_blocks`` — and the fence scan STOPS once that
|
||||
many blocks are in, because the same truncation makes every later
|
||||
block unreachable (the bound keeps a fence-stuffed answer to at most
|
||||
``show_text_max_blocks`` successful lookups instead of one ``SELECT``
|
||||
per fence on the request's event loop).
|
||||
"""
|
||||
if not settings.answer_text:
|
||||
return []
|
||||
if settings.show_text_max_blocks == 0 or settings.show_text_max_chars == 0:
|
||||
return []
|
||||
|
||||
cap = settings.show_text_max_chars
|
||||
blocks: list[VerbatimBlock] = []
|
||||
#: The locator strings already CLAIMED (canonical written forms) — a
|
||||
#: repeated fence never resolves twice, and an unresolvable fence
|
||||
#: still holds its slot against a same-locator ``shown`` block.
|
||||
claimed: set[str] = set()
|
||||
#: The locators of the blocks actually appended — a resolved block
|
||||
#: can carry a NARROWER locator than the locator its fence wrote (the
|
||||
#: char cap cut the slice), and the same range shown by the tool
|
||||
#: carries exactly that narrower key, so both keys dedupe.
|
||||
emitted: set[str] = set()
|
||||
|
||||
for raw in fences_in(answer):
|
||||
canonical = canonical_locator(raw)
|
||||
if canonical is None or canonical in claimed:
|
||||
continue
|
||||
# Reserve the key BEFORE resolving: an unresolvable fence must not
|
||||
# let a same-locator ``shown`` block jump into the fence's slot.
|
||||
claimed.add(canonical)
|
||||
block = _block_for_canonical(db, canonical, cap)
|
||||
if block is None or block.locator in emitted:
|
||||
continue
|
||||
claimed.add(block.locator)
|
||||
emitted.add(block.locator)
|
||||
blocks.append(block)
|
||||
if len(blocks) >= settings.show_text_max_blocks:
|
||||
# The display budget is already spent — the truncation below
|
||||
# makes every later block unreachable, so stop LOOKING too:
|
||||
# a fence-stuffed answer costs at most
|
||||
# ``show_text_max_blocks`` successful lookups instead of one
|
||||
# ``SELECT`` per fence on the request's event loop.
|
||||
break
|
||||
|
||||
for block in shown:
|
||||
if block.locator in emitted or block.locator in claimed:
|
||||
continue
|
||||
emitted.add(block.locator)
|
||||
blocks.append(block)
|
||||
|
||||
return blocks[: settings.show_text_max_blocks]
|
||||
|
||||
|
||||
def _block_for_canonical(db: Session, canonical: str, cap: int) -> VerbatimBlock | None:
|
||||
"""Resolve one ALREADY-CANONICAL locator (the output of :func:`canonical_locator`)."""
|
||||
identity, _, range_part = canonical.partition("#")
|
||||
if not range_part:
|
||||
return block_for(db, identity, cap=cap)
|
||||
start_str, _, end_str = range_part.partition("-")
|
||||
start = int(start_str)
|
||||
end = int(end_str)
|
||||
return block_for(db, identity, offset=start, limit=end - start + 1, cap=cap)
|
||||
Reference in New Issue
Block a user