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.
+