"""Family canon routes — the web door to the promotion engine. The agent decides promotions and each project's answers through the MCP tools; this door exists so a person can READ every decision and its reasons — the ideas, the adoption matrix, the log — and retire an idea or undo a decision when they disagree. The promote and propose endpoints are here for parity with the agent's door, and are recorded as the operator's. Every write is gated on the idea's note in the service. """ import logging from quart import Blueprint, jsonify, request from grimoire.auth import get_current_user_id, login_required from grimoire.routes.utils import not_found from grimoire.services import family as family_svc from grimoire.services import family_adoption as adoption_svc logger = logging.getLogger(__name__) family_bp = Blueprint("family", __name__, url_prefix="/api/family") def _page() -> tuple[int, int]: try: limit = max(1, min(int(request.args.get("limit", 50)), 200)) offset = max(0, int(request.args.get("offset", 0))) except ValueError: limit, offset = 50, 0 return limit, offset @family_bp.route("/ideas", methods=["GET"]) @login_required async def list_ideas_route(): limit, offset = _page() ideas = await family_svc.list_ideas( get_current_user_id(), status=request.args.get("status") or None, platform=request.args.get("platform") or None, limit=limit, offset=offset, ) return jsonify({ "ideas": ideas, "criteria": list(family_svc.CRITERIA), "conflict_order": list(adoption_svc.CONFLICT_ORDER), }) @family_bp.route("/ideas/", methods=["GET"]) @login_required async def get_idea_route(note_id: int): uid = get_current_user_id() idea = await family_svc.get_idea(uid, note_id) if idea is None: return not_found("Family idea") idea["precedents"] = await family_svc.precedents(uid, note_id) return jsonify(idea) @family_bp.route("/ideas//propose", methods=["POST"]) @login_required async def propose_route(note_id: int): data = await request.get_json() or {} try: idea, created = await family_svc.propose( get_current_user_id(), note_id, reason=data.get("reason") or "", applies_when=data.get("applies_when") or None, decided_via="operator", ) except ValueError as exc: return jsonify({"error": str(exc)}), 400 return jsonify({"idea": idea, "created": created}), (201 if created else 200) @family_bp.route("/ideas//promote", methods=["POST"]) @login_required async def promote_route(note_id: int): data = await request.get_json() or {} try: result = await family_svc.promote( get_current_user_id(), note_id, applies_when=data.get("applies_when") or "", platforms=data.get("platforms") or [], criteria=data.get("criteria") or {}, evidence=data.get("evidence") or [], reason=data.get("reason") or "", precedent_ids=data.get("precedent_ids") or [], decided_via="operator", ) except ValueError as exc: return jsonify({"error": str(exc)}), 400 return jsonify(result) @family_bp.route("/ideas//retire", methods=["POST"]) @login_required async def retire_route(note_id: int): data = await request.get_json() or {} try: result = await family_svc.retire( get_current_user_id(), note_id, reason=data.get("reason") or "", decided_via="operator", ) except ValueError as exc: return jsonify({"error": str(exc)}), 400 return jsonify(result) @family_bp.route("/matrix", methods=["GET"]) @login_required async def matrix_route(): """Projects × canon ideas — the adoption matrix. Read-only: the agent answers through assess_family_adoption; a person reads the answers here and undoes one from the decision log if they disagree.""" matrix = await adoption_svc.adoption_matrix( get_current_user_id(), platform=request.args.get("platform") or None, project_id=request.args.get("project_id", type=int) or None, ) return jsonify(matrix) @family_bp.route("/decisions", methods=["GET"]) @login_required async def list_decisions_route(): limit, offset = _page() idea_id = request.args.get("idea_id", type=int) project_id = request.args.get("project_id", type=int) rows = await family_svc.list_decisions( get_current_user_id(), idea_id=idea_id or None, project_id=project_id or None, limit=limit, offset=offset, ) return jsonify({"decisions": rows, "limit": limit, "offset": offset}) @family_bp.route("/decisions//undo", methods=["POST"]) @login_required async def undo_route(decision_id: int): data = await request.get_json() or {} try: result = await family_svc.undo( get_current_user_id(), decision_id, reason=data.get("reason") or "", decided_via="operator", ) except ValueError as exc: return jsonify({"error": str(exc)}), 400 return jsonify(result)