"""Unit: locked persona prompt builder (PLAN §6 verbatim + both modes). Also covers the phase-31 ```` section (the stored, lite-generated KB outline): its own builder contract (empty → ``""``, char budget + ``[…truncated…]`` marker, pathological budgets), its placement between ```` and ```` 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 ```` 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): the teaching refusals in :mod:`app.rag.agent` re-state the same contract; the ```` marker keying (HIGH only) is unchanged. """ 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, 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 ```` 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 #: ```` 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) -> 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, ) 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 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_full_documents() -> None: doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster") prompt = build_high_prompt([doc]) assert "HIGH" in prompt assert "DEFLECT_MODE" not in prompt assert "" in prompt and "" in prompt assert 'path="kubernetes.md"' in prompt assert "Talos Linux on three nodes." in prompt 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") b = _doc("b.md", "CONTENT_B", "Title B") prompt = build_high_prompt([a, b]) assert prompt.index("CONTENT_A") < prompt.index("CONTENT_B") assert 'title="Title B"' in prompt def test_high_prompt_without_documents_stays_honest() -> None: prompt = build_high_prompt([]) assert "" 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 "LOW" 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 "" 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 section existed. (Phase 37: the HIGH prompt additionally carries the ```` section after the mode body — the fixtures account for it; the LOW prompt is untouched.)""" doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster") block = ( '\n' "Talos Linux on three nodes.\n" "" ) assert build_high_prompt([doc]) == ( _base("HIGH") + "\n\n" + block + "\n" + "\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 "" not in build_high_prompt([doc]) assert "" not in build_deflect_prompt([]) # Phase 70: the rewritten copy stays out of the LOW path — # the byte-identical equality above already proves it; this names # the contract (no , no new copy) on both empty/non-empty LOW # builds. for low in (build_deflect_prompt(["T1"]), build_deflect_prompt([])): assert "" not in low assert TOOLS_SECTION not in low # ---------- section copy (phase 70: ls / read / grep) ---------- def test_tools_section_markers_and_new_tool_names() -> None: """Phase 70: the section keeps the ````/```` 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("\n") assert TOOLS_SECTION.rstrip().endswith("") 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_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 "" 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 "" not in low assert "one level at a time" not in low assert "including the source name" not in low def test_documents_section_has_no_leading_intro() -> None: """Phase 72, task 05 (gate iterations 2-3, reverted): the ```` section must NOT lead with an in-context reminder or name the ```` blocks — the live telemetry showed that copy primed the model to latch the seed documents' paths as ``ls`` scopes (the incident turn regressed to a cap-reached loop on run 2 and re-trapped on run 5), and the reminder never flipped the seed-doc ``read``s (15/15 across gate runs 1-5). The section is exactly the document blocks again.""" doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster") high = build_high_prompt([doc]) i_open = high.index("") i_block = high.index('\n" # no intro line 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 ```` section after ````, 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("") < prompt.index("") assert prompt.rstrip().endswith("") for tool in ("`ls`", "`grep`", "`read`"): assert tool in prompt for old in ("list_documents", "read_document", "search_documents"): assert old not in prompt def test_relevance_placeholder_rejected_for_garbage() -> None: with pytest.raises(ValueError, match="HIGH or LOW"): _base("MEDIUM") # ---------- 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"\n{KB_INTRO}\n{OVERVIEW}\n" ) 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"\n{KB_INTRO}\n{OVERVIEW}\n" 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"\n{KB_INTRO}\n-") assert section.endswith(f"{TRUNCATION_MARKER}\n") # 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"\n{KB_INTRO}\n").removesuffix( "\n" ) 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 # ---------- 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 ```` section existed — with or without steering notes. (Phase 37: the HIGH prompt additionally carries the ```` section after the mode body — the fixtures account for it; the LOW prompt is untouched.)""" doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster") block = ( '\n' "Talos Linux on three nodes.\n" "" ) docs_block = "\n\n" + block + "\n" + "\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 "" not in build_high_prompt( [doc], notes=["be concise"], kb_overview=kb ) assert "" 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("HIGH") i_kb_open = prompt.index("") i_kb_close = prompt.index("") i_tuning = prompt.index("") i_docs = prompt.index("") 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("HIGH") i_kb_close = prompt.index("") i_docs = prompt.index("") assert i_rel < i_kb_close < i_docs assert "" 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("LOW") i_kb_open = prompt.index("") i_kb_close = prompt.index("") i_tuning = prompt.index("") 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("") < prompt.index(TRUNCATION_MARKER) # The capped section (open tag through close tag) fits the budget. section = prompt[prompt.index("") :] close = section.index("") section = section[: close + len("")] 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