phase: 113_source_chip_quality
All gates green — no defects found; this pass was verification only. **Phase 113 final verification pass — report** - Verified (no code changes needed): `select_documents_tiered` cited/related tiering + `select_documents` wrapper, `TurnPlan.related_docs`, `ChatDoneEvent.related` (additive, old payloads parse), `appendRelated` UI row (`.related-doc`, never `.source-chip`), done-frame + restore-path wiring, two settings with validators, `.env.example` entries - `uv run pytest --cov=app --cov-report=term-missing` → 2422 passed, app/ coverage **99%** (>90% gate) - `uv run pytest tests/e2e/test_source_chip_quality.py -v --no-cov` (isolated) → 2 passed - Regression E2E `test_retrieval_quality.py` + `test_honest_deflection.py` + `test_chat_rag.py` + `test_sources_midstream_bug.py` → 17 passed - `uv run ruff check . && uv run pyright` → clean (0 errors); `bash .agents/validate.sh` → "validation OK" Completion criteria: 1. Single-doc question → exactly one `.source-chip` (E2E): ✅ passed 2. Weak 2nd doc only in de-emphasized related row, never `.source-chip` (unit + E2E): ✅ passed 3. Deflected turn → zero citation chips, weak hits in related row: ✅ passed 4. Full suite green, coverage >90%, isolated E2E green, lint/types clean: ✅ passed 5. `--no-gpg-sign` commit + phase dir move: left to harness per pass rules (task files already in `complete/`) No deviations. Next pending phase: `114_embed_question_length`.
This commit is contained in:
+63
-19
@@ -164,7 +164,7 @@ import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
@@ -194,7 +194,7 @@ from app.rag.llm import (
|
||||
)
|
||||
from app.rag.overview import load_kb_overview
|
||||
from app.rag.prompts import build_deflect_prompt, build_high_prompt, history_to_messages
|
||||
from app.rag.retriever import RetrievedChunk, retrieve, select_documents, weak_hit_titles
|
||||
from app.rag.retriever import RetrievedChunk, retrieve, select_documents_tiered, weak_hit_titles
|
||||
from app.rag.scaffolding import ScaffoldingFilter # phase 71: the streaming filter
|
||||
from app.rag.suggestions import derive_suggestions
|
||||
from app.schemas import (
|
||||
@@ -257,8 +257,9 @@ class TurnPlan:
|
||||
fts_hits: int # lexical (OR-tsquery) candidates matched
|
||||
deflected: bool
|
||||
system_prompt: str
|
||||
docs: list[Document] # cited sources (weak hits when deflected)
|
||||
suggestions: list[str] # "Maybe try" chips (deflected turns only)
|
||||
docs: list[Document] # cited sources (phase 113: the bar-clearing tier)
|
||||
related_docs: list[Document] = field(default_factory=list) # phase 113
|
||||
suggestions: list[str] = field(default_factory=list) # "Maybe try" chips (deflected turns only)
|
||||
tuning_count: int = 0 # steering notes injected into the system prompt
|
||||
#: Hit chunks with ``is_summary`` whose parent document made it into
|
||||
#: *docs* (phase 30; per-turn log line ``summary_hits=N``).
|
||||
@@ -306,13 +307,29 @@ def plan_turn(
|
||||
``summary_hits`` (phase 30) counts the hit chunks with
|
||||
``is_summary`` whose parent document is among the selected
|
||||
top-N documents — both the HIGH and the LOW branch record it.
|
||||
|
||||
Phase 113 (the usefulness bar, LOCKED A2): retrieval documents are
|
||||
tiered before either branch — ``docs`` (cited) are the distinct
|
||||
parent documents whose best hit-chunk cosine clears
|
||||
``settings.source_usefulness_floor`` (at most ``top_n_docs`` — the
|
||||
ceiling, never a quota); ``related_docs`` are the next ranked
|
||||
documents (at most ``related_max_docs``) that did not earn a cited
|
||||
slot. On a deflected turn the weak hits fall to ``related_docs``
|
||||
(the cited tier is usually empty — nothing below the bar earned a
|
||||
citation slot); the LOW prompt itself is unchanged (weak-hit titles
|
||||
only).
|
||||
"""
|
||||
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)
|
||||
fts_hits = sum(1 for c in chunks if c.fts_hit)
|
||||
docs = select_documents(chunks, n=settings.top_n_docs)
|
||||
docs, related_docs = select_documents_tiered(
|
||||
chunks,
|
||||
n=settings.top_n_docs,
|
||||
floor=settings.source_usefulness_floor,
|
||||
related_cap=settings.related_max_docs,
|
||||
)
|
||||
selected_ids = {d.id for d in docs}
|
||||
summary_hits = sum(1 for c in chunks if c.is_summary and c.document.id in selected_ids)
|
||||
lexical_supported = fts_hits > 0 and best_cosine >= settings.lexical_support_floor
|
||||
@@ -323,6 +340,7 @@ def plan_turn(
|
||||
False,
|
||||
build_high_prompt(docs, notes=steering, kb_overview=kb_text),
|
||||
docs,
|
||||
related_docs,
|
||||
[],
|
||||
len(steering),
|
||||
summary_hits,
|
||||
@@ -335,6 +353,7 @@ def plan_turn(
|
||||
True,
|
||||
build_deflect_prompt(titles, notes=steering, kb_overview=kb_text),
|
||||
docs,
|
||||
related_docs,
|
||||
derive_suggestions(titles, settings.suggestions),
|
||||
len(steering),
|
||||
summary_hits,
|
||||
@@ -747,24 +766,36 @@ async def chat(
|
||||
|
||||
# 4. Durable record + required per-turn log line (PLAN §9).
|
||||
# Phase 37: the agent's read documents join the
|
||||
# retrieval's — deduped by (source, path), order preserved.
|
||||
# The combined list feeds query_log.sources and the log
|
||||
# line (retrieval docs even on deflected turns —
|
||||
# observability, the phase-113 A3 precedent). Phase 112:
|
||||
# retrieval's — deduped by (source, path), order
|
||||
# preserved (the read doc stays last). Phase 113:
|
||||
# the retrieval now arrives in two tiers — the cited
|
||||
# docs (``plan.docs``) and the related docs
|
||||
# (``plan.related_docs``, the scored-but-below-the-bar
|
||||
# documents). The DURABLE record keeps the full
|
||||
# retrieval (LOCKED A3: query_log records retrieval,
|
||||
# not citations — even on deflected turns, where the
|
||||
# weak hits live in the related tier). Phase 112:
|
||||
# done.sources is the CITATION surface — it carries the
|
||||
# combined list on grounded turns and [] on deflected
|
||||
# ones (a deflected answer cites nothing; the weak hits
|
||||
# stay in the durable record). A cancelled turn (the
|
||||
# generator closed by the consumer) never reaches this
|
||||
# step — no query_log row.
|
||||
# cited docs + the agent-read docs on grounded turns
|
||||
# and [] on deflected ones (a deflected answer cites
|
||||
# nothing; the weak hits stay in the durable record).
|
||||
# A cancelled turn (the generator closed by the
|
||||
# consumer) never reaches this step — no query_log row.
|
||||
cited_docs: list[Document] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
cited_seen: set[tuple[str, str]] = set()
|
||||
for doc in [*plan.docs, *holder.read_docs]:
|
||||
key = (doc.source, doc.path)
|
||||
if key not in cited_seen:
|
||||
cited_seen.add(key)
|
||||
cited_docs.append(doc)
|
||||
record_docs: list[Document] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for doc in [*plan.docs, *plan.related_docs, *holder.read_docs]:
|
||||
key = (doc.source, doc.path)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
cited_docs.append(doc)
|
||||
source_paths = [f"{d.source}/{d.path}" for d in cited_docs]
|
||||
record_docs.append(doc)
|
||||
source_paths = [f"{d.source}/{d.path}" for d in record_docs]
|
||||
total_ms = int((time.monotonic() - started) * 1000)
|
||||
try:
|
||||
with SessionLocal() as log_db:
|
||||
@@ -810,18 +841,31 @@ async def chat(
|
||||
# as a citation; the weak hits are scored docs, not
|
||||
# citations). The retrieval stays durably recorded above
|
||||
# (query_log.sources + the log line — observability
|
||||
# unchanged); the phase-113 related-doc tier is the home
|
||||
# for the weak hits' visibility.
|
||||
# unchanged).
|
||||
# Phase 113 (A2/A4): done.related carries the related
|
||||
# tier — the scored documents that did not clear the
|
||||
# usefulness bar (deduped against the cited list, the
|
||||
# same (source, path) pattern as cited_docs: an agent-
|
||||
# read related doc is a citation, never a "nearby doc"
|
||||
# — and capped by related_max_docs in the tiering). The
|
||||
# UI renders it as the de-emphasized related-docs row,
|
||||
# never a citation chip; old clients ignore the field.
|
||||
cited_refs: list[SourceRef] = []
|
||||
if not plan.deflected:
|
||||
cited_refs = [
|
||||
SourceRef(source=d.source, path=d.path, title=d.title)
|
||||
for d in cited_docs
|
||||
]
|
||||
related_refs = [
|
||||
SourceRef(source=d.source, path=d.path, title=d.title)
|
||||
for d in plan.related_docs
|
||||
if (d.source, d.path) not in cited_seen
|
||||
]
|
||||
yield sse_event(
|
||||
ChatDoneEvent(
|
||||
deflected=plan.deflected,
|
||||
sources=cited_refs,
|
||||
related=related_refs,
|
||||
suggestions=plan.suggestions,
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
@@ -126,6 +126,29 @@ class Settings(BaseSettings):
|
||||
# ``relevance_threshold`` (a floor above the threshold is a typo that
|
||||
# would make every FTS hit require a HIGH cosine anyway).
|
||||
lexical_support_floor: float = 0.35
|
||||
#: Usefulness bar for the citation slot (phase 113, LOCKED A2): a
|
||||
#: retrieved document earns ``done.sources`` (the UI's citation chip)
|
||||
#: only when the **cosine** of its best hit chunk clears this floor —
|
||||
#: the vector signal must corroborate the citation, mirroring the A8
|
||||
#: honesty gate's ``lexical_support_floor``. Documents that scored but
|
||||
#: stay below the bar are demoted to the secondary related-doc tier
|
||||
#: (at most ``related_max_docs``). ``top_n_docs`` is the CEILING for
|
||||
#: the cited tier, never a quota: a single strong document yields one
|
||||
#: citation. Default 0.35 — the same bar as ``lexical_support_floor``;
|
||||
#: tunable via ``BOR_SOURCE_USEFULNESS_FLOOR``. Must satisfy
|
||||
#: ``0 <= source_usefulness_floor <= relevance_threshold`` (a floor
|
||||
#: above the threshold would demote to the related tier documents the
|
||||
#: gate itself calls grounded — the ``lexical_support_floor`` typo
|
||||
#: guard). ``0`` disables the bar (every scored doc is citable — the
|
||||
#: pre-phase behavior, the kill switch).
|
||||
source_usefulness_floor: float = 0.35
|
||||
#: Cap on the secondary related-doc tier (phase 113, LOCKED A4):
|
||||
#: documents that scored but did not clear ``source_usefulness_floor``
|
||||
#: ride the ``done`` frame's ``related`` list (the UI's de-emphasized
|
||||
#: "nearby docs" row — never a citation chip). ``0`` = no related
|
||||
#: docs at all (the kill switch); a negative value fails startup
|
||||
#: loudly (the ``agent_max_rounds`` pattern).
|
||||
related_max_docs: int = 2
|
||||
#: Maximum output tokens a chat answer may use (owner instruction
|
||||
#: 2026-08-22: answers must run to their natural end — the old hard
|
||||
#: 700-token cap cut long answers off mid-sentence).
|
||||
@@ -327,6 +350,31 @@ class Settings(BaseSettings):
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("source_usefulness_floor")
|
||||
@classmethod
|
||||
def _source_usefulness_floor_bounds(cls, v: float, info: ValidationInfo) -> float:
|
||||
"""The usefulness bar must be in [0, relevance_threshold]. A value
|
||||
above the relevance threshold would be a typo — it would demote to
|
||||
the related tier documents the honesty gate itself calls grounded
|
||||
(the ``lexical_support_floor`` typo guard, phase 113)."""
|
||||
if v < 0:
|
||||
raise ValueError("source_usefulness_floor must be >= 0")
|
||||
threshold = info.data.get("relevance_threshold")
|
||||
if isinstance(threshold, float) and v > threshold:
|
||||
raise ValueError(
|
||||
f"source_usefulness_floor ({v}) must be <= relevance_threshold ({threshold})"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("related_max_docs")
|
||||
@classmethod
|
||||
def _related_max_docs_non_negative(cls, v: int) -> int:
|
||||
"""``0`` is the no-related-docs kill switch — a negative cap is a
|
||||
typo (the ``agent_max_rounds`` pattern, phase 113)."""
|
||||
if v < 0:
|
||||
raise ValueError("related_max_docs must be >= 0 (0 = no related docs)")
|
||||
return v
|
||||
|
||||
@field_validator("import_extensions")
|
||||
@classmethod
|
||||
def _import_extensions_known(cls, v: str) -> str:
|
||||
|
||||
+85
-12
@@ -27,8 +27,13 @@
|
||||
unchanged (same lists, same ``1/(k+rank)`` terms).
|
||||
* **Fusion** — Reciprocal Rank Fusion (``score = Σ 1/(k + rank)`` over the
|
||||
lists a chunk appears in; chunks hit by both lists get both terms). The
|
||||
fused score ranks; :meth:`select_documents` and :func:`weak_hit_titles`
|
||||
keep working off ``score``.
|
||||
fused score ranks; :func:`select_documents` and :func:`weak_hit_titles`
|
||||
keep working off ``score``. Phase 113: the document tiering
|
||||
(:func:`select_documents_tiered`) keeps the same rank order and adds the
|
||||
usefulness bar — a document earns the cited tier only when its best
|
||||
hit-chunk cosine clears ``BOR_SOURCE_USEFULNESS_FLOOR``; the rest of the
|
||||
ranked documents (up to ``BOR_RELATED_MAX_DOCS``) become the related
|
||||
tier.
|
||||
|
||||
The product requirement (LOCKED A7, revised 2026-08-24): the LLM receives
|
||||
the **entire relevant document**, not just the chunk — chunk hits map back
|
||||
@@ -560,6 +565,77 @@ def weak_hit_titles(chunks: Sequence[RetrievedChunk]) -> list[str]:
|
||||
return titles
|
||||
|
||||
|
||||
def select_documents_tiered(
|
||||
chunks: Sequence[RetrievedChunk],
|
||||
n: int | None = None,
|
||||
floor: float = 0.0,
|
||||
related_cap: int = 0,
|
||||
) -> tuple[list[Document], list[Document]]:
|
||||
"""Tier chunk hits into the cited and the related parent documents
|
||||
(phase 113, LOCKED A2/A4 — the usefulness bar).
|
||||
|
||||
Distinct parent documents are ranked exactly like :func:`select_documents`
|
||||
(best fused score first — the same stable score-descending walk, so a
|
||||
document's rank position is fixed by its FIRST seen chunk) and each
|
||||
document's **best hit-chunk cosine** is tracked across all of its
|
||||
chunks. The tiers are then cut in that rank order:
|
||||
|
||||
* **cited** — the documents whose best-chunk cosine clears *floor*,
|
||||
up to *n* (default ``BOR_TOP_N_DOCS``). The ceiling, never a quota:
|
||||
a single strong document yields one cited document, and documents
|
||||
whose cosine stays below the bar are skipped (the next-ranked
|
||||
clearing document takes their slot — the bar filters, it does not
|
||||
backfill). The bar is on the **cosine**, not the RRF fused score:
|
||||
the fused ``score`` is a rank key, not a similarity, and a
|
||||
lexical-only hit has cosine 0.0 — vector-unsupported by definition
|
||||
(LOCKED A2, consistent with the A8 honesty gate).
|
||||
* **related** — the next distinct documents in the same rank order
|
||||
that are not already cited (any cosine, including 0.0 lexical-only
|
||||
hits), up to *related_cap* (default 0). Never overlaps the cited
|
||||
list. The done frame carries them in the secondary ``related``
|
||||
tier — the UI's de-emphasized "nearby docs" row, never a citation
|
||||
chip (LOCKED A4).
|
||||
|
||||
With ``floor=0.0`` (no bar — a zero floor admits every scored
|
||||
document, so the legacy "any score, top-N" selection holds exactly)
|
||||
and ``related_cap=0`` the tiering degenerates to the legacy behavior:
|
||||
:func:`select_documents` is a thin wrapper on that.
|
||||
|
||||
The returned rows carry the full document content, byte-identical —
|
||||
a matched parent document is **never truncated** (A7 revised, owner
|
||||
permission 2026-08-24).
|
||||
"""
|
||||
top_n = n if n is not None else get_settings().top_n_docs
|
||||
no_bar = floor <= 0.0
|
||||
|
||||
order: list[Document] = []
|
||||
best_cosine: dict[uuid.UUID, float] = {}
|
||||
for rc in sorted(chunks, key=lambda c: c.score, reverse=True):
|
||||
doc = rc.document
|
||||
if doc.id in best_cosine:
|
||||
if rc.cosine > best_cosine[doc.id]:
|
||||
best_cosine[doc.id] = rc.cosine
|
||||
continue
|
||||
best_cosine[doc.id] = rc.cosine
|
||||
order.append(doc)
|
||||
|
||||
cited: list[Document] = []
|
||||
for doc in order:
|
||||
if len(cited) >= top_n:
|
||||
break
|
||||
if no_bar or best_cosine[doc.id] >= floor:
|
||||
cited.append(doc)
|
||||
|
||||
cited_ids = {doc.id for doc in cited}
|
||||
related: list[Document] = []
|
||||
for doc in order:
|
||||
if len(related) >= related_cap:
|
||||
break
|
||||
if doc.id not in cited_ids:
|
||||
related.append(doc)
|
||||
return cited, related
|
||||
|
||||
|
||||
def select_documents(
|
||||
chunks: Sequence[RetrievedChunk],
|
||||
n: int | None = None,
|
||||
@@ -572,14 +648,11 @@ def select_documents(
|
||||
permission 2026-08-24). There is deliberately no context budget: an
|
||||
oversized prompt must fail loudly through the ``LLMError`` → SSE
|
||||
``error`` path, never arrive as silent partial context.
|
||||
"""
|
||||
top_n = n if n is not None else get_settings().top_n_docs
|
||||
|
||||
docs: list[Document] = []
|
||||
seen: set[uuid.UUID] = set()
|
||||
for rc in sorted(chunks, key=lambda c: c.score, reverse=True):
|
||||
if rc.document.id in seen:
|
||||
continue
|
||||
seen.add(rc.document.id)
|
||||
docs.append(rc.document)
|
||||
return docs[:top_n]
|
||||
Phase 113: a thin wrapper on :func:`select_documents_tiered` — the
|
||||
legacy "any score, top-N" behavior is the cited tier with a zero
|
||||
floor (no bar) and an empty related tier, byte-identical for all
|
||||
existing callers.
|
||||
"""
|
||||
cited, _ = select_documents_tiered(chunks, n, 0.0, 0)
|
||||
return cited
|
||||
|
||||
+10
-1
@@ -183,11 +183,20 @@ class ChatToolResultEvent(BaseModel):
|
||||
|
||||
|
||||
class ChatDoneEvent(BaseModel):
|
||||
"""Final SSE event of a chat turn: metadata for the finished answer."""
|
||||
"""Final SSE event of a chat turn: metadata for the finished answer.
|
||||
|
||||
Phase 113: ``related`` — the secondary related-doc tier (documents
|
||||
that scored but did not clear the usefulness bar, LOCKED A2/A4). The
|
||||
UI renders it as the de-emphasized "nearby docs" row — never a
|
||||
citation chip — while ``sources`` stays the citation surface. The
|
||||
field is ADDITIVE: old clients ignore unknown fields (PLAN §4 house
|
||||
contract) and old frames without it parse with the default ``[]``.
|
||||
"""
|
||||
|
||||
type: str = "done"
|
||||
deflected: bool
|
||||
sources: list[SourceRef]
|
||||
related: list[SourceRef] = []
|
||||
suggestions: list[str] = []
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user