"""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 datetime import datetime, timedelta, timezone from quart import Blueprint, jsonify, request from scribe.auth import get_current_user_id, login_required from scribe.services import moment_actions as moment_actions_svc from scribe.services import moments as moments_svc from scribe.services import reply_shapes as shapes_svc from scribe.services import rule_moment_judgments as judgments_svc from scribe.services import rulebooks as rulebooks_svc from scribe.services.retrieval_telemetry import moment_usage 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)}) @retrieval_bp.route("/moments", methods=["GET"]) @login_required async def moments_route(): """The moments of work that rules mount on (milestone 458), with the actions that reach each one on this install. The same payload `list_moments` returns, from the same services, so the session and the Settings view cannot name different moments — plus what the view shows beside each one (step 6): `mounted`, how many rules each carries, and `usage`, the deliveries and opens per moment over `days` (the block `retrieval_telemetry` reports as `moment_usage`). """ uid = get_current_user_id() try: days = max(1, min(int(request.args.get("days", 30)), 365)) except ValueError: return jsonify({"error": "days must be a whole number"}), 400 out = moments_svc.catalog() out.update(await moment_actions_svc.actions_by_moment(uid)) # The catalog is the page; the counts beside it are not worth losing it # over. A failed count is flagged rather than shown as "nothing mounted". try: out["mounted"] = await rulebooks_svc.mount_counts(uid) except Exception: logger.warning("mount counts unreadable", exc_info=True) out["mounted"], out["mounted_failed"] = {}, True out["usage"] = await moment_usage(uid, datetime.now(timezone.utc) - timedelta(days=days)) out["usage"]["days"] = days return jsonify(out) @retrieval_bp.route("/reply-shapes", methods=["GET"]) @login_required async def reply_shapes_route(): """The default reply shapes and the moment each rides (milestone 500). `list_reply_shapes`' payload from the same service, so the Settings view and the session cannot show different defaults — plus what only the operator's view needs (step 5): per shape, the preferences mounted on its moment and its deliveries over `days` (default 30, 1–90). """ try: days = min(90, max(1, int(request.args.get("days") or shapes_svc.OVERVIEW_DAYS))) except ValueError: days = shapes_svc.OVERVIEW_DAYS return jsonify(await shapes_svc.overview(get_current_user_id(), days=days)) async def _mapping_change(change): """map/unmap from the browser: the MCP tools' service, recorded as human. The mapping comes as a JSON body, or — for DELETE, whose body many clients will not send — as query parameters of the same names. """ data = await request.get_json(silent=True) if data is None and request.args: data = request.args.to_dict() if not isinstance(data, dict): return jsonify({"error": "Expected a JSON object"}), 400 if not data.get("tool") or not data.get("moment"): return jsonify({"error": "tool and moment are required"}), 400 try: result = await change( get_current_user_id(), str(data["tool"]), str(data.get("match") or ""), str(data["moment"]), reason=str(data.get("reason") or ""), actor="human", ) except ValueError as e: # Unknown moment, a match the tool cannot take, nothing to remove — # the service says what would work, so pass it through. return jsonify({"error": str(e)}), 400 return jsonify(result) @retrieval_bp.route("/moments/mappings", methods=["POST"]) @login_required async def map_action_route(): return await _mapping_change(moment_actions_svc.map_action) @retrieval_bp.route("/moments/mappings", methods=["DELETE"]) @login_required async def unmap_action_route(): return await _mapping_change(moment_actions_svc.unmap_action) @retrieval_bp.route("/moments/proposals", methods=["GET"]) @login_required async def moment_proposals_route(): """The moment proposals waiting on a judgment (milestone 458 step 7) — `rule_moment_proposals`' payload, from the same service. `?rule_id=` narrows to one rule, for the rule editor.""" try: rule_id = int(request.args.get("rule_id") or 0) except ValueError: return jsonify({"error": "rule_id must be a whole number"}), 400 return jsonify(await judgments_svc.pending(get_current_user_id(), rule_id=rule_id or None)) @retrieval_bp.route("/moments/proposals/judge", methods=["POST"]) @login_required async def judge_moment_proposals_route(): """Confirm or reject proposals: `{"judgments": [{rule_id, moment, verdict, note}]}` — `judge_rule_moments` from the browser. A confirm mounts.""" data = await request.get_json(silent=True) if not isinstance(data, dict) or not isinstance(data.get("judgments"), list): return jsonify({"error": "Expected {\"judgments\": [...]}"}), 400 return jsonify(await judgments_svc.judge(get_current_user_id(), data["judgments"]))