"""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/", 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)})