feat(retrieval): a record the operator names by number reaches the menu (#4796)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 54s
CI & Build / Python tests (push) Successful in 1m47s
CI & Build / Build & push image (push) Successful in 37s

#4796 "A record the operator names by number reaches auto-inject only if
its wording happens to match". "yes go ahead with 4448" says which record
is meant, but a number means nothing to an embedding, so the prompt menu
filled with records resembling the words around it.

- record_refs.named_record_ids reads the operator's raw prompt, never the
  reply-enriched query:
  - `#N`, unless the word before it marks another numbering (PR, CI, rule,
    milestone, system, log...);
  - a bare number of 3 or more digits that opens the message, follows a
    reference word or continues a list one started;
  - never a quantity ("300 seconds"), a date, version, path or fenced code.
- Each id is resolved through the ACL check; trashed or inaccessible ids
  are dropped.
- A "Named in your message" block leads the menu: the kind, the System,
  the name and the opening of the body, or the seen pointer if the record
  is already on the ledger. It takes no share of top_k, and the semantic
  lines leave those ids out.
- The block is booked under the new `named_ref` source, registered as an
  unbidden lookup that is allowed to be quiet. A named id that also ranked
  counts as suppressed in the auto_inject row, so #3668's identity holds.
- Named records now arrive even when the search finds nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 22:10:14 -04:00
co-authored by Claude Opus 5.5
parent 5c6d9a7b55
commit f95ffae972
4 changed files with 469 additions and 41 deletions
+143 -40
View File
@@ -24,6 +24,7 @@ from scribe.services import design_systems as design_systems_svc
from scribe.services import knowledge as knowledge_svc
from scribe.services import notes as notes_svc
from scribe.services import projects as projects_svc
from scribe.services import record_refs as record_refs_svc
from scribe.services import shape_ledger as shape_ledger_svc
from scribe.services import snippets as snippets_svc
from scribe.services import task_claims as task_claims_svc
@@ -123,6 +124,34 @@ def _menu_seen_line(note_id: int, kind: str, name: str) -> str:
"""A pointer to a record this session was already shown, not a copy of it."""
return f"> - #{note_id} [{kind} · seen] {name}"
# How much of a named record's body sits under its line. The opening, not an
# elided middle: nothing matched here, so there is no passage to keep, and what
# the reader needs is what the record IS — which is where a record says it.
_NAMED_OPENING_CHARS = 240
def _named_opening(body: str | None) -> str:
text = " ".join((body or "").split())
if len(text) <= _NAMED_OPENING_CHARS:
return text
return text[:_NAMED_OPENING_CHARS].rsplit(" ", 1)[0] + " …"
async def _named_records(user_id: int, ids: list[int]) -> list:
"""The records behind ids the operator named that this user may open.
An id that is trashed, someone else's private record or not a record at all
is dropped without a word: the parser cannot tell "4448" the task from
"4448" a quantity it misread, and the lookup is what settles it.
"""
found = []
for nid in ids:
got = await notes_svc.get_note_for_user(user_id, nid)
if got is not None and got[0].deleted_at is None:
found.append(got[0])
return found
# Max chars of a Process body to fold into the auto-surface description.
_PROC_PREVIEW_CHARS = 200
@@ -1071,6 +1100,13 @@ async def build_autoinject_hint(
q = (query or "").strip()
if not cfg["enabled"] or not q:
return empty
# RECORDS NAMED BY NUMBER (#4796), read from the operator's own words before
# the query is enriched: a number in the assistant's reply is not one the
# operator named. "go ahead with 4448" says which record is meant, and a
# number means nothing to an embedding, so these are looked up rather than
# left to a ranking that can only match the words around them.
named = await _named_records(user_id, record_refs_svc.named_record_ids(q))
named_ids = [int(n.id) for n in named]
# Everything below searches, logs and fills its slots on the ENRICHED
# query, so the telemetry row records what was actually asked (#4364).
q = _autoinject_query(q, context)
@@ -1122,7 +1158,13 @@ async def build_autoinject_hint(
# readable — `result_count == 0` with `suppressed_count > 0` is "everything
# that matched, this session has already seen", which is a different fact
# about the bar from "nothing cleared it" and used to be unreportable here.
fresh = [(s, n) for s, n in hits if int(n.id) not in already]
# A record the operator NAMED is counted the same way: it is shown, but by
# the lookup above and booked under `named_ref`, so this arm did not
# surface it and must not claim it.
fresh = [
(s, n) for s, n in hits
if int(n.id) not in already and int(n.id) not in named_ids
]
record_retrieval(
user_id=user_id, source="auto_inject", query=q,
threshold=cfg["threshold"], limit=cfg["top_k"],
@@ -1133,36 +1175,89 @@ async def build_autoinject_hint(
suppressed=len(hits) - len(fresh),
duration_ms=(time.perf_counter() - t0) * 1000.0,
)
if not hits:
if not hits and not named:
return empty
# Margin gate: keep only hits close to the strongest one. Computed over ALL
# hits, repeats included — the band measures distance from the top SCORE,
# and letting the ledger move that cutoff would make "you were shown this"
# change what counts as relevant, which is the axis independence the rule
# band keeps for the same reason.
top_score = hits[0][0]
kept = [(s, n) for s, n in hits if s >= top_score - _AUTOINJECT_BAND]
kept = await _reserve_slot_for_reuse(
user_id, q, kept, cfg, project_id=(project_id or None),
already=already,
)
# AFTER the reuse slot, because that one evicts the menu's weakest hit
# while this one extends: running them the other way round would let a
# reserved lesson be the line reuse throws off, and a slot that another
# slot can silently undo is not a guarantee.
kept, lesson_slot_id = await _reserve_slot_for_lesson(
user_id, q, kept, cfg, project_id=(project_id or None),
already=already,
)
kept: list = []
lesson_slot_id = None
if hits:
# Margin gate: keep only hits close to the strongest one. Computed over
# ALL hits, repeats included — the band measures distance from the top
# SCORE, and letting the ledger move that cutoff would make "you were
# shown this" change what counts as relevant, which is the axis
# independence the rule band keeps for the same reason.
top_score = hits[0][0]
kept = [(s, n) for s, n in hits if s >= top_score - _AUTOINJECT_BAND]
# The slots are told about the named records as if already shown, so a
# slot that picks one does not book a surfacing the lookup booked too.
shown = already | set(named_ids)
kept = await _reserve_slot_for_reuse(
user_id, q, kept, cfg, project_id=(project_id or None),
already=shown,
)
# AFTER the reuse slot, because that one evicts the menu's weakest hit
# while this one extends: running them the other way round would let a
# reserved lesson be the line reuse throws off, and a slot that another
# slot can silently undo is not a guarantee.
kept, lesson_slot_id = await _reserve_slot_for_lesson(
user_id, q, kept, cfg, project_id=(project_id or None),
already=shown,
)
# A named record is shown once, in its own block, never again as a match.
kept = [(s, n) for s, n in kept if int(n.id) not in named_ids]
# A collaborator's note can reach this menu via a shared project, and the
# operator never asked for it — so say whose it is. Unattributed, it reads as
# something they wrote and settled.
owners = await owner_names_for({
int(n.user_id) for _s, n in kept if n.user_id != user_id
int(n.user_id) for n in [*named, *(n for _s, n in kept)]
if n.user_id != user_id
})
# A superseded record is DEMOTED, not removed (#278) — so one can still reach
# this menu, and when it does the reader has to be told. An agent handed
# stale material with nothing marking it acts on it with full confidence,
# which is worse than never having surfaced it. One query for the whole menu.
stale = await superseded_ids([*named_ids, *(int(n.id) for _s, n in kept)])
systems = await system_names_for(
{i for i in named_ids if i not in already}
| {int(n.id) for _s, n in kept if int(n.id) not in already}
)
lines: list[str] = []
note_ids: list[int] = []
# FIRST, because the operator said which record they meant and everything
# after this block is a guess at what else might bear on it. The header
# says these were found by NUMBER: the parser reads "with 4448" as a
# reference, and the reader is the one placed to notice it was a quantity.
if named:
lines.append(
"> Named in your message — the records with those numbers, looked "
"up by id rather than matched. Open any in full with `get_note(id)`; "
"a line marked `seen` is already in your context:"
)
for note in named:
nid = int(note.id)
note_ids.append(nid)
kind = _record_kind(note)
name = _menu_name(note.title, note.note_type, note.data, note.body)
if nid in already:
line = _menu_seen_line(nid, kind, name)
if nid in stale:
line += " — SUPERSEDED"
lines.append(line)
continue
line = f"> - #{nid} [{_menu_label(kind, systems.get(nid))}] \"{name}\""
if nid in stale:
line += " — SUPERSEDED, a later record covers this; check that first"
if note.user_id != user_id:
who = owners.get(int(note.user_id)) or "another user"
line += f" — shared by {who}"
lines.append(line)
opening = _named_opening(note.body)
if opening:
lines.append(f"> ↳ {opening}")
# "records", not "notes" — the menu can hold snippets, processes and tasks
# too, and the kind marker on each line is only legible if the header doesn't
# already claim they're all one thing.
@@ -1170,13 +1265,14 @@ async def build_autoinject_hint(
# is shown again with a marker rather than withheld, so the header must stop
# promising the old contract. It now says what the marker means instead,
# once, rather than each repeated line having to explain itself.
lines = [
"> Possibly relevant from your Scribe records — open any in full with "
"`get_note(id)`, or `get_snippet` / `get_process` / `get_lesson` for "
"those kinds. Each line is a record's name, its kind and System, and "
"the passage that matched; a line marked `seen` is a pointer to one "
"already shown this session, so it is in your context:",
]
if kept:
lines.append(
"> Possibly relevant from your Scribe records — open any in full with "
"`get_note(id)`, or `get_snippet` / `get_process` / `get_lesson` for "
"those kinds. Each line is a record's name, its kind and System, and "
"the passage that matched; a line marked `seen` is a pointer to one "
"already shown this session, so it is in your context:"
)
# THE REGISTER, SAID ONCE AND ONLY WHEN IT APPLIES (milestone 385 step 5).
#
# The operator's requirement for this kind was "they don't always have to
@@ -1201,21 +1297,15 @@ async def build_autoinject_hint(
"what you are doing and use your judgement — a lesson is not a "
"rule and binds nothing."
)
# A superseded record is DEMOTED, not removed (#278) — so one can still reach
# this menu, and when it does the reader has to be told. An agent handed
# stale material with nothing marking it acts on it with full confidence,
# which is worse than never having surfaced it. One query for the whole menu.
stale = await superseded_ids([int(n.id) for _s, n in kept])
# From THIS arm's own search (`_rep_ai`), so a chunk is only ever paired
# with the query that actually matched it.
menu_chunks = _rep_ai.get("best_chunk") or {}
systems = await system_names_for({int(n.id) for _s, n in kept if int(n.id) not in already})
note_ids: list[int] = []
menu_ids: list[int] = []
for score, note in kept:
nid = int(note.id)
note_ids.append(nid)
menu_ids.append(nid)
kind = _record_kind(note)
# The NAME, not the title (#4364): a snippet's or lesson's title is its
# embedding shape, trigger and all, and ran past 1,500 characters here.
@@ -1270,18 +1360,31 @@ async def build_autoinject_hint(
record_surfaced(
user_id=user_id,
note_ids=[
i for i in note_ids
i for i in menu_ids
if i not in already and i != lesson_slot_id
],
source="auto_inject",
project_id=project_id,
)
# A lookup, not a ranking, so it writes no retrieval_logs row — there is no
# score or bar to tune — and its surfacings are its own source, which is
# what lets pull-through say whether a named record gets opened.
named_fresh = [i for i in named_ids if i not in already]
if named_fresh:
record_surfaced(
user_id=user_id, note_ids=named_fresh, source="named_ref",
project_id=project_id,
)
# The lessons this menu put in front of the reader, repeats included — for
# the soft-link recorder (#4637), which pairs them with the rule arm's
# lines in the same response. Relevance, not the session ledger, is what
# makes a co-arrival, so a `seen` lesson counts.
lesson_ids = [int(n.id) for _s, n in kept if _record_kind(n) == LESSON_NOTE_TYPE]
# makes a co-arrival, so a `seen` lesson counts — and so does one the
# operator named, which is relevance by their own say-so.
lesson_ids = [
int(n.id) for n in [*named, *(n for _s, n in kept)]
if _record_kind(n) == LESSON_NOTE_TYPE
]
return {
"context": "\n".join(lines), "note_ids": note_ids, "config": cfg,
"lesson_ids": lesson_ids,