feat(retrieval): the operator can see what was tuned, and every write is recorded (#4102)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m30s
CI & Build / Build & push image (push) Canceled after 31s

The other half of the bargain in milestone 416 step 4. The model moves
these dials; this is what makes that reviewable rather than merely
automatic.

THE HOLE THIS CLOSES

Every retrieval floor is an ordinary settings key, and `/api/settings`
accepts any key at all. A floor written through it landed correctly and
recorded nothing — a tuning history with holes in it, which is worse
than no history because it reads as complete.

So the generic endpoint now routes registry-owned keys through
`set_dial` instead of writing them as plain rows. ROUTED, not refused:
refusing would only work for callers that had been updated, while this
way the form, a script, and an old client all leave the trail, and
there is no version of "forgot to use the other endpoint". Clearing a
control is written as an explicit set back to the shipped default,
because the operator reverting something is the single most important
move this history can record.

`set_dial` now also refuses to record a no-op. The Settings form
re-sends every field on every save, so without that one press of Save
would write six rows saying the operator set six dials to the numbers
they were already on — and a history nobody can skim is one nobody
reads.

WHAT THE OPERATOR GETS

`/api/retrieval/surfaces`, `/surfaces/<name>` and `/tuning-history`,
with `actor` fixed server-side rather than taken from the payload: a
payload-supplied actor would let a model claim to be the operator, and
"did I do this, or did the session?" is the first question this list
is asked.

In Settings: the five missing BUDGETS (until now only auto-inject had
one, so the only control over a noisy surface was to raise its bar —
which discards that surface's best candidates along with its worst),
and a "What has been tuned" panel showing each change, who made it, and
the reason given. The operator's own changes are marked.

The MCP tool demands a reason; these endpoints do not. That asymmetry
is deliberate and stated in routes/retrieval.py: the requirement exists
to make the MODEL read the records before moving a number on someone
else's behalf, and the operator is that someone — a mandatory
justification box on every control would be friction charged to the one
participant who owes no explanation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-17 11:36:22 -04:00
co-authored by Claude Opus 5
parent 25bd6742e0
commit 6240652dce
7 changed files with 582 additions and 0 deletions
+267
View File
@@ -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<TuningEvent[]>([]);
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.
</p>
</div>
<div class="field">
<label for="kb-writepath-topk">Max prior-art lines per edit</label>
<input
id="kb-writepath-topk"
v-model="kbWritePathTopK"
type="number"
min="1"
max="10"
step="1"
class="fs-input input"
style="max-width: 8rem"
/>
<p class="field-hint">How many snippets and issues one edit may be offered (110). 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.</p>
</div>
<div class="field">
<label for="kb-rulehint-threshold">Standing-rule confidence threshold (01)</label>
<input
@@ -1539,6 +1648,20 @@ async function deleteUser(userId: number) {
arriving unread; lower it if a rule you needed never showed up.
</p>
</div>
<div class="field">
<label for="kb-rulehint-topk">Max rules per edit</label>
<input
id="kb-rulehint-topk"
v-model="kbRuleHintTopK"
type="number"
min="1"
max="10"
step="1"
class="fs-input input"
style="max-width: 8rem"
/>
<p class="field-hint">How many standing rules one edit may be shown (110).</p>
</div>
<div class="field">
<label for="kb-toolrule-threshold">Command confidence threshold (01)</label>
<input
@@ -1560,6 +1683,20 @@ async function deleteUser(userId: number) {
apply; lower it if a <code>git push</code> arrives with nothing.
</p>
</div>
<div class="field">
<label for="kb-toolrule-topk">Max rules per command</label>
<input
id="kb-toolrule-topk"
v-model="kbToolRuleTopK"
type="number"
min="1"
max="10"
step="1"
class="fs-input input"
style="max-width: 8rem"
/>
<p class="field-hint">How many standing rules one command may be shown (110). This is the busiest arm there is — it fires before every command — so its budget is the one most worth keeping small.</p>
</div>
<div class="field">
<label for="kb-promptrule-threshold">Prompt confidence threshold (01)</label>
<input
@@ -1580,6 +1717,20 @@ async function deleteUser(userId: number) {
not a command or a file — which is why it carries its own number.
</p>
</div>
<div class="field">
<label for="kb-promptrule-topk">Max rules per prompt</label>
<input
id="kb-promptrule-topk"
v-model="kbPromptRuleTopK"
type="number"
min="1"
max="10"
step="1"
class="fs-input input"
style="max-width: 8rem"
/>
<p class="field-hint">How many rules or preferences one message may be shown (110).</p>
</div>
<div class="field">
<label for="kb-reportpref-threshold">Completion-report confidence threshold (01)</label>
<input
@@ -1602,6 +1753,61 @@ async function deleteUser(userId: number) {
will reveal the problem on its own.
</p>
</div>
<div class="field">
<label for="kb-reportpref-topk">Max report preferences</label>
<input
id="kb-reportpref-topk"
v-model="kbReportPrefTopK"
type="number"
min="1"
max="10"
step="1"
class="fs-input input"
style="max-width: 8rem"
/>
<p class="field-hint">How many preferences a finished task may be shown (110).</p>
</div>
<!-- THE REVIEW SURFACE (#4102). These numbers are maintained by the
model that uses them: it reads which records each bar refused and
moves the dial with the argument attached. This panel is the other
half of that bargain — a change made on your behalf is one you can
read, disagree with, and set back by hand above. -->
<div class="tuning-history">
<h4 class="tuning-history-title">What has been tuned</h4>
<p class="field-hint">
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.
</p>
<p v-if="loadingTuning" class="field-hint">Loading…</p>
<p v-else-if="!tuningEvents.length" class="field-hint">
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.
</p>
<ul v-else class="tuning-list">
<li v-for="ev in tuningEvents" :key="ev.id" class="tuning-item">
<div class="tuning-item-head">
<span class="tuning-surface">{{ ev.surface }}</span>
<span class="tuning-dial">{{ ev.dial }}</span>
<span class="tuning-move">
<template v-if="ev.old_value !== null">{{ ev.old_value }} →</template>
<template v-else>set to</template>
{{ ev.new_value }}
</span>
<span class="tuning-actor" :class="{ 'is-human': ev.actor === 'human' }">
{{ ev.actor === 'human' ? 'you' : 'Claude' }}
</span>
<span v-if="ev.created_at" class="tuning-when">{{ fmtDate(ev.created_at) }}</span>
</div>
<p class="tuning-reason">{{ ev.reason }}</p>
</li>
</ul>
</div>
<!-- A design system belongs to a PROJECT, and the picker for it lives on
the project. There was a setting here that designated the system
this install's own interface was built from; it only ever described
@@ -3848,4 +4054,65 @@ async function deleteUser(userId: number) {
.area-admin-form { display: flex; flex-direction: column; gap: 0.5rem; flex: 1; }
.area-admin-create { margin-top: var(--fs-space-4); }
.area-admin-actions { display: flex; gap: 0.4rem; }
/* The retrieval tuning trail (#4102). Reads as a record, not a control panel:
the operator is reviewing what was done, and the reason is the part worth
reading, so it gets the full-width line under a compact header row. */
.tuning-history {
margin-top: var(--fs-space-5);
padding-top: var(--fs-space-4);
border-top: 1px solid var(--fs-border-color);
}
.tuning-history-title {
margin: 0 0 var(--fs-space-2);
font-size: 0.95rem;
color: var(--fs-text-primary);
}
.tuning-list {
list-style: none;
margin: var(--fs-space-3) 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--fs-space-2);
}
.tuning-item {
padding: var(--fs-space-3);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-md);
}
.tuning-item-head {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--fs-space-2);
font-size: 0.85rem;
}
.tuning-surface {
font-family: var(--fs-font-mono);
color: var(--fs-text-primary);
}
.tuning-dial,
.tuning-move {
color: var(--fs-text-secondary);
}
.tuning-actor {
color: var(--fs-text-tertiary);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
padding: 0 0.4rem;
}
/* The operator's own changes are marked, because "did I do this, or did the
session?" is the first question this list is asked. */
.tuning-actor.is-human {
color: var(--fs-accent);
border-color: var(--fs-accent);
}
.tuning-when { margin-left: auto; color: var(--fs-text-tertiary); }
.tuning-reason {
margin: var(--fs-space-2) 0 0;
font-size: 0.85rem;
color: var(--fs-text-secondary);
}
</style>