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
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:
@@ -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 (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.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="kb-rulehint-threshold">Standing-rule confidence threshold (0–1)</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 (1–10).</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="kb-toolrule-threshold">Command confidence threshold (0–1)</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 (1–10). 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 (0–1)</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 (1–10).</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="kb-reportpref-threshold">Completion-report confidence threshold (0–1)</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 (1–10).</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>
|
||||
|
||||
Reference in New Issue
Block a user