feat(retrieval): every semantic search hands on the passage that matched
CI & Build / Python lint (push) Successful in 8s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Failing after 1m9s
CI & Build / Build & push image (push) Skipped

#4243 fixed one door. Scribe has three semantic searches over three chunk
tables, and all three collapsed chunk rows to the best one per record — each
of them KNEW which passage earned the hit, and each dropped it. Every surface
downstream then previewed the head of the document instead: a span the search
had already scored lower, with nothing saying so.

Mechanism, one place:
  - embeddings.record_best_chunk publishes {id: {index, text}} into `report`.
    Carried in `report`, NOT the return value: all three return
    list[tuple[float, Record]] and ~30 sites unpack that pair (lesson #4207).
  - semantic_search_rules and semantic_search_milestones now select
    chunk_index/chunk_text and publish the winner, as notes already did.
    semantic_search_milestones gains `report`, which it had no way to take.
  - services/text.matched_excerpt is the one choice of span, and
    excerpt_fields the one result block. Doors keep their own field names —
    the web renders `snippet`, MCP returns `excerpt` — because renaming a
    field a frontend reads is a different change from fixing what goes in it.

Surfaces:
  - knowledge.query_knowledge, whose own comment calls it "the human's MAIN
    search surface", was `(note.body or "")[:200]` on every row alike. Now the
    matched passage on a search, the opening on a browse, and `snippet_is`
    saying which. KnowledgeView renders that snippet, so this was live.
  - search(content_type='milestone') gains `matched` — the plan body stays
    out, but the passage that matched comes along, because recognising a plan
    means recognising the part you asked about and a description written at
    the start need not mention it.
  - The auto-inject menu and the write-path prior-art menu put the passage
    under their line. Both were title-only, which answers "does this apply?"
    for a lesson or snippet (the trigger is IN the title) and not at all for
    an issue or dev-log. No fallback to the body's opening: on a menu that is
    preamble dressed as a reason, and once indented it cannot be told apart.

Left alone deliberately: the rule arms. A rule hint already renders the rule's
TRIGGER, which is written to answer exactly "does this apply to me" and beats
a matched chunk at it; and that line's budget was measured at #3851. Adding a
passage there would duplicate the trigger and spend the budget twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-21 09:44:10 -04:00
co-authored by Claude Opus 5
parent 6abedb0168
commit 253fb974f3
8 changed files with 527 additions and 51 deletions
+34 -34
View File
@@ -11,7 +11,7 @@ import time
from scribe.mcp._context import current_user_id
from scribe.services.access import owner_names_for
from scribe.services.text import elide
from scribe.services.text import MATCHED_PASSAGE, excerpt_fields
from scribe.services.embeddings import (
DEFAULT_SIMILARITY_THRESHOLD, semantic_search_milestones, semantic_search_notes,
semantic_search_rules,
@@ -20,6 +20,14 @@ from scribe.services import rulebooks as rulebooks_svc
from scribe.services.retrieval_telemetry import record_retrieval, retrieval_summary
# A matched chunk is at most _CHUNK_CHAR_BUDGET (1400) characters, and it is
# the evidence the ranking was built on — so it is worth more room than the 240
# characters of document opening this used to send. Elision inside a chunk is
# far less lossy than a head cut of a whole record: the region is already the
# right one.
_EXCERPT_CHARS = 1000
async def _search_rules(uid: int, q: str, limit: int, project_id: int) -> dict:
"""Rules by meaning — a separate result shape because a rule IS different.
@@ -74,10 +82,19 @@ async def _search_milestones(uid: int, q: str, limit: int, project_id: int) -> d
Its own result shape, like rules: a milestone is a plan with progress, not
a note with a body. The plan itself is left out — get_milestone reads it —
because a search hit is for recognising a plan, and bodies run long.
because a search hit is for recognising a plan, and bodies run long. What
does come along is `matched`: the one passage of the body the query
actually hit, with `matched_is` saying whether it is that passage or
merely the body's opening. Recognising a plan means recognising the part
of it you were asking about, and a description written at the start need
not mention that part (#4243).
Not part of content_type="all", whose results are note-shaped.
"""
raw = await semantic_search_milestones(uid, q, project_id=project_id or None, limit=limit)
report: dict = {}
raw = await semantic_search_milestones(
uid, q, project_id=project_id or None, limit=limit, report=report,
)
chunks = report.get("best_chunk") or {}
progress: dict[int, dict] = {}
if raw:
from scribe.services import milestones as milestones_svc
@@ -91,6 +108,14 @@ async def _search_milestones(uid: int, q: str, limit: int, project_id: int) -> d
"id": m.id,
"title": m.title,
"description": m.description or "",
# The plan body stays out — get_milestone reads it — but the
# passage that MATCHED comes along, because a plan is
# recognised by the part of it the query was about and a
# description need not mention that part at all (#4243).
**excerpt_fields(
m.body or "", chunks.get(int(m.id)), _EXCERPT_CHARS,
key="matched",
),
"status": m.status,
"project_id": m.project_id,
"total": progress.get(m.id, {}).get("total", 0),
@@ -103,14 +128,6 @@ async def _search_milestones(uid: int, q: str, limit: int, project_id: int) -> d
}
# A matched chunk is at most _CHUNK_CHAR_BUDGET (1400) characters, and it is
# the evidence the ranking was built on — so it is worth more room than the 240
# characters of document opening this used to send. Elision inside a chunk is
# far less lossy than a head cut of a whole record: the region is already the
# right one.
_EXCERPT_CHARS = 1000
def result_excerpt(note, chunk: dict | None) -> dict:
"""The part of a record a caller judges "should I open this?" on.
@@ -125,30 +142,13 @@ def result_excerpt(note, chunk: dict | None) -> dict:
first, which the search had already judged less relevant, and the caller
would decide from that and never know (#4243).
So: show the matched passage when there is one, the body when it fits, and
in either case SAY which of the two this is. A caller that cannot tell an
excerpt from a whole record cannot tell whether looking deeper is worth it,
which is the only decision this field supports.
The choice of span lives in services/text.py, shared with the web's
knowledge search, so the two doors cannot drift on which text a reader is
shown or on whether they are told what it is.
"""
body = note.body or ""
matched = (chunk or {}).get("text") or ""
out: dict = {"body_length": len(body)}
if matched.strip():
text, cut = elide(matched.strip(), _EXCERPT_CHARS)
out["excerpt"] = text
out["excerpt_is"] = "matched_passage"
if (chunk or {}).get("index") is not None:
out["chunk_index"] = int(chunk["index"])
else:
# No stored chunk — an un-embedded record, or a caller that passed no
# report. Fall back to the opening, and name it as the opening rather
# than letting it pass for the relevant part.
text, cut = elide(body, _EXCERPT_CHARS)
out["excerpt"] = text
out["excerpt_is"] = "body_opening"
if cut or (out["excerpt_is"] == "matched_passage" and len(matched) < len(body)):
out["read_full"] = "get_note / get_task by id for the whole record."
out = excerpt_fields(note.body or "", chunk, _EXCERPT_CHARS)
if out.get("excerpt_is") == MATCHED_PASSAGE and (chunk or {}).get("index") is not None:
out["chunk_index"] = int(chunk["index"])
return out