Files
brain-of-reese/app/rag/retriever.py
T
ducoterra a19d78d284
Build and Push Containers / build-and-push-app (push) Successful in 1m57s
Build and Push Containers / build-and-push-db (push) Failing after 13s
phase: 122_image_documents
**Phase 122 (image documents) — final verification pass: all green. No code changes were needed; defects found: none.**

**Verified (implementation already complete in working tree, reviewed end-to-end):**
- Toggle (`BOR_IMAGES`/`BOR_IMAGE_EXTENSIONS`/`BOR_IMAGE_DIR`, off by default) + `GET /api/config` `images` flag
- Ingest: bytes digest, `image_dir` persistent copy, `content = summary = vision description` (chat-model call; only text embedded), fail-soft skip + `images_failed` counter
- Serve/display: `/api/documents/{id}/image` route (404 matrix), viewer `<img>` + description, Sources 48px lazy thumbnails, chat inline source figure (alt = summary), agent `read` marker
- Prune guard: images-off syncs never prune `is_image` docs

**Test / lint / coverage (exact commands & outcomes):**
- `uv run pytest` → exit 0 (green; note: pytest 9.1.1 `-q` omits the final count line in output — exit code authoritative)
- `uv run pytest --cov=app --cov-report=term-missing` → **2715 passed, exit 0, TOTAL 99%** (>90% gate)
- `uv run ruff check . && uv run pyright` → "All checks passed!" / "0 errors, 0 warnings, 0 informations"
- `uv run pytest tests/e2e/test_image_documents.py -v --no-cov` → **4 passed, exit 0** (isolation)

**Completion criteria:** (1) images=true → described/embedded/displayed docs: ✅ (E2E + integration) · (2) images=false byte-identical + image docs survive sync: ✅ (E2E negative app + unit/integration) · (3) viewer + chat rendering with alt text; failed description skips + logs, sync completes: ✅ · (4) test/lint/coverage gates: ✅ · (5) commit + phase move: deferred to harness per this pass's rules (working tree left uncommitted).

**Notable deviation (pre-existing, documented in code):** image route uses `require_user` (phase-79 posture, same gate as the document content endpoint) rather than the phase text's "public" parenthetical — matches the endpoint it mirrors.

**Next pending phase:** `123_chat_image_questions`.
2026-09-25 01:54:23 -04:00

967 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Hybrid retrieval: pgvector cosine ∪ Postgres FTS, RRF-fused (PLAN §6, A7).
* **Vector list** — top-N chunks by cosine distance (``embedding <=> $1``),
each carrying its cosine ``1 − distance`` (the honesty-gate input).
* **Lexical list** — top-N chunks matching an OR-``tsquery`` over the
question's tokens, ordered by ``ts_rank``, UNION the name-hit list:
documents whose PATH components match a question name token under
the two-class rule (the 2026-09-05 incident fix, extended
2026-09-16 for product names WITHOUT digits — phase 119, LOCKED
A2). The token set is class-agnostic — every normalized whitespace
token of length >= 4 (dotted kept whole: ``llama.cpp`` →
``llamacpp``) plus the versioned join ("Qwen 3.8" → ``qwen38``) —
the digit distinction lives on the MATCH side:
* a DIGIT-BEARING token prefix-matches a normalized path part or
file stem (``qwen38`` →
``qwen3.8-27b-juggernaut-vulkan.container`` — the incident's
original precision guard);
* a DIGITLESS token exact-matches a normalized path part, file
stem, or stem sub-component (``gitea`` → the ``gitea/`` folder,
``gitea.md``, ``kubernetes_gitea``, ``gitea-values``).
TITLES ARE NEVER MATCHED — titles are prose: ``deploy/Deployments/
reeseapps/README.md`` is titled "Deployments" and must not
name-match the common token "deploy" (the owner-verified failure
mode of the naive relaxation). The name-hit documents LEAD the
lexical list (ranked by distinct matched-token count, then catalog
order ``(source, path)`` — the old total-matched-length tie-break
is retired, it outranked 5-char product names by 6-char common
tokens; capped at :data:`NAME_HIT_LIMIT`),
the FTS rows follow. This is what finds name-your-tool questions
("gitlab") that vector similarity buries — and, the 2026-09-05
incident, the versioned-name case the default parser lexes
incompatibly (question "Qwen 3.8" → ``qwen``/``3``/``8`` can never
match a document's ``qwen3``/``8``/``27b`` tokens, while every
unrelated llama.cpp quadlet out-ranks the target on the shared
``llama``/``cpp`` tokens). The name-hit rows carry ``fts_hit=True``
(they ARE the lexical signal — the A8 honesty gate answers on them
only when the best cosine clears ``BOR_LEXICAL_SUPPORT_FLOOR``,
A8 revised 2026-09-14), ``cosine=0.0``, and ``name_hit=True`` (the
selection-tier bonus input, phase 119 D2); the RRF fusion is
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; :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.
Phase 119, D2 (LOCKED A3): the SELECTION walks (``select_suggested``,
``select_related``, ``weak_hit_titles``) re-rank on an EFFECTIVE score
— the document's best fused chunk score plus ``BOR_NAME_HIT_BONUS``
when any of its chunks is a name hit (the D1 path match). A
product-name question ("How do I deploy gitea?") thus lifts the
product's own documents into the seeded suggestion tier even when the
name hit only LEADS the lexical list. The bonus lives in the selection
layer only (the phase-106 recency-boost pattern: additive, bounded,
single apply site): chunk scores, the fusion, ``fuse()``,
:func:`retrieve()`, the A8 honesty gate, and ``query_log.top_score``
are untouched; ``0`` reproduces the pre-phase walk byte-identically
(the kill switch) and a negative value fails startup loudly.
The product requirement (A7, re-revised by the phase-118 owner directive,
LOCKED A6, 2026-09-15): the retrieval path seeds **summaries** — the
suggestion tier (:func:`select_suggested`, top-N distinct documents, no
cosine floor, LOCKED A3) whose summary blocks are the grounded prompt's
``<documents>`` starting points. The full text of a document enters the
context ONLY through the agent's capped ``read`` tool
(:mod:`app.rag.agent`; ``BOR_READ_MAX_CHARS`` + :data:`TRUNCATION_MARKER`),
never through the retrieval seeding. A document's content itself is still
carried on its rows byte-identical — the ``read`` tool serves it whole,
un-truncated up to its cap.
Deterministic tie-break for equal fused scores:
``(−fused, −cosine, document.path, chunk.position)``.
"""
from __future__ import annotations
import math
import re
import uuid
from collections.abc import Sequence
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from pathlib import Path
from sqlalchemy import select, text
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models import Chunk, Document
from app.schemas import SourceRef
#: Shared overflow marker (phase 15; imported by ``app.rag.prompts``)
#: — used by the steering (<tuning>) section, the phase-118 NULL-summary
#: suggestion preview fallback (A5), and the capped agent ``read``
#: result. The seeded summary blocks and the ``read``-served document
#: content never truncate silently (A6 re-revised): full text enters the
#: context only through the capped ``read`` tool.
TRUNCATION_MARKER = "[…truncated…]"
#: Alphanumeric tokens of a question (``to_tsquery`` input, OR-joined),
#: with DOTTED tokens kept whole (``llama.cpp`` → ``llama.cpp``). The
#: default parser lexes a dotted word as ONE lexeme (``to_tsvector`` of
#: "llama.cpp" → ``'llama.cpp'``; of "Qwen 3.8" → ``'3.8'``), so a split
#: token (``llama`` | ``cpp``) can never match the document side — the
#: 2026-09-05 incident's question phrase "llama.cpp" lexed
#: incompatibly on both sides of the query. Dotted tokens are passed
#: through as single ``to_tsquery`` lexemes (verified: Postgres accepts
#: ``'llama.cpp'`` and ``'3.8'`` as lexemes).
_TOKEN_RE = re.compile(r"[a-z0-9]+(?:\.[a-z0-9]+)*")
#: Name-hit policy (the 2026-09-05 incident): a question token shorter
#: than this (normalized) is too weak a name signal ("3", "8", "the") —
#: it would match half the KB by accident.
NAME_TOKEN_MIN_LEN = 4
#: The name-hit list may contribute at most this many documents to the
#: lexical side (before the FTS rows); the rest are dropped.
NAME_HIT_LIMIT = 10
#: Alphanumeric runs of a lowercased string (name normalization).
_ALNUM_RE = re.compile(r"[a-z0-9]+")
#: Non-alphanumeric runs of a lowercased string (the stem sub-component
#: split — ``kubernetes_gitea`` → ``kubernetes`` / ``gitea``).
_STEM_SPLIT_RE = re.compile(r"[^a-z0-9]+")
def _normalize_name(s: str) -> str:
"""Lowercased, alnum-only form of *s* (``Qwen 3.8`` → ``qwen38``)."""
return "".join(_ALNUM_RE.findall(s.lower()))
def name_hit_tokens(question: str) -> list[str]:
"""The name-match candidates of one question (class-agnostic,
LOCKED A2).
Every qualifying whitespace token is a candidate — the digit
distinction (which CLASS of match a token gets) lives on the
matching side (:func:`_name_hit_chunks`), because product names
WITHOUT digits ("gitea", "forgejo", "gateway") were the
2026-09-16 live finding: the old digit-only list gave them no
name signal at all, so an OR-tsquery dominated by a common token
("deploy") buried the product's own documents. Prose precision is
now carried by the match class itself (a digitless token must
EQUAL a whole path component — "server"/"arguments" rarely do),
not by filtering the candidate list.
* the :func:`_normalize_name` form of every whitespace token, kept
when at least :data:`NAME_TOKEN_MIN_LEN` chars — dotted tokens
kept whole (``llama.cpp`` → ``llamacpp``, ``1panel``,
``qwen38`` from a single written token);
* the versioned-name JOIN — the normalized concatenation of every
ADJACENT token pair whose SECOND token is purely numeric (a
version number: ``"… for Qwen 3.8"`` → ``Qwen`` + ``3.8`` joins
to ``qwen38``; prose joins like ``correct`` + ``llama`` and the
word-after-version boundaries like ``3.8`` + ``show`` are
dropped). A purely-numeric second token means the join always
starts with the first token's text (a letter in practice), so no
digit-leading artifact (``38show``) can survive.
Order of first appearance, de-duplicated. Matching applies the
A2 rule: digit-bearing candidates prefix a normalized path part
or file stem; digitless candidates equal a part, stem, or stem
sub-component — titles are never matched.
"""
tokens = question.split()
out: list[str] = []
seen: set[str] = set()
def add(tok: str) -> None:
if len(tok) >= NAME_TOKEN_MIN_LEN and tok not in seen:
seen.add(tok)
out.append(tok)
for i, raw in enumerate(tokens):
norm = _normalize_name(raw)
add(norm)
if i + 1 < len(tokens):
next_norm = _normalize_name(tokens[i + 1])
if next_norm and next_norm.isdigit():
add(norm + next_norm) # the versioned-name join
return out
#: One row of the lexical candidate query (all fields needed to build a
#: detached :class:`Document` plus the chunk fields and ``ts_rank``).
_LEXICAL_SQL = text(
"""
SELECT c.id AS chunk_id,
c.position AS position,
c.content AS content,
d.id AS doc_id,
d.source AS source,
d.path AS path,
d.full_path AS full_path,
d.title AS title,
d.content AS doc_content,
d.content_hash AS content_hash,
d.indexed_at AS indexed_at,
d.created_at AS created_at,
c.is_summary AS is_summary,
ts_rank(c.tsv, to_tsquery('english', :tsquery)) AS rank
FROM chunks c
JOIN documents d ON d.id = c.document_id
WHERE c.tsv @@ to_tsquery('english', :tsquery)
ORDER BY rank DESC, d.path ASC, c.position ASC
LIMIT :limit
"""
)
@dataclass
class RetrievedChunk:
"""One retrieval candidate: fused rank score + parent document row.
* ``score`` — RRF fused score (the ranking key for document selection
and weak-hit titles).
* ``cosine`` — vector similarity ``1 − distance`` (the honesty-gate
input; ``0.0`` for lexical-only hits that have no vector rank).
* ``fts_hit`` — the chunk matched the question's OR-tsquery.
* ``is_summary`` — True for the lite-model summary chunk (phase 30,
position −1): its parent *is* the source document, so a summary hit
resolves to the full source document through the unchanged
chunk→document mapping (A7 revised). Default ``False`` keeps every
ordinary content chunk valid.
* ``name_hit`` — True for the name-hit representative row (phase 119,
D1): the chunk came from the document PATH match, not the
OR-tsquery — the selection tier's bonus input (task 02, D2).
Default ``False`` keeps every ordinary construction valid;
:func:`fuse`'s ``replace()`` copies it (the double-hit merge ORs it
in — a vector row that is also the name hit's representative chunk
keeps the flag).
"""
chunk_id: uuid.UUID
position: int
content: str
score: float
document: Document
cosine: float = 0.0
fts_hit: bool = False
is_summary: bool = False
name_hit: bool = False
def lexical_tsquery(question: str) -> str | None:
"""OR-joined token string for ``to_tsquery('english', …)``, or ``None``.
Tokens are lowercased ``[a-z0-9]+`` runs with DOTTED words kept whole
(see :data:`_TOKEN_RE` — the default parser lexes ``llama.cpp`` as
one lexeme, so the query must carry it whole too), de-duplicated in
order of first appearance. Postgres does the lexing/stemming; a
question whose tokens are all stopwords lexes to an *empty* tsquery
(which matches nothing), so no special-casing is needed there.
Pure-symbol questions ("???", "🔧") yield no tokens → ``None`` → no
lexical query at all.
"""
seen: set[str] = set()
tokens: list[str] = []
for tok in _TOKEN_RE.findall(question.lower()):
if tok not in seen:
seen.add(tok)
tokens.append(tok)
return " | ".join(tokens) if tokens else None
def fuse(
vector: Sequence[RetrievedChunk],
lexical: Sequence[RetrievedChunk],
k: int,
) -> list[RetrievedChunk]:
"""Reciprocal Rank Fusion over the two ranked candidate lists.
``score(chunk) = Σ 1/(k + rank)`` — one term per list the chunk appears
in (ranks are 1-based; a chunk in both lists gets both terms). Returns
the union ordered by ``(-score, -cosine, document.path, position)``.
Lexical-only hits (no vector rank) enter with ``cosine=0.0`` and
``fts_hit=True``; vector chunks matched by the lexical list get
``fts_hit=True`` in place (the input objects are mutated — callers
should not reuse them afterwards), and ``name_hit=True`` is ORed in
(a vector row that is also the name hit's representative chunk is a
name-hit row — the phase-119 selection tier must see it).
"""
if k <= 0:
raise ValueError("rrf k must be > 0")
by_id: dict[uuid.UUID, RetrievedChunk] = {}
fused: dict[uuid.UUID, float] = {}
for rank, rc in enumerate(vector, start=1):
by_id[rc.chunk_id] = rc
fused[rc.chunk_id] = fused.get(rc.chunk_id, 0.0) + 1.0 / (k + rank)
for rank, rc in enumerate(lexical, start=1):
term = 1.0 / (k + rank)
if rc.chunk_id in by_id:
existing = by_id[rc.chunk_id]
by_id[rc.chunk_id] = replace(
existing, fts_hit=True, name_hit=existing.name_hit or rc.name_hit
)
fused[rc.chunk_id] += term
else:
rc = replace(rc, fts_hit=True)
by_id[rc.chunk_id] = rc
fused[rc.chunk_id] = fused.get(rc.chunk_id, 0.0) + term
out = [replace(rc, score=fused[rc.chunk_id]) for rc in by_id.values()]
out.sort(key=lambda rc: (-rc.score, -rc.cosine, rc.document.path, rc.position))
return out
def apply_recency_boost(
chunks: Sequence[RetrievedChunk],
*,
now: datetime | None = None,
weight: float | None = None,
half_life_days: int | None = None,
) -> list[RetrievedChunk]:
"""Additive recency boost on the fused score (phase 106, D6).
Each chunk's score becomes
``score + weight * exp(−age_days / half_life_days)`` where
``age_days = max(0, (now − document.created_at))`` in days — a
zero-age document gets the full *weight* (the MAXIMUM additive
score), each ``half_life_days`` of age multiplies the remaining
boost by ``e**-1`` (≈0.37), and a FUTURE ``created_at`` clamps to
age 0 (the document reads as brand-new — consistent with D3's
today-folding in :mod:`app.rag.doc_dates`). Defaults: *weight* /
*half_life_days* from :func:`get_settings` (``recency_boost`` /
``recency_half_life_days``), *now* from ``datetime.now(UTC)``.
Magnitude rationale (the ``0.0007`` default, the k=60 RRF scale):
rank 1 vs 2 in one list differs by ~0.00026 and rank 1 vs 10 by
~0.0021, so the full weight is a bounded 2-3 rank head start —
enough to break near-ties toward the newer document, far below the
fused gap between a document that answers and one that merely
resembles (the phase-106 fine-line battery pins the measured
margin: 0.00263 ≥ 3× the zero-age boost).
Pure (the :func:`fuse` convention): the inputs are never mutated —
every boosted chunk is a ``replace()`` copy — and the result is
re-sorted with the EXISTING deterministic key
``(−score, −cosine, document.path, position)``; with ``weight=0``
every score is untouched and an already-fused (already-sorted)
input comes back byte-identical (the kill switch, pinned).
Untouched by design: the A8 honesty gate and ``query_log.top_score``
(both read the chunk's ``cosine``, which the boost never modifies),
:func:`weak_hit_titles` (titles only), and the never-truncated
top-N contract (:func:`select_documents` still feeds whole
documents — the boost re-ranks WHICH documents, never truncates).
SINGLE APPLY SITE: :func:`retrieve()` is the only caller in
``app/`` — the chat API and ``scripts/eval_retrieval.py`` inherit
the boost through it; nothing else may apply it.
"""
if weight is None or half_life_days is None:
settings = get_settings()
if weight is None:
weight = settings.recency_boost
if half_life_days is None:
half_life_days = settings.recency_half_life_days
if half_life_days <= 0:
raise ValueError("half_life_days must be > 0")
if now is None:
now = datetime.now(UTC)
boosted: list[RetrievedChunk] = []
for rc in chunks:
age_days = max(0.0, (now - rc.document.created_at).total_seconds() / 86400.0)
boosted.append(
replace(
rc,
score=rc.score + weight * math.exp(-age_days / half_life_days),
)
)
boosted.sort(key=lambda rc: (-rc.score, -rc.cosine, rc.document.path, rc.position))
return boosted
def _vector_candidates(
db: Session, question_embedding: list[float], limit: int
) -> list[RetrievedChunk]:
"""Top-*limit* chunks by pgvector cosine distance (``<=>``).
``cosine = 1 − distance``. Chunks whose embedding is still NULL
(two-phase import in progress) are skipped. Each candidate carries
its ``Chunk.is_summary`` flag (phase 30) so a summary hit stays
identifiable after fusion.
"""
distance = Chunk.embedding.cosine_distance(question_embedding)
rows = db.execute(
select(Chunk, distance.label("distance"), Document)
.join(Document, Chunk.document_id == Document.id)
.where(Chunk.embedding.is_not(None))
.order_by(distance)
.limit(limit)
).all()
return [
RetrievedChunk(
chunk_id=chunk.id,
position=chunk.position,
content=chunk.content,
score=0.0, # fused score is filled in by :func:`fuse`
document=doc,
cosine=round(1.0 - float(dist), 6),
is_summary=chunk.is_summary,
)
for chunk, dist, doc in rows
]
#: One name-hit row: the document fields (detached :class:`Document`)
#: plus the representative chunk — the ``is_summary`` chunk when the
#: document has one (its natural-language summary is the best chunk of
#: a machine file, and the chunk the vector list is likeliest to have
#: ranked too, so the RRF merge dedupes cleanly), else chunk 0.
_NAME_HIT_SQL = text(
"""
SELECT d.id AS doc_id,
d.source AS source,
d.path AS path,
d.full_path AS full_path,
d.title AS title,
d.content AS doc_content,
d.content_hash AS content_hash,
d.indexed_at AS indexed_at,
d.created_at AS created_at,
c.id AS chunk_id,
c.position AS position,
c.content AS content,
c.is_summary AS is_summary
FROM documents d
LEFT JOIN LATERAL (
SELECT id, position, content, is_summary
FROM chunks
WHERE document_id = d.id
ORDER BY is_summary DESC, position ASC
LIMIT 1
) c ON true
WHERE d.id = ANY(:ids) AND c.id IS NOT NULL
"""
)
def _name_parts(path: str) -> tuple[set[str], set[str]]:
"""The normalized name components of a document path (LOCKED A2).
Returns ``(prefix_set, equal_set)``:
* ``prefix_set`` — the normalized form of every path part plus the
normalized file stem (``qwen3.8-27b-juggernaut-vulkan.container``
→ ``{…, qwen3827bjuggernautvulkancontainer, qwen3827bjuggernaut…}``):
DIGIT-BEARING tokens prefix-match these;
* ``equal_set`` — ``prefix_set`` plus the stem's sub-components
(the stem lowercased, split on non-alphanumeric runs, each piece
normalized, empties dropped: ``kubernetes_gitea`` →
``kubernetes`` / ``gitea``): DIGITLESS tokens exact-match these.
"""
p = Path(path)
prefix = {_normalize_name(part) for part in p.parts}
prefix.discard("")
prefix.add(_normalize_name(p.stem))
equal = set(prefix)
for piece in _STEM_SPLIT_RE.split(p.stem.lower()):
norm = _normalize_name(piece)
if norm:
equal.add(norm)
return prefix, equal
def _name_token_matches(token: str, prefix: set[str], equal: set[str]) -> bool:
"""The two-class match of one candidate token against one path
(LOCKED A2, phase 119):
* the token CONTAINS A DIGIT → it is a PREFIX of a normalized path
part or file stem (``qwen38`` →
``qwen3.8-27b-juggernaut-vulkan.container``);
* the token HAS NO DIGIT → it EQUALS a normalized path part, file
stem, or stem sub-component (``gitea`` → the ``gitea/`` folder,
``gitea.md``, ``kubernetes_gitea``, ``gitea-values``).
"""
if any(ch.isdigit() for ch in token):
return any(part.startswith(token) for part in prefix)
return token in equal
def _name_hit_chunks(db: Session, question: str) -> list[RetrievedChunk]:
"""The documents whose PATH matches the question (the 2026-09-05
incident's versioned-name signal, extended 2026-09-16 for product
names without digits — phase 119, D1, LOCKED A2).
A document is a name hit when at least one :func:`name_hit_tokens`
candidate matches its path components under the two-class rule
(:func:`_name_token_matches`): DIGIT-BEARING tokens prefix-match a
normalized path part or file stem (``qwen38`` →
``qwen3.8-27b-juggernaut-vulkan.container``); DIGITLESS tokens
exact-match a normalized path part, file stem, or stem
sub-component (``gitea`` → the ``gitea/`` folder, ``gitea.md``,
``kubernetes_gitea``, ``gitea-values``). **Titles are never
matched** — titles are prose: ``deploy/Deployments/reeseapps/
README.md`` is titled "Deployments" and must NOT name-match the
common token ``deploy`` or the 9 other deployment-titled docs
(the owner-verified failure mode of the naive title relaxation).
Ranked by (distinct matched-token count DESC, then ``(source,
path)`` catalog order) — the old total-matched-length tie-break is
RETIRED (it systematically outranked 5-char product names by
6-char common tokens) — capped at :data:`NAME_HIT_LIMIT`. Each hit
becomes one lexical :class:`RetrievedChunk` (its representative
chunk, ``fts_hit=True``, ``cosine=0.0``, ``name_hit=True``). Two
lightweight queries: one projection over (id, source, path, title)
in catalog order (the title is selected but never matched), one
LATERAL chunk fetch for the ≤ :data:`NAME_HIT_LIMIT` winners (no
full-content load; the content joins in via the row fetch below).
"""
tokens = name_hit_tokens(question)
if not tokens:
return []
rows = db.execute(
select(Document.id, Document.source, Document.path, Document.title).order_by(
Document.source, Document.path
)
).all()
scored: list[tuple[int, uuid.UUID]] = []
by_id: dict[uuid.UUID, tuple[str, str]] = {} # id -> (source, path)
for doc_id, source, path, _title in rows:
by_id[doc_id] = (source, path)
prefix, equal = _name_parts(path)
matched = sum(1 for t in tokens if _name_token_matches(t, prefix, equal))
if matched:
scored.append((matched, doc_id))
if not scored:
return []
# Ranked by (distinct matched-token count DESC), the deterministic
# catalog tie-break (source, path) — the old total-matched-length
# tie-break is retired (it outranked 5-char product names by
# 6-char common tokens).
scored.sort(key=lambda s: (-s[0], by_id[s[1]][0], by_id[s[1]][1]))
ids = [s[1] for s in scored[:NAME_HIT_LIMIT]]
hit_rows = list(db.execute(_NAME_HIT_SQL, {"ids": ids}).all())
# The LATERAL query returns winners in id order; re-order by the
# ranked order computed above so the lexical list is deterministic.
order = {doc_id: rank for rank, doc_id in enumerate(ids)}
hit_rows.sort(key=lambda row: order[row.doc_id])
out: list[RetrievedChunk] = []
for row in hit_rows:
doc = Document(
id=row.doc_id,
source=row.source,
path=row.path,
full_path=row.full_path,
title=row.title,
content=row.doc_content,
content_hash=row.content_hash,
indexed_at=row.indexed_at,
created_at=row.created_at,
)
out.append(
RetrievedChunk(
chunk_id=row.chunk_id,
position=row.position,
content=row.content,
score=0.0, # filled in by :func:`fuse`
document=doc,
cosine=0.0, # no vector rank — name-only hit
fts_hit=True, # lexical signal — A8 answers if cosine corroborates
is_summary=bool(row.is_summary),
name_hit=True, # phase 119 — the selection tier's bonus input
)
)
return out
def _lexical_candidates(db: Session, question: str, limit: int) -> list[RetrievedChunk]:
"""The lexical candidate list: name hits, then FTS rows (A7).
The name-hit documents (:func:`_name_hit_chunks`, the versioned-name
signal) LEAD the list — they are the strongest lexical evidence for
a name-your-thing question — followed by the top-*limit* chunks
matching the question's OR-tsquery, ordered by ``ts_rank`` (with
``d.path, c.position`` as the deterministic tie-break). Deduped by
chunk id (a name-hit representative chunk the FTS list also ranked
appears once). An empty tsquery (stopword-only question) simply
contributes no FTS rows; a question with no name tokens contributes
no name hits — both halves are independent.
"""
out: list[RetrievedChunk] = []
seen: set[uuid.UUID] = set()
for rc in _name_hit_chunks(db, question):
if rc.chunk_id not in seen:
seen.add(rc.chunk_id)
out.append(rc)
tsquery = lexical_tsquery(question)
if tsquery is None:
return out
rows = db.execute(
_LEXICAL_SQL, {"tsquery": tsquery, "limit": limit}
).all()
for row in rows:
if row.chunk_id in seen:
continue
seen.add(row.chunk_id)
doc = Document(
id=row.doc_id,
source=row.source,
path=row.path,
full_path=row.full_path,
title=row.title,
content=row.doc_content,
content_hash=row.content_hash,
indexed_at=row.indexed_at,
created_at=row.created_at,
)
out.append(
RetrievedChunk(
chunk_id=row.chunk_id,
position=row.position,
content=row.content,
score=0.0, # filled in by :func:`fuse`
document=doc,
cosine=0.0, # no vector rank — lexical-only hit
fts_hit=True,
is_summary=row.is_summary,
)
)
return out
def retrieve(
db: Session,
question: str,
question_embedding: list[float],
vector_candidates: int | None = None,
lexical_candidates: int | None = None,
) -> list[RetrievedChunk]:
"""Hybrid retrieval (A7): vector top-N ∪ FTS top-N, RRF-fused.
Returns the fused candidate list in rank order (best first). Each
:class:`RetrievedChunk` carries the fused ``score`` (ranking), the
``cosine`` similarity (honesty gate) and the ``fts_hit`` flag.
"""
settings = get_settings()
v_n = (
settings.hybrid_vector_candidates if vector_candidates is None else vector_candidates
)
l_n = (
settings.hybrid_lexical_candidates if lexical_candidates is None else lexical_candidates
)
if v_n <= 0:
raise ValueError("vector_candidates must be >= 1")
if l_n <= 0:
raise ValueError("lexical_candidates must be >= 1")
vector = _vector_candidates(db, question_embedding, v_n)
lexical = _lexical_candidates(db, question, l_n)
fused = fuse(vector, lexical, settings.rrf_k)
if settings.recency_boost > 0:
# Phase 106, D6 — the SINGLE recency-boost apply site: an
# additive post-fusion re-rank (see :func:`apply_recency_boost`).
# ``0`` = off: the pre-phase ranking returns byte-identical (the
# kill switch) and weight-0 callers pay nothing.
return apply_recency_boost(fused)
return fused
def _selection_order(
chunks: Sequence[RetrievedChunk],
bonus: float,
) -> list[tuple[Document, float, float, int]]:
"""The shared selection walk of the phase-119 name-hit bonus (D2,
LOCKED A3) — one walk for ``select_suggested``, ``select_related``,
and ``weak_hit_titles``.
Returns ``(document, effective_score, best_cosine, first_seen_index)``
for each distinct document, where *effective_score* is the
document's best fused chunk score plus *bonus* when ANY of its
chunks carries ``name_hit`` (the D1 path match — the bonus is per
DOCUMENT, applied ONCE no matter how many of the document's chunks
are name hits).
The walk keeps the EXISTING selection semantics (not just the loop):
the same stable score-descending order as
:func:`select_documents_tiered` / :func:`select_suggested` — a
document's rank position is fixed by its FIRST seen chunk — with its
best cosine tracked across ALL of its chunks (the tiered walk's
tracking).
* *bonus* ``== 0`` (the kill switch) or no name-hit chunk present:
the document order is IDENTICAL to the pre-phase walk — no re-sort
happens at all (byte-identical, LOCKED A3);
* otherwise the documents are ordered by
``(−effective_score, −best_cosine, document.path,
first_seen_index)`` — the bounded re-rank: a name-hit document
gets a head start on the fused scale, and an effective-score tie
resolves by cosine, then path, then the pre-bonus rank.
The bonus lives in the SELECTION layer only: the chunk objects are
never modified — their ``score``/``cosine``/``fts_hit`` (the A8
gate's inputs) and ``query_log.top_score`` are untouched.
"""
order: list[Document] = []
best_score: dict[uuid.UUID, float] = {}
best_cosine: dict[uuid.UUID, float] = {}
any_name_hit: dict[uuid.UUID, bool] = {}
first_seen: dict[uuid.UUID, int] = {}
for idx, rc in enumerate(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
if rc.name_hit:
any_name_hit[doc.id] = True
else:
order.append(doc)
best_score[doc.id] = rc.score
best_cosine[doc.id] = rc.cosine
any_name_hit[doc.id] = rc.name_hit
first_seen[doc.id] = idx
effective = {
doc.id: best_score[doc.id] + (bonus if any_name_hit[doc.id] else 0.0)
for doc in order
}
if bonus > 0.0 and any(any_name_hit[doc.id] for doc in order):
order.sort(
key=lambda doc: (
-effective[doc.id],
-best_cosine[doc.id],
doc.path,
first_seen[doc.id],
)
)
return [
(doc, effective[doc.id], best_cosine[doc.id], first_seen[doc.id])
for doc in order
]
def weak_hit_titles(
chunks: Sequence[RetrievedChunk],
bonus: float | None = None,
) -> list[str]:
"""Distinct parent-document titles of *chunks*, best SELECTION score
first (the phase-119 name-hit bonus applied — see
:func:`_selection_order`, D2, LOCKED A3).
Deflection mode (PLAN §6, A8) is built from these *titles only* — the
LOW prompt and the "Maybe try" chips never see document content.
"""
if bonus is None:
bonus = get_settings().name_hit_bonus
return [doc.title for doc, _eff, _cos, _idx in _selection_order(chunks, bonus)]
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).
Phase 118 retired the full-text seeding role (A6); the suggested
tier (:func:`select_suggested`) seeds the prompt now — this helper
stays as a dormant public helper (env back-compat for the settings
it was calibrated by).
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; A6 re-revised 2026-09-15: the retrieval path
seeds SUMMARIES — the cited tier's full texts no longer ride the
grounded prompt, full text enters the context only through the
capped ``read`` tool; the rows themselves still carry the whole
content).
"""
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,
) -> list[Document]:
"""Map chunk hits to distinct parent documents, ranked by best fused score.
Phase 118 retired the full-text seeding role (A6); the suggested
tier (:func:`select_suggested`) seeds the prompt now — this helper
stays as a dormant public helper (env back-compat for the settings
it was calibrated by).
At most *n* documents are returned (default ``BOR_TOP_N_DOCS``). The
returned rows carry the full document content, byte-identical — a
matched parent document is **never truncated** (A7 revised, owner
permission 2026-08-24; A6 re-revised 2026-09-15: the seeded prompt
now carries SUMMARIES — the full text reaches the context only
through the capped ``read`` tool, not through this selection). There
is deliberately no context budget: an oversized prompt must fail
loudly through the ``LLMError`` → SSE ``error`` path, never arrive
as silent partial context.
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
def select_suggested(
chunks: Sequence[RetrievedChunk],
n: int | None = None,
bonus: float | None = None,
) -> list[Document]:
"""Top-N distinct parent documents in SELECTION rank order — the
phase-118 "start here" suggestion tier (LOCKED A3, re-revised by
phase 119: the walk now carries the bounded name-hit bonus, D2),
with NO cosine floor.
Distinct parent documents are walked in the SAME stable score-
descending order as :func:`select_documents_tiered` (a document's rank
position is fixed by its FIRST seen chunk; dedupe by ``document.id``)
— the shared :func:`_selection_order` walk — plus, when the bonus is
on AND a name-hit chunk is present, the
``(−effective, −best_cosine, path, first_seen)`` re-rank that gives
a name-hit document its head start. At most *n* of them are returned
(default the ``BOR_SUGGESTED_DOCS`` setting, 5). Unlike the phase-113
cited tier, the usefulness bar NEVER filters here: a lexical-only hit
with cosine 0.0 is suggested when it ranks. Suggestions are opt-in
starting points, not citations — the seeded prompt carries the
document's summary, and the LLM decides whether to extend its context
by reading the document's full text.
*bonus* defaults to the ``BOR_NAME_HIT_BONUS`` setting (0.005 — the
owner-tunable starting point); ``0`` reproduces the pre-phase walk
byte-identically (the kill switch, LOCKED A3).
The returned rows carry the full document content, byte-identical —
the content is what the agent's ``read`` tool serves later (never
truncated; A6 re-revises A7: full text enters the context only through
the capped ``read`` tool).
"""
top_n = n if n is not None else get_settings().suggested_docs
if bonus is None:
bonus = get_settings().name_hit_bonus
return [doc for doc, _eff, _cos, _idx in _selection_order(chunks, bonus)[:top_n]]
def select_related(
chunks: Sequence[RetrievedChunk],
excluded_ids: set[uuid.UUID],
cap: int,
bonus: float | None = None,
) -> list[Document]:
"""The documents ranked AFTER *excluded_ids* — the phase-118 related
tier (rank 6+ for the contiguous top-5 suggestion set), up to *cap*
(``BOR_RELATED_MAX_DOCS``).
The SAME shared selection walk as :func:`select_documents_tiered` /
:func:`select_suggested` (a document's rank position is fixed by its
FIRST seen chunk; dedupe by ``document.id`` — the phase-119 name-hit
bonus applied, D2, LOCKED A3), skipping every document whose id is in
*excluded_ids* and admitting at most *cap* documents. There is NO
cosine floor: the related tier is the ranked remainder (a lexical-
only cosine 0.0 hit is included) — its job on the ``done`` frame is
visibility (the UI's de-emphasized "nearby docs" row), not
citation. With the turn wiring's exclusion — exactly the suggested
tier's document ids (LOCKED A3: a contiguous top-N, no floor) —
"excluding the suggested" is exactly "rank 6+".
*bonus* defaults to the ``BOR_NAME_HIT_BONUS`` setting (0.005 — the
owner-tunable starting point); ``0`` reproduces the pre-phase walk
byte-identically (the kill switch, LOCKED A3).
The returned rows carry the full document content, byte-identical
(the tier is metadata for the ``done`` frame and the durable
record; the prompt and ``read`` contract are untouched).
"""
if bonus is None:
bonus = get_settings().name_hit_bonus
out: list[Document] = []
for doc, _eff, _cos, _idx in _selection_order(chunks, bonus):
if len(out) >= cap:
break
if doc.id in excluded_ids:
continue
out.append(doc)
return out
def source_ref_with_image(doc: Document) -> SourceRef:
"""One :class:`~app.schemas.SourceRef` wire frame for *doc* — the
phase-122 (task 05) SHARED frame builder: the chat API's cited
tier (the agent's read docs) and the related tier both run through
it, so the per-doc ref shape has exactly one construction site.
The ref carries the chip identity (``source`` / ``path`` /
``title``) and, ONLY for a standalone-image document (``is_image``),
the optional ``image_url`` — the image BYTES route
``/api/documents/<id>/image`` (the chat's sources block renders the
compact inline image from it, the summary as alt + caption —
"shown in the chat nicely", TODO L6). For a TEXT document the field
stays ``None`` and is DROPPED by the model's serializer (never
``null`` — the omission rule): a text-doc frame is byte-identical
to pre-phase. The frame's doc id rides the path — the same way the
document content endpoint's ``(source, path)`` lookup does (no new
id leak beyond what the frame already carries).
"""
ref = SourceRef(source=doc.source, path=doc.path, title=doc.title)
if doc.is_image:
ref.image_url = f"/api/documents/{doc.id}/image"
return ref