Files
brain-of-reese/app/rag/prompts.py
T
ducoterra 4f20c256d7
Build and Push Containers / build-and-push-app (push) Successful in 7m24s
Build and Push Containers / build-and-push-db (push) Successful in 36s
phase: 191_source_name_labels_tags
All gates re-verified against a stable tree. Report:

**Verified (all 6 tasks in `complete/`; this was the final pass)**
- `SourceLabel`+alembic `0030` (dev DB `alembic current` = `0030 (head)`, table present, 0 rows); admin-only `GET/PUT/DELETE /api/source-labels` (`require_admin` router-wide, `resolve_source_name`, partial-update caps/404s); Sources-page "Names, labels and tags" card (read-only derived-name `<code>`, per-row `saveInFlight`, 404 note that survives the settle, chips reuse `.git-source-hidden-count`); `question_name_hits` D2 rule; ONE `all_labels` read per turn in `chat.py` feeding both the `source_name_hit` flag and the prompt surfaces; `source_name_hit_bonus=0.005` in `_selection_order`; ` name=`/` label=` in `build_high_prompt` + `build_overview_prompt(names=…)`.
- **Completion criteria:** E2E `tests/e2e/test_source_name_labels.py` ✅ 7 passed (arms a–e: editor all kinds, control/named question, rename-never-breaks-identity, 404 note, reset) · full suite ✅ · coverage ✅ · README/AGENTS ✅ · phase dir in `complete/` ✅ · commit left to the harness.
- **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **exit 0, TOTAL 7160 stmts / 16 missed = 99%** (>90%) · `uv run pytest tests/e2e/test_source_name_labels.py -v --no-cov` → **7 passed, exit 0** · regression E2E isolated, exit 0: `test_document_viewer`, `test_admin_auth`, `test_api_tokens`, `test_git_sources_admin`, `test_kb_tree`, `test_local_directory_sources` · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors.
- **Battery (task 04 criterion):** `uv run python -m scripts.eval_retrieval --from-file tests/fixtures/retrieval_battery.txt` at default vs `BOR_SOURCE_NAME_HIT_BONUS=0` → **`diff` empty (line-for-line identical)**, matching the recorded arms (dev KB is empty, so every question deflects; the observable boost is unit/E2E-pinned).
- **Documentation:** README — fixed the `/git-sources.html` rows (nav label is **Sources**) and cross-linked the phase-191 card from that section; full audit otherwise clean: all 86 `BOR_*` settings documented with defaults matching `app/config.py`, 0 broken file/dir references and 0 broken anchors in README+AGENTS, AGENTS rule 17 re-checked line-by-line against the code.
- **Notable / deviations:** (1) a **concurrent pipeline process** (`phased-execution/progress.mjs` for `191_…_00_phase`) was mutating this same working tree mid-pass — it rewrote `tests/e2e/test_source_name_labels.py` (11→7 tests), `frontend/assets/git-sources.js`, `tests/unit/test_source_labels_editor_ui.py` and `AGENTS.md` at 05:27; every gate above was (re-)run after those edits with the tree no source file newer than the green full run. (2) The task-06 report/phase text claims regression suites `test_chat_token_gate.py`, `test_git_sources_page.py`, `test_rag_browser.py` are green — **those files have never existed in git history** (those runs exit 4); I ran the real equivalents listed above instead (phase files are not mine to edit). (3) **No live changes**: the VM DB already has `source_labels` (0 rows) but the deployed app predates this uncommitted code, so deploying `/api/source-labels` to the live service is the owner's post-commit step, not part of this phase's criteria.
- **Next pending phase:** none — `191_source_name_labels_tags` is the only entry in `.agents/phases/todo/`.
2026-10-19 06:13:32 -04:00

1081 lines
57 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Locked system-prompt builder (PLAN §6).
The persona + HONESTY GATE text is **locked verbatim** — change it through
the plan, not here. (PLAN §6 revision, 2026-08-22: the owner's working-tree
persona edits are preserved — no mandated ``"you've got this"`` tagline and
no mandated deflection opening; the honesty gate itself is unchanged.)
Two modes:
* ``HIGH`` — grounded turn: the top-ranked documents' SUMMARIES under
``<documents>`` (phase 118, LOCKED A6 re-revising A7 — the "start here"
suggestion seeding: each block is the document's stored summary, never
the full content; the LLM extends its context by ``read``-ing a
document's full text through the capped ``read`` tool, the ONLY full-
text path. A NULL/blank summary — a fail-soft import miss — falls back
to a ``suggestion_preview_chars`` content preview + the shared
``[…truncated…]`` marker; no LLM call at chat time, LOCKED A5).
* ``LOW`` — deflection turn: weak-hit *titles only* plus the
``DEFLECT_MODE`` marker (the E2E mock LLM keys on that marker).
Steering (phase 15): when the owner has stored tuning notes, both modes
carry a ``<tuning>`` section between ``<relevance>…</relevance>`` and the
mode body. With zero notes the prompt is byte-identical to the
pre-steering text.
KB overview (phase 31): when the single ``kb_overview`` row holds a
lite-generated outline of the knowledge base, both modes carry a
``<knowledge_base>`` section between ``<relevance>…</relevance>`` and
the ``<tuning>`` section (order: ``<relevance>`` →
``<knowledge_base>`` → ``<tuning>`` → mode body) — the agent knows
roughly what the KB contains before retrieval. With an empty row the
prompt is byte-identical to the pre-phase text.
Agent tools (phase 37; phase 70: the copy teaches the harness-aligned
``ls`` / ``read`` / ``grep`` shapes; phase 72: the copy states the
document-identity contract up front — ``ls``'s ``path`` is a *source
name*, not a directory or file path, and ``read``/``grep`` take the
combined ``source/path`` string *including the source name* (a bare
document path will not resolve) — the same two things the phase-72
teaching refusals in :mod:`app.rag.agent` re-state after the fact, so
the model carries the contract before it calls a tool; phase 94,
task 03: the ``ls`` clause is rewritten to the drill-down tree
contract — no ``path``: the synced sources with counts + summaries, a
source name: its top-level folders + files, a ``source/folder`` path:
one level deeper — a listing shows only that level's subfolders + own
files (never the whole KB in one call), the folder summaries say
what's in a folder before drilling, and ``grep`` stays the locator for
finding one document without listing; the ``read``/``grep`` clauses
and the discipline rules are byte-identical): the **HIGH**
prompt only carries a ``<tools>`` section after the ``<documents>``
body — the grounded turn may extend its context through the three
server-side tools (round-capped, see :mod:`app.rag.agent`; the cap is
the bound and this section does not re-state it, phase 45). The
LOW/deflection prompt never carries it (phase 71: the LOW prompt's
only addition is the plain-text line below — it still has no
``<tools>`` section).
Deflection plain-text line (phase 71, owner-permitted 2026-09-03):
the otherwise-locked ``LOW`` prompt gains exactly one instruction
line — "Reply in plain text only — you have no tools in this mode."
— appended to the ``DEFLECT_MODE`` body: a deflected turn offers no
tools, so any tool markup there is always wrong, and the line closes
the door at the prompt (the deterministic filter + one bounded
recovery in :mod:`app.rag.scaffolding` / :mod:`app.rag.agent` is the
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
HONESTY GATE's compliance is **stochastic** across runs when
misleading context is injected — the "What is the capital of
Mongolia?" question had two *irrelevant* docs promoted into the HIGH
prompt by a weak single-token FTS hit (the pre-phase A8 rule: any
``fts_hits > 0`` flipped HIGH), and run 1 answered parametrically —
"Ulaanbaatar" — *with an explicit disclosure*, while the identical
one-tap re-run produced a clean, textbook deflection. The owner's
decision: the disclosed general-knowledge answer is treated as
**acceptable and documented** — a small local model cannot be relied
on to obey Rules 1/3 100% when handed misleading context, so this
prompt text stays byte-identical (LOCKED verbatim; options (i) tighten
the copy / (ii) amend this locked prompt via the plan to explicitly
permit disclosed general-knowledge answers remain open to a future
owner decision). The deterministic lever is the honesty gate itself
(A8 revised 2026-09-14: an FTS hit flips HIGH only when
``best_cosine >= lexical_support_floor``, so the misleading docs are
no longer injected — see :mod:`app.api.chat`).
Source names, labels and tags (phase 191, D4): a ``<document>`` block can
carry the owner's label row for its source as two ADDITIVE attributes —
``name="…"`` (the source's ``display_name``) immediately after
``source="…"``, then ``label="…"`` (the one-line description) — so the
model learns the name→docs mapping ("the git server" is a source with
these documents). The attribute order is fixed
(``source`` → ``name?`` → ``label?`` → ``path`` → ``title`` → ``date``),
an unset field adds NOTHING, and no *names* map / no entry for the
source leaves the block byte-identical to pre-phase (the alias contract,
D1: the identity attributes stay the DERIVED name — a display name never
replaces ``source`` or ``path``). Only the two new values are XML-escaped
(they are free-form owner text); the identity attributes keep their
pre-phase unescaped bytes.
"""
from __future__ import annotations
import re
from collections.abc import Mapping, Sequence
from typing import Any
from xml.sax.saxutils import escape
from app.config import Settings, get_settings
from app.models import Document
from app.rag.retriever import TRUNCATION_MARKER
from app.rag.source_labels import SourceLabelView
from app.schemas import HistoryTurn
#: PLAN §6 verbatim (line wrapping included); ``{relevance}`` is filled by
#: :func:`_base`.
PERSONA: str = (
'You are "Brain of Reese" — the digital brain of Reese, a self-hoster and\n'
"homelab tinkerer. Personality: chippy, upbeat, warm, and genuinely\n"
"optimistic about the user's ability to do things.\n"
"\n"
"Rules:\n"
"1. Answer ONLY from the provided document context. Cite which document(s)\n"
" you used, by path.\n"
"2. Be concrete: names, versions, ports, hosts, schedules — the specifics in\n"
" the docs are the value.\n"
'3. HONESTY GATE: if <relevance> is "LOW", you must NOT pretend to know.\n'
" Offer 2-3 alternative questions about things you DO have notes on.\n"
"4. Never invent facts, hosts, or steps that are not in the context.\n"
"5. Keep answers tight: short paragraphs, bullets where helpful.\n"
"\n"
"<relevance>{relevance}</relevance>"
)
#: One-line intro of the ``<tuning>`` section (phase 15): the owner's notes
#: steer the answer and win over the defaults when they conflict.
_STEERING_INTRO = (
"The owner of this brain asked you to steer your answers as follows. "
"Where these instructions conflict with the defaults above, follow the owner:\n"
)
#: One-line intro of the ``<knowledge_base>`` section (phase 31;
#: phase 142, task 03 — the honest-intro rewrite, owner-confirmed
#: 2026-09-30): the outline is a COMPACT, possibly partial picture
#: that favors RECENT documents (task 02 orders the generation input
#: newest-first, so recent docs are the ones it covers when the cap
#: bites) — a map of what exists, not a complete inventory — and
#: "latest / complete set" questions verify with ``ls``/``grep``
#: before answering from it; "(generated at import time)" is GONE
#: (false — the sync regenerates the row on every KB change — and it
#: taught the model to treat a partial outline as the whole frozen KB).
_KB_INTRO = (
'A compact picture of the knowledge base\'s categories and topics — '
'it favors recent documents and may omit older or lesser ones: a map '
'of what exists, not a complete inventory. For questions about the '
'latest state or the complete set ("the last N …", "everything '
'about …", "all the …"), verify with `ls` (drill into the folders) '
'or `grep` before answering from the outline:\n'
)
#: The ``<documents>`` section's start-here intro (phase 118, task 03 —
#: the owner directive, TODO L3; phase 119, task 04, LOCKED A5 — the
#: final sentence re-cut to the cite discipline): the seeded blocks are
#: the SUMMARIES of the top-ranked documents for the question, opt-in
#: starting points — start here if one seems right; ``read`` the
#: document's combined ``source/path`` to add its FULL content before
#: answering specifics (the full text is NOT in the prompt until you
#: read it); cite ONLY the documents you read (or the suggested document
#: you answered from without reading it) — never one you neither read
#: nor used. The discipline sentence closes the phase-119 live
#: confabulation — the model's "Docs used:" line cited a file it never
#: read, confabulated from the suggestion blocks sitting in context —
#: and retires the phase-118 "cite the document(s) you used, by path"
#: sentence. Rendered BEFORE the first block, only when at least one
#: block is present. The ``<documents>`` tag and the per-block
#: ``<document>`` markup stay byte-stable around it (the E2E mock's
#: block parser and the ``read``-tool copy key off both).
SUGGEST_INTRO = (
"The blocks below are the summaries of the top-ranked documents for "
"your question — start here if one seems right to you: call `read` "
"with that document's combined `source/path` to add its full content "
"before answering specifics (its full text is not in the prompt until "
"you read it). Cite only the document(s) you read — or, if you "
"answered from a suggested summary without reading it, cite that "
"suggested document — never a document you neither read nor used."
)
#: The ``<tools>`` instructions section — **HIGH prompt only** (phase 37,
#: task 03; phase 70: the copy is rewritten for the harness-aligned
#: ``ls`` / ``read`` / ``grep`` shapes, names/args exactly as the
#: ``AGENT_TOOLS`` schemas in :mod:`app.rag.agent`; phase 72: the copy
#: states the document-identity contract UP FRONT — ``ls``'s optional
#: ``path`` is a *source name* (not a directory or file path) and
#: ``read``/``grep`` take the combined ``source/path`` string *including
#: the source name* (a bare document path will not resolve) — the same
#: two things the phase-72 teaching refusals re-state after the fact;
#: phase 94, task 03 — the owner-permitted tool-surface revision,
#: recorded in the phase 94 overview: the ``ls`` clause is rewritten
#: to the drill-down tree contract — no ``path``: the synced sources
#: with counts + summaries, a source name: its top-level folders +
#: files, a ``source/folder`` path: one level deeper — a listing shows
#: only that level's subfolders + own files, never the whole KB in one
#: call, the folder summaries say what's in a folder before drilling,
#: and ``grep`` stays the locator for finding one document without
#: listing; the ``read``/``grep`` clauses and the discipline rules are
#: byte-identical):
#: a grounded turn may extend its context through the three server-side
#: tools (round cap: ``BOR_AGENT_MAX_ROUNDS`` — the cap is the bound and
#: this section does not re-state it, phase 45). Appended after the mode
#: body (``<documents>``), so the instructions are the last thing the
#: model reads. The LOW/deflection prompt never carries it — a
#: deflection has no grounded context to extend (phase 71: the LOW
#: prompt's only addition is the plain-text line in
#: :func:`build_deflect_prompt`). The E2E mock keys off the ``<tools>``
#: marker's *presence*, not this wording. Task 05 (the live gate's
#: iteration loop) keeps the baseline and carries the discipline rules
#: the live telemetry motivated, refined across the task-05 re-runs of
#: 2026-09-03/04 (run 1: 7/16 executed, 44% — the ``lite`` model obeyed
#: the user's "open it / read it" and ``read`` seed-context documents,
#: then repeated the refused call; run 2: 8/18, 44% — the repeat is
#: gone, but a grep turn batched two calls per reply and the harness
#: runs only the first of a batch): the ``read`` clause carries the
#: phase-118 summary-seed contract (the ``<documents>`` section holds
#: SUMMARIES — a suggested document's full text is not in the prompt
#: until ``read`` adds it; do not re-read a document you have already
#: read IN FULL — answer from the text already in the prompt — a
#: PARTIAL document (a range read, or a truncated read) is not
#: complete in the context until read all: read the other parts with
#: ``offset`` / ``limit`` (phase 141, task 02 — the re-read sentence
#: re-cut to the coverage-true form; the mock keys off the ``<tools>``
#: marker's presence, not the wording, so it is untouched); if the
#: user asks to open or read a suggested document, ``read`` it — that
#: is the point of the section — the phase-72 do-not-read rule and
#: the reverted ``<document path="...">`` anchor are retired with the
#: full-text seeds);
#: the one-call rule names the consequence (a batched second call is
#: discarded — runs only the first); the never-repeat rule says why
#: (the refusal already told you the correct form); the ``grep`` clause
#: leads with "for a normal search pass only ``pattern``" (the gate's
#: live runs showed the model scoping ``grep`` with an ``ls``-style
#: source name — the incident shape, but on grep). The behavioral
#: contract lives in the ``AGENT_TOOLS`` descriptions as well (the most
#: local text at call time): ``read`` adds a document's FULL text by
#: its combined ``source/path`` identity — the ``<documents>``
#: summaries are the starting points, not the content; ``grep`` with
#: only ``pattern`` searches the whole knowledge base, and a source
#: name is not a document.
#: Phase 145 (task 02) — the original-source rule the ``read`` teaching
#: carries (LOCKED verbatim — pinned in :mod:`tests.unit.test_prompt_lock`
#: and :mod:`tests.unit.test_prompts`). :data:`TOOLS_SECTION` embeds this
#: exact sentence-group at the ``read`` clause's end (the truncation
#: workflow sentence) and BEFORE the ``grep`` clause begins; the
#: cross-pins (``TOOLS_SECTION`` minus this rule reproduces the
#: pre-phase-145 bytes; ``count == 1`` + the boundary substring) keep
#: the embedded copy and this constant byte-identical. A read result for
#: a git-sourced document carries an ``origin:`` line (and a ``branch:``
#: line when a branch is pinned) — the read contract's fact — so when
#: the answer uses such a document it includes the original source: a
#: link to the repository (the ``origin`` line) with the document's path
#: (the ``Document …`` header) named and the pinned branch when one is
#: shown, as the repository's file-view URL where the model can tell it
#: (``<repo-url>/blob/<branch>/<path>``), or the repository URL plus the
#: path as plain text otherwise — the user opens the complete original
#: from the answer in one step, not a second question. The rule is
#: CONDITIONAL on the ``origin`` line's presence — a local-sourced
#: document's read result carries none, so the rule adds nothing to
#: those answers (the model has no URL to cite; it must not invent one —
#: the sentence's conditioning does that job).
ORIGIN_RULE: str = (
"A read result for a document from a git source carries an origin line "
"(and a branch line when a branch is pinned): when your answer uses "
"such a document, include the original source in the answer — a link "
"to the repository from the origin line, with the document's path "
"(from the Document header) named and the pinned branch when one is "
"shown — as the repository's file-view URL where you can tell it "
"(for example <repo-url>/blob/<branch>/<path>), or the repository "
"URL plus the path as plain text otherwise. The user should be able "
"to open the complete original from your answer in one step, not ask "
"again for the source."
)
#: The search-discipline rule (phase 162, task 03, LOCKED A4) — appended
#: to the ``TOOLS_SECTION`` tools paragraph immediately after the grep
#: sentence (i.e. after the ``…retry with the plain text you expect to
#: see.`` clause, the placement from the 2026-10-07 owner conversation
#: where the agent answered from the lossy ``<knowledge_base>`` outline
#: instead of running ``ls``, and where the exact-substring grep silently
#: missed the accented ``BESTÅ`` in the IKEA PDF's text layer). Adapted
#: verbatim from the conversation's proposed system-prompt patch for the
#: folded grep (LOCKED A3). The three lessons: (1) an outline/summary is
#: a hint, never an inventory — ``ls`` (no ``path``) before claiming the
#: KB lacks something; (2) grep folds accents but one pattern is one
#: string — split filename tokens and the part number need separate
#: greps; (3) a gitignored note describes repo status, not index status.
#: The mock E2E is unaffected (it keys on markers, not this sentence).
SEARCH_DISCIPLINE_RULE: str = (
"A summary or outline is a hint, never an inventory: before claiming "
"the knowledge base lacks something, run `ls` with no `path` to list "
"every source, and `grep` the artifact's part number or a filename "
"token. `grep` folds accents — `besta` matches `BESTÅ` — but one "
"pattern is one string: a name's split tokens (`frame`, `white`) and "
"its part number (`AA-1272068`) need separate greps. A note saying a "
"file is gitignored describes its repo status, not whether it is "
"indexed as a source."
)
TOOLS_SECTION: str = (
"<tools>\n"
"You may extend your context with three tools. `ls` lists the "
"knowledge base as a tree, one level at a time: with no `path` it "
"lists every synced source with its document count and a summary "
"of its contents; with a source name (e.g. 'homelab') it lists "
"that source's top-level folders and files; with a `source/folder` "
"path it drills one level deeper. A listing shows only that "
"level's subfolders and its own files — never the whole knowledge "
"base in one call — and each folder line's summary says what the "
"folder contains before you drill into it. File lines are "
"`source: X | path: Y | title: Z`; to find one specific document "
"without listing, use `grep`. `read` pulls in one document by its "
"combined `source/path` string, exactly as shown in the `ls` "
"output — including the source name — or in the <documents> "
"summary blocks — adding its full content to your context. The "
"<documents> section holds SUMMARIES — the full text of a "
"suggested document is not in your prompt until you `read` it. Do "
"not re-read a document you have already read in full — its full "
"text is in your prompt, answer from it. If you have read only "
"part of a document (a range read, or a truncated read), read the "
"other parts with `offset` and `limit` — the document is not "
"complete in your context until you have read it all. If the user "
"asks you to open or read a suggested document, `read` it — that "
"is the point of the section. For `read`, a bare document path "
"(without the source name) will not resolve. Very large documents "
"are capped: a "
"cut read returns the first part plus a TRUNCATED notice — the "
"document did not end where it stopped; use `grep` (pattern) to "
"find the rest, it searches the whole document. A read result for a "
"document from a git source carries an origin line (and a branch line "
"when a branch is pinned): when your answer uses such a document, "
"include the original source in the answer — a link to the "
"repository from the origin line, with the document's path (from the "
"Document header) named and the pinned branch when one is shown — as "
"the repository's file-view URL where you can tell it (for example "
"<repo-url>/blob/<branch>/<path>), or the repository URL plus the "
"path as plain text otherwise. The user should be able to open the "
"complete original from your answer in one step, not ask again for "
"the source. `grep` locates an exact string "
"(case- and accent-insensitive — accents fold: `besta` matches "
"`BESTÅ`) in the indexed documents and returns up to 20 "
"matching `source/path:line: text` lines — a locator, not a "
"context-adder: read the winner with `read`. A grep pattern is a "
"plain substring, NEVER a regex — '.*' and '\\.' are literal text "
"there; if such a pattern returns no matches, retry with the plain "
"text you expect to see. "
+ SEARCH_DISCIPLINE_RULE
+ " For a normal search pass only `pattern` — "
"its optional `path` argument "
"limits the search to one document you already know, by the same "
"combined `source/path` string; never a source name — a bare "
"document path (without the source name) will not resolve there "
"either. Make exactly one tool call per reply — a reply carrying "
"two tool calls runs only the first, the second is discarded — and "
"wait for the result before the next call. Never repeat a call that "
"was refused or already succeeded — the refusal already told you "
"the correct form. Answer as soon as you have what you need.\n"
"</tools>"
)
#: The ``show_image`` contract clause (phase 124, task 03, LOCKED A5) —
#: appended INSIDE the ``<tools>`` section of the HIGH prompt, after the
#: existing three-tool copy and before the closing tag, ONLY when
#: :func:`build_high_prompt` is called with ``show_image=True`` (the
#: ``BOR_ANSWER_IMAGES`` toggle, via ``app.api.chat``). It teaches the
#: same relevance gate as the ``show_image`` tool description in
#: :mod:`app.rag.agent` (LOCKED A5: the gate is the LLM's decision — no
#: server-side image scoring), plus the image-doc nature the tool
#: description cannot fully carry: the text of an image document (in a
#: ``<documents>`` block or a ``read`` result) is a description
#: GENERATED from the image, not the image's own words. The four gate
#: elements, all stated here: (1) the image-doc nature; (2) the
#: "contains the answer" bar — call it only when THAT image is what the
#: user actually wants to see, never a merely related image, never to
#: pad an answer; (3) at most ONE image per answer (also enforced
#: server-side, task 02's ``ALREADY_SHOWING`` refusal); (4) the text
#: answer is still provided in full — the image supplements, never
#: replaces. ``TOOLS_SECTION`` itself stays BYTE-IDENTICAL (the
#: pre-phase pins in :mod:`tests.unit.test_prompt_lock` hold); a
#: ``show_image=False`` build (the default — every pre-phase call site)
#: is byte-identical to the pre-task output. The ``<tools>`` marker the
#: E2E mock keys on is untouched either way — the clause rides on its
#: presence, not the wording. The LOW/deflection prompt never carries
#: the clause (a deflected turn has no tools and never shows an image —
#: the phase's locked design).
SHOW_IMAGE_CLAUSE: str = (
"`show_image` displays a standalone image document as part of your "
"answer. The text of an image document — in a <documents> block or "
"a `read` result — is a description GENERATED from the image, not "
"the image's own words. Call `show_image` only when that image is "
"what the user actually wants to see: a diagram, screenshot, or "
"photo of the very thing you are answering about — never for a "
"merely related image, and never to pad an answer. Show at most one "
"image per answer. The image supplements your text — answer fully "
"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],
settings: Settings,
) -> list[dict[str, Any]]:
"""Client-provided chat history → model messages (phase 74, TODO L4).
The ``POST /api/chat`` ``history`` (the client's prior turns, oldest
first) becomes the message block that sits between the system prompt
and the current user message — so a follow-up question reaches the
model together with the exchange so far, on BOTH turn branches
(the deflected path and the grounded agent).
Trimming (owner-locked A3, 2026-09-08): the turns are walked
**newest-first** and kept while BOTH budgets hold — the turn count
stays ≤ ``settings.history_max_turns`` and the cumulative chars
(``len(text) + len(thinking or "")`` per turn) stay ≤
``settings.history_max_chars``. A turn that would overflow either
remaining budget is DROPPED WHOLE — never cut mid-answer — and the
walk stops there, so the kept history is always the contiguous
newest window (the oldest turns are the ones dropped; ``0`` on
either budget yields ``[]`` — the pre-phase-74 behavior). The kept
turns are returned in chronological (oldest → newest) order.
Mapping (owner-locked A4, 2026-09-08): ``who="user"`` →
``{"role": "user", "content": text}``; ``who="brain"`` →
``{"role": "assistant", "content": text}`` plus
``"reasoning_content": thinking`` ONLY when *thinking* is
non-empty — the preserve-thinking wire convention
:mod:`app.rag.llm` already reads on the response side
(``delta.reasoning_content``), which is what keeps the owner's
preserve-thinking models carrying the reasoning chain forward.
Pure and side-effect free (no I/O) — unit-testable in isolation.
"""
kept: list[HistoryTurn] = []
chars = 0
for turn in reversed(history):
if len(kept) >= settings.history_max_turns:
break
size = len(turn.text) + len(turn.thinking or "")
if chars + size > settings.history_max_chars:
break
kept.append(turn)
chars += size
messages: list[dict[str, Any]] = []
for turn in reversed(kept):
if turn.who == "user":
messages.append({"role": "user", "content": turn.text})
continue
message: dict[str, Any] = {"role": "assistant", "content": turn.text}
if turn.thinking:
message["reasoning_content"] = turn.thinking
messages.append(message)
return messages
def _base(relevance: str) -> str:
if relevance not in ("HIGH", "LOW"):
raise ValueError(f"relevance must be HIGH or LOW, got {relevance!r}")
return PERSONA.replace("{relevance}", relevance)
def build_steering_section(notes: Sequence[str], max_chars: int | None = None) -> str:
"""The ``<tuning>`` section of the system prompt (phase 15).
* No notes (or only blank ones) → ``""`` — callers then build the
prompt exactly as before, so a zero-note prompt is byte-identical to
the pre-steering text.
* Otherwise: numbered notes (in the given order — the chat turn passes
them oldest-first, so #1 is the oldest note) capped at *max_chars*
(default ``BOR_STEERING_MAX_CHARS``). When the budget cannot hold
every note, the oldest-fitting prefix is kept and the overflow is
replaced by the shared ``[…truncated…]`` marker.
"""
cleaned = [str(n).strip() for n in notes]
cleaned = [n for n in cleaned if n]
if not cleaned:
return ""
limit = max_chars if max_chars is not None else get_settings().steering_max_chars
if limit <= 0:
return ""
def render(count: int) -> str:
lines = [f"{i}. {note}" for i, note in enumerate(cleaned[:count], start=1)]
if count < len(cleaned):
lines.append(TRUNCATION_MARKER)
return f"<tuning>\n{_STEERING_INTRO}" + "\n".join(lines) + "\n</tuning>"
for count in range(len(cleaned), 0, -1):
rendered = render(count)
if len(rendered) <= limit:
return rendered
# Pathological budget: not even the empty note list fits. The section
# must still respect the cap — the bare marker when it fits, else none.
if len(TRUNCATION_MARKER) <= limit:
return TRUNCATION_MARKER
return ""
def build_kb_section(overview: str, max_chars: int | None = None) -> str:
"""The ``<knowledge_base>`` section of the system prompt (phase 31).
* No outline (or only whitespace) → ``""`` — callers then build the
prompt exactly as before, so a no-overview prompt is byte-identical
to the pre-phase text (phase 15 convention).
* Otherwise: the intro line + the stored outline, capped at
*max_chars* (default ``BOR_KB_OVERVIEW_MAX_CHARS``). When the budget
cannot hold the whole outline, the longest-fitting prefix is kept
and the overflow is replaced by the shared ``[…truncated…]`` marker
on its own line — the exact :func:`build_steering_section` pattern,
including its pathological-budget handling (never exceed the cap;
bare marker when even one outline character does not fit).
"""
text = str(overview or "").strip()
if not text:
return ""
limit = max_chars if max_chars is not None else get_settings().kb_overview_max_chars
if limit <= 0:
return ""
def render(cut: int) -> str:
lines = [text[:cut]]
if cut < len(text):
lines.append(TRUNCATION_MARKER)
return f"<knowledge_base>\n{_KB_INTRO}" + "\n".join(lines) + "\n</knowledge_base>"
for cut in range(len(text), 0, -1):
rendered = render(cut)
if len(rendered) <= limit:
return rendered
# Pathological budget: not even one outline character fits. The
# section must still respect the cap — the bare marker when it fits,
# else none (steering precedent, phase 15).
if len(TRUNCATION_MARKER) <= limit:
return TRUNCATION_MARKER
return ""
#: Phase 125 (task 05, LOCKED A4) — the header line of one block of an
#: enriched image doc's stored ``image_context`` (the exact shape the
#: importer's reference-block writer emits, tasks 03/04):
#: ``Referenced in <source>/<markdown_path>:`` — a block is one
#: referring markdown file, and a block's line FOLLOWING its header is
#: always its first ``- Alt text: "..."`` bullet. A look-alike line in
#: the context body without that bullet is spurious and dropped
#: (graceful degradation, A4).
_IMAGE_CTX_HEADER_RE = re.compile(r"^Referenced in (?P<ref>.+):$")
#: Phase 137 (task 04, ASSUMPTION A6) — the header line of the PDF
#: provenance block of a PDF-embedded image doc's stored
#: ``image_context`` (the exact shape the importer's document-block
#: writer emits, phase 137 tasks 02/03 — written FIRST, before any
#: markdown reference blocks, ASSUMPTION A2):
#: ``Extracted from <source>/<pdf path> (page <N>):`` — ``loc`` is the
#: ``<source>/<pdf path>`` locator (``.+`` greedy to the LAST
#: `` (page N):`` — the pdf path may itself contain spaces); the line
#: FOLLOWING the header is page-text body (or the next block's header)
#: and is never part of the clause — the header line alone IS the
#: clause (no bullet is required or expected). A look-alike line inside
#: the page-text body emits a clause the same way — accepted: the
#: stored value is the context the importer wrote, not free model text
#: (the ``Referenced in`` twin IS guarded by its bullet requirement).
_PDF_CTX_HEADER_RE = re.compile(r"^Extracted from (?P<loc>.+) \(page (?P<page>\d+)\):$")
#: The ``- Alt text: "..."`` bullet of the same block. The greedy alt
#: field (the block writer does not escape quotes) is captured between
#: the opening quote (the pattern's literal) and the CLOSING quote
#: (the pattern's trailing ``"$``) — an alt with inner ``"`` survives
#: intact (the greedy walk keeps the inner quote, the final one is the
#: delimiter; an empty alt matches as the empty group).
_IMAGE_CTX_ALT_RE = re.compile(r'^- Alt text: "(?P<alt>.*)"$')
def _image_ctx_clause(ref: str, alt: str | None) -> str:
"""One clause of the retrieval context line (phase 125, task 05):
the referring markdown's stored identity (``<source>/<path>``
exactly as the block header carries it) plus the reference's alt
text quoted after a colon — the reference's purpose in one phrase.
A reference with no alt (``![](path)``) renders the bare identity
(nothing to quote).
"""
if alt:
return f'{ref}: "{alt}"'
return ref
def _extract_context_line(image_context: str) -> str | None:
"""The ONE-LINE rendering of an enriched image doc's stored
``image_context`` (phase 125, task 05, LOCKED A4; phase 137, task
04, ASSUMPTION A6) — or ``None`` when nothing in the value parses
as a reference block OR a provenance block (graceful degradation:
the ``<document>`` block then shows only the description, exactly
pre-phase — the value never raises into the prompt build).
The stored value is blank-line-joined blocks of two kinds:
* the phase-137 document (provenance) block (tasks 02/03) — written
FIRST for a PDF-embedded image (ASSUMPTION A2: provenance before
purpose); ONE header line, no bullet::
Extracted from <source>/<pdf path> (page <N>):
<page text — never rendered: it already reached the vision
model at describe time>
→ the clause ``From <source>/<pdf path> (page <N>)`` — the
header line alone (the page-text body is skipped as context
body; a spurious ``Extracted from … (page N):`` line inside the
page text emits a clause the same way — it is the stored context
the importer wrote, not free model text, accepted).
* the phase-125 reference block (tasks 03/04) — one per referring
markdown file; the line FOLLOWING the header is always its first
``- Alt text: "..."`` bullet (a look-alike header without its
bullet is spurious and dropped)::
Referenced in <source>/<markdown_path>:
- Alt text: "<alt_text>"
Context: <context paragraphs — may span many lines>
- Alt text: "<a second reference's alt>"
→ the clause ``Referenced in <ref>: "<alt>"`` (identity + the
FIRST reference's alt; the ``Context:`` body is never rendered —
LOCKED A3, the paragraphs already reached the vision model at
enrichment time, so the line stays short enough not to bloat
the prompt).
Clauses join with ``"; "`` IN STORED ORDER:
* BOTH kinds (the phase-137 combined shape — document block first,
then the markdown blocks) render each clause with its own
prefix::
From <source>/<pdf> (page N); Referenced in docs/a.md: "alt"
* a MARKDOWN-ONLY value keeps the phase-125 shape byte-identically
(the regression pin — ONE ``Referenced in `` prefix for the
joined referenced clauses)::
Referenced in docs/a.md: "alt a"; docs/b.md
A value with NO parsable header of either kind parses to ``None``
(pre-phase rows, unparseable text). The markdown doc's
``IMAGE_CONTEXT:`` pending record (phase 125, task 04) carries no
parsable header of either kind and parses to ``None`` — and it
rides a TEXT doc, which :func:`_document_body` excludes from the
line via the ``is_image`` guard anyway.
"""
clauses: list[tuple[str, str]] = []
# (kind, text), in stored order: a ``"pdf"`` clause's text already
# carries its ``From …`` prefix; a ``"ref"`` clause's text is the
# BARE ``<ref>: "<alt>"`` core — the prefix is applied at the join,
# below (a markdown-only value keeps ONE prefix, byte-identically).
ref: str | None = None # the current block's "source/markdown_path"
alt: str | None = None # the current block's first bullet's alt text
expecting_alt = False # the line after a "Referenced in" header must be its bullet
for line in image_context.splitlines():
header = _IMAGE_CTX_HEADER_RE.match(line)
if header is not None:
if ref is not None and not expecting_alt:
clauses.append(("ref", _image_ctx_clause(ref, alt)))
ref, alt = header.group("ref"), None
expecting_alt = True
continue
pdf_header = _PDF_CTX_HEADER_RE.match(line)
if pdf_header is not None:
# A provenance header requires NO following bullet — the
# header line alone is the clause (the next line is
# page-text body or the next header; neither changes it).
# A pending reference block's bullet never came → dropped
# by the same spurious-header rule; a complete one is
# kept, in stored order.
if ref is not None and not expecting_alt:
clauses.append(("ref", _image_ctx_clause(ref, alt)))
clauses.append(
("pdf", f"From {pdf_header.group('loc')} (page {pdf_header.group('page')})")
)
ref, alt, expecting_alt = None, None, False
continue
alt_match = _IMAGE_CTX_ALT_RE.match(line) if ref is not None else None
if expecting_alt:
if alt_match is None:
# Spurious header (a look-alike line in context text):
# its bullet never came — drop the block.
ref, alt, expecting_alt = None, None, False
else:
alt = alt_match.group("alt")
expecting_alt = False
continue
# Any other line is context body — a provenance block's page
# text, a reference block's Context: paragraphs, or a second
# bullet of the same block — and the one-line rendering is
# unaffected (no branch needed: with a live block and no
# pending bullet, ``alt`` is never ``None``).
if ref is not None and not expecting_alt:
clauses.append(("ref", _image_ctx_clause(ref, alt)))
if not clauses:
return None
if all(kind == "ref" for kind, _ in clauses):
# Markdown-only: the phase-125 shape, byte-identically (the
# regression pin) — ONE ``Referenced in `` prefix for the
# joined clauses.
return "Referenced in " + "; ".join(text for _, text in clauses)
# A provenance clause is present: every clause carries its own
# prefix (the ``pdf`` ones already do; the referenced clauses gain
# theirs) — the stored order (document block first, ASSUMPTION A2)
# is the render order.
return "; ".join(
text if kind == "pdf" else f"Referenced in {text}" for kind, text in clauses
)
def _attr(value: str) -> str:
"""XML-escape one attribute value (phase 191, D4) — ``&``/``<``/``>``
and the double quote (``&quot;``), the delimiter the block uses.
Applied ONLY to the two additive label attributes (``name`` /
``label``): they hold free-form OWNER text where a ``"`` is realistic
and must not break the attribute. The pre-existing identity
attributes (``source`` / ``path`` / ``title`` / ``date``) deliberately
keep their unescaped pre-phase bytes — escaping them here would move
every label-less block, which the phase's byte-identity criterion
forbids.
"""
return escape(value, {'"': "&quot;"})
def _source_label_attrs(
doc: Document, names: Mapping[str, SourceLabelView] | None
) -> str:
"""The additive `` name="…"`` / `` label="…"`` fragment of one
``<document>`` block (phase 191, D4) — the empty string unless the
source has a label row with that field set.
``None`` / an empty map / no entry for ``doc.source`` / a row with
neither field set all render ``""`` — the block is then byte-identical
to pre-phase, and a label-only row adds exactly the one ``label``
attribute (an unset field adds NOTHING, it is never an empty
attribute).
"""
if not names:
return ""
view = names.get(doc.source)
if view is None:
return ""
attrs = ""
if view.display_name:
attrs += f' name="{_attr(view.display_name)}"'
if view.label:
attrs += f' label="{_attr(view.label)}"'
return attrs
def _image_context_line(doc: Document) -> str | None:
"""The phase-125 context line for one suggested document (task 05,
LOCKED A4) — ``None`` in the common case (EVERY text doc, and every
image doc without a parsable ``image_context`` — standalone /
pre-phase rows: the byte-identical path) and the one-line
:func:`_extract_context_line` rendering otherwise (``is_image``
docs with a non-NULL, parsable ``image_context`` only)."""
if not doc.is_image or not doc.image_context:
return None
return _extract_context_line(doc.image_context)
def _document_body(doc: Document) -> str:
"""The body of one ``<document>`` suggestion block (phase 118, LOCKED
A6): the document's stored summary (stripped) — NEVER the full
content.
Defensive fallback ONLY when the summary is missing (``None`` or
whitespace — a fail-soft import miss, LOCKED A5): the first
``suggestion_preview_chars`` characters of the content plus the
shared :data:`TRUNCATION_MARKER` on its own line — the settings read
happens on this fallback path ONLY (a prompt built from
summary-bearing docs makes no settings call for the cap). Content at
or under the cap rides whole, unmarked (nothing was cut). No LLM
call at chat time — the preview is deterministic.
Phase 125 (task 05, LOCKED A4; phase 137, task 04, A6): an ENRICHED
image doc (``is_image`` with a non-NULL ``image_context`` that
parses — see :func:`_image_context_line`) leads its body with the
ONE short context line — ``Referenced in <source>/<path>: "<alt>"``
(the phase-125 reference shape) or ``From <source>/<pdf> (page N)``
(the phase-137 provenance shape) — before the description, so the
LLM knows WHY the image exists in the documentation, not only what
it shows. Every other doc is
byte-identical to the pre-phase body: every text doc (its
``image_context`` may carry the task-04 pending record, which never
renders) and every non-enriched image doc (NULL ``image_context``
— the phase's byte-identity criterion).
"""
summary = (doc.summary or "").strip()
if summary:
body = summary
else:
limit = get_settings().suggestion_preview_chars
content = doc.content
body = (
content[:limit] + "\n" + TRUNCATION_MARKER
if len(content) > limit
else content
)
context_line = _image_context_line(doc)
if context_line is not None:
body = context_line + "\n\n" + body
return body
def build_high_prompt(
documents: Sequence[Document],
notes: Sequence[str] | None = None,
kb_overview: str | None = None,
folder_lines: Sequence[str] = (),
show_image: bool = False,
show_text: bool = False,
names: Mapping[str, SourceLabelView] | None = None,
) -> str:
"""Grounded turn: locked persona (+ steering, + KB overview) + the
top-ranked documents' SUMMARY blocks + the ``<tools>`` instructions
(phase 37; phase 70: the harness-aligned ``ls`` / ``read`` /
``grep`` shapes; phase 72: the document-identity contract up front;
phase 118, LOCKED A6 re-revising A7: the ``<documents>`` section
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; 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
section. ``<tools>`` is always present in the HIGH prompt (the round
cap — not the prompt — decides whether the tools are actually
offered to the model, see :mod:`app.rag.agent`).
*show_image* (phase 124, task 03, LOCKED A5): when true (the
caller passes the ``BOR_ANSWER_IMAGES`` toggle —
``app.api.chat``'s grounded plan is the only call site that does),
the :data:`SHOW_IMAGE_CLAUSE` rides INSIDE the ``<tools>`` section
— appended after the existing three-tool copy and before the
closing ``</tools>`` tag, so the ``<tools>`` marker the E2E mock
keys on is untouched and the clause reaches the model together with
the tool teaching. The section order is otherwise UNCHANGED and
:data:`TOOLS_SECTION` itself is never edited: the built section is
``TOOLS_SECTION``'s text minus its final ``</tools>`` + the clause
+ the closing tag. The default ``False`` keeps EVERY pre-existing
call byte-identical (the pre-task pins in
:mod:`tests.unit.test_prompts` / :mod:`tests.unit.test_prompt_lock`
hold untouched). The deflection prompt (:func:`build_deflect_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
blocks are the summaries of the top-ranked documents, ``read`` adds
the full text, and the answer cites only the documents it read (or
the suggested document it answered from without reading — the
phase-119 cite discipline, LOCKED A5; the phase-118 "cite what you
used, by path" sentence is retired). This is NOT the reverted
phase-72 in-context reminder (that copy
taught the seed documents as already-read context; this one teaches
the summary-as-starting-point contract the A6 re-revision requires).
*folder_lines* (phase 119, D3, LOCKED A4): the suggested-folder
context lines (``app.rag.agent.suggested_folder_lines``) — the
direct children of each suggested document's parent folder, so the
model ``read``s the right file in round 1 instead of walking the
``ls`` drill-downs. When blocks are present AND the lines are
non-empty, the ``<documents>`` body is ``SUGGEST_INTRO``, then the
folder lines (each on its own line, immediately after the intro
line), a blank line, then the first ``<document>`` block. They are
PLAIN lines — no new markup/tag (the E2E mock keys off the
``<documents>`` marker and the LAST block's tail, so the lines must
never land after a summary). Empty *folder_lines* ⇒ the phase-118
build is byte-identical (pinned in
``tests/unit/test_prompts.py``).
Each ``<document>`` block carries the identity attributes
``source`` / ``path`` / ``title`` — and, since phase 106 (D5),
``date`` (the row's ``created_at`` UTC date part, ``YYYY-MM-DD``,
APPENDED after ``title`` — the only position; always present,
``created_at`` is NOT NULL) — plus the document's summary
(:func:`_document_body`; the NULL/whitespace-summary preview
fallback is LOCKED A5; an enriched image doc's summary leads with
the phase-125 one-line context line, LOCKED A4).
*names* (phase 191, D4): the source-name → :class:`SourceLabelView`
map of the owner's label rows (the SAME single ``all_labels`` read
that feeds the task-04 selection boost — one query per turn). When a
doc's source has a row, the block head gains `` name="{display_name}"``
immediately after ``source="…"`` and `` label="{label}"`` after that
(the fixed order ``source`` → ``name?`` → ``label?`` → ``path`` →
``title`` → ``date``); the two values are XML-escaped
(:func:`_attr`), the identity attributes are not. An unset field adds
NOTHING, so a label-only row gains exactly the one attribute, and
``None`` / ``{}`` / no entry for the source keeps the block
byte-identical to pre-phase (the default, and the kill pin in
:mod:`tests.unit.test_prompts`). This is surfacing only — the alias
contract (D1) keeps ``source`` / ``path`` as the document identity
the tools, citations and locators use.
"""
# Phase 106 (D5): every document the model sees carries its
# creation date — the block's ``date`` attribute (the row's
# ``created_at`` UTC date part, appended after ``title`` — the
# only position; always present, ``created_at`` is NOT NULL).
# Phase 118 (A6): the block body is the summary — the full content
# stays out of the prompt (the capped ``read`` tool is the only
# full-text path).
blocks = [
f'<document source="{doc.source}"{_source_label_attrs(doc, names)}'
f' path="{doc.path}" title="{doc.title}" '
f'date="{doc.created_at:%Y-%m-%d}">\n'
f"{_document_body(doc)}\n"
"</document>"
for doc in documents
]
body = "\n\n".join(blocks) if blocks else (
"(no documents matched — do not invent specifics)"
)
if blocks:
if folder_lines:
# Phase 119 (D3, LOCKED A4): the folder lines ride immediately
# after the intro line, one per line, a blank line, then the
# first block — plain lines, no new markup (the <document>
# block markup AND body stay byte-identical around them).
body = SUGGEST_INTRO + "\n" + "\n".join(folder_lines) + "\n\n" + body
else:
body = SUGGEST_INTRO + "\n\n" + body
prompt = _base("HIGH")
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:
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
def build_deflect_prompt(
titles: Sequence[str],
notes: Sequence[str] | None = None,
kb_overview: str | None = None,
) -> str:
"""Deflection turn: weak-hit titles only (no document content).
Section order (phase 31): ``<relevance>`` → ``<knowledge_base>`` →
``<tuning>`` → ``DEFLECT_MODE`` body; empty steering/overview omit
their section, keeping the prompt byte-identical to the pre-phase
text. The body ends with the phase-71 plain-text line (owner-
permitted 2026-09-03 — the LOW prompt's only change): a deflected
turn offers no tools, so any tool markup there is always wrong.
"""
weak = "\n".join(f"- {t}" for t in titles) if titles else "(nothing close at all)"
mid = "\n".join(
part
for part in (build_kb_section(kb_overview or ""), build_steering_section(notes or []))
if part
)
gap = f"\n{mid}\n" if mid else "\n"
return (
_base("LOW")
+ gap
+ "DEFLECT_MODE: retrieval was weak — the titles below are the closest "
"your notes come to the question. They are titles only; do not pretend "
"they answer it. Use them to propose 2-3 alternative questions.\n"
# Phase 71 (owner-permitted 2026-09-03): the one plain-text line
# — prevention at the prompt. The E2E mock keys on the
# DEFLECT_MODE marker's presence, so appending is safe.
"Reply in plain text only — you have no tools in this mode.\n"
+ weak
)