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
68 lines
2.7 KiB
Python
68 lines
2.7 KiB
Python
"""Locked system-prompt builder (PLAN §6).
|
|
|
|
The persona + HONESTY GATE text is **locked verbatim** — change it through
|
|
the plan, not here. Two modes:
|
|
|
|
* ``HIGH`` — grounded turn: full top-document texts under ``<documents>``.
|
|
* ``LOW`` — deflection turn: weak-hit *titles only* plus the
|
|
``DEFLECT_MODE`` marker (the E2E mock LLM keys on that marker).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Sequence
|
|
|
|
from app.models import Document
|
|
|
|
#: 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 ("you\'ve got this").\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 <relevance> is "LOW", you must NOT pretend to know.\n'
|
|
' Start your answer with a variant of: "I haven\'t done anything like that."\n'
|
|
" Then 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>{relevance}</relevance>"
|
|
)
|
|
|
|
|
|
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_high_prompt(documents: Sequence[Document]) -> str:
|
|
"""Grounded turn: locked persona + full texts of the top documents."""
|
|
blocks = [
|
|
f'<document source="{doc.source}" path="{doc.path}" title="{doc.title}">\n'
|
|
f"{doc.content}\n"
|
|
"</document>"
|
|
for doc in documents
|
|
]
|
|
body = "\n\n".join(blocks) if blocks else (
|
|
"(no documents matched — do not invent specifics)"
|
|
)
|
|
return _base("HIGH") + "\n<documents>\n" + body + "\n</documents>"
|
|
|
|
|
|
def build_deflect_prompt(titles: Sequence[str]) -> str:
|
|
"""Deflection turn: weak-hit titles only (no document content)."""
|
|
weak = "\n".join(f"- {t}" for t in titles) if titles else "(nothing close at all)"
|
|
return (
|
|
_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"
|
|
+ weak
|
|
)
|