Every ledger is cleared on a compact, and a retrieval floor becomes something the model maintains #163

Merged
bvandeusen merged 12 commits from dev into main 2026-09-17 12:01:42 -04:00
7 changed files with 582 additions and 0 deletions
Showing only changes of commit 6240652dce - Show all commits
+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>
+2
View File
@@ -14,6 +14,7 @@ from scribe.routes.notes import notes_bp
from scribe.routes.milestones import milestones_bp
from scribe.routes.task_logs import task_logs_bp
from scribe.routes.projects import projects_bp
from scribe.routes.retrieval import retrieval_bp
from scribe.routes.settings import settings_bp
from scribe.routes.tasks import tasks_bp
from scribe.routes.groups import groups_bp
@@ -79,6 +80,7 @@ def create_app() -> Quart:
app.register_blueprint(milestones_bp)
app.register_blueprint(notes_bp)
app.register_blueprint(projects_bp)
app.register_blueprint(retrieval_bp)
app.register_blueprint(settings_bp)
app.register_blueprint(task_logs_bp)
app.register_blueprint(tasks_bp)
+106
View File
@@ -0,0 +1,106 @@
"""The operator's view of retrieval tuning — what the dials are, and who moved them (#4102).
WHY THIS EXISTS SEPARATELY FROM /api/settings
The numbers themselves are ordinary settings rows and could be written through
the generic KV endpoint. They must not be, and that is the whole point of this
blueprint: a floor changed through `/api/settings` moves silently, leaving the
history saying nothing happened. From milestone 416 those dials are moved by
the model on the operator's behalf, so a trail with holes in it is worse than
no trail — it reads as complete.
So every change to a retrieval dial goes through `set_dial`, from the UI as
much as from the MCP tool, and the only difference is the `actor` recorded.
"""
import logging
from quart import Blueprint, jsonify, request
from scribe.auth import get_current_user_id, login_required
from scribe.services.retrieval_tuning import set_dial, current_settings, tuning_history
logger = logging.getLogger(__name__)
retrieval_bp = Blueprint("retrieval", __name__, url_prefix="/api/retrieval")
# What is recorded when the operator changes a dial in Settings and says
# nothing about why.
#
# The MCP tool REFUSES a blank reason, and this endpoint does not, which is a
# deliberate asymmetry rather than an oversight. The requirement exists to make
# the model look at the records before it moves a number on someone else's
# behalf. The operator IS that someone: they are the audience the trail is
# written for, they cannot be uninformed about their own decision, and a
# mandatory justification textarea on every control would be friction charged
# to the one participant who owes no explanation (rule 24).
#
# The event is still written, because "the operator set this by hand" is the
# single most useful thing the history can tell a later session — it is the one
# entry the model must not quietly tune back.
_OPERATOR_DEFAULT_REASON = "Set directly in Settings by the operator."
@retrieval_bp.route("/surfaces", methods=["GET"])
@login_required
async def get_surfaces_route():
"""Every tunable surface: its live floor and budget, what it asks and over
what, and the reason each dial was last moved."""
uid = get_current_user_id()
surfaces = await current_settings(uid)
return jsonify({"surfaces": surfaces, "total": len(surfaces)})
@retrieval_bp.route("/surfaces/<surface>", methods=["PUT"])
@login_required
async def tune_surface_route(surface: str):
"""Move one dial, recorded as a human change.
`actor` is fixed here rather than taken from the payload: this endpoint is
reached with a session cookie from the Settings form, so the actor is known
and accepting a claim about it would let the one field a reviewer relies on
be set to anything.
"""
uid = get_current_user_id()
data = await request.get_json()
if not isinstance(data, dict):
return jsonify({"error": "Expected a JSON object"}), 400
if "dial" not in data or "value" not in data:
return jsonify({"error": "dial and value are required"}), 400
try:
value = float(data["value"])
except (TypeError, ValueError):
return jsonify({"error": f"value must be a number, got {data['value']!r}"}), 400
reason = str(data.get("reason") or "").strip() or _OPERATOR_DEFAULT_REASON
try:
result = await set_dial(
uid, surface, str(data["dial"]), value, reason=reason, actor="human",
)
except ValueError as e:
# Unknown surface, unknown dial — the service names the alternatives,
# so the message is worth passing through rather than flattening.
return jsonify({"error": str(e)}), 400
return jsonify(result)
@retrieval_bp.route("/tuning-history", methods=["GET"])
@login_required
async def tuning_history_route():
"""What has been changed about retrieval on this install, newest first.
The review surface. Unscoped by default because the operator's question
here is "what has been done on my behalf", not "why is this one arm set
like that" — the per-surface scoping is for the model.
"""
uid = get_current_user_id()
surface = request.args.get("surface") or None
try:
limit = int(request.args.get("limit", 50))
except ValueError:
return jsonify({"error": "limit must be a whole number"}), 400
try:
events = await tuning_history(uid, surface=surface, limit=limit)
except ValueError as e:
return jsonify({"error": str(e)}), 400
return jsonify({"events": events, "total": len(events)})
+36
View File
@@ -9,6 +9,8 @@ from quart import Blueprint, jsonify, request
from scribe.auth import login_required, get_current_user_id
from scribe.config import Config
from scribe.services.retrieval_surfaces import dial_for_key, get_surface
from scribe.services.retrieval_tuning import set_dial
from scribe.services.settings import (
SECRET_MASK, delete_setting, get_all_settings, get_setting, set_settings_batch,
)
@@ -25,6 +27,13 @@ settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
# (forge_token left with 0078: forge credentials are keyring rows now, #2778.)
_SECRET_KEYS = frozenset({"smtp_password", "forge_webhook_secret"})
# What the tuning history records for a dial changed through this form. The MCP
# tool refuses a blank reason; the operator is not asked for one, because 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. See
# routes/retrieval.py, which states the asymmetry in full.
_SETTINGS_FORM_REASON = "Changed in Settings by the operator."
def _masked(settings: dict) -> dict:
return {
@@ -52,6 +61,33 @@ async def update_settings_route():
to_save = {}
for k, v in data.items():
str_v = str(v)
# A retrieval dial is never written as a plain settings row, wherever
# the request came from (#4102). The value would land correctly and the
# tuning history would say nothing happened — and since milestone 416
# those dials are moved by the model on the operator's behalf, a
# history with holes in it is worse than none: it reads as complete.
#
# Routed rather than refused on purpose. Refusing would work only for
# callers that had been updated; this way every caller that ever writes
# one of these keys — this form, a script, an old client — leaves the
# trail, and there is no version of "forgot to use the other endpoint".
dial = dial_for_key(k)
if dial:
surface, which = dial
s = get_surface(surface)
# A CLEARED control means "back to the shipped starting point", and
# that is a change like any other — it is the operator reverting
# something, which is the single most important move this history
# can record. So it is written as an explicit set to the default
# rather than deleted, which would leave the same value behind and
# no record of anyone having chosen it.
default = s.floor_default if which == "floor" else s.budget_default
try:
await set_dial(uid, surface, which, float(str_v or default),
reason=_SETTINGS_FORM_REASON, actor="human")
except (TypeError, ValueError) as e:
return jsonify({"error": f"{k}: {e}"}), 400
continue
# A masked secret round-tripping through a client is "unchanged", not
# a request to store the mask over the real credential.
if k in _SECRET_KEYS and str_v == SECRET_MASK:
+20
View File
@@ -213,6 +213,26 @@ def get_surface(name: str) -> Surface:
) from None
def dial_for_key(key: str) -> tuple[str, str] | None:
"""Which `(surface, dial)` a settings key belongs to, or None.
The registry read backwards, and it exists for one caller: the generic
`/api/settings` endpoint, which accepts any key at all. Without this, a
floor written through that endpoint moves with no event recorded, and the
tuning history says nothing happened — a trail with holes in it, which is
worse than no trail because it reads as complete.
Derived rather than listed so a seventh surface is covered the moment it is
added here, which is the only way this stays true.
"""
for surface in SURFACES.values():
if key == surface.floor_key:
return (surface.name, "floor")
if key == surface.budget_key:
return (surface.name, "budget")
return None
async def floor_for(user_id: int, name: str) -> float:
"""This install's current floor for a surface, clamped to [0, 1]."""
s = get_surface(name)
+18
View File
@@ -162,6 +162,21 @@ async def set_dial(
applied = float(min(MAX_BUDGET, max(1, int(float(value)))))
key, stored = s.budget_key, str(int(applied))
# A change to the value it already has is not a change, and must not be
# written. The Settings form re-sends every field on every save, so without
# this the history fills with rows saying the operator set six dials to the
# numbers they were already on — and a history nobody can skim is one
# nobody reads, which costs the surface its entire purpose.
#
# Reported rather than silently skipped, so a caller that expected to move
# something learns that it did not.
if abs(applied - old) < 1e-9:
return {
"surface": surface, "dial": dial, "previous": old,
"applied": applied, "clamped": abs(applied - float(value)) > 1e-9,
"reason": text, "actor": actor, "unchanged": True,
}
await set_setting(user_id, key, stored)
async with async_session() as session:
session.add(RetrievalTuningEvent(
@@ -181,6 +196,9 @@ async def set_dial(
"clamped": abs(applied - float(value)) > 1e-9,
"reason": text,
"actor": actor,
# Always present, both ways round: a caller that has to test for the
# key's absence to learn the answer will eventually forget to.
"unchanged": False,
}
+133
View File
@@ -0,0 +1,133 @@
"""The operator's half of the tuning surface (#4102).
WHY THIS EXISTS
The model moves these dials — that is the operator's decision for milestone
416 — so the browser's job is no longer "set the number". It is to show what
was set, by whom, and on what argument, and to let the operator disagree.
The load-bearing guard here is the last one. `/api/settings` is a generic
key-value endpoint that accepts any key at all, and every retrieval floor IS an
ordinary settings key. A floor written straight through it would land correctly
and record nothing — a tuning history with holes in it, which is worse than no
history because it reads as complete. So the generic endpoint routes those keys
through `set_dial`, and that routing is asserted rather than remembered.
"""
import inspect
import pytest
def test_retrieval_blueprint_registered():
from scribe.routes.retrieval import retrieval_bp
assert retrieval_bp.name == "retrieval"
assert retrieval_bp.url_prefix == "/api/retrieval"
def test_retrieval_blueprint_registered_in_app():
from scribe.app import create_app
app = create_app()
assert "retrieval" in app.blueprints
def test_every_endpoint_is_reachable_on_the_app():
"""Handlers existing is not the same as them being routed."""
from scribe.app import create_app
app = create_app()
rules = {
str(r.rule) for r in app.url_map.iter_rules()
if r.endpoint.startswith("retrieval.")
}
assert rules == {
"/api/retrieval/surfaces",
"/api/retrieval/surfaces/<surface>",
"/api/retrieval/tuning-history",
}
def test_the_browser_and_the_agent_call_the_same_service():
"""Rule 33 parity. Two callers, one service — or the two surfaces drift and
the guard that a reason is required exists on only one of them."""
from scribe.mcp.tools import retrieval_tuning as tool
from scribe.routes import retrieval as routes
from scribe.services import retrieval_tuning as svc
assert routes.set_dial is svc.set_dial
assert tool.tuning_svc is svc
def test_the_route_does_not_take_the_actor_from_the_caller():
"""`actor` is the one field a reviewer leans on to answer "did I do this,
or did the session?". A payload-supplied actor would let a model claim to
be the operator, which turns the column into decoration."""
from scribe.routes import retrieval as routes
src = inspect.getsource(routes.tune_surface_route)
assert 'actor="human"' in src
assert 'data.get("actor"' not in src and 'data["actor"]' not in src
# ── the hole the generic settings endpoint would otherwise leave ────────────
def test_every_registry_key_is_recognised_as_a_dial():
"""Derived from the registry, both directions. A seventh surface added
without a row here is a floor that can be written silently again."""
from scribe.services.retrieval_surfaces import SURFACES, dial_for_key
for name, surface in SURFACES.items():
assert dial_for_key(surface.floor_key) == (name, "floor")
assert dial_for_key(surface.budget_key) == (name, "budget")
def test_an_ordinary_setting_is_not_mistaken_for_a_dial():
"""The interception must be narrow. A false positive here would send an
unrelated setting through a service that parses it as a float and rejects
the save."""
from scribe.services.retrieval_surfaces import dial_for_key
for key in ("smtp_password", "kb_autoinject_enabled", "theme",
"kb_planmatch_threshold", ""):
assert dial_for_key(key) is None, key
def test_the_settings_endpoint_routes_a_dial_through_the_recorder():
"""THE GUARD. Asserted on the source because the alternative is a full
request-context round trip for a branch whose whole content is "which
function gets called" — and because what must not regress is precisely
that this module reaches for `set_dial` at all."""
from scribe.routes import settings as routes
src = inspect.getsource(routes.update_settings_route)
assert "dial_for_key" in src, (
"the generic settings endpoint no longer recognises retrieval dials — "
"a floor written through it now moves with no event recorded, and the "
"tuning history will say nothing happened"
)
assert "set_dial" in src
assert 'actor="human"' in src
@pytest.mark.asyncio
async def test_setting_a_dial_to_the_value_it_already_has_records_nothing():
"""The Settings form re-sends every field on every save. Without this, one
press of Save writes 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."""
from unittest.mock import AsyncMock, MagicMock, patch
from scribe.services import retrieval_tuning as rt
from tests.helpers import make_mock_session
session = make_mock_session()
with patch.object(rt, "async_session", MagicMock(return_value=session)), \
patch.object(rt, "set_setting", AsyncMock()) as setter, \
patch.object(rt, "floor_for", AsyncMock(return_value=0.72)), \
patch.object(rt, "budget_for", AsyncMock(return_value=3)):
out = await rt.set_dial(1, "prompt_rule", "floor", 0.72,
reason="re-saved the settings form untouched")
assert out["unchanged"] is True
session.add.assert_not_called()
# And the setting is left alone too: rewriting the same value would bump
# whatever timestamp the row carries for no reason.
setter.assert_not_called()