Files
FabledGrimoire/src/scribe/routes/retrieval.py
T
bvandeusenandClaude Opus 5.5 69dddb7551
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
feat(500): Settings shows the shipped reply shapes, what adjusts each, and how often each arrives
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>
2026-10-09 16:19:45 -04:00

223 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"]))