diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 4d27527..4c7c8d8 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -23,6 +23,10 @@ const kbInjectEnabled = ref(true); const kbInjectThreshold = ref("0.55"); const kbInjectTopK = ref("3"); const kbWritePathEnabled = ref(true); +// The write-path arm's OWN threshold, stricter than auto-inject's 0.55 above: +// code embeddings sit on a much higher similarity floor than prose, so 0.55 let +// unrelated code through (#2223). Shares top-k, not the threshold. +const kbWritePathThreshold = ref("0.68"); // Near-duplicate report floor. Deliberately looser than the 0.90 write-time // gate: that one BLOCKS a create and must be unforgiving of noise, this one only // suggests a merge the operator reviews (services/dedup.py). @@ -76,9 +80,13 @@ async function saveKbInject() { // default, not to 0 — a 0 floor would report every snippet as a duplicate of // every other one. const dupT = Math.min(1, Math.max(0, Number(kbDuplicateThreshold.value) || 0.82)); + // Same `|| default` reasoning as dupT: falling back to 0 would surface every + // snippet in the corpus on every edit, which is the failure this knob fixes. + const wpT = Math.min(1, Math.max(0, Number(kbWritePathThreshold.value) || 0.68)); kbInjectThreshold.value = String(t); kbInjectTopK.value = String(k); kbDuplicateThreshold.value = String(dupT); + kbWritePathThreshold.value = String(wpT); savingKbInject.value = true; kbInjectSaved.value = false; try { @@ -86,9 +94,11 @@ async function saveKbInject() { kb_autoinject_enabled: kbInjectEnabled.value ? 'true' : 'false', kb_autoinject_threshold: String(t), kb_autoinject_top_k: String(k), - // Its own switch, but deliberately the same threshold/ceiling — see - // WRITEPATH_ENABLED_KEY in services/plugin_context.py. + // Its own switch AND its own threshold (shares only the ceiling) — see + // WRITEPATH_DEFAULT_THRESHOLD in services/plugin_context.py for the + // measurements that split them. kb_writepath_enabled: kbWritePathEnabled.value ? 'true' : 'false', + kb_writepath_threshold: String(wpT), kb_duplicate_threshold: String(dupT), }); kbInjectSaved.value = true; @@ -474,6 +484,9 @@ onMounted(async () => { kbInjectTopK.value = allSettings.kb_autoinject_top_k; } kbWritePathEnabled.value = allSettings.kb_writepath_enabled !== "false"; + if (allSettings.kb_writepath_threshold !== undefined) { + kbWritePathThreshold.value = allSettings.kb_writepath_threshold; + } if (allSettings.kb_duplicate_threshold !== undefined) { kbDuplicateThreshold.value = allSettings.kb_duplicate_threshold; } @@ -1220,8 +1233,31 @@ function formatUserDate(iso: string): string { Checks the file Claude is about to write or edit against your recorded snippets — what's already kept at that path, and what resembles the code being written — so a helper you already have is offered before it's - rewritten. Uses the same threshold and ceiling above, and never blocks the - edit. Off = prior art surfaces only on your own prompts. + rewritten. Uses the ceiling above with its own threshold below, and never + blocks the edit. Off = prior art surfaces only on your own prompts. +

+ +
+ + +

+ Stricter than the prompt threshold above on purpose. Any two pieces of + code look somewhat alike — shared keywords, indentation, structure — so + resemblance scores start higher for code than for prose, and a bar tuned + for prompts flags unrelated code as prior art. Lower this if genuine + duplicates go unnoticed; raise it if you're being offered snippets that + have nothing to do with what's being written. Snippets recorded at the + exact file are always shown regardless — those are prior art by + location, not by resemblance.

diff --git a/src/scribe/services/plugin_context.py b/src/scribe/services/plugin_context.py index c5d878f..fdf04d8 100644 --- a/src/scribe/services/plugin_context.py +++ b/src/scribe/services/plugin_context.py @@ -56,16 +56,47 @@ AUTOINJECT_DEFAULT_ENABLED = True AUTOINJECT_DEFAULT_THRESHOLD = 0.55 AUTOINJECT_DEFAULT_TOP_K = 3 -# The write-path trigger (#2082) gets its own on/off switch but SHARES the -# threshold and top-k above. One knob for "how loud may Scribe be" is easier to -# reason about than two that drift; the switch is separate because wanting prior -# art on writes while keeping prompts quiet (or the reverse) is a real -# preference, and it's the only part of the gate an operator can judge without -# data. The two surfaces log under different `source` values, so if the -# telemetry ever shows they want different thresholds, splitting them is a -# data-backed change rather than a guess made up front. +# The write-path trigger (#2082) gets its own on/off switch, its own threshold, +# and shares only top-k. It originally shared the threshold too, on the argument +# that one "how loud may Scribe be" knob beats two that drift — and reserved the +# split for when telemetry showed the two surfaces wanted different values. +# +# #2223 is that evidence. Measured against the live instance, the semantic arm's +# scores for CODE sit far above what the same threshold means for PROSE: +# near-duplicate of a recorded helper 0.73-0.74 (true positive) +# unrelated colour math / Vue SFC / CSS 0.55-0.63 (false positive) +# `x = 1` 0.58 (false positive) +# 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 +# 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 +# hit, so with a single hit it never engages. +# +# 0.68 clears every measured false positive with margin and still sits 0.05 +# below both true positives. Auto-inject keeps 0.55 — it was tuned on prose and +# is not implicated. Tune from retrieval_logs (source='write_path') + note_usage +# pull-through (#2085) once a real corpus accrues; a cross-encoder rerank +# (#1038) would subsume this bump. WRITEPATH_ENABLED_KEY = "kb_writepath_enabled" +WRITEPATH_THRESHOLD_KEY = "kb_writepath_threshold" WRITEPATH_DEFAULT_ENABLED = True +WRITEPATH_DEFAULT_THRESHOLD = 0.68 + +# Minimum SUBSTANCE (non-whitespace chars) a payload must carry before the +# semantic arm will run at all — the cheap half of the operator's #89 idea +# ("a sliding scale between number of characters and semantic threshold"). +# +# Deliberately NOT a settings knob and deliberately conservative. Its job is +# only to drop payloads too small to carry meaning, where an embedding is noise +# rather than signal: `x = 1`, a renamed variable, a changed string literal — +# which is what most single-line Edits look like, and the majority of Edits are +# single-line. 48 sits below the smallest plausible reusable helper (a one-line +# `def` with a body runs ~60), so it errs toward keeping recall and leaves +# precision to the threshold above, which is where the measured separation is. +# The full length↔threshold CURVE is still open in #89 — the operator flagged it +# as wanting a brainstorm, so this stays a flat floor rather than an invented +# scale. It also saves a pointless embedding round-trip on trivial edits. +WRITEPATH_MIN_CODE_CHARS = 48 # 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. @@ -307,15 +338,31 @@ def _prior_art_line(item: dict, marker: str, owner: str | None) -> str: async def get_writepath_config(user_id: int) -> dict: - """Write-path trigger settings: its own `enabled`, auto-inject's gates.""" + """Write-path trigger settings: its own `enabled` and `threshold`, auto-inject's top_k. + + The threshold OVERRIDES the inherited auto-inject value — code embeddings + have a much higher similarity floor than prose, so the two surfaces need + different bars. See WRITEPATH_DEFAULT_THRESHOLD for the measurements (#2223). + top_k is still shared: "how many titles at once" means the same thing on + both surfaces, and nothing suggests they want different ceilings. + """ cfg = await get_autoinject_config(user_id) enabled_raw = await get_setting( user_id, WRITEPATH_ENABLED_KEY, "true" if WRITEPATH_DEFAULT_ENABLED else "false", ) + + try: + threshold = float(await get_setting( + user_id, WRITEPATH_THRESHOLD_KEY, str(WRITEPATH_DEFAULT_THRESHOLD))) + except (TypeError, ValueError): + threshold = WRITEPATH_DEFAULT_THRESHOLD + threshold = min(1.0, max(0.0, threshold)) + return { **cfg, "enabled": enabled_raw.strip().lower() in ("true", "1", "yes", "on"), + "threshold": threshold, } @@ -332,11 +379,14 @@ async def build_write_path_hint( convention snippet locations are recorded in. `code` is what's about to be written, used only as the semantic query. - Same four anti-bloat gates as auto-inject (threshold, margin, session dedup - via `exclude_ids`, titles-never-bodies) plus the shared top-k cap across BOTH + Carries auto-inject's anti-bloat gates (margin, session dedup via + `exclude_ids`, titles-never-bodies) plus the shared top-k cap across BOTH arms — so a file with a lot of recorded history can't turn one edit into a - wall of text. Returns empty context when disabled, when there's no path, or - when nothing is recorded — which is the common case, and the point. + wall of text. Two gates are its OWN, because code is not prose: a stricter + similarity threshold, and a minimum-substance floor on `code` below which the + semantic arm doesn't run at all (#2223 — see WRITEPATH_DEFAULT_THRESHOLD and + WRITEPATH_MIN_CODE_CHARS). Returns empty context when disabled, when there's + no path, or when nothing is recorded — which is the common case, and the point. Note the repo↔project mapping is deliberately one-way: the hook sends a git remote, which the ROUTE resolves to `project_id` through the repo bindings. @@ -394,6 +444,13 @@ async def build_write_path_hint( scored: list[tuple[str, dict]] = [] remaining = top_k - len(placed) query = (code or "").strip() + # Drop payloads too small to carry meaning before spending an embedding on + # them — a one-line Edit is not a helper being rewritten, and its embedding + # scores off the corpus floor rather than off any real resemblance (#2223). + # Whitespace doesn't count: code is indentation-heavy, so raw length would + # let a deeply-nested one-liner through on padding alone. + if len("".join(query.split())) < WRITEPATH_MIN_CODE_CHARS: + query = "" if remaining > 0 and query: t0 = time.perf_counter() hits = await semantic_search_notes( diff --git a/tests/test_write_path_trigger.py b/tests/test_write_path_trigger.py index 3fff4bf..852a281 100644 --- a/tests/test_write_path_trigger.py +++ b/tests/test_write_path_trigger.py @@ -30,11 +30,31 @@ def _note(nid, title, user_id=1): def _cfg(**over): - base = {"enabled": True, "threshold": 0.55, "top_k": 3} + base = {"enabled": True, "threshold": 0.68, "top_k": 3} base.update(over) return base +# The semantic arm ignores payloads carrying less than WRITEPATH_MIN_CODE_CHARS +# of non-whitespace substance (#2223), so any test that expects it to RUN has to +# pass something a real Edit would plausibly contain. `code="x"` used to be the +# fixture here, which meant the gate tests were passing payloads that in +# production are exactly the noise the floor now drops. +REAL_CODE = '''def debounce(fn, wait=0.25): + """Rate-limit a callback so it fires once after the last call.""" + timer = None + + def wrapped(*a, **kw): + nonlocal timer + if timer: + timer.cancel() + timer = threading.Timer(wait, fn, a, kw) + timer.start() + + return wrapped +''' + + # --- silence: the common case ------------------------------------------------ @pytest.mark.asyncio @@ -44,7 +64,7 @@ async def test_disabled_returns_empty_and_touches_nothing(): with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_cfg(enabled=False))), \ patch.object(pc.snippets_svc, "list_snippets", listing), \ patch.object(pc, "semantic_search_notes", search): - out = await pc.build_write_path_hint(1, "src/x.py", code="def f(): ...") + out = await pc.build_write_path_hint(1, "src/x.py", code=REAL_CODE) assert out["context"] == "" and out["note_ids"] == [] listing.assert_not_called() search.assert_not_called() @@ -57,7 +77,7 @@ async def test_no_path_returns_empty(): search = AsyncMock() with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_cfg())), \ patch.object(pc, "semantic_search_notes", search): - out = await pc.build_write_path_hint(1, " ", code="def f(): ...") + out = await pc.build_write_path_hint(1, " ", code=REAL_CODE) assert out["context"] == "" search.assert_not_called() @@ -69,7 +89,7 @@ async def test_nothing_recorded_is_silent(): patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \ patch.object(pc, "semantic_search_notes", AsyncMock(return_value=[])), \ patch.object(pc, "record_retrieval", MagicMock()): - out = await pc.build_write_path_hint(1, "src/x.py", code="def f(): ...") + out = await pc.build_write_path_hint(1, "src/x.py", code=REAL_CODE) assert out["context"] == "" and out["note_ids"] == [] @@ -86,7 +106,7 @@ async def test_snippet_at_this_path_is_surfaced_without_a_score(): patch.object(pc, "semantic_search_notes", AsyncMock(return_value=[])), \ patch.object(pc, "record_retrieval", MagicMock()), \ patch.object(pc, "owner_names_for", AsyncMock(return_value={})): - out = await pc.build_write_path_hint(1, "src/x.py", code="def f(): ...") + out = await pc.build_write_path_hint(1, "src/x.py", code=REAL_CODE) assert out["note_ids"] == [12] assert '#12 [here] "debounce — rate-limit a callback"' in out["context"] assert "get_snippet(id)" in out["context"] @@ -109,7 +129,7 @@ async def test_directory_is_only_consulted_when_the_file_leaves_room(): patch.object(pc, "semantic_search_notes", AsyncMock(return_value=[])), \ patch.object(pc, "record_retrieval", MagicMock()), \ patch.object(pc, "owner_names_for", AsyncMock(return_value={})): - out = await pc.build_write_path_hint(1, "src/lib/x.py", code="x") + out = await pc.build_write_path_hint(1, "src/lib/x.py", code=REAL_CODE) # The file alone filled the budget, so the directory was never queried. assert calls == ["src/lib/x.py"] assert out["note_ids"] == [1, 2, 3] @@ -129,7 +149,7 @@ async def test_directory_hits_are_marked_nearby_not_here(): patch.object(pc, "semantic_search_notes", AsyncMock(return_value=[])), \ patch.object(pc, "record_retrieval", MagicMock()), \ patch.object(pc, "owner_names_for", AsyncMock(return_value={})): - out = await pc.build_write_path_hint(1, "src/lib/x.py", code="x") + out = await pc.build_write_path_hint(1, "src/lib/x.py", code=REAL_CODE) assert '#9 [nearby] "sibling helper"' in out["context"] @@ -143,7 +163,7 @@ async def test_a_failing_location_lookup_does_not_sink_the_hint(): AsyncMock(return_value=[(0.80, _note(5, "throttle — …"))])), \ patch.object(pc, "record_retrieval", MagicMock()), \ patch.object(pc, "owner_names_for", AsyncMock(return_value={})): - out = await pc.build_write_path_hint(1, "src/x.py", code="def f(): ...") + out = await pc.build_write_path_hint(1, "src/x.py", code=REAL_CODE) assert out["note_ids"] == [5] @@ -157,7 +177,7 @@ async def test_semantic_arm_is_snippet_only_and_browse_scoped(): patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \ patch.object(pc, "semantic_search_notes", search), \ patch.object(pc, "record_retrieval", MagicMock()): - await pc.build_write_path_hint(1, "src/x.py", code="def f(): ...") + await pc.build_write_path_hint(1, "src/x.py", code=REAL_CODE) kwargs = search.await_args.kwargs # Prior art means snippets — not the dev-log that happens to mention one. assert kwargs["note_type"] == "snippet" @@ -174,7 +194,7 @@ async def test_margin_gate_applies_to_the_semantic_arm(): patch.object(pc, "semantic_search_notes", AsyncMock(return_value=hits)), \ patch.object(pc, "record_retrieval", MagicMock()), \ patch.object(pc, "owner_names_for", AsyncMock(return_value={})): - out = await pc.build_write_path_hint(1, "src/x.py", code="def f(): ...") + out = await pc.build_write_path_hint(1, "src/x.py", code=REAL_CODE) assert out["note_ids"] == [11, 22] assert "#33" not in out["context"] @@ -210,7 +230,7 @@ async def test_place_beats_meaning_and_the_cap_covers_both_arms(): AsyncMock(return_value=[(0.9, _note(7, "scored"))])), \ patch.object(pc, "record_retrieval", MagicMock()), \ patch.object(pc, "owner_names_for", AsyncMock(return_value={})): - out = await pc.build_write_path_hint(1, "src/x.py", code="x") + out = await pc.build_write_path_hint(1, "src/x.py", code=REAL_CODE) # Location hit first; the whole menu stays within top_k. assert out["note_ids"] == [1, 7] assert len(out["note_ids"]) <= 2 @@ -231,7 +251,7 @@ async def test_semantic_arm_only_asks_for_the_budget_the_place_arm_left(): patch.object(pc.snippets_svc, "list_snippets", _listing), \ patch.object(pc, "semantic_search_notes", search), \ patch.object(pc, "record_retrieval", MagicMock()): - await pc.build_write_path_hint(1, "src/x.py", code="x") + await pc.build_write_path_hint(1, "src/x.py", code=REAL_CODE) assert search.await_args.kwargs["limit"] == 2 @@ -244,7 +264,7 @@ async def test_session_dedup_excludes_ids_from_both_arms(): AsyncMock(return_value=([_snippet_item(12, "already shown")], 1))), \ patch.object(pc, "semantic_search_notes", search), \ patch.object(pc, "record_retrieval", MagicMock()): - out = await pc.build_write_path_hint(1, "src/x.py", code="x", exclude_ids=[12]) + out = await pc.build_write_path_hint(1, "src/x.py", code=REAL_CODE, exclude_ids=[12]) # The location hit was already surfaced this session → dropped, not repeated. assert out["note_ids"] == [] assert 12 in search.await_args.kwargs["exclude_ids"] @@ -262,7 +282,7 @@ async def test_another_users_snippet_is_attributed(): patch.object(pc, "semantic_search_notes", AsyncMock(return_value=[])), \ patch.object(pc, "record_retrieval", MagicMock()), \ patch.object(pc, "owner_names_for", AsyncMock(return_value={42: "alex"})): - out = await pc.build_write_path_hint(1, "src/x.py", code="x") + out = await pc.build_write_path_hint(1, "src/x.py", code=REAL_CODE) assert "shared by alex, treat as a suggestion" in out["context"] @@ -277,7 +297,7 @@ async def test_telemetry_uses_its_own_source(): AsyncMock(return_value=[(0.9, _note(7, "scored"))])), \ patch.object(pc, "record_retrieval", rec), \ patch.object(pc, "owner_names_for", AsyncMock(return_value={})): - await pc.build_write_path_hint(1, "src/x.py", code="x", project_id=4) + await pc.build_write_path_hint(1, "src/x.py", code=REAL_CODE, project_id=4) rec.assert_called_once() assert rec.call_args.kwargs["source"] == "write_path" assert rec.call_args.kwargs["source"] != "auto_inject" @@ -285,7 +305,7 @@ async def test_telemetry_uses_its_own_source(): @pytest.mark.asyncio -async def test_config_has_its_own_switch_but_shares_the_gates(): +async def test_config_has_its_own_switch_and_threshold_but_shares_top_k(): from scribe.services import plugin_context as pc stored = {pc.WRITEPATH_ENABLED_KEY: "false"} with patch.object(pc, "get_setting", @@ -293,11 +313,116 @@ async def test_config_has_its_own_switch_but_shares_the_gates(): cfg = await pc.get_writepath_config(1) # Its own switch is off while auto-inject stays on... assert cfg["enabled"] is False - # ...and the gates are auto-inject's, not a second set that could drift. - assert cfg["threshold"] == pc.AUTOINJECT_DEFAULT_THRESHOLD + # ...its threshold is its OWN and stricter than auto-inject's, because code + # embeddings have a higher similarity floor than prose (#2223). Sharing 0.55 + # made unrelated code — including `x = 1` at 0.58 — clear the bar. + assert cfg["threshold"] == pc.WRITEPATH_DEFAULT_THRESHOLD + assert cfg["threshold"] > pc.AUTOINJECT_DEFAULT_THRESHOLD + # ...and top_k is still shared: "how many titles at once" means the same + # thing on both surfaces. assert cfg["top_k"] == pc.AUTOINJECT_DEFAULT_TOP_K +@pytest.mark.asyncio +async def test_writepath_threshold_is_operator_tunable_and_clamped(): + """Rule #25 — it's a product knob, so it has to be settable, and a typo in + the settings box must not be able to open the gate to everything.""" + from scribe.services import plugin_context as pc + + async def _cfg_with(raw): + stored = {pc.WRITEPATH_THRESHOLD_KEY: raw} + with patch.object(pc, "get_setting", + AsyncMock(side_effect=lambda uid, k, d: stored.get(k, d))): + return await pc.get_writepath_config(1) + + assert (await _cfg_with("0.9"))["threshold"] == 0.9 + assert (await _cfg_with("5"))["threshold"] == 1.0 # clamped up + assert (await _cfg_with("-3"))["threshold"] == 0.0 # clamped down + # Garbage falls back to the default rather than to 0.0, which would surface + # every snippet in the corpus on every edit. + assert (await _cfg_with("banana"))["threshold"] == pc.WRITEPATH_DEFAULT_THRESHOLD + + +# --- the minimum-substance floor on the semantic arm (#2223) ------------------ + +@pytest.mark.asyncio +async def test_trivial_payload_skips_the_semantic_arm_entirely(): + """`x = 1` scored 0.58 against an unrelated helper on the live instance — the + embedder's floor for "some code", not a resemblance. A payload too small to + carry meaning must not reach the embedder at all, and must not cost a + retrieval_logs row either.""" + from scribe.services import plugin_context as pc + search, rec = AsyncMock(return_value=[]), MagicMock() + with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_cfg())), \ + patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \ + patch.object(pc, "semantic_search_notes", search), \ + patch.object(pc, "record_retrieval", rec): + out = await pc.build_write_path_hint(1, "src/x.py", code="x = 1") + assert out["context"] == "" and out["note_ids"] == [] + search.assert_not_called() + rec.assert_not_called() + + +@pytest.mark.asyncio +async def test_the_floor_counts_substance_not_indentation(): + """Code is indentation-heavy, so raw length would let a deeply-nested + one-liner through on padding alone.""" + from scribe.services import plugin_context as pc + padded = " " * 400 + "return None" # long, but almost no substance + search = AsyncMock(return_value=[]) + with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_cfg())), \ + patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \ + patch.object(pc, "semantic_search_notes", search), \ + patch.object(pc, "record_retrieval", MagicMock()): + await pc.build_write_path_hint(1, "src/x.py", code=padded) + assert len(padded) > pc.WRITEPATH_MIN_CODE_CHARS # would pass a raw-length check + search.assert_not_called() + + +@pytest.mark.asyncio +async def test_the_floor_does_not_block_the_place_arm(): + """The floor is a statement about the semantic query, not about the edit. A + snippet recorded at this exact path is prior art however small the change.""" + from scribe.services import plugin_context as pc + search = AsyncMock(return_value=[]) + with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_cfg())), \ + patch.object(pc.snippets_svc, "list_snippets", + AsyncMock(return_value=([_snippet_item(12, "here helper")], 1))), \ + patch.object(pc, "semantic_search_notes", search), \ + patch.object(pc, "record_retrieval", MagicMock()), \ + patch.object(pc, "owner_names_for", AsyncMock(return_value={})): + out = await pc.build_write_path_hint(1, "src/x.py", code="x = 1") + assert out["note_ids"] == [12] + search.assert_not_called() + + +@pytest.mark.asyncio +async def test_a_real_helper_clears_the_floor(): + """The floor errs toward keeping recall — anything that plausibly IS a + reusable helper has to get through, or the feature stops working.""" + from scribe.services import plugin_context as pc + search = AsyncMock(return_value=[(0.80, _note(5, "debounce — …"))]) + with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_cfg())), \ + patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \ + patch.object(pc, "semantic_search_notes", search), \ + patch.object(pc, "record_retrieval", MagicMock()), \ + patch.object(pc, "owner_names_for", AsyncMock(return_value={})): + out = await pc.build_write_path_hint(1, "src/x.py", code=REAL_CODE) + assert out["note_ids"] == [5] + search.assert_called_once() + + +def test_the_floor_stays_below_the_smallest_plausible_helper(): + """A guard on the constant itself: raising it far enough to reject a real + one-line helper would silently gut the arm rather than sharpen it.""" + from scribe.services import plugin_context as pc + smallest_real_helper = 'def slug(t): return re.sub(r"[^a-z0-9]+", "-", t.lower()).strip("-")' + substance = len("".join(smallest_real_helper.split())) + assert pc.WRITEPATH_MIN_CODE_CHARS < substance + # And still strictly above a degenerate one-liner. + assert pc.WRITEPATH_MIN_CODE_CHARS > len("".join("x = 1".split())) + + # --- the route between them -------------------------------------------------- def test_route_reads_every_arg_the_hook_sends():