CI & Build / TypeScript typecheck (push) Failing after 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 16s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Has been skipped
Closes #2244. Retrieval matches on concept, and concepts are language-agnostic: asking about a TypeScript union-find scores 0.72-0.73 against a PYTHON snippet, comfortably over the 0.68 bar. That is useful — a different-language solution gives you the shape even when the code isn't reusable — but the menu line said nothing about it, so the reader either dismissed a good structural reference or pasted Python into a .ts file. Worth noting this predates the concept-query change: raw TS code already matched the Python snippet at 0.73, because the embedder reads identifiers and structure semantically rather than syntactically. The fail state has been shipping quietly; #2242 only made it an intended use rather than an accident. - knowledge._note_to_item projects `language` from the data mirror, same shape as the existing verification projection — a plain column read, no body parsing. - The semantic arm carries language through on the item it builds; it is the arm where these arise, since a snippet recorded AT the path you're editing is almost never in another language. - _prior_art_line folds it into the marker: [similar 0.72 · python]. Together with the score rather than after the title, because the two jointly are the judgement being offered. - One explanatory line is added to the menu, and only when something on it is actually tagged. Two deliberate calls: LABEL, DON'T FILTER. A stricter threshold for foreign-language hits would suppress exactly the shape-borrowing this exists for. They were never the problem; their being undisclosed was. ONLY CLAIM A MISMATCH YOU CAN ESTABLISH. _foreign_language returns "" when either side is unknown — unrecognised extension, or a snippet with no recorded language. A wrong "· python" is worse than no tag. Same-language hits stay unlabelled, so the common case keeps a clean line and the preamble stays off the menu entirely. Operator-typed language names fold through an alias table first (py/python3 → python, tsx → typescript, c++ → cpp); unrecognised names pass through lowercased, which still makes an unknown-but-equal pair compare equal. Trap found while building: _note() in the tests is a MagicMock, so `note.data` auto-created a truthy mock that would have rendered its repr into a menu line. Both test helpers now set data = None explicitly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
189 lines
7.2 KiB
Python
189 lines
7.2 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"
|
|
# See the same note in test_write_path_trigger: an auto-created mock on
|
|
# `.data` is truthy and would leak into a rendered menu line (#2244).
|
|
n.data = None
|
|
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"
|