**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`.
967 lines
42 KiB
Python
967 lines
42 KiB
Python
"""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
|