All verification complete. Final report: **Phase 119 final verification pass — all criteria verified, one stale pin fixed.** - Verified implementation of all 6 tasks: D1 component name-hit rule (`name_hit` flag, titles never matched, retired length tie-break), D2 `BOR_NAME_HIT_BONUS` (0.005 default, 0 = byte-identical kill switch, negative fails startup, selection-layer only, `eval_retrieval` `suggested:` line), D3 suggested-folder lines (after `SUGGEST_INTRO`, before first block), D4 cite-discipline `SUGGEST_INTRO` sentence (PERSONA/LOW/`TOOLS_SECTION` byte-pins intact), D5 `done.sources` = read docs only (frontend no-op on empty confirmed), D6 mock `repeat your folder map` echo + new suite + telemetry. - Battery (replica restored per skill, fingerprint docs=1000/chunks=8866 verified, `eval_retrieval --from-file tests/fixtures/retrieval_battery.txt` re-run): **GATE PASS** — gitea README #4 in suggested top-5, forgejo 5/5 (README #1), gateway README in top-5 (#4), qwen3.8-27b quadlets top-5, Mongolia HIGH/fts=5 unchanged. - New E2E in isolation: `4 passed` ×2 (deterministic). All 27 modified E2E suites in isolation: 26 green; **1 stale pin fixed** — `test_source_chip_quality.py` durable-record order pin pre-dated the D1 re-rank (`aliases` stem sub-component name-hits `ssh_aliases.txt`, deterministically lifting `backups.md` over `kubernetes.md`; probe-verified 0.016277 vs 0.016036, 4/4 stable) — re-pinned with the phase-119 rationale; suite green ×2. - Gates: `uv run pytest --cov=app --cov-report=term-missing` → **2547 passed, app coverage 99%** (>90%); `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors. - Completion criteria: 1 ✅ (battery, recorded), 2 ✅ (folder lines; block/LOW byte-identical pins green), 3 ✅ (read-only chips, zero-read chips nothing, related row + durable record untouched — unit+E2E agree), 4 ✅ (all green), 5 → commit/phase-move left to the harness per pass rules (nothing committed). - Deviations: battery output + real-model telemetry recorded in `.agents/reports/119_name_signal_read_chips/task06_battery_and_e2e.md` and `TOOL_CALLING_TESTING.md` §11 (task files in `complete/` are immutable to this pass); gateway canonical doc at #4 vs overview's #3 was already documented at task 06 (containment gate met). - Next pending phase: **none** — `todo/` holds only phase 119.
1018 lines
45 KiB
Python
1018 lines
45 KiB
Python
"""Unit: locked persona prompt builder (PLAN §6 verbatim + both modes).
|
|
|
|
Also covers the phase-31 ``<knowledge_base>`` section (the stored,
|
|
lite-generated KB outline): its own builder contract (empty → ``""``,
|
|
char budget + ``[…truncated…]`` marker, pathological budgets), its
|
|
placement between ``<relevance>`` and ``<tuning>`` in both modes, and
|
|
the byte-identical-when-absent convention (phase 15 precedent).
|
|
|
|
And the phase-71 deflection plain-text line (owner-permitted
|
|
2026-09-03): the LOW prompt = pre-phase text + exactly the one new
|
|
line; the ``DEFLECT_MODE`` marker-keying contract is unchanged and
|
|
the line never leaks into the HIGH prompt.
|
|
|
|
And the ``<tools>`` copy (phase 72: the document-identity contract
|
|
stated up front — the combined ``source/path`` identity for
|
|
``read``/``grep``; phase 94, task 03: the ``ls`` clause rewritten to
|
|
the drill-down tree contract — one level per call, sources at the
|
|
top, folders + files below, ``grep`` as the without-listing locator —
|
|
while the ``read``/``grep`` clauses and the discipline rules are
|
|
byte-identical; phase 118, task 04: the ``read`` clause rewritten
|
|
for the summary-seed mode — the ``<documents>`` section holds
|
|
SUMMARIES, ``read`` adds the full text, and only an already-read
|
|
document is refused — while the ``ls``/``grep`` clauses and the
|
|
discipline rules stay byte-identical): the teaching refusals in
|
|
:mod:`app.rag.agent` re-state the same contract; the ``<tools>``
|
|
marker keying (HIGH only) is unchanged.
|
|
|
|
And the phase-119 cite discipline (task 04, LOCKED A5): the intro's
|
|
final sentence ("cite the document(s) you used, by path", phase 118)
|
|
is REPLACED — the HIGH prompt carries the discipline sentence exactly
|
|
once, inside ``<documents>`` after the intro's start-here framing;
|
|
the LOW prompt and ``PERSONA`` / ``TOOLS_SECTION`` stay byte-identical
|
|
(the full sha pins live in :mod:`tests.unit.test_prompt_lock`).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
|
|
import pytest
|
|
|
|
from app.config import Settings
|
|
from app.models import Document
|
|
from app.rag.prompts import (
|
|
PERSONA,
|
|
SUGGEST_INTRO,
|
|
TOOLS_SECTION,
|
|
_base,
|
|
build_deflect_prompt,
|
|
build_high_prompt,
|
|
build_kb_section,
|
|
build_steering_section,
|
|
)
|
|
from app.rag.retriever import TRUNCATION_MARKER
|
|
|
|
#: A small multi-line outline standing in for the lite-generated one.
|
|
OVERVIEW = "- Homelab\n - Kubernetes (k3s)\n- Deployments\n - Borg backups"
|
|
|
|
#: The locked one-line intro of the ``<knowledge_base>`` section.
|
|
KB_INTRO = "The basic categories of everything in this knowledge base (generated at import time):"
|
|
|
|
|
|
#: The fixture documents' fixed creation date (phase 106, D5) — the
|
|
#: ``<document>`` block formats its UTC date part (the detached fixture
|
|
#: rows carry it exactly as the NOT NULL DB column guarantees it for
|
|
#: real rows).
|
|
_FIXTURE_CREATED_AT = datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC)
|
|
|
|
|
|
def _doc(
|
|
path: str,
|
|
content: str,
|
|
title: str,
|
|
summary: str | None = None,
|
|
) -> Document:
|
|
return Document(
|
|
id=uuid.uuid4(),
|
|
source="Homelab",
|
|
path=path,
|
|
full_path=f"/tmp/{path}",
|
|
title=title,
|
|
content=content,
|
|
content_hash="0" * 64,
|
|
created_at=_FIXTURE_CREATED_AT,
|
|
summary=summary,
|
|
)
|
|
|
|
|
|
def test_persona_rules_present_verbatim() -> None:
|
|
# Aligned to the owner's working-tree persona edits (PLAN §6 revision,
|
|
# 2026-08-22): no "you've got this" tagline, no mandated deflection
|
|
# opening. The honesty gate itself (rule 3) is unchanged.
|
|
for fragment in (
|
|
'You are "Brain of Reese" — the digital brain of Reese, a self-hoster and',
|
|
"optimistic about the user's ability to do things",
|
|
"Answer ONLY from the provided document context. Cite which document(s)",
|
|
"you used, by path.",
|
|
"Be concrete: names, versions, ports, hosts, schedules",
|
|
'HONESTY GATE: if <relevance> is "LOW", you must NOT pretend to know',
|
|
"Offer 2-3 alternative questions about things you DO have notes on.",
|
|
"Never invent facts, hosts, or steps that are not in the context.",
|
|
"Keep answers tight: short paragraphs, bullets where helpful.",
|
|
):
|
|
assert fragment in PERSONA
|
|
|
|
|
|
def test_persona_owner_edits_are_preserved() -> None:
|
|
"""PLAN §6 revision (2026-08-22): the removed elements must stay out."""
|
|
assert 'you\'ve got this' not in PERSONA # tagline removed by the owner
|
|
assert "Start your answer with a variant of" not in PERSONA # no mandated opening
|
|
assert "HONESTY GATE" in PERSONA # the gate itself is intact
|
|
|
|
|
|
def test_high_prompt_carries_relevance_marker_and_summary_block() -> None:
|
|
"""Phase 118 (LOCKED A6): the grounded turn seeds the document's
|
|
SUMMARY — the full content never reaches the prompt (the ``read``
|
|
tool is the only full-text path)."""
|
|
doc = _doc(
|
|
"kubernetes.md",
|
|
"Talos Linux on three nodes. FULL_CONTENT_SENTINEL_987654",
|
|
"Kubernetes Homelab Cluster",
|
|
summary="A Talos Linux cluster on three nodes.",
|
|
)
|
|
prompt = build_high_prompt([doc])
|
|
assert "<relevance>HIGH</relevance>" in prompt
|
|
assert "DEFLECT_MODE" not in prompt
|
|
assert "<documents>" in prompt and "</documents>" in prompt
|
|
assert 'path="kubernetes.md"' in prompt
|
|
assert "A Talos Linux cluster on three nodes." in prompt # the summary
|
|
assert "FULL_CONTENT_SENTINEL_987654" not in prompt # never the content
|
|
assert "Talos Linux on three nodes." not in prompt # nor the full sentence
|
|
assert "HONESTY GATE" in prompt # persona intact
|
|
|
|
|
|
def test_high_prompt_lists_multiple_documents_in_order() -> None:
|
|
a = _doc("a.md", "CONTENT_A", "Title A", summary="Summary A.")
|
|
b = _doc("b.md", "CONTENT_B", "Title B", summary="Summary B.")
|
|
prompt = build_high_prompt([a, b])
|
|
assert prompt.index("Summary A.") < prompt.index("Summary B.")
|
|
assert 'title="Title B"' in prompt
|
|
assert "CONTENT_A" not in prompt and "CONTENT_B" not in prompt
|
|
|
|
|
|
def test_high_prompt_seeds_summaries_of_all_suggested_docs() -> None:
|
|
"""The phase-118 completion pin: a grounded prompt built from five
|
|
summary-bearing documents contains ALL FIVE summaries + the intro,
|
|
and ZERO full-content characters (the full texts are not in the
|
|
prompt at all)."""
|
|
docs = [
|
|
_doc(
|
|
f"doc{i}.md",
|
|
f"FULL_CONTENT_SENTINEL_{i} " + "x" * 100,
|
|
f"Title {i}",
|
|
summary=f"SUMMARY_{i} of the document.",
|
|
)
|
|
for i in range(5)
|
|
]
|
|
prompt = build_high_prompt(docs)
|
|
assert SUGGEST_INTRO in prompt
|
|
for i in range(5):
|
|
assert f"SUMMARY_{i} of the document.\n" in prompt
|
|
assert f"FULL_CONTENT_SENTINEL_{i}" not in prompt
|
|
assert f'path="doc{i}.md"' in prompt
|
|
# One block per document, in the given order.
|
|
assert prompt.count("<document ") == 5
|
|
positions = [prompt.index(f"SUMMARY_{i}") for i in range(5)]
|
|
assert positions == sorted(positions)
|
|
# The intro leads the section: <documents> → intro → first block.
|
|
i_open = prompt.index("<documents>")
|
|
i_block = prompt.index("<document ")
|
|
assert prompt[i_open:i_block] == f"<documents>\n{SUGGEST_INTRO}\n\n"
|
|
|
|
|
|
def test_high_prompt_without_documents_stays_honest() -> None:
|
|
prompt = build_high_prompt([])
|
|
assert "<documents>" in prompt
|
|
assert "do not invent specifics" in prompt
|
|
|
|
|
|
def test_low_prompt_has_deflect_mode_and_titles_only() -> None:
|
|
titles = ["Kubernetes Homelab Cluster", "Backup Strategy"]
|
|
prompt = build_deflect_prompt(titles)
|
|
assert "<relevance>LOW</relevance>" in prompt
|
|
assert "DEFLECT_MODE" in prompt # marker the E2E mock keys on
|
|
assert "- Kubernetes Homelab Cluster" in prompt
|
|
assert "- Backup Strategy" in prompt
|
|
|
|
|
|
def test_low_prompt_never_contains_document_content() -> None:
|
|
secret = "SECRET_DOCUMENT_CONTENT_12345"
|
|
prompt = build_deflect_prompt(["Some Title"])
|
|
assert secret not in prompt
|
|
assert "<documents>" not in prompt
|
|
assert "HONESTY GATE" in prompt # the LOW rule is what the model must follow
|
|
|
|
|
|
def test_low_prompt_with_no_titles() -> None:
|
|
assert "nothing close at all" in build_deflect_prompt([])
|
|
|
|
|
|
def test_zero_note_prompt_is_byte_identical_to_pre_steering() -> None:
|
|
"""Phase 15 contract: with no steering notes the prompt is exactly what
|
|
it was before the <tuning> section existed. (Phase 37: the HIGH prompt
|
|
additionally carries the ``<tools>`` section after the mode body — the
|
|
fixtures account for it; phase 118: the ``<documents>`` section leads
|
|
with the ``SUGGEST_INTRO`` line — the fixture accounts for it; the
|
|
LOW prompt is untouched.)"""
|
|
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
|
|
block = (
|
|
'<document source="Homelab" path="kubernetes.md" '
|
|
'title="Kubernetes Homelab Cluster" date="2024-06-15">\n'
|
|
"Talos Linux on three nodes.\n"
|
|
"</document>"
|
|
)
|
|
assert build_high_prompt([doc]) == (
|
|
_base("HIGH")
|
|
+ "\n<documents>\n"
|
|
+ SUGGEST_INTRO
|
|
+ "\n\n"
|
|
+ block
|
|
+ "\n</documents>"
|
|
+ "\n"
|
|
+ TOOLS_SECTION
|
|
)
|
|
# Phase 71: the LOW prompt carries the owner-permitted plain-text
|
|
# line after the DEFLECT_MODE sentence (the marker-keying contract
|
|
# is unchanged — the E2E mock keys on the marker's presence).
|
|
assert build_deflect_prompt(["T1", "T2"]) == (
|
|
_base("LOW")
|
|
+ "\nDEFLECT_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"
|
|
"Reply in plain text only — you have no tools in this mode.\n"
|
|
+ "- T1\n- T2"
|
|
)
|
|
assert "<tuning>" not in build_high_prompt([doc])
|
|
assert "<tuning>" not in build_deflect_prompt([])
|
|
# Phase 70: the rewritten <tools> copy stays out of the LOW path —
|
|
# the byte-identical equality above already proves it; this names
|
|
# the contract (no <tools>, no new copy) on both empty/non-empty LOW
|
|
# builds.
|
|
for low in (build_deflect_prompt(["T1"]), build_deflect_prompt([])):
|
|
assert "<tools>" not in low
|
|
assert TOOLS_SECTION not in low
|
|
|
|
|
|
# ---------- <tools> section copy (phase 70: ls / read / grep) ----------
|
|
|
|
|
|
def test_tools_section_markers_and_new_tool_names() -> None:
|
|
"""Phase 70: the section keeps the ``<tools>``/``</tools>`` markers
|
|
the E2E mock keys on and teaches the harness-aligned tool names
|
|
(backticked, exactly as the ``AGENT_TOOLS`` schemas name them)."""
|
|
assert TOOLS_SECTION.startswith("<tools>\n")
|
|
assert TOOLS_SECTION.rstrip().endswith("</tools>")
|
|
for tool in ("`ls`", "`grep`", "`read`"):
|
|
assert tool in TOOLS_SECTION
|
|
|
|
|
|
def test_tools_section_teaches_the_harness_shapes() -> None:
|
|
"""Copy pins: ``ls``'s phase-63 catalog-line format, ``grep``'s
|
|
case-insensitive exact-string locator contract (up to 20
|
|
``source/path:line: text`` lines, a locator not a context-adder),
|
|
and ``read``'s combined ``source/path`` + full content. (Phase 72:
|
|
the source-name scope clause and the combined-identity clause are
|
|
pinned byte-for-byte in
|
|
:func:`test_tools_section_phase72_contract_clauses`.)"""
|
|
assert "source: X | path: Y | title: Z" in TOOLS_SECTION
|
|
assert "case-insensitive" in TOOLS_SECTION
|
|
assert "up to 20" in TOOLS_SECTION
|
|
assert "source/path:line: text" in TOOLS_SECTION
|
|
assert "locator, not a context-adder" in TOOLS_SECTION
|
|
assert "combined `source/path`" in TOOLS_SECTION
|
|
assert "full content" in TOOLS_SECTION
|
|
assert "Answer as soon as you have what you need" in TOOLS_SECTION
|
|
|
|
|
|
def test_tools_section_phase72_contract_clauses() -> None:
|
|
"""Phase 72 + phase 94: the contract clauses the teaching refusals
|
|
re-state after the fact, pinned byte-for-byte in the constant —
|
|
the ``ls`` clause (phase 94: the drill-down tree contract — one
|
|
level per call, sources at the top, folders + files below, never
|
|
the whole KB in one call, ``grep`` as the without-listing locator)
|
|
and the ``read``/``grep`` combined-identity clause (the combined
|
|
``source/path`` string exactly as shown in the ``ls`` output,
|
|
*including the source name*; a bare document path will not
|
|
resolve)."""
|
|
# The ls drill-down clauses (phase 94, task 03).
|
|
assert "one level at a time" in TOOLS_SECTION
|
|
assert "lists every synced source with its document count" in TOOLS_SECTION
|
|
assert "that source's top-level folders and files" in TOOLS_SECTION
|
|
assert "never the whole knowledge base in one call" in TOOLS_SECTION
|
|
assert "to find one specific document without listing, use `grep`" in TOOLS_SECTION
|
|
# The read combined-identity clause (byte-identical across phases).
|
|
assert (
|
|
"combined `source/path` string, exactly as shown in the `ls` "
|
|
"output — including the source name"
|
|
) in TOOLS_SECTION
|
|
# The bare-path note: read clause AND grep clause (exactly twice).
|
|
assert TOOLS_SECTION.count(
|
|
"a bare document path (without the source name) will not resolve"
|
|
) == 2
|
|
# The pre-phase-70 scope wording is gone — replaced by the
|
|
# explicit source-name contract (and the phase-72 source-name-only
|
|
# clause by the phase-94 drill-down contract).
|
|
assert "pass a source name as `path`" not in TOOLS_SECTION
|
|
assert "not a directory or file path" not in TOOLS_SECTION
|
|
|
|
|
|
def test_tools_section_phase95_read_truncation_clause() -> None:
|
|
"""Phase 95 (task 01): the ``read`` teaching gains exactly one line —
|
|
very large documents are capped, a cut read returns the first part
|
|
plus the TRUNCATED notice (the document did not end where it
|
|
stopped), and ``grep`` is the follow-up (it searches the whole
|
|
document). The ``ls``/``grep`` teaching is untouched (phase 94 owns
|
|
``ls``) — the clause is pinned byte-for-byte in the constant."""
|
|
assert (
|
|
"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."
|
|
) in TOOLS_SECTION
|
|
# It rides the HIGH prompt and never the LOW (deflected) prompt.
|
|
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes")
|
|
assert "Very large documents are capped" in build_high_prompt([doc])
|
|
|
|
|
|
def test_tools_section_phase118_summary_seed_read_clause() -> None:
|
|
"""Phase 118 (task 04, A6): the ``read`` clause is rewritten for
|
|
the summary-seed mode — the ``<documents>`` section holds
|
|
SUMMARIES (a suggested document's full text is not in the prompt
|
|
until ``read`` adds it); do not re-read an already-read document
|
|
(answer from the text already in the prompt); if the user asks to
|
|
open or read a suggested document, ``read`` it. The phase-72 "do
|
|
not call ``read`` for a ``<documents>`` document" copy is retired.
|
|
The ``ls`` clause (phase 94 drill-down contract), the ``grep``
|
|
clause, and the discipline rules stay byte-identical — the
|
|
prompt lock's prefix/suffix anchors survive (see
|
|
``test_prompt_lock``)."""
|
|
# The new summary-seed copy (pinned byte-for-byte).
|
|
assert (
|
|
"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 — its full "
|
|
"text is already in your prompt; answer directly from it. If "
|
|
"the user asks you to open or read a suggested document, "
|
|
"`read` it — that is the point of the section."
|
|
) in TOOLS_SECTION
|
|
# The read identity handoff now also names the <documents> summary
|
|
# blocks (the combined identity is shown there), keeping the
|
|
# "including the source name" contract.
|
|
assert (
|
|
"exactly as shown in the `ls` output — including the source "
|
|
"name — or in the <documents> summary blocks — adding its "
|
|
"full content to your context"
|
|
) in TOOLS_SECTION
|
|
# The retired pre-phase-118 copy is gone.
|
|
assert "Do not call `read` for a document already shown in" not in TOOLS_SECTION
|
|
assert "even when the user asks you to open or read it" not in TOOLS_SECTION
|
|
# The ls clause (phase 94 drill-down contract) — byte-identical.
|
|
assert (
|
|
"`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`."
|
|
) in TOOLS_SECTION
|
|
# The grep clause — byte-identical.
|
|
assert (
|
|
"`grep` locates an exact string (case-insensitive) 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. 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."
|
|
) in TOOLS_SECTION
|
|
# The discipline rules — byte-identical.
|
|
assert (
|
|
"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."
|
|
) in TOOLS_SECTION
|
|
# The new read clause rides the built HIGH prompt and never the
|
|
# LOW (deflected) prompt.
|
|
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes", summary="K8S")
|
|
high = build_high_prompt([doc])
|
|
assert "The <documents> section holds SUMMARIES" in high
|
|
assert "`read` it — that is the point of the section." in high
|
|
low = build_deflect_prompt(["T1"])
|
|
assert "The <documents> section holds SUMMARIES" not in low
|
|
|
|
|
|
def test_tools_section_phase72_clauses_in_high_prompt_not_low() -> None:
|
|
"""Phase 72/94: the contract clauses ride the HIGH prompt with the
|
|
rest of the section and never leak into the LOW/deflection prompt
|
|
(whose byte-identity is pinned in
|
|
:func:`test_zero_note_prompt_is_byte_identical_to_pre_steering`)."""
|
|
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
|
|
high = build_high_prompt([doc])
|
|
assert "<tools>" in high
|
|
assert "one level at a time" in high # the phase-94 ls clause
|
|
assert "including the source name" in high
|
|
for low in (build_deflect_prompt(["T1"]), build_deflect_prompt([])):
|
|
assert "<tools>" not in low
|
|
assert "one level at a time" not in low
|
|
assert "including the source name" not in low
|
|
|
|
|
|
def test_documents_section_leads_with_the_suggest_intro() -> None:
|
|
"""Phase 118, task 03: the ``<documents>`` section leads with the
|
|
start-here :data:`SUGGEST_INTRO` line BEFORE the first block (the
|
|
phase-15 ``_STEERING_INTRO`` / phase-31 ``_KB_INTRO`` precedent) —
|
|
only when at least one block is present. This is the summary-as-
|
|
starting-point framing, not the reverted phase-72 do-not-read
|
|
reminder (that copy taught the seed texts as already-read context;
|
|
with A6's summary seeding the blocks are starting points the model
|
|
may ``read`` through to full text)."""
|
|
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
|
|
high = build_high_prompt([doc])
|
|
i_open = high.index("<documents>")
|
|
i_block = high.index('<document source="Homelab"')
|
|
assert high[i_open : i_block] == f"<documents>\n{SUGGEST_INTRO}\n\n"
|
|
# The intro occurs exactly once and sits inside the section.
|
|
assert high.count(SUGGEST_INTRO) == 1
|
|
assert high.index(SUGGEST_INTRO) > high.index("<documents>")
|
|
assert high.index(SUGGEST_INTRO) < high.index("</documents>")
|
|
|
|
|
|
def test_documents_section_without_blocks_has_no_intro() -> None:
|
|
"""The intro rides only on present blocks: an empty ``<documents>``
|
|
section is exactly the fallback line again (no intro, no blocks)."""
|
|
high = build_high_prompt([])
|
|
assert SUGGEST_INTRO not in high
|
|
i_open = high.index("<documents>")
|
|
i_close = high.index("</documents>")
|
|
assert (
|
|
high[i_open : i_close]
|
|
== "<documents>\n(no documents matched — do not invent specifics)\n"
|
|
)
|
|
|
|
|
|
# ---------- phase 119 (D3, LOCKED A4): the suggested-folder lines ----------
|
|
|
|
|
|
def test_high_prompt_folder_lines_after_intro_before_first_block() -> None:
|
|
"""The folder lines ride the ``<documents>`` section immediately
|
|
AFTER the ``SUGGEST_INTRO`` line — each on its own line — then a
|
|
blank line, then the first ``<document>`` block. Plain lines: no
|
|
new markup/tag anywhere (the E2E mock keys off the ``<documents>``
|
|
marker and the LAST block's tail)."""
|
|
doc = _doc(
|
|
"deploy/Deployments/reeseapps/gitea/app/gitea-web.env.j2",
|
|
"FULL_CONTENT_SENTINEL_119",
|
|
"Gitea Web Env",
|
|
summary="Gitea web env file.",
|
|
)
|
|
lines = [
|
|
"Homelab/deploy/Deployments/reeseapps/gitea/: app/ (5 docs), README.md",
|
|
"Homelab/deploy/Deployments/reeseapps/: gateway/ (3 docs), gitea/ (8 docs)",
|
|
]
|
|
prompt = build_high_prompt([doc], folder_lines=lines)
|
|
i_open = prompt.index("<documents>")
|
|
i_block = prompt.index("<document ")
|
|
assert prompt[i_open:i_block] == f"<documents>\n{SUGGEST_INTRO}\n{lines[0]}\n{lines[1]}\n\n"
|
|
# Each line occurs exactly once, inside the section, before the
|
|
# first block (never after a summary — the E2E tail echo is safe).
|
|
for line in lines:
|
|
assert prompt.count(line) == 1
|
|
assert prompt.index("<documents>") < prompt.index(line) < prompt.index("<document ")
|
|
assert prompt.index(line) < prompt.index("</documents>")
|
|
# The <document> block markup AND body stay byte-identical: from the
|
|
# first block on, the prompt equals the no-folder-line build.
|
|
plain = build_high_prompt([doc])
|
|
assert prompt[prompt.index("<document ") :] == plain[plain.index("<document ") :]
|
|
# No new markup: the section still opens/closes exactly once (the
|
|
# ``<documents>`` MENTION in the TOOLS_SECTION copy is plain text —
|
|
# count the tag + newline, not the bare substring).
|
|
assert prompt.count("<documents>\n") == 1
|
|
assert prompt.count("</documents>") == 1
|
|
assert prompt.count("<document ") == 1
|
|
|
|
|
|
def test_high_prompt_single_folder_line_exact_shape() -> None:
|
|
"""One folder line: the exact slice between the section open and the
|
|
first block is ``<documents>\n`` + intro + ``\n`` + line +
|
|
``\n\n``."""
|
|
doc = _doc("a.md", "CONTENT", "Title A", summary="Summary A.")
|
|
line = "Homelab/: a.md (2 docs), b/ (1 doc)"
|
|
prompt = build_high_prompt([doc], folder_lines=[line])
|
|
i_open = prompt.index("<documents>")
|
|
i_block = prompt.index("<document ")
|
|
assert prompt[i_open:i_block] == f"<documents>\n{SUGGEST_INTRO}\n{line}\n\n"
|
|
|
|
|
|
def test_high_prompt_empty_folder_lines_byte_identical_to_phase_118() -> None:
|
|
"""LOCKED A4: empty ``folder_lines`` (the default) ⇒ the phase-118
|
|
build, byte-identical — the existing pins keep passing and this
|
|
pins the default explicitly (both an explicit ``()`` and the
|
|
omitted parameter)."""
|
|
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
|
|
block = (
|
|
'<document source="Homelab" path="kubernetes.md" '
|
|
'title="Kubernetes Homelab Cluster" date="2024-06-15">\n'
|
|
"Talos Linux on three nodes.\n"
|
|
"</document>"
|
|
)
|
|
expected = (
|
|
_base("HIGH")
|
|
+ "\n<documents>\n"
|
|
+ SUGGEST_INTRO
|
|
+ "\n\n"
|
|
+ block
|
|
+ "\n</documents>"
|
|
+ "\n"
|
|
+ TOOLS_SECTION
|
|
)
|
|
# An explicit empty tuple AND the omitted parameter (the default):
|
|
assert build_high_prompt([doc], folder_lines=()) == expected
|
|
assert build_high_prompt([doc]) == expected
|
|
# The LOW (deflected) prompt has no folder_lines parameter at all —
|
|
# the deflected build stays byte-identical (the phase-118 pin in
|
|
# test_zero_note_prompt_is_byte_identical_to_pre_steering stands).
|
|
|
|
|
|
def test_folder_lines_ride_only_on_present_blocks() -> None:
|
|
"""Like the intro, the folder lines ride on present blocks ONLY: an
|
|
empty ``<documents>`` section is unchanged (no lines, no blocks)."""
|
|
prompt = build_high_prompt([], folder_lines=["X/: y.md"])
|
|
assert prompt == build_high_prompt([])
|
|
assert "X/: y.md" not in prompt
|
|
|
|
|
|
# ---------- phase 119 (D4, LOCKED A5): the cite-discipline sentence ----------
|
|
|
|
#: The LOCKED A5 sentence (phase 119, task 04) — the exact replacement
|
|
#: for the retired phase-118 final sentence of :data:`SUGGEST_INTRO`
|
|
#: ("Cite the document(s) you used, by path."). Closes the live
|
|
#: confabulation: the answer's "Docs used:" line cited a file the
|
|
#: agent never read.
|
|
CITE_DISCIPLINE = (
|
|
"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."
|
|
)
|
|
|
|
|
|
def test_high_prompt_carries_cite_discipline_exactly_once_in_documents() -> None:
|
|
"""LOCKED A5: the HIGH prompt carries the discipline sentence
|
|
EXACTLY ONCE — it IS the intro's final sentence, so it sits inside
|
|
``<documents>``, AFTER the intro's start-here framing and BEFORE
|
|
the first block (both the plain build and the folder-line build;
|
|
the sentence rides the intro line, which never moves)."""
|
|
doc = _doc(
|
|
"kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster", summary="K8S"
|
|
)
|
|
assert CITE_DISCIPLINE in SUGGEST_INTRO
|
|
assert SUGGEST_INTRO.endswith(CITE_DISCIPLINE) # it is the final sentence
|
|
for high in (build_high_prompt([doc]), build_high_prompt([doc], folder_lines=["X/: y.md"])):
|
|
assert high.count(CITE_DISCIPLINE) == 1
|
|
i_docs = high.index("<documents>")
|
|
i_sentence = high.index(CITE_DISCIPLINE)
|
|
i_block = high.index("<document ")
|
|
i_close = high.index("</documents>")
|
|
assert i_docs < i_sentence < i_close
|
|
assert i_sentence > high.index("start here if one seems right")
|
|
assert i_sentence < i_block # before the first block, never after a summary
|
|
# The retired phase-118 sentence is gone from the prompt (it is
|
|
# gone from the constant — pinned in test_prompt_lock too).
|
|
high = build_high_prompt([doc])
|
|
assert "Cite the document(s) you used, by path." not in high
|
|
# The sentence rides on present blocks ONLY: an empty ``<documents>``
|
|
# section (no intro) carries none of it.
|
|
assert CITE_DISCIPLINE not in build_high_prompt([])
|
|
|
|
|
|
def test_cite_discipline_absent_from_low_prompt() -> None:
|
|
"""LOCKED A5: the discipline sentence belongs to ``SUGGEST_INTRO``
|
|
(the HIGH path) — it never leaks into the LOW (deflected) prompt,
|
|
whose byte-identity is pinned separately (the LOW anchors in
|
|
``test_prompt_lock`` pass unchanged)."""
|
|
for prompt in (
|
|
build_deflect_prompt(["T1", "T2"]),
|
|
build_deflect_prompt(["T1"], notes=["be concise"], kb_overview=OVERVIEW),
|
|
build_deflect_prompt([]),
|
|
):
|
|
assert CITE_DISCIPLINE not in prompt
|
|
assert "Cite only the document(s) you read" not in prompt
|
|
assert "Cite the document(s) you used, by path." not in prompt
|
|
|
|
|
|
def test_tools_section_old_names_and_budget_copy_gone() -> None:
|
|
"""The phase-37/68 tool names and the phase-37 per-tool budget line
|
|
(phase 45: the round cap is the bound — the prompt does not
|
|
re-state it) are out of the copy."""
|
|
for old in ("list_documents", "read_document", "search_documents"):
|
|
assert old not in TOOLS_SECTION
|
|
assert "more than one" not in TOOLS_SECTION
|
|
assert "extra document" not in TOOLS_SECTION
|
|
|
|
|
|
def test_high_prompt_still_ends_with_tools_section() -> None:
|
|
"""Mock keying intact: the HIGH prompt still ends with the
|
|
``<tools>`` section after ``</documents>``, now in the phase-70
|
|
copy — new names in, old names out."""
|
|
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
|
|
prompt = build_high_prompt([doc])
|
|
assert TOOLS_SECTION in prompt
|
|
assert prompt.index("</documents>") < prompt.index("<tools>")
|
|
assert prompt.rstrip().endswith("</tools>")
|
|
for tool in ("`ls`", "`grep`", "`read`"):
|
|
assert tool in prompt
|
|
for old in ("list_documents", "read_document", "search_documents"):
|
|
assert old not in prompt
|
|
|
|
|
|
# ---------- phase 118 (task 03): the NULL-summary preview fallback (A5) ----------
|
|
|
|
|
|
def _content_only_doc(content: str) -> Document:
|
|
return _doc("kubernetes.md", content, "Kubernetes Homelab Cluster")
|
|
|
|
|
|
def test_null_summary_falls_back_to_preview_plus_marker() -> None:
|
|
"""A doc whose ``summary`` is None (a fail-soft import miss) seeds the
|
|
first ``suggestion_preview_chars`` (default 400) content characters +
|
|
the shared ``[…truncated…]`` marker on its own line — never the full
|
|
content, never an LLM call."""
|
|
content = "A" * 400 + "B" * 600 # 1000 chars
|
|
prompt = build_high_prompt([_content_only_doc(content)])
|
|
body_start = prompt.index('date="2024-06-15">\n') + len('date="2024-06-15">\n')
|
|
body_end = prompt.index("\n</document>", body_start)
|
|
assert prompt[body_start:body_end] == content[:400] + "\n" + TRUNCATION_MARKER
|
|
assert "B" * 40 not in prompt # beyond the 400-char cut
|
|
assert prompt.count(TRUNCATION_MARKER) == 1 # only the block's marker
|
|
|
|
|
|
def test_whitespace_summary_uses_the_same_preview_fallback() -> None:
|
|
for blank in ("", " ", "\n \t "):
|
|
doc = _doc("kubernetes.md", "A" * 500 + "B" * 500, "T", summary=blank)
|
|
prompt = build_high_prompt([doc])
|
|
assert "A" * 400 in prompt
|
|
assert "B" * 40 not in prompt
|
|
assert TRUNCATION_MARKER in prompt
|
|
|
|
|
|
def test_short_content_preview_is_the_whole_content_unmarked() -> None:
|
|
"""Content at or under the cap rides whole — nothing was cut, so no
|
|
marker (the marker signals truncation, not the fallback)."""
|
|
for content in ("short body", "C" * 399, "D" * 400):
|
|
prompt = build_high_prompt([_content_only_doc(content)])
|
|
assert content + "\n</document>" in prompt
|
|
assert TRUNCATION_MARKER not in prompt
|
|
|
|
|
|
def test_summary_stripped_and_never_truncated_by_the_preview_cap() -> None:
|
|
"""The summary body is the stripped ``doc.summary`` — even when it is
|
|
longer than the preview cap (the cap bounds only the FALLBACK; a
|
|
stored summary is trusted context, LOCKED A5)."""
|
|
summary = "S" * 900
|
|
content = "CONTENT_SENTINEL " + "x" * 50
|
|
prompt = build_high_prompt([_doc("kubernetes.md", content, "T", summary=f" {summary}\n")])
|
|
assert summary in prompt
|
|
assert content not in prompt
|
|
assert TRUNCATION_MARKER not in prompt
|
|
|
|
|
|
def test_preview_cap_setting_is_honored_on_the_fallback_path(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A non-default ``suggestion_preview_chars`` (``BOR_SUGGESTION_PREVIEW_CHARS``
|
|
— the env mapping is pinned in :mod:`tests.unit.test_config`) cuts the
|
|
preview at the setting's cap on the fallback path."""
|
|
from app.rag import prompts as prompts_mod
|
|
|
|
monkeypatch.setattr(
|
|
prompts_mod,
|
|
"get_settings",
|
|
lambda: Settings(_env_file=None, suggestion_preview_chars=10), # pyright: ignore[reportCallIssue]
|
|
)
|
|
content = "A" * 100 + "B" * 100
|
|
prompt = build_high_prompt([_content_only_doc(content)])
|
|
assert "A" * 10 in prompt
|
|
assert "A" * 11 not in prompt
|
|
assert TRUNCATION_MARKER in prompt
|
|
|
|
|
|
def test_preview_fallback_never_reads_settings_for_summarized_docs(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""House pattern (``build_steering_section`` / ``build_kb_section``):
|
|
the fallback cap is read from settings on the fallback path ONLY — a
|
|
prompt built from summary-bearing docs makes no settings call for
|
|
it (a dead ``get_settings`` must not break such a build)."""
|
|
from app.rag import prompts as prompts_mod
|
|
|
|
doc = _doc("kubernetes.md", "CONTENT_SENTINEL", "T", summary="the summary")
|
|
|
|
def _dead() -> None: # pragma: no cover - must never be called
|
|
raise AssertionError("get_settings() called for a summarized doc")
|
|
|
|
monkeypatch.setattr(prompts_mod, "get_settings", _dead)
|
|
prompt = build_high_prompt([doc])
|
|
assert "the summary\n</document>" in prompt
|
|
assert "CONTENT_SENTINEL" not in prompt
|
|
|
|
|
|
# ---------- phase 118: the LOW prompt stays byte-identical ----------
|
|
|
|
|
|
def test_low_prompt_byte_identical_to_pre_task() -> None:
|
|
"""LOCKED A8 surface: the deflection prompt is untouched by the
|
|
summary seeding — byte-identical build on the same inputs (the full
|
|
sha pin lives in :mod:`tests.unit.test_prompt_lock`)."""
|
|
for titles, notes, kb in (
|
|
(["T1", "T2"], None, None),
|
|
(["T1"], ["be concise"], None),
|
|
([], None, OVERVIEW),
|
|
(["T1", "T2"], ["be concise"], OVERVIEW),
|
|
):
|
|
prompt = build_deflect_prompt(titles, notes=notes, kb_overview=kb)
|
|
assert "DEFLECT_MODE" in prompt
|
|
assert "<documents>" not in prompt
|
|
assert SUGGEST_INTRO not in prompt
|
|
assert "<tools>" not in prompt
|
|
|
|
|
|
def test_relevance_placeholder_rejected_for_garbage() -> None:
|
|
with pytest.raises(ValueError, match="HIGH or LOW"):
|
|
_base("MEDIUM")
|
|
|
|
|
|
# ---------- <knowledge_base> section (phase 31) ----------
|
|
|
|
|
|
def test_kb_section_empty_when_no_overview() -> None:
|
|
assert build_kb_section("") == ""
|
|
assert build_kb_section(" \n\t ") == ""
|
|
|
|
|
|
def test_kb_section_format_intro_and_content() -> None:
|
|
assert build_kb_section(OVERVIEW) == (
|
|
f"<knowledge_base>\n{KB_INTRO}\n{OVERVIEW}\n</knowledge_base>"
|
|
)
|
|
|
|
|
|
def test_kb_section_trims_overview_edges() -> None:
|
|
assert build_kb_section(f" {OVERVIEW} \n") == build_kb_section(OVERVIEW)
|
|
|
|
|
|
def test_kb_section_fits_budget_exactly_no_marker() -> None:
|
|
exact = f"<knowledge_base>\n{KB_INTRO}\n{OVERVIEW}\n</knowledge_base>"
|
|
section = build_kb_section(OVERVIEW, max_chars=len(exact))
|
|
assert TRUNCATION_MARKER not in section
|
|
assert section == exact
|
|
|
|
|
|
def test_kb_section_over_budget_capped_with_marker() -> None:
|
|
text = "- " + "x" * 500
|
|
# The section frame alone is 121 chars, so the cap must clear it for
|
|
# any outline prefix to fit (pathological budgets are tested below).
|
|
cap = 200
|
|
section = build_kb_section(text, max_chars=cap)
|
|
assert len(section) <= cap # the budget is never exceeded
|
|
assert TRUNCATION_MARKER in section
|
|
assert section.startswith(f"<knowledge_base>\n{KB_INTRO}\n-")
|
|
assert section.endswith(f"{TRUNCATION_MARKER}\n</knowledge_base>")
|
|
# The body is the kept prefix + the marker on its own line, and the
|
|
# kept part must be a true prefix of the outline (longest-fitting).
|
|
body = section.removeprefix(f"<knowledge_base>\n{KB_INTRO}\n").removesuffix(
|
|
"\n</knowledge_base>"
|
|
)
|
|
kept, marker = body.rsplit("\n", 1)
|
|
assert marker == TRUNCATION_MARKER
|
|
assert kept.startswith("- ")
|
|
assert text.startswith(kept), "the kept part must be a prefix of the outline"
|
|
# And it is the longest such prefix: one more char would not fit.
|
|
assert len(section) > cap - 2, "the cut must sit as close to the cap as possible"
|
|
|
|
|
|
def test_kb_section_default_budget_from_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from app.rag import prompts as prompts_mod
|
|
|
|
monkeypatch.setattr(
|
|
prompts_mod, "get_settings", lambda: Settings(_env_file=None) # pyright: ignore[reportCallIssue]
|
|
)
|
|
text = "y" * 9_000 # > the 4 000-char default
|
|
section = build_kb_section(text)
|
|
assert TRUNCATION_MARKER in section
|
|
assert len(section) <= 4_000
|
|
|
|
|
|
def test_kb_section_nonpositive_budget_is_empty() -> None:
|
|
assert build_kb_section(OVERVIEW, max_chars=0) == ""
|
|
assert build_kb_section(OVERVIEW, max_chars=-10) == ""
|
|
|
|
|
|
def test_kb_section_tiny_budget_never_exceeds_cap() -> None:
|
|
# Pathological budget (steering precedent, phase 15): the section must
|
|
# never exceed the cap — bare marker when it fits, no section at all
|
|
# when even that doesn't.
|
|
assert build_kb_section("a" * 500, max_chars=10) == "" # marker (13) > 10
|
|
fits_marker = build_kb_section("a" * 500, max_chars=len(TRUNCATION_MARKER))
|
|
assert fits_marker == TRUNCATION_MARKER
|
|
|
|
|
|
# ---------- <knowledge_base> placement (both modes) ----------
|
|
|
|
|
|
def test_no_overview_prompt_is_byte_identical_to_pre_phase() -> None:
|
|
"""Phase 31 contract: with no KB overview (None, empty, or blank)
|
|
every prompt is exactly what it was before the ``<knowledge_base>``
|
|
section existed — with or without steering notes. (Phase 37: the HIGH
|
|
prompt additionally carries the ``<tools>`` section after the mode
|
|
body; phase 118: the ``<documents>`` section leads with the
|
|
``SUGGEST_INTRO`` line — the fixtures account for both; the LOW
|
|
prompt is untouched.)"""
|
|
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
|
|
block = (
|
|
'<document source="Homelab" path="kubernetes.md" '
|
|
'title="Kubernetes Homelab Cluster" date="2024-06-15">\n'
|
|
"Talos Linux on three nodes.\n"
|
|
"</document>"
|
|
)
|
|
docs_block = (
|
|
"\n<documents>\n"
|
|
+ SUGGEST_INTRO
|
|
+ "\n\n"
|
|
+ block
|
|
+ "\n</documents>"
|
|
+ "\n"
|
|
+ TOOLS_SECTION
|
|
)
|
|
high_plain = _base("HIGH") + docs_block
|
|
high_steered = _base("HIGH") + "\n" + build_steering_section(["be concise"]) + docs_block
|
|
# Phase 71: the owner-permitted plain-text line is part of the
|
|
# DEFLECT_MODE body in every LOW build (with or without steering).
|
|
low_plain = (
|
|
_base("LOW")
|
|
+ "\nDEFLECT_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"
|
|
"Reply in plain text only — you have no tools in this mode.\n"
|
|
+ "- T1\n- T2"
|
|
)
|
|
low_steered = (
|
|
_base("LOW")
|
|
+ "\n"
|
|
+ build_steering_section(["be concise"])
|
|
+ "\nDEFLECT_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"
|
|
"Reply in plain text only — you have no tools in this mode.\n"
|
|
+ "- T1\n- T2"
|
|
)
|
|
for kb in (None, "", " \n\t "):
|
|
assert build_high_prompt([doc], kb_overview=kb) == high_plain
|
|
assert build_high_prompt([doc], notes=["be concise"], kb_overview=kb) == high_steered
|
|
assert build_deflect_prompt(["T1", "T2"], kb_overview=kb) == low_plain
|
|
assert build_deflect_prompt(
|
|
["T1", "T2"], notes=["be concise"], kb_overview=kb
|
|
) == low_steered
|
|
assert "<knowledge_base>" not in build_high_prompt(
|
|
[doc], notes=["be concise"], kb_overview=kb
|
|
)
|
|
assert "<knowledge_base>" not in build_deflect_prompt(
|
|
["T1"], notes=["be concise"], kb_overview=kb
|
|
)
|
|
|
|
|
|
def test_high_prompt_kb_section_ordered_between_relevance_and_tuning() -> None:
|
|
doc = _doc("kubernetes.md", "TALOS_DOC_CONTENT", "Kubernetes Homelab Cluster")
|
|
prompt = build_high_prompt([doc], notes=["be concise"], kb_overview=OVERVIEW)
|
|
i_rel = prompt.index("<relevance>HIGH</relevance>")
|
|
i_kb_open = prompt.index("<knowledge_base>")
|
|
i_kb_close = prompt.index("</knowledge_base>")
|
|
i_tuning = prompt.index("<tuning>")
|
|
i_docs = prompt.index("<documents>")
|
|
assert i_rel < i_kb_open < i_kb_close < i_tuning < i_docs
|
|
assert KB_INTRO in prompt
|
|
assert OVERVIEW in prompt # outline intact within the section
|
|
assert "1. be concise" in prompt # steering still there
|
|
assert "TALOS_DOC_CONTENT" in prompt # documents still full
|
|
|
|
|
|
def test_high_prompt_kb_section_without_steering() -> None:
|
|
doc = _doc("kubernetes.md", "TALOS_DOC_CONTENT", "Kubernetes Homelab Cluster")
|
|
prompt = build_high_prompt([doc], kb_overview=OVERVIEW)
|
|
i_rel = prompt.index("<relevance>HIGH</relevance>")
|
|
i_kb_close = prompt.index("</knowledge_base>")
|
|
i_docs = prompt.index("<documents>")
|
|
assert i_rel < i_kb_close < i_docs
|
|
assert "<tuning>" not in prompt # no notes → no steering section
|
|
assert build_kb_section(OVERVIEW) in prompt
|
|
|
|
|
|
def test_deflect_prompt_kb_section_ordered_between_relevance_and_tuning() -> None:
|
|
prompt = build_deflect_prompt(
|
|
["Title A", "Title B"], notes=["be concise"], kb_overview=OVERVIEW
|
|
)
|
|
i_rel = prompt.index("<relevance>LOW</relevance>")
|
|
i_kb_open = prompt.index("<knowledge_base>")
|
|
i_kb_close = prompt.index("</knowledge_base>")
|
|
i_tuning = prompt.index("<tuning>")
|
|
i_mode = prompt.index("DEFLECT_MODE")
|
|
assert i_rel < i_kb_open < i_kb_close < i_tuning < i_mode
|
|
assert KB_INTRO in prompt
|
|
assert OVERVIEW in prompt
|
|
assert "1. be concise" in prompt
|
|
assert "- Title A" in prompt # weak-hit titles still carried
|
|
|
|
|
|
def test_prompt_kb_section_over_settings_budget_capped_with_marker(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Prompt-level budget: an overview longer than
|
|
``kb_overview_max_chars`` is capped with the shared marker — in both
|
|
modes."""
|
|
from app.rag import prompts as prompts_mod
|
|
|
|
monkeypatch.setattr(
|
|
prompts_mod,
|
|
"get_settings",
|
|
# 200 > the 121-char section frame, so a prefix + marker can fit.
|
|
lambda: Settings(_env_file=None, kb_overview_max_chars=200), # pyright: ignore[reportCallIssue]
|
|
)
|
|
text = "- " + "z" * 500
|
|
doc = _doc("kubernetes.md", "TALOS_DOC_CONTENT", "Kubernetes Homelab Cluster")
|
|
for prompt in (
|
|
build_high_prompt([doc], kb_overview=text),
|
|
build_deflect_prompt(["Title A"], kb_overview=text),
|
|
):
|
|
assert TRUNCATION_MARKER in prompt
|
|
assert prompt.index("<knowledge_base>") < prompt.index(TRUNCATION_MARKER)
|
|
# The capped section (open tag through close tag) fits the budget.
|
|
section = prompt[prompt.index("<knowledge_base>") :]
|
|
close = section.index("</knowledge_base>")
|
|
section = section[: close + len("</knowledge_base>")]
|
|
assert len(section) <= 200
|
|
|
|
|
|
# ---------- phase 71: the deflection plain-text line (prevention) ----------
|
|
|
|
#: The owner-permitted (2026-09-03) line appended to the ``DEFLECT_MODE``
|
|
#: body — the LOW prompt's only phase-71 change. The E2E mock keys on
|
|
#: the ``DEFLECT_MODE`` marker's *presence*, not the wording, so the
|
|
#: marker-keying contract is unchanged by the appended line.
|
|
PLAIN_TEXT_ONLY_LINE = "Reply in plain text only — you have no tools in this mode."
|
|
|
|
|
|
def _pre_phase71_low_body() -> str:
|
|
"""The ``DEFLECT_MODE`` body exactly as it was before phase 71."""
|
|
return (
|
|
"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"
|
|
)
|
|
|
|
|
|
def test_low_prompt_is_pre_phase_plus_exactly_the_plain_text_line() -> None:
|
|
"""Diff pin: the LOW prompt = pre-phase text + exactly the one new
|
|
line, appended to the ``DEFLECT_MODE`` body; the weak-hit title list
|
|
follows exactly as before (and the line occurs exactly once)."""
|
|
prompt = build_deflect_prompt(["T1", "T2"])
|
|
assert prompt == (
|
|
_base("LOW")
|
|
+ "\n"
|
|
+ _pre_phase71_low_body()
|
|
+ PLAIN_TEXT_ONLY_LINE
|
|
+ "\n"
|
|
+ "- T1\n- T2"
|
|
)
|
|
assert prompt.count(PLAIN_TEXT_ONLY_LINE) == 1
|
|
assert prompt.endswith("- T1\n- T2") # the title list is untouched
|
|
|
|
|
|
def test_low_prompt_carries_the_line_and_keeps_the_mock_marker() -> None:
|
|
"""The new line is present in the LOW prompt (inside the
|
|
``DEFLECT_MODE`` body, after the marker) and the ``DEFLECT_MODE``
|
|
marker the E2E mock keys on stays put."""
|
|
for titles, tail in ((["T1"], "- T1"), ([], "(nothing close at all)")):
|
|
prompt = build_deflect_prompt(titles)
|
|
assert "DEFLECT_MODE" in prompt
|
|
assert PLAIN_TEXT_ONLY_LINE in prompt
|
|
assert prompt.index("DEFLECT_MODE") < prompt.index(PLAIN_TEXT_ONLY_LINE)
|
|
# The title list (or the no-titles fallback) follows the line
|
|
# exactly as before.
|
|
assert prompt.endswith(tail)
|
|
|
|
|
|
def test_plain_text_line_never_leaks_into_high_prompt() -> None:
|
|
"""The line is the LOW prompt's: every HIGH build (with/without
|
|
steering/overview) is unchanged and carries none of it."""
|
|
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
|
|
for notes, kb in (
|
|
(None, None),
|
|
(["be concise"], None),
|
|
(None, OVERVIEW),
|
|
(["be concise"], OVERVIEW),
|
|
):
|
|
high = build_high_prompt([doc], notes=notes, kb_overview=kb)
|
|
assert PLAIN_TEXT_ONLY_LINE not in high
|
|
assert "you have no tools" not in high
|