CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Successful in 1m19s
CI & Build / Python tests (push) Successful in 1m58s
CI & Build / Build & push image (push) Successful in 39s
Settings > General > Reply shapes. One menu row per shape (core, completion, asks, plan): its title and when it arrives, how many of the operator's preferences adjust it, and its deliveries over 30 days (in full / as a reminder, "—" when the counts cannot be read). A verdict line leads. Opening a row shows the shipped text, read-only because it is product, and the preferences mounted on its moment; each opens in the rule editor, and "Adjust this" opens a new preference already mounted on the shape's moment with a starting trigger. The rule editor takes an optional preset (kind, moments, trigger) and, opened outside the Rules view, asks where a new record lives instead of dropping it. GET /api/retrieval/reply-shapes now returns reply_shapes.overview: the catalog plus mounted preferences (the same lookup that delivers them) and counts from the reply_shape delivery rows. #5497 (step 5 of milestone 500). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
223 lines
9.4 KiB
Python
223 lines
9.4 KiB
Python
"""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/<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)})
|
||
|
||
|
||
@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"]))
|