feat(family): the promotion engine - triggers, the three criteria, the decision log and undo (milestone 463 step 3, #4989)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Failing after 1m5s
CI & Build / Python tests (push) Successful in 1m58s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Failing after 1m5s
CI & Build / Python tests (push) Successful in 1m58s
CI & Build / Build & push image (push) Skipped
The agent promotes a family idea when all three criteria hold, and no person approves it. The criteria are product text: services/family.py states them, and the promote tool's docstring names every criterion the service enforces.
- Criteria: each one vetoes on its own when its reasoning is blank. Platform terms also needs an applies-when and a platform scope; proven also needs named evidence. A veto keeps the idea a candidate and is logged, so it becomes precedent.
- Precedent: every promotion stores the decisions on the nearest ideas by meaning, plus any the caller names.
- Promotion sets canon, the applicability test and the platform scope, and opens an unassessed ledger row for each member project the promoter can write. Re-promotion moves the version past every version the idea has held.
- Retire and undo: undo reverses only the latest idea-level decision, restores its recorded before-state, and logs itself with the undone decision as its precedent. Settled here: leaving canon closes the unassessed rows but keeps the judged ones, which read as needing a recheck after a re-promotion.
- Triggers open evaluations but never promote:
- a cross-project lineage citation ("matching #N") on a note or task write;
- a same-meaning record in another project on a shared platform, on create, at 0.80 (measured: the known pattern's builds scored 0.79-0.82, an unrelated project's best match 0.65);
- a milestone closing on a platform.
Each fails open and rides the response as family_hint.
- Doors: seven MCP tools, /api/family REST endpoints, and a Family page (nav, /family) showing the criteria, the ideas, and the decision log with undo and retire.
- utils/recordHref.ts holds the one copy of "where a record opens", now shared with LessonDetailView.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
"""Family canon routes — the web door to the promotion engine (milestone 463).
|
||||
|
||||
The agent decides promotions through the MCP tools; this door exists so a
|
||||
person can READ every decision and its reasons, 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 (rule 33), and are recorded as the operator's.
|
||||
Every write is gated on the idea's note in the service (rule 78).
|
||||
"""
|
||||
import logging
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from scribe.auth import get_current_user_id, login_required
|
||||
from scribe.routes.utils import not_found
|
||||
from scribe.services import family as family_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)})
|
||||
|
||||
|
||||
@family_bp.route("/ideas/<int:note_id>", 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/<int:note_id>/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/<int:note_id>/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/<int:note_id>/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("/decisions", methods=["GET"])
|
||||
@login_required
|
||||
async def list_decisions_route():
|
||||
limit, offset = _page()
|
||||
idea_id = request.args.get("idea_id", type=int)
|
||||
rows = await family_svc.list_decisions(
|
||||
get_current_user_id(), idea_id=idea_id or None, limit=limit, offset=offset,
|
||||
)
|
||||
return jsonify({"decisions": rows, "limit": limit, "offset": offset})
|
||||
|
||||
|
||||
@family_bp.route("/decisions/<int:decision_id>/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)
|
||||
Reference in New Issue
Block a user