feat(rag): lite-generated KB overview in the system prompt — stored single row, regenerated on import, <knowledge_base> section in HIGH+LOW prompts
This commit is contained in:
+216
-2
@@ -1,12 +1,34 @@
|
||||
"""Unit: locked persona prompt builder (PLAN §6 verbatim + both modes)."""
|
||||
"""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).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import Settings
|
||||
from app.models import Document
|
||||
from app.rag.prompts import PERSONA, _base, build_deflect_prompt, build_high_prompt
|
||||
from app.rag.prompts import (
|
||||
PERSONA,
|
||||
_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):"
|
||||
|
||||
|
||||
def _doc(path: str, content: str, title: str) -> Document:
|
||||
@@ -116,3 +138,195 @@ def test_zero_note_prompt_is_byte_identical_to_pre_steering() -> None:
|
||||
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."""
|
||||
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
|
||||
block = (
|
||||
'<document source="Homelab" path="kubernetes.md" title="Kubernetes Homelab Cluster">\n'
|
||||
"Talos Linux on three nodes.\n"
|
||||
"</document>"
|
||||
)
|
||||
docs_block = "\n<documents>\n" + block + "\n</documents>"
|
||||
high_plain = _base("HIGH") + docs_block
|
||||
high_steered = _base("HIGH") + "\n" + build_steering_section(["be concise"]) + docs_block
|
||||
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"
|
||||
+ "- 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"
|
||||
+ "- 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
|
||||
|
||||
Reference in New Issue
Block a user