diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 5ecaa00..79920f2 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -97,6 +97,20 @@ const kbToolRuleThreshold = ref("0.68"); // prose rather than anything a tool produced (#3852). const kbPromptRuleThreshold = ref("0.72"); const kbReportPrefThreshold = ref("0.72"); +// The BUDGETS, one per arm (#4102). Until this step only auto-inject had one +// and every other arm's ceiling was a module constant nobody could reach — so +// the only control an operator had over a noisy surface was to raise its bar, +// which discards the surface's best candidates along with its worst. A budget +// keeps the top of the ranking and drops the tail, which is what was wanted. +const kbWritePathTopK = ref("3"); +const kbRuleHintTopK = ref("5"); +const kbToolRuleTopK = ref("5"); +const kbPromptRuleTopK = ref("3"); +const kbReportPrefTopK = ref("3"); +// What has been changed about retrieval, newest first — the review surface for +// changes the model made on the operator's behalf (#4102). +const tuningEvents = ref([]); +const loadingTuning = ref(false); // Near-duplicate report floors, one per record kind (services/dedup.py). // Snippets are single-chunk, so their floor sits below the 0.90 write-time // gate and catches what it lets through. Notes/tasks are scored at chunk @@ -149,6 +163,37 @@ async function saveRetention() { } } +// One entry in the retrieval tuning trail (#4102). Mirrors +// RetrievalTuningEvent.to_dict() — `old_value` is null for the first change to +// a surface, which is a different event from moving off a value somebody chose +// and renders differently below. +interface TuningEvent { + id: number; + created_at: string | null; + surface: string; + dial: string; + old_value: number | null; + new_value: number; + actor: string; + reason: string; +} + +async function loadTuningHistory() { + loadingTuning.value = true; + try { + const res = await apiGet<{ events: TuningEvent[] }>( + "/api/retrieval/tuning-history?limit=25", + ); + tuningEvents.value = res.events ?? []; + } catch { + // A history that cannot be read is not worth a toast on page load — the + // panel says so itself, and the settings above are still usable. + tuningEvents.value = []; + } finally { + loadingTuning.value = false; + } +} + async function saveKbInject() { const t = Math.min(1, Math.max(0, Number(kbInjectThreshold.value) || 0)); const k = Math.min(10, Math.max(1, Math.floor(Number(kbInjectTopK.value) || 1))); @@ -173,6 +218,22 @@ async function saveKbInject() { const trT = Math.min(1, Math.max(0, Number(kbToolRuleThreshold.value) || 0.68)); const prT = Math.min(1, Math.max(0, Number(kbPromptRuleThreshold.value) || 0.72)); const rpT = Math.min(1, Math.max(0, Number(kbReportPrefThreshold.value) || 0.72)); + // The budgets, clamped the way the server clamps them: a whole number in + // [1, 10]. Never 0 — an arm turned off is turned off by its switch, and a + // budget of zero would run the search, log the retrieval and render nothing, + // which reads in the telemetry exactly like a bar nothing cleared. + const asK = (v: string, d: number) => + Math.min(10, Math.max(1, Math.floor(Number(v) || d))); + const wpK = asK(kbWritePathTopK.value, 3); + const rhK = asK(kbRuleHintTopK.value, 5); + const trK = asK(kbToolRuleTopK.value, 5); + const prK = asK(kbPromptRuleTopK.value, 3); + const rpK = asK(kbReportPrefTopK.value, 3); + kbWritePathTopK.value = String(wpK); + kbRuleHintTopK.value = String(rhK); + kbToolRuleTopK.value = String(trK); + kbPromptRuleTopK.value = String(prK); + kbReportPrefTopK.value = String(rpK); kbInjectThreshold.value = String(t); kbInjectTopK.value = String(k); kbDupThresholdSnippet.value = String(dupSnip); @@ -211,6 +272,15 @@ async function saveKbInject() { // and a constant that lands under the bar is a dead arm, not a quiet // one (#3860). kb_reportpref_threshold: String(rpT), + // The budgets. Every one of these keys is recognised by the server as a + // retrieval dial, so this save is recorded in the tuning history as a + // change the OPERATOR made — which is the one entry the model must not + // quietly tune back. + kb_writepath_top_k: String(wpK), + kb_rulehint_top_k: String(rhK), + kb_toolrule_top_k: String(trK), + kb_promptrule_top_k: String(prK), + kb_reportpref_top_k: String(rpK), kb_duplicate_threshold_snippet: String(dupSnip), kb_duplicate_threshold_note: String(dupNote), kb_duplicate_threshold_task: String(dupTask), @@ -218,6 +288,10 @@ async function saveKbInject() { }); kbInjectSaved.value = true; setTimeout(() => (kbInjectSaved.value = false), 2000); + // The save just appended to the history it sits above, so re-read it — + // otherwise the panel shows a trail that is stale by exactly the change + // the operator is looking at it to confirm. + await loadTuningHistory(); } catch { toastStore.show('Failed to save auto-inject settings', 'error'); } finally { @@ -672,6 +746,27 @@ onMounted(async () => { if (allSettings.kb_writepath_threshold !== undefined) { kbWritePathThreshold.value = allSettings.kb_writepath_threshold; } + if (allSettings.kb_writepath_top_k !== undefined) { + kbWritePathTopK.value = allSettings.kb_writepath_top_k; + } else if (allSettings.kb_autoinject_top_k !== undefined) { + // The write path shared auto-inject's ceiling until it was given its own + // key, so an install that tuned the shared one must keep seeing that value + // here — the server falls back the same way. + kbWritePathTopK.value = allSettings.kb_autoinject_top_k; + } + if (allSettings.kb_rulehint_top_k !== undefined) { + kbRuleHintTopK.value = allSettings.kb_rulehint_top_k; + } + if (allSettings.kb_toolrule_top_k !== undefined) { + kbToolRuleTopK.value = allSettings.kb_toolrule_top_k; + } + if (allSettings.kb_promptrule_top_k !== undefined) { + kbPromptRuleTopK.value = allSettings.kb_promptrule_top_k; + } + if (allSettings.kb_reportpref_top_k !== undefined) { + kbReportPrefTopK.value = allSettings.kb_reportpref_top_k; + } + await loadTuningHistory(); if (allSettings.kb_duplicate_threshold_snippet !== undefined) { kbDupThresholdSnippet.value = allSettings.kb_duplicate_threshold_snippet; } @@ -1517,6 +1612,20 @@ async function deleteUser(userId: number) { location, not by resemblance.

+
+ + +

How many snippets and issues one edit may be offered (1–10). Reach for this rather than the threshold when the hint feels long: lowering it keeps the best matches and drops the tail, while raising the bar above throws away good matches along with weak ones.

+
+
+ + +

How many standing rules one edit may be shown (1–10).

+
git push arrives with nothing.

+
+ + +

How many standing rules one command may be shown (1–10). This is the busiest arm there is — it fires before every command — so its budget is the one most worth keeping small.

+
+
+ + +

How many rules or preferences one message may be shown (1–10).

+
+
+ + +

How many preferences a finished task may be shown (1–10).

+
+ +
+

What has been tuned

+

+ Claude adjusts the bars and budgets above from what each arm actually + surfaced and refused, and has to state a reason to change one. Nothing + here needs your attention as a matter of course — it is here so that + when a surface behaves oddly, why it is set the way it is can be read + rather than guessed at. Anything you change yourself is recorded the + same way, and is the one entry Claude will not quietly move back. +

+

Loading…

+

+ Nothing has been changed yet — every surface is on the value Scribe + shipped. Those are starting points measured against one corpus with + one embedding model, not answers, so expect this to fill. +

+ +
+