phase: 119_name_signal_read_chips
Build and Push Containers / build-and-push-app (push) Successful in 2m1s
Build and Push Containers / build-and-push-db (push) Successful in 18s

All verification complete. Final report:

**Phase 119 final verification pass — all criteria verified, one stale pin fixed.**
- Verified implementation of all 6 tasks: D1 component name-hit rule (`name_hit` flag, titles never matched, retired length tie-break), D2 `BOR_NAME_HIT_BONUS` (0.005 default, 0 = byte-identical kill switch, negative fails startup, selection-layer only, `eval_retrieval` `suggested:` line), D3 suggested-folder lines (after `SUGGEST_INTRO`, before first block), D4 cite-discipline `SUGGEST_INTRO` sentence (PERSONA/LOW/`TOOLS_SECTION` byte-pins intact), D5 `done.sources` = read docs only (frontend no-op on empty confirmed), D6 mock `repeat your folder map` echo + new suite + telemetry.
- Battery (replica restored per skill, fingerprint docs=1000/chunks=8866 verified, `eval_retrieval --from-file tests/fixtures/retrieval_battery.txt` re-run): **GATE PASS** — gitea README #4 in suggested top-5, forgejo 5/5 (README #1), gateway README in top-5 (#4), qwen3.8-27b quadlets top-5, Mongolia HIGH/fts=5 unchanged.
- New E2E in isolation: `4 passed` ×2 (deterministic). All 27 modified E2E suites in isolation: 26 green; **1 stale pin fixed** — `test_source_chip_quality.py` durable-record order pin pre-dated the D1 re-rank (`aliases` stem sub-component name-hits `ssh_aliases.txt`, deterministically lifting `backups.md` over `kubernetes.md`; probe-verified 0.016277 vs 0.016036, 4/4 stable) — re-pinned with the phase-119 rationale; suite green ×2.
- Gates: `uv run pytest --cov=app --cov-report=term-missing` → **2547 passed, app coverage 99%** (>90%); `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
- Completion criteria: 1 ✅ (battery, recorded), 2 ✅ (folder lines; block/LOW byte-identical pins green), 3 ✅ (read-only chips, zero-read chips nothing, related row + durable record untouched — unit+E2E agree), 4 ✅ (all green), 5 → commit/phase-move left to the harness per pass rules (nothing committed).
- Deviations: battery output + real-model telemetry recorded in `.agents/reports/119_name_signal_read_chips/task06_battery_and_e2e.md` and `TOOL_CALLING_TESTING.md` §11 (task files in `complete/` are immutable to this pass); gateway canonical doc at #4 vs overview's #3 was already documented at task 06 (containment gate met).
- Next pending phase: **none** — `todo/` holds only phase 119.
This commit is contained in:
2026-09-16 15:50:48 -04:00
parent 795fb56425
commit a5b63f83ad
89 changed files with 4377 additions and 655 deletions
+75 -25
View File
@@ -55,12 +55,18 @@ point — and the LLM extends its context by ``read``-ing what it needs
(the capped ``read`` tool is the ONLY full-text path). Both tiers are
computed once per turn, for BOTH branches: ``suggested_docs`` (no
floor, A3) and ``related_docs`` (rank 6+, the done frame's row).
``done.sources`` is the citation surface — suggested + agent-read,
deduped (A4: a grounded turn always shows chips); ``query_log.sources``
and the per-turn log line keep recording the full retrieval
(suggested + related + read, LOCKED A3). The deflected branch's prompt
is byte-identical (weak-hit titles only — A8 untouched); its TurnPlan
still carries both tiers for the durable record.
Phase 119 (LOCKED A1, owner decision 2026-09-16 — the phase-118 A4
union RETIRED): ``done.sources`` is the citation surface and carries
the AGENT-READ documents only (``holder.read_docs``, deduped by
``(source, path)``, read order) — a zero-read grounded turn (the
summary-only fast path) chips nothing, an accepted, owner-directed
consequence (the answer prose names the doc it used per the phase-119
cite discipline, A5); ``query_log.sources`` and the per-turn log line
keep recording the full retrieval (suggested + related + read, 118-A3
stands — the durable record records retrieval, not citations). The
deflected branch's prompt is byte-identical (weak-hit titles only —
A8 untouched); its TurnPlan still carries both tiers for the durable
record.
KB overview (phase 31): the lite-generated outline of the knowledge
base (single ``kb_overview`` row) is read per turn (one indexed PK
@@ -198,6 +204,7 @@ from app.rag.agent import (
AgentHolder,
MalformedReplyError, # phase 71: the recovery policy's terminal signal
run_agent,
suggested_folder_lines, # phase 119 (D3): the suggested-folder context lines
)
from app.rag.llm import (
EmbeddingError,
@@ -282,8 +289,11 @@ class TurnPlan:
deflected: bool
system_prompt: str
# The summary-seeded suggestion tier (phase 118, A6: top-N distinct
# documents, NO floor, A3 — the HIGH prompt carries their SUMMARIES;
# the done frame's citation surface + run_agent's seed_docs, A4).
# documents, NO floor, A3 — the HIGH prompt carries their SUMMARIES
# and they seed run_agent). Phase 119 (LOCKED A1): NOT the done
# frame's citation surface anymore — done.sources is the
# agent-read docs only (the phase-118 A4 union retired); this tier
# still rides the durable record (query_log + the log line).
suggested_docs: list[Document]
# Rank 6+ after the suggested set (phase 118: the next ranked docs
# that are not already suggested, at most related_max_docs — the
@@ -306,6 +316,7 @@ def plan_turn(
settings: Settings,
notes: Sequence[str] | None = None,
kb_overview: str | None = None,
folder_lines: Sequence[str] = (),
) -> TurnPlan:
"""Apply the honesty gate (A8, revised) and assemble prompt + context.
@@ -336,6 +347,17 @@ def plan_turn(
prompts are byte-identical to the pre-phase text and ``kb_chars``
is 0.
*folder_lines* (phase 119, D3, LOCKED A4): the suggested-folder
context lines, computed by the chat endpoint (which holds the DB
session) via ``suggested_folder_lines`` over the SAME deterministic
suggested walk this function performs internally. The HIGH branch
passes them to ``build_high_prompt`` (they ride after
``SUGGEST_INTRO``, before the first ``<document>`` block); the LOW
branch IGNORES them — the deflected prompt stays byte-identical.
Empty *folder_lines* ⇒ the HIGH prompt is byte-identical to the
phase-118 shape. The :class:`TurnPlan`, the durable record, and the
per-turn log line are untouched.
``summary_hits`` (phase 30; phase 118 redefinition) counts the hit
chunks with ``is_summary`` whose parent document is in the
SUGGESTED set — both the HIGH and the LOW branch record it.
@@ -380,7 +402,9 @@ def plan_turn(
best_cosine,
fts_hits,
False,
build_high_prompt(suggested, notes=steering, kb_overview=kb_text),
build_high_prompt(
suggested, notes=steering, kb_overview=kb_text, folder_lines=folder_lines
),
suggested,
related_docs,
[],
@@ -581,8 +605,26 @@ async def chat(
kb_overview = load_kb_overview(step_db)
with SessionLocal() as step_db:
chunks = retrieve(step_db, request.message, question_vec)
# Phase 119 (D3, LOCKED A4): the suggested-folder
# context — the SAME deterministic suggested walk
# plan_turn performs internally (one extra cheap
# walk, no shared state); each suggested doc's
# parent-folder contents pre-seed the HIGH prompt
# so the model reads the right file in round 1
# instead of walking ls drill-downs. The LOW branch
# ignores them (the deflected prompt stays
# byte-identical); empty ⇒ the phase-118 prompt is
# byte-identical too.
folder_lines = suggested_folder_lines(
step_db,
select_suggested(chunks, n=settings.suggested_docs),
)
plan = plan_turn(
chunks, settings, notes=steering_notes, kb_overview=kb_overview
chunks,
settings,
notes=steering_notes,
kb_overview=kb_overview,
folder_lines=folder_lines,
)
except Exception: # noqa: BLE001 — DB failure mid-turn
logger.exception(
@@ -852,19 +894,25 @@ async def chat(
# "start here" tier, no floor, A3) and the related
# docs (``plan.related_docs``, rank 6+ after the
# suggested set). The DURABLE record keeps the full
# retrieval (LOCKED A3: query_log records retrieval,
# retrieval (118-A3: query_log records retrieval,
# not citations — even on deflected turns, where the
# weak hits are suggested). Phase 112/118:
# done.sources is the CITATION surface — it carries the
# suggested docs + the agent-read docs (deduped,
# LOCKED A4) on grounded turns and [] on deflected
# ones (a deflected answer cites nothing; the weak
# hits stay in the durable record).
# weak hits are suggested). Phase 112/119:
# done.sources is the CITATION surface — READ DOCS
# ONLY (phase 119, LOCKED A1, owner decision
# 2026-09-16; the phase-118 A4 suggested+read union
# is RETIRED): on grounded turns it carries
# ``holder.read_docs`` deduped by (source, path),
# read order — a zero-read grounded turn (the
# summary-only fast path) chips nothing, an accepted
# consequence (the answer prose names the doc per the
# phase-119 cite discipline, A5) — 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] = []
cited_seen: set[tuple[str, str]] = set()
for doc in [*plan.suggested_docs, *holder.read_docs]:
for doc in holder.read_docs:
key = (doc.source, doc.path)
if key not in cited_seen:
cited_seen.add(key)
@@ -925,13 +973,15 @@ async def chat(
# citations). The retrieval stays durably recorded above
# (query_log.sources + the log line — observability
# unchanged).
# Phase 118 (A3/A4): done.related carries the related
# tier — the ranked documents beyond the suggested set
# (rank 6+ after the contiguous top-N suggestion, capped
# by related_max_docs in the tiering), 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". The UI renders it as the
# Phase 118 (A3) / phase 119 (A1): done.related carries
# the related tier — the ranked documents beyond the
# suggested set (rank 6+ after the contiguous top-N
# suggestion, capped by related_max_docs in the tiering),
# deduped against the cited list, the same (source,
# path) pattern as cited_docs — which is the READ DOCS
# only now: a read related doc is a chip, never a
# "nearby doc" (unchanged intent, keyed on read docs
# since phase 119). The UI renders the row as the
# de-emphasized related-docs row, never a citation chip;
# old clients ignore the field.
cited_refs: list[SourceRef] = []
+29
View File
@@ -292,6 +292,24 @@ class Settings(BaseSettings):
#: of document age (full weight at age 0, ``weight/e`` at one
#: half-life). ``<= 0`` fails startup loudly (same validator family).
recency_half_life_days: int = 365
#: Bounded name-hit bonus on the SELECTION-time document score
#: (phase 119, D2, LOCKED A3): the selection walks
#: (``select_suggested`` / ``select_related`` / ``weak_hit_titles``
#: in ``app.rag.retriever``) add this to a document's best fused
#: chunk score when any of its chunks is a NAME HIT (the document
#: PATH matched a question name token under the D1 two-class rule)
#: — a product-name question ("How do I deploy gitea?") lifts the
#: product's own documents into the seeded suggestion tier. The
#: phase-106 recency-boost precedent: additive, bounded, single
#: apply site (the selection layer ONLY — chunk scores, ``fuse()``,
#: ``retrieve()``, the A8 honesty gate, and ``query_log.top_score``
#: are untouched), ``0`` = off (the pre-phase walk returns
#: byte-identical — the kill switch); a negative value fails startup
#: loudly. 0.005 ≈ a 2-4 rank head start on the k=60 RRF scale
#: (rank 1 vs 5 in one list differs by ~0.0010) — an owner-tunable
#: starting point, not a calibrated constant (the phase-119 battery
#: records the realized margins).
name_hit_bonus: float = 0.005
# --- Admin & sign-in (phase 16; A10 revised 2026-08-22) ---
# Single-admin auth via a signed session cookie (Starlette
@@ -546,6 +564,17 @@ class Settings(BaseSettings):
raise ValueError("recency_half_life_days must be > 0 (days)")
return v
@field_validator("name_hit_bonus")
@classmethod
def _name_hit_bonus_non_negative(cls, v: float) -> float:
"""``0`` is the byte-identical kill switch (the pre-phase
selection order) — a NEGATIVE bonus would demote name-hit
documents, the exact opposite of D2 (the ``agent_max_rounds``
pattern, phase 119)."""
if v < 0:
raise ValueError("name_hit_bonus must be >= 0 (0 = off)")
return v
@field_validator("db_pool_size")
@classmethod
def _db_pool_size_positive(cls, v: int) -> int:
+92
View File
@@ -920,6 +920,98 @@ def render_folder_listing(
return "\n".join([header, *body])
def suggested_folder_lines(
db: Session,
suggested: Sequence[Document],
max_lines: int = 5,
max_entries: int = 8,
) -> list[str]:
"""The HIGH prompt's suggested-folder context lines (phase 119, D3,
LOCKED A4) — pure composition over the existing ``ls`` machinery.
One line per DISTINCT parent folder of the *suggested* documents —
in suggested-doc order, deduped by ``(source, parent prefix)`` (the
first suggested doc wins the slot), at most *max_lines* lines:
* the parent prefix is the path up to (excluding) the last ``/``
(``""`` = the source root);
* the line is ``<source>/<prefix>/: e1, e2, …`` (the source root
renders as ``<source>/: …`` — the filesystem-style folder path,
trailing slash included, + the colon) with the folder's direct
children in the
EXISTING ``ls`` folder-level rendering order — the direct
subfolders first (``name/ (N docs)``, the recursive doc count,
singular ``(1 doc)``), then the files by relative filename — so
the line reads the same as the model's own ``ls`` output of that
folder (the :func:`group_folder_listing` grouping, over
:func:`_source_document_rows` + :func:`_source_folder_summaries`);
* the suggested document that OWNS the line is excluded from the
entries (its identity is already in its ``<document>`` block — the
line is the folder's OTHER contents, the pre-seed that makes the
model ``read`` the right file in round 1 instead of walking the
``ls`` drill-downs);
* at most *max_entries* entries, then `` +N more`` (N = the
remaining count, the true pre-cap folder total — the suggested
doc leaves the total even when its row sat past
:data:`LS_MAX_FILE_LINES`); a folder whose only entry was the
suggested doc renders its header alone (the ``… — 0 documents, 0
folders:`` empty-level precedent).
Empty *suggested* → ``[]`` (the caller then builds the
byte-identical phase-118 prompt). Module-level so unit tests can
monkeypatch the fetchers without a database (the house style:
:func:`ls_top` / :func:`ls_folder` compose the same fetchers).
"""
lines: list[str] = []
seen: set[tuple[str, str]] = set()
rows_cache: dict[str, tuple[list[tuple[str, str, str]], dict[str, str]]] = {}
for doc in suggested:
if len(lines) >= max_lines:
break
prefix = folder_of(doc.path)
key = (doc.source, prefix)
if key in seen:
continue
seen.add(key)
if doc.source not in rows_cache:
rows_cache[doc.source] = (
_source_document_rows(db, doc.source),
_source_folder_summaries(db, doc.source),
)
rows, summaries = rows_cache[doc.source]
subfolders, files, total_files = group_folder_listing(
doc.source, prefix, rows, summaries
)
entries = [
f"{sub}/ ({count} {'doc' if count == 1 else 'docs'})"
for sub, count, _summary in subfolders
]
# The owning suggested doc is a direct file of this folder —
# drop it from the entries (its identity is already in its
# <document> block); its count leaves the total either way
# (even when its row sat past the LS_MAX_FILE_LINES file cap).
files = [entry for entry in files if entry[1] != doc.path]
# The file entries come AFTER the subfolders (the ls folder-level
# order) and ride by RELATIVE filename — the basename within the
# folder (the line's ``<source>/<prefix>/:`` header supplies the
# folder; combined ``source/prefix/name`` is the read identity).
entries.extend(path.rsplit("/", 1)[-1] for _src, path, _title, _date in files)
total = len(subfolders) + total_files - 1
shown = entries[:max_entries]
# The pinned identity shape: ``<source>/<prefix>/:`` (the source
# root: ``<source>/:``) — the filesystem-style folder path
# (trailing slash included) + the colon.
identity = f"{doc.source}/{prefix}/" if prefix else f"{doc.source}/"
line = f"{identity}:"
if shown:
suffix = ", ".join(shown)
if total > len(shown):
suffix += f" +{total - len(shown)} more"
line += f" {suffix}"
lines.append(line)
return lines
def find_document(db: Session, source: str, path: str) -> Document | None:
"""The indexed document at ``(source, path)``, or ``None``.
+49 -14
View File
@@ -132,21 +132,30 @@ _KB_INTRO = (
)
#: The ``<documents>`` section's start-here intro (phase 118, task 03 —
#: the owner directive, TODO L3): the seeded blocks are the SUMMARIES of
#: the top-ranked documents for the question, opt-in starting points —
#: start here if one seems right; ``read`` the document's combined
#: ``source/path`` to add its FULL content before answering specifics
#: (the full text is NOT in the prompt until you read it); cite the
#: document(s) you used, by path. Rendered BEFORE the first block, only
#: when at least one block is present. The ``<documents>`` tag and the
#: per-block ``<document>`` markup stay byte-stable around it (the E2E
#: mock's block parser and the ``read``-tool copy key off both).
#: the owner directive, TODO L3; phase 119, task 04, LOCKED A5 — the
#: final sentence re-cut to the cite discipline): the seeded blocks are
#: the SUMMARIES of the top-ranked documents for the question, opt-in
#: starting points — start here if one seems right; ``read`` the
#: document's combined ``source/path`` to add its FULL content before
#: answering specifics (the full text is NOT in the prompt until you
#: read it); cite ONLY the documents you read (or the suggested document
#: you answered from without reading it) — never one you neither read
#: nor used. The discipline sentence closes the phase-119 live
#: confabulation — the model's "Docs used:" line cited a file it never
#: read, confabulated from the suggestion blocks sitting in context —
#: and retires the phase-118 "cite the document(s) you used, by path"
#: sentence. Rendered BEFORE the first block, only when at least one
#: block is present. The ``<documents>`` tag and the per-block
#: ``<document>`` markup stay byte-stable around it (the E2E mock's
#: block parser and the ``read``-tool copy key off both).
SUGGEST_INTRO = (
"The blocks below are the summaries of the top-ranked documents for "
"your question — start here if one seems right to you: call `read` "
"with that document's combined `source/path` to add its full content "
"before answering specifics (its full text is not in the prompt until "
"you read it). Cite the document(s) you used, by path."
"you read it). Cite only the document(s) you read — or, if you "
"answered from a suggested summary without reading it, cite that "
"suggested document — never a document you neither read nor used."
)
#: The ``<tools>`` instructions section — **HIGH prompt only** (phase 37,
@@ -416,6 +425,7 @@ def build_high_prompt(
documents: Sequence[Document],
notes: Sequence[str] | None = None,
kb_overview: str | None = None,
folder_lines: Sequence[str] = (),
) -> str:
"""Grounded turn: locked persona (+ steering, + KB overview) + the
top-ranked documents' SUMMARY blocks + the ``<tools>`` instructions
@@ -423,7 +433,8 @@ def build_high_prompt(
``grep`` shapes; phase 72: the document-identity contract up front;
phase 118, LOCKED A6 re-revising A7: the ``<documents>`` section
seeds SUMMARIES, never full texts — full text enters the context
only through the capped ``read`` tool).
only through the capped ``read`` tool; phase 119, LOCKED A4: the
suggested-folder context lines, below).
Section order: ``<relevance>`` → ``<knowledge_base>`` → ``<tuning>``
→ ``<documents>`` → ``<tools>``; empty steering/overview omit their
@@ -435,11 +446,28 @@ def build_high_prompt(
start-here :data:`SUGGEST_INTRO` line (before the first block — the
phase-15 ``_STEERING_INTRO`` / phase-31 ``_KB_INTRO`` precedent): the
blocks are the summaries of the top-ranked documents, ``read`` adds
the full text, and the answer cites the document(s) used by path.
This is NOT the reverted phase-72 in-context reminder (that copy
the full text, and the answer cites only the documents it read (or
the suggested document it answered from without reading — the
phase-119 cite discipline, LOCKED A5; the phase-118 "cite what you
used, by path" sentence is retired). This is NOT the reverted
phase-72 in-context reminder (that copy
taught the seed documents as already-read context; this one teaches
the summary-as-starting-point contract the A6 re-revision requires).
*folder_lines* (phase 119, D3, LOCKED A4): the suggested-folder
context lines (``app.rag.agent.suggested_folder_lines``) — the
direct children of each suggested document's parent folder, so the
model ``read``s the right file in round 1 instead of walking the
``ls`` drill-downs. When blocks are present AND the lines are
non-empty, the ``<documents>`` body is ``SUGGEST_INTRO``, then the
folder lines (each on its own line, immediately after the intro
line), a blank line, then the first ``<document>`` block. They are
PLAIN lines — no new markup/tag (the E2E mock keys off the
``<documents>`` marker and the LAST block's tail, so the lines must
never land after a summary). Empty *folder_lines* ⇒ the phase-118
build is byte-identical (pinned in
``tests/unit/test_prompts.py``).
Each ``<document>`` block carries the identity attributes
``source`` / ``path`` / ``title`` — and, since phase 106 (D5),
``date`` (the row's ``created_at`` UTC date part, ``YYYY-MM-DD``,
@@ -466,7 +494,14 @@ def build_high_prompt(
"(no documents matched — do not invent specifics)"
)
if blocks:
body = SUGGEST_INTRO + "\n\n" + body
if folder_lines:
# Phase 119 (D3, LOCKED A4): the folder lines ride immediately
# after the intro line, one per line, a blank line, then the
# first block — plain lines, no new markup (the <document>
# block markup AND body stay byte-identical around them).
body = SUGGEST_INTRO + "\n" + "\n".join(folder_lines) + "\n\n" + body
else:
body = SUGGEST_INTRO + "\n\n" + body
prompt = _base("HIGH")
for part in (build_kb_section(kb_overview or ""), build_steering_section(notes or [])):
if part:
+272 -89
View File
@@ -4,16 +4,30 @@
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 normalized name (title + path stem, alnum-only,
lowercased) contains a DIGIT-BEARING normalized question token of
length >= 4 — bare tokens (``1panel``) and the join of adjacent
question tokens starting with a letter ("Qwen 3.8" → ``qwen38``).
The digit requirement is the precision guard: plain prose words
("server", "arguments" — 4+ chars but no digit) never name-match,
while versioned product names (the incident's whole point) always
carry one. The name-hit documents LEAD the lexical list (ranked by
match count, then total matched length, then catalog order; capped
at :data:`NAME_HIT_LIMIT`),
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
@@ -23,7 +37,8 @@
``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) and ``cosine=0.0``; the RRF fusion is
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
@@ -35,6 +50,19 @@
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
@@ -96,6 +124,10 @@ 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``)."""
@@ -103,14 +135,23 @@ def _normalize_name(s: str) -> str:
def name_hit_tokens(question: str) -> list[str]:
"""The normalized name-match candidates of one question.
"""The name-match candidates of one question (class-agnostic,
LOCKED A2).
Only DIGIT-BEARING candidates count (the precision guard — a plain
prose word like "server" or "arguments" must never name-match a
document; a versioned product name always carries a digit):
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 (``1panel``,
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
@@ -121,18 +162,17 @@ def name_hit_tokens(question: str) -> list[str]:
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.
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 any(ch.isdigit() for ch in tok)
and tok not in seen
):
if len(tok) >= NAME_TOKEN_MIN_LEN and tok not in seen:
seen.add(tok)
out.append(tok)
@@ -187,6 +227,13 @@ class RetrievedChunk:
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
@@ -197,6 +244,7 @@ class RetrievedChunk:
cosine: float = 0.0
fts_hit: bool = False
is_summary: bool = False
name_hit: bool = False
def lexical_tsquery(question: str) -> str | None:
@@ -234,7 +282,9 @@ def fuse(
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).
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")
@@ -247,7 +297,9 @@ def fuse(
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)
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)
@@ -390,23 +442,76 @@ _NAME_HIT_SQL = text(
)
def _name_hit_chunks(db: Session, question: str) -> list[RetrievedChunk]:
"""The documents whose NAME matches the question (the 2026-09-05
incident, the versioned-name signal).
def _name_parts(path: str) -> tuple[set[str], set[str]]:
"""The normalized name components of a document path (LOCKED A2).
A document is a name hit when its normalized name —
:func:`_normalize_name` of its ``title`` followed by the normalized
path stem (``qwen3.8-27b-juggernaut-vulkan.container`` →
``qwen3827bjuggernautvulkan``) — contains at least one
:func:`name_hit_tokens` candidate (``qwen38``). Ranked by
(distinct matched tokens, total matched length, source, path) —
catalog order is the final deterministic tie-break — capped at
:data:`NAME_HIT_LIMIT`. Each hit becomes one lexical
:class:`RetrievedChunk` (its representative chunk, ``fts_hit=True``,
``cosine=0.0``). Two lightweight queries: one projection over
(id, source, path, title) in catalog order, one LATERAL chunk
fetch for the ≤ :data:`NAME_HIT_LIMIT` winners (no full-content
load; the content joins in via the row fetch below).
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:
@@ -416,22 +521,22 @@ def _name_hit_chunks(db: Session, question: str) -> list[RetrievedChunk]:
Document.source, Document.path
)
).all()
scored: list[tuple[int, int, uuid.UUID]] = []
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:
for doc_id, source, path, _title in rows:
by_id[doc_id] = (source, path)
name = _normalize_name(title) + _normalize_name(Path(path).stem)
matched = [t for t in tokens if t in name]
prefix, equal = _name_parts(path)
matched = sum(1 for t in tokens if _name_token_matches(t, prefix, equal))
if matched:
scored.append((len(matched), sum(len(t) for t in matched), doc_id))
scored.append((matched, doc_id))
if not scored:
return []
# Ranked by (distinct matched tokens, total matched length), the
# deterministic catalog tie-break (source, path) last.
scored.sort(
key=lambda s: (-s[0], -s[1], by_id[s[2]][0], by_id[s[2]][1])
)
ids = [s[2] for s in scored[:NAME_HIT_LIMIT]]
# 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.
@@ -460,6 +565,7 @@ def _name_hit_chunks(db: Session, question: str) -> list[RetrievedChunk]:
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
@@ -556,20 +662,92 @@ def retrieve(
return fused
def weak_hit_titles(chunks: Sequence[RetrievedChunk]) -> list[str]:
"""Distinct parent-document titles of *chunks*, best fused score first.
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.
"""
titles: list[str] = []
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)
titles.append(rc.document.title)
return titles
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(
@@ -685,54 +863,56 @@ def select_documents(
def select_suggested(
chunks: Sequence[RetrievedChunk],
n: int | None = None,
bonus: float | None = None,
) -> list[Document]:
"""Top-N distinct parent documents in fused rank order — the phase-118
"start here" suggestion tier (LOCKED A3), with NO cosine floor.
"""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``),
and 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
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
order: list[Document] = []
seen: set[uuid.UUID] = set()
for rc in sorted(chunks, key=lambda c: c.score, reverse=True):
if len(order) >= top_n:
break
doc = rc.document
if doc.id in seen:
continue
seen.add(doc.id)
order.append(doc)
return order
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 stable score-descending 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``), skipping every document whose id is in
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
@@ -741,18 +921,21 @@ def select_related(
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] = []
seen: set[uuid.UUID] = set()
for rc in sorted(chunks, key=lambda c: c.score, reverse=True):
for doc, _eff, _cos, _idx in _selection_order(chunks, bonus):
if len(out) >= cap:
break
doc = rc.document
if doc.id in seen or doc.id in excluded_ids:
if doc.id in excluded_ids:
continue
seen.add(doc.id)
out.append(doc)
return out