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
227 lines
9.1 KiB
Python
227 lines
9.1 KiB
Python
"""Moving a retrieval surface's floor or budget, with the argument attached (#4102).
|
|
|
|
WHY THIS EXISTS
|
|
|
|
The operator's decision for milestone 416 step 4:
|
|
|
|
"the floor should be chosen and adjusted by the model using it. we've come
|
|
back to something either fails or has to be looked at by the user we need a
|
|
model consistent surface for the adjustment of these floor values. the user
|
|
should be able to touch it but the model should be the thing handling it 9
|
|
times out of 10."
|
|
|
|
`retrieval_surfaces` made the six arms describe their numbers the same way.
|
|
This module is the write half: one call that moves one dial on one surface,
|
|
records what it was, what it became, who moved it and why, and refuses to do
|
|
any of that without a reason.
|
|
|
|
WHY A REASON IS REQUIRED
|
|
|
|
Because the alternative was tried and measured. The milestone originally listed
|
|
self-tuning as a non-goal on the strength of one case: `report_preference`
|
|
logged 69 consecutive declines with the refused record 0.0006 under the bar, and
|
|
every percentile in the readout said "lower it". Reading the refused record
|
|
showed it was rule 77 "Extract intent from loose phrasing" — a false positive —
|
|
so lowering the bar would have attached that rule to every completion report
|
|
ever written. The statistic and the correct action pointed in opposite
|
|
directions.
|
|
|
|
What separated them was opening the record. A required `reason` is the cheapest
|
|
mechanism that makes that step happen: a caller who must write down why has to
|
|
have looked, and a caller who writes down something wrong has left the operator
|
|
a sentence to disagree with. A number moved silently leaves nothing.
|
|
|
|
WHAT THIS DELIBERATELY DOES NOT DO
|
|
|
|
It does not decide anything itself. There is no rule here that reads a
|
|
percentile and picks a value, and that absence is the design — the non-goal that
|
|
survived is *statistical* auto-tuning, precisely because the statistic was the
|
|
thing that was wrong. The judgement stays with the reader; this module only
|
|
makes the judgement recordable and reversible.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from sqlalchemy import select
|
|
|
|
from scribe.models import async_session
|
|
from scribe.models.retrieval_tuning import RetrievalTuningEvent
|
|
from scribe.services.retrieval_surfaces import (
|
|
MAX_BUDGET,
|
|
budget_for,
|
|
floor_for,
|
|
get_surface,
|
|
surface_names,
|
|
)
|
|
from scribe.services.settings import set_setting
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DIALS = ("floor", "budget")
|
|
|
|
# Long enough to say what was read and what it showed; short enough that nobody
|
|
# pastes a telemetry dump in. The number is not a measurement — it is the point
|
|
# at which "0.66" stops being an acceptable answer to "why".
|
|
_MIN_REASON_CHARS = 20
|
|
|
|
|
|
def _clean_reason(reason: str) -> str:
|
|
"""The guardrail, enforced here rather than by the column.
|
|
|
|
`reason` is NOT NULL in the schema, which "" satisfies. A required field
|
|
that accepts an empty string is a formality, and this one is the whole
|
|
mechanism — see the module docstring.
|
|
"""
|
|
text = (reason or "").strip()
|
|
if len(text) < _MIN_REASON_CHARS:
|
|
raise ValueError(
|
|
"reason is required, and has to say what you read. A floor moved "
|
|
"without a stated basis is a number nobody can review or revert. "
|
|
"Name the evidence: which surface's telemetry, and what the refused "
|
|
"records actually were — `retrieval_telemetry(near_miss_samples=N)` "
|
|
"returns them by id, and reading them is the step that separates a "
|
|
"real miss from a bar doing its job."
|
|
)
|
|
return text
|
|
|
|
|
|
async def current_settings(user_id: int) -> list[dict]:
|
|
"""Every tunable surface with its live pair and its last stated reason.
|
|
|
|
The read side of the tuning surface, and shaped for a reader who is about to
|
|
change something: the value, what the arm asks and over what corpus and how
|
|
often — because a floor cannot be moved sensibly without those three — and
|
|
the reason last given, so the next change argues with the last one instead
|
|
of overwriting it blind.
|
|
"""
|
|
out: list[dict] = []
|
|
async with async_session() as session:
|
|
for name in surface_names():
|
|
s = get_surface(name)
|
|
rows = (
|
|
await session.execute(
|
|
select(RetrievalTuningEvent)
|
|
.where(
|
|
RetrievalTuningEvent.surface == name,
|
|
RetrievalTuningEvent.user_id == user_id,
|
|
)
|
|
.order_by(RetrievalTuningEvent.created_at.desc())
|
|
.limit(len(DIALS))
|
|
)
|
|
).scalars().all()
|
|
last = {r.dial: r for r in rows}
|
|
out.append({
|
|
"surface": name,
|
|
"floor": await floor_for(user_id, name),
|
|
"budget": await budget_for(user_id, name),
|
|
"floor_default": s.floor_default,
|
|
"budget_default": s.budget_default,
|
|
"asks": s.asks,
|
|
"over": s.over,
|
|
"fires": s.fires,
|
|
# Absent rather than empty when a surface has never been moved:
|
|
# "still on the shipped starting point" is a real state and
|
|
# should not render as a blank reason somebody wrote.
|
|
"last_change": {
|
|
dial: last[dial].to_dict() for dial in DIALS if dial in last
|
|
},
|
|
})
|
|
return out
|
|
|
|
|
|
async def set_dial(
|
|
user_id: int,
|
|
surface: str,
|
|
dial: str,
|
|
value: float,
|
|
*,
|
|
reason: str,
|
|
actor: str = "model",
|
|
) -> dict:
|
|
"""Move one dial on one surface, recording the change and its argument.
|
|
|
|
Returns the applied value alongside the previous one, so a caller can see
|
|
that a clamp bit rather than assuming the number it sent is the number in
|
|
force — the failure being avoided is a tool reporting success for a value
|
|
the registry silently corrected.
|
|
"""
|
|
s = get_surface(surface) # refuses an unknown name
|
|
if dial not in DIALS:
|
|
raise ValueError(f"dial must be one of {DIALS}, got {dial!r}")
|
|
if actor not in ("model", "human"):
|
|
raise ValueError(f"actor must be 'model' or 'human', got {actor!r}")
|
|
text = _clean_reason(reason)
|
|
|
|
if dial == "floor":
|
|
old = await floor_for(user_id, surface)
|
|
applied = min(1.0, max(0.0, float(value)))
|
|
key, stored = s.floor_key, str(applied)
|
|
else:
|
|
old = float(await budget_for(user_id, surface))
|
|
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(
|
|
user_id=user_id, surface=surface, dial=dial,
|
|
old_value=old, new_value=applied, actor=actor, reason=text,
|
|
))
|
|
await session.commit()
|
|
|
|
return {
|
|
"surface": surface,
|
|
"dial": dial,
|
|
"previous": old,
|
|
"applied": applied,
|
|
# True when the registry corrected what was asked for. Said out loud
|
|
# because a caller that believes it set 1.4 will read the next
|
|
# telemetry as evidence about a bar that was never in force.
|
|
"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,
|
|
}
|
|
|
|
|
|
async def tuning_history(
|
|
user_id: int, *, surface: str | None = None, limit: int = 20
|
|
) -> list[dict]:
|
|
"""What has been moved, newest first — the operator's review surface.
|
|
|
|
Scoped to one surface when asked, because the question is almost always
|
|
"why is THIS arm set like this", and an unscoped list buries one surface's
|
|
two changes under another's twenty.
|
|
"""
|
|
if surface is not None:
|
|
get_surface(surface) # refuse a typo on the read too
|
|
async with async_session() as session:
|
|
q = (
|
|
select(RetrievalTuningEvent)
|
|
.where(RetrievalTuningEvent.user_id == user_id)
|
|
.order_by(RetrievalTuningEvent.created_at.desc())
|
|
.limit(max(1, min(int(limit), 200)))
|
|
)
|
|
if surface is not None:
|
|
q = q.where(RetrievalTuningEvent.surface == surface)
|
|
rows = (await session.execute(q)).scalars().all()
|
|
return [r.to_dict() for r in rows]
|