Phase 03 (Story: Chat RAG Answer — happy path):
- app/rag/retriever.py: top-k cosine search + parent-doc selection with
per-doc dedupe and BOR_MAX_CONTEXT_CHARS cap ([…truncated…] marker)
- app/rag/prompts.py: locked persona + HIGH/DEFLECT prompt builders
- app/rag/llm.py: LLMError + chat_stream (turbo, temp 0.4, max 700, stream)
- app/api/chat.py: POST /api/chat SSE — delta* then done{deflected,
sources, suggestions}; query_log row + PLAN §9 per-turn log line;
structured error event on mid-stream failure, JSON 503 when DB down
- frontend: SSE reader, live bubble streaming, source chips -> /sources.html,
red role=alert banner, Send button state that always recovers
- fix(scaffold): [hidden] { display: none !important } — .kb-banner's
display:flex was overriding the hidden attribute (banner always visible)
- tests: unit (retriever/prompts/sse/llm) + integration (real Postgres RAG
turn, query_log, error + 503 paths, mid-turn failures) + Playwright story
suite (grounded answer, log row, raw SSE shape); smoke placeholder test
replaced with the real never-stale-button contract
89 lines
3.3 KiB
Python
89 lines
3.3 KiB
Python
"""Unit: locked persona prompt builder (PLAN §6 verbatim + both modes)."""
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
|
|
import pytest
|
|
|
|
from app.models import Document
|
|
from app.rag.prompts import PERSONA, _base, build_deflect_prompt, build_high_prompt
|
|
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
def test_persona_rules_present_verbatim() -> None:
|
|
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 ("you\'ve got this")',
|
|
"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',
|
|
'Start your answer with a variant of: "I haven\'t done anything like that."',
|
|
"Then 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_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 "<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 "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 "<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_relevance_placeholder_rejected_for_garbage() -> None:
|
|
with pytest.raises(ValueError, match="HIGH or LOW"):
|
|
_base("MEDIUM")
|