"""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: full top-document texts under ````. * ``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 ```` section between ``…`` 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 ```` section between ``…`` and the ```` section (order: ```` → ```` → ```` → 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): the **HIGH** prompt only carries a ```` section after the ```` body — the grounded turn may call the server-side ``list_documents`` / ``read_document`` tools (round-capped, see :mod:`app.rag.agent`). The LOW/deflection prompt never carries it and stays byte-identical to the pre-phase text. """ from __future__ import annotations from collections.abc import Sequence from app.config import get_settings from app.models import Document from app.rag.retriever import TRUNCATION_MARKER #: 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 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}" ) #: One-line intro of the ```` 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 ```` section (phase 31): the #: lite-generated outline is the agent's a-priori picture of the KB. _KB_INTRO = ( "The basic categories of everything in this knowledge base " "(generated at import time):\n" ) #: The ```` instructions section — **HIGH prompt only** (phase 37, #: task 03): a grounded turn may extend its context through the two #: server-side tools (round cap: ``BOR_AGENT_MAX_ROUNDS``, see #: :mod:`app.rag.agent`). Appended after #: the mode body (````), 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 — and stays #: byte-identical to the pre-phase text. The E2E mock keys off the #: ```` marker's *presence*, not this wording. TOOLS_SECTION: str = ( "\n" "If the documents in your context reference other files, or you need " "content that is not included above, call `list_documents` to see what " "is indexed, then `read_document` to pull in exactly one more document. " "Answer as soon as you have what you need — do not read more than one " "extra document.\n" "" ) 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 ```` 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"\n{_STEERING_INTRO}" + "\n".join(lines) + "\n" 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 ```` 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"\n{_KB_INTRO}" + "\n".join(lines) + "\n" 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 "" def build_high_prompt( documents: Sequence[Document], notes: Sequence[str] | None = None, kb_overview: str | None = None, ) -> str: """Grounded turn: locked persona (+ steering, + KB overview) + full texts of the top documents + the ```` instructions (phase 37). Section order: ```` → ```` → ```` → ```` → ````; empty steering/overview omit their section. ```` 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`). """ blocks = [ f'\n' f"{doc.content}\n" "" for doc in documents ] body = "\n\n".join(blocks) if blocks else ( "(no documents matched — do not invent specifics)" ) prompt = _base("HIGH") for part in (build_kb_section(kb_overview or ""), build_steering_section(notes or [])): if part: prompt += "\n" + part return prompt + "\n\n" + body + "\n\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): ```` → ```` → ```` → ``DEFLECT_MODE`` body; empty steering/overview omit their section, keeping the prompt byte-identical to the pre-phase text. """ 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" + weak )