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:
2026-08-25 20:22:51 -04:00
parent 572a4190a6
commit 0654b304e1
23 changed files with 2276 additions and 34 deletions
+80
View File
@@ -0,0 +1,80 @@
# Story: KB Overview in the System Prompt (lite-generated knowledge-base outline)
**Phase:** `31_kb_overview_prompt.md` · **E2E:** `tests/e2e/test_kb_overview.py`
## Narrative
As **any user of Reese**, I ask questions without knowing what the
knowledge base actually covers. Retrieval only reveals the answer
documents after it has run, and when it finds nothing the deflection has
no picture of what Reese DOES know. I want the system prompt to carry,
on every turn, a plain-text outline of the **basic categories** of
everything that has been read — so the agent knows roughly what its
knowledge base contains before retrieval returns documents. The outline
is **generated by the `lite` model** from the document catalogue
(source, path, title, first summary line per document) and **stored in
a single row**, so it is regenerated whenever an import changes the
knowledge base — never per chat turn.
- **Given** a knowledge base that was imported (with at least one
document added or updated, or no outline stored yet)
- **When** I ask any question
- **Then** the system prompt of that turn — grounded (HIGH) or
deflected (LOW) — contains a `<knowledge_base>` section with the
stored outline (budgeted by `BOR_KB_OVERVIEW_MAX_CHARS`, overflow
marked with the shared `[…truncated…]` marker), and the per-turn log
line records `kb_chars=N`; when no outline is stored, both prompts
are byte-identical to the pre-phase text.
## Acceptance criteria
1. Migration 0005: single-row `kb_overview` table (`id INTEGER PK
DEFAULT 1`, `content TEXT NOT NULL DEFAULT ''`,
`updated_at TIMESTAMPTZ`) + `KbOverview` model — no other schema
change (reversible, integration-tested up/down).
2. `app/rag/overview.py`: the `KB_OVERVIEW_MODE` prompt builder
(document list capped at `BOR_OVERVIEW_INPUT_MAX_CHARS`, overflow
cut at the cap with the shared truncation marker), `load_kb_overview`
(one indexed PK lookup, `""` when the row is missing/empty), and
`regenerate_overview` (best-effort upsert into the single row with a
fresh UTC `updated_at`, `overview: regenerated docs=… chars=…` log
line; a `lite` failure logs and leaves the previous outline intact;
zero documents → no model call, any existing row untouched).
3. Prompt injection (phase 15 convention): the `<knowledge_base>`
section is budgeted by `BOR_KB_OVERVIEW_MAX_CHARS` (default 4000)
with the shared `[…truncated…]` overflow, ordered
`<relevance>` → `<knowledge_base>` → `<tuning>` → mode body in
**both** the HIGH and LOW prompts; a missing/empty row keeps both
prompts **byte-identical** to the pre-phase text (unit-asserted).
4. Chat turn: `plan_turn` reads the stored row per turn (no per-turn
LLM call) and records `TurnPlan.kb_chars`; the per-turn log line
records `kb_chars=N` after `tuning=N` (PLAN §9 extension).
5. Import trigger: after a run that added/updated at least one document
(or no row exists yet), `import_docs` regenerates the outline with
the same `lite` model (`BOR_LLM_SUMMARY_MODEL` — A5 extended);
unchanged re-imports and `--limit` debug runs never burn a `lite`
call; the summary line ends `overview=updated|skipped|failed` and a
`lite` failure never changes the import's exit code.
6. `.env.example` + README document `BOR_KB_OVERVIEW_MAX_CHARS` /
`BOR_OVERVIEW_INPUT_MAX_CHARS` and the regeneration behavior; the
deterministic mock LLM answers `KB_OVERVIEW_MODE` with a byte-stable
outline (first 8 tokens of the document list) and echoes the
`<knowledge_base>` section's first bullet as `(kb: …)` — the
`(tuning: …)` steering-echo precedent.
7. Unit + integration green, `app/` coverage >90% (TOTAL ≥ pre-change),
story E2E green in isolation, `ruff` + `pyright` clean, one
`--no-gpg-sign` commit.
## Playwright Mapping Rule
`tests/e2e/test_kb_overview.py` — one story, one file, run in
isolation. It seeds the `kb_overview` row directly in the DB (the
import-trigger path is integration-tested) with a recognizable
multi-bullet outline, imports the shared fixture KB, asks an on-topic
question and asserts the rendered brain answer ends with
`(kb: Kubernetes cluster and node maintenance notes)` (the mock's echo
of the injected section's first bullet — only possible if the
`<knowledge_base>` section reached the LLM prompt), asks an off-topic
question and asserts the deflected answer carries the same echo (the
section is in the LOW prompt too), deletes the row and asserts a fresh
answer has no `(kb: …)` suffix (absence end-to-end), and runs the real
`regenerate_overview` against the mock to assert the `KB_OVERVIEW_MODE`
branch's byte-stable outline is stored and echoed by a subsequent turn.
+2
View File
@@ -25,6 +25,8 @@ BOR_RELEVANCE_THRESHOLD=0.62 # answer when best cosine >= this OR an FTS hi
BOR_MAX_OUTPUT_TOKENS=32768 # max answer length in tokens (answers must not be cut off) BOR_MAX_OUTPUT_TOKENS=32768 # max answer length in tokens (answers must not be cut off)
BOR_STEERING_MAX_CHARS=8000 # char budget for the <tuning> (steering notes) prompt section BOR_STEERING_MAX_CHARS=8000 # char budget for the <tuning> (steering notes) prompt section
BOR_SUMMARY_MAX_CHARS=12000 # cap on document content sent to the lite summary model (phase 30) BOR_SUMMARY_MAX_CHARS=12000 # cap on document content sent to the lite summary model (phase 30)
BOR_KB_OVERVIEW_MAX_CHARS=4000 # char budget for the <knowledge_base> prompt section (phase 31)
BOR_OVERVIEW_INPUT_MAX_CHARS=40000 # cap on the document list sent to the lite model for the KB outline (phase 31)
BOR_CHUNK_TARGET_CHARS=2000 BOR_CHUNK_TARGET_CHARS=2000
BOR_CHUNK_OVERLAP_CHARS=200 BOR_CHUNK_OVERLAP_CHARS=200
BOR_EMBED_BATCH_SIZE=16 BOR_EMBED_BATCH_SIZE=16
+10
View File
@@ -247,6 +247,14 @@ embedded.
stdlib `ast`) and their title comes from the file stem. stdlib `ast`) and their title comes from the file stem.
- Unchanged files are **not re-embedded** — only new/changed ones, so - Unchanged files are **not re-embedded** — only new/changed ones, so
refreshes are cheap. refreshes are cheap.
- After a run that **changed** the knowledge base (at least one document
added or updated), the import also regenerates the stored **KB
overview** — a plain-text outline of the KB's basic categories that
every chat turn injects into the system prompt as `<knowledge_base>`
(phase 31). The regeneration is best-effort and change-gated: unchanged
re-imports and `--limit` debug runs skip it (no `lite` call), and a
`lite`-model failure leaves the previous outline intact without failing
the import. The summary line ends `overview=updated|skipped|failed`.
- To sanity-check the LLM backend (models + embedding dimension) after any - To sanity-check the LLM backend (models + embedding dimension) after any
aipi change: `uv run python -m scripts.llm_probe`. aipi change: `uv run python -m scripts.llm_probe`.
@@ -454,6 +462,8 @@ served locally (no CDN), `BOR_ENVIRONMENT=production`.
| `BOR_SOURCES_DIR` | `~/bor-sources` | where the `BOR_GIT_SOURCES` repos are cloned/pulled (one subdirectory per repo) | | `BOR_SOURCES_DIR` | `~/bor-sources` | where the `BOR_GIT_SOURCES` repos are cloned/pulled (one subdirectory per repo) |
| `BOR_STEERING_MAX_CHARS` | `8000` | char budget for the `<tuning>` (steering notes) prompt section | | `BOR_STEERING_MAX_CHARS` | `8000` | char budget for the `<tuning>` (steering notes) prompt section |
| `BOR_SUMMARY_MAX_CHARS` | `12000` | cap on document content sent to the `lite` summary model at import (see *Document summaries*) | | `BOR_SUMMARY_MAX_CHARS` | `12000` | cap on document content sent to the `lite` summary model at import (see *Document summaries*) |
| `BOR_KB_OVERVIEW_MAX_CHARS` | `4000` | char budget for the `<knowledge_base>` (KB overview) prompt section |
| `BOR_OVERVIEW_INPUT_MAX_CHARS` | `40000` | cap on the document list sent to the `lite` model when generating the KB overview |
| `BOR_SUGGESTIONS` | built-in list | JSON list of onboarding chips | | `BOR_SUGGESTIONS` | built-in list | JSON list of onboarding chips |
| `BOR_ADMIN_PASSWORD` | *(required)* | the single admin's password (plaintext, `.env`); app refuses to start when empty | | `BOR_ADMIN_PASSWORD` | *(required)* | the single admin's password (plaintext, `.env`); app refuses to start when empty |
| `BOR_SESSION_SECRET` | *(required)* | signing key for the `bor_session` cookie; `python -c 'import secrets;print(secrets.token_hex(32))'` | | `BOR_SESSION_SECRET` | *(required)* | signing key for the `bor_session` cookie; `python -c 'import secrets;print(secrets.token_hex(32))'` |
+48
View File
@@ -0,0 +1,48 @@
"""kb_overview: single-row, lite-generated knowledge-base outline
Revision ID: 0005
Revises: 0004
Create Date: 2026-08-25
Phase 31 (kb-overview-prompt story, A13 — one additive, reversible table,
no other schema change):
* ``kb_overview`` — a **single-row** table (``id INTEGER PK DEFAULT 1``)
holding a plain-text outline of the knowledge base's basic categories,
generated by the aipi ``lite`` model (phase 30's ``LLMClient.chat``)
whenever an import changes the KB. Chat turns only *read* the row (one
indexed PK lookup) and inject it into the system prompt as the
``<knowledge_base>`` section, so the agent knows roughly what the KB
contains before retrieval returns documents. ``content`` defaults to the
empty string — no row / empty row means the prompts are byte-identical
to the pre-phase text (phase 15 convention); ``updated_at`` is stamped
server-side on every upsert.
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "0005"
down_revision = "0004"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"kb_overview",
sa.Column("id", sa.Integer(), primary_key=True, server_default=sa.text("1")),
sa.Column("content", sa.Text(), nullable=False, server_default=sa.text("''")),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
def downgrade() -> None:
op.drop_table("kb_overview")
+35 -4
View File
@@ -36,6 +36,16 @@ assembly is untouched. ``TurnPlan.summary_hits`` counts the hit chunks
with ``is_summary`` whose parent document landed in the selected with ``is_summary`` whose parent document landed in the selected
top-N context, and the per-turn log line records ``summary_hits=N`` top-N context, and the per-turn log line records ``summary_hits=N``
after ``fts_hits`` (PLAN §9 line extension). after ``fts_hits`` (PLAN §9 line extension).
KB overview (phase 31): the lite-generated outline of the knowledge
base (single ``kb_overview`` row) is read per turn (one indexed PK
lookup — no LLM call) and injected into **both** prompts as the
``<knowledge_base>`` section, ordered ``<relevance>`` →
``<knowledge_base>`` → ``<tuning>`` → mode body. With an empty row the
prompts stay byte-identical to the pre-phase text (phase 15
convention); ``TurnPlan.kb_chars`` records the length of the stored
outline (0 when absent) and the per-turn log line records
``kb_chars=N`` after ``tuning=N`` (PLAN §9 line extension).
""" """
from __future__ import annotations from __future__ import annotations
@@ -60,6 +70,7 @@ from app.rag.llm import (
LLMError, LLMError,
StreamPiece, # noqa: F401 (phase 17 typing: chat_stream yields StreamPiece) StreamPiece, # noqa: F401 (phase 17 typing: chat_stream yields StreamPiece)
) )
from app.rag.overview import load_kb_overview
from app.rag.prompts import build_deflect_prompt, build_high_prompt from app.rag.prompts import build_deflect_prompt, build_high_prompt
from app.rag.retriever import RetrievedChunk, retrieve, select_documents, weak_hit_titles from app.rag.retriever import RetrievedChunk, retrieve, select_documents, weak_hit_titles
from app.rag.suggestions import derive_suggestions from app.rag.suggestions import derive_suggestions
@@ -107,12 +118,17 @@ class TurnPlan:
#: Hit chunks with ``is_summary`` whose parent document made it into #: Hit chunks with ``is_summary`` whose parent document made it into
#: *docs* (phase 30; per-turn log line ``summary_hits=N``). #: *docs* (phase 30; per-turn log line ``summary_hits=N``).
summary_hits: int = 0 summary_hits: int = 0
#: Length of the stored KB overview injected as the
#: ``<knowledge_base>`` section (phase 31; per-turn log line
#: ``kb_chars=N``). 0 when no non-empty row exists.
kb_chars: int = 0
def plan_turn( def plan_turn(
chunks: Sequence[RetrievedChunk], chunks: Sequence[RetrievedChunk],
settings: Settings, settings: Settings,
notes: Sequence[str] | None = None, notes: Sequence[str] | None = None,
kb_overview: str | None = None,
) -> TurnPlan: ) -> TurnPlan:
"""Apply the honesty gate (A8, revised) and assemble prompt + context. """Apply the honesty gate (A8, revised) and assemble prompt + context.
@@ -133,11 +149,20 @@ def plan_turn(
when non-empty, both the HIGH and the LOW prompt carry the when non-empty, both the HIGH and the LOW prompt carry the
``<tuning>`` section; with no notes the prompts are unchanged. ``<tuning>`` section; with no notes the prompts are unchanged.
*kb_overview* is the stored KB outline (phase 31, one PK lookup per
turn): when non-empty, both prompts carry the ``<knowledge_base>``
section (between ``<relevance>`` and ``<tuning>``) and
``kb_chars`` records the outline's length; with no outline the
prompts are byte-identical to the pre-phase text and ``kb_chars``
is 0.
``summary_hits`` (phase 30) counts the hit chunks with ``summary_hits`` (phase 30) counts the hit chunks with
``is_summary`` whose parent document is among the selected ``is_summary`` whose parent document is among the selected
top-N documents — both the HIGH and the LOW branch record it. top-N documents — both the HIGH and the LOW branch record it.
""" """
steering = list(notes or []) steering = list(notes or [])
kb_text = (kb_overview or "").strip()
kb_chars = len(kb_text)
best_cosine = max((c.cosine for c in chunks), default=0.0) best_cosine = max((c.cosine for c in chunks), default=0.0)
fts_hits = sum(1 for c in chunks if c.fts_hit) fts_hits = sum(1 for c in chunks if c.fts_hit)
docs = select_documents(chunks, n=settings.top_n_docs) docs = select_documents(chunks, n=settings.top_n_docs)
@@ -148,22 +173,24 @@ def plan_turn(
best_cosine, best_cosine,
fts_hits, fts_hits,
False, False,
build_high_prompt(docs, notes=steering), build_high_prompt(docs, notes=steering, kb_overview=kb_text),
docs, docs,
[], [],
len(steering), len(steering),
summary_hits, summary_hits,
kb_chars,
) )
titles = weak_hit_titles(chunks) titles = weak_hit_titles(chunks)
return TurnPlan( return TurnPlan(
best_cosine, best_cosine,
fts_hits, fts_hits,
True, True,
build_deflect_prompt(titles, notes=steering), build_deflect_prompt(titles, notes=steering, kb_overview=kb_text),
docs, docs,
derive_suggestions(titles, settings.suggestions), derive_suggestions(titles, settings.suggestions),
len(steering), len(steering),
summary_hits, summary_hits,
kb_chars,
) )
@@ -216,8 +243,11 @@ async def chat(
settings = get_settings() settings = get_settings()
try: try:
steering_notes = load_steering_notes(db) steering_notes = load_steering_notes(db)
# KB overview (phase 31): one indexed PK lookup per turn — the
# outline is generated at import time, never per chat turn.
kb_overview = load_kb_overview(db)
chunks = retrieve(db, request.message, question_vec) chunks = retrieve(db, request.message, question_vec)
plan = plan_turn(chunks, settings, notes=steering_notes) plan = plan_turn(chunks, settings, notes=steering_notes, kb_overview=kb_overview)
except Exception: # noqa: BLE001 — DB failure mid-turn except Exception: # noqa: BLE001 — DB failure mid-turn
logger.exception( logger.exception(
"chat: retrieval failed question=%r total_ms=%d", "chat: retrieval failed question=%r total_ms=%d",
@@ -284,13 +314,14 @@ async def chat(
logger.info( logger.info(
"question=%r embed_ms=%d top_score=%.3f fts_hits=%d summary_hits=%d tuning=%d " "question=%r embed_ms=%d top_score=%.3f fts_hits=%d summary_hits=%d tuning=%d "
"threshold=%.2f deflected=%s sources=%r thinking_chars=%d total_ms=%d", "kb_chars=%d threshold=%.2f deflected=%s sources=%r thinking_chars=%d total_ms=%d",
request.message, request.message,
embed_ms, embed_ms,
plan.top_score, plan.top_score,
plan.fts_hits, plan.fts_hits,
plan.summary_hits, plan.summary_hits,
plan.tuning_count, plan.tuning_count,
plan.kb_chars,
settings.relevance_threshold, settings.relevance_threshold,
plan.deflected, plan.deflected,
source_paths, source_paths,
+11
View File
@@ -76,6 +76,17 @@ class Settings(BaseSettings):
#: and the shared ``[…truncated…]`` marker is appended (see #: and the shared ``[…truncated…]`` marker is appended (see
#: ``app.rag.summarizer``). #: ``app.rag.summarizer``).
summary_max_chars: int = 12_000 summary_max_chars: int = 12_000
#: Char budget for the ``<knowledge_base>`` section of the system prompt
#: (phase 31: lite-generated KB overview, ``app.rag.overview``). The
#: newest-fitting prefix of the stored outline is kept and the overflow
#: is replaced by the shared ``[…truncated…]`` marker (phase 15
#: convention — ``app.rag.prompts``).
kb_overview_max_chars: int = 4_000
#: Cap on the document list (source/path/title/first summary line per
#: row) sent to the ``lite`` overview model in one call (phase 31,
#: ``app.rag.overview``). Overflow is cut at the cap and the shared
#: ``[…truncated…]`` marker is appended (summarizer convention).
overview_input_max_chars: int = 40_000
# --- Hybrid retrieval (A7, revised 2026-08-21) --- # --- Hybrid retrieval (A7, revised 2026-08-21) ---
# cosine top-N ∪ Postgres FTS top-N, fused with Reciprocal Rank Fusion # cosine top-N ∪ Postgres FTS top-N, fused with Reciprocal Rank Fusion
+24
View File
@@ -10,6 +10,9 @@ Data model — see ``.agent/PLAN.md`` §Data Model:
the deflection decision, and latency. the deflection decision, and latency.
* ``steering_notes`` — owner tuning notes injected into the system prompt * ``steering_notes`` — owner tuning notes injected into the system prompt
of every chat turn (phase 15, ``<tuning>`` section). of every chat turn (phase 15, ``<tuning>`` section).
* ``kb_overview`` — single-row lite-generated outline of the KB's basic
categories, injected as the ``<knowledge_base>``
section of every chat turn (phase 31).
""" """
from __future__ import annotations from __future__ import annotations
@@ -109,3 +112,24 @@ class SteeringNote(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
note: Mapped[str] = mapped_column(Text) # trimmed, 1–2000 chars (API-enforced) note: Mapped[str] = mapped_column(Text) # trimmed, 1–2000 chars (API-enforced)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class KbOverview(Base):
"""Single-row, lite-generated outline of the knowledge base (phase 31).
Exactly one row (``id = 1``, enforced by the migration 0005 server
defaults) holds a plain-text outline of the KB's basic categories,
generated by the aipi ``lite`` model whenever an import changes the KB.
Chat turns only read this row (one indexed PK lookup) and inject it into
the system prompt of every turn as the ``<knowledge_base>`` section — an
empty row means the section is absent and the prompt stays
byte-identical to the pre-phase text (phase 15 convention).
"""
__tablename__ = "kb_overview"
id: Mapped[int] = mapped_column(Integer, primary_key=True, server_default="1")
content: Mapped[str] = mapped_column(Text, server_default="") # the outline text
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
+202
View File
@@ -0,0 +1,202 @@
"""KB overview generator (phase 31, task 02).
Builds the ``KB_OVERVIEW_MODE`` prompt from the document catalogue
(source, path, title, first summary line per document), calls the aipi
``lite`` model through the one-shot ``LLMClient.chat`` (phase 30, same
model as document summaries — no new model management), and stores the
plain-text outline in the single ``kb_overview`` row (migration 0005).
Chat turns never generate an overview — they read the stored row
(:func:`load_kb_overview`, one indexed PK lookup) and inject it into the
system prompt of every turn as the ``<knowledge_base>`` section
(phase 31, task 03). Generation happens at import time (phase 31, task
04) and is **best-effort and change-gated**: callers invoke it only when
an import added/updated at least one document (or no row exists yet),
and a ``lite`` failure logs and leaves the previous outline intact — an
old outline is better than none (phase locked decisions).
The ``KB_OVERVIEW_MODE`` marker follows the ``SUMMARY_MODE`` /
``DEFLECT_MODE`` convention: the deterministic E2E mock LLM keys on it
in the system prompt (wired in phase 31, task 05).
"""
from __future__ import annotations
import logging
from collections.abc import Sequence
from datetime import UTC, datetime
from typing import Protocol
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import Settings, get_settings
from app.db import SessionLocal
from app.models import Document, KbOverview
from app.rag.llm import LLMError
from app.rag.retriever import TRUNCATION_MARKER
logger = logging.getLogger("app.rag.overview")
#: System-prompt marker for KB overview generation — the E2E mock LLM keys
#: on it (same convention as ``SUMMARY_MODE`` / ``DEFLECT_MODE``).
KB_OVERVIEW_MODE = "KB_OVERVIEW_MODE"
#: Locked instruction for the ``lite`` model (phase 31): the overview is
#: the agent's *a priori* picture of the knowledge base, so it must be a
#: compact plain-text outline of basic categories and topics — strictly
#: grounded in the document list, nothing invented.
OVERVIEW_INSTRUCTION = (
"From the document list below, write a compact plain-text outline of "
"the basic categories and topics this knowledge base covers. Group by "
"source where useful, use `-` bullet lines, at most ~1500 characters, "
"no markdown headings, and no topics not present in the list."
)
#: Full system prompt: marker first (the mock's key), then the instruction.
SYSTEM_PROMPT = f"{KB_OVERVIEW_MODE}: {OVERVIEW_INSTRUCTION}"
class OverviewLLM(Protocol):
"""The one-shot chat surface the overview generator needs.
:class:`app.rag.llm.LLMClient` satisfies it; unit tests pass a
duck-typed fake (``chat`` + ``settings``) instead — same pattern as
the summarizer's ``SummaryLLM`` protocol.
"""
settings: Settings
async def chat(
self, messages: list[dict[str, str]], model: str | None = None
) -> str: ...
def _first_summary_line(summary: str | None) -> str:
"""First line of a stored summary, stripped; ``''`` when absent.
The stored summary (phase 30) ends in the code-appended
``Source: …`` pointer line, so its first line is the model-written
lead sentence — the best one-line picture of the document for the
outline. Blank/whitespace-only summaries yield ``''`` as well.
"""
if not summary:
return ""
for line in summary.splitlines():
stripped = line.strip()
if stripped:
return stripped
return ""
def build_overview_prompt(
rows: Sequence[tuple[str, str, str, str | None]], max_chars: int | None = None
) -> tuple[str, str]:
"""The ``(system, user)`` message pair for one overview call.
Each row is ``(source, path, title, summary)``.
* ``system`` — :data:`SYSTEM_PROMPT`: the ``KB_OVERVIEW_MODE`` marker
+ the locked instruction.
* ``user`` — one line per document,
``source — path — title — {first line of summary}``, joined with
newlines. The summary field is omitted when the document has no
summary (markdown docs and the fail-soft path — no dangling
dash). The list is capped at *max_chars* (default
``BOR_OVERVIEW_INPUT_MAX_CHARS``): overflow is cut exactly at the
cap and the shared ``[…truncated…]`` marker is appended on its own
line, so the model never sees more than the cap and the cut is
visible (summarizer convention, phase 30).
"""
limit = max_chars if max_chars is not None else get_settings().overview_input_max_chars
lines: list[str] = []
for source, path, title, summary in rows:
line = f"{source} — {path} — {title}"
first = _first_summary_line(summary)
if first:
line += f" — {first}"
lines.append(line)
user = "\n".join(lines)
if len(user) > limit:
user = user[:limit] + "\n" + TRUNCATION_MARKER
return SYSTEM_PROMPT, user
def load_kb_overview(db: Session) -> str:
"""The stored KB outline for prompt injection (phase 31, task 03).
One indexed PK lookup (the single row, ``id = 1``) returning
``content`` trimmed. Returns ``""`` when the row is missing or
empty — the chat path then omits the ``<knowledge_base>`` section
entirely, keeping the prompt byte-identical to the pre-phase text
(phase 15 convention).
"""
row = db.get(KbOverview, 1)
if row is None:
return ""
return (row.content or "").strip()
async def regenerate_overview(
llm: OverviewLLM, session: Session | None = None
) -> bool:
"""Generate a fresh KB outline with the ``lite`` model and upsert it.
Best-effort and change-gated by the caller (phase locked decisions):
the import script (phase 31, task 04) and the admin sync (phase 32)
invoke this only when an import added/updated at least one document
(or no row exists yet). *session* may be supplied (the import
script's own session, tests); a private one is opened and closed
otherwise — same convention as ``import_sources``.
* Zero documents → the existing row (if any) is left untouched and
``False`` is returned — no KB, no outline, no wasted model call.
* Happy path → the model's text is upserted into the single row
(``id = 1``, fresh UTC ``updated_at``), committed, and
``overview: regenerated docs=… chars=…`` is logged; returns
``True``. Idempotent by construction: one row, always ``id = 1``.
* :class:`LLMError` → ``overview: regeneration failed — …`` is
logged and ``False`` is returned with the previous row intact —
an old outline is better than none.
"""
owns_session = session is None
if session is None:
session = SessionLocal()
try:
result = session.execute(
select(Document.source, Document.path, Document.title, Document.summary)
.order_by(Document.source, Document.path)
).all()
rows: list[tuple[str, str, str, str | None]] = [
(source, path, title, summary)
for source, path, title, summary in result
]
if not rows:
logger.info(
"overview: no documents in the knowledge base — "
"leaving any existing outline untouched"
)
return False
system, user = build_overview_prompt(rows)
try:
text = await llm.chat(
[{"role": "system", "content": system}, {"role": "user", "content": user}],
model=llm.settings.llm_summary_model,
)
except LLMError as e:
logger.error("overview: regeneration failed — %s", e)
return False
now = datetime.now(UTC)
row = session.get(KbOverview, 1)
if row is None:
session.add(KbOverview(id=1, content=text, updated_at=now))
else:
row.content = text
row.updated_at = now
session.commit()
logger.info("overview: regenerated docs=%d chars=%d", len(rows), len(text))
return True
finally:
if owns_session:
session.close()
+87 -11
View File
@@ -15,6 +15,14 @@ Steering (phase 15): when the owner has stored tuning notes, both modes
carry a ``<tuning>`` section between ``<relevance>…</relevance>`` and the carry a ``<tuning>`` section between ``<relevance>…</relevance>`` and the
mode body. With zero notes the prompt is byte-identical to the mode body. With zero notes the prompt is byte-identical to the
pre-steering text. pre-steering text.
KB overview (phase 31): when the single ``kb_overview`` row holds a
lite-generated outline of the knowledge base, both modes carry a
``<knowledge_base>`` section between ``<relevance>…</relevance>`` and
the ``<tuning>`` section (order: ``<relevance>`` →
``<knowledge_base>`` → ``<tuning>`` → mode body) — the agent knows
roughly what the KB contains before retrieval. With an empty row the
prompt is byte-identical to the pre-phase text.
""" """
from __future__ import annotations from __future__ import annotations
@@ -51,6 +59,13 @@ _STEERING_INTRO = (
"Where these instructions conflict with the defaults above, follow the owner:\n" "Where these instructions conflict with the defaults above, follow the owner:\n"
) )
#: One-line intro of the ``<knowledge_base>`` section (phase 31): the
#: lite-generated outline is the agent's a-priori picture of the KB.
_KB_INTRO = (
"The basic categories of everything in this knowledge base "
"(generated at import time):\n"
)
def _base(relevance: str) -> str: def _base(relevance: str) -> str:
if relevance not in ("HIGH", "LOW"): if relevance not in ("HIGH", "LOW"):
@@ -95,9 +110,57 @@ def build_steering_section(notes: Sequence[str], max_chars: int | None = None) -
return "" return ""
def build_high_prompt(documents: Sequence[Document], notes: Sequence[str] | None = None) -> str: def build_kb_section(overview: str, max_chars: int | None = None) -> str:
"""Grounded turn: locked persona (+ steering) + full texts of the top """The ``<knowledge_base>`` section of the system prompt (phase 31).
documents."""
* No outline (or only whitespace) → ``""`` — callers then build the
prompt exactly as before, so a no-overview prompt is byte-identical
to the pre-phase text (phase 15 convention).
* Otherwise: the intro line + the stored outline, capped at
*max_chars* (default ``BOR_KB_OVERVIEW_MAX_CHARS``). When the budget
cannot hold the whole outline, the longest-fitting prefix is kept
and the overflow is replaced by the shared ``[…truncated…]`` marker
on its own line — the exact :func:`build_steering_section` pattern,
including its pathological-budget handling (never exceed the cap;
bare marker when even one outline character does not fit).
"""
text = str(overview or "").strip()
if not text:
return ""
limit = max_chars if max_chars is not None else get_settings().kb_overview_max_chars
if limit <= 0:
return ""
def render(cut: int) -> str:
lines = [text[:cut]]
if cut < len(text):
lines.append(TRUNCATION_MARKER)
return f"<knowledge_base>\n{_KB_INTRO}" + "\n".join(lines) + "\n</knowledge_base>"
for cut in range(len(text), 0, -1):
rendered = render(cut)
if len(rendered) <= limit:
return rendered
# Pathological budget: not even one outline character fits. The
# section must still respect the cap — the bare marker when it fits,
# else none (steering precedent, phase 15).
if len(TRUNCATION_MARKER) <= limit:
return TRUNCATION_MARKER
return ""
def build_high_prompt(
documents: Sequence[Document],
notes: Sequence[str] | None = None,
kb_overview: str | None = None,
) -> str:
"""Grounded turn: locked persona (+ steering, + KB overview) + full
texts of the top documents.
Section order (phase 31): ``<relevance>`` → ``<knowledge_base>`` →
``<tuning>`` → ``<documents>``; empty steering/overview omit their
section, keeping the prompt byte-identical to the pre-phase text.
"""
blocks = [ blocks = [
f'<document source="{doc.source}" path="{doc.path}" title="{doc.title}">\n' f'<document source="{doc.source}" path="{doc.path}" title="{doc.title}">\n'
f"{doc.content}\n" f"{doc.content}\n"
@@ -107,21 +170,34 @@ def build_high_prompt(documents: Sequence[Document], notes: Sequence[str] | None
body = "\n\n".join(blocks) if blocks else ( body = "\n\n".join(blocks) if blocks else (
"(no documents matched — do not invent specifics)" "(no documents matched — do not invent specifics)"
) )
section = build_steering_section(notes or [])
prompt = _base("HIGH") prompt = _base("HIGH")
if section: for part in (build_kb_section(kb_overview or ""), build_steering_section(notes or [])):
prompt += "\n" + section if part:
prompt += "\n" + part
return prompt + "\n<documents>\n" + body + "\n</documents>" return prompt + "\n<documents>\n" + body + "\n</documents>"
def build_deflect_prompt(titles: Sequence[str], notes: Sequence[str] | None = None) -> str: def build_deflect_prompt(
"""Deflection turn: weak-hit titles only (no document content).""" titles: Sequence[str],
notes: Sequence[str] | None = None,
kb_overview: str | None = None,
) -> str:
"""Deflection turn: weak-hit titles only (no document content).
Section order (phase 31): ``<relevance>`` → ``<knowledge_base>`` →
``<tuning>`` → ``DEFLECT_MODE`` body; empty steering/overview omit
their section, keeping the prompt byte-identical to the pre-phase text.
"""
weak = "\n".join(f"- {t}" for t in titles) if titles else "(nothing close at all)" weak = "\n".join(f"- {t}" for t in titles) if titles else "(nothing close at all)"
section = build_steering_section(notes or []) mid = "\n".join(
mid = f"\n{section}\n" if section else "\n" part
for part in (build_kb_section(kb_overview or ""), build_steering_section(notes or []))
if part
)
gap = f"\n{mid}\n" if mid else "\n"
return ( return (
_base("LOW") _base("LOW")
+ mid + gap
+ "DEFLECT_MODE: retrieval was weak — the titles below are the closest " + "DEFLECT_MODE: retrieval was weak — the titles below are the closest "
"your notes come to the question. They are titles only; do not pretend " "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" "they answer it. Use them to propose 2-3 alternative questions.\n"
+63 -4
View File
@@ -27,6 +27,17 @@ caches) is skipped, along with non-content dirs (``.venv``,
``build``). Re-runs are cheap: files are diffed by sha256 and unchanged ``build``). Re-runs are cheap: files are diffed by sha256 and unchanged
ones are not re-embedded; ``--prune`` also drops documents whose files no ones are not re-embedded; ``--prune`` also drops documents whose files no
longer match the format filter. longer match the format filter.
After a run that **changed** the knowledge base (at least one document
added or updated — or no outline stored yet), the single ``kb_overview``
row is regenerated with the ``lite`` model (phase 31): the plain-text
outline of the KB's basic categories that every chat turn injects into
the system prompt as ``<knowledge_base>``. The regeneration is
**best-effort and change-gated** — unchanged re-imports and ``--limit``
debug runs never burn a ``lite`` call, and a ``lite`` failure only
reports ``overview=failed`` on the summary line: the import's exit code
is about files, and the previous outline stays (an old outline is better
than none).
""" """
from __future__ import annotations from __future__ import annotations
@@ -40,8 +51,11 @@ from pathlib import Path
from app.config import Settings, get_settings from app.config import Settings, get_settings
from app.core.debugging import configure_debugging from app.core.debugging import configure_debugging
from app.core.logging import configure_logging from app.core.logging import configure_logging
from app.rag.importer import import_sources from app.db import SessionLocal
from app.models import KbOverview
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient from app.rag.llm import LLMClient
from app.rag.overview import regenerate_overview
from scripts.git_sync import GitSyncError, clone_or_pull from scripts.git_sync import GitSyncError, clone_or_pull
logger = logging.getLogger("scripts.import_docs") logger = logging.getLogger("scripts.import_docs")
@@ -116,6 +130,18 @@ def _resolve_sources(cli_sources: list[Path] | None, settings: Settings) -> list
return [path.expanduser() for path in DEFAULT_SOURCES] return [path.expanduser() for path in DEFAULT_SOURCES]
def _overview_row_exists() -> bool:
"""Whether the single ``kb_overview`` row (id = 1) is present.
One indexed PK lookup (phase 31, task 04): a missing outline after an
unchanged re-import — e.g. the first run after migration 0005 — still
gets a fresh outline, while a present one is left untouched until the
KB actually changes.
"""
with SessionLocal() as session:
return session.get(KbOverview, 1) is not None
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv) args = build_parser().parse_args(argv)
settings = get_settings() settings = get_settings()
@@ -144,16 +170,49 @@ def main(argv: list[str] | None = None) -> int:
return 1 return 1
llm = LLMClient() llm = LLMClient()
summary = asyncio.run(import_sources(sources, llm, prune=args.prune, limit=args.limit))
async def _run() -> tuple[ImportSummary, str]:
"""Import, then (change-gated) refresh the stored KB overview.
One event loop, one ``LLMClient`` (phase 31, task 04): the
outline that every chat prompt injects as ``<knowledge_base>`` is
regenerated only when this run added/updated at least one
document — or when no outline exists yet after a walk that
actually saw files (e.g. the first run after migration 0005).
``--limit`` debug runs (an incomplete walk must not rewrite the
outline — mirrors the ``--prune``-with-``--limit`` guard) and
unchanged re-imports never burn a ``lite`` call, and a ``lite``
failure only flips the status token (``failed``) — the import's
exit code is unchanged.
"""
summary = await import_sources(sources, llm, prune=args.prune, limit=args.limit)
if args.limit is not None:
logger.info("overview: skipped (--limit)")
return summary, "skipped"
if summary.added + summary.updated == 0:
if summary.files == 0:
logger.info("overview: skipped (nothing imported)")
return summary, "skipped"
if _overview_row_exists():
logger.info("overview: skipped (KB unchanged)")
return summary, "skipped"
# No outline yet after an unchanged re-import (e.g. the first
# run after migration 0005) — fall through and generate one.
ok = await regenerate_overview(llm)
return summary, "updated" if ok else "failed"
summary, overview_status = asyncio.run(_run())
print( print(
f"import_docs: files={summary.files} added={summary.added} " f"import_docs: files={summary.files} added={summary.added} "
f"updated={summary.updated} unchanged={summary.unchanged} " f"updated={summary.updated} unchanged={summary.unchanged} "
f"pruned={summary.pruned} errors={summary.errors} chunks={summary.chunks} " f"pruned={summary.pruned} errors={summary.errors} chunks={summary.chunks} "
f"embed_batches={summary.embed_batches} summaries={summary.summaries} " f"embed_batches={summary.embed_batches} summaries={summary.summaries} "
f"summary_errors={summary.summary_errors} formats={summary.format_counts()}" f"summary_errors={summary.summary_errors} formats={summary.format_counts()} "
f"overview={overview_status}"
) )
# Non-zero if any file failed, so cron/CI notice — the rest of the KB # Non-zero if any file failed, so cron/CI notice — the rest of the KB
# was imported and the failed files are retried on the next run. # was imported and the failed files are retried on the next run. The
# overview is best-effort: a failed outline never changes the exit code.
return 1 if summary.errors else 0 return 1 if summary.errors else 0
+48
View File
@@ -15,6 +15,9 @@ Implements just enough of the aipi surface:
tokens of the user message (the summarizer puts the capped document tokens of the user message (the summarizer puts the capped document
content there) — byte-stable for a given fixture (document summaries, content there) — byte-stable for a given fixture (document summaries,
phase 30) phase 30)
- ``KB_OVERVIEW_MODE`` -> the deterministic outline: the first 8 tokens
of the user message (the generator puts the document list there) —
byte-stable for a given KB (KB overview, phase 31)
- ``DEFLECT_MODE`` -> honest "I haven't done anything like that" answer - ``DEFLECT_MODE`` -> honest "I haven't done anything like that" answer
- otherwise -> upbeat answer quoting the provided document context - otherwise -> upbeat answer quoting the provided document context
- user message containing ``pretend to think slowly`` -> 3s warm-up delay - user message containing ``pretend to think slowly`` -> 3s warm-up delay
@@ -30,6 +33,9 @@ Implements just enough of the aipi surface:
- system prompt containing ``<tuning>`` (phase 15, steering notes) -> - system prompt containing ``<tuning>`` (phase 15, steering notes) ->
the composed answer ends with `` (tuning: <first note line>)`` — the composed answer ends with `` (tuning: <first note line>)`` —
makes prompt injection observable in the UI deterministically. makes prompt injection observable in the UI deterministically.
- system prompt containing ``<knowledge_base>`` (phase 31, KB overview)
-> the composed answer ends with `` (kb: <first bullet line>)`` —
the same echo convention for the overview's prompt injection.
- user message containing ``show the end of your notes`` (phase 24, - user message containing ``show the end of your notes`` (phase 24,
whole-document context) -> the answer quotes the **last 160 chars of whole-document context) -> the answer quotes the **last 160 chars of
the document context** — a tail echo, byte-stable across runs, so a the document context** — a tail echo, byte-stable across runs, so a
@@ -155,6 +161,30 @@ def first_tuning_note(system: str) -> str | None:
return None return None
#: First ``-`` bullet line of a ``<knowledge_base>`` section (phase 31).
_KB_BLOCK_RE = re.compile(r"<knowledge_base>\n(.*?)\n</knowledge_base>", re.S)
_KB_BULLET_RE = re.compile(r"^-(?:\s+(.*))?$")
def first_kb_bullet(system: str) -> str | None:
"""The first outline bullet in the system prompt, or ``None``.
The stored outline (phase 31) is ``-`` bullet lines (see
``app.rag.overview.OVERVIEW_INSTRUCTION``); the mock echoes the first
one into its answer as `` (kb: <bullet>)`` — the exact
:func:`first_tuning_note` convention, so prompt injection of the
``<knowledge_base>`` section is observable in the UI.
"""
block = _KB_BLOCK_RE.search(system)
if not block:
return None
for line in block.group(1).splitlines():
m = _KB_BULLET_RE.match(line.strip())
if m:
return (m.group(1) or "").strip()
return None
def compose_answer(body: dict[str, Any]) -> str: def compose_answer(body: dict[str, Any]) -> str:
system = _system(body) system = _system(body)
user = _user(body) user = _user(body)
@@ -172,6 +202,17 @@ def compose_answer(body: dict[str, Any]) -> str:
f"This document covers " f"This document covers "
f"{' '.join(TOKEN_RE.findall(user.lower())[:24])}." f"{' '.join(TOKEN_RE.findall(user.lower())[:24])}."
) )
elif "KB_OVERVIEW_MODE" in system:
# KB overview (phase 31): the ``lite`` stand-in returns the
# deterministic outline — the first 8 tokens of the user message
# (the generator puts the document list there). Byte-stable for a
# given KB, so the stored row is a pure function of the fixture.
# Checked BEFORE the DEFLECT_MODE branch, like SUMMARY_MODE, so a
# prompt that ever carries both markers cannot shadow the
# overview call.
answer = "Knowledge base outline:\n- " + " ".join(
TOKEN_RE.findall(user.lower())[:8]
)
elif "DEFLECT_MODE" in system: elif "DEFLECT_MODE" in system:
answer = ( answer = (
"Ah — I haven't done anything like that, so I don't want to make stuff up! " "Ah — I haven't done anything like that, so I don't want to make stuff up! "
@@ -202,6 +243,13 @@ def compose_answer(body: dict[str, Any]) -> str:
note = first_tuning_note(system) note = first_tuning_note(system)
if note: if note:
answer = f"{answer} (tuning: {note})" answer = f"{answer} (tuning: {note})"
# KB overview (phase 31): when the system prompt carries
# <knowledge_base>, the answer ends with the first outline bullet —
# mirrors the steering echo exactly (appended after it, so the kb
# suffix is the last thing rendered).
bullet = first_kb_bullet(system)
if bullet:
answer = f"{answer} (kb: {bullet})"
return answer return answer
+281
View File
@@ -0,0 +1,281 @@
"""Phase 31 E2E (Playwright): the stored KB overview reaches every turn.
Story: ``.agent/user_stories/kb-overview-prompt.md``
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_kb_overview.py -v --no-cov
The story: a single-row ``kb_overview`` table holds a lite-generated
plain-text outline of the KB's basic categories; every chat turn — HIGH
(grounded) and LOW (deflected) alike — injects it into the system prompt
as the ``<knowledge_base>`` section, so the agent knows roughly what the
KB contains before retrieval returns. The mock LLM echoes the section's
first bullet into its answer (`` (kb: <first bullet>)`` — the
``(tuning: …)`` steering-echo precedent), which makes the prompt change
observable in the rendered UI deterministically.
The row is seeded **directly in the DB** so the INJECTION path is what
is under test (the import-time trigger is integration-tested in
``tests/integration/test_import_docs_overview.py``). The outline is
multi-line so the echo must pick the FIRST bullet — not the intro line,
not a later line. The last test additionally runs the real
``regenerate_overview`` against the mock to cover the mock's
``KB_OVERVIEW_MODE`` generation branch end-to-end (stored outline is the
byte-stable 8-token digest of the generator's document list).
Test → story mapping (Playwright Mapping Rule):
1. ``test_on_topic_answer_echoes_kb_overview``
2. ``test_deflected_answer_still_echoes_kb_overview``
3. ``test_absent_row_yields_no_kb_echo``
4. ``test_mock_generated_outline_is_stored_and_echoed``
"""
from __future__ import annotations
import asyncio
import re
from pathlib import Path
from threading import Thread
from typing import Any
from playwright.sync_api import Page, expect
from sqlalchemy import select, text
from app.config import Settings
from app.db import SessionLocal
from app.models import Document, KbOverview, QueryLog
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from app.rag.overview import build_overview_prompt, regenerate_overview
from tests.e2e.mock_llm import TOKEN_RE
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
QUESTION = "How is my Kubernetes cluster set up?"
OFF_TOPIC = "How do I bake sourdough bread?"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
#: The stored outline: multi-line ``-`` bullets (the generator's locked
#: format). The echo must pick the FIRST bullet — the parsing (skip the
#: intro line, strip the dash) is part of the story.
OVERVIEW = (
"- Kubernetes cluster and node maintenance notes\n"
"- Backup schedules and restore runbooks\n"
"- Networking: static DNS and kafkabridge"
)
FIRST_BULLET = "Kubernetes cluster and node maintenance notes"
KB_ECHO = f"(kb: {FIRST_BULLET})"
# --- Importer + thread helpers (test_document_summaries.py pattern) ---
async def _import_fixtures(mock_port: int) -> ImportSummary:
kwargs: dict[str, Any] = {
"_env_file": None,
"llm_base_url": f"http://127.0.0.1:{mock_port}/v1",
}
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return await import_sources([FIXTURES], LLMClient(settings))
def _run_in_thread(coro: Any) -> Any:
"""Run a coroutine on a worker thread.
Playwright's sync API keeps an asyncio loop running on the test thread,
so ``asyncio.run`` cannot be called directly from a test body.
"""
box: dict[str, Any] = {}
def runner() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
box["error"] = e
t = Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
def _reset_db(
mock_port: int, seed: bool, overview: str | None = None
) -> ImportSummary | None:
"""Truncate the KB (+ query log, steering, kb_overview), re-import the
fixtures, and optionally seed the single ``kb_overview`` row."""
with SessionLocal() as db:
db.execute(
text("TRUNCATE chunks, documents, query_log, steering_notes, kb_overview")
)
db.commit()
if overview is not None:
db.add(KbOverview(id=1, content=overview))
db.commit()
if not seed:
return None
return _run_in_thread(_import_fixtures(mock_port))
def _ask(page: Page, app_url: str, question: str, done_text: str) -> Any:
"""Submit *question* and wait until the streamed answer has fully
landed.
``done_text`` is the deterministic last thing the answer contains for
its path: the ``(kb: …)`` echo (grounded AND deflected turns with a
stored row — it is appended after everything else) or the mock
marker (the no-row control, where the echo is absent by design).
"""
page.set_default_timeout(30_000)
page.goto(app_url)
page.fill("#message-input", question)
page.click("#send-btn")
bubble = page.locator(".msg.brain .bubble")
bubble.first.wait_for(state="visible", timeout=30_000)
expect(bubble.first).to_contain_text(done_text, timeout=30_000)
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Send")
return bubble.first
def _last_query_log() -> QueryLog:
with SessionLocal() as db:
rows = db.scalars(select(QueryLog)).all()
assert len(rows) == 1, f"expected exactly one query_log row, got {len(rows)}"
return rows[0]
def _overview_row() -> KbOverview | None:
with SessionLocal() as db:
return db.get(KbOverview, 1)
def _doc_rows() -> list[tuple[str, str, str, str | None]]:
with SessionLocal() as db:
rows = db.execute(
select(Document.source, Document.path, Document.title, Document.summary)
.order_by(Document.source, Document.path)
).all()
return [(source, path, title, summary) for source, path, title, summary in rows]
# --- 1. Injection: grounded turn ------------------------------------------
def test_on_topic_answer_echoes_kb_overview(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""On-topic question with a stored row: the rendered answer ENDS with
the mock's echo of the ``<knowledge_base>`` section's first bullet —
only possible if the section reached the LLM prompt."""
summary = _reset_db(mock_llm, seed=True, overview=OVERVIEW)
assert summary is not None and summary.added == 8 # A9 formats
assert _overview_row() is not None # the row the turn must inject
bubble = _ask(page, app_url, QUESTION, KB_ECHO)
# The echo is the LAST thing in the bubble (the section was in the
# HIGH prompt) and names the FIRST outline bullet (not the intro,
# not a later line).
expect(bubble).to_have_text(
re.compile(rf"{re.escape(KB_ECHO)}\s*$"), timeout=30_000
)
# Grounded, not deflected — the overview does not change the gate.
expect(page.locator(".msg.brain.is-deflected")).to_have_count(0)
row = _last_query_log()
assert row.question == QUESTION
assert row.deflected is False
# --- 2. Injection: deflected turn ------------------------------------------
def test_deflected_answer_still_echoes_kb_overview(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""Off-topic question (LOW path): the deflected answer STILL carries
the echo — the ``<knowledge_base>`` section is in the LOW prompt too
(that is what lets the model offer real alternatives)."""
_reset_db(mock_llm, seed=True, overview=OVERVIEW)
# The deflection answer carries no mock marker — the kb echo is its
# deterministic end, so it doubles as the completion signal.
bubble = _ask(page, app_url, OFF_TOPIC, KB_ECHO)
expect(page.locator(".msg.brain.is-deflected")).to_have_count(1)
expect(bubble).to_have_text(re.compile(r"haven't done anything like that"))
expect(bubble).to_have_text(
re.compile(rf"{re.escape(KB_ECHO)}\s*$"), timeout=30_000
)
row = _last_query_log()
assert row.question == OFF_TOPIC
assert row.deflected is True
# --- 3. Absence: no row → no echo ------------------------------------------
def test_absent_row_yields_no_kb_echo(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""Delete the row: a fresh question's answer carries NO ``(kb: …)``
suffix — the section is absent and the prompt behaves exactly like
the pre-phase text (byte-identity is unit-asserted; this proves it
end-to-end through the rendered answer)."""
summary = _reset_db(mock_llm, seed=True, overview=OVERVIEW)
assert summary is not None
# Now delete the row (the absence control).
with SessionLocal() as db:
db.execute(text("TRUNCATE kb_overview"))
db.commit()
assert _overview_row() is None
bubble = _ask(page, app_url, QUESTION, MOCK_ANSWER_MARKER)
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER)
expect(bubble).not_to_contain_text("(kb:")
row = _last_query_log()
assert row.deflected is False # grounded either way — only the echo is gone
# --- 4. Generation: KB_OVERVIEW_MODE branch through the real regenerator ----
def test_mock_generated_outline_is_stored_and_echoed(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""The mock's ``KB_OVERVIEW_MODE`` branch is exercised end-to-end:
the real ``regenerate_overview`` (``LLMClient`` pointed at the mock)
stores the byte-stable 8-token digest of the generator's document
list, and a chat turn echoes its first bullet."""
summary = _reset_db(mock_llm, seed=True, overview=None)
assert summary is not None and summary.added == 8
assert _overview_row() is None # direct import never regenerates
kwargs: dict[str, Any] = {
"_env_file": None,
"llm_base_url": f"http://127.0.0.1:{mock_llm}/v1",
}
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
ok = _run_in_thread(regenerate_overview(LLMClient(settings)))
assert ok is True
row = _overview_row()
assert row is not None and row.id == 1
# Byte-stable expectation: the mock digests the generator's user
# message (the same document list, the same cap) to its first 8 tokens.
_, user = build_overview_prompt(_doc_rows())
expected = "Knowledge base outline:\n- " + " ".join(
TOKEN_RE.findall(user.lower())[:8]
)
assert row.content == expected
first_bullet = row.content.splitlines()[1].removeprefix("- ").strip()
assert first_bullet # the digest line is non-empty
# A grounded chat turn echoes the STORED outline's first bullet.
bubble = _ask(page, app_url, QUESTION, f"(kb: {first_bullet})")
expect(bubble).to_have_text(
re.compile(rf"\(kb: {re.escape(first_bullet)}\)\s*$"), timeout=30_000
)
expect(page.locator(".msg.brain.is-deflected")).to_have_count(0)
@@ -0,0 +1,222 @@
"""Integration test: ``import_docs`` regenerates the KB overview (phase 31, task 04).
Drives ``scripts.import_docs.main()`` end to end against the local compose
Postgres with a deterministic fake LLM (no live aipi, no git — explicit
``--source`` dirs and fresh settings, the phase 28 test's mocking style),
covering the change-gated overview trigger:
- a KB-changing import → ``kb_overview`` row written, the ``lite`` ``chat``
called exactly once, summary line ends ``overview=updated``;
- an unchanged re-import → ``chat`` **not** called again, ``overview=skipped``;
- a ``lite`` failure → exit code still ``0`` (the import itself was fine),
``overview=failed``, the previous row untouched;
- a ``--limit`` debug run with changes → ``overview=skipped``;
- an empty source run with no row → no row created, ``overview=skipped``.
"""
from __future__ import annotations
import logging
from collections.abc import Iterator
from pathlib import Path
import pytest
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.config import Settings
from app.models import KbOverview
from app.rag.llm import LLMError
from scripts import import_docs
from tests.fakes import FakeEmbedder
class FailingChatEmbedder(FakeEmbedder):
"""A ``lite`` model that always fails (drives the fail-soft path)."""
async def chat(self, messages: list[dict[str, str]], model: str | None = None) -> str:
self.chat_calls.append(list(messages))
raise LLMError("simulated lite-model failure (test sentinel)")
def _row(db: Session) -> KbOverview | None:
"""The stored ``kb_overview`` row (freshly reloaded)."""
db.expire_all()
return db.get(KbOverview, 1)
def _run_main(
monkeypatch: pytest.MonkeyPatch,
llm: FakeEmbedder,
argv: list[str],
capsys: pytest.CaptureFixture[str],
) -> tuple[int, str]:
"""Run ``import_docs.main`` with fresh settings, a fake LLM, and a
fail-loud git mock (``--source`` always wins, so git must stay idle)."""
monkeypatch.setattr(
import_docs,
"get_settings",
lambda: Settings(_env_file=None), # pyright: ignore[reportCallIssue]
)
def _no_git(url: str, dest: Path | str) -> Path:
raise AssertionError("git sync must not run with explicit --source")
monkeypatch.setattr(import_docs, "clone_or_pull", _no_git)
monkeypatch.setattr(import_docs, "LLMClient", lambda: llm)
rc = import_docs.main(argv)
return rc, capsys.readouterr().out
@pytest.fixture()
def src(tmp_path: Path) -> Path:
"""A source dir with two markdown docs (md → no summary chat calls)."""
root = tmp_path / "MyDocs"
root.mkdir()
(root / "alpha.md").write_text("# Alpha\n\nFirst document.\n", encoding="utf-8")
(root / "beta.md").write_text("# Beta\n\nSecond document.\n", encoding="utf-8")
return root
@pytest.fixture(autouse=True)
def _clean_kb(db: Session) -> Iterator[None]:
db.execute(text("TRUNCATE chunks, documents, kb_overview"))
db.commit()
yield
db.execute(text("TRUNCATE chunks, documents, kb_overview"))
db.commit()
def test_changed_import_writes_overview_row(
db: Session,
src: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
llm = FakeEmbedder()
records: list[logging.LogRecord] = []
class _Sink(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
records.append(record)
overview_logger = logging.getLogger("app.rag.overview")
sink = _Sink()
overview_logger.addHandler(sink)
overview_logger.setLevel(logging.INFO)
try:
rc, out = _run_main(monkeypatch, llm, ["--source", str(src)], capsys)
finally:
overview_logger.removeHandler(sink)
assert rc == 0
assert "added=2" in out
assert out.rstrip().endswith("overview=updated")
# Exactly one lite call — the overview itself (markdown files never
# get a summary, so nothing else may touch ``chat``).
assert len(llm.chat_calls) == 1
by_role = {m["role"]: m["content"] for m in llm.chat_calls[0]}
assert "KB_OVERVIEW_MODE" in by_role["system"]
# One line per doc: source — path — title (no summary for markdown).
assert "MyDocs — alpha.md — Alpha" in by_role["user"]
assert "MyDocs — beta.md — Beta" in by_role["user"]
# The model's outline lands in the single row.
row = _row(db)
assert row is not None
assert row.content == "Summary of MyDocs"
assert row.updated_at is not None
# The phase's required log line (PLAN §9).
assert any("overview: regenerated docs=2 chars=" in r.getMessage() for r in records)
def test_unchanged_reimport_does_not_call_lite(
db: Session,
src: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
llm = FakeEmbedder()
rc, out = _run_main(monkeypatch, llm, ["--source", str(src)], capsys)
assert rc == 0
assert out.rstrip().endswith("overview=updated")
assert len(llm.chat_calls) == 1
assert _row(db) is not None
# Same hashes → no KB change → no lite call, previous outline kept.
rc, out = _run_main(monkeypatch, llm, ["--source", str(src)], capsys)
assert rc == 0
assert "unchanged=2" in out
assert out.rstrip().endswith("overview=skipped")
assert len(llm.chat_calls) == 1 # no new lite call
row = _row(db)
assert row is not None and row.content == "Summary of MyDocs"
def test_lite_failure_is_fail_soft(
db: Session,
src: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
good = FakeEmbedder()
rc, out = _run_main(monkeypatch, good, ["--source", str(src)], capsys)
assert rc == 0
assert out.rstrip().endswith("overview=updated")
previous = _row(db)
assert previous is not None
previous_content = previous.content
# A KB-changing run whose ``lite`` model fails: the import still
# succeeds (exit 0) and the previous outline stays untouched.
(src / "alpha.md").write_text("# Alpha\n\nChanged content.\n", encoding="utf-8")
bad = FailingChatEmbedder()
rc, out = _run_main(monkeypatch, bad, ["--source", str(src)], capsys)
assert rc == 0 # a failed outline must not fail the import
assert "updated=1" in out
assert out.rstrip().endswith("overview=failed")
assert len(bad.chat_calls) == 1 # the (failed) attempt was made
row = _row(db)
assert row is not None
assert row.content == previous_content # previous row untouched
def test_limit_run_skips_overview(
db: Session,
src: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
llm = FakeEmbedder()
rc, out = _run_main(monkeypatch, llm, ["--source", str(src)], capsys)
assert rc == 0
assert out.rstrip().endswith("overview=updated")
assert len(llm.chat_calls) == 1
# An incomplete walk must not rewrite the outline (mirrors the
# --prune-with---limit guard).
(src / "alpha.md").write_text("# Alpha\n\nChanged content.\n", encoding="utf-8")
rc, out = _run_main(monkeypatch, llm, ["--source", str(src), "--limit", "1"], capsys)
assert rc == 0
assert "updated=1" in out
assert out.rstrip().endswith("overview=skipped")
assert len(llm.chat_calls) == 1 # --limit never burns a lite call
row = _row(db)
assert row is not None and row.content == "Summary of MyDocs"
def test_empty_source_without_row_creates_nothing(
db: Session,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
empty = tmp_path / "EmptyDocs"
empty.mkdir()
llm = FakeEmbedder()
rc, out = _run_main(monkeypatch, llm, ["--source", str(empty)], capsys)
assert rc == 0
assert "files=0" in out
assert out.rstrip().endswith("overview=skipped")
assert llm.chat_calls == [] # no KB → no outline, no wasted model call
assert _row(db) is None # nothing created
+249
View File
@@ -0,0 +1,249 @@
"""Integration: KB overview (phase 31) — the ``<knowledge_base>`` section
of the chat system prompt.
Real Postgres (``podman compose up -d db``) seeded from
``tests/fixtures/docs/`` through the real importer; the chat path reuses
the deterministic capturing fake LLM from ``test_chat_api``
(token-overlap embeddings), so the stored row's journey —
``kb_overview`` row → per-turn PK lookup → ``<knowledge_base>`` section
of the **exact** captured system prompt (HIGH and LOW) — is verified
end-to-end without a network.
The byte-identity contract (phase 15 convention): with no row, the
captured system prompt equals the pre-phase construction
(``build_high_prompt`` / ``build_deflect_prompt`` with
``kb_overview=None``) — asserted with ``==``, not ``in``.
Requires: podman compose up -d db
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select, text
from test_chat_api import FakeRagLLM, _stream_chat, _token_vec
from app.api import chat as chat_api
from app.main import app as fastapi_app
from app.models import Document, KbOverview
from app.rag.importer import import_sources
from app.rag.prompts import build_deflect_prompt, build_high_prompt
from app.rag.retriever import retrieve, weak_hit_titles
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "docs"
QUESTION = "How is my Kubernetes cluster set up?"
OFF_TOPIC = "How do I bake sourdough bread?"
#: A multi-line, multi-bullet outline: the section must carry it whole
#: (well within ``BOR_KB_OVERVIEW_MAX_CHARS``) and the per-turn log line
#: records its length.
OVERVIEW = (
"- Kubernetes cluster and node maintenance notes\n"
"- Backup schedules and restore runbooks\n"
"- Networking: static DNS and kafkabridge"
)
@pytest.fixture(autouse=True)
def clean_kb_overview(db) -> Iterator[None]:
"""The outline row + query log are global state: reset around every
test so no test inherits another test's row."""
db.execute(text("TRUNCATE kb_overview, query_log"))
db.commit()
yield
db.execute(text("TRUNCATE kb_overview, query_log"))
db.commit()
@pytest.fixture()
def seeded_kb(db) -> Iterator[FakeRagLLM]:
"""Fresh Postgres with the fixture docs imported (real pipeline)."""
db.execute(text("TRUNCATE chunks, documents, query_log, kb_overview"))
db.commit()
llm = FakeRagLLM()
summary = asyncio.run(import_sources([FIXTURES], llm, session=db))
assert summary.added == 8 # A9 formats; .hidden/ skipped
yield llm
db.execute(text("TRUNCATE chunks, documents, query_log, kb_overview"))
db.commit()
def _seed_overview(db) -> None:
db.add(KbOverview(id=1, content=OVERVIEW))
db.commit()
def _cited_docs(db, frames: list[dict]) -> list[Document]:
"""The documents the done event cited, in citation order — the same
list ``plan_turn`` passed to the prompt builder."""
docs = []
for s in frames[-1]["sources"]:
doc = db.scalar(select(Document).where(Document.path == s["path"]))
assert doc is not None, f"done source {s['path']!r} missing from the KB"
docs.append(doc)
return docs
def _turn_log_lines(caplog: pytest.LogCaptureFixture) -> list[str]:
"""The per-turn ``question=…`` log lines (PLAN §9) from this test."""
return [r.getMessage() for r in caplog.records if "question=" in r.getMessage()]
# ---------- no row → byte-identical to the pre-phase prompts ----------
def test_no_row_high_prompt_byte_identical_to_pre_phase(
client: TestClient, db, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
) -> None:
"""No ``kb_overview`` row: the captured HIGH system prompt EQUALS the
pre-phase construction exactly — the section is absent, not empty."""
caplog.set_level(logging.INFO, logger="app.chat")
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
try:
_, _, frames = _stream_chat(client, QUESTION)
finally:
fastapi_app.dependency_overrides.clear()
assert frames[-1]["deflected"] is False
(system, user) = seeded_kb.seen_messages[0][0], seeded_kb.seen_messages[0][1]
assert user["content"] == QUESTION
expected = build_high_prompt(_cited_docs(db, frames), notes=[], kb_overview=None)
assert system["content"] == expected
assert "<knowledge_base>" not in system["content"]
lines = _turn_log_lines(caplog)
assert lines and "kb_chars=0" in lines[-1]
def test_no_row_low_prompt_byte_identical_to_pre_phase(
client: TestClient, db, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
) -> None:
"""No row, off-topic question: the captured LOW (deflection) prompt
EQUALS the pre-phase construction exactly."""
caplog.set_level(logging.INFO, logger="app.chat")
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
try:
_, _, frames = _stream_chat(client, OFF_TOPIC)
finally:
fastapi_app.dependency_overrides.clear()
assert frames[-1]["deflected"] is True
(system, user) = seeded_kb.seen_messages[0][0], seeded_kb.seen_messages[0][1]
assert user["content"] == OFF_TOPIC
# Reconstruct the LOW prompt the way plan_turn does — the pre-phase
# construction (kb_overview=None), the same deterministic retrieval.
chunks = retrieve(db, OFF_TOPIC, _token_vec(OFF_TOPIC))
expected = build_deflect_prompt(
weak_hit_titles(chunks), notes=[], kb_overview=None
)
assert system["content"] == expected
assert "<knowledge_base>" not in system["content"]
assert "DEFLECT_MODE" in system["content"]
lines = _turn_log_lines(caplog)
assert lines and "kb_chars=0" in lines[-1]
# ---------- row present → section in BOTH prompts, exactly ----------
def test_row_high_prompt_carries_kb_section_exactly(
client: TestClient, db, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
) -> None:
"""Stored row: the captured HIGH prompt EQUALS the construction with
the outline — section present, ordered before ``<documents>``."""
_seed_overview(db)
caplog.set_level(logging.INFO, logger="app.chat")
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
try:
_, _, frames = _stream_chat(client, QUESTION)
finally:
fastapi_app.dependency_overrides.clear()
assert frames[-1]["deflected"] is False
(system, _user) = seeded_kb.seen_messages[0][0], seeded_kb.seen_messages[0][1]
expected = build_high_prompt(
_cited_docs(db, frames), notes=[], kb_overview=OVERVIEW
)
assert system["content"] == expected
# Section shape + order: <relevance> → <knowledge_base> → <documents>.
prompt = system["content"]
assert (
prompt.index("<relevance>HIGH</relevance>")
< prompt.index("<knowledge_base>")
< prompt.index(OVERVIEW)
< prompt.index("</knowledge_base>")
< prompt.index("<documents>")
)
# The per-turn log line records the outline's length (PLAN §9).
lines = _turn_log_lines(caplog)
assert lines and f"kb_chars={len(OVERVIEW)}" in lines[-1]
def test_row_low_prompt_carries_kb_section_exactly(
client: TestClient, db, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
) -> None:
"""Stored row, off-topic question: the LOW prompt EQUALS the
construction with the outline — the section is in the deflection
prompt too (real alternatives, not hallucinated ones)."""
_seed_overview(db)
caplog.set_level(logging.INFO, logger="app.chat")
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
try:
_, _, frames = _stream_chat(client, OFF_TOPIC)
finally:
fastapi_app.dependency_overrides.clear()
assert frames[-1]["deflected"] is True
(system, _user) = seeded_kb.seen_messages[0][0], seeded_kb.seen_messages[0][1]
chunks = retrieve(db, OFF_TOPIC, _token_vec(OFF_TOPIC))
expected = build_deflect_prompt(
weak_hit_titles(chunks), notes=[], kb_overview=OVERVIEW
)
assert system["content"] == expected
prompt = system["content"]
assert (
prompt.index("<relevance>LOW</relevance>")
< prompt.index("<knowledge_base>")
< prompt.index(OVERVIEW)
< prompt.index("</knowledge_base>")
< prompt.index("DEFLECT_MODE")
)
# Deflection still sees titles only — never document content.
assert "Talos Linux" not in prompt
lines = _turn_log_lines(caplog)
assert lines and f"kb_chars={len(OVERVIEW)}" in lines[-1]
def test_row_reread_every_turn_and_deleted_row_stops_it(
client: TestClient, db, seeded_kb: FakeRagLLM
) -> None:
"""The row is read per turn (not cached): it steers every turn until
it is deleted, and the following turn is section-free again."""
_seed_overview(db)
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
try:
_stream_chat(client, QUESTION)
_stream_chat(client, QUESTION)
assert len(seeded_kb.seen_messages) == 2
for messages in seeded_kb.seen_messages:
assert "<knowledge_base>" in messages[0]["content"]
assert OVERVIEW in messages[0]["content"]
# Delete the row → the next turn's prompt drops the section.
db.execute(text("TRUNCATE kb_overview"))
db.commit()
_stream_chat(client, QUESTION)
assert len(seeded_kb.seen_messages) == 3
assert "<knowledge_base>" not in seeded_kb.seen_messages[-1][0]["content"]
finally:
fastapi_app.dependency_overrides.clear()
+12 -10
View File
@@ -3,14 +3,15 @@
Drives the **real Alembic engine** against the live dev database Drives the **real Alembic engine** against the live dev database
(``podman compose up -d db``), mirroring the style of (``podman compose up -d db``), mirroring the style of
``test_migration_0002.py`` (information_schema assertions on the state the ``test_migration_0002.py`` (information_schema assertions on the state the
migration must leave): migration must leave). The tests target revision ``0004`` explicitly so
later migrations (0005, …) cannot break them:
* upgrade to head → ``documents.summary`` (TEXT, nullable) and * upgrade 0003 → 0004 → ``documents.summary`` (TEXT, nullable) and
``chunks.is_summary`` (BOOLEAN NOT NULL, default false) both exist, and a ``chunks.is_summary`` (BOOLEAN NOT NULL, default false) both exist, and a
chunk inserted without the column gets ``is_summary = false`` (pre-0004 chunk inserted without the column gets ``is_summary = false`` (pre-0004
insert paths stay valid); insert paths stay valid);
* downgrade to 0003 → both columns are gone; * downgrade to 0003 → both columns are gone;
* upgrade to head again → both are back (round-trip). * upgrade 0003 → 0004 again → both are back (round-trip).
The ``alembic`` fixture guarantees the DB ends at head even if a test The ``alembic`` fixture guarantees the DB ends at head even if a test
fails or the process is interrupted. fails or the process is interrupted.
@@ -65,13 +66,13 @@ def _version(db: Session) -> str | None:
return db.execute(text("SELECT version_num FROM alembic_version")).scalar() return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
def test_upgrade_to_head_adds_summary_columns(db: Session, alembic: Config) -> None: def test_upgrade_to_0004_adds_summary_columns(db: Session, alembic: Config) -> None:
"""Upgrade to head: both columns exist with the locked types/defaults.""" """Upgrade 0003 → 0004: both columns exist with the locked types/defaults."""
command.downgrade(alembic, "0003") # start from the pre-0004 state command.downgrade(alembic, "0003") # start from the pre-0004 state
assert _version(db) == "0003" assert _version(db) == "0003"
command.upgrade(alembic, "head") command.upgrade(alembic, "0004")
assert _version(db) == "0004", "alembic_version must be at 0004 (head)" assert _version(db) == "0004", "alembic_version must be at 0004"
summary = _column(db, "documents", "summary") summary = _column(db, "documents", "summary")
assert summary is not None, "documents.summary is missing" assert summary is not None, "documents.summary is missing"
@@ -130,9 +131,10 @@ def test_downgrade_to_0003_removes_columns(db: Session, alembic: Config) -> None
def test_upgrade_round_trip_restores_columns(db: Session, alembic: Config) -> None: def test_upgrade_round_trip_restores_columns(db: Session, alembic: Config) -> None:
"""Upgrade back to head after the downgrade: both columns are back.""" """Downgrade to 0003, then upgrade 0003 → 0004: both columns are back."""
command.upgrade(alembic, "head") command.downgrade(alembic, "0003")
assert _version(db) == "0004", "round-trip upgrade must land at 0004 (head)" command.upgrade(alembic, "0004")
assert _version(db) == "0004", "round-trip upgrade must land at 0004"
summary = _column(db, "documents", "summary") summary = _column(db, "documents", "summary")
assert summary is not None and summary[1] == "YES", "documents.summary must be back" assert summary is not None and summary[1] == "YES", "documents.summary must be back"
+152
View File
@@ -0,0 +1,152 @@
"""Integration: migration 0005 (kb_overview) schema contract.
Drives the **real Alembic engine** against the live dev database
(``podman compose up -d db``), mirroring the style of
``test_migration_0004.py`` (information_schema assertions on the state the
migration must leave):
* upgrade to head → ``kb_overview`` exists with exactly the three columns
the phase locks in (``id INTEGER PK`` default 1, ``content TEXT NOT NULL``
default ``''``, ``updated_at TIMESTAMPTZ NOT NULL`` default ``now()``),
and a bare insert lands the single-row defaults (id=1, content='');
* downgrade to 0004 → the table is gone;
* upgrade to head again → it is back (round-trip).
The ``alembic`` fixture guarantees the DB ends at head even if a test
fails or the process is interrupted.
"""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
import pytest
from alembic.config import Config
from sqlalchemy import text
from sqlalchemy.orm import Session
from alembic import command
from app.db import db_available
@pytest.fixture()
def alembic(db: Session) -> Iterator[Config]:
"""Real Alembic config bound to the dev DB (URL from app settings).
Starts at head (repairs an interrupted earlier run); teardown upgrades
to head no matter what happened, so the dev DB is never left below
head.
"""
if not db_available():
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
cfg = Config() # no alembic.ini file — env.py gets the URL from app config
cfg.set_main_option("script_location", "alembic")
command.upgrade(cfg, "head")
try:
yield cfg
finally:
command.upgrade(cfg, "head")
def _column(db: Session, column: str) -> tuple[Any, ...] | None:
"""(data_type, is_nullable, column_default) for one kb_overview column."""
row = db.execute(
text(
"SELECT data_type, is_nullable, column_default"
" FROM information_schema.columns"
" WHERE table_name = 'kb_overview' AND column_name = :c"
),
{"c": column},
).fetchone()
return tuple(row) if row is not None else None
def _version(db: Session) -> str | None:
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
def test_upgrade_to_head_creates_kb_overview(db: Session, alembic: Config) -> None:
"""Upgrade to head: the single-row table exists with the locked
column types, nullability, and server defaults."""
command.downgrade(alembic, "0004") # start from the pre-0005 state
assert _version(db) == "0004"
command.upgrade(alembic, "head")
assert _version(db) == "0005", "alembic_version must be at 0005 (head)"
pk = db.execute(
text(
"SELECT column_name FROM information_schema.table_constraints tc"
" JOIN information_schema.key_column_usage kcu"
" ON tc.constraint_name = kcu.constraint_name"
" WHERE tc.table_name = 'kb_overview' AND tc.constraint_type = 'PRIMARY KEY'"
)
).scalar()
assert pk == "id", "kb_overview primary key must be id"
id_col = _column(db, "id")
assert id_col is not None, "kb_overview.id is missing"
assert id_col[0] == "integer", "kb_overview.id must be INTEGER"
assert id_col[1] == "NO", "kb_overview.id must be NOT NULL"
assert id_col[2] == "1", "kb_overview.id must have server default 1"
content = _column(db, "content")
assert content is not None, "kb_overview.content is missing"
assert content[0] == "text", "kb_overview.content must be TEXT"
assert content[1] == "NO", "kb_overview.content must be NOT NULL"
assert content[2] is not None and "''" in content[2], (
"kb_overview.content must have server default ''"
)
updated = _column(db, "updated_at")
assert updated is not None, "kb_overview.updated_at is missing"
assert updated[0] == "timestamp with time zone", "kb_overview.updated_at must be TIMESTAMPTZ"
assert updated[1] == "NO", "kb_overview.updated_at must be NOT NULL"
assert updated[2] is not None and "now()" in updated[2], (
"kb_overview.updated_at must have server default now()"
)
def test_bare_insert_gets_single_row_defaults(db: Session, alembic: Config) -> None:
"""A column-less insert lands the single-row shape the phase relies on:
``id = 1``, ``content = ''``, server-stamped ``updated_at``."""
command.upgrade(alembic, "head")
try:
db.execute(text("DELETE FROM kb_overview"))
db.execute(text("INSERT INTO kb_overview DEFAULT VALUES"))
db.commit()
row = db.execute(
text("SELECT id, content, updated_at IS NOT NULL FROM kb_overview")
).fetchone()
assert row is not None, "the bare insert must land one row"
assert row[0] == 1, "kb_overview.id must default to 1"
assert row[1] == "", "kb_overview.content must default to the empty string"
assert row[2] is True, "kb_overview.updated_at must be stamped by the server"
finally:
db.execute(text("DELETE FROM kb_overview"))
db.commit()
def test_downgrade_to_0004_drops_table(db: Session, alembic: Config) -> None:
"""Downgrade to 0004: the table is dropped (A13 — reversible)."""
command.downgrade(alembic, "0004")
assert _version(db) == "0004"
exists = db.execute(
text("SELECT to_regclass('public.kb_overview') IS NOT NULL")
).scalar()
assert exists is False, "kb_overview must be dropped by the downgrade"
def test_upgrade_round_trip_restores_table(db: Session, alembic: Config) -> None:
"""Upgrade back to head after the downgrade: table + defaults are back."""
command.upgrade(alembic, "head")
assert _version(db) == "0005", "round-trip upgrade must land at 0005 (head)"
id_col = _column(db, "id")
assert id_col is not None and id_col[2] == "1", "kb_overview.id must be back with default 1"
content = _column(db, "content")
assert content is not None and content[2] is not None and "''" in content[2], (
"kb_overview.content must keep its '' default after the round-trip"
)
+147 -3
View File
@@ -19,13 +19,16 @@ from fastapi.testclient import TestClient
from app.api import chat as chat_api from app.api import chat as chat_api
from app.config import Settings from app.config import Settings
from app.main import app as fastapi_app from app.main import app as fastapi_app
from app.models import Document, QueryLog from app.models import Document, KbOverview, QueryLog
from app.rag.llm import StreamPiece from app.rag.llm import StreamPiece
from app.rag.retriever import RetrievedChunk, weak_hit_titles from app.rag.retriever import RetrievedChunk, weak_hit_titles
from app.rag.suggestions import MAX_SUGGESTIONS, derive_suggestions from app.rag.suggestions import MAX_SUGGESTIONS, derive_suggestions
ANSWER = "I haven't done anything like that — try one of these instead!" ANSWER = "I haven't done anything like that — try one of these instead!"
#: A small KB outline standing in for the lite-generated one (phase 31).
KB_OVERVIEW = "- Homelab\n - Kubernetes (k3s)\n- Deployments\n - Borg backups"
def _settings(threshold: float = 0.30) -> Settings: def _settings(threshold: float = 0.30) -> Settings:
return Settings( return Settings(
@@ -236,6 +239,87 @@ def test_no_summary_chunks_yields_zero_summary_hits() -> None:
assert plan_low.summary_hits == 0 assert plan_low.summary_hits == 0
# ---------- KB overview (phase 31: <knowledge_base> section + kb_chars) ----------
def test_plan_turn_high_injects_kb_overview() -> None:
"""HIGH branch: the stored outline lands in the prompt between
``<relevance>`` and ``<tuning>`` (or the ``<documents>`` body with no
notes), and ``kb_chars`` records the outline's length."""
doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_CONTENT")
plan = chat_api.plan_turn(
[_chunk(doc, 0.90)], _settings(threshold=0.30), kb_overview=KB_OVERVIEW
)
assert plan.deflected is False
assert plan.kb_chars == len(KB_OVERVIEW)
prompt = plan.system_prompt
assert "<knowledge_base>" in prompt
assert KB_OVERVIEW in prompt
i_rel = prompt.index("<relevance>HIGH</relevance>")
i_kb = prompt.index("<knowledge_base>")
i_docs = prompt.index("<documents>")
assert i_rel < i_kb < i_docs
def test_plan_turn_high_kb_section_ordered_before_tuning() -> None:
"""Both sections present: ``<relevance>`` → ``<knowledge_base>`` →
``<tuning>`` → ``<documents>`` (the locked phase-31 order)."""
doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_CONTENT")
plan = chat_api.plan_turn(
[_chunk(doc, 0.90)],
_settings(threshold=0.30),
notes=["be concise"],
kb_overview=KB_OVERVIEW,
)
prompt = plan.system_prompt
i_rel = prompt.index("<relevance>HIGH</relevance>")
i_kb = 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 < i_kb_close < i_tuning < i_docs
assert plan.kb_chars == len(KB_OVERVIEW)
def test_plan_turn_low_injects_kb_overview() -> None:
"""LOW (deflected) branch: the outline is injected there too, ahead
of the DEFLECT_MODE body, and document content stays excluded."""
doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_NEVER_SENT")
plan = chat_api.plan_turn(
[_chunk(doc, 0.10)], _settings(threshold=0.30), kb_overview=KB_OVERVIEW
)
assert plan.deflected is True
assert plan.kb_chars == len(KB_OVERVIEW)
prompt = plan.system_prompt
assert "<knowledge_base>" in prompt
assert KB_OVERVIEW in prompt
i_rel = prompt.index("<relevance>LOW</relevance>")
i_kb = prompt.index("<knowledge_base>")
i_mode = prompt.index("DEFLECT_MODE")
assert i_rel < i_kb < i_mode
assert "TALOS_DOC_NEVER_SENT" not in prompt # titles only, still
def test_plan_turn_empty_overview_keeps_prompt_and_zero_kb_chars() -> None:
"""No outline (None/empty/blank) → ``kb_chars == 0`` and a prompt
byte-identical to the no-overview build in both branches."""
high_doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_CONTENT")
low_doc = _doc("Backup Strategy", "BACKUP_DOC_CONTENT")
for chunks, kb in (
([_chunk(high_doc, 0.90)], None),
([_chunk(high_doc, 0.90)], ""),
([_chunk(high_doc, 0.90)], " \n\t "),
([_chunk(low_doc, 0.10)], None),
([_chunk(low_doc, 0.10)], ""),
):
plan = chat_api.plan_turn(chunks, _settings(threshold=0.30), kb_overview=kb)
baseline = chat_api.plan_turn(chunks, _settings(threshold=0.30))
assert plan.kb_chars == 0
assert baseline.kb_chars == 0
assert plan.system_prompt == baseline.system_prompt # byte-identical
assert "<knowledge_base>" not in plan.system_prompt
# ---------- prompt content (LOW vs HIGH) ---------- # ---------- prompt content (LOW vs HIGH) ----------
@@ -360,12 +444,15 @@ class _FakeSession:
"""Stands in for the DB session: records the QueryLog row it is given. """Stands in for the DB session: records the QueryLog row it is given.
``scalars`` always yields no steering notes (phase 15) so the chat ``scalars`` always yields no steering notes (phase 15) so the chat
turn's ``load_steering_notes`` call stays a no-op here. turn's ``load_steering_notes`` call stays a no-op here, and ``get``
returns the single ``kb_overview`` row when one is configured
(phase 31) — ``None`` by default, i.e. no stored outline.
""" """
def __init__(self) -> None: def __init__(self, kb_overview: str = "") -> None:
self.added: list[Any] = [] self.added: list[Any] = []
self.commits = 0 self.commits = 0
self.kb_overview = kb_overview
def add(self, obj: Any) -> None: def add(self, obj: Any) -> None:
self.added.append(obj) self.added.append(obj)
@@ -376,6 +463,11 @@ class _FakeSession:
def scalars(self, _stmt: Any) -> _FakeSteeringResult: def scalars(self, _stmt: Any) -> _FakeSteeringResult:
return _FakeSteeringResult() return _FakeSteeringResult()
def get(self, model: Any, pk: Any) -> Any:
if model is KbOverview and self.kb_overview:
return KbOverview(id=1, content=self.kb_overview)
return None
@pytest.fixture() @pytest.fixture()
def gate_env(monkeypatch: pytest.MonkeyPatch) -> Iterator[tuple[_FakeSession, _CannedLLM]]: def gate_env(monkeypatch: pytest.MonkeyPatch) -> Iterator[tuple[_FakeSession, _CannedLLM]]:
@@ -479,3 +571,55 @@ def test_endpoint_score_at_threshold_answers(
assert isinstance(row, QueryLog) assert isinstance(row, QueryLog)
assert row.deflected is False assert row.deflected is False
assert row.top_score == pytest.approx(0.30) assert row.top_score == pytest.approx(0.30)
# ---------- endpoint: KB overview row (phase 31) ----------
def test_endpoint_stored_kb_row_injected_into_system_prompt(
client: TestClient,
gate_env: tuple[_FakeSession, _CannedLLM],
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A non-empty ``kb_overview`` row (read via one PK lookup) reaches
the LLM's system prompt in both modes, and the per-turn log line
records ``kb_chars=N`` (PLAN §9)."""
session, llm = gate_env
doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_SENT")
monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.30)]))
session.kb_overview = f" {KB_OVERVIEW} " # the loader trims it
with caplog.at_level("INFO", logger="app.chat"):
_ask(client, "How is my Kubernetes cluster set up?")
(system, _user) = llm.seen[0][0], llm.seen[0][1]
assert "<knowledge_base>" in system["content"]
assert KB_OVERVIEW in system["content"]
assert system["content"].index("<relevance>HIGH</relevance>") < system["content"].index(
"<knowledge_base>"
) < system["content"].index("<documents>")
log_lines = [r.getMessage() for r in caplog.records if "question=" in r.getMessage()]
assert log_lines and f"kb_chars={len(KB_OVERVIEW)}" in log_lines[-1]
def test_endpoint_no_kb_row_prompt_unchanged(
client: TestClient,
gate_env: tuple[_FakeSession, _CannedLLM],
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""No ``kb_overview`` row → the section is absent (byte-identical to
the pre-phase prompt) and the log line records ``kb_chars=0``."""
session, llm = gate_env
assert session.kb_overview == "" # fixture default: no stored row
doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_SENT")
monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.30)]))
with caplog.at_level("INFO", logger="app.chat"):
_ask(client, "How is my Kubernetes cluster set up?")
(system, _user) = llm.seen[0][0], llm.seen[0][1]
assert "<knowledge_base>" not in system["content"]
log_lines = [r.getMessage() for r in caplog.records if "question=" in r.getMessage()]
assert log_lines and "kb_chars=0" in log_lines[-1]
+387
View File
@@ -0,0 +1,387 @@
"""Unit: KB overview generator (phase 31, task 02).
The prompt tests are pure (no DB): ``KB_OVERVIEW_MODE`` system prompt,
per-document user lines (source/path/title/first summary line), and the
input cap with the shared ``[…truncated…]`` marker. The loader and
regenerator tests run against the local compose Postgres (preferred —
real single-row upsert), skipping with clear instructions when the stack
is not up — same pattern as ``test_importer.py``.
"""
from __future__ import annotations
import asyncio
import logging
import uuid
from datetime import UTC, datetime
import pytest
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.config import Settings, get_settings
from app.models import Document, KbOverview
from app.rag.llm import LLMError
from app.rag.overview import (
KB_OVERVIEW_MODE,
OVERVIEW_INSTRUCTION,
SYSTEM_PROMPT,
build_overview_prompt,
load_kb_overview,
regenerate_overview,
)
from app.rag.retriever import TRUNCATION_MARKER
REPLY = "- Homelab\n - Kubernetes (k3s)\n- Deployments\n - Borg backups"
class _FakeLLM:
"""Duck-typed stand-in for ``LLMClient`` (``chat`` + ``settings``).
Records the messages and the ``model`` kwarg it was called with;
returns a canned reply or raises (e.g. :class:`LLMError`).
"""
def __init__(self, reply: str = REPLY, fail: Exception | None = None) -> None:
self._reply = reply
self._fail = fail
self.settings = Settings(_env_file=None) # pyright: ignore[reportCallIssue]
self.messages: list[dict[str, str]] = []
self.model: str | None = None
self.calls = 0
async def chat(
self, messages: list[dict[str, str]], model: str | None = None
) -> str:
self.calls += 1
self.messages = list(messages)
self.model = model
if self._fail is not None:
raise self._fail
return self._reply
# ---------- build_overview_prompt: system ----------
def test_system_prompt_has_marker_and_locked_instruction() -> None:
assert SYSTEM_PROMPT.startswith(KB_OVERVIEW_MODE)
assert OVERVIEW_INSTRUCTION in SYSTEM_PROMPT
for fragment in (
"compact plain-text outline",
"basic categories and topics",
"Group by source",
"use `-` bullet lines",
"~1500 characters",
"no markdown headings",
"no topics not present in the list",
):
assert fragment in SYSTEM_PROMPT
system, _ = build_overview_prompt([])
assert system == SYSTEM_PROMPT
assert KB_OVERVIEW_MODE in system # the marker the E2E mock keys on
# ---------- build_overview_prompt: user lines ----------
def test_user_lines_carry_source_path_title_and_first_summary_line() -> None:
rows = [
("Homelab", "kubernetes/k3s.md", "K3s Cluster", None),
(
"Deployments",
"backups/borg.yaml",
"Borg Backups",
"Backups run nightly via the borg schedule.\nSource: Deployments/backups/borg.yaml",
),
]
system, user = build_overview_prompt(rows)
assert system == SYSTEM_PROMPT
assert user == (
"Homelab — kubernetes/k3s.md — K3s Cluster\n"
"Deployments — backups/borg.yaml — Borg Backups — "
"Backups run nightly via the borg schedule."
)
def test_user_line_omits_summary_field_when_absent() -> None:
_, user = build_overview_prompt([("Homelab", "a.md", "Title A", None)])
assert user == "Homelab — a.md — Title A"
assert not user.endswith(" — ") # no dangling dash
def test_user_line_omits_summary_field_when_blank() -> None:
_, user = build_overview_prompt([("Homelab", "a.md", "Title A", " \n\t ")])
assert user == "Homelab — a.md — Title A"
def test_user_line_uses_only_first_summary_line() -> None:
"""Multi-line summaries contribute their first line only (the
model-written lead sentence; the ``Source: …`` pointer is last)."""
_, user = build_overview_prompt(
[
(
"Homelab",
"a.yaml",
"Title A",
"First line.\nSecond line.\nSource: Homelab/a.yaml",
)
]
)
assert user == "Homelab — a.yaml — Title A — First line."
assert "Second line" not in user
assert "Source:" not in user
def test_zero_rows_yield_empty_user() -> None:
_, user = build_overview_prompt([])
assert user == ""
# ---------- build_overview_prompt: input cap ----------
def test_user_prompt_truncated_with_marker_when_over_custom_cap() -> None:
rows = [("S", f"p{i}.md", f"T{i}", None) for i in range(10)]
_, full = build_overview_prompt(rows, max_chars=10_000)
cap = 25
_, user = build_overview_prompt(rows, max_chars=cap)
assert user == full[:cap] + "\n" + TRUNCATION_MARKER
assert user.endswith(TRUNCATION_MARKER)
assert len(user) > cap # the marker makes the cut visible past the cap
def test_user_prompt_at_exact_cap_not_truncated() -> None:
rows = [("S", "p.md", "T", None)] # "S — p.md — T" = 12 chars
_, user = build_overview_prompt(rows, max_chars=12)
assert user == "S — p.md — T"
assert TRUNCATION_MARKER not in user
def test_user_prompt_truncated_at_default_cap() -> None:
"""No explicit cap → ``BOR_OVERVIEW_INPUT_MAX_CHARS`` (read from the
live settings, so the test holds for any configured value)."""
cap = get_settings().overview_input_max_chars
rows = [("S", f"p{i}.md", "T", None) for i in range(3_000)]
_, user = build_overview_prompt(rows)
assert user.endswith(TRUNCATION_MARKER)
body = user.removesuffix("\n" + TRUNCATION_MARKER)
assert len(body) == cap # cut exactly at the cap, marker on its own line
assert "p2999.md" not in body # the overflow never reaches the model
# ---------- load_kb_overview ----------
def test_load_kb_overview_no_row_empty(db: Session) -> None:
db.execute(text("DELETE FROM kb_overview"))
db.commit()
assert load_kb_overview(db) == ""
def test_load_kb_overview_returns_trimmed_content(db: Session) -> None:
db.execute(text("DELETE FROM kb_overview"))
db.add(KbOverview(id=1, content=f" {REPLY} \n"))
db.commit()
assert load_kb_overview(db) == REPLY
def test_load_kb_overview_whitespace_only_row_empty(db: Session) -> None:
db.execute(text("DELETE FROM kb_overview"))
db.add(KbOverview(id=1, content=" \n\t "))
db.commit()
assert load_kb_overview(db) == ""
# ---------- regenerate_overview ----------
def _add_doc(
db: Session, source: str, path: str, title: str, summary: str | None = None
) -> Document:
doc = Document(
id=uuid.uuid4(),
source=source,
path=path,
full_path=f"/tmp/{path}",
title=title,
content="body",
content_hash="0" * 64,
summary=summary,
)
db.add(doc)
db.commit()
return doc
def _truncate_documents(db: Session) -> None:
db.execute(text("TRUNCATE chunks, documents"))
db.commit()
def _overview_row(db: Session) -> KbOverview | None:
return db.get(KbOverview, 1)
@pytest.fixture()
def clean_overview(db: Session):
db.execute(text("DELETE FROM kb_overview"))
db.commit()
yield
db.execute(text("DELETE FROM kb_overview"))
db.commit()
def test_regenerate_happy_path_creates_single_row_id_1(
db: Session, clean_overview, caplog: pytest.LogCaptureFixture
) -> None:
_truncate_documents(db)
try:
_add_doc(db, "Homelab", "b.md", "B")
_add_doc(db, "Homelab", "a.md", "A", "A summary.\nSource: Homelab/a.md")
llm = _FakeLLM()
with caplog.at_level(logging.INFO, logger="app.rag.overview"):
ok = asyncio.run(regenerate_overview(llm, db))
assert ok is True
assert llm.calls == 1
row = db.get(KbOverview, 1)
assert row is not None, "the single row must be upserted"
assert row.id == 1
assert row.content == REPLY
assert row.updated_at is not None
age = datetime.now(UTC) - row.updated_at
assert age.total_seconds() < 300, "updated_at must be a fresh UTC timestamp"
assert f"overview: regenerated docs=2 chars={len(REPLY)}" in caplog.text
finally:
_truncate_documents(db)
def test_regenerate_updates_existing_row_in_place(db: Session, clean_overview) -> None:
_truncate_documents(db)
past = datetime(2020, 1, 1, 12, 0, 0, tzinfo=UTC)
db.add(KbOverview(id=1, content="old outline", updated_at=past))
db.commit()
try:
_add_doc(db, "Homelab", "a.md", "A")
ok = asyncio.run(regenerate_overview(_FakeLLM(), db))
assert ok is True
count = db.scalar(text("SELECT count(*) FROM kb_overview"))
assert count == 1, "still exactly one row after the re-generation"
row = db.get(KbOverview, 1)
assert row is not None
assert row.content == REPLY # replaced, not appended
assert row.updated_at is not None and row.updated_at > past
finally:
_truncate_documents(db)
def test_regenerate_orders_documents_by_source_path(db: Session, clean_overview) -> None:
_truncate_documents(db)
try:
# Inserted out of order — the model must see a deterministic order.
_add_doc(db, "Deployments", "z.md", "Z")
_add_doc(db, "Homelab", "b.md", "B")
_add_doc(db, "Homelab", "a.md", "A")
llm = _FakeLLM()
assert asyncio.run(regenerate_overview(llm, db)) is True
finally:
_truncate_documents(db)
user = next(m["content"] for m in llm.messages if m["role"] == "user")
assert user == (
"Deployments — z.md — Z\nHomelab — a.md — A\nHomelab — b.md — B"
)
def test_regenerate_calls_the_configured_summary_model(
db: Session, clean_overview
) -> None:
_truncate_documents(db)
try:
_add_doc(db, "Homelab", "a.md", "A")
llm = _FakeLLM()
assert asyncio.run(regenerate_overview(llm, db)) is True
finally:
_truncate_documents(db)
assert llm.model == llm.settings.llm_summary_model # the ``lite`` default
assert llm.model == "lite"
assert [m["role"] for m in llm.messages] == ["system", "user"]
assert KB_OVERVIEW_MODE in llm.messages[0]["content"]
assert "A" in llm.messages[1]["content"]
def test_regenerate_zero_docs_leaves_existing_row_untouched(
db: Session, clean_overview
) -> None:
db.execute(text("DELETE FROM kb_overview"))
db.add(KbOverview(id=1, content="old outline"))
db.commit()
_truncate_documents(db)
llm = _FakeLLM()
ok = asyncio.run(regenerate_overview(llm, db))
assert ok is False
assert llm.calls == 0, "no KB → no wasted lite-model call"
row = db.get(KbOverview, 1)
assert row is not None and row.content == "old outline" # untouched
def test_regenerate_zero_docs_without_row_creates_nothing(db: Session, clean_overview) -> None:
_truncate_documents(db)
llm = _FakeLLM()
ok = asyncio.run(regenerate_overview(llm, db))
assert ok is False
assert llm.calls == 0
assert _overview_row(db) is None, "no row must be invented for an empty KB"
def test_regenerate_llm_error_leaves_previous_row_intact(
db: Session, clean_overview, caplog: pytest.LogCaptureFixture
) -> None:
db.execute(text("DELETE FROM kb_overview"))
db.add(KbOverview(id=1, content="old outline"))
db.commit()
_truncate_documents(db)
try:
_add_doc(db, "Homelab", "a.md", "A")
llm = _FakeLLM(fail=LLMError("simulated lite-model failure"))
with caplog.at_level(logging.ERROR, logger="app.rag.overview"):
ok = asyncio.run(regenerate_overview(llm, db))
assert ok is False, "fail-soft: the import must not be dragged down"
row = db.get(KbOverview, 1)
assert row is not None and row.content == "old outline", (
"the previous outline stays — an old outline is better than none"
)
assert "overview: regeneration failed —" in caplog.text
assert "simulated lite-model failure" in caplog.text
finally:
_truncate_documents(db)
def test_regenerate_llm_error_without_row_creates_nothing(
db: Session, clean_overview
) -> None:
_truncate_documents(db)
try:
_add_doc(db, "Homelab", "a.md", "A")
llm = _FakeLLM(fail=LLMError("simulated lite-model failure"))
ok = asyncio.run(regenerate_overview(llm, db))
finally:
_truncate_documents(db)
assert ok is False
assert _overview_row(db) is None
def test_regenerate_opens_own_session_when_none(db: Session, clean_overview) -> None:
"""``session=None`` → a private ``SessionLocal`` session is opened,
committed, and closed (the import-script call path)."""
_truncate_documents(db)
try:
_add_doc(db, "Homelab", "a.md", "A")
ok = asyncio.run(regenerate_overview(_FakeLLM()))
finally:
_truncate_documents(db)
assert ok is True
row = db.get(KbOverview, 1) # fresh lookup via the test session
assert row is not None and row.content == REPLY
+216 -2
View File
@@ -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 from __future__ import annotations
import uuid import uuid
import pytest import pytest
from app.config import Settings
from app.models import Document 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: 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: def test_relevance_placeholder_rejected_for_garbage() -> None:
with pytest.raises(ValueError, match="HIGH or LOW"): with pytest.raises(ValueError, match="HIGH or LOW"):
_base("MEDIUM") _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