2b85443dd1
CI & Build / Python lint (push) Successful in 2s
CI & Build / integration (push) Successful in 31s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 44s
retrieval_logs answers "what did the ranker return, at what scores" — the right substrate for tuning a threshold. It cannot answer the question the snippet corpus actually needs: did anyone open this? A snippet nobody opens is not neutral. It takes a slot in every future auto-inject menu and crowds out something useful. Adds note_usage_events (migration 0071): one row per note per event, either 'surfaced' (we put its title in front of an agent) or 'pulled' (someone opened it in full), tagged with which surface produced it. Closes the gap #2082 recorded against this work. The write-path PLACE arm carries no score, so it has no home in retrieval_logs — folding it in would corrupt the score distribution that table exists to capture. The result was that the arm firing on the STRONGEST claim ("there is already a canonical helper in this exact file") was the one arm nobody could measure. Both arms now emit usage events under distinct sources, so their pull-through rates are finally comparable. Deliberate departures from the task as written: - Not in-session correlation. The original framing was "correlate result_ids against a later get_note in the same session." There is no session identity server-side — the MCP endpoint is stateless and the hooks send no session id — and adding one would mean threading an opaque client-supplied token through every read path. Two independent counters answer the question without it: surfaced 40×, pulled 0 is dead weight regardless of how those events distribute across sessions. - Pulls record at the ENTRY POINTS (MCP tools, REST detail route), not in snippets_svc.get_snippet, which update and merge also reach. Counting those would inflate precisely the number meant to say "someone chose to look at this." - get_note records for every note kind, not just snippets. The auto-inject menu surfaces tasks and processes too; scoping this to snippets would pin those at zero pulls forever and make them read as dead weight next to snippets that merely had a counter. Surfaced in the Snippets list as an "N/M used" badge, warning-toned once a record has been offered 3+ times and never opened, with the tooltip saying what to do about it (usually: its "when to reach for it" doesn't say when). No badge at all below one surfacing — "0/0" reads as a verdict when it's an absence of evidence. Also returned from MCP list_snippets so the agent can see dead weight without opening the UI. Telemetry keeps the retrieval_telemetry contract throughout: writes are fire-and-forget, reads degrade to zeroes, and no path can raise into the surface it observes. Refs #2085 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
186 lines
7.1 KiB
Python
186 lines
7.1 KiB
Python
"""Tests for the usage signal (#2085) — was a surfaced record ever pulled?
|
|
|
|
Covers the payload shaping and the two contracts that make this safe to leave in
|
|
the hot path: telemetry never raises, and telemetry never blocks. Plus the thing
|
|
the feature exists for — that the write-path PLACE arm is now recorded, since
|
|
before this it surfaced snippets while leaving no trace anywhere.
|
|
"""
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from scribe.services import note_usage
|
|
from scribe.services.note_usage import (
|
|
empty_usage,
|
|
record_pulled,
|
|
record_surfaced,
|
|
usage_for_notes,
|
|
)
|
|
|
|
|
|
def _note(nid, title, user_id=1):
|
|
n = MagicMock()
|
|
n.id, n.title, n.user_id = nid, title, user_id
|
|
n.note_type = "snippet"
|
|
return n
|
|
|
|
|
|
# --- recording ------------------------------------------------------------
|
|
|
|
|
|
async def test_record_surfaced_writes_one_row_per_note():
|
|
with patch.object(note_usage, "_schedule") as sched:
|
|
record_surfaced(user_id=7, note_ids=[11, 12], source="auto_inject")
|
|
rows = sched.call_args[0][0]
|
|
assert [r["note_id"] for r in rows] == [11, 12]
|
|
assert {r["event"] for r in rows} == {"surfaced"}
|
|
assert {r["source"] for r in rows} == {"auto_inject"}
|
|
assert {r["user_id"] for r in rows} == {7}
|
|
|
|
|
|
async def test_record_pulled_writes_a_single_row():
|
|
with patch.object(note_usage, "_schedule") as sched:
|
|
record_pulled(user_id=7, note_id=11, source="mcp_get_snippet")
|
|
rows = sched.call_args[0][0]
|
|
assert rows == [
|
|
{"user_id": 7, "note_id": 11, "event": "pulled", "source": "mcp_get_snippet"}
|
|
]
|
|
|
|
|
|
async def test_empty_menu_schedules_nothing():
|
|
"""No notes surfaced is not an event — it must not cost a write."""
|
|
with patch.object(note_usage, "_insert_events") as ins:
|
|
record_surfaced(user_id=1, note_ids=[], source="auto_inject")
|
|
ins.assert_not_called()
|
|
|
|
|
|
async def test_recording_never_raises_on_bad_input():
|
|
"""Telemetry sits in the hot path of every retrieval. A malformed id must
|
|
cost a data point, never the operator's request."""
|
|
with patch.object(note_usage, "_schedule"):
|
|
record_surfaced(user_id=1, note_ids=["not-an-int"], source="auto_inject")
|
|
record_pulled(user_id=1, note_id=None, source="mcp_get_note")
|
|
|
|
|
|
async def test_recording_without_an_event_loop_is_skipped_not_raised():
|
|
"""Called from a sync context outside the app (a script, a test helper),
|
|
there is no loop to schedule on. Skip rather than blow up."""
|
|
with patch.object(
|
|
note_usage.asyncio, "get_running_loop", side_effect=RuntimeError
|
|
):
|
|
record_pulled(user_id=1, note_id=5, source="mcp_get_note")
|
|
|
|
|
|
# --- readout --------------------------------------------------------------
|
|
|
|
|
|
async def test_usage_for_notes_zero_fills_every_requested_id():
|
|
"""The caller renders this shape unconditionally, so a note with no events
|
|
must come back as zeroes, not as a missing key."""
|
|
session = MagicMock()
|
|
session.execute = AsyncMock(return_value=MagicMock(all=MagicMock(return_value=[])))
|
|
ctx = MagicMock()
|
|
ctx.__aenter__ = AsyncMock(return_value=session)
|
|
ctx.__aexit__ = AsyncMock(return_value=False)
|
|
with patch.object(note_usage, "async_session", return_value=ctx):
|
|
out = await usage_for_notes([3, 4])
|
|
assert out == {3: empty_usage(), 4: empty_usage()}
|
|
|
|
|
|
async def test_usage_for_notes_splits_counts_by_event():
|
|
from datetime import datetime, timezone
|
|
|
|
ts = datetime(2026, 7, 28, tzinfo=timezone.utc)
|
|
rows = [(3, "surfaced", 9, ts), (3, "pulled", 2, ts)]
|
|
session = MagicMock()
|
|
session.execute = AsyncMock(
|
|
return_value=MagicMock(all=MagicMock(return_value=rows))
|
|
)
|
|
ctx = MagicMock()
|
|
ctx.__aenter__ = AsyncMock(return_value=session)
|
|
ctx.__aexit__ = AsyncMock(return_value=False)
|
|
with patch.object(note_usage, "async_session", return_value=ctx):
|
|
out = await usage_for_notes([3])
|
|
assert out[3]["surfaced_count"] == 9
|
|
assert out[3]["pull_count"] == 2
|
|
assert out[3]["last_pulled_at"] == ts.isoformat()
|
|
|
|
|
|
async def test_usage_readout_failure_degrades_to_zeroes():
|
|
"""A telemetry readout must not be able to break the list it decorates."""
|
|
with patch.object(note_usage, "async_session", side_effect=RuntimeError("boom")):
|
|
out = await usage_for_notes([3])
|
|
assert out == {3: empty_usage()}
|
|
|
|
|
|
async def test_no_ids_short_circuits_without_a_query():
|
|
with patch.object(note_usage, "async_session") as sess:
|
|
assert await usage_for_notes([]) == {}
|
|
sess.assert_not_called()
|
|
|
|
|
|
# --- the gap this closes --------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"marker, expected_source",
|
|
[("here", "write_path_place"), ("nearby", "write_path_place")],
|
|
)
|
|
async def test_write_path_place_arm_is_recorded(marker, expected_source):
|
|
"""The place arm carries no score, so it has no home in retrieval_logs — it
|
|
surfaced snippets while leaving no trace anywhere. That was the blocker
|
|
#2082 recorded against this task; this is the assertion that it's closed."""
|
|
from scribe.services import plugin_context
|
|
|
|
here = [{"id": 42, "title": "helper", "user_id": 1, "note_type": "snippet"}]
|
|
with (
|
|
patch.object(
|
|
plugin_context,
|
|
"get_writepath_config",
|
|
AsyncMock(return_value={"enabled": True, "threshold": 0.55, "top_k": 3}),
|
|
),
|
|
patch.object(
|
|
plugin_context.snippets_svc,
|
|
"list_snippets",
|
|
AsyncMock(side_effect=[(here, 1), ([], 0)]),
|
|
),
|
|
patch.object(
|
|
plugin_context, "semantic_search_notes", AsyncMock(return_value=[])
|
|
),
|
|
patch.object(plugin_context, "owner_names_for", AsyncMock(return_value={})),
|
|
patch.object(plugin_context, "record_retrieval"),
|
|
patch.object(plugin_context, "record_surfaced") as surfaced,
|
|
):
|
|
out = await plugin_context.build_write_path_hint(
|
|
1, "src/a.py", code="def f(): pass"
|
|
)
|
|
|
|
assert out["note_ids"] == [42]
|
|
sources = {c.kwargs["source"] for c in surfaced.call_args_list}
|
|
assert expected_source in sources
|
|
|
|
|
|
async def test_auto_inject_records_what_survived_the_margin_gate():
|
|
"""Not what the ranker returned — retrieval_logs already holds that. These
|
|
two numbers must not silently mean different things per surface."""
|
|
from scribe.services import plugin_context
|
|
|
|
hits = [(0.90, _note(1, "kept")), (0.40, _note(2, "cut by the margin gate"))]
|
|
with (
|
|
patch.object(
|
|
plugin_context,
|
|
"get_autoinject_config",
|
|
AsyncMock(return_value={"enabled": True, "threshold": 0.3, "top_k": 5}),
|
|
),
|
|
patch.object(
|
|
plugin_context, "semantic_search_notes", AsyncMock(return_value=hits)
|
|
),
|
|
patch.object(plugin_context, "owner_names_for", AsyncMock(return_value={})),
|
|
patch.object(plugin_context, "record_retrieval"),
|
|
patch.object(plugin_context, "record_surfaced") as surfaced,
|
|
):
|
|
await plugin_context.build_autoinject_hint(1, "a query")
|
|
|
|
assert surfaced.call_args.kwargs["note_ids"] == [1]
|
|
assert surfaced.call_args.kwargs["source"] == "auto_inject"
|