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
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:
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user