"""Unit: the honesty gate (A8) — boundary, prompts, and suggestion chips. Pure gate logic runs against fake retriever output (``RetrievedChunk`` rows from a fake retriever) with no Postgres and no network. The endpoint-level tests drive ``POST /api/chat`` with the retriever, the DB session, and the LLM all faked, so the whole deflection contract (prompt → deltas → done event → query_log) is verified without a stack. """ from __future__ import annotations import hashlib import json import uuid from collections.abc import Iterator, Sequence from datetime import UTC, datetime from pathlib import Path from typing import TYPE_CHECKING, Any import pytest from fastapi.testclient import TestClient from app.api import chat as chat_api from app.config import Settings from app.main import app as fastapi_app from app.models import Document, KbOverview, QueryLog from app.rag import agent as rag_agent from app.rag.agent import AGENT_TOOLS, SHOW_TEXT_TOOL from app.rag.llm import StreamPiece, ToolCallPiece from app.rag.prompts import SUGGEST_INTRO, build_deflect_prompt from app.rag.retriever import RetrievedChunk, weak_hit_titles from app.rag.source_labels import SourceLabelView from app.rag.suggestions import MAX_SUGGESTIONS, derive_suggestions from tests.conftest import ADMIN_PASSWORD if TYPE_CHECKING: from app.rag.scaffolding import ScaffoldingFilter 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, floor: float | None = None) -> Settings: if floor is None: floor = threshold * 0.5 # half the threshold — keeps existing tests green return Settings( _env_file=None, # pyright: ignore[reportCallIssue] relevance_threshold=threshold, lexical_support_floor=floor, ) def _doc( title: str, content: str, summary: str | None = None ) -> Document: return Document( id=uuid.uuid4(), source="Homelab", path=f"{title.lower().replace(' ', '-')}.md", full_path="/tmp/doc.md", title=title, content=content, # Phase 30/118: the stored lite-model summary — the HIGH block's # BODY (task 03). ``None`` exercises the A5 preview fallback # (the first ``suggestion_preview_chars`` content chars). summary=summary, content_hash="0" * 64, # Phase 106, D5: the HIGH block formats the row's created_at # UTC date part — the detached fixture carries it (the NOT NULL # DB column guarantees it for real rows). created_at=datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC), ) def _chunk( doc: Document, score: float, cosine: float | None = None, fts_hit: bool = False, is_summary: bool = False, ) -> RetrievedChunk: """Fake candidate: *score* is the fused rank score; *cosine* (defaults to *score*) is the vector-similarity gate input.""" return RetrievedChunk( chunk_id=uuid.uuid4(), position=-1 if is_summary else 0, content=doc.content[:32], score=score, document=doc, cosine=score if cosine is None else cosine, fts_hit=fts_hit, is_summary=is_summary, ) # ---------- gate boundary (fake retriever rows, no LLM) ---------- def test_gate_boundary_score_at_threshold_answers() -> None: """Score exactly at the threshold ⇒ HIGH (the gate is strict <).""" doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_CONTENT") plan = chat_api.plan_turn([_chunk(doc, 0.30)], _settings(threshold=0.30)) assert plan.deflected is False assert plan.top_score == pytest.approx(0.30) assert "HIGH" in plan.system_prompt assert "DEFLECT_MODE" not in plan.system_prompt assert "TALOS_DOC_CONTENT" in plan.system_prompt assert plan.suggestions == [] def test_gate_boundary_just_below_threshold_deflects() -> None: doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_CONTENT") plan = chat_api.plan_turn([_chunk(doc, 0.2999)], _settings(threshold=0.30)) assert plan.deflected is True assert plan.top_score == pytest.approx(0.2999) assert "LOW" in plan.system_prompt assert "DEFLECT_MODE" in plan.system_prompt # Titles only: the full document content must never reach the LLM. assert "TALOS_DOC_CONTENT" not in plan.system_prompt assert "Kubernetes Homelab Cluster" in plan.system_prompt def test_gate_is_env_tunable_via_settings() -> None: doc = _doc("Backup Strategy", "BACKUP_DOC_CONTENT") hits = [_chunk(doc, 0.30)] assert chat_api.plan_turn(hits, _settings(threshold=0.35)).deflected is True assert chat_api.plan_turn(hits, _settings(threshold=0.25)).deflected is False # ---------- hybrid gate matrix (A8, revised: cosine AND fts) ---------- def test_gate_weak_cosine_with_fts_hit_still_answers() -> None: """cosine < threshold but a lexical hit corroborated by cosine >= floor ⇒ HIGH — the FTS-OR branch. This is the name-your-tool case: "kafkabridge" grounds despite weak vector overlap. A8 revised 2026-09-14: FTS alone no longer promotes; cosine must also clear lexical_support_floor (here 0.15 = half of threshold 0.30).""" doc = _doc("Static DNS", "DNS_DOC_CONTENT") plan = chat_api.plan_turn( [_chunk(doc, 0.02, cosine=0.10, fts_hit=True)], _settings(threshold=0.30, floor=0.05), # floor=0.05 so 0.10 >= floor ) assert plan.deflected is False assert plan.top_score == pytest.approx(0.10) # gate input is the cosine assert plan.fts_hits == 1 assert "DNS_DOC_CONTENT" in plan.system_prompt assert plan.suggestions == [] def test_gate_weak_cosine_zero_fts_deflects() -> None: doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_CONTENT") plan = chat_api.plan_turn([_chunk(doc, 0.02, cosine=0.10)], _settings(threshold=0.30)) assert plan.deflected is True assert plan.top_score == pytest.approx(0.10) assert plan.fts_hits == 0 def test_gate_strong_cosine_without_fts_answers() -> None: doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_CONTENT") plan = chat_api.plan_turn([_chunk(doc, 0.90, cosine=0.90)], _settings(threshold=0.30)) assert plan.deflected is False assert plan.fts_hits == 0 def test_gate_fts_hits_counts_all_lexical_candidates() -> None: a = _doc("Alpha", "ALPHA_CONTENT") b = _doc("Beta", "BETA_CONTENT") chunks = [ _chunk(a, 0.03, cosine=0.05, fts_hit=True), _chunk(a, 0.02, cosine=0.04, fts_hit=True), # same doc, second chunk _chunk(b, 0.01, cosine=0.03), ] plan = chat_api.plan_turn(chunks, _settings(threshold=0.30, floor=0.03)) assert plan.deflected is False assert plan.fts_hits == 2 # per chunk, not per doc def test_gate_lexical_only_chunk_does_not_inflate_cosine() -> None: """top_score stays the best *vector* cosine even when a lexical-only chunk (cosine 0.0 by construction) carries the highest fused score.""" a = _doc("Alpha", "ALPHA_CONTENT") b = _doc("Beta", "BETA_CONTENT") chunks = [ _chunk(a, 0.50, cosine=0.55), # vector rank 1 _chunk(b, 0.90, cosine=0.0, fts_hit=True), # lexical rank 1 wins the ranking ] plan = chat_api.plan_turn(chunks, _settings(threshold=0.30)) assert plan.top_score == pytest.approx(0.55) assert plan.deflected is False # 0.55 >= 0.30 anyway # Phase 118 (A3): the suggestion tier has NO floor — the lexical- # only doc (cosine 0.0 by construction) is SUGGESTED when it ranks. # Rank order is the fused score, so Beta (0.90) leads Alpha (0.50); # with two docs nothing is left for the related tier (rank 6+). assert [d.title for d in plan.suggested_docs] == ["Beta", "Alpha"] assert plan.related_docs == [] # ---------- lexical support floor (A8 revised 2026-09-14) ---------- def test_gate_fts_hit_below_floor_deflects() -> None: """The Mongolia case: FTS hit with cosine below lexical_support_floor → LOW (deflected). The lexical-only hit no longer promotes to HIGH. This is the regression pin for phase 112.""" doc = _doc("Capital Quest", "QUEST_DOC_CONTENT") plan = chat_api.plan_turn( [_chunk(doc, 0.05, cosine=0.10, fts_hit=True)], _settings(threshold=0.62), ) assert plan.deflected is True assert plan.top_score == pytest.approx(0.10) assert plan.fts_hits == 1 assert "DEFLECT_MODE" in plan.system_prompt assert "QUEST_DOC_CONTENT" not in plan.system_prompt assert "Capital Quest" in plan.system_prompt # title only assert plan.suggestions # derived from weak-hit titles def test_gate_fts_hit_at_floor_answers() -> None: """FTS hit with cosine exactly at lexical_support_floor → HIGH. The floor is inclusive (>=), not strict (<).""" doc = _doc("Capital Quest", "QUEST_DOC_CONTENT") plan = chat_api.plan_turn( [_chunk(doc, 0.40, cosine=0.35, fts_hit=True)], _settings(threshold=0.62), ) assert plan.deflected is False assert plan.top_score == pytest.approx(0.35) assert plan.fts_hits == 1 assert "QUEST_DOC_CONTENT" in plan.system_prompt assert plan.suggestions == [] def test_gate_fts_hit_above_floor_below_threshold_answers() -> None: """FTS hit with cosine between floor and threshold → HIGH. The corroborated-lexical path fires.""" doc = _doc("Capital Quest", "QUEST_DOC_CONTENT") plan = chat_api.plan_turn( [_chunk(doc, 0.50, cosine=0.50, fts_hit=True)], _settings(threshold=0.62), ) assert plan.deflected is False assert plan.top_score == pytest.approx(0.50) assert plan.fts_hits == 1 assert "QUEST_DOC_CONTENT" in plan.system_prompt assert plan.suggestions == [] def test_gate_fts_hit_above_code_default_floor_answers() -> None: """The quadrant table's "0.50 with default settings" row: the CODE defaults (``test_lexical_support_floor_validation_default`` pins them: threshold 0.62 / floor 0.35) — fts>0 + cosine 0.50 >= 0.35 → HIGH. Named literally (not via the helper's half-threshold floor) so the production-default path is pinned on its own.""" doc = _doc("Capital Quest", "QUEST_DOC_CONTENT") plan = chat_api.plan_turn( [_chunk(doc, 0.50, cosine=0.50, fts_hit=True)], _settings(threshold=0.62, floor=0.35), ) assert plan.deflected is False assert plan.top_score == pytest.approx(0.50) assert plan.fts_hits == 1 assert "QUEST_DOC_CONTENT" in plan.system_prompt assert plan.suggestions == [] def test_gate_high_cosine_overrides_fts_deflection() -> None: """Strong cosine (>= threshold) → HIGH regardless of FTS status. The cosine-primary path is unchanged.""" doc = _doc("Capital Quest", "QUEST_DOC_CONTENT") plan = chat_api.plan_turn( [_chunk(doc, 0.90, cosine=0.80, fts_hit=True)], _settings(threshold=0.62), ) assert plan.deflected is False assert plan.top_score == pytest.approx(0.80) assert plan.fts_hits == 1 assert "QUEST_DOC_CONTENT" in plan.system_prompt assert plan.suggestions == [] def test_gate_fts_no_cosine_deflects() -> None: """FTS hit with cosine = 0.0 → LOW (the extreme Mongolia case).""" doc = _doc("Capital Quest", "QUEST_DOC_CONTENT") plan = chat_api.plan_turn( [_chunk(doc, 0.90, cosine=0.0, fts_hit=True)], _settings(threshold=0.62), ) assert plan.deflected is True assert plan.top_score == 0.0 assert plan.fts_hits == 1 assert "DEFLECT_MODE" in plan.system_prompt def test_gate_multiple_fts_below_floor_deflects() -> None: """Multiple FTS hits, all below lexical_support_floor → LOW. The gate requires the BEST cosine to clear the floor, not just any hit.""" a = _doc("Alpha Quest", "ALPHA_CONTENT") b = _doc("Beta Quest", "BETA_CONTENT") chunks = [ _chunk(a, 0.30, cosine=0.20, fts_hit=True), _chunk(b, 0.25, cosine=0.15, fts_hit=True), ] plan = chat_api.plan_turn(chunks, _settings(threshold=0.62)) assert plan.deflected is True assert plan.fts_hits == 2 assert "DEFLECT_MODE" in plan.system_prompt def test_gate_one_fts_above_floor_answers() -> None: """Multiple chunks, one FTS hit above floor → HIGH. The best cosine (from the corroborated hit) clears the floor.""" a = _doc("Alpha Quest", "ALPHA_CONTENT") b = _doc("Beta Quest", "BETA_CONTENT") chunks = [ _chunk(a, 0.30, cosine=0.20, fts_hit=True), # below floor _chunk(b, 0.25, cosine=0.40, fts_hit=True), # above floor ] plan = chat_api.plan_turn(chunks, _settings(threshold=0.62)) assert plan.deflected is False assert plan.fts_hits == 2 assert "ALPHA_CONTENT" in plan.system_prompt assert "BETA_CONTENT" in plan.system_prompt # ---------- config validation (lexical_support_floor) ---------- def test_lexical_support_floor_validation_floor_above_threshold_fails() -> None: """lexical_support_floor > relevance_threshold is rejected at startup.""" with pytest.raises(ValueError, match="lexical_support_floor"): Settings( _env_file=None, # pyright: ignore[reportCallIssue] relevance_threshold=0.62, lexical_support_floor=0.70, ) def test_lexical_support_floor_validation_negative_fails() -> None: """Negative lexical_support_floor is rejected.""" with pytest.raises(ValueError, match="lexical_support_floor"): Settings( _env_file=None, # pyright: ignore[reportCallIssue] lexical_support_floor=-0.1, ) def test_lexical_support_floor_validation_at_threshold_succeeds() -> None: """lexical_support_floor == relevance_threshold is legal.""" s = Settings( _env_file=None, # pyright: ignore[reportCallIssue] relevance_threshold=0.62, lexical_support_floor=0.62, ) assert s.lexical_support_floor == 0.62 def test_lexical_support_floor_validation_default() -> None: """Default lexical_support_floor is 0.35.""" import os # Conftest sets BOR_RELEVANCE_THRESHOLD=0.30 and BOR_LEXICAL_SUPPORT_FLOOR=0.15. # We need the CODE defaults, so clear both and let the class defaults apply. saved_relevance = os.environ.pop("BOR_RELEVANCE_THRESHOLD", None) saved_floor = os.environ.pop("BOR_LEXICAL_SUPPORT_FLOOR", None) try: s = Settings(_env_file=None) # pyright: ignore[reportCallIssue] assert s.lexical_support_floor == 0.35 assert s.relevance_threshold == 0.62 finally: if saved_relevance is not None: os.environ["BOR_RELEVANCE_THRESHOLD"] = saved_relevance if saved_floor is not None: os.environ["BOR_LEXICAL_SUPPORT_FLOOR"] = saved_floor def test_gate_zero_chunks_deflects_with_fallback_chips() -> None: plan = chat_api.plan_turn([], _settings()) assert plan.deflected is True assert plan.top_score == 0.0 assert "nothing close at all" in plan.system_prompt # No weak hits ⇒ onboarding fallback fills the chips. assert 2 <= len(plan.suggestions) <= MAX_SUGGESTIONS # ---------- summary-seed tiering (phase 118, LOCKED A3/A4/A6) ---------- def _tier_settings( threshold: float = 0.62, lex_floor: float = 0.35, suggested_cap: int = 5, related_cap: int = 2, ) -> Settings: """Explicit code defaults (production calibration) — the env's mock- calibrated values (tests/conftest.py) are overridden per test. ``source_usefulness_floor`` / ``top_n_docs`` are deliberately left at their code defaults: phase 118 retired their seeding role (A6) — ``plan_turn`` never consults them (pinned in ``test_plan_turn_does_not_consult_retired_seeding_settings``).""" return Settings( _env_file=None, # pyright: ignore[reportCallIssue] relevance_threshold=threshold, lexical_support_floor=lex_floor, suggested_docs=suggested_cap, related_max_docs=related_cap, ) def _seven_docs_with_summaries() -> list[Document]: """The 7-doc fixture (rank 1–7 by fused score): every document has a stored SUMMARY (distinct sentinel) and a distinct FULL-CONTENT sentinel that must never reach the prompt (A6: the ``read`` tool is the only full-text path).""" return [ _doc( f"Doc {i}", f"FULL_CONTENT_SENTINEL_{i}_SHOULD_NEVER_REACH_THE_PROMPT", summary=f"SUMMARY_TEXT_{i}", ) for i in range(7) ] def test_plan_turn_high_seeds_top5_suggested_related_is_rank6plus() -> None: """The phase-118 core pin (LOCKED A3/A6): the HIGH prompt seeds exactly the top-5 suggested documents' SUMMARY text and NONE of their full content; the related tier is rank 6+ (docs 6–7, capped by ``related_max_docs``).""" docs_in = _seven_docs_with_summaries() chunks = [ _chunk(d, 0.9 - 0.1 * i, cosine=0.8 - 0.05 * i) for i, d in enumerate(docs_in) ] plan = chat_api.plan_turn(chunks, _tier_settings()) assert plan.deflected is False assert [d.title for d in plan.suggested_docs] == [f"Doc {i}" for i in range(5)] assert [d.title for d in plan.related_docs] == ["Doc 5", "Doc 6"] # The prompt seeds the five summaries … for i in range(5): assert f"SUMMARY_TEXT_{i}" in plan.system_prompt # … and NONE of the seven documents' full content (suggested OR # related) reaches the LLM (A6). for i in range(7): assert f"FULL_CONTENT_SENTINEL_{i}" not in plan.system_prompt def test_plan_turn_folder_lines_ride_the_high_prompt() -> None: """Phase 119 (D3, LOCKED A4): plan_turn passes *folder_lines* through to the HIGH prompt — after ``SUGGEST_INTRO``, before the first ```` block (each on its own line).""" doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_CONTENT") lines = ["Homelab/: kubernetes-homelab-cluster.md (2 docs)"] plan = chat_api.plan_turn( [_chunk(doc, 0.90)], _settings(threshold=0.30), folder_lines=lines ) assert plan.deflected is False prompt = plan.system_prompt i_open = prompt.index("") i_block = prompt.index("\n{SUGGEST_INTRO}\n{lines[0]}\n\n" def test_plan_turn_folder_lines_default_keeps_phase_118_high_prompt() -> None: """Omitted *folder_lines* (the default ``()``) ⇒ the HIGH prompt is the phase-118 shape, byte-identical.""" doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_CONTENT") plan = chat_api.plan_turn([_chunk(doc, 0.90)], _settings(threshold=0.30)) assert plan.deflected is False prompt = plan.system_prompt i_open = prompt.index("") i_block = prompt.index("\n{SUGGEST_INTRO}\n\n" def test_plan_turn_folder_lines_ignored_on_the_low_branch() -> None: """The LOW (deflected) branch IGNORES *folder_lines* — the deflected prompt stays byte-identical (LOCKED A4).""" doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_CONTENT") low_with = chat_api.plan_turn( [_chunk(doc, 0.05, cosine=0.05)], _settings(threshold=0.30), folder_lines=["Homelab/: x.md"], ) low_without = chat_api.plan_turn( [_chunk(doc, 0.05, cosine=0.05)], _settings(threshold=0.30) ) assert low_with.deflected is True assert low_with.system_prompt == low_without.system_prompt assert "Homelab/: x.md" not in low_with.system_prompt def test_plan_turn_high_single_strong_doc_yields_one_suggested() -> None: """The suggested cap is a CEILING, not a quota: one doc ⇒ one suggested doc, an empty related tier (nothing beyond rank 1).""" strong = _doc("Kubernetes Homelab Cluster", "STRONG_DOC_CONTENT") plan = chat_api.plan_turn([_chunk(strong, 0.90, cosine=0.80)], _tier_settings()) assert plan.deflected is False assert [d.title for d in plan.suggested_docs] == ["Kubernetes Homelab Cluster"] assert plan.related_docs == [] # No stored summary (the fixture default) → the A5 preview fallback # carries the short content whole (under the 400-char cap). assert "STRONG_DOC_CONTENT" in plan.system_prompt def test_plan_turn_high_suggests_strong_and_weak_no_floor() -> None: """The recurring incident under phase 118 (A3): the weak 2nd doc no longer loses a seeding slot to a bar — the floor never filters, so BOTH docs are suggested (rank order) and ride the durable record (118-A3 — the phase-119 A1 citation surface is the read docs only); the HIGH prompt seeds both summaries (the A5 fallback carries the short fixture content whole).""" strong = _doc("Kubernetes Homelab Cluster", "STRONG_DOC_CONTENT") weak = _doc("Backup Strategy", "WEAK_DOC_CONTENT") chunks = [_chunk(strong, 0.90, cosine=0.80), _chunk(weak, 0.80, cosine=0.20)] plan = chat_api.plan_turn(chunks, _tier_settings()) assert plan.deflected is False assert [d.title for d in plan.suggested_docs] == [ "Kubernetes Homelab Cluster", "Backup Strategy", ] assert plan.related_docs == [] assert "STRONG_DOC_CONTENT" in plan.system_prompt assert "WEAK_DOC_CONTENT" in plan.system_prompt def test_plan_turn_low_weak_hits_are_suggested_record() -> None: """Deflected turn: the weak hits are SUGGESTED too (no floor, A3) — the TurnPlan carries suggested + related for the durable record — while the LOW prompt itself stays byte-identical (weak-hit titles only, never content).""" a = _doc("Alpha", "ALPHA_DOC_NEVER_SENT") b = _doc("Beta", "BETA_DOC_NEVER_SENT") chunks = [_chunk(a, 0.30, cosine=0.20), _chunk(b, 0.20, cosine=0.15)] plan = chat_api.plan_turn(chunks, _tier_settings()) assert plan.deflected is True assert [d.title for d in plan.suggested_docs] == ["Alpha", "Beta"] # rank order assert plan.related_docs == [] # nothing beyond rank 2 for 2 docs assert "ALPHA_DOC_NEVER_SENT" not in plan.system_prompt assert "Beta" in plan.system_prompt # weak-hit titles still carried assert plan.suggestions # chips unchanged def test_plan_turn_related_cap_zero_kills_the_related_tier() -> None: """related_max_docs=0 is the kill switch: rank-6+ docs are scored and suggested-adjacent but neither suggested nor related — the done frame's row stays empty.""" docs_in = _seven_docs_with_summaries() chunks = [ _chunk(d, 0.9 - 0.1 * i, cosine=0.8 - 0.05 * i) for i, d in enumerate(docs_in) ] plan = chat_api.plan_turn(chunks, _tier_settings(related_cap=0)) assert plan.deflected is False assert len(plan.suggested_docs) == 5 # the suggestion tier is untouched assert plan.related_docs == [] # The cap restores the rank-6+ row (rank order, capped). wide = chat_api.plan_turn(chunks, _tier_settings(related_cap=3)) assert [d.title for d in wide.related_docs] == ["Doc 5", "Doc 6"] def test_plan_turn_suggested_docs_setting_caps_the_suggested_tier() -> None: """``suggested_docs`` (``BOR_SUGGESTED_DOCS``) is honored as the suggestion cap: 3 here ⇒ the top-3 rank-ordered docs are suggested and rank 4+ falls to the related tier (capped at ``related_max_docs``).""" docs_in = _seven_docs_with_summaries() chunks = [ _chunk(d, 0.9 - 0.1 * i, cosine=0.8 - 0.05 * i) for i, d in enumerate(docs_in) ] plan = chat_api.plan_turn(chunks, _tier_settings(suggested_cap=3)) assert [d.title for d in plan.suggested_docs] == ["Doc 0", "Doc 1", "Doc 2"] assert [d.title for d in plan.related_docs] == ["Doc 3", "Doc 4"] # cap 2 def test_plan_turn_does_not_consult_retired_seeding_settings() -> None: """Phase 118 (A6): ``top_n_docs`` and ``source_usefulness_floor`` lost their seeding role — ``plan_turn`` never consults them. A degenerate config (the maximum legal bar — 0.62, above every chunk's cosine of 0.50 — and a top-N of 1) changes nothing: the suggested tier is still the no-floor top-5 in rank order and the related tier is still rank 6+. (Behavioral pin — the settings themselves stay, env back-compat.)""" docs_in = _seven_docs_with_summaries() chunks = [ _chunk(docs_in[0], 0.9, cosine=0.50, fts_hit=True), ] + [ _chunk(d, 0.9 - 0.1 * i, cosine=0.50) for i, d in enumerate(docs_in[1:], start=1) ] settings = Settings( _env_file=None, # pyright: ignore[reportCallIssue] relevance_threshold=0.62, lexical_support_floor=0.35, top_n_docs=1, # retired: the old full-text seeding ceiling # retired: the maximum legal bar (== threshold) — above every # cosine here (0.50), so the OLD tiering would cite nothing. source_usefulness_floor=0.62, related_max_docs=2, ) plan = chat_api.plan_turn(chunks, settings) assert plan.deflected is False # 0.50 >= the 0.35 lex floor, fts fired assert [d.title for d in plan.suggested_docs] == [f"Doc {i}" for i in range(5)] assert [d.title for d in plan.related_docs] == ["Doc 5", "Doc 6"] # ---------- summary hits (phase 30: summary → full source document) ---------- def test_summary_hit_on_suggested_doc_counts() -> None: """HIGH branch: a suggested (rank-1) document hit via its summary chunk ⇒ 1. Phase 118 (A6): the *summary* is what the LLM sees in the prompt (no stored summary here → the A5 preview fallback carries the short fixture content whole).""" a = _doc("Alpha", "ALPHA_FULL_SOURCE_CONTENT") b = _doc("Beta", "BETA_FULL_SOURCE_CONTENT") chunks = [ _chunk(a, 0.90, is_summary=True), # suggested doc reached through its summary _chunk(b, 0.50), ] plan = chat_api.plan_turn(chunks, _settings(threshold=0.30)) assert plan.deflected is False assert plan.summary_hits == 1 # The A5 preview fallback (short content, under the 400-char cap) # carries the content whole — the block body, never more. assert "ALPHA_FULL_SOURCE_CONTENT" in plan.system_prompt def test_summary_hits_counts_suggested_parent_only() -> None: """Phase 118 redefinition (redefined from the phase-113 cited set): a summary chunk counts ONLY when its parent document is in the SUGGESTED set — a rank-1 (suggested) parent counts, a rank-6 (related-tier-only) parent does not.""" docs_in = [_doc(f"Doc {i}", f"CONTENT_{i}") for i in range(7)] chunks = [ _chunk(docs_in[0], 0.90, is_summary=True), # suggested parent — counts _chunk(docs_in[1], 0.80), _chunk(docs_in[2], 0.70), _chunk(docs_in[3], 0.60), _chunk(docs_in[4], 0.50), _chunk(docs_in[5], 0.40, is_summary=True), # related-only parent — does not _chunk(docs_in[6], 0.30), ] plan = chat_api.plan_turn(chunks, _settings(threshold=0.30)) assert plan.deflected is False assert [d.title for d in plan.suggested_docs] == [f"Doc {i}" for i in range(5)] assert [d.title for d in plan.related_docs] == ["Doc 5", "Doc 6"] assert plan.summary_hits == 1 def test_low_branch_counts_summary_hit_on_suggested_doc() -> None: """LOW (deflected) branch records ``summary_hits`` too: the weak hit's parent is still the suggested (weak-hit) document (no floor, A3).""" a = _doc("Gamma", "GAMMA_DOC_CONTENT") b = _doc("Delta", "DELTA_DOC_CONTENT") chunks = [ _chunk(a, 0.05, cosine=0.05, is_summary=True), # weak cosine, no FTS _chunk(b, 0.03, cosine=0.03), ] plan = chat_api.plan_turn(chunks, _settings(threshold=0.30)) assert plan.deflected is True assert plan.summary_hits == 1 def test_no_summary_chunks_yields_zero_summary_hits() -> None: """Legacy chunks (``is_summary=false``) keep ``summary_hits == 0``.""" a = _doc("Alpha", "ALPHA_CONTENT") b = _doc("Beta", "BETA_CONTENT") plan = chat_api.plan_turn([_chunk(a, 0.90), _chunk(b, 0.40)], _settings(threshold=0.30)) assert plan.summary_hits == 0 plan_low = chat_api.plan_turn([_chunk(a, 0.05, cosine=0.05)], _settings(threshold=0.30)) assert plan_low.deflected is True assert plan_low.summary_hits == 0 # ---------- KB overview (phase 31: section + kb_chars) ---------- def test_plan_turn_high_injects_kb_overview() -> None: """HIGH branch: the stored outline lands in the prompt between ```` and ```` (or the ```` 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 "" in prompt assert KB_OVERVIEW in prompt i_rel = prompt.index("HIGH") i_kb = prompt.index("") i_docs = prompt.index("") assert i_rel < i_kb < i_docs def test_plan_turn_high_kb_section_ordered_before_tuning() -> None: """Both sections present: ```` → ```` → ```` → ```` (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("HIGH") i_kb = prompt.index("") i_kb_close = prompt.index("") i_tuning = prompt.index("") i_docs = prompt.index("") 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 "" in prompt assert KB_OVERVIEW in prompt i_rel = prompt.index("LOW") i_kb = prompt.index("") 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 "" not in plan.system_prompt # ---------- prompt content (LOW vs HIGH) ---------- def test_low_prompt_has_titles_only_no_content() -> None: a = _doc("Kubernetes Homelab Cluster", "ALPHA_DOC_CONTENT") b = _doc("Backup Strategy", "BETA_DOC_CONTENT") plan = chat_api.plan_turn([_chunk(b, 0.10), _chunk(a, 0.20)], _settings()) prompt = plan.system_prompt assert "LOW" in prompt assert "DEFLECT_MODE" in prompt assert "HONESTY GATE" in prompt # the LOW rule is what the model follows # Phase 71 (owner-permitted 2026-09-03): the deflection plain-text # line — the LOW turn offers no tools, so any tool markup there is # always wrong (prevention at the prompt; the filter + recovery is # the backstop). assert "Reply in plain text only — you have no tools in this mode." in prompt assert "- Kubernetes Homelab Cluster" in prompt assert "- Backup Strategy" in prompt assert "ALPHA_DOC_CONTENT" not in prompt assert "BETA_DOC_CONTENT" not in prompt assert "" not in prompt def test_high_path_unaffected() -> None: a = _doc("Kubernetes Homelab Cluster", "ALPHA_DOC_CONTENT") b = _doc("Backup Strategy", "BETA_DOC_CONTENT") plan = chat_api.plan_turn([_chunk(a, 0.90), _chunk(b, 0.40)], _settings()) assert plan.deflected is False assert plan.suggestions == [] assert "HIGH" in plan.system_prompt assert "DEFLECT_MODE" not in plan.system_prompt # Phase 71: the deflection plain-text line never leaks into HIGH. assert "Reply in plain text only" not in plan.system_prompt assert "ALPHA_DOC_CONTENT" in plan.system_prompt assert "BETA_DOC_CONTENT" in plan.system_prompt # Phase 118 (A3): both docs are suggested (no floor) — the durable- # record input — with nothing left for the related tier (rank 6+). assert [d.title for d in plan.suggested_docs] == [ "Kubernetes Homelab Cluster", "Backup Strategy", ] assert plan.related_docs == [] # ---------- deflected branch: byte-identical (A8 gate untouched) ---------- def test_low_prompt_byte_identical_to_pre_task_sha_pin() -> None: """Phase 118 (A8 gate untouched): the deflected prompt is BYTE-IDENTICAL to pre-task on the same chunks — it is exactly ``build_deflect_prompt(weak_hit_titles(…))`` (the tiering feeds the TurnPlan's durable-record fields, never the LOW prompt). The sha256 pin makes any future LOW-body drift loud; the suggestions/chips and the deflected flag are unchanged.""" a = _doc("Kubernetes Homelab Cluster", "ALPHA_DOC_CONTENT") b = _doc("Backup Strategy", "BETA_DOC_CONTENT") chunks = [_chunk(b, 0.10), _chunk(a, 0.20)] plan = chat_api.plan_turn(chunks, _settings()) assert plan.deflected is True expected = build_deflect_prompt(weak_hit_titles(chunks)) assert plan.system_prompt == expected # byte-identical to the pre-task build assert ( hashlib.sha256(plan.system_prompt.encode("utf-8")).hexdigest() == "603395e013c97be8a13837eda533c0b7bd5da4f7a0806b0ae8ad7d6c52420913" ) assert plan.suggestions # the "Maybe try" chips are unchanged # Both tiers still ride the plan (the durable-record inputs, A3). assert [d.title for d in plan.suggested_docs] == [ "Kubernetes Homelab Cluster", "Backup Strategy", ] assert plan.related_docs == [] # ---------- weak_hit_titles (fake retriever mapping) ---------- def test_weak_hit_titles_dedupe_and_rank_by_best_score() -> None: a = _doc("Kubernetes Homelab Cluster", "AAA") b = _doc("Backup Strategy", "BBB") chunks = [_chunk(b, 0.5), _chunk(a, 0.2), _chunk(a, 0.9)] assert weak_hit_titles(chunks) == ["Kubernetes Homelab Cluster", "Backup Strategy"] # ---------- suggestions derivation ---------- def test_suggestions_derived_from_titles_in_order() -> None: got = derive_suggestions( ["Kubernetes Homelab Cluster", "Backup Strategy", "Deploying a New Service"], fallback=["should not appear"], ) assert len(got) == 3 assert all(s.strip() for s in got) assert "Kubernetes Homelab Cluster" in got[0] assert "Backup Strategy" in got[1] assert "Deploying a New Service" in got[2] def test_suggestions_capped_at_three() -> None: got = derive_suggestions([f"Title {i}" for i in range(6)], fallback=["F"]) assert len(got) == MAX_SUGGESTIONS == 3 def test_suggestions_top_up_from_fallback_when_titles_thin() -> None: got = derive_suggestions( ["Backup Strategy"], fallback=["How is my Kubernetes cluster set up?", "What's my backup strategy?"], ) assert len(got) == 3 assert got[0] == "What's in your notes about Backup Strategy?" assert got[1] == "How is my Kubernetes cluster set up?" def test_suggestions_dedupes_and_ignores_blank() -> None: got = derive_suggestions( ["Backup Strategy", "backup strategy", " "], fallback=["What's my backup strategy?", " "], ) # "backup strategy" is a case-insensitive dup; blank title/fallback are # skipped — including ones that only look blank after formatting. Only # two valid items remain, and the list never pads with junk. assert got == [ "What's in your notes about Backup Strategy?", "What's my backup strategy?", ] assert all("about ?" not in s and s == s.strip() for s in got) def test_suggestions_empty_input_yields_fallback_only() -> None: assert derive_suggestions([], fallback=[]) == [] got = derive_suggestions([], fallback=["One?", "Two?"]) assert got == ["One?", "Two?"] # ---------- endpoint-level gate (fake retriever + fake LLM + fake session) ---------- class _CannedLLM: """Records the messages it is given; streams a canned answer. Without *read_paths* it never emits tool calls, so a grounded turn through the phase-37 agent loop ends after the single (tools-offered) request. With *read_paths*, the request whose conversation carries *i* tool results (i < len(read_paths)) emits ``read(read_paths[i])`` (phase 119: the read-only done-sources pins drive multi-read turns), and once the list is exhausted the request streams the answer. *seen_tools* records each request's ``tools`` value for the phase-37 wiring pins. """ def __init__(self, answer: str = ANSWER, read_paths: Sequence[str] = ()) -> None: self.settings = Settings(_env_file=None) # pyright: ignore[reportCallIssue] self.embed_batches = 0 self.answer = answer self.read_paths = list(read_paths) self.seen: list[list[dict[str, str]]] = [] self.seen_tools: list[list[dict[str, Any]] | None] = [] async def embed_one(self, _text: str) -> list[float]: return [0.0] * 768 async def chat_stream( self, messages: list[dict[str, str]], tools: list[dict[str, Any]] | None = None, scaffolding: ScaffoldingFilter | None = None, # phase 71 pass-through ): self.seen.append(messages) self.seen_tools.append(tools) if tools is not None and self.read_paths: tool_results = sum(1 for m in messages if m.get("role") == "tool") if tool_results < len(self.read_paths): yield ToolCallPiece( id=f"call_{tool_results + 1}", name="read", arguments={"path": self.read_paths[tool_results]}, ) return for i in range(0, len(self.answer), 12): yield StreamPiece("content", self.answer[i : i + 12]) class _ToolCallCannedLLM(_CannedLLM): """Streams ONE tool call (the given name + arguments) on the first request, then the canned answer — the phase-146 (task 01) frame- builder driver: the endpoint's ``ToolCallPiece`` branch (the ``_pump`` loop in ``app.api.chat``) is the ONE extraction path every ``tool`` frame goes through, so the scope guard is pinned through the real endpoint (no parallel re-implementation).""" def __init__( self, tool_name: str, arguments: dict[str, Any], answer: str = ANSWER ) -> None: super().__init__(answer=answer) self.tool_name = tool_name self.arguments = arguments self._emitted = False async def chat_stream( self, messages: list[dict[str, str]], tools: list[dict[str, Any]] | None = None, scaffolding: ScaffoldingFilter | None = None, # phase 71 pass-through ): self.seen.append(messages) self.seen_tools.append(tools) if not self._emitted: self._emitted = True yield ToolCallPiece( id="call_1", name=self.tool_name, arguments=self.arguments ) return for i in range(0, len(self.answer), 12): yield StreamPiece("content", self.answer[i : i + 12]) class _FakeSteeringResult: """Empty steering-note result (no stored notes in these unit tests).""" def all(self) -> list[Any]: return [] class _FakeSession: """Stands in for the DB session: records the QueryLog row it is given. ``scalars`` always yields no steering notes (phase 15) so the chat 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, kb_overview: str = "") -> None: self.added: list[Any] = [] self.commits = 0 self.kb_overview = kb_overview def __enter__(self) -> _FakeSession: return self def __exit__(self, *args: Any) -> None: pass def add(self, obj: Any) -> None: self.added.append(obj) def commit(self) -> None: self.commits += 1 def scalars(self, _stmt: Any) -> _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 def execute(self, *args: Any, **kwargs: Any) -> list[Any]: """Phase 119 (D3): the endpoint's suggested-folder lines run the ``ls`` catalog fetchers (``_source_document_rows`` / ``_source_folder_summaries``) on this session — the fake catalog is empty, so each suggested doc's line is its header alone (``Homelab/:``), keeping the prompt builds deterministic in these gate tests.""" return [] @pytest.fixture(autouse=True) def _admin_signed_in(client: TestClient) -> None: """Phase 79 (task 03): ``POST /api/chat`` is user-gated — the endpoint-level tests run as the signed-in ADMIN, so the shared ``client`` logs in once per test. The admin session short-circuits ``require_user`` before any DB touch, so the fake-session wiring in ``gate_env`` is untouched.""" r = client.post("/api/login", json={"password": ADMIN_PASSWORD}) assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}" @pytest.fixture() def gate_env(monkeypatch: pytest.MonkeyPatch) -> Iterator[tuple[_FakeSession, _CannedLLM]]: """``POST /api/chat`` with retriever, session, and LLM all faked. SEC-14-04: the chat endpoint uses short-lived sessions via ``SessionLocal()`` — monkeypatch ``chat_api.SessionLocal`` instead of overriding ``get_db``. """ monkeypatch.setattr(chat_api, "db_available", lambda: True) session = _FakeSession() llm = _CannedLLM() monkeypatch.setattr(chat_api, "SessionLocal", lambda: session) monkeypatch.setitem(fastapi_app.dependency_overrides, chat_api.get_llm, lambda: llm) # These tests assert against a specific gate threshold; keep it stable # regardless of the production default (0.62) or any .env. monkeypatch.setattr( chat_api, "get_settings", lambda: Settings(_env_file=None, relevance_threshold=0.30), # pyright: ignore[reportCallIssue] ) yield session, llm fastapi_app.dependency_overrides.clear() def _ask(client: TestClient, message: str) -> list[dict[str, Any]]: with client.stream("POST", "/api/chat", json={"message": message}) as r: assert r.status_code == 200 frames: list[dict[str, Any]] = [] buf = "" for part in r.iter_text(): buf += part while "\n\n" in buf: frame, buf = buf.split("\n\n", 1) frame = frame.strip() if frame.startswith("data:"): frames.append(json.loads(frame.removeprefix("data:").strip())) assert buf.strip() == "" return frames def _fake_retriever(chunks: list[RetrievedChunk]) -> Any: def retrieve(_db: Any, _question: str, _vec: list[float]) -> list[RetrievedChunk]: return chunks return retrieve def _find_docs(docs: dict[tuple[str, str], Document]) -> Any: """A fake ``find_document`` resolving *docs* by ``(source, path)`` (the agent module's hook — the fake sessions here have no catalog).""" def find_document(_db: Any, source: str, path: str) -> Document | None: return docs.get((source, path)) return find_document def test_endpoint_just_below_threshold_deflects( client: TestClient, gate_env: tuple[_FakeSession, _CannedLLM], monkeypatch: pytest.MonkeyPatch, ) -> None: session, llm = gate_env doc = _doc("Deploying a New Service", "DOC_CONTENT_NEVER_SENT") monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.2999)])) frames = _ask(client, "How do I bake sourdough bread?") deltas = [f for f in frames if f["type"] == "delta"] assert "".join(d["text"] for d in deltas) == ANSWER # the LLM was still called done = frames[-1] assert done["type"] == "done" assert done["deflected"] is True assert 2 <= len(done["suggestions"]) <= MAX_SUGGESTIONS # title chip + fallback assert all(s.strip() for s in done["suggestions"]) assert any("Deploying a New Service" in s for s in done["suggestions"]) # Phase 112 (A8 revised, TODO L2): a deflected turn cites nothing — # done.sources is the citation surface (the UI chips every entry as # "the answer used this"), and the weak hits are scored docs, not # citations. assert done["sources"] == [] # The LLM saw the LOW prompt: DEFLECT_MODE + titles, never doc content. (system, user) = llm.seen[0][0], llm.seen[0][1] assert user["content"] == "How do I bake sourdough bread?" assert "DEFLECT_MODE" in system["content"] assert "DOC_CONTENT_NEVER_SENT" not in system["content"] # Durable record: deflected + the weak score. The retrieval itself # stays recorded (observability unchanged — query_log records # retrieval, not citations; the phase-113 A3 precedent). (row,) = session.added assert isinstance(row, QueryLog) assert row.deflected is True assert row.top_score == pytest.approx(0.2999) assert row.sources # the weak-hit doc's path, for threshold tuning assert session.commits == 1 def test_endpoint_grounded_turn_runs_agent_loop_with_tools( client: TestClient, gate_env: tuple[_FakeSession, _CannedLLM], monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 37: a grounded endpoint turn runs the agent loop — the single no-tool-call request carries the offered tool list (the default round cap keeps the tools offered), no ``tool`` frames stream, and the ``done`` event is the plain retrieval shape (the tool-free answer is byte-identical). Phase 182 (task 03) pin update, deliberate: the offer is the three base tools plus ``show_text`` (``answer_text`` defaults ON), the base three staying element-for-element ``AGENT_TOOLS`.""" _session, llm = gate_env doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_SENT") monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.90)])) frames = _ask(client, "How is my Kubernetes cluster set up?") assert frames[-1]["type"] == "done" assert frames[-1]["deflected"] is False assert not any(f["type"] == "tool" for f in frames) assert len(llm.seen) == 1 # Phase 182 (task 03): ``answer_text`` defaults ON — the offer is # the base three (byte-identical) plus ``show_text``. assert llm.seen_tools == [[*AGENT_TOOLS, SHOW_TEXT_TOOL]] # one request offered = llm.seen_tools[0] assert offered is not None assert offered[:3] == AGENT_TOOLS # the base offer untouched # The system prompt is the HIGH prompt with the instructions # (phase 70: the harness-aligned ls/read/grep copy — new names in, # old phase-37/68 names out). (system, _user) = llm.seen[0][0], llm.seen[0][1] assert "HIGH" in system["content"] assert "" in system["content"] for tool in ("`ls`", "`grep`", "`read`"): assert tool in system["content"] for old in ("list_documents", "read_document", "search_documents"): assert old not in system["content"] def test_endpoint_deflected_turn_never_offers_tools( client: TestClient, gate_env: tuple[_FakeSession, _CannedLLM], monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 37: a deflected endpoint turn keeps the direct ``chat_stream`` — the single request carries no ``tools`` key (``seen_tools == [None]``), A8 byte-identical.""" _session, llm = gate_env doc = _doc("Deploying a New Service", "DOC_CONTENT_NEVER_SENT") monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.2999)])) frames = _ask(client, "How do I bake sourdough bread?") assert frames[-1]["type"] == "done" assert frames[-1]["deflected"] is True assert not any(f["type"] == "tool" for f in frames) assert len(llm.seen) == 1 assert llm.seen_tools == [None] (system, _user) = llm.seen[0][0], llm.seen[0][1] assert "" not in system["content"] # the LOW prompt never carries it # Phase 70: the rewritten copy stays out of the deflected path # (the LOW prompt is byte-identical to the pre-phase text). assert "You may extend your context with three tools" not in system["content"] def test_endpoint_score_at_threshold_answers_zero_read_grounded_turn_chips_nothing( client: TestClient, gate_env: tuple[_FakeSession, _CannedLLM], monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 119 (LOCKED A1 — the phase-118 "always chips" pin re-targeted): a grounded turn on which the agent reads NOTHING (the summary-only fast path — the canned LLM never emits a tool call) chips nothing: ``done.sources`` is the READ DOCS only, so the never-read suggested doc appears NOWHERE in it (the explicit contrast against the retired A4 union). The durable record keeps the retrieval (118-A3 stands).""" session, llm = gate_env doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_SENT") monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.30)])) frames = _ask(client, "How is my Kubernetes cluster set up?") done = frames[-1] assert done["type"] == "done" assert done["deflected"] is False assert done["suggestions"] == [] # A1: read docs only — nothing was read ⇒ no chips. The suggested # doc (rank 1) appears NOWHERE in done.sources (the retired A4 union # would have carried it). assert done["sources"] == [] (system, _user) = llm.seen[0][0], llm.seen[0][1] assert "HIGH" in system["content"] assert "DEFLECT_MODE" not in system["content"] assert "TALOS_DOC_SENT" in system["content"] # The durable record still records the retrieval (118-A3 stands). (row,) = session.added assert isinstance(row, QueryLog) assert row.deflected is False assert "kubernetes-homelab-cluster.md" in row.sources def test_endpoint_done_sources_are_the_read_docs_in_read_order( client: TestClient, gate_env: tuple[_FakeSession, _CannedLLM], monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 119 (LOCKED A1): the done frame's ``sources`` is exactly the agent's READ documents — deduped by (source, path), READ order (not the suggested tier's rank order). Suggested docs that were never read appear NOWHERE in the frame (the contrast pin against the retired A4 union); the durable record keeps the full retrieval (118-A3 stands).""" session, _llm = gate_env a = _doc("Alpha", "ALPHA_DOC_CONTENT") b = _doc("Beta", "BETA_DOC_CONTENT") c = _doc("Gamma", "GAMMA_DOC_CONTENT") chunks = [_chunk(a, 0.90), _chunk(b, 0.80), _chunk(c, 0.70)] monkeypatch.setattr(chat_api, "retrieve", _fake_retriever(chunks)) llm = _CannedLLM(read_paths=[f"Homelab/{b.path}", f"Homelab/{a.path}"]) fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: llm monkeypatch.setattr( rag_agent, "find_document", _find_docs({(a.source, a.path): a, (b.source, b.path): b}), ) frames = _ask(client, "Which of these documents do I have?") done = frames[-1] assert done["deflected"] is False # Both reads streamed as tool frames (the agent loop ran). reads = [f for f in frames if f["type"] == "tool"] assert [f["argument"] for f in reads] == [ f"Homelab/{b.path}", f"Homelab/{a.path}", ] # A1: read docs only, read order — B first (it was read first), A # second; the never-read suggested doc C is NOWHERE in the frame, and # the suggested tier's rank order (A, B, C) is NOT the chip order. assert [(s["source"], s["path"]) for s in done["sources"]] == [ (b.source, b.path), (a.source, a.path), ] assert not any(s["path"] == c.path for s in done["sources"]) assert len(done["sources"]) == len({(s["source"], s["path"]) for s in done["sources"]}) assert done["related"] == [] # nothing beyond rank 3 for 3 docs # The durable record keeps the full retrieval (118-A3 stands). (row,) = session.added assert isinstance(row, QueryLog) for doc in (a, b, c): assert f"Homelab/{doc.path}" in row.sources def test_endpoint_read_related_doc_is_cited_not_related( client: TestClient, gate_env: tuple[_FakeSession, _CannedLLM], monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 119 (A1) × phase 118 (A3): a related-tier doc (rank 6+) that the agent ``read``s is a CHIP (in ``done.sources``) and is EXCLUDED from ``done.related`` — a "nearby doc" that was actually used must not read as nearby (unchanged intent, keyed on read docs since phase 119). The suggested docs, never read, are absent from the frame; the other related doc stays in the tier.""" session, _llm = gate_env docs = [_doc(f"Doc {i}", f"CONTENT_{i}") for i in range(7)] chunks = [ _chunk(d, 0.9 - 0.1 * i, cosine=0.8 - 0.05 * i) for i, d in enumerate(docs) ] monkeypatch.setattr(chat_api, "retrieve", _fake_retriever(chunks)) read_doc = docs[5] # rank 6 — the related tier (rank 7 = docs[6]) llm = _CannedLLM(read_paths=[f"Homelab/{read_doc.path}"]) fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: llm monkeypatch.setattr( rag_agent, "find_document", _find_docs({(read_doc.source, read_doc.path): read_doc}), ) frames = _ask(client, "What does the sixth ranked document say?") done = frames[-1] assert done["deflected"] is False # A1: read docs only — the read related doc is the ONLY chip (the # never-read suggested docs are nowhere in the frame). assert [(s["source"], s["path"]) for s in done["sources"]] == [ (read_doc.source, read_doc.path) ] # The read related doc is NOT "nearby"; the other related doc is. related = [(s["source"], s["path"]) for s in done["related"]] assert related == [(docs[6].source, docs[6].path)] assert not any((d.source, d.path) in related for d in docs[:5]) # The durable record keeps the full retrieval (118-A3 stands). (row,) = session.added assert isinstance(row, QueryLog) assert row.deflected is False for d in docs: assert f"Homelab/{d.path}" in row.sources def test_endpoint_suggested_folder_lines_reach_the_high_prompt( client: TestClient, gate_env: tuple[_FakeSession, _CannedLLM], monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 119 (D3, LOCKED A4): the endpoint computes the suggested-folder lines BEFORE plan_turn (the same deterministic suggested walk, over the short-lived step session) and they ride the grounded HIGH prompt — after ``SUGGEST_INTRO``, before the first ```` block.""" _session, llm = gate_env doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_SENT") monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.30)])) line = "Homelab/: kubernetes-homelab-cluster.md (2 docs)" monkeypatch.setattr( chat_api, "suggested_folder_lines", lambda db, suggested: [line], ) frames = _ask(client, "How is my Kubernetes cluster set up?") assert frames[-1]["deflected"] is False (system, _user) = llm.seen[0][0], llm.seen[0][1] i_open = system["content"].index("") i_block = system["content"].index("\n{SUGGEST_INTRO}\n{line}\n\n" ) # ---------- endpoint: grep scope frame (phase 146, task 01) ---------- def test_endpoint_scoped_grep_frame_carries_path( client: TestClient, gate_env: tuple[_FakeSession, _CannedLLM], monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 146 (task 01): a SCOPED grep (``grep(pattern, path)``) streams a ``tool`` frame that carries the scope — the re-searched document's combined ``source/path`` — as the additive ``path`` field, with ``argument`` still the model's ``pattern`` (the ONLY case the field is present on the wire).""" _session, _llm = gate_env doc = _doc("The Ops Handbook", "page one\nthe grep me line\npage three") monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.90)])) llm = _ToolCallCannedLLM("grep", {"pattern": "grep me", "path": f"Homelab/{doc.path}"}) fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: llm monkeypatch.setattr( rag_agent, "find_document", _find_docs({(doc.source, doc.path): doc}), ) frames = _ask(client, "Where does the handbook mention grep me?") tools = [f for f in frames if f["type"] == "tool"] assert tools == [ { "type": "tool", "name": "grep", "argument": "grep me", "path": f"Homelab/{doc.path}", } ] assert frames[-1]["type"] == "done" assert frames[-1]["deflected"] is False assert len(llm.seen) == 2 # the grep round + the answer round def test_endpoint_bare_grep_frame_has_no_path_key( client: TestClient, gate_env: tuple[_FakeSession, _CannedLLM], monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 146 (task 01): a bare grep's frame is byte-identical to pre-phase — the ``path`` key is ABSENT (never ``null``) and no other frame of the turn gains a key either.""" _session, _llm = gate_env doc = _doc("The Ops Handbook", "content") monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.90)])) llm = _ToolCallCannedLLM("grep", {"pattern": "grep me"}) fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: llm monkeypatch.setattr(rag_agent, "all_documents", lambda _db: []) frames = _ask(client, "Search the whole KB for grep me") tools = [f for f in frames if f["type"] == "tool"] assert tools == [{"type": "tool", "name": "grep", "argument": "grep me"}] assert all("path" not in f for f in frames) assert frames[-1]["type"] == "done" def test_endpoint_grep_non_string_path_scope_omitted( client: TestClient, gate_env: tuple[_FakeSession, _CannedLLM], monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 146 (task 01): a NON-string ``path`` (the model-error shape — a list here; the agent refuses the call) never reaches the frame: the builder's string guard yields ``None`` → the key is omitted, so the frame is the exact bare-grep shape.""" _session, _llm = gate_env doc = _doc("The Ops Handbook", "content") monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.90)])) llm = _ToolCallCannedLLM("grep", {"pattern": "grep me", "path": ["s", "d.md"]}) fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: llm monkeypatch.setattr(rag_agent, "all_documents", lambda _db: []) frames = _ask(client, "Search the whole KB for grep me") tools = [f for f in frames if f["type"] == "tool"] assert tools == [{"type": "tool", "name": "grep", "argument": "grep me"}] assert all("path" not in f for f in frames) assert frames[-1]["type"] == "done" def test_endpoint_read_and_ls_frames_never_carry_scope( client: TestClient, gate_env: tuple[_FakeSession, _CannedLLM], monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 146 (task 01): ``read`` and ``ls`` NEVER read a scope from their arguments — even though their ``path`` argument IS a string (the combined ``source/path`` / the source name), it rides ``argument`` (the pre-phase wire shape) and the frame has no ``path`` key.""" _session, _llm = gate_env doc = _doc("The Ops Handbook", "content") monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.90)])) monkeypatch.setattr( rag_agent, "find_document", _find_docs({(doc.source, doc.path): doc}), ) monkeypatch.setattr(rag_agent, "list_source_names", lambda _db: []) # A plain read (its ``path`` argument is a string — argument, not # scope; the frame stays the exact pre-phase shape). llm = _ToolCallCannedLLM("read", {"path": f"Homelab/{doc.path}"}) fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: llm frames = _ask(client, "What does the handbook say?") reads = [f for f in frames if f["type"] == "tool"] assert reads == [{"type": "tool", "name": "read", "argument": f"Homelab/{doc.path}"}] assert all("path" not in f for f in frames) assert frames[-1]["type"] == "done" # A scoped ls (same — the argument is the source-name string). llm = _ToolCallCannedLLM("ls", {"path": "Homelab"}) fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: llm frames = _ask(client, "List my sources") lss = [f for f in frames if f["type"] == "tool"] assert lss == [{"type": "tool", "name": "ls", "argument": "Homelab"}] assert all("path" not in f for f in frames) assert frames[-1]["type"] == "done" # ---------- 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 "" in system["content"] assert KB_OVERVIEW in system["content"] assert system["content"].index("HIGH") < system["content"].index( "" ) < system["content"].index("") 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 "" 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] def test_endpoint_log_line_records_suggested_after_summary_hits( client: TestClient, gate_env: tuple[_FakeSession, _CannedLLM], monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """Phase 118 (PLAN §9 extension): the per-turn log line gains ``suggested=N`` immediately AFTER ``summary_hits=N`` — the field order of every existing field is untouched (the phase-114 ``retries=N scaffold_stripped=N`` tail stays last). The value is the seeded suggestion tier's size (``len(plan.suggested_docs)``). Phase 172 (A6): the line gains ``thinking=`` immediately BEFORE ``thinking_chars=N`` — the effective (overlay-resolved) ``BOR_LLM_CHAT_THINKING_LEVEL`` (the chat level), trimmed, ``-`` when empty (the off switch). Phase 178 (A5): the field stays pinned to the CHAT level — the level the turn actually ran at; the one-shot summary level (``llm_summary_thinking_level``, a different knob) is never read by the line and must not leak into the line's ``thinking=`` value (cross-pin + inverse off-pin below).""" _session, _llm = gate_env doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_SENT") chunks = [_chunk(doc, 0.30), _chunk(doc, 0.20, is_summary=True)] monkeypatch.setattr(chat_api, "retrieve", _fake_retriever(chunks)) def _last_log_line() -> str: caplog.clear() with caplog.at_level("INFO", logger="app.chat"): _ask(client, "How is my Kubernetes cluster set up?") log_lines = [r.getMessage() for r in caplog.records if "question=" in r.getMessage()] assert log_lines return log_lines[-1] # The default (the fixture's settings carry the field default): # thinking=low, immediately before thinking_chars. line = _last_log_line() assert "thinking=low thinking_chars=" in line # The new slot: suggested=N right after summary_hits=N (one suggested # doc — the single fixture doc — and one summary hit on it). assert "summary_hits=1 suggested=1" in line # The full field order (the phase-114 tail stays last; phase 172 # inserts thinking= between sources= and thinking_chars=). order = ( "question=", "embed_ms=", "top_score=", "fts_hits=", "summary_hits=", "suggested=", "tuning=", "kb_chars=", "history_msgs=", "threshold=", "deflected=", "sources=", "thinking=", "thinking_chars=", "tool_calls=", "rounds=", "cap_hit=", "total_ms=", "retries=", "scaffold_stripped=", # phase 182 (task 05): the PLAN §9 additive tail — the blocks # actually SENT this turn, last on the line. "verbatim_blocks=", ) idx = -1 for field in order: pos = line.find(field) assert pos > idx, f"{field} out of order in the per-turn log line" idx = pos # The effective value: a non-default setting rides the line as-is. monkeypatch.setattr( chat_api, "get_settings", lambda: Settings(_env_file=None, relevance_threshold=0.30, llm_chat_thinking_level="high"), # pyright: ignore[reportCallIssue] ) line = _last_log_line() assert "thinking=high thinking_chars=" in line # The off switch: an empty setting logs ``-`` ... monkeypatch.setattr( chat_api, "get_settings", lambda: Settings(_env_file=None, relevance_threshold=0.30, llm_chat_thinking_level=""), # pyright: ignore[reportCallIssue] ) line = _last_log_line() assert "thinking=- thinking_chars=" in line # ... and so does a whitespace-only one (A6: the TRIMMED value # decides, the log field mirrors the wire's trim). monkeypatch.setattr( chat_api, "get_settings", lambda: Settings(_env_file=None, relevance_threshold=0.30, llm_chat_thinking_level=" "), # pyright: ignore[reportCallIssue] ) line = _last_log_line() assert "thinking=- thinking_chars=" in line # Phase 178 (A5) cross-pin: the line is pinned to the CHAT level # — with the one-shot summary level set to a DIFFERENT value, # the line still shows the chat level; the summary's "high" # does not appear as the line's ``thinking=`` value. monkeypatch.setattr( chat_api, "get_settings", lambda: Settings( _env_file=None, # pyright: ignore[reportCallIssue] relevance_threshold=0.30, llm_chat_thinking_level="low", llm_summary_thinking_level="high", ), ) line = _last_log_line() assert "thinking=low thinking_chars=" in line assert "thinking=high" not in line # The inverse off-pin: the chat off-switch logs ``-`` even # though the one-shot summary knob is set — only the chat level # (the level this turn ran at) rides the line. monkeypatch.setattr( chat_api, "get_settings", lambda: Settings( _env_file=None, # pyright: ignore[reportCallIssue] relevance_threshold=0.30, llm_chat_thinking_level="", llm_summary_thinking_level="high", ), ) line = _last_log_line() assert "thinking=- thinking_chars=" in line assert "thinking=high" not in line # --------------------------------------------------------------------------- # Phase 182 (task 05): the per-turn log line's ``verbatim_blocks=N`` tail # and the deflected turn's resolve-noting posture (D9). The count is the # blocks ACTUALLY SENT (after the canonical-locator dedupe and the # ``show_text_max_blocks`` cut), so the resolver is faked here — its own # behaviour is pinned in ``tests/unit/test_verbatim_resolver.py`` and the # real end-to-end fusion in ``tests/integration/test_chat_api.py``. # --------------------------------------------------------------------------- def _block(n: int) -> Any: """One fake resolved block (the resolver's return type — the counting and the frame wiring are what these pins are about).""" from app.rag.verbatim import VerbatimBlock return VerbatimBlock( source="Homelab", path=f"quadlet/gufo.container{n}", title=f"gufo{n}", start_line=1, end_line=4, total_lines=12, text=f"[Unit{n}\nExec=gufo", truncated=False, locator=f"Homelab/quadlet/gufo.container{n}#1-4", ) def test_log_line_ends_with_verbatim_blocks_zero_on_a_plain_turn( client: TestClient, gate_env: tuple[_FakeSession, _CannedLLM], monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """A plain grounded turn (no tool blocks, no fence in the answer) ends ``… scaffold_stripped=0 verbatim_blocks=0`` — the PLAN §9 field is UNIFORM across turn shapes (the phase-67 ``retries=N`` pattern), and the ``done`` frame carries no ``answer_text`` key (A9).""" _session, _llm = gate_env 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"): frames = _ask(client, "How is my Kubernetes cluster set up?") lines = [r.getMessage() for r in caplog.records if "question=" in r.getMessage()] assert lines assert lines[-1].endswith("scaffold_stripped=0 verbatim_blocks=0") done = [f for f in frames if f.get("type") == "done"] assert done and "answer_text" not in done[0] def test_log_line_counts_the_verbatim_blocks_sent( client: TestClient, gate_env: tuple[_FakeSession, _CannedLLM], monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """Two resolved blocks → ``verbatim_blocks=2`` AND both ride the ``done`` frame's ``answer_text`` in the resolver's order (the frame and the line count the SAME list — never the raw tool-call count).""" _session, _llm = gate_env doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_SENT") monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.30)])) monkeypatch.setattr( chat_api, "resolve_answer_blocks", lambda *_a: [_block(1), _block(2)] ) with caplog.at_level("INFO", logger="app.chat"): frames = _ask(client, "How is my Kubernetes cluster set up?") lines = [r.getMessage() for r in caplog.records if "question=" in r.getMessage()] assert lines and lines[-1].endswith("verbatim_blocks=2") done = [f for f in frames if f.get("type") == "done"] assert done assert done[0]["answer_text"] == [ { "source": "Homelab", "path": "quadlet/gufo.container1", "title": "gufo1", "start_line": 1, "end_line": 4, "total_lines": 12, "text": "[Unit1\nExec=gufo", "truncated": False, "locator": "Homelab/quadlet/gufo.container1#1-4", }, { "source": "Homelab", "path": "quadlet/gufo.container2", "title": "gufo2", "start_line": 1, "end_line": 4, "total_lines": 12, "text": "[Unit2\nExec=gufo", "truncated": False, "locator": "Homelab/quadlet/gufo.container2#1-4", }, ] def test_deflected_turn_resolves_nothing_and_logs_zero( client: TestClient, gate_env: tuple[_FakeSession, _CannedLLM], monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """D9: a DEFLECTED turn resolves NOTHING even when its own answer text carries a `` ```bor `` fence — a hallucinated locator must not leak KB text through the turn that just disclaimed it. The resolver is never called, the line reads ``verbatim_blocks=0`` and the frame carries no ``answer_text`` key.""" _session, llm = gate_env llm.answer = ( "I haven't done anything like that.\n" "```bor Homelab/quadlet/gufo.container 1-4\n" "Exec=/usr/bin/lie\n" "```" ) doc = _doc("Sourdough Starter", "BREAD_DOC_SENT") monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.10)])) calls: list[Any] = [] monkeypatch.setattr( chat_api, "resolve_answer_blocks", lambda *_a: calls.append(1) or [], # pyright: ignore[reportFunctionMemberAccess] ) with caplog.at_level("INFO", logger="app.chat"): frames = _ask(client, "How do I bake sourdough bread?") lines = [r.getMessage() for r in caplog.records if "question=" in r.getMessage()] assert lines and lines[-1].endswith("verbatim_blocks=0") assert calls == [] # the resolver never ran on a deflected turn done = [f for f in frames if f.get("type") == "done"] assert done and done[0]["deflected"] is True assert "answer_text" not in done[0] # ---------- phase 191 (D4, task 05): the turn's prompt learns the name ---------- def _label_view( source: str = "Homelab", display_name: str | None = None, label: str | None = None, ) -> SourceLabelView: return SourceLabelView(source=source, display_name=display_name, label=label, tags=()) def test_plan_turn_high_prompt_carries_the_source_name() -> None: """The HIGH branch hands the label views to ``build_high_prompt``: the suggested block gains `` name="…"`` / `` label="…"`` right after ``source="…"`` — the model sees the name→docs mapping.""" doc = _doc("Gitea Upgrade", "GITEA_DOC_CONTENT", summary="Upgrading Gitea.") names = {"Homelab": _label_view(display_name="the git server", label="self-hosted forge")} plan = chat_api.plan_turn( [_chunk(doc, 0.90, cosine=0.90)], _settings(threshold=0.30), names=names, ) assert plan.deflected is False assert 'source="Homelab" name="the git server" label="self-hosted forge" path=' in ( plan.system_prompt ) assert "Upgrading Gitea." in plan.system_prompt # the body is untouched def test_plan_turn_without_names_is_byte_identical() -> None: """THE kill pin on the call-through: ``names`` omitted, ``None`` and ``{}`` each build the exact pre-phase prompt (and every other TurnPlan field is identical).""" doc = _doc("Gitea Upgrade", "GITEA_DOC_CONTENT", summary="Upgrading Gitea.") chunks = [_chunk(doc, 0.90, cosine=0.90)] settings = _settings(threshold=0.30) pre = chat_api.plan_turn(chunks, settings) for names in (None, {}): plan = chat_api.plan_turn(chunks, settings, names=names) assert plan.system_prompt == pre.system_prompt assert plan.deflected == pre.deflected assert [d.id for d in plan.suggested_docs] == [d.id for d in pre.suggested_docs] assert ' name="' not in pre.system_prompt def test_plan_turn_low_prompt_ignores_names() -> None: """A deflected turn has no document blocks: the LOW prompt is byte-identical whether or not the source is named (D4 touches the suggested tier only).""" doc = _doc("Mongolia Trivia", "MONGOLIA_CONTENT") settings = _settings(threshold=0.62) chunks = [_chunk(doc, 0.05, cosine=0.10, fts_hit=True)] pre = chat_api.plan_turn(chunks, settings) plan = chat_api.plan_turn(chunks, settings, names={"Homelab": _label_view("Homelab", "x")}) assert plan.deflected is True assert plan.system_prompt == pre.system_prompt # The pre-phase deflected build, unchanged, is what the LOW branch owes. assert plan.system_prompt == build_deflect_prompt( weak_hit_titles(chunks), notes=[], kb_overview="" ) def test_chat_module_issues_exactly_one_label_read_per_turn() -> None: """The marker pin for the "one query per turn" contract: the endpoint reads the label rows ONCE (``all_labels``) and derives both the D2 hit set and the prompt map from that read — no second label query, and task 04's convenience wrapper (which would re-query) is gone from the module.""" src = Path(chat_api.__file__).read_text(encoding="utf-8") assert src.count("all_labels(") == 1 assert "question_name_hit_sources(" not in src assert src.count("names=labels") == 1, "the single read is what the prompt receives" assert "question_name_hits(" in src, "the pure D2 rule stays applied per view" def test_endpoint_single_label_read_feeds_flags_and_prompt( client: TestClient, gate_env: tuple[_FakeSession, _CannedLLM], monkeypatch: pytest.MonkeyPatch, ) -> None: """The one-read contract on the wire (task 04 + task 05): the ONE ``all_labels`` result of a turn sets the D2 boost flags AND the ```` name/label attributes — one query, two surfaces.""" _session, llm = gate_env doc = _doc("Gitea Upgrade", "GITEA_DOC_CONTENT", summary="Upgrading Gitea.") chunk = _chunk(doc, 0.90, cosine=0.90) assert chunk.source_name_hit is False # the pre-flag state monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([chunk])) views = { "Homelab": SourceLabelView( source="Homelab", display_name="the git server", label="self-hosted forge", tags=("gitea",), ) } reads: list[int] = [] def _counting_all_labels(_db: Any) -> dict[str, SourceLabelView]: reads.append(1) return views monkeypatch.setattr(chat_api, "all_labels", _counting_all_labels) frames = _ask(client, "how do I restart the git server?") assert [f for f in frames if f.get("type") == "done"], "the turn completed" assert len(reads) == 1, "ONE label read for the whole turn" assert chunk.source_name_hit is True, "the same read sets the task-04 boost flag" system = llm.seen[0][0]["content"] assert 'source="Homelab" name="the git server" label="self-hosted forge"' in system