feat(plugin): knowledge auto-inject (Path A) — title-first per-turn awareness
CI & Build / Python lint (push) Successful in 2s
CI & Build / integration (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 21s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 55s
CI & Build / Python lint (push) Successful in 2s
CI & Build / integration (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 21s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 55s
New UserPromptSubmit hook (scribe_autoinject.sh) + GET /api/plugin/retrieve that surface the TITLES (never bodies) of the few notes clearing four anti-bloat gates: a per-user confidence threshold (stricter than pull search), a margin gate, per-session dedup (exclude_ids), and a top-k ceiling. Each retrieval is logged to retrieval_logs as source=auto_inject so the threshold can be tuned from data. Per-user config (enable / threshold / top-k) is DB-backed via /api/settings with a Settings UI card; defaults enabled, threshold 0.55, top-k 3 (conservative — tune once auto_inject telemetry accrues). Scribe: project 2, milestone 93, task 1033. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xz4j1H7pjYSjKsEpgcNH5E
This commit is contained in:
@@ -10,6 +10,92 @@ def _rule(rid, title, topic_id):
|
||||
return r
|
||||
|
||||
|
||||
def _note(nid, title):
|
||||
n = MagicMock()
|
||||
n.id, n.title = nid, title
|
||||
return n
|
||||
|
||||
|
||||
# ─── knowledge auto-inject (Path A) ──────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_autoinject_config_defaults_and_clamps():
|
||||
from scribe.services import plugin_context as pc
|
||||
|
||||
# No settings stored → defaults.
|
||||
with patch.object(pc, "get_setting", AsyncMock(side_effect=lambda uid, k, d: d)):
|
||||
cfg = await pc.get_autoinject_config(1)
|
||||
assert cfg == {
|
||||
"enabled": pc.AUTOINJECT_DEFAULT_ENABLED,
|
||||
"threshold": pc.AUTOINJECT_DEFAULT_THRESHOLD,
|
||||
"top_k": pc.AUTOINJECT_DEFAULT_TOP_K,
|
||||
}
|
||||
|
||||
# Out-of-range values are clamped; top_k capped at the hard ceiling.
|
||||
stored = {
|
||||
pc.AUTOINJECT_ENABLED_KEY: "false",
|
||||
pc.AUTOINJECT_THRESHOLD_KEY: "5",
|
||||
pc.AUTOINJECT_TOP_K_KEY: "999",
|
||||
}
|
||||
with patch.object(pc, "get_setting",
|
||||
AsyncMock(side_effect=lambda uid, k, d: stored.get(k, d))):
|
||||
cfg = await pc.get_autoinject_config(1)
|
||||
assert cfg["enabled"] is False
|
||||
assert cfg["threshold"] == 1.0
|
||||
assert cfg["top_k"] == pc._AUTOINJECT_MAX_TOP_K
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_autoinject_hint_disabled_returns_empty_and_skips_search():
|
||||
from scribe.services import plugin_context as pc
|
||||
search = AsyncMock()
|
||||
with patch.object(pc, "get_autoinject_config",
|
||||
AsyncMock(return_value={"enabled": False, "threshold": 0.55, "top_k": 3})), \
|
||||
patch.object(pc, "semantic_search_notes", search), \
|
||||
patch.object(pc, "record_retrieval", MagicMock()):
|
||||
out = await pc.build_autoinject_hint(1, "anything")
|
||||
assert out["context"] == "" and out["note_ids"] == []
|
||||
search.assert_not_called() # disabled → no retrieval at all
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_autoinject_hint_titles_only_with_margin_gate():
|
||||
from scribe.services import plugin_context as pc
|
||||
# top=0.80; 0.74 within band (0.10), 0.61 outside → dropped.
|
||||
hits = [(0.80, _note(11, "Pool sizing decision")),
|
||||
(0.74, _note(22, "run_maintenance thresholds")),
|
||||
(0.61, _note(33, "unrelated-ish"))]
|
||||
rec = MagicMock()
|
||||
with patch.object(pc, "get_autoinject_config",
|
||||
AsyncMock(return_value={"enabled": True, "threshold": 0.55, "top_k": 3})), \
|
||||
patch.object(pc, "semantic_search_notes", AsyncMock(return_value=hits)), \
|
||||
patch.object(pc, "record_retrieval", rec):
|
||||
out = await pc.build_autoinject_hint(1, "postgres pool", project_id=2,
|
||||
exclude_ids=[99])
|
||||
# Margin gate kept the top two, dropped the straggler.
|
||||
assert out["note_ids"] == [11, 22]
|
||||
assert '#11 "Pool sizing decision" (0.80)' in out["context"]
|
||||
assert "#33" not in out["context"]
|
||||
# Title-first: no body text, ever.
|
||||
assert "get_note(id)" in out["context"]
|
||||
# Telemetry fired with the auto_inject source and the full candidate set.
|
||||
rec.assert_called_once()
|
||||
assert rec.call_args.kwargs["source"] == "auto_inject"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_autoinject_hint_blank_query_returns_empty():
|
||||
from scribe.services import plugin_context as pc
|
||||
search = AsyncMock()
|
||||
with patch.object(pc, "get_autoinject_config",
|
||||
AsyncMock(return_value={"enabled": True, "threshold": 0.55, "top_k": 3})), \
|
||||
patch.object(pc, "semantic_search_notes", search):
|
||||
out = await pc.build_autoinject_hint(1, " ")
|
||||
assert out["context"] == ""
|
||||
search.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_session_context_renders_titles_grouped_by_topic():
|
||||
rules = [
|
||||
|
||||
Reference in New Issue
Block a user