finally getting accurate answers
This commit is contained in:
+114
-7
@@ -66,6 +66,16 @@ task 04):
|
||||
each line truncated to 200 chars. A grep is a **locator**, not a
|
||||
context-adder: it never appends to the answer context (only
|
||||
``read`` does — ``holder.read_docs`` is untouched by a grep).
|
||||
The 2026-09-05 incident teaching (the harness prior is that grep
|
||||
takes a REGEX; this grep is a fixed substring and the contract does
|
||||
not change): when a grep RAN but found nothing and the pattern is
|
||||
regex-shaped (:func:`looks_like_regex`) with a non-empty
|
||||
:func:`plain_form`, the no-match result is the TEACHING line —
|
||||
:data:`NO_MATCHES_REGEX` / :data:`NO_MATCHES_REGEX_SCOPED` — which
|
||||
states the plain-substring contract and hands over the plain-form
|
||||
retry hint; a grep that matched, or a no-match for a plain pattern
|
||||
(or one that reduces to nothing), is byte-identical to the ordinary
|
||||
line. Still a (counted) result, never a refusal.
|
||||
3. Rejected calls get a one-line refusal and count in nothing
|
||||
(``holder.tool_calls`` tracks executed calls only): unknown tool name
|
||||
→ ``"Unknown tool."``; a ``read`` without a usable ``path`` (missing,
|
||||
@@ -163,6 +173,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, cast
|
||||
@@ -268,12 +279,16 @@ AGENT_TOOLS: list[dict[str, Any]] = [
|
||||
"Search the indexed documents for an exact string "
|
||||
"(case-insensitive) and return up to 20 matching lines "
|
||||
"as `source/path:line: text` — a locator, not a "
|
||||
"context-adder: read the winner with `read`. For a "
|
||||
"normal search pass ONLY `pattern` — it searches every "
|
||||
"document and that is how you search the knowledge "
|
||||
"base; never pass a source name as `path` (a source "
|
||||
"name is not a document). Call one tool at a time — "
|
||||
"wait for this result before your next call."
|
||||
"context-adder: read the winner with `read`. The "
|
||||
"pattern is a plain substring, NEVER a regex — if a "
|
||||
"pattern with regex syntax (like '.*' or '\\.') comes "
|
||||
"back with no matches, retry with the plain text you "
|
||||
"expect to see. For a normal search pass ONLY `pattern` "
|
||||
"— it searches every document and that is how you "
|
||||
"search the knowledge base; never pass a source name "
|
||||
"as `path` (a source name is not a document). Call one "
|
||||
"tool at a time — wait for this result before your "
|
||||
"next call."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
@@ -282,7 +297,8 @@ AGENT_TOOLS: list[dict[str, Any]] = [
|
||||
"type": "string",
|
||||
"description": (
|
||||
"The exact text to search for (a plain "
|
||||
"substring, not a regex)"
|
||||
"substring, not a regex — no '.*', no "
|
||||
"'\\.', no character classes)"
|
||||
),
|
||||
},
|
||||
"path": {
|
||||
@@ -417,6 +433,79 @@ SEARCH_LINE_LIMIT = 200
|
||||
NO_MATCHES = "No matches for '{pattern}' in the knowledge base."
|
||||
NO_MATCHES_SCOPED = "No matches for '{pattern}' in {source}/{path}."
|
||||
|
||||
#: Teaching no-match lines (the 2026-09-05 incident — owner chat:
|
||||
#: "Qwen 3.8" question failed repeatedly). The harness-trained prior is
|
||||
#: that ``grep(pattern)`` takes a REGEX (pi.dev's grep, ripgrep, grep
|
||||
#: itself); this app's grep is a case-insensitive FIXED SUBSTRING
|
||||
#: (owner-locked A5 — the contract does not change). A regex-shaped
|
||||
#: pattern (``qwen.*3\.8``, ``qwen 3\.8``) can therefore never match, and
|
||||
#: the bare no-match line above made the turbo model trust the miss and
|
||||
#: end the turn with a wrong "I searched the entire knowledge base"
|
||||
#: refusal. These lines are the deterministic teaching: the same result
|
||||
#: (a no-match is still a *result* — counted, no context added) with the
|
||||
#: correct contract stated and a plain-text retry hint (the
|
||||
#: :func:`plain_form` of the pattern, when non-empty). Deterministic only
|
||||
#: (owner permission 2026-09-03: "deterministic guardrails only right
|
||||
#: now"): no model participates in detection or repair.
|
||||
NO_MATCHES_REGEX = (
|
||||
"No matches for '{pattern}'. grep matches a plain substring "
|
||||
"(case-insensitive), not a regex — '.*', '\\.' and the like are "
|
||||
"literal text here, so that pattern can never match. Retry with the "
|
||||
"plain text you expect to see (e.g. '{plain}')."
|
||||
)
|
||||
NO_MATCHES_REGEX_SCOPED = (
|
||||
"No matches for '{pattern}' in {source}/{path}. grep matches a plain "
|
||||
"substring (case-insensitive), not a regex — retry with the plain "
|
||||
"text you expect to see (e.g. '{plain}')."
|
||||
)
|
||||
|
||||
#: One of these metacharacters anywhere in the pattern marks it
|
||||
#: regex-shaped (the detection for the teaching no-match lines above).
|
||||
#: Plain substrings that happen to contain one ("C++", "a|b") are
|
||||
#: affected only on a NO-MATCH — a pattern that matched literally still
|
||||
#: gets its ordinary result, so the teaching can never suppress a real
|
||||
#: hit.
|
||||
_REGEX_META_RE = re.compile(r"[*+?(){}\[\]|\\^$]")
|
||||
|
||||
|
||||
# Backslash + a non-alphanumeric char is an escaped literal (``\\.`` →
|
||||
# ``.``); backslash + an alphanumeric is a class shorthand (``\\d``,
|
||||
# ``\\w``, ``\\s``) with no plain-text equivalent (→ dropped).
|
||||
_ESCAPE_RE = re.compile(r"\\(.)")
|
||||
|
||||
|
||||
def looks_like_regex(pattern: str) -> bool:
|
||||
"""True when *pattern* carries regex metacharacters (see
|
||||
:data:`_REGEX_META_RE`). Pure detection — the grep itself stays a
|
||||
fixed substring (owner-locked A5)."""
|
||||
return _REGEX_META_RE.search(pattern) is not None
|
||||
|
||||
|
||||
def plain_form(pattern: str) -> str:
|
||||
"""A deterministic plain-text retry hint for a regex-shaped pattern.
|
||||
|
||||
The hint is the pattern reduced to literal text, in this order:
|
||||
drop ``.*`` runs on the RAW pattern first (the wildcard — an escaped
|
||||
``\\.`` + ``\\*`` pair carries no raw ``.*`` run, so a literal dot
|
||||
survives), unescape (``\\.`` → ``.``; class shorthands like ``\\d``
|
||||
dropped), keep only the FIRST ``|`` alternative, drop character
|
||||
classes (``[0-9]``), drop quantifier runs (``*``, ``+``, ``?``,
|
||||
``{2,3}``), drop group parens and anchors (contents kept). Whitespace
|
||||
is preserved. ``qwen.*3\\.8`` → ``qwen3.8`` (the incident's exact
|
||||
recovery), ``llama\\.cpp`` → ``llama.cpp``, ``qwen[0-9]+`` →
|
||||
``qwen``. A pattern made of pure metacharacters reduces to ``""`` —
|
||||
callers then fall back to the ordinary no-match line (no hint).
|
||||
"""
|
||||
p = re.sub(r"\.\*", "", pattern) # .* wildcard runs (raw form)
|
||||
p = _ESCAPE_RE.sub(lambda m: m.group(1) if not m.group(1).isalnum() else "", p)
|
||||
p = p.split("|", 1)[0] # first alternative only — a hint, not an answer
|
||||
p = re.sub(r"\[[^\]]*\]", "", p) # character classes carry no literal text
|
||||
p = re.sub(r"\{[^{}]*\}", "", p) # {n} / {n,} / {n,m} quantifiers
|
||||
p = re.sub(r"[*+?]+", "", p) # stray * + ? quantifiers
|
||||
p = p.replace("(", "").replace(")", "")
|
||||
p = p.replace("^", "").replace("$", "")
|
||||
return p.strip()
|
||||
|
||||
|
||||
def list_catalog(db: Session) -> list[tuple[str, str, str]]:
|
||||
"""Every indexed document as ``(source, path, title)``.
|
||||
@@ -694,6 +783,24 @@ def _execute_tool(
|
||||
# Locked A5: a grep never adds context — read_docs untouched.
|
||||
if not matches:
|
||||
shown = pattern[:100] # keep a long pattern short in the line
|
||||
# The 2026-09-05 incident teaching: a regex-shaped pattern
|
||||
# (the harness prior) can NEVER match a fixed-substring
|
||||
# grep, so a no-match for one is not "the KB lacks this" —
|
||||
# it is "the pattern was in the wrong form". Teach the
|
||||
# contract and hand over the plain-form retry hint (when the
|
||||
# reduction is non-empty); a plain pattern (or a pattern
|
||||
# that reduces to nothing) keeps the ordinary line
|
||||
# byte-identical.
|
||||
plain = plain_form(pattern) if looks_like_regex(pattern) else ""
|
||||
if plain:
|
||||
if scoped_to is not None:
|
||||
return NO_MATCHES_REGEX_SCOPED.format(
|
||||
pattern=shown,
|
||||
source=scoped_to[0],
|
||||
path=scoped_to[1],
|
||||
plain=plain[:100],
|
||||
)
|
||||
return NO_MATCHES_REGEX.format(pattern=shown, plain=plain[:100])
|
||||
if scoped_to is not None:
|
||||
# The scoped no-match line is keyed on the resolved
|
||||
# source/path (== the argument, stripped).
|
||||
|
||||
+5
-2
@@ -149,8 +149,11 @@ TOOLS_SECTION: str = (
|
||||
"path (without the source name) will not resolve. `grep` locates an "
|
||||
"exact string (case-insensitive) in the indexed documents and "
|
||||
"returns up to 20 matching `source/path:line: text` lines — a "
|
||||
"locator, not a context-adder: read the winner with `read`; for a "
|
||||
"normal search pass only `pattern` — its optional `path` argument "
|
||||
"locator, not a context-adder: read the winner with `read`. A grep "
|
||||
"pattern is a plain substring, NEVER a regex — '.*' and '\\.' are "
|
||||
"literal text there; if such a pattern returns no matches, retry "
|
||||
"with the plain text you expect to see. For a normal search pass "
|
||||
"only `pattern` — its optional `path` argument "
|
||||
"limits the search to one document you already know, by the same "
|
||||
"combined `source/path` string; never a source name — a bare "
|
||||
"document path (without the source name) will not resolve there "
|
||||
|
||||
+230
-17
@@ -3,8 +3,27 @@
|
||||
* **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``. This is what finds
|
||||
name-your-tool questions ("gitlab") that vector similarity buries.
|
||||
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`),
|
||||
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 then answers
|
||||
instead of deflecting) and ``cosine=0.0``; 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; :meth:`select_documents` and :func:`weak_hit_titles`
|
||||
@@ -26,6 +45,7 @@ import re
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -38,8 +58,78 @@ from app.models import Chunk, Document
|
||||
#: path never truncates (A7 revised, owner permission 2026-08-24).
|
||||
TRUNCATION_MARKER = "[…truncated…]"
|
||||
|
||||
#: Alphanumeric tokens of a question (``to_tsquery`` input, OR-joined).
|
||||
_TOKEN_RE = re.compile(r"[a-z0-9]+")
|
||||
#: 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]+")
|
||||
|
||||
|
||||
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 normalized name-match candidates of one question.
|
||||
|
||||
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):
|
||||
|
||||
* the :func:`_normalize_name` form of every whitespace token, kept
|
||||
when at least :data:`NAME_TOKEN_MIN_LEN` chars (``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.
|
||||
"""
|
||||
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
|
||||
):
|
||||
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``).
|
||||
@@ -96,11 +186,14 @@ class RetrievedChunk:
|
||||
def lexical_tsquery(question: str) -> str | None:
|
||||
"""OR-joined token string for ``to_tsquery('english', …)``, or ``None``.
|
||||
|
||||
Tokens are lowercased ``[a-z0-9]+`` runs, 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.
|
||||
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] = []
|
||||
@@ -181,21 +274,141 @@ def _vector_candidates(
|
||||
]
|
||||
|
||||
|
||||
def _lexical_candidates(db: Session, question: str, limit: int) -> list[RetrievedChunk]:
|
||||
"""Top-*limit* chunks matching the question's OR-tsquery (A7).
|
||||
|
||||
Ordered by ``ts_rank`` (with ``d.path, c.position`` as the
|
||||
deterministic tie-break); an empty tsquery (stopword-only question)
|
||||
simply matches nothing.
|
||||
#: 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,
|
||||
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_hit_chunks(db: Session, question: str) -> list[RetrievedChunk]:
|
||||
"""The documents whose NAME matches the question (the 2026-09-05
|
||||
incident, the versioned-name signal).
|
||||
|
||||
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).
|
||||
"""
|
||||
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, 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)
|
||||
name = _normalize_name(title) + _normalize_name(Path(path).stem)
|
||||
matched = [t for t in tokens if t in name]
|
||||
if matched:
|
||||
scored.append((len(matched), sum(len(t) for t in 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]]
|
||||
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,
|
||||
)
|
||||
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, # the lexical signal — the A8 gate answers
|
||||
is_summary=bool(row.is_summary),
|
||||
)
|
||||
)
|
||||
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 []
|
||||
return out
|
||||
rows = db.execute(
|
||||
_LEXICAL_SQL, {"tsquery": tsquery, "limit": limit}
|
||||
).all()
|
||||
out: list[RetrievedChunk] = []
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user