refactor(retrieval): the notes arms run on the one pipeline - auto_inject, its reuse and lesson slots, the write path by meaning, and rule_via_lesson are specs (milestone 456 step 4, #4906)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m2s
CI & Build / Python tests (push) Successful in 1m52s
CI & Build / Build & push image (push) Successful in 35s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m2s
CI & Build / Python tests (push) Successful in 1m52s
CI & Build / Build & push image (push) Successful in 35s
retrieval_pipeline gains the notes half: NoteArm / NoteSlot / NoteMoment / NoteIO / NoteResult and run_note_arm, which writes once the stages both notes arms copied: search, withhold this response's own menu (#3739), fresh/repeat split, the call row before any return (#3497, #3752), the band, the reserved slots in their order (reuse evicts, lesson extends), and the surfacing rows. The note renderer (_record_kind, _menu_name, _menu_passage, the seen pointer, menu_entry) moves with it, and run_via_lesson_arm takes rule_via_lesson. Behaviour-preserving, with flags for today's differences: notes still log BEFORE the band and rules after it (step 7's question). One deliberate change: a notes arm now fails open like the rule arms, so a failing search costs its lines and no longer the whole hook response. The I/O is resolved from plugin_context at call time (_note_io), so the existing patches keep working. The review re-run reads the AUTO_INJECT spec instead of restating it, and its guard now compares the two live searches. The registry declares the pipeline's notes fan-out sites. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,6 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import textwrap
|
import textwrap
|
||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
from scribe.services import design_systems as design_systems_svc
|
from scribe.services import design_systems as design_systems_svc
|
||||||
@@ -35,7 +34,7 @@ from scribe.services.embeddings import (
|
|||||||
semantic_search_rules,
|
semantic_search_rules,
|
||||||
)
|
)
|
||||||
from scribe.services import lesson_rules as lesson_rules_svc
|
from scribe.services import lesson_rules as lesson_rules_svc
|
||||||
from scribe.services.lessons import LESSON_NOTE_TYPE, claim_line
|
from scribe.services.lessons import LESSON_NOTE_TYPE
|
||||||
from scribe.services.note_usage import record_surfaced
|
from scribe.services.note_usage import record_surfaced
|
||||||
from scribe.services.rule_usage import record_rule_surfaced
|
from scribe.services.rule_usage import record_rule_surfaced
|
||||||
from scribe.services.supersession import superseded_ids
|
from scribe.services.supersession import superseded_ids
|
||||||
@@ -48,6 +47,8 @@ from scribe.services.retrieval_surfaces import (
|
|||||||
from scribe.services import retrieval_pipeline as rp
|
from scribe.services import retrieval_pipeline as rp
|
||||||
from scribe.services.retrieval_pipeline import ( # noqa: F401 - re-exported
|
from scribe.services.retrieval_pipeline import ( # noqa: F401 - re-exported
|
||||||
_RULEHINT_BAND, _rule_band, _rule_hint_line, checkpoint_for, checkpoint_reason,
|
_RULEHINT_BAND, _rule_band, _rule_hint_line, checkpoint_for, checkpoint_reason,
|
||||||
|
VIA_LESSON_LIMIT, _menu_label, _menu_name, _menu_passage, _menu_seen_line,
|
||||||
|
_record_kind, menu_entry,
|
||||||
)
|
)
|
||||||
from scribe.services.retrieval_telemetry import record_retrieval
|
from scribe.services.retrieval_telemetry import record_retrieval
|
||||||
from scribe.services.settings import get_setting
|
from scribe.services.settings import get_setting
|
||||||
@@ -60,74 +61,9 @@ logger = logging.getLogger(__name__)
|
|||||||
# Defensive cap below Claude Code's 10k additionalContext limit.
|
# Defensive cap below Claude Code's 10k additionalContext limit.
|
||||||
_MAX_CHARS = 9000
|
_MAX_CHARS = 9000
|
||||||
|
|
||||||
# WHAT A MENU LINE CARRIES (#4364): the record's NAME, its kind and System,
|
# The notes renderer — a record's name, kind, System and matched passage —
|
||||||
# and the WHOLE passage that matched. Metadata plus the evidence, rather than a
|
# lives in `retrieval_pipeline` with the rest of the notes stages (milestone
|
||||||
# title asked to be both.
|
# 456 step 4) and is re-exported above.
|
||||||
#
|
|
||||||
# The name, not the title. A snippet's or lesson's title is `name — when it
|
|
||||||
# applies` by construction (`embeddings.trigger_title`), because that join is
|
|
||||||
# what makes it rank on its situation. That is an EMBEDDING shape, and rendered
|
|
||||||
# as a menu line it ran to 1,500+ characters — the trigger paragraph spent
|
|
||||||
# again on every line, and again on every repeat. The trigger still arrives
|
|
||||||
# when it is what matched: it is in the chunk, and `_menu_passage` hands it
|
|
||||||
# over when the title was the whole match.
|
|
||||||
#
|
|
||||||
# The whole passage, not 200 characters of it. The search already chose the
|
|
||||||
# chunk that matched; the old cut kept its head and tail, and the head is the
|
|
||||||
# title every chunk is prefixed with — so the reader got the title twice and
|
|
||||||
# lost the middle, which is where the match was (lesson #4248). A chunk is at
|
|
||||||
# most ~1.4 KB (`embeddings._CHUNK_CHAR_BUDGET`), and it is shown once: a
|
|
||||||
# repeat is a one-line pointer (`_menu_seen_line`), not a second copy.
|
|
||||||
|
|
||||||
|
|
||||||
def _menu_name(title: str | None, note_type: str | None, data=None, body: str | None = "") -> str:
|
|
||||||
"""The record's name — its title without the trigger composed into it."""
|
|
||||||
title = (title or "(untitled)").replace("\n", " ").strip()
|
|
||||||
data = data if isinstance(data, dict) else {}
|
|
||||||
if note_type == "snippet":
|
|
||||||
from scribe.services.embeddings import TRIGGER_SEP
|
|
||||||
return (data.get("name") or title.partition(TRIGGER_SEP)[0]).strip() or title
|
|
||||||
if note_type == LESSON_NOTE_TYPE:
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
from scribe.services.embeddings import untrigger_title
|
|
||||||
from scribe.services.lessons import lesson_trigger
|
|
||||||
trigger = lesson_trigger(SimpleNamespace(data=data, body=body or ""))
|
|
||||||
# One line even when the stored name is a story (#4797).
|
|
||||||
return claim_line(untrigger_title(title, trigger).strip() or title)
|
|
||||||
return title
|
|
||||||
|
|
||||||
|
|
||||||
def _menu_passage(title: str | None, chunk_text: str | None, name: str = "") -> str:
|
|
||||||
"""The matched chunk on one line, without the title it was embedded under.
|
|
||||||
|
|
||||||
Every chunk is `title\nsection` (`embeddings.embedding_text`), and `title`
|
|
||||||
here must be the EMBEDDED one (`embeddings.document_title`) — for a snippet
|
|
||||||
or lesson that is `name — trigger`, not the stored name — so the prefix is
|
|
||||||
stripped exactly. A chunk that WAS only the title — a short
|
|
||||||
record, or the head chunk of one — matched on the title, and for a
|
|
||||||
trigger-keyed kind the part of it the name line no longer shows is the
|
|
||||||
trigger: that is returned, because it is precisely what matched.
|
|
||||||
One line, so the menu's blockquote survives it.
|
|
||||||
"""
|
|
||||||
title = (title or "").strip()
|
|
||||||
text = (chunk_text or "").strip()
|
|
||||||
if title and text.startswith(title):
|
|
||||||
text = text[len(title):]
|
|
||||||
text = " ".join(text.split())
|
|
||||||
if not text and name and title.startswith(name) and title != name:
|
|
||||||
text = " ".join(title[len(name):].lstrip(" —-").split())
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def _menu_label(kind: str, systems: list[str] | None) -> str:
|
|
||||||
"""`issue (done) · Plugin & hooks` — the kind, then where it belongs."""
|
|
||||||
return " · ".join([kind, *systems]) if systems else kind
|
|
||||||
|
|
||||||
|
|
||||||
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
|
# How much of a named record's body sits under its line. The opening, not an
|
||||||
@@ -201,7 +137,7 @@ AUTOINJECT_DEFAULT_TOP_K = SURFACES["auto_inject"].budget_default
|
|||||||
# Any two Python-shaped payloads share keywords, indentation and structure, so
|
# Any two Python-shaped payloads share keywords, indentation and structure, so
|
||||||
# the floor for "some code" is ~0.55-0.63 — auto-inject's 0.55 lands INSIDE that
|
# the floor for "some code" is ~0.55-0.63 — auto-inject's 0.55 lands INSIDE that
|
||||||
# noise band, and 6 of 8 probe payloads produced a nudge (4 of them noise). The
|
# noise band, and 6 of 8 probe payloads produced a nudge (4 of them noise). The
|
||||||
# margin gate can't rescue it either: _AUTOINJECT_BAND is relative to the top
|
# margin gate can't rescue it either: the notes band (retrieval_pipeline._NOTE_BAND) is relative to the top
|
||||||
# hit, so with a single hit it never engages.
|
# hit, so with a single hit it never engages.
|
||||||
#
|
#
|
||||||
# 0.68 clears every measured false positive with margin and still sits 0.05
|
# 0.68 clears every measured false positive with margin and still sits 0.05
|
||||||
@@ -591,9 +527,6 @@ _CONCEPT_MAX_DECLS = 4
|
|||||||
# the code itself (e.g. all we found was `f()`), so we keep the raw payload.
|
# the code itself (e.g. all we found was `f()`), so we keep the raw payload.
|
||||||
_CONCEPT_MIN_CHARS = 16
|
_CONCEPT_MIN_CHARS = 16
|
||||||
|
|
||||||
# Margin gate: drop any hit more than this far below the top hit's score, so a
|
|
||||||
# single strong match doesn't drag in a wall of barely-passing neighbours.
|
|
||||||
_AUTOINJECT_BAND = 0.10
|
|
||||||
# Hard ceiling on top-k regardless of the user's setting — this is an
|
# Hard ceiling on top-k regardless of the user's setting — this is an
|
||||||
# awareness menu (titles only), never a content dump.
|
# awareness menu (titles only), never a content dump.
|
||||||
# The budget ceiling, now shared by every surface rather than owned by this
|
# The budget ceiling, now shared by every surface rather than owned by this
|
||||||
@@ -814,243 +747,6 @@ async def get_autoinject_config(user_id: int) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _record_kind(note) -> str:
|
|
||||||
"""The kind marker for an injected menu line — and, for a task, its status.
|
|
||||||
|
|
||||||
The menu is drawn from every record that carries an embedding, so a snippet,
|
|
||||||
a stored process, an issue and a stray dev-log all arrive looking identical.
|
|
||||||
Recorded prior art only stands out if the line says what it is — and the kind
|
|
||||||
is also what tells the reader which tool opens it.
|
|
||||||
|
|
||||||
Task-ness wins over `note_type` because it's the more useful distinction at a
|
|
||||||
glance: "there's an open issue about this" beats "there's a note about this".
|
|
||||||
|
|
||||||
A TASK ALSO CARRIES ITS STATUS, because for that kind alone the line is
|
|
||||||
read as a claim about live work. A finished step and an open one rendered
|
|
||||||
identically is not a cosmetic gap: a done step was cited as a milestone's
|
|
||||||
open one on the strength of a line exactly like this, which carries an id,
|
|
||||||
a kind and a title and said nothing about where the work stood (#4154).
|
|
||||||
Only for tasks — a note or a snippet has no status to be wrong about.
|
|
||||||
"""
|
|
||||||
if note.is_task:
|
|
||||||
kind = "issue" if note.task_kind == "issue" else "task"
|
|
||||||
# No fallback for a missing status: `is_task` IS `status is not None`
|
|
||||||
# (models/note.py), so a branch for a task without one could never be
|
|
||||||
# taken, and a dead branch is a claim about the data that isn't true.
|
|
||||||
#
|
|
||||||
# Parenthesised rather than dot-joined: the write-path prior-art line
|
|
||||||
# joins its own fields with " · ", so a dotted status would read as
|
|
||||||
# another flag beside `seen` instead of as part of the kind.
|
|
||||||
return f"{kind} ({note.status})"
|
|
||||||
return note.note_type or "note"
|
|
||||||
|
|
||||||
|
|
||||||
_REUSE_KINDS = ("snippet", "process")
|
|
||||||
|
|
||||||
|
|
||||||
async def _reserve_slot_for_reuse(
|
|
||||||
user_id: int,
|
|
||||||
query: str,
|
|
||||||
kept: list,
|
|
||||||
cfg: dict,
|
|
||||||
*,
|
|
||||||
project_id: int | None,
|
|
||||||
already: set[int],
|
|
||||||
) -> list:
|
|
||||||
"""Guarantee the reuse-shaped kinds one slot, if one clears threshold (#2246).
|
|
||||||
|
|
||||||
Ranking by raw cosine is blind to what KIND of record answers what kind of
|
|
||||||
ask, and the corpus makes that fatal rather than merely imperfect: Scribe's
|
|
||||||
project records are *about software work*, so a task titled "surface snippets
|
|
||||||
before the agent writes code" is a near-perfect lexical match for "write a
|
|
||||||
function…" while being useless as an answer to it. Measured live, a prompt
|
|
||||||
asking for a helper returned three records about BUILDING the retrieval
|
|
||||||
system and zero snippets.
|
|
||||||
|
|
||||||
The bias is structural and gets WORSE as the project record grows — which is
|
|
||||||
the direction Scribe is supposed to grow. Snippets are ~0.5% of the corpus
|
|
||||||
here; no threshold tuning fixes a 200:1 ratio.
|
|
||||||
|
|
||||||
So the reserved hit is deliberately NOT held to the margin band. The band
|
|
||||||
measures distance from the top overall score, and that top score is the very
|
|
||||||
thing snippets lose to. It still has to clear the configured threshold, so a
|
|
||||||
weak snippet cannot buy the slot — silence stays the default.
|
|
||||||
"""
|
|
||||||
if any(_record_kind(n) in _REUSE_KINDS for _s, n in kept):
|
|
||||||
return kept # reuse already represented; nothing to do
|
|
||||||
|
|
||||||
top_k = cfg["top_k"]
|
|
||||||
_t0 = time.perf_counter()
|
|
||||||
_rep: dict = {}
|
|
||||||
# THE LEDGER IS NOT AN EXCLUSION HERE EITHER (#4101) — but what is already
|
|
||||||
# in `kept` still is, and the two are different claims. A record sitting in
|
|
||||||
# this call's own menu must not be shown twice in it; a record shown in an
|
|
||||||
# EARLIER call is exactly what the slot should be allowed to spend itself
|
|
||||||
# on, because a snippet that was relevant then and is relevant now is the
|
|
||||||
# reuse case rather than a duplicate of it.
|
|
||||||
reuse = await semantic_search_notes(
|
|
||||||
user_id, query,
|
|
||||||
limit=1,
|
|
||||||
threshold=cfg["threshold"],
|
|
||||||
project_id=project_id,
|
|
||||||
exclude_ids={int(n.id) for _s, n in kept},
|
|
||||||
note_type=_REUSE_KINDS,
|
|
||||||
scope="browse",
|
|
||||||
report=_rep,
|
|
||||||
)
|
|
||||||
fresh_reuse = [(s, n) for s, n in reuse if int(n.id) not in already]
|
|
||||||
# A real semantic query competing for a menu slot — logged like the scored
|
|
||||||
# arm it displaces. Before this, the hit it PUSHED OUT was in
|
|
||||||
# retrieval_logs and the query that pushed it out was not, so the slot
|
|
||||||
# could never be evaluated against what it replaced (#2463; #1038 and
|
|
||||||
# #2085 are gated on this ledger being complete).
|
|
||||||
record_retrieval(
|
|
||||||
user_id=user_id, source="reuse_slot", query=query,
|
|
||||||
threshold=cfg["threshold"], limit=1, project_id=project_id,
|
|
||||||
is_task=None, results=fresh_reuse,
|
|
||||||
best_available=_rep.get("best_available_score"),
|
|
||||||
best_available_id=_rep.get("best_available_id"),
|
|
||||||
searched=bool(_rep.get("searched", True)),
|
|
||||||
suppressed=len(reuse) - len(fresh_reuse),
|
|
||||||
duration_ms=(time.perf_counter() - _t0) * 1000.0,
|
|
||||||
)
|
|
||||||
# Verify the kind rather than trusting the query that asked for it, and
|
|
||||||
# dedup against this call's own menu. This slot exists FOR reuse kinds — a
|
|
||||||
# slot silently spent on something else is worse than no slot, because the
|
|
||||||
# line is indistinguishable from one that earned its place on score.
|
|
||||||
kept_ids = {int(n.id) for _s, n in kept}
|
|
||||||
fresh = [
|
|
||||||
(s, n) for s, n in reuse
|
|
||||||
if _record_kind(n) in _REUSE_KINDS and int(n.id) not in kept_ids
|
|
||||||
][:1]
|
|
||||||
if not fresh:
|
|
||||||
return kept
|
|
||||||
|
|
||||||
# Take the LAST slot, never the first: the strongest overall hit is still the
|
|
||||||
# best answer to the prompt, and displacing it would trade one blindness for
|
|
||||||
# another.
|
|
||||||
if len(kept) >= top_k:
|
|
||||||
return kept[:top_k - 1] + fresh
|
|
||||||
return (kept + fresh)[:top_k]
|
|
||||||
|
|
||||||
|
|
||||||
async def _reserve_slot_for_lesson(
|
|
||||||
user_id: int,
|
|
||||||
query: str,
|
|
||||||
kept: list,
|
|
||||||
cfg: dict,
|
|
||||||
*,
|
|
||||||
project_id: int | None,
|
|
||||||
already: set[int],
|
|
||||||
) -> tuple[list, int | None]:
|
|
||||||
"""Guarantee a lesson one slot, if one clears the bar (milestone 385 step 5).
|
|
||||||
|
|
||||||
WHY A SLOT, AND WHAT IT ACTUALLY DISPLACES
|
|
||||||
|
|
||||||
The step's own framing was that "a slot spent on a lesson is a slot not
|
|
||||||
spent on a rule that binds". That is not what happens here, and the
|
|
||||||
correction matters for judging the cost: the notes menu and the rule hints
|
|
||||||
are separate functions with separate budgets, composed by the caller
|
|
||||||
(`build_prompt_rule_hint` says why). A line reserved in THIS menu displaces
|
|
||||||
a note, a snippet or an issue — never a rule.
|
|
||||||
|
|
||||||
THE ASYMMETRY, which is `preference_slot`'s argument on a different corpus:
|
|
||||||
|
|
||||||
- a NOTE crowded out of this menu is a lost convenience. It stays
|
|
||||||
searchable, and the operator can ask for it.
|
|
||||||
- a RULE crowded out still fires at an act arm. The prompt hit is a
|
|
||||||
preview of a second chance.
|
|
||||||
- a LESSON crowded out is the feature failing. A lesson exists only to be
|
|
||||||
met at the moment it applies — nobody browses lessons looking for one —
|
|
||||||
so the arm that surfaces it IS its delivery, and the loss is total and
|
|
||||||
silent. Silent delivery failure is the exact shape #3727 recorded: an
|
|
||||||
insight with no home arrived as a rule proposal instead.
|
|
||||||
|
|
||||||
And the ratio only moves one way. Lessons are by design rare and hard-won
|
|
||||||
while project records grow with the work, which is the 200:1 problem
|
|
||||||
`reuse_slot` was built for (#2246), before it has had a chance to be
|
|
||||||
measured here.
|
|
||||||
|
|
||||||
THE SLOT BUYS POSITION, NOT A LOWER BAR. It reserves at the menu's own
|
|
||||||
threshold, so a weak lesson cannot buy the line and silence stays the
|
|
||||||
default — the discipline both existing slots keep.
|
|
||||||
|
|
||||||
IT EXTENDS, IT NEVER DISPLACES, siding with `preference_slot` over
|
|
||||||
`reuse_slot`. Two reasons, and the second is the one that would be hard to
|
|
||||||
recover later: a displaced hit was returned by the general search and sits
|
|
||||||
in that call's `retrieval_logs` row, so evicting it makes the two tables
|
|
||||||
disagree about the same call for a reason nothing in the data explains
|
|
||||||
(#3668, and milestone #379 is what that costs). The first is voice — a
|
|
||||||
lesson does not bind, and a record that does not bind should not be able
|
|
||||||
to throw a better-scoring one off the menu.
|
|
||||||
|
|
||||||
Returns the possibly-extended list and the id the slot spent. The caller
|
|
||||||
needs that id to keep each source's surfaced set matching its own log row:
|
|
||||||
this slot records its own surfacing under its own name, so counting it
|
|
||||||
again under `auto_inject` would double it.
|
|
||||||
"""
|
|
||||||
if any(_record_kind(n) == LESSON_NOTE_TYPE for _s, n in kept):
|
|
||||||
return kept, None # a lesson already placed on score
|
|
||||||
|
|
||||||
_t0 = time.perf_counter()
|
|
||||||
_rep: dict = {}
|
|
||||||
# KIND-FILTERED, so the slot can only ever be spent on what it is for —
|
|
||||||
# `preference_slot`'s reasoning: verifying the kind after an open search
|
|
||||||
# would let a stray note buy the line, and that line would be
|
|
||||||
# indistinguishable from one that earned its place.
|
|
||||||
#
|
|
||||||
# `include_global_kinds` is the half that makes a lesson reachable at all
|
|
||||||
# from a project it was not written on, which is this kind's whole claim
|
|
||||||
# (#3730). Without it the slot would be a guarantee that silently only
|
|
||||||
# applies to lessons learned here.
|
|
||||||
found = await semantic_search_notes(
|
|
||||||
user_id, query,
|
|
||||||
limit=1,
|
|
||||||
threshold=cfg["threshold"],
|
|
||||||
project_id=project_id,
|
|
||||||
exclude_ids={int(n.id) for _s, n in kept},
|
|
||||||
note_type=(LESSON_NOTE_TYPE,),
|
|
||||||
include_global_kinds=True,
|
|
||||||
scope="browse",
|
|
||||||
report=_rep,
|
|
||||||
)
|
|
||||||
fresh = [(s, n) for s, n in found if int(n.id) not in already]
|
|
||||||
# ITS OWN SOURCE, from the first deploy. This slot is a claim that a kind
|
|
||||||
# deserves a guaranteed line, and a claim like that has to be falsifiable:
|
|
||||||
# `best_available_id` (#3807) names the lesson a bar refused, and the
|
|
||||||
# result count says how often the guarantee was actually spent. Without
|
|
||||||
# this row the question "does the lesson slot earn its line?" would have no
|
|
||||||
# data behind it in either direction — which is #2463's finding, recorded
|
|
||||||
# about the slot that shipped without one.
|
|
||||||
record_retrieval(
|
|
||||||
user_id=user_id, source="lesson_slot", query=query,
|
|
||||||
threshold=cfg["threshold"], limit=1, project_id=project_id,
|
|
||||||
is_task=None, results=fresh,
|
|
||||||
best_available=_rep.get("best_available_score"),
|
|
||||||
best_available_id=_rep.get("best_available_id"),
|
|
||||||
searched=bool(_rep.get("searched", True)),
|
|
||||||
suppressed=len(found) - len(fresh),
|
|
||||||
duration_ms=(time.perf_counter() - _t0) * 1000.0,
|
|
||||||
)
|
|
||||||
kept_ids = {int(n.id) for _s, n in kept}
|
|
||||||
slot = [
|
|
||||||
(s, n) for s, n in found
|
|
||||||
if _record_kind(n) == LESSON_NOTE_TYPE and int(n.id) not in kept_ids
|
|
||||||
][:1]
|
|
||||||
if not slot:
|
|
||||||
return kept, None
|
|
||||||
|
|
||||||
slot_id = int(slot[0][1].id)
|
|
||||||
# FRESH ONLY, matching the row above: a ledger repeat is rendered (#4101)
|
|
||||||
# but is not a new surfacing, so this source's two tables stay identical.
|
|
||||||
if slot_id not in already:
|
|
||||||
record_surfaced(
|
|
||||||
user_id=user_id, note_ids=[slot_id], source="lesson_slot",
|
|
||||||
project_id=project_id,
|
|
||||||
)
|
|
||||||
return kept + slot, slot_id
|
|
||||||
|
|
||||||
|
|
||||||
async def build_autoinject_hint(
|
async def build_autoinject_hint(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
query: str,
|
query: str,
|
||||||
@@ -1062,7 +758,7 @@ async def build_autoinject_hint(
|
|||||||
|
|
||||||
The four anti-bloat gates (see the module + milestone-93 design):
|
The four anti-bloat gates (see the module + milestone-93 design):
|
||||||
1. high-confidence threshold (stricter than pull) — set per-user;
|
1. high-confidence threshold (stricter than pull) — set per-user;
|
||||||
2. margin gate — keep only hits within _AUTOINJECT_BAND of the top score;
|
2. margin gate — keep only hits within retrieval_pipeline._NOTE_BAND of the top score;
|
||||||
3. session marking — caller passes already-injected ids as `exclude_ids`
|
3. session marking — caller passes already-injected ids as `exclude_ids`
|
||||||
and they are rendered again with `[seen]`, never withheld (#4101);
|
and they are rendered again with `[seen]`, never withheld (#4101);
|
||||||
4. title-first payload — id + kind + title + score only, never bodies.
|
4. title-first payload — id + kind + title + score only, never bodies.
|
||||||
@@ -1107,83 +803,23 @@ async def build_autoinject_hint(
|
|||||||
# before. A note line is a title and a score — the repeat costs about as
|
# before. A note line is a title and a score — the repeat costs about as
|
||||||
# much as the comma in this sentence — so there is nothing here to save.
|
# much as the comma in this sentence — so there is nothing here to save.
|
||||||
already = {int(i) for i in (exclude_ids or [])}
|
already = {int(i) for i in (exclude_ids or [])}
|
||||||
t0 = time.perf_counter()
|
# The ranked half is `retrieval_pipeline.run_note_arm` (milestone 456):
|
||||||
_rep_ai: dict = {}
|
# search, the fresh/repeat split, the call row, the band, the reuse and
|
||||||
hits = await semantic_search_notes(
|
# lesson slots in that order, and the surfacing rows are written there.
|
||||||
user_id, q,
|
# What is decided HERE is what makes this moment this moment — the query
|
||||||
limit=cfg["top_k"],
|
# the operator's words became, and the records they named by number.
|
||||||
threshold=cfg["threshold"],
|
result = await rp.run_note_arm(
|
||||||
project_id=(project_id or None),
|
rp.AUTO_INJECT,
|
||||||
# LESSONS ARE PROJECT-INDEPENDENT (#3730), so they join this menu's
|
rp.NoteMoment(
|
||||||
# candidate set from wherever they were learned. Widening it is what
|
user_id=user_id, query=q, project_id=project_id,
|
||||||
# makes the reserved slot below falsifiable rather than decorative: if
|
seen=frozenset(already), named=frozenset(named_ids),
|
||||||
# the slot were the only path a lesson had, the general contest would
|
),
|
||||||
# be permanently closed to the kind and "the slot earns its line" would
|
floor=cfg["threshold"], budget=cfg["top_k"], io=_note_io(),
|
||||||
# be true by construction. The switch adds nothing else — it ORs in
|
|
||||||
# `GLOBAL_NOTE_TYPES` and no other kind is in it.
|
|
||||||
include_global_kinds=True,
|
|
||||||
# Injection is the one retrieval nobody asked for, so it takes the BROWSE
|
|
||||||
# scope: never a record shared one-to-one with the operator. What can
|
|
||||||
# still appear is a collaborator's note inside a shared project — legible
|
|
||||||
# only because the line below names its owner.
|
|
||||||
scope="browse",
|
|
||||||
report=_rep_ai,
|
|
||||||
)
|
)
|
||||||
# `results=fresh` and `suppressed` together, on the rule arms' contract
|
kept = result.menu
|
||||||
# (#3752): a rendered repeat is not a new surfacing, so it stays out of the
|
if not kept and not named:
|
||||||
# row's result set and is COUNTED instead. That keeps this source's surfaced
|
|
||||||
# set identical to its own log row (#3668) while making a zero-result call
|
|
||||||
# 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.
|
|
||||||
# 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"],
|
|
||||||
project_id=(project_id or None), is_task=None, results=fresh,
|
|
||||||
best_available=_rep_ai.get("best_available_score"),
|
|
||||||
best_available_id=_rep_ai.get("best_available_id"),
|
|
||||||
searched=bool(_rep_ai.get("searched", True)),
|
|
||||||
suppressed=len(hits) - len(fresh),
|
|
||||||
duration_ms=(time.perf_counter() - t0) * 1000.0,
|
|
||||||
)
|
|
||||||
if not hits and not named:
|
|
||||||
return empty
|
return empty
|
||||||
|
|
||||||
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
|
# 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
|
# operator never asked for it — so say whose it is. Unattributed, it reads as
|
||||||
# something they wrote and settled.
|
# something they wrote and settled.
|
||||||
@@ -1192,16 +828,19 @@ async def build_autoinject_hint(
|
|||||||
if n.user_id != user_id
|
if n.user_id != user_id
|
||||||
})
|
})
|
||||||
|
|
||||||
# A superseded record is DEMOTED, not removed (#278) — so one can still reach
|
# A superseded record is DEMOTED, not removed (#278) — `menu_entry` says
|
||||||
# this menu, and when it does the reader has to be told. An agent handed
|
# so on the line. One query for the whole menu.
|
||||||
# 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)])
|
stale = await superseded_ids([*named_ids, *(int(n.id) for _s, n in kept)])
|
||||||
systems = await system_names_for(
|
systems = await system_names_for(
|
||||||
{i for i in named_ids if i not in already}
|
{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}
|
| {int(n.id) for _s, n in kept if int(n.id) not in already}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _shared_by(note) -> str:
|
||||||
|
if note.user_id == user_id:
|
||||||
|
return ""
|
||||||
|
return owners.get(int(note.user_id)) or "another user"
|
||||||
|
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
note_ids: list[int] = []
|
note_ids: list[int] = []
|
||||||
# FIRST, because the operator said which record they meant and everything
|
# FIRST, because the operator said which record they meant and everything
|
||||||
@@ -1217,24 +856,15 @@ async def build_autoinject_hint(
|
|||||||
for note in named:
|
for note in named:
|
||||||
nid = int(note.id)
|
nid = int(note.id)
|
||||||
note_ids.append(nid)
|
note_ids.append(nid)
|
||||||
kind = _record_kind(note)
|
# The OPENING under the line, not a passage: nothing matched here, so
|
||||||
name = _menu_name(note.title, note.note_type, note.data, note.body)
|
# there is no passage to keep, and what the reader needs is what the
|
||||||
if nid in already:
|
# record IS — which is where a record says it.
|
||||||
line = _menu_seen_line(nid, kind, name)
|
lines.extend(menu_entry(
|
||||||
if nid in stale:
|
nid, kind=_record_kind(note),
|
||||||
line += " — SUPERSEDED"
|
name=_menu_name(note.title, note.note_type, note.data, note.body),
|
||||||
lines.append(line)
|
systems=systems.get(nid), seen=nid in already, stale=nid in stale,
|
||||||
continue
|
shared_by=_shared_by(note), under=_named_opening(note.body),
|
||||||
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
|
# "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
|
# too, and the kind marker on each line is only legible if the header doesn't
|
||||||
@@ -1275,78 +905,35 @@ async def build_autoinject_hint(
|
|||||||
"what you are doing and use your judgement — a lesson is not a "
|
"what you are doing and use your judgement — a lesson is not a "
|
||||||
"rule and binds nothing."
|
"rule and binds nothing."
|
||||||
)
|
)
|
||||||
# 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 {}
|
|
||||||
|
|
||||||
menu_ids: list[int] = []
|
|
||||||
for score, note in kept:
|
for score, note in kept:
|
||||||
nid = int(note.id)
|
nid = int(note.id)
|
||||||
note_ids.append(nid)
|
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
|
# 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.
|
# embedding shape, trigger and all, and ran past 1,500 characters here.
|
||||||
name = _menu_name(note.title, note.note_type, note.data, note.body)
|
name = _menu_name(note.title, note.note_type, note.data, note.body)
|
||||||
if nid in already:
|
|
||||||
# A POINTER, not a copy (#4364). The record is in this session's
|
|
||||||
# context already — the ledger is cleared at compaction, so "seen"
|
|
||||||
# stays true — and re-rendering it spent its whole line again for
|
|
||||||
# nothing. What the reader needs is the reminder that it matched
|
|
||||||
# again, and the id to open it if it has scrolled out of mind.
|
|
||||||
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}\" ({score:.2f})"
|
|
||||||
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}, treat as a suggestion"
|
|
||||||
lines.append(line)
|
|
||||||
# The passage that earned the line, WHOLE, indented under it (#4364).
|
# The passage that earned the line, WHOLE, indented under it (#4364).
|
||||||
# Absent when the record has no stored chunk — an un-embedded row, or
|
# Absent when the record has no stored chunk — an un-embedded row, or
|
||||||
# the reserved lesson and reuse slots, which are fetched by their own
|
# the reserved lesson and reuse slots, which are fetched by their own
|
||||||
# queries and so are not in this search's report. No fallback to the
|
# queries and so are not in this search's report. No fallback to the
|
||||||
# body's opening: on a menu that would be a line of preamble dressed as
|
# body's opening: on a menu that would be a line of preamble dressed as
|
||||||
# a reason, and a reader cannot tell the two apart once indented alike.
|
# a reason, and a reader cannot tell the two apart once indented alike.
|
||||||
passage = _menu_passage(
|
passage = "" if nid in already else _menu_passage(
|
||||||
document_title(note.title, note.note_type, note.data, note.body),
|
document_title(note.title, note.note_type, note.data, note.body),
|
||||||
(menu_chunks.get(nid) or {}).get("text"), name,
|
(result.chunks.get(nid) or {}).get("text"), name,
|
||||||
)
|
)
|
||||||
if passage:
|
who = _shared_by(note)
|
||||||
lines.append(f"> ↳ {passage}")
|
lines.extend(menu_entry(
|
||||||
|
nid, kind=_record_kind(note), name=name, systems=systems.get(nid),
|
||||||
|
seen=nid in already, stale=nid in stale, score=score,
|
||||||
|
shared_by=f"{who}, treat as a suggestion" if who else "",
|
||||||
|
under=passage,
|
||||||
|
))
|
||||||
|
|
||||||
# Records what SURVIVED the margin gate, not what the ranker returned — the
|
|
||||||
# menu the agent actually saw. retrieval_logs already holds the full
|
|
||||||
# candidate set for threshold tuning; conflating the two would make
|
|
||||||
# "surfaced" mean two different things depending on the surface (#2085).
|
|
||||||
#
|
|
||||||
# FRESH ONLY, which is the same cut the log row above takes (#4101). A
|
|
||||||
# repeat is rendered but is not a new surfacing, and counting it again would
|
|
||||||
# make this table disagree with `retrieval_logs` about the same call —
|
|
||||||
# #3668's identity, which is the cheapest true statement available about
|
|
||||||
# this pair of tables and is not worth a marker's convenience.
|
|
||||||
# THE RESERVED LESSON IS NOT THIS ARM'S SURFACING. It was fetched by its own
|
|
||||||
# query and already recorded under `lesson_slot`, so counting it here would
|
|
||||||
# book one delivery twice and leave `lesson_slot`'s two tables describing
|
|
||||||
# different numbers of the same event — #3668's identity, which is the
|
|
||||||
# cheapest true statement available about this pair of tables. It stays in
|
|
||||||
# `note_ids`, which is the session LEDGER and must list every line rendered.
|
|
||||||
record_surfaced(
|
|
||||||
user_id=user_id,
|
|
||||||
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
|
# 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
|
# 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.
|
# what lets pull-through say whether a named record gets opened. The
|
||||||
|
# ranked menu's own rows were written by the pipeline.
|
||||||
named_fresh = [i for i in named_ids if i not in already]
|
named_fresh = [i for i in named_ids if i not in already]
|
||||||
if named_fresh:
|
if named_fresh:
|
||||||
record_surfaced(
|
record_surfaced(
|
||||||
@@ -1369,97 +956,46 @@ async def build_autoinject_hint(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── A rule reached through its lessons (milestone 440, #4633) ──────────────
|
def _note_io() -> rp.NoteIO:
|
||||||
#
|
"""The ranker and recorders the notes arms report to, read at call time —
|
||||||
# A rule's own document is written in the rule's words, which are general by
|
`_rule_io`'s reason: whatever this module's names are when the arm runs."""
|
||||||
# design; the situations that keep proving it are often closer to what a
|
return rp.NoteIO(
|
||||||
# session is actually doing. A lesson JUDGED to be an instance of a rule (a
|
search=semantic_search_notes,
|
||||||
# CONFIRMED link) carries that situation, so a lesson matching the moment
|
record_retrieval=record_retrieval,
|
||||||
# brings its rule along — in rule voice, naming the lesson that reached it.
|
record_surfaced=record_surfaced,
|
||||||
#
|
)
|
||||||
# ITS OWN SLOT, NOT A RULE SLOT, as a stated default. The plan asked for this
|
|
||||||
# to be settled by measurement, and there is nothing to measure until links
|
|
||||||
# are confirmed (#4632). Until then the asymmetry decides it: a via-lesson
|
|
||||||
# line that took a rule slot could push out a rule the ranker matched
|
|
||||||
# DIRECTLY, a stronger claim displaced by a weaker one, while an extra line
|
|
||||||
# costs one line. `rule_via_lesson` is logged as its own source so the
|
|
||||||
# question can be answered from data later (#4636).
|
|
||||||
VIA_LESSON_LIMIT = 1
|
|
||||||
# Lessons fetched before keeping only the linked ones. The search cannot be
|
|
||||||
# told "linked lessons only", so it overfetches and filters; on a corpus where
|
|
||||||
# most lessons are unlinked, a fetch of one would almost always be spent on a
|
|
||||||
# lesson that carries nothing.
|
|
||||||
_VIA_LESSON_OVERFETCH = 10
|
|
||||||
|
|
||||||
|
|
||||||
async def _rules_via_lessons(
|
async def _rules_via_lessons(
|
||||||
user_id: int, query: str, *, project_id: int | None, skip: set[int],
|
user_id: int, query: str, *, project_id: int | None, skip: set[int],
|
||||||
held: set[int], where: str,
|
held: set[int], where: str,
|
||||||
) -> tuple[list[str], list[int]]:
|
) -> tuple[list[str], list[int]]:
|
||||||
"""Rule lines reached through a matching lesson's CONFIRMED links.
|
"""Rule lines reached through a matching lesson's CONFIRMED links — the
|
||||||
|
pipeline's via-lesson arm (`retrieval_pipeline.run_via_lesson_arm`, where
|
||||||
|
the design is written), with its I/O read from this module at call time.
|
||||||
|
|
||||||
The lesson bar is the notes menu's own threshold — the bar a lesson has to
|
The lesson bar is the notes menu's own threshold. Returns (lines, rule ids
|
||||||
clear to be shown at all — so a lesson too weak to surface cannot carry a
|
shown).
|
||||||
rule in. `skip` is every rule this response already names plus the
|
|
||||||
session ledger: suppression applies to the RULE, whichever lesson reached
|
|
||||||
it. Returns (lines, rule ids shown). Fails open, like every arm.
|
|
||||||
"""
|
"""
|
||||||
try:
|
async def bar() -> float:
|
||||||
confirmed = await lesson_rules_svc.confirmed_lessons(user_id)
|
return (await get_autoinject_config(user_id))["threshold"]
|
||||||
if not confirmed:
|
|
||||||
return [], []
|
result = await rp.run_via_lesson_arm(
|
||||||
bar = (await get_autoinject_config(user_id))["threshold"]
|
rp.RuleMoment(
|
||||||
t0 = time.perf_counter()
|
user_id=user_id, query=query, project_id=project_id, where=where,
|
||||||
_rep: dict = {}
|
held=frozenset(held),
|
||||||
found = await semantic_search_notes(
|
),
|
||||||
user_id, query, limit=_VIA_LESSON_OVERFETCH, threshold=bar,
|
skip=frozenset(skip),
|
||||||
project_id=project_id, note_type=(LESSON_NOTE_TYPE,),
|
io=rp.ViaLessonIO(
|
||||||
include_global_kinds=True, scope="browse", report=_rep,
|
search=semantic_search_notes,
|
||||||
|
linked=lesson_rules_svc.confirmed_lessons,
|
||||||
|
rules_for=lesson_rules_svc.confirmed_rules_in_scope,
|
||||||
|
floor=bar,
|
||||||
|
record_retrieval=record_retrieval,
|
||||||
|
record_rule_surfaced=record_rule_surfaced,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
matched = [(s, n) for s, n in found if int(n.id) in confirmed]
|
return result.lines, result.rule_ids
|
||||||
by_lesson = await lesson_rules_svc.confirmed_rules_in_scope(
|
|
||||||
user_id, [int(n.id) for _s, n in matched], project_id,
|
|
||||||
)
|
|
||||||
candidates = [
|
|
||||||
(s, rule, n) for s, n in matched for rule in by_lesson.get(int(n.id), [])
|
|
||||||
]
|
|
||||||
chosen: list = []
|
|
||||||
taken: set[int] = set(skip)
|
|
||||||
for s, rule, n in candidates:
|
|
||||||
if rule.id in taken:
|
|
||||||
continue
|
|
||||||
taken.add(rule.id)
|
|
||||||
chosen.append((s, rule, n))
|
|
||||||
if len(chosen) >= VIA_LESSON_LIMIT:
|
|
||||||
break
|
|
||||||
# Logged whenever a search ran, results or not — the #3497 guard. The
|
|
||||||
# score is the LESSON's, because the lesson is what was matched; the
|
|
||||||
# best-available id is left off for the same reason, since the row's
|
|
||||||
# results are rules and an id beside them would read as a rule id.
|
|
||||||
record_retrieval(
|
|
||||||
user_id=user_id, source="rule_via_lesson", query=query,
|
|
||||||
threshold=bar, limit=VIA_LESSON_LIMIT, project_id=project_id,
|
|
||||||
is_task=None, results=[(s, rule) for s, rule, _n in chosen],
|
|
||||||
duration_ms=(time.perf_counter() - t0) * 1000.0,
|
|
||||||
best_available=_rep.get("best_available_score"),
|
|
||||||
searched=bool(_rep.get("searched", True)),
|
|
||||||
suppressed=len({r.id for _s, r, _n in candidates}) - len(chosen),
|
|
||||||
)
|
|
||||||
if not chosen:
|
|
||||||
return [], []
|
|
||||||
rule_ids = [rule.id for _s, rule, _n in chosen]
|
|
||||||
record_rule_surfaced(user_id=user_id, rule_ids=rule_ids, source="rule_via_lesson")
|
|
||||||
lines = [
|
|
||||||
_rule_hint_line(rule, where=where, seen=False, held=rule.id in held)
|
|
||||||
+ f" Reached through lesson #{n.id} "
|
|
||||||
+ f"\u201c{_menu_name(n.title, n.note_type, n.data, n.body)}\u201d, "
|
|
||||||
+ "a recorded instance of it."
|
|
||||||
for _s, rule, n in chosen
|
|
||||||
]
|
|
||||||
return lines, rule_ids
|
|
||||||
except Exception:
|
|
||||||
logger.debug("rule-via-lesson arm failed", exc_info=True)
|
|
||||||
return [], []
|
|
||||||
|
|
||||||
|
|
||||||
async def _add_rules_via_lessons(
|
async def _add_rules_via_lessons(
|
||||||
@@ -2026,126 +1562,28 @@ async def build_write_path_hint(
|
|||||||
# empty mapping means every line falls back to its title alone.
|
# empty mapping means every line falls back to its title alone.
|
||||||
wp_chunks: dict[int, dict] = {}
|
wp_chunks: dict[int, dict] = {}
|
||||||
if remaining > 0 and query:
|
if remaining > 0 and query:
|
||||||
t0 = time.perf_counter()
|
# The ranked half is `retrieval_pipeline.run_note_arm` with the
|
||||||
# Pulled-and-already-listed ids stay in the query (as evidence for
|
# WRITE_PATH spec (milestone 456): which kinds it asks for and why,
|
||||||
# `resembles`) but never in the menu, so the limit has to cover them.
|
# the withheld-menu accounting (#3739), the fresh/repeat split, the
|
||||||
# `seen` is this call's own menu now, which is the only thing left that
|
# call row and the surfacing rows are written there. What is decided
|
||||||
# is a reason to withhold (#4101).
|
# HERE is the menu above it — what place and sync already listed is
|
||||||
in_menu = seen
|
# withheld, and a PULLED snippet among those is still scored, because
|
||||||
pulled_in_menu = in_menu & set(pulled)
|
# its resemblance to this payload is the stamping feed's evidence.
|
||||||
_rep_wp: dict = {}
|
result = await rp.run_note_arm(
|
||||||
hits = await semantic_search_notes(
|
rp.WRITE_PATH,
|
||||||
user_id, query,
|
rp.NoteMoment(
|
||||||
limit=remaining + len(pulled_in_menu),
|
user_id=user_id, query=query, project_id=project_id,
|
||||||
threshold=cfg["threshold"],
|
seen=frozenset(excluded), in_menu=frozenset(seen),
|
||||||
project_id=scope_project,
|
still_scored=frozenset(pulled),
|
||||||
exclude_ids=in_menu - set(pulled),
|
),
|
||||||
# Snippets AND recorded experience (#2246). This arm was
|
floor=cfg["threshold"], budget=remaining, io=_note_io(),
|
||||||
# snippets-only, which is auto-inject's mistake inverted: an issue
|
|
||||||
# saying "we tried this and it deadlocked", or a dev-log recording
|
|
||||||
# how a problem was solved, is prior art for the code about to be
|
|
||||||
# written — arguably better prior art than a resembling helper,
|
|
||||||
# because it says what NOT to do.
|
|
||||||
#
|
|
||||||
# `task_kind="issue"` keeps the open to-do list out. A task titled
|
|
||||||
# "add debouncing to the search box" resembles the code being
|
|
||||||
# written and answers nothing; an ISSUE is corrective work with a
|
|
||||||
# root cause in it, and a non-task note is durable knowledge. Both
|
|
||||||
# earned their place; a todo did not.
|
|
||||||
#
|
|
||||||
# AND LESSONS (milestone 385 step 5). This arm is kind-FILTERED, so
|
|
||||||
# a kind absent from this tuple is not merely outranked here — it
|
|
||||||
# is unreachable, and nothing reports an arm that never had the
|
|
||||||
# candidate (#3702). The founding example of the kind is a lesson
|
|
||||||
# about a code shape ("two absolutely-positioned siblings"), which
|
|
||||||
# is the moment this arm fires and no other.
|
|
||||||
#
|
|
||||||
# NO RESERVED SLOT HERE, unlike the prompt menu. This arm fires
|
|
||||||
# before EVERY Write and Edit, where a guaranteed extra line is a
|
|
||||||
# guaranteed extra interruption per keystroke-batch — the same
|
|
||||||
# argument that keeps the act arms' budgets tight. And the contest
|
|
||||||
# is already fair: the field is snippets, issues and lessons rather
|
|
||||||
# than the whole corpus, so the 200:1 dilution a slot answers is
|
|
||||||
# not what happens here. Lessons surfaced by this arm are
|
|
||||||
# identifiable in the telemetry by their kind.
|
|
||||||
note_type=("snippet", "note", LESSON_NOTE_TYPE),
|
|
||||||
task_kind="issue",
|
|
||||||
# Project-independent, for the reason the prompt menu passes it:
|
|
||||||
# a lesson's claim is that it transfers, and an arm scoped to the
|
|
||||||
# project it was written on cannot test that claim (#3730).
|
|
||||||
include_global_kinds=True,
|
|
||||||
# Same reasoning as auto-inject: nobody asked for this, so it takes
|
|
||||||
# the browse scope and never surfaces a one-to-one direct share.
|
|
||||||
scope="browse",
|
|
||||||
report=_rep_wp,
|
|
||||||
)
|
)
|
||||||
wp_chunks = _rep_wp.get("best_chunk") or {}
|
wp_chunks = result.chunks
|
||||||
resembles = {
|
resembles = {
|
||||||
int(note.id): float(score) for score, note in hits
|
int(note.id): float(score) for score, note in result.answered
|
||||||
if int(note.id) in pulled
|
if int(note.id) in pulled
|
||||||
}
|
}
|
||||||
shown = [(s, n) for s, n in hits if int(n.id) not in in_menu]
|
for score, note in result.menu:
|
||||||
# WHAT THIS ARM WITHHELD AFTER THE SEARCH ANSWERED, and the reason
|
|
||||||
# `best_available_score` cannot always be reported here (#3739 again,
|
|
||||||
# from the side its fix did not reach).
|
|
||||||
#
|
|
||||||
# This arm is the one note arm that filters TWICE. `exclude_ids` takes
|
|
||||||
# the already-listed ids into the search, but the PULLED ones among them
|
|
||||||
# stay in the query deliberately — `resembles` above needs them — and
|
|
||||||
# are dropped in the line above instead. So the score the search
|
|
||||||
# reported is PRE that drop while the row's `result_count` is POST it,
|
|
||||||
# and a record already listed in this same menu could be logged as
|
|
||||||
# something the BAR turned away. Live proof on the first read after
|
|
||||||
# #3739 shipped: write_path's near-miss max was 0.822 while the lowest
|
|
||||||
# score it ever RETURNED was 0.6857 — a "rejection" that beat every
|
|
||||||
# acceptance.
|
|
||||||
#
|
|
||||||
# So the honest answer is null — "not measured on this call" — whenever
|
|
||||||
# this filter removed anything, because then the bar is not the only
|
|
||||||
# thing that turned something away and the reported score may belong to
|
|
||||||
# a record we withheld ourselves. Calls where nothing was dropped keep
|
|
||||||
# reporting it, which is most of them.
|
|
||||||
#
|
|
||||||
# THE LEDGER IS NO LONGER PART OF THIS (#4101), and that is why the
|
|
||||||
# suppression column can now be filled in where it could not before.
|
|
||||||
# The old objection was that a count here would be PARTIAL — covering
|
|
||||||
# the drops made in this function but not the ones `exclude_ids` made
|
|
||||||
# inside the search — and a partial number under a name that reads as
|
|
||||||
# complete is the substitution this milestone exists to stop. That was
|
|
||||||
# right while the ledger was one of the things `exclude_ids` carried.
|
|
||||||
# It no longer is: every ledger repeat comes back from the search and is
|
|
||||||
# rendered, so `suppressed` counts all of them and none are hidden
|
|
||||||
# inside the query. What `exclude_ids` still removes is this call's own
|
|
||||||
# menu, which is not suppression at all — those records ARE being shown,
|
|
||||||
# one block further up.
|
|
||||||
withheld_here = len(hits) - len(shown)
|
|
||||||
hits = shown[:remaining]
|
|
||||||
fresh = [(s, n) for s, n in hits if int(n.id) not in excluded]
|
|
||||||
record_retrieval(
|
|
||||||
user_id=user_id, source="write_path", query=query,
|
|
||||||
threshold=cfg["threshold"], limit=remaining,
|
|
||||||
# is_task is None, not False: this arm now returns issues too, and
|
|
||||||
# recording it as a notes-only retrieval would misdescribe the
|
|
||||||
# candidate set the threshold is being tuned against.
|
|
||||||
project_id=scope_project, is_task=None, results=fresh,
|
|
||||||
best_available=(
|
|
||||||
None if withheld_here else _rep_wp.get("best_available_score")
|
|
||||||
),
|
|
||||||
# Withheld on the SAME condition as the score. A surviving id
|
|
||||||
# beside a null score would name a record without saying what it
|
|
||||||
# scored, which is the pair disagreeing in the other direction.
|
|
||||||
best_available_id=(
|
|
||||||
None if withheld_here else _rep_wp.get("best_available_id")
|
|
||||||
),
|
|
||||||
searched=bool(_rep_wp.get("searched", True)),
|
|
||||||
suppressed=len(hits) - len(fresh),
|
|
||||||
duration_ms=(time.perf_counter() - t0) * 1000.0,
|
|
||||||
)
|
|
||||||
if hits:
|
|
||||||
top_score = hits[0][0]
|
|
||||||
for score, note in hits:
|
|
||||||
if score < top_score - _AUTOINJECT_BAND:
|
|
||||||
continue
|
|
||||||
# Name the kind unless it's a snippet — the menu's default and
|
# Name the kind unless it's a snippet — the menu's default and
|
||||||
# the header's default reading. An issue or a dev-log offered
|
# the header's default reading. An issue or a dev-log offered
|
||||||
# here is a different KIND of claim ("this was already tried")
|
# here is a different KIND of claim ("this was already tried")
|
||||||
@@ -2403,12 +1841,12 @@ async def build_write_path_hint(
|
|||||||
for marker, item in menu:
|
for marker, item in menu:
|
||||||
# A rendered repeat is not a new surfacing (#4101) — same cut the log
|
# A rendered repeat is not a new surfacing (#4101) — same cut the log
|
||||||
# row takes, so this table and `retrieval_logs` keep agreeing about the
|
# row takes, so this table and `retrieval_logs` keep agreeing about the
|
||||||
# same call (#3668).
|
# same call (#3668). The semantic arm's rows (`write_path_semantic`)
|
||||||
if int(item["id"]) in excluded:
|
# were written by the pipeline beside its call row, so only the
|
||||||
|
# lookups are booked here.
|
||||||
|
if int(item["id"]) in excluded or not marker.startswith("nearby"):
|
||||||
continue
|
continue
|
||||||
arm = ("write_path_place" if marker.startswith("nearby")
|
by_arm.setdefault("write_path_place", []).append(int(item["id"]))
|
||||||
else "write_path_semantic")
|
|
||||||
by_arm.setdefault(arm, []).append(int(item["id"]))
|
|
||||||
for arm, ids in by_arm.items():
|
for arm, ids in by_arm.items():
|
||||||
record_surfaced(
|
record_surfaced(
|
||||||
user_id=user_id, note_ids=ids, source=arm, project_id=project_id,
|
user_id=user_id, note_ids=ids, source=arm, project_id=project_id,
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ import time
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from scribe.services.lessons import LESSON_NOTE_TYPE, claim_line
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── Shared rendering and ranking helpers (moved from plugin_context) ──────
|
# ── Shared rendering and ranking helpers (moved from plugin_context) ──────
|
||||||
@@ -681,6 +683,663 @@ async def run_rule_arm(
|
|||||||
return RuleResult()
|
return RuleResult()
|
||||||
|
|
||||||
|
|
||||||
|
# ── The notes corpus (milestone 456 step 4) ──────────────────────────────
|
||||||
|
#
|
||||||
|
# The same stages over the other corpus: search, withhold what this response
|
||||||
|
# already lists, split fresh from repeats, log the call before any early
|
||||||
|
# return, band, reserved slots, surfacing rows. Two arms take them — the
|
||||||
|
# prompt's menu (`auto_inject`) and the write path's match by meaning
|
||||||
|
# (`write_path`) — and they differed only in which kinds they ask for, whether
|
||||||
|
# a menu sits above them in the same response, and which slots they reserve.
|
||||||
|
#
|
||||||
|
# ONE DIFFERENCE FROM THE RULE ARMS IS KEPT, ON PURPOSE, FOR STEP 7. A notes
|
||||||
|
# arm logs the cut BEFORE its band; a rule arm logs after it. #2085 chose the
|
||||||
|
# first for notes (the log row is the candidate set a floor is tuned against,
|
||||||
|
# the surfacing rows are what the reader saw) and #3851 the second for rules.
|
||||||
|
# Both are recorded decisions, so this refactor reproduces both.
|
||||||
|
#
|
||||||
|
# What stays OUTSIDE: every lookup. The records named by number, the snippets
|
||||||
|
# recorded at a path, the rulings, the design system and the shape ledger
|
||||||
|
# have no score and no floor; the route builders compose them around what
|
||||||
|
# this returns.
|
||||||
|
|
||||||
|
# Margin gate: drop any hit more than this far below the top hit's score, so a
|
||||||
|
# single strong match doesn't drag in a wall of barely-passing neighbours.
|
||||||
|
# Twice the rule band, which is narrow because the rule corpus is flat (#3851).
|
||||||
|
_NOTE_BAND = 0.10
|
||||||
|
|
||||||
|
|
||||||
|
def _record_kind(note) -> str:
|
||||||
|
"""The kind marker for an injected menu line — and, for a task, its status.
|
||||||
|
|
||||||
|
The menu is drawn from every record that carries an embedding, so a snippet,
|
||||||
|
a stored process, an issue and a stray dev-log all arrive looking identical.
|
||||||
|
Recorded prior art only stands out if the line says what it is — and the kind
|
||||||
|
is also what tells the reader which tool opens it.
|
||||||
|
|
||||||
|
Task-ness wins over `note_type` because it's the more useful distinction at a
|
||||||
|
glance: "there's an open issue about this" beats "there's a note about this".
|
||||||
|
|
||||||
|
A TASK ALSO CARRIES ITS STATUS, because for that kind alone the line is
|
||||||
|
read as a claim about live work. A finished step and an open one rendered
|
||||||
|
identically is not a cosmetic gap: a done step was cited as a milestone's
|
||||||
|
open one on the strength of a line exactly like this, which carries an id,
|
||||||
|
a kind and a title and said nothing about where the work stood (#4154).
|
||||||
|
Only for tasks — a note or a snippet has no status to be wrong about.
|
||||||
|
"""
|
||||||
|
if note.is_task:
|
||||||
|
kind = "issue" if note.task_kind == "issue" else "task"
|
||||||
|
# No fallback for a missing status: `is_task` IS `status is not None`
|
||||||
|
# (models/note.py), so a branch for a task without one could never be
|
||||||
|
# taken, and a dead branch is a claim about the data that isn't true.
|
||||||
|
#
|
||||||
|
# Parenthesised rather than dot-joined: the write-path prior-art line
|
||||||
|
# joins its own fields with " · ", so a dotted status would read as
|
||||||
|
# another flag beside `seen` instead of as part of the kind.
|
||||||
|
return f"{kind} ({note.status})"
|
||||||
|
return note.note_type or "note"
|
||||||
|
|
||||||
|
|
||||||
|
# WHAT A MENU LINE CARRIES (#4364): the record's NAME, its kind and System,
|
||||||
|
# and the WHOLE passage that matched. Metadata plus the evidence, rather than a
|
||||||
|
# title asked to be both.
|
||||||
|
#
|
||||||
|
# The name, not the title. A snippet's or lesson's title is `name — when it
|
||||||
|
# applies` by construction (`embeddings.trigger_title`), because that join is
|
||||||
|
# what makes it rank on its situation. That is an EMBEDDING shape, and rendered
|
||||||
|
# as a menu line it ran to 1,500+ characters — the trigger paragraph spent
|
||||||
|
# again on every line, and again on every repeat. The trigger still arrives
|
||||||
|
# when it is what matched: it is in the chunk, and `_menu_passage` hands it
|
||||||
|
# over when the title was the whole match.
|
||||||
|
#
|
||||||
|
# The whole passage, not 200 characters of it. The search already chose the
|
||||||
|
# chunk that matched; the old cut kept its head and tail, and the head is the
|
||||||
|
# title every chunk is prefixed with — so the reader got the title twice and
|
||||||
|
# lost the middle, which is where the match was (lesson #4248). A chunk is at
|
||||||
|
# most ~1.4 KB (`embeddings._CHUNK_CHAR_BUDGET`), and it is shown once: a
|
||||||
|
# repeat is a one-line pointer (`_menu_seen_line`), not a second copy.
|
||||||
|
|
||||||
|
|
||||||
|
def _menu_name(title: str | None, note_type: str | None, data=None, body: str | None = "") -> str:
|
||||||
|
"""The record's name — its title without the trigger composed into it."""
|
||||||
|
title = (title or "(untitled)").replace("\n", " ").strip()
|
||||||
|
data = data if isinstance(data, dict) else {}
|
||||||
|
if note_type == "snippet":
|
||||||
|
from scribe.services.embeddings import TRIGGER_SEP
|
||||||
|
return (data.get("name") or title.partition(TRIGGER_SEP)[0]).strip() or title
|
||||||
|
if note_type == LESSON_NOTE_TYPE:
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from scribe.services.embeddings import untrigger_title
|
||||||
|
from scribe.services.lessons import lesson_trigger
|
||||||
|
trigger = lesson_trigger(SimpleNamespace(data=data, body=body or ""))
|
||||||
|
# One line even when the stored name is a story (#4797).
|
||||||
|
return claim_line(untrigger_title(title, trigger).strip() or title)
|
||||||
|
return title
|
||||||
|
|
||||||
|
|
||||||
|
def _menu_passage(title: str | None, chunk_text: str | None, name: str = "") -> str:
|
||||||
|
"""The matched chunk on one line, without the title it was embedded under.
|
||||||
|
|
||||||
|
Every chunk is `title\nsection` (`embeddings.embedding_text`), and `title`
|
||||||
|
here must be the EMBEDDED one (`embeddings.document_title`) — for a snippet
|
||||||
|
or lesson that is `name — trigger`, not the stored name — so the prefix is
|
||||||
|
stripped exactly. A chunk that WAS only the title — a short
|
||||||
|
record, or the head chunk of one — matched on the title, and for a
|
||||||
|
trigger-keyed kind the part of it the name line no longer shows is the
|
||||||
|
trigger: that is returned, because it is precisely what matched.
|
||||||
|
One line, so the menu's blockquote survives it.
|
||||||
|
"""
|
||||||
|
title = (title or "").strip()
|
||||||
|
text = (chunk_text or "").strip()
|
||||||
|
if title and text.startswith(title):
|
||||||
|
text = text[len(title):]
|
||||||
|
text = " ".join(text.split())
|
||||||
|
if not text and name and title.startswith(name) and title != name:
|
||||||
|
text = " ".join(title[len(name):].lstrip(" —-").split())
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _menu_label(kind: str, systems: list[str] | None) -> str:
|
||||||
|
"""`issue (done) · Plugin & hooks` — the kind, then where it belongs."""
|
||||||
|
return " · ".join([kind, *systems]) if systems else kind
|
||||||
|
|
||||||
|
|
||||||
|
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}"
|
||||||
|
|
||||||
|
|
||||||
|
def menu_entry(
|
||||||
|
note_id: int, *, kind: str, name: str, systems: list[str] | None = None,
|
||||||
|
seen: bool = False, stale: bool = False, score: float | None = None,
|
||||||
|
shared_by: str = "", under: str = "",
|
||||||
|
) -> list[str]:
|
||||||
|
"""One record on a notes menu: its line, and what sits under it.
|
||||||
|
|
||||||
|
The prompt menu's two blocks — records named by number and records ranked
|
||||||
|
by meaning — used to write this out twice, differing only in what they
|
||||||
|
pass: a ranked line carries its score, a named one does not, and each puts
|
||||||
|
its own text under the line (the matched passage, or the record's opening).
|
||||||
|
|
||||||
|
A repeat is a POINTER, not a copy (#4364). The record is in this session's
|
||||||
|
context already — the ledger is cleared at compaction, so "seen" stays true
|
||||||
|
— and re-rendering it spent its whole line again for nothing.
|
||||||
|
|
||||||
|
A superseded record is DEMOTED, not removed (#278), so one can still reach
|
||||||
|
a 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.
|
||||||
|
"""
|
||||||
|
if seen:
|
||||||
|
line = _menu_seen_line(note_id, kind, name)
|
||||||
|
return [line + (" — SUPERSEDED" if stale else "")]
|
||||||
|
line = f"> - #{note_id} [{_menu_label(kind, systems)}] \"{name}\""
|
||||||
|
if score is not None:
|
||||||
|
line += f" ({score:.2f})"
|
||||||
|
if stale:
|
||||||
|
line += " — SUPERSEDED, a later record covers this; check that first"
|
||||||
|
if shared_by:
|
||||||
|
line += f" — shared by {shared_by}"
|
||||||
|
return [line, f"> ↳ {under}"] if under else [line]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NoteSlot:
|
||||||
|
"""One line reserved for a kind the open ranking keeps losing."""
|
||||||
|
|
||||||
|
source: str
|
||||||
|
kinds: tuple[str, ...]
|
||||||
|
"""What the slot is FOR — asked for by the search and checked on the way
|
||||||
|
out, so a slot is never spent on a line indistinguishable from one that
|
||||||
|
earned its place on score."""
|
||||||
|
|
||||||
|
evicts: bool
|
||||||
|
"""Take the menu's last line when the menu is full, rather than adding one."""
|
||||||
|
|
||||||
|
include_global_kinds: bool = False
|
||||||
|
books_own: bool = False
|
||||||
|
"""The slot's line is recorded as surfaced under the slot's own source,
|
||||||
|
and so never again under the arm's."""
|
||||||
|
|
||||||
|
|
||||||
|
# Order is load-bearing: reuse evicts the menu's weakest hit while the lesson
|
||||||
|
# slot extends, so running them the other way round would let a reserved
|
||||||
|
# lesson be the line reuse throws off — a slot another slot can silently undo
|
||||||
|
# is not a guarantee.
|
||||||
|
REUSE_SLOT = NoteSlot("reuse_slot", ("snippet", "process"), evicts=True)
|
||||||
|
LESSON_SLOT = NoteSlot(
|
||||||
|
"lesson_slot", (LESSON_NOTE_TYPE,), evicts=False,
|
||||||
|
include_global_kinds=True, books_own=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NoteArm:
|
||||||
|
"""One ranked notes surface: what it searches, and which stages it takes."""
|
||||||
|
|
||||||
|
source: str
|
||||||
|
"""The telemetry `source` of its call row. MUST equal the SURFACES key."""
|
||||||
|
|
||||||
|
surfaced_as: str
|
||||||
|
"""The `source` its surfacing rows carry."""
|
||||||
|
|
||||||
|
note_type: tuple[str, ...] | None = None
|
||||||
|
task_kind: str | None = None
|
||||||
|
include_global_kinds: bool = True
|
||||||
|
"""Lessons are project-independent (#3730), so they join the candidate set
|
||||||
|
from wherever they were learned."""
|
||||||
|
|
||||||
|
scope: str = "browse"
|
||||||
|
"""Nobody asked for an injected line, so it takes the BROWSE scope: never
|
||||||
|
a record shared one-to-one with the operator."""
|
||||||
|
|
||||||
|
slots: tuple[NoteSlot, ...] = ()
|
||||||
|
withholds: bool = False
|
||||||
|
"""A menu sits above this arm in the same response, and what it lists is
|
||||||
|
kept out of the search (`exclude_ids`) — same-call duplication, which is a
|
||||||
|
different claim from the session ledger (#4101)."""
|
||||||
|
|
||||||
|
|
||||||
|
AUTO_INJECT = NoteArm(
|
||||||
|
"auto_inject", surfaced_as="auto_inject", slots=(REUSE_SLOT, LESSON_SLOT),
|
||||||
|
)
|
||||||
|
# Snippets AND recorded experience (#2246): an issue saying "we tried this and
|
||||||
|
# it deadlocked" is prior art for the code about to be written. `task_kind`
|
||||||
|
# keeps the open to-do list out — a task resembles the code and answers
|
||||||
|
# nothing. Lessons too (milestone 385 step 5): the arm is kind-FILTERED, so a
|
||||||
|
# kind absent here is unreachable, not merely outranked (#3702). No reserved
|
||||||
|
# slot: this arm fires before every Write and Edit, and the field is already
|
||||||
|
# narrow enough that the 200:1 dilution a slot answers does not happen.
|
||||||
|
WRITE_PATH = NoteArm(
|
||||||
|
"write_path", surfaced_as="write_path_semantic",
|
||||||
|
note_type=("snippet", "note", LESSON_NOTE_TYPE), task_kind="issue",
|
||||||
|
withholds=True,
|
||||||
|
)
|
||||||
|
NOTE_ARMS: tuple[NoteArm, ...] = (AUTO_INJECT, WRITE_PATH)
|
||||||
|
NOTE_SLOTS: tuple[NoteSlot, ...] = (REUSE_SLOT, LESSON_SLOT)
|
||||||
|
|
||||||
|
# What the notes stages record under, for the registry's fan-out sites.
|
||||||
|
NOTE_SOURCES: tuple[str, ...] = (
|
||||||
|
*(arm.source for arm in NOTE_ARMS), *(slot.source for slot in NOTE_SLOTS),
|
||||||
|
)
|
||||||
|
NOTE_SURFACED_SOURCES: tuple[str, ...] = (
|
||||||
|
*(arm.surfaced_as for arm in NOTE_ARMS),
|
||||||
|
*(slot.source for slot in NOTE_SLOTS if slot.books_own),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def note_search_filters(arm: NoteArm) -> dict:
|
||||||
|
"""The arm's constant search keywords — which kinds, whose records.
|
||||||
|
|
||||||
|
Read by `retrieval_review` too: a re-run that searched with different
|
||||||
|
visibility or kinds from the arm's would judge a menu nobody was shown.
|
||||||
|
"""
|
||||||
|
out: dict = {}
|
||||||
|
if arm.note_type:
|
||||||
|
out["note_type"] = arm.note_type
|
||||||
|
if arm.task_kind:
|
||||||
|
out["task_kind"] = arm.task_kind
|
||||||
|
if arm.include_global_kinds:
|
||||||
|
out["include_global_kinds"] = True
|
||||||
|
out["scope"] = arm.scope
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NoteIO:
|
||||||
|
"""The ranker and the two recorders a notes arm reports to."""
|
||||||
|
|
||||||
|
search: Callable[..., Any]
|
||||||
|
"""`semantic_search_notes`, or a stand-in with its signature."""
|
||||||
|
|
||||||
|
record_retrieval: Callable[..., Any]
|
||||||
|
record_surfaced: Callable[..., Any]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NoteMoment:
|
||||||
|
"""What a notes arm searches with, and what this response already holds."""
|
||||||
|
|
||||||
|
user_id: int
|
||||||
|
query: str
|
||||||
|
project_id: int | None
|
||||||
|
"""Searched and logged as `project_id or None`."""
|
||||||
|
|
||||||
|
seen: frozenset[int] = frozenset()
|
||||||
|
"""The session ledger: rendered as a pointer, never withheld (#4101)."""
|
||||||
|
|
||||||
|
named: frozenset[int] = frozenset()
|
||||||
|
"""Records this response shows by LOOKUP (named by number). Shown in their
|
||||||
|
own block and booked under `named_ref`, so this arm did not surface them."""
|
||||||
|
|
||||||
|
in_menu: frozenset[int] = frozenset()
|
||||||
|
"""Records listed earlier in this same response — withheld."""
|
||||||
|
|
||||||
|
still_scored: frozenset[int] = frozenset()
|
||||||
|
"""Of `in_menu`, those the search must still score (a pulled snippet's
|
||||||
|
resemblance to the payload is evidence for the shape ledger)."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NoteResult:
|
||||||
|
"""What a notes arm put on the menu, and what its search answered."""
|
||||||
|
|
||||||
|
menu: list = field(default_factory=list)
|
||||||
|
"""(score, note) best first: band, slots, and the named records removed."""
|
||||||
|
|
||||||
|
answered: list = field(default_factory=list)
|
||||||
|
"""Everything the search returned, before this response's own menu was
|
||||||
|
withheld — what `resembles` is read from."""
|
||||||
|
|
||||||
|
chunks: dict = field(default_factory=dict)
|
||||||
|
"""The passage each hit matched on, from the arm's OWN search — so a chunk
|
||||||
|
is only ever paired with the query that matched it. The slots' lines are
|
||||||
|
fetched by their own queries and so have none here."""
|
||||||
|
|
||||||
|
slot_ids: dict = field(default_factory=dict)
|
||||||
|
"""{slot source: the id it spent}."""
|
||||||
|
|
||||||
|
|
||||||
|
def _note_band(hits: list) -> list:
|
||||||
|
"""The top hit, plus every hit within `_NOTE_BAND` of it.
|
||||||
|
|
||||||
|
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 (#3851's axis
|
||||||
|
independence).
|
||||||
|
"""
|
||||||
|
if not hits:
|
||||||
|
return []
|
||||||
|
top = hits[0][0]
|
||||||
|
return [(s, n) for s, n in hits if s >= top - _NOTE_BAND]
|
||||||
|
|
||||||
|
|
||||||
|
async def _reserve_note_slot(
|
||||||
|
io: NoteIO, arm: NoteArm, slot: NoteSlot, moment: NoteMoment, menu: list,
|
||||||
|
*, floor: float, budget: int, shown: set[int],
|
||||||
|
) -> tuple[list, int | None]:
|
||||||
|
"""Guarantee `slot.kinds` one line, if one clears the arm's own bar.
|
||||||
|
|
||||||
|
WHY A SLOT AT ALL (#2246, milestone 385 step 5). Ranking by raw cosine is
|
||||||
|
blind to what KIND of record answers what kind of ask, and the corpus
|
||||||
|
makes that fatal: Scribe's project records are about software work, so a
|
||||||
|
task about building a helper outranks the snippet that IS one. Snippets
|
||||||
|
were ~0.5% of the corpus when this was measured; no floor fixes 200:1. A
|
||||||
|
lesson crowded out is worse still — it exists only to be met at the moment
|
||||||
|
it applies, so the arm that surfaces it IS its delivery.
|
||||||
|
|
||||||
|
THE SLOT BUYS POSITION, NOT A LOWER BAR. It reserves at the menu's own
|
||||||
|
floor and is NOT held to the band (the top score is the very thing these
|
||||||
|
kinds lose to), so a weak record cannot buy the line.
|
||||||
|
|
||||||
|
ITS OWN SOURCE, from the first deploy. A guarantee has to be falsifiable,
|
||||||
|
and the hit a slot pushed out sits in the arm's row while the query that
|
||||||
|
pushed it out would otherwise be nowhere (#2463).
|
||||||
|
|
||||||
|
THE LEDGER IS NOT AN EXCLUSION HERE EITHER (#4101), but this call's own
|
||||||
|
menu is: a record already on it must not be shown twice, while one shown
|
||||||
|
in an EARLIER call is exactly what a slot may spend itself on — a snippet
|
||||||
|
relevant then and now is the reuse case, not a duplicate of it.
|
||||||
|
|
||||||
|
Returns the possibly-changed menu and the id the slot spent.
|
||||||
|
"""
|
||||||
|
if any(_record_kind(n) in slot.kinds for _s, n in menu):
|
||||||
|
return menu, None
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
report: dict = {}
|
||||||
|
on_menu = {int(n.id) for _s, n in menu}
|
||||||
|
kwargs: dict = {
|
||||||
|
"limit": 1, "threshold": floor, "project_id": moment.project_id or None,
|
||||||
|
"exclude_ids": set(on_menu),
|
||||||
|
# KIND-FILTERED, so the slot can only be spent on what it is for.
|
||||||
|
"note_type": slot.kinds, "scope": arm.scope, "report": report,
|
||||||
|
}
|
||||||
|
if slot.include_global_kinds:
|
||||||
|
kwargs["include_global_kinds"] = True
|
||||||
|
found = await io.search(moment.user_id, moment.query, **kwargs)
|
||||||
|
fresh = [(s, n) for s, n in found if int(n.id) not in shown]
|
||||||
|
source = slot.source
|
||||||
|
try:
|
||||||
|
io.record_retrieval(
|
||||||
|
user_id=moment.user_id, source=source, query=moment.query,
|
||||||
|
threshold=floor, limit=1, project_id=moment.project_id or None,
|
||||||
|
is_task=None, results=fresh,
|
||||||
|
best_available=report.get("best_available_score"),
|
||||||
|
best_available_id=report.get("best_available_id"),
|
||||||
|
searched=bool(report.get("searched", True)),
|
||||||
|
suppressed=len(found) - len(fresh),
|
||||||
|
duration_ms=(time.perf_counter() - t0) * 1000.0,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001 - observation never breaks the observed
|
||||||
|
_telemetry_failed(source)
|
||||||
|
# Verified, not trusted: the kind the query asked for, and not a line
|
||||||
|
# this menu already carries.
|
||||||
|
placed = [
|
||||||
|
(s, n) for s, n in found
|
||||||
|
if _record_kind(n) in slot.kinds and int(n.id) not in on_menu
|
||||||
|
][:1]
|
||||||
|
if not placed:
|
||||||
|
return menu, None
|
||||||
|
|
||||||
|
slot_id = int(placed[0][1].id)
|
||||||
|
# FRESH ONLY, matching the row above: a ledger repeat is rendered (#4101)
|
||||||
|
# but is not a new surfacing.
|
||||||
|
if slot.books_own and slot_id not in shown:
|
||||||
|
try:
|
||||||
|
io.record_surfaced(
|
||||||
|
user_id=moment.user_id, note_ids=[slot_id], source=source,
|
||||||
|
project_id=moment.project_id or None,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001 - observation never breaks the observed
|
||||||
|
_telemetry_failed(source)
|
||||||
|
if not slot.evicts:
|
||||||
|
# IT EXTENDS, IT NEVER DISPLACES, siding with `preference_slot`: a
|
||||||
|
# displaced hit sits in the arm's row, and evicting it would make the
|
||||||
|
# two tables disagree about the same call (#3668). And a record that
|
||||||
|
# does not bind should not throw a better-scoring one off the menu.
|
||||||
|
return menu + placed, slot_id
|
||||||
|
# Take the LAST line, never the first: the strongest overall hit is still
|
||||||
|
# the best answer, and displacing it would trade one blindness for another.
|
||||||
|
if len(menu) >= budget:
|
||||||
|
return menu[:budget - 1] + placed, slot_id
|
||||||
|
return (menu + placed)[:budget], slot_id
|
||||||
|
|
||||||
|
|
||||||
|
async def run_note_arm(
|
||||||
|
arm: NoteArm, moment: NoteMoment, *, floor: float, budget: int, io: NoteIO,
|
||||||
|
) -> NoteResult:
|
||||||
|
"""Run one notes arm through every stage it takes, in the one order.
|
||||||
|
|
||||||
|
search → withhold this response's menu → fresh/repeat split → call row →
|
||||||
|
band → reserved slots → surfacing rows. Fails open to an empty result.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
report: dict = {}
|
||||||
|
scored = moment.in_menu & moment.still_scored
|
||||||
|
kwargs: dict = {
|
||||||
|
# The still-scored ids come back and are dropped below, so the
|
||||||
|
# limit has to cover them.
|
||||||
|
"limit": budget + len(scored), "threshold": floor,
|
||||||
|
"project_id": moment.project_id or None,
|
||||||
|
**note_search_filters(arm), "report": report,
|
||||||
|
}
|
||||||
|
if arm.withholds:
|
||||||
|
kwargs["exclude_ids"] = set(moment.in_menu - scored)
|
||||||
|
answered = await io.search(moment.user_id, moment.query, **kwargs)
|
||||||
|
hits = [(s, n) for s, n in answered if int(n.id) not in moment.in_menu]
|
||||||
|
# WHAT THIS ARM WITHHELD AFTER THE SEARCH ANSWERED (#3739). The score
|
||||||
|
# the search reports is measured BEFORE that drop, so a record already
|
||||||
|
# listed in this response could be logged as one the BAR turned away
|
||||||
|
# — live proof once read a "rejection" at 0.822 against a lowest
|
||||||
|
# acceptance of 0.6857. Whenever this removed anything, the honest
|
||||||
|
# best-available is null: "not measured on this call".
|
||||||
|
withheld = len(answered) - len(hits)
|
||||||
|
hits = hits[:budget]
|
||||||
|
# `results=fresh` and `suppressed` together (#3752): a rendered repeat
|
||||||
|
# is not a new surfacing, so it is COUNTED rather than reported, and an
|
||||||
|
# all-repeat zero reads apart from a bar nothing cleared. A record
|
||||||
|
# NAMED in this response is counted the same way — shown, but by the
|
||||||
|
# lookup and booked under `named_ref`.
|
||||||
|
fresh = [
|
||||||
|
(s, n) for s, n in hits
|
||||||
|
if int(n.id) not in moment.seen and int(n.id) not in moment.named
|
||||||
|
]
|
||||||
|
source = arm.source
|
||||||
|
try:
|
||||||
|
io.record_retrieval(
|
||||||
|
user_id=moment.user_id, source=source, query=moment.query,
|
||||||
|
threshold=floor, limit=budget,
|
||||||
|
project_id=moment.project_id or None, is_task=None,
|
||||||
|
results=fresh,
|
||||||
|
best_available=(
|
||||||
|
None if withheld else report.get("best_available_score")
|
||||||
|
),
|
||||||
|
# Withheld on the SAME condition: a surviving id beside a null
|
||||||
|
# score would name a record without saying what it scored.
|
||||||
|
best_available_id=(
|
||||||
|
None if withheld else report.get("best_available_id")
|
||||||
|
),
|
||||||
|
searched=bool(report.get("searched", True)),
|
||||||
|
suppressed=len(hits) - len(fresh),
|
||||||
|
duration_ms=(time.perf_counter() - t0) * 1000.0,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001 - observation never breaks the observed
|
||||||
|
_telemetry_failed(source)
|
||||||
|
result = NoteResult(answered=answered, chunks=report.get("best_chunk") or {})
|
||||||
|
if not hits:
|
||||||
|
return result
|
||||||
|
|
||||||
|
menu = _note_band(hits)
|
||||||
|
# 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.
|
||||||
|
shown = set(moment.seen | moment.named)
|
||||||
|
for slot in arm.slots:
|
||||||
|
menu, slot_id = await _reserve_note_slot(
|
||||||
|
io, arm, slot, moment, menu, floor=floor, budget=budget,
|
||||||
|
shown=shown,
|
||||||
|
)
|
||||||
|
if slot_id is not None:
|
||||||
|
result.slot_ids[slot.source] = slot_id
|
||||||
|
# A named record is shown once, in its own block, never again as a match.
|
||||||
|
menu = [(s, n) for s, n in menu if int(n.id) not in moment.named]
|
||||||
|
result.menu = menu
|
||||||
|
|
||||||
|
# What SURVIVED the band — the menu the reader saw — where the call
|
||||||
|
# row holds the candidate set a floor is tuned against (#2085). FRESH
|
||||||
|
# ONLY, the row's own cut (#4101, #3668). A slot that books its own
|
||||||
|
# line is not this arm's surfacing: counting it twice would leave the
|
||||||
|
# slot's two tables describing different numbers of one event.
|
||||||
|
own = {
|
||||||
|
sid for src, sid in result.slot_ids.items()
|
||||||
|
if any(slot.books_own and slot.source == src for slot in arm.slots)
|
||||||
|
}
|
||||||
|
ids = [
|
||||||
|
int(n.id) for _s, n in menu
|
||||||
|
if int(n.id) not in moment.seen and int(n.id) not in own
|
||||||
|
]
|
||||||
|
if ids:
|
||||||
|
source = arm.surfaced_as
|
||||||
|
try:
|
||||||
|
io.record_surfaced(
|
||||||
|
user_id=moment.user_id, note_ids=ids, source=source,
|
||||||
|
project_id=moment.project_id,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001 - observation never breaks the observed
|
||||||
|
_telemetry_failed(source)
|
||||||
|
return result
|
||||||
|
except Exception: # noqa: BLE001 - a recall aid never breaks its act
|
||||||
|
logger.debug("%s arm failed", arm.source, exc_info=True)
|
||||||
|
return NoteResult()
|
||||||
|
|
||||||
|
|
||||||
|
# ── A rule reached through its lessons (milestone 440, #4633) ────────────
|
||||||
|
#
|
||||||
|
# A rule's own document is written in the rule's words, which are general by
|
||||||
|
# design; the situations that keep proving it are often closer to what a
|
||||||
|
# session is actually doing. A lesson JUDGED to be an instance of a rule (a
|
||||||
|
# CONFIRMED link) carries that situation, so a lesson matching the moment
|
||||||
|
# brings its rule along — in rule voice, naming the lesson that reached it.
|
||||||
|
# It searches the NOTES corpus and answers with rules, which is why it sits
|
||||||
|
# between the two halves of this module.
|
||||||
|
#
|
||||||
|
# ITS OWN SLOT, NOT A RULE SLOT, as a stated default. There was nothing to
|
||||||
|
# measure until links are confirmed (#4632), so the asymmetry decides it: a
|
||||||
|
# via-lesson line that took a rule slot could push out a rule the ranker
|
||||||
|
# matched DIRECTLY, a stronger claim displaced by a weaker one, while an extra
|
||||||
|
# line costs one line. Logged as its own source so it can be judged (#4636).
|
||||||
|
|
||||||
|
VIA_LESSON_SOURCE = "rule_via_lesson"
|
||||||
|
VIA_LESSON_LIMIT = 1
|
||||||
|
# Lessons fetched before keeping only the linked ones. The search cannot be
|
||||||
|
# told "linked lessons only", so it overfetches and filters; on a corpus where
|
||||||
|
# most lessons are unlinked, a fetch of one would almost always be spent on a
|
||||||
|
# lesson that carries nothing.
|
||||||
|
_VIA_LESSON_OVERFETCH = 10
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ViaLessonIO:
|
||||||
|
"""The lesson search, the link lookups, and the recorders."""
|
||||||
|
|
||||||
|
search: Callable[..., Any]
|
||||||
|
"""`semantic_search_notes`, or a stand-in with its signature."""
|
||||||
|
|
||||||
|
linked: Callable[..., Any]
|
||||||
|
"""`lesson_rules.confirmed_lessons`: the lessons with a confirmed link."""
|
||||||
|
|
||||||
|
rules_for: Callable[..., Any]
|
||||||
|
"""`lesson_rules.confirmed_rules_in_scope`: their rules, in scope."""
|
||||||
|
|
||||||
|
floor: Callable[..., Any]
|
||||||
|
"""The notes menu's own bar — a lesson too weak to be shown cannot carry a
|
||||||
|
rule in. Awaited only once a linked lesson exists."""
|
||||||
|
|
||||||
|
record_retrieval: Callable[..., Any]
|
||||||
|
record_rule_surfaced: Callable[..., Any]
|
||||||
|
|
||||||
|
|
||||||
|
async def run_via_lesson_arm(
|
||||||
|
moment: RuleMoment, *, skip: frozenset[int], io: ViaLessonIO,
|
||||||
|
) -> RuleResult:
|
||||||
|
"""Rule lines reached through a matching lesson's CONFIRMED links.
|
||||||
|
|
||||||
|
`skip` is every rule this response already names plus the session ledger:
|
||||||
|
suppression applies to the RULE, whichever lesson reached it. Fails open.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
confirmed = await io.linked(moment.user_id)
|
||||||
|
if not confirmed:
|
||||||
|
return RuleResult()
|
||||||
|
bar = await io.floor()
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
report: dict = {}
|
||||||
|
found = await io.search(
|
||||||
|
moment.user_id, moment.query, limit=_VIA_LESSON_OVERFETCH,
|
||||||
|
threshold=bar, project_id=moment.project_id,
|
||||||
|
note_type=(LESSON_NOTE_TYPE,), include_global_kinds=True,
|
||||||
|
scope="browse", report=report,
|
||||||
|
)
|
||||||
|
matched = [(s, n) for s, n in found if int(n.id) in confirmed]
|
||||||
|
by_lesson = await io.rules_for(
|
||||||
|
moment.user_id, [int(n.id) for _s, n in matched], moment.project_id,
|
||||||
|
)
|
||||||
|
candidates = [
|
||||||
|
(s, rule, n) for s, n in matched for rule in by_lesson.get(int(n.id), [])
|
||||||
|
]
|
||||||
|
chosen: list = []
|
||||||
|
taken: set[int] = set(skip)
|
||||||
|
for s, rule, n in candidates:
|
||||||
|
if rule.id in taken:
|
||||||
|
continue
|
||||||
|
taken.add(rule.id)
|
||||||
|
chosen.append((s, rule, n))
|
||||||
|
if len(chosen) >= VIA_LESSON_LIMIT:
|
||||||
|
break
|
||||||
|
# Logged whenever a search ran, results or not — the #3497 guard. The
|
||||||
|
# score is the LESSON's, because the lesson is what was matched; the
|
||||||
|
# best-available id is left off for the same reason, since the row's
|
||||||
|
# results are rules and an id beside them would read as a rule id.
|
||||||
|
try:
|
||||||
|
io.record_retrieval(
|
||||||
|
user_id=moment.user_id, source=VIA_LESSON_SOURCE,
|
||||||
|
query=moment.query, threshold=bar, limit=VIA_LESSON_LIMIT,
|
||||||
|
project_id=moment.project_id, is_task=None,
|
||||||
|
results=[(s, rule) for s, rule, _n in chosen],
|
||||||
|
duration_ms=(time.perf_counter() - t0) * 1000.0,
|
||||||
|
best_available=report.get("best_available_score"),
|
||||||
|
searched=bool(report.get("searched", True)),
|
||||||
|
suppressed=len({r.id for _s, r, _n in candidates}) - len(chosen),
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001 - observation never breaks the observed
|
||||||
|
_telemetry_failed(VIA_LESSON_SOURCE)
|
||||||
|
if not chosen:
|
||||||
|
return RuleResult()
|
||||||
|
rule_ids = [rule.id for _s, rule, _n in chosen]
|
||||||
|
try:
|
||||||
|
io.record_rule_surfaced(
|
||||||
|
user_id=moment.user_id, rule_ids=rule_ids, source=VIA_LESSON_SOURCE,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001 - observation never breaks the observed
|
||||||
|
_telemetry_failed(VIA_LESSON_SOURCE)
|
||||||
|
lines = [
|
||||||
|
_rule_hint_line(rule, where=moment.where, seen=False,
|
||||||
|
held=rule.id in moment.held)
|
||||||
|
+ f" Reached through lesson #{n.id} "
|
||||||
|
+ f"“{_menu_name(n.title, n.note_type, n.data, n.body)}”, "
|
||||||
|
+ "a recorded instance of it."
|
||||||
|
for _s, rule, n in chosen
|
||||||
|
]
|
||||||
|
return RuleResult(
|
||||||
|
lines=lines, rule_ids=rule_ids, shown_rule_ids=list(rule_ids),
|
||||||
|
shown=[(s, rule) for s, rule, _n in chosen],
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001 - a recall aid never breaks its act
|
||||||
|
logger.debug("%s arm failed", VIA_LESSON_SOURCE, exc_info=True)
|
||||||
|
return RuleResult()
|
||||||
|
|
||||||
|
|
||||||
# ── The moment arm (milestone 458) ───────────────────────────────────────
|
# ── The moment arm (milestone 458) ───────────────────────────────────────
|
||||||
#
|
#
|
||||||
# A LOOKUP beside the ranked arms, not one of them. A rule mounted on a moment
|
# A LOOKUP beside the ranked arms, not one of them. A rule mounted on a moment
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from scribe.services.retrieval_pipeline import MOMENT_RULE_SOURCE, RULE_ARMS, RULE_SOURCES
|
from scribe.services.retrieval_pipeline import (
|
||||||
|
MOMENT_RULE_SOURCE, NOTE_SOURCES, NOTE_SURFACED_SOURCES, RULE_ARMS, RULE_SOURCES,
|
||||||
|
)
|
||||||
|
|
||||||
# ── How a point is reached ────────────────────────────────────────────────
|
# ── How a point is reached ────────────────────────────────────────────────
|
||||||
#
|
#
|
||||||
@@ -262,8 +264,10 @@ POINTS: dict[str, Point] = dict([
|
|||||||
# nothing, because the paths are relative to `src/` and so begin `scribe/` —
|
# nothing, because the paths are relative to `src/` and so begin `scribe/` —
|
||||||
# every site sailed through a check that looked like it was running.
|
# every site sailed through a check that looked like it was running.
|
||||||
FAN_OUT_SITES: dict[str, tuple[str, ...]] = {
|
FAN_OUT_SITES: dict[str, tuple[str, ...]] = {
|
||||||
|
# The write path's two LOOKUPS. Its ranked arm (`write_path_semantic`)
|
||||||
|
# records through the pipeline's site below (milestone 456 step 4).
|
||||||
"scribe/services/plugin_context.py::record_surfaced(source=arm)": (
|
"scribe/services/plugin_context.py::record_surfaced(source=arm)": (
|
||||||
"write_path_place", "write_path_semantic", "write_path_sync",
|
"write_path_place", "write_path_sync",
|
||||||
),
|
),
|
||||||
"scribe/services/system_rulings.py::record_system_surfaced(source=source)": (
|
"scribe/services/system_rulings.py::record_system_surfaced(source=source)": (
|
||||||
"rulings_pre_tool", "rulings_write_path",
|
"rulings_pre_tool", "rulings_write_path",
|
||||||
@@ -272,12 +276,16 @@ FAN_OUT_SITES: dict[str, tuple[str, ...]] = {
|
|||||||
"enter_project", "start_planning", "get_task", "get_project",
|
"enter_project", "start_planning", "get_task", "get_project",
|
||||||
"get_milestone",
|
"get_milestone",
|
||||||
),
|
),
|
||||||
# The one retrieval pipeline (milestone 456): every rule arm's call row and
|
# The one retrieval pipeline (milestone 456): every ranked arm's call row
|
||||||
# surfacing rows are written by the same two sites, so `source` arrives as
|
# and surfacing rows — both corpora — are written by these sites, so
|
||||||
# a value. The values are the pipeline's own specs, read rather than
|
# `source` arrives as a value. The values are the pipeline's own specs,
|
||||||
# restated, so an arm added there is declared here by construction.
|
# read rather than restated, so an arm added there is declared here by
|
||||||
|
# construction.
|
||||||
"scribe/services/retrieval_pipeline.py::record_retrieval(source=source)": (
|
"scribe/services/retrieval_pipeline.py::record_retrieval(source=source)": (
|
||||||
RULE_SOURCES
|
RULE_SOURCES + NOTE_SOURCES
|
||||||
|
),
|
||||||
|
"scribe/services/retrieval_pipeline.py::record_surfaced(source=source)": (
|
||||||
|
NOTE_SURFACED_SOURCES
|
||||||
),
|
),
|
||||||
"scribe/services/retrieval_pipeline.py::record_rule_surfaced(source=source)": (
|
"scribe/services/retrieval_pipeline.py::record_rule_surfaced(source=source)": (
|
||||||
tuple(arm.source for arm in RULE_ARMS)
|
tuple(arm.source for arm in RULE_ARMS)
|
||||||
|
|||||||
@@ -96,18 +96,20 @@ async def _rerun(
|
|||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""The logged call's query, searched again the way its arm searches.
|
"""The logged call's query, searched again the way its arm searches.
|
||||||
|
|
||||||
`auto_inject`'s parameters, from `build_autoinject_hint`: browse scope,
|
`auto_inject`'s parameters, read from its spec (`retrieval_pipeline.
|
||||||
lessons reachable across projects, the arm's floor as logged. The reserved
|
AUTO_INJECT`) rather than restated: which kinds, whose records, and the
|
||||||
slots (reuse, lesson) are left out — each logs its own source, and is
|
arm's floor as logged. The reserved slots (reuse, lesson) are left out —
|
||||||
judged as that source when it becomes reviewable.
|
each logs its own source, and is judged as that source when it becomes
|
||||||
|
reviewable.
|
||||||
|
|
||||||
Records created after the call are dropped before ranking — the call could
|
Records created after the call are dropped before ranking — the call could
|
||||||
not have been offered them — and named in `report["postdated"]`.
|
not have been offered them — and named in `report["postdated"]`.
|
||||||
"""
|
"""
|
||||||
# Imported here: plugin_context imports retrieval_telemetry, which reads
|
# Imported here: plugin_context imports retrieval_telemetry, which reads
|
||||||
# this module's judged block.
|
# this module's judged block.
|
||||||
|
from scribe.services import retrieval_pipeline as rp
|
||||||
from scribe.services.embeddings import document_title, semantic_search_notes
|
from scribe.services.embeddings import document_title, semantic_search_notes
|
||||||
from scribe.services.plugin_context import _menu_name, _menu_passage, _record_kind
|
from scribe.services.retrieval_pipeline import _menu_name, _menu_passage, _record_kind
|
||||||
|
|
||||||
rep: dict = {}
|
rep: dict = {}
|
||||||
hits = await semantic_search_notes(
|
hits = await semantic_search_notes(
|
||||||
@@ -115,8 +117,7 @@ async def _rerun(
|
|||||||
limit=depth + POSTDATED_SLACK,
|
limit=depth + POSTDATED_SLACK,
|
||||||
threshold=row.threshold if row.threshold is not None else 0.0,
|
threshold=row.threshold if row.threshold is not None else 0.0,
|
||||||
project_id=row.project_id or None,
|
project_id=row.project_id or None,
|
||||||
include_global_kinds=True,
|
**rp.note_search_filters(rp.AUTO_INJECT),
|
||||||
scope="browse",
|
|
||||||
report=rep,
|
report=rep,
|
||||||
)
|
)
|
||||||
postdated = [int(n.id) for _s, n in hits if _after(n.created_at, row.created_at)]
|
postdated = [int(n.id) for _s, n in hits if _after(n.created_at, row.created_at)]
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
"""The notes corpus on the one retrieval pipeline (milestone 456 step 4).
|
||||||
|
|
||||||
|
`run_note_arm` is driven directly here, with the search and both recorders
|
||||||
|
stubbed through `NoteIO`: the stage order, the invariants the rule arms
|
||||||
|
already pin (#3497's call row before any return, #3752's fresh-only cut,
|
||||||
|
#4101's ledger-renders-not-removes), the write path's withheld menu (#3739),
|
||||||
|
and the two reserved slots in their load-bearing order. The builders that
|
||||||
|
compose these arms keep their own tests; these pin the stages they share.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from scribe.services import retrieval_pipeline as rp
|
||||||
|
from scribe.services.lessons import LESSON_NOTE_TYPE
|
||||||
|
from scribe.services.retrieval_registry import FAN_OUT_SITES, POINTS
|
||||||
|
from scribe.services.retrieval_surfaces import SURFACES
|
||||||
|
from tests.helpers import fake_note
|
||||||
|
|
||||||
|
|
||||||
|
def _io(route):
|
||||||
|
"""A NoteIO whose search answers by the kinds asked for, and records calls."""
|
||||||
|
calls: list[dict] = []
|
||||||
|
|
||||||
|
async def search(_uid, _q, **kw):
|
||||||
|
calls.append(kw)
|
||||||
|
return list(route(kw))
|
||||||
|
|
||||||
|
return rp.NoteIO(search=search, record_retrieval=MagicMock(),
|
||||||
|
record_surfaced=MagicMock()), calls
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(mock, source):
|
||||||
|
return [c.kwargs for c in mock.call_args_list if c.kwargs["source"] == source]
|
||||||
|
|
||||||
|
|
||||||
|
def _note(nid, score=None, **kw):
|
||||||
|
note = fake_note(id=nid, title=f"record {nid}", user_id=1, **kw)
|
||||||
|
return (score, note) if score is not None else note
|
||||||
|
|
||||||
|
|
||||||
|
async def _run(arm, route, *, budget=3, **moment):
|
||||||
|
io, calls = _io(route)
|
||||||
|
result = await rp.run_note_arm(
|
||||||
|
arm, rp.NoteMoment(user_id=1, query="q", project_id=moment.pop("project_id", 2),
|
||||||
|
**moment),
|
||||||
|
floor=0.5, budget=budget, io=io,
|
||||||
|
)
|
||||||
|
return result, io, calls
|
||||||
|
|
||||||
|
|
||||||
|
# ── the call row (#3497, #3752) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_call_that_found_nothing_is_still_logged():
|
||||||
|
result, io, _ = await _run(rp.AUTO_INJECT, lambda kw: [])
|
||||||
|
assert result.menu == []
|
||||||
|
(row,) = _rows(io.record_retrieval, "auto_inject")
|
||||||
|
assert row["results"] == [] and row["suppressed"] == 0
|
||||||
|
io.record_surfaced.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repeats_and_named_records_are_counted_not_reported():
|
||||||
|
hits = [_note(11, 0.80), _note(22, 0.79), _note(33, 0.78)]
|
||||||
|
result, io, _ = await _run(
|
||||||
|
rp.AUTO_INJECT, lambda kw: [] if kw.get("note_type") else hits,
|
||||||
|
seen=frozenset({11}), named=frozenset({33}),
|
||||||
|
)
|
||||||
|
(row,) = _rows(io.record_retrieval, "auto_inject")
|
||||||
|
assert [int(n.id) for _s, n in row["results"]] == [22]
|
||||||
|
assert row["suppressed"] == 2
|
||||||
|
# The repeat stays on the menu (#4101); the named record does not — it is
|
||||||
|
# shown by its own block.
|
||||||
|
assert [int(n.id) for _s, n in result.menu] == [11, 22]
|
||||||
|
(surfaced,) = _rows(io.record_surfaced, "auto_inject")
|
||||||
|
assert surfaced["note_ids"] == [22]
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_notes_stage_logs_before_it_can_return():
|
||||||
|
"""Structural, like the rule arms' guard: no `return` in a notes stage
|
||||||
|
precedes its call row. The via-lesson arm's one exception is the return
|
||||||
|
before any search runs — nothing was asked, so there is nothing to log."""
|
||||||
|
tree = ast.parse(Path("src/scribe/services/retrieval_pipeline.py").read_text())
|
||||||
|
fns = {n.name: n for n in ast.walk(tree) if isinstance(n, ast.AsyncFunctionDef)}
|
||||||
|
for name in ("run_note_arm", "_reserve_note_slot", "run_via_lesson_arm"):
|
||||||
|
fn = fns[name]
|
||||||
|
logged = [n.lineno for n in ast.walk(fn) if isinstance(n, ast.Call)
|
||||||
|
and getattr(n.func, "attr", None) == "record_retrieval"]
|
||||||
|
searched = [n.lineno for n in ast.walk(fn) if isinstance(n, ast.Call)
|
||||||
|
and getattr(n.func, "attr", None) == "search"]
|
||||||
|
assert logged and searched, f"{name} no longer searches and logs"
|
||||||
|
early = [n.lineno for n in ast.walk(fn) if isinstance(n, ast.Return)
|
||||||
|
and min(searched) < n.lineno < min(logged)]
|
||||||
|
assert not early, f"{name} can return at line {early[0]} before logging"
|
||||||
|
|
||||||
|
|
||||||
|
# ── the band, and the arm failing open ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_band_narrows_the_menu_after_the_row_is_written():
|
||||||
|
"""Notes log BEFORE the band (#2085) — the row is the candidate set a
|
||||||
|
floor is tuned against, the surfacing rows are what the reader saw."""
|
||||||
|
hits = [_note(1, 0.90), _note(2, 0.70)]
|
||||||
|
result, io, _ = await _run(rp.WRITE_PATH, lambda kw: hits)
|
||||||
|
(row,) = _rows(io.record_retrieval, "write_path")
|
||||||
|
assert [int(n.id) for _s, n in row["results"]] == [1, 2]
|
||||||
|
assert [int(n.id) for _s, n in result.menu] == [1]
|
||||||
|
(surfaced,) = _rows(io.record_surfaced, "write_path_semantic")
|
||||||
|
assert surfaced["note_ids"] == [1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_search_that_raises_costs_the_arm_not_the_response():
|
||||||
|
async def boom(*_a, **_kw):
|
||||||
|
raise RuntimeError("index unavailable")
|
||||||
|
|
||||||
|
io = rp.NoteIO(search=boom, record_retrieval=MagicMock(), record_surfaced=MagicMock())
|
||||||
|
result = await rp.run_note_arm(
|
||||||
|
rp.AUTO_INJECT, rp.NoteMoment(user_id=1, query="q", project_id=None),
|
||||||
|
floor=0.5, budget=3, io=io,
|
||||||
|
)
|
||||||
|
assert result.menu == [] and result.answered == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_recorder_that_raises_costs_only_its_row():
|
||||||
|
hits = [_note(1, 0.90)]
|
||||||
|
io, _ = _io(lambda kw: [] if kw.get("note_type") else hits)
|
||||||
|
io.record_retrieval.side_effect = RuntimeError("telemetry down")
|
||||||
|
result = await rp.run_note_arm(
|
||||||
|
rp.AUTO_INJECT, rp.NoteMoment(user_id=1, query="q", project_id=None),
|
||||||
|
floor=0.5, budget=3, io=io,
|
||||||
|
)
|
||||||
|
assert [int(n.id) for _s, n in result.menu] == [1]
|
||||||
|
|
||||||
|
|
||||||
|
# ── the write path's withheld menu (#3739) ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_this_responses_menu_is_withheld_but_a_pulled_one_is_still_scored():
|
||||||
|
pulled, listed = 5, 6
|
||||||
|
answered = [_note(pulled, 0.91), _note(7, 0.88)]
|
||||||
|
result, io, calls = await _run(
|
||||||
|
rp.WRITE_PATH, lambda kw: answered, budget=2,
|
||||||
|
in_menu=frozenset({pulled, listed}), still_scored=frozenset({pulled}),
|
||||||
|
)
|
||||||
|
(call,) = calls
|
||||||
|
# The listed one never reaches the search; the pulled one does, and the
|
||||||
|
# limit covers it so the menu still gets its full budget.
|
||||||
|
assert call["exclude_ids"] == {listed}
|
||||||
|
assert call["limit"] == 3
|
||||||
|
assert [int(n.id) for _s, n in result.answered] == [pulled, 7]
|
||||||
|
assert [int(n.id) for _s, n in result.menu] == [7]
|
||||||
|
# Something was withheld after the search answered, so the score it
|
||||||
|
# reported may be one of ours: not measured on this call.
|
||||||
|
(row,) = _rows(io.record_retrieval, "write_path")
|
||||||
|
assert row["best_available"] is None and row["best_available_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_prompt_menu_never_sends_the_ledger_into_its_search():
|
||||||
|
_result, _io_, calls = await _run(
|
||||||
|
rp.AUTO_INJECT, lambda kw: [], seen=frozenset({11}),
|
||||||
|
)
|
||||||
|
assert "exclude_ids" not in calls[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ── the reserved slots, in their order ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _routed(main, reuse=(), lesson=()):
|
||||||
|
def route(kw):
|
||||||
|
kinds = kw.get("note_type") or ()
|
||||||
|
if LESSON_NOTE_TYPE in kinds:
|
||||||
|
return lesson
|
||||||
|
if kinds:
|
||||||
|
return reuse
|
||||||
|
return main
|
||||||
|
return route
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reuse_evicts_the_last_line_and_the_lesson_extends():
|
||||||
|
main = [_note(1, 0.70), _note(2, 0.69), _note(3, 0.68)]
|
||||||
|
reuse = [_note(9, 0.60, note_type="snippet")]
|
||||||
|
lesson = [_note(42, 0.58, note_type=LESSON_NOTE_TYPE)]
|
||||||
|
result, io, calls = await _run(
|
||||||
|
rp.AUTO_INJECT, _routed(main, reuse, lesson), budget=3,
|
||||||
|
)
|
||||||
|
# Reuse took the last of three; the lesson was added as a fourth.
|
||||||
|
assert [int(n.id) for _s, n in result.menu] == [1, 2, 9, 42]
|
||||||
|
assert result.slot_ids == {"reuse_slot": 9, "lesson_slot": 42}
|
||||||
|
# Reuse ran first: the lesson query was told about the snippet.
|
||||||
|
assert [c.get("note_type") for c in calls] == [
|
||||||
|
None, ("snippet", "process"), (LESSON_NOTE_TYPE,),
|
||||||
|
]
|
||||||
|
assert 9 in calls[2]["exclude_ids"]
|
||||||
|
# Each slot logs under its own name.
|
||||||
|
assert _rows(io.record_retrieval, "reuse_slot") and _rows(io.record_retrieval, "lesson_slot")
|
||||||
|
# The lesson books its own surfacing and never the arm's; the reuse line
|
||||||
|
# is counted under the arm, as it always was.
|
||||||
|
assert _rows(io.record_surfaced, "lesson_slot")[0]["note_ids"] == [42]
|
||||||
|
assert _rows(io.record_surfaced, "auto_inject")[0]["note_ids"] == [1, 2, 9]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_slot_stands_down_when_its_kind_won_on_score():
|
||||||
|
main = [_note(9, 0.80, note_type="snippet"), _note(42, 0.79, note_type=LESSON_NOTE_TYPE)]
|
||||||
|
_result, io, calls = await _run(rp.AUTO_INJECT, _routed(main))
|
||||||
|
assert len(calls) == 1
|
||||||
|
assert not _rows(io.record_retrieval, "reuse_slot")
|
||||||
|
assert not _rows(io.record_retrieval, "lesson_slot")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_slot_spends_itself_on_a_repeat_without_booking_it_again():
|
||||||
|
"""A record shown in an EARLIER call is exactly what a slot may spend
|
||||||
|
itself on (#4101) — it is rendered, but it is not a new surfacing."""
|
||||||
|
main = [_note(1, 0.70)]
|
||||||
|
lesson = [_note(42, 0.58, note_type=LESSON_NOTE_TYPE)]
|
||||||
|
result, io, _ = await _run(
|
||||||
|
rp.AUTO_INJECT, _routed(main, lesson=lesson), seen=frozenset({42}),
|
||||||
|
)
|
||||||
|
assert 42 in [int(n.id) for _s, n in result.menu]
|
||||||
|
assert not _rows(io.record_surfaced, "lesson_slot")
|
||||||
|
(row,) = _rows(io.record_retrieval, "lesson_slot")
|
||||||
|
assert row["results"] == [] and row["suppressed"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_write_path_reserves_no_slot():
|
||||||
|
main = [_note(1, 0.80)]
|
||||||
|
_result, _io_, calls = await _run(rp.WRITE_PATH, _routed(main, [_note(9, 0.7)]))
|
||||||
|
assert len(calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── the specs are the declarations ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_notes_source_is_registered_and_every_arm_is_tunable():
|
||||||
|
for source in (*rp.NOTE_SOURCES, *rp.NOTE_SURFACED_SOURCES):
|
||||||
|
assert source in POINTS, f"{source} records telemetry but is not registered"
|
||||||
|
for arm in rp.NOTE_ARMS:
|
||||||
|
assert arm.source in SURFACES, f"{arm.source} has no floor or budget to tune"
|
||||||
|
# And the slots are measurable, never tunable: a budget of 1 is their feature.
|
||||||
|
for slot in rp.NOTE_SLOTS:
|
||||||
|
assert slot.source not in SURFACES
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_pipelines_fan_out_sites_declare_every_notes_source():
|
||||||
|
logged = FAN_OUT_SITES[
|
||||||
|
"scribe/services/retrieval_pipeline.py::record_retrieval(source=source)"]
|
||||||
|
surfaced = FAN_OUT_SITES[
|
||||||
|
"scribe/services/retrieval_pipeline.py::record_surfaced(source=source)"]
|
||||||
|
assert set(rp.NOTE_SOURCES) <= set(logged)
|
||||||
|
assert set(rp.NOTE_SURFACED_SOURCES) == set(surfaced)
|
||||||
|
# Not the arm's: the slot whose line it is, and the lookups' own site.
|
||||||
|
assert "write_path_semantic" not in FAN_OUT_SITES[
|
||||||
|
"scribe/services/plugin_context.py::record_surfaced(source=arm)"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_filters_say_which_kinds_and_whose_records():
|
||||||
|
assert rp.note_search_filters(rp.AUTO_INJECT) == {
|
||||||
|
"include_global_kinds": True, "scope": "browse",
|
||||||
|
}
|
||||||
|
assert rp.note_search_filters(rp.WRITE_PATH) == {
|
||||||
|
"note_type": ("snippet", "note", LESSON_NOTE_TYPE), "task_kind": "issue",
|
||||||
|
"include_global_kinds": True, "scope": "browse",
|
||||||
|
}
|
||||||
@@ -5,12 +5,9 @@ Integration tests pin the parts a mock would make true by construction: which
|
|||||||
logged calls are offered, that a verdict lands and replaces, that "opened
|
logged calls are offered, that a verdict lands and replaces, that "opened
|
||||||
after" reads the usage table, and that the readout counts by rank.
|
after" reads the usage table, and that the readout counts by rank.
|
||||||
"""
|
"""
|
||||||
import ast
|
|
||||||
import inspect
|
|
||||||
import textwrap
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
@@ -54,31 +51,46 @@ async def test_an_empty_verdict_list_is_refused():
|
|||||||
# ── the re-run replays the arm ───────────────────────────────────────────
|
# ── the re-run replays the arm ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _search_kwargs(fn) -> dict:
|
_FILTERS = ("note_type", "task_kind", "include_global_kinds", "scope")
|
||||||
"""The constant keywords `fn` passes to semantic_search_notes."""
|
|
||||||
tree = ast.parse(textwrap.dedent(inspect.getsource(fn)))
|
|
||||||
for node in ast.walk(tree):
|
|
||||||
if (isinstance(node, ast.Call) and getattr(node.func, "id", "")
|
|
||||||
== "semantic_search_notes"):
|
|
||||||
return {
|
|
||||||
kw.arg: kw.value.value for kw in node.keywords
|
|
||||||
if isinstance(kw.value, ast.Constant)
|
|
||||||
}
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_re_run_searches_the_way_the_arm_does():
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_re_run_searches_the_way_the_arm_does():
|
||||||
"""A re-run with different visibility or kinds from the arm's would judge
|
"""A re-run with different visibility or kinds from the arm's would judge
|
||||||
a menu nobody was shown. Read from both call sites, so a change to the
|
a menu nobody was shown. Both searches are RUN and their keywords
|
||||||
arm's search fails here until the review follows it."""
|
compared, so a change to the arm's spec fails here until the review
|
||||||
from scribe.services.plugin_context import build_autoinject_hint
|
follows it — and the arm has to have filters for the check to mean
|
||||||
|
anything (rule 167)."""
|
||||||
|
from scribe.services import retrieval_pipeline as rp
|
||||||
|
|
||||||
arm = _search_kwargs(build_autoinject_hint)
|
arm_kw: dict = {}
|
||||||
rerun = _search_kwargs(review._rerun)
|
|
||||||
assert arm, "found no semantic_search_notes call in the arm"
|
async def arm_search(_uid, _q, **kw):
|
||||||
for key in ("include_global_kinds", "scope"):
|
arm_kw.update(kw)
|
||||||
assert key in arm, f"the arm no longer passes {key} as a constant"
|
return []
|
||||||
assert rerun.get(key) == arm[key], key
|
|
||||||
|
await rp.run_note_arm(
|
||||||
|
rp.AUTO_INJECT, rp.NoteMoment(user_id=1, query="q", project_id=None),
|
||||||
|
floor=0.5, budget=3,
|
||||||
|
io=rp.NoteIO(search=arm_search, record_retrieval=MagicMock(),
|
||||||
|
record_surfaced=MagicMock()),
|
||||||
|
)
|
||||||
|
rerun_kw: dict = {}
|
||||||
|
|
||||||
|
async def rerun_search(_uid, _q, **kw):
|
||||||
|
rerun_kw.update(kw)
|
||||||
|
return []
|
||||||
|
|
||||||
|
row = SimpleNamespace(query="q", threshold=0.5, limit_n=3, project_id=None,
|
||||||
|
created_at=CALL)
|
||||||
|
with patch("scribe.services.embeddings.semantic_search_notes", side_effect=rerun_search):
|
||||||
|
await review._rerun(1, row, 3)
|
||||||
|
|
||||||
|
arm = {k: arm_kw[k] for k in _FILTERS if k in arm_kw}
|
||||||
|
assert "scope" in arm and "include_global_kinds" in arm, (
|
||||||
|
f"the arm no longer passes its visibility filters: {arm_kw}"
|
||||||
|
)
|
||||||
|
assert {k: rerun_kw[k] for k in _FILTERS if k in rerun_kw} == arm
|
||||||
|
|
||||||
|
|
||||||
CALL = datetime(2026, 9, 20, 12, 0, tzinfo=timezone.utc)
|
CALL = datetime(2026, 9, 20, 12, 0, tzinfo=timezone.utc)
|
||||||
|
|||||||
@@ -70,9 +70,9 @@ def test_every_surface_name_is_a_real_telemetry_source():
|
|||||||
# The rule arms record through the one pipeline (milestone 456), where
|
# The rule arms record through the one pipeline (milestone 456), where
|
||||||
# `source` is the spec's own field — so for them the spec IS the string
|
# `source` is the spec's own field — so for them the spec IS the string
|
||||||
# `record_retrieval` receives, and the join key is checked against it.
|
# `record_retrieval` receives, and the join key is checked against it.
|
||||||
from scribe.services.retrieval_pipeline import RULE_ARMS
|
from scribe.services.retrieval_pipeline import NOTE_ARMS, RULE_ARMS
|
||||||
|
|
||||||
via_pipeline = {arm.source for arm in RULE_ARMS}
|
via_pipeline = {arm.source for arm in (*RULE_ARMS, *NOTE_ARMS)}
|
||||||
missing = [
|
missing = [
|
||||||
s.name for s in rs.SURFACES.values()
|
s.name for s in rs.SURFACES.values()
|
||||||
if f'source="{s.name}"' not in blob
|
if f'source="{s.name}"' not in blob
|
||||||
|
|||||||
@@ -1727,16 +1727,28 @@ def test_every_hook_rule_search_says_which_project_it_is_for():
|
|||||||
f"{path}:{call.lineno} searches every project's rules from a hook"
|
f"{path}:{call.lineno} searches every project's rules from a hook"
|
||||||
)
|
)
|
||||||
|
|
||||||
# The pipeline: exactly one search, in `_ranked`, whose keyword set is
|
# The pipeline: exactly one RULE search, in `_ranked`, whose keyword set
|
||||||
# built as a dict literal — so the keys are read from that literal.
|
# is built as a dict literal — so the keys are read from that literal.
|
||||||
|
# Since step 4 the module also searches the NOTES corpus (the two notes
|
||||||
|
# arms, their slots, and the via-lesson arm's lesson search), so the
|
||||||
|
# searches are counted per function: a rule search copied anywhere else
|
||||||
|
# is a new function in this map, and fails it.
|
||||||
pipeline = ast.parse(Path("src/scribe/services/retrieval_pipeline.py").read_text())
|
pipeline = ast.parse(Path("src/scribe/services/retrieval_pipeline.py").read_text())
|
||||||
searches = [
|
by_function = {}
|
||||||
n for n in ast.walk(pipeline)
|
for fn in ast.walk(pipeline):
|
||||||
if isinstance(n, ast.Call) and getattr(n.func, "attr", None) == "search"
|
if isinstance(fn, ast.AsyncFunctionDef):
|
||||||
]
|
n = sum(1 for c in ast.walk(fn) if isinstance(c, ast.Call)
|
||||||
assert len(searches) == 1, (
|
and getattr(c.func, "attr", None) == "search")
|
||||||
f"the pipeline has {len(searches)} rule searches, expected 1 — every "
|
if n:
|
||||||
f"arm is meant to reach the ranker through `_ranked`"
|
by_function[fn.name] = n
|
||||||
|
assert by_function == {
|
||||||
|
"_ranked": 1, # the rule ranker, every rule arm
|
||||||
|
"_reserve_note_slot": 1, # notes: a reserved slot
|
||||||
|
"run_note_arm": 1, # notes: the arm itself
|
||||||
|
"run_via_lesson_arm": 1, # notes: lessons, then their linked rules
|
||||||
|
}, (
|
||||||
|
f"the pipeline's searches moved: {by_function} — every rule arm is "
|
||||||
|
f"meant to reach the ranker through `_ranked`"
|
||||||
)
|
)
|
||||||
ranked = next(n for n in ast.walk(pipeline)
|
ranked = next(n for n in ast.walk(pipeline)
|
||||||
if isinstance(n, ast.AsyncFunctionDef) and n.name == "_ranked")
|
if isinstance(n, ast.AsyncFunctionDef) and n.name == "_ranked")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
from scribe.services import plugin_context as pc_module
|
from scribe.services import plugin_context as pc_module
|
||||||
from scribe.services import retrieval_surfaces as rs
|
from scribe.services import retrieval_surfaces as rs
|
||||||
|
from scribe.services.retrieval_pipeline import REUSE_SLOT
|
||||||
from scribe.services.lessons import LESSON_NOTE_TYPE
|
from scribe.services.lessons import LESSON_NOTE_TYPE
|
||||||
from tests.helpers import fake_note, writepath_cfg
|
from tests.helpers import fake_note, writepath_cfg
|
||||||
|
|
||||||
@@ -340,7 +341,7 @@ _CFG = {"enabled": True, "threshold": 0.55, "top_k": 3}
|
|||||||
def _asked_for_reuse(calls: list[dict]) -> bool:
|
def _asked_for_reuse(calls: list[dict]) -> bool:
|
||||||
"""Did the reuse slot issue its reserved query on this run?"""
|
"""Did the reuse slot issue its reserved query on this run?"""
|
||||||
return any(
|
return any(
|
||||||
tuple(c.get("note_type") or ()) == pc_module._REUSE_KINDS for c in calls
|
tuple(c.get("note_type") or ()) == REUSE_SLOT.kinds for c in calls
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -621,9 +621,10 @@ def test_the_rule_arm_asks_for_a_set_and_lets_the_band_narrow_it():
|
|||||||
sharper — are narrowed harder than the notes menu is.
|
sharper — are narrowed harder than the notes menu is.
|
||||||
"""
|
"""
|
||||||
from scribe.services import plugin_context as pc
|
from scribe.services import plugin_context as pc
|
||||||
|
from scribe.services import retrieval_pipeline as rp
|
||||||
|
|
||||||
assert pc.RULEHINT_LIMIT > 1
|
assert pc.RULEHINT_LIMIT > 1
|
||||||
assert 0 < pc._RULEHINT_BAND < pc._AUTOINJECT_BAND
|
assert 0 < pc._RULEHINT_BAND < rp._NOTE_BAND
|
||||||
|
|
||||||
|
|
||||||
# --- the minimum-substance floor on the semantic arm (#2223) ------------------
|
# --- the minimum-substance floor on the semantic arm (#2223) ------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user