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:
@@ -33,6 +33,7 @@ from scribe.routes.dashboard import dashboard_bp
|
||||
from scribe.routes.systems import systems_bp
|
||||
from scribe.routes.canonical_systems import canonical_systems_bp
|
||||
from scribe.routes.platforms import platforms_bp
|
||||
from scribe.routes.family import family_bp
|
||||
from scribe.routes.lessons import lessons_bp
|
||||
from scribe.routes.snippets import snippets_bp
|
||||
from scribe.routes.webhooks import webhooks_bp
|
||||
@@ -103,6 +104,7 @@ def create_app() -> Quart:
|
||||
app.register_blueprint(systems_bp)
|
||||
app.register_blueprint(canonical_systems_bp)
|
||||
app.register_blueprint(platforms_bp)
|
||||
app.register_blueprint(family_bp)
|
||||
app.register_blueprint(snippets_bp)
|
||||
app.register_blueprint(webhooks_bp)
|
||||
|
||||
|
||||
@@ -163,6 +163,9 @@ _READ_ONLY_TOOLS = frozenset({
|
||||
# The platform catalog and a project's answers (milestone 463). A pure
|
||||
# read; set_project_platforms is the write.
|
||||
"list_platforms",
|
||||
# Family canon (milestone 463): ideas, one idea with its precedents, and
|
||||
# the decision log. Reads of records the caller can read.
|
||||
"list_family_ideas", "get_family_idea", "list_family_decisions",
|
||||
# The pass over the corpus and its queue (milestone 458 step 7): which
|
||||
# rules are unjudged and which proposals wait. Reads of the caller's own
|
||||
# rules, as list_rules is.
|
||||
@@ -187,6 +190,9 @@ _WRITE_TOOLS = frozenset({
|
||||
"create_project", "update_project", "delete_project", "decide_project_inception",
|
||||
"create_system", "update_system", "delete_system", "map_system_to_canonical",
|
||||
"set_project_platforms",
|
||||
# family canon — the promotion engine
|
||||
"propose_family_idea", "promote_family_idea", "retire_family_idea",
|
||||
"undo_family_decision",
|
||||
"bind_repo", "unbind_repo",
|
||||
# snippets, processes, the shape ledger
|
||||
"create_snippet", "update_snippet", "delete_snippet", "verify_snippet",
|
||||
|
||||
@@ -5,7 +5,7 @@ to an MCPServer instance. `register_all(mcp)` is the single entry point called
|
||||
from `mcp.server.build_mcp_server`.
|
||||
"""
|
||||
from scribe.mcp.tools import (
|
||||
design_systems, lessons, milestones, notes, processes, projects, recent, repos,
|
||||
design_systems, family, lessons, milestones, notes, processes, projects, recent, repos,
|
||||
moments, platforms, retrieval_review, retrieval_tuning,
|
||||
wide_net,
|
||||
rulebooks, search, shapes, snippets, systems, tags, tasks, trash,
|
||||
@@ -25,6 +25,7 @@ def register_all(mcp) -> None:
|
||||
milestones.register(mcp)
|
||||
systems.register(mcp)
|
||||
platforms.register(mcp)
|
||||
family.register(mcp)
|
||||
design_systems.register(mcp)
|
||||
tags.register(mcp)
|
||||
recent.register(mcp)
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Family canon MCP tools — the promotion engine's agent door (milestone 463).
|
||||
|
||||
Thin wrappers over services/family.py. The agent is the decider here: no
|
||||
person approves a promotion, so the criteria are stated in these docstrings
|
||||
and enforced by the service, and every decision is logged with its reason.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from scribe.mcp._context import current_user_id
|
||||
from scribe.services import family as family_svc
|
||||
|
||||
|
||||
async def list_family_ideas(
|
||||
status: str = "", platform: str = "", limit: int = 50, offset: int = 0,
|
||||
) -> dict:
|
||||
"""Family ideas — records that every project on a platform shares — with
|
||||
their state: `candidate` (waiting to be evaluated, or held back by a
|
||||
criterion), `canon` (promoted; every project on its platforms answers it)
|
||||
or `retired`.
|
||||
|
||||
Args:
|
||||
status: candidate | canon | retired. Empty = all.
|
||||
platform: a platform slug (list_platforms). Empty = all.
|
||||
limit / offset: page through the list.
|
||||
"""
|
||||
ideas = await family_svc.list_ideas(
|
||||
current_user_id(), status=status or None, platform=platform or None,
|
||||
limit=max(1, min(limit, 200)), offset=max(0, offset),
|
||||
)
|
||||
return {"ideas": ideas, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
async def get_family_idea(note_id: int) -> dict:
|
||||
"""One family idea with what an evaluation needs: its state, platforms,
|
||||
full decision history, ledger counts, THE THREE CRITERIA, and the
|
||||
`precedents` — the decisions on the ideas nearest this one by meaning.
|
||||
Read the precedents before deciding, and decide consistently with them
|
||||
unless this idea differs in a way you can name.
|
||||
|
||||
Args:
|
||||
note_id: the idea's record id.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
idea = await family_svc.get_idea(uid, note_id)
|
||||
if idea is None:
|
||||
raise ValueError(f"#{note_id} is not a family idea you can read")
|
||||
idea["precedents"] = await family_svc.precedents(uid, note_id)
|
||||
return idea
|
||||
|
||||
|
||||
async def propose_family_idea(note_id: int, reason: str, applies_when: str = "") -> dict:
|
||||
"""Record a note, snippet or lesson as a family-idea CANDIDATE: something
|
||||
you believe every project on some platform will face. It reaches no
|
||||
project's ledger until it is promoted.
|
||||
|
||||
Proposing is cheap and does not promote. When the record already meets
|
||||
the three criteria, call promote_family_idea directly instead.
|
||||
|
||||
Args:
|
||||
note_id: the record that carries the idea.
|
||||
reason: why this looks like a family idea.
|
||||
applies_when: optional draft of when it applies, in platform terms.
|
||||
"""
|
||||
idea, created = await family_svc.propose(
|
||||
current_user_id(), note_id, reason=reason, applies_when=applies_when or None,
|
||||
)
|
||||
return {"idea": idea, "created": created}
|
||||
|
||||
|
||||
async def promote_family_idea(
|
||||
note_id: int,
|
||||
applies_when: str,
|
||||
platforms: list[str],
|
||||
platform_terms: str,
|
||||
platform_problem: str,
|
||||
proven: str,
|
||||
evidence: list[str],
|
||||
reason: str,
|
||||
precedent_ids: list[int] | None = None,
|
||||
) -> dict:
|
||||
"""Evaluate a record against the three criteria and, if all hold, promote
|
||||
it to family canon. YOU decide; nobody approves. The record of why is what
|
||||
makes the decision reviewable and the next one consistent.
|
||||
|
||||
An idea is promoted only when ALL THREE hold, and each is answered with
|
||||
your reasoning:
|
||||
|
||||
1. platform_terms — its 'when it applies' is stated in terms of a
|
||||
platform (what a project is built on or ships as), not one app's
|
||||
domain. Any project on the platform could read it and know whether it
|
||||
applies.
|
||||
2. platform_problem — it answers a problem the platform itself causes, or
|
||||
a stance the operator holds across projects. One app's preference does
|
||||
not qualify.
|
||||
3. proven — it has worked for real at least once (CI green, verified on a
|
||||
device, shipped). Name that in `evidence`.
|
||||
|
||||
Any criterion left blank — or `applies_when`, `platforms` or `evidence`
|
||||
left empty — VETOES the promotion on its own. A veto is not an error: the
|
||||
idea stays a candidate and the veto is logged, so the next evaluation of
|
||||
something like it sees why this one was held. That is also how you record
|
||||
"evaluated, and it does not qualify": leave the failing criterion blank
|
||||
and say why in `reason`.
|
||||
|
||||
Before deciding, read get_family_idea's `precedents`. The engine also
|
||||
finds the nearest earlier decisions itself and stores them as consulted.
|
||||
|
||||
On promotion every project that is on one of `platforms` and that you can
|
||||
write gets an `unassessed` row in its adoption ledger. If the idea was
|
||||
canon before, its version moves, so earlier answers read as needing a
|
||||
recheck.
|
||||
|
||||
Args:
|
||||
note_id: the record that carries the idea.
|
||||
applies_when: when it applies, in platform terms.
|
||||
platforms: platform slugs it is for (list_platforms).
|
||||
platform_terms: why criterion 1 holds (blank = it does not).
|
||||
platform_problem: why criterion 2 holds (blank = it does not).
|
||||
proven: why criterion 3 holds (blank = it does not).
|
||||
evidence: what proved it — a CI run, a task, a commit, a device check.
|
||||
reason: the decision in one or two sentences.
|
||||
precedent_ids: earlier family decisions you followed, if any.
|
||||
"""
|
||||
return await family_svc.promote(
|
||||
current_user_id(), note_id,
|
||||
applies_when=applies_when, platforms=platforms or [],
|
||||
criteria={
|
||||
"platform_terms": platform_terms,
|
||||
"platform_problem": platform_problem,
|
||||
"proven": proven,
|
||||
},
|
||||
evidence=evidence or [], reason=reason, precedent_ids=precedent_ids or [],
|
||||
)
|
||||
|
||||
|
||||
async def retire_family_idea(note_id: int, reason: str) -> dict:
|
||||
"""Demote a family idea — it no longer applies, or a better one replaces
|
||||
it. Its history and every judged ledger answer are kept; rows nobody had
|
||||
judged yet are closed. Undoable with undo_family_decision.
|
||||
|
||||
Args:
|
||||
note_id: the idea.
|
||||
reason: why it is retired.
|
||||
"""
|
||||
return await family_svc.retire(current_user_id(), note_id, reason=reason)
|
||||
|
||||
|
||||
async def undo_family_decision(decision_id: int, reason: str) -> dict:
|
||||
"""Reverse a family decision — a promotion, a retirement or a proposal —
|
||||
restoring the state it recorded as `before`. Only the latest idea-level
|
||||
decision on an idea can be undone; the undo is logged too, so the history
|
||||
keeps both.
|
||||
|
||||
Args:
|
||||
decision_id: the decision (list_family_decisions).
|
||||
reason: why it is undone.
|
||||
"""
|
||||
return await family_svc.undo(current_user_id(), decision_id, reason=reason)
|
||||
|
||||
|
||||
async def list_family_decisions(note_id: int = 0, limit: int = 50, offset: int = 0) -> dict:
|
||||
"""The family decision log, newest first: every proposal, promotion,
|
||||
veto, retirement and undo, with its reason, evidence and the precedents
|
||||
it followed. `undoable` marks the decision an undo would reverse.
|
||||
|
||||
Args:
|
||||
note_id: only this idea's decisions. 0 = all.
|
||||
limit / offset: page through the log.
|
||||
"""
|
||||
rows = await family_svc.list_decisions(
|
||||
current_user_id(), idea_id=note_id or None,
|
||||
limit=max(1, min(limit, 200)), offset=max(0, offset),
|
||||
)
|
||||
return {"decisions": rows, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
def register(mcp) -> None:
|
||||
for fn in (
|
||||
list_family_ideas, get_family_idea, propose_family_idea,
|
||||
promote_family_idea, retire_family_idea, undo_family_decision,
|
||||
list_family_decisions,
|
||||
):
|
||||
mcp.tool(name=fn.__name__)(fn)
|
||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
|
||||
from scribe.mcp._context import current_user_id
|
||||
from scribe.services import dedup as dedup_svc
|
||||
from scribe.services import family as family_svc
|
||||
from scribe.services import milestones as milestones_svc
|
||||
from scribe.services import notes as notes_svc
|
||||
from scribe.services import task_logs as task_logs_svc
|
||||
@@ -172,12 +173,20 @@ async def update_milestone(
|
||||
if order_index >= 0:
|
||||
fields["order_index"] = order_index
|
||||
await refuse_guessed_ids(title, description, body)
|
||||
# Asked BEFORE the write: only the transition into done is a closing.
|
||||
closing = status == "done" and await family_svc.milestone_is_open(uid, milestone_id)
|
||||
milestone = await milestones_svc.update_milestone(uid, milestone_id, **fields)
|
||||
if milestone is None:
|
||||
raise ValueError(f"milestone {milestone_id} not found")
|
||||
data = milestone.to_dict()
|
||||
if closing:
|
||||
# Family canon's milestone trigger (milestone 463): a plan closing on
|
||||
# a platform is the moment to ask whether what it built is shared.
|
||||
hint = await family_svc.milestone_trigger(uid, milestone)
|
||||
if hint:
|
||||
data["family_hint"] = hint
|
||||
return await moment_delivery.attach_moment_rules(
|
||||
uid, "update_milestone", {"status": status, "project_id": project_id},
|
||||
milestone.to_dict(),
|
||||
uid, "update_milestone", {"status": status, "project_id": project_id}, data,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from scribe.mcp._context import current_user_id
|
||||
from scribe.mcp.tools import systems as systems_tools
|
||||
from scribe.services import access as access_svc
|
||||
from scribe.services import dedup as dedup_svc
|
||||
from scribe.services import family as family_svc
|
||||
from scribe.services import notes as notes_svc
|
||||
from scribe.services import supersession as supersession_svc
|
||||
from scribe.services import systems as systems_svc
|
||||
@@ -237,6 +238,9 @@ async def create_note(
|
||||
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
|
||||
await supersession_svc.attach_relations(uid, note.id, data, hint=True)
|
||||
data.update(dedup_svc.note_overlap_response(overlaps, "note"))
|
||||
# Family canon's write-time triggers (milestone 463): a cited source of a
|
||||
# pattern, or the same idea in another project on a shared platform.
|
||||
await family_svc.attach_family_hint(uid, data, note, created=True)
|
||||
return await moment_delivery.attach_moment_rules(uid, "create_note", {"project_id": project_id}, data)
|
||||
|
||||
|
||||
@@ -319,6 +323,8 @@ async def update_note(
|
||||
uid, getattr(note, "user_id", uid) or uid, data, note_id, note.project_id
|
||||
)
|
||||
await supersession_svc.attach_relations(uid, note_id, data, hint=True)
|
||||
if body:
|
||||
await family_svc.attach_family_hint(uid, data, note, created=False)
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from scribe.mcp._context import current_user_id
|
||||
from scribe.mcp.tools import systems as systems_tools
|
||||
from scribe.services import access as access_svc
|
||||
from scribe.services import dedup as dedup_svc
|
||||
from scribe.services import family as family_svc
|
||||
from scribe.services import snippets as snippets_svc
|
||||
from scribe.services.note_usage import attach_usage, record_pulled
|
||||
from scribe.services import systems as systems_svc
|
||||
@@ -224,6 +225,9 @@ async def create_snippet(
|
||||
advice = snippets_svc.trigger_advice(when_to_use)
|
||||
if advice:
|
||||
data["trigger_advice"] = advice
|
||||
# Family canon (milestone 463): the same shape recorded in another project
|
||||
# on a shared platform opens an evaluation of the earlier one.
|
||||
await family_svc.attach_family_hint(uid, data, note, created=True)
|
||||
return await moment_delivery.attach_moment_rules(uid, "create_snippet", {"project_id": project_id}, data)
|
||||
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ from scribe.mcp._context import current_user_id
|
||||
from scribe.mcp.tools import systems as systems_tools
|
||||
from scribe.services import access as access_svc
|
||||
from scribe.services import dedup as dedup_svc
|
||||
from scribe.services import family as family_svc
|
||||
from scribe.services import milestones as milestones_svc
|
||||
from scribe.services import notes as notes_svc
|
||||
# Imported by NAME, not reached through notes_svc: minted_kind is pure
|
||||
@@ -363,6 +364,9 @@ async def create_task(
|
||||
data = note.to_dict()
|
||||
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
|
||||
data.update(dedup_svc.note_overlap_response(overlaps, "task"))
|
||||
# A task that says it is "matching" another project's work names the
|
||||
# source of a pattern — family canon's citation trigger (milestone 463).
|
||||
await family_svc.attach_family_hint(uid, data, note, created=True)
|
||||
return await placement_svc.attach_placement(uid, data, note)
|
||||
|
||||
|
||||
@@ -457,6 +461,8 @@ async def update_task(
|
||||
uid, getattr(note, "user_id", uid) or uid, data, task_id, note.project_id
|
||||
)
|
||||
await placement_svc.attach_placement(uid, data, note)
|
||||
if body:
|
||||
await family_svc.attach_family_hint(uid, data, note, created=False)
|
||||
if status in _CLOSING_STATUSES:
|
||||
data["report_back"] = REPORT_BACK_CUE
|
||||
# The operator's own adjustments to the completion report, retrieved
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,838 @@
|
||||
"""Family canon's promotion engine (milestone 463 step 3).
|
||||
|
||||
A family idea moves between three states — candidate, canon, retired — and
|
||||
every move is a decision with a reason, written to `family_decisions`. No
|
||||
person approves a promotion: the agent decides against the written criteria
|
||||
below, and the log is what keeps one decision consistent with the last similar
|
||||
one, and what lets a person read or undo any of them afterwards.
|
||||
|
||||
WHO DECIDES WHAT
|
||||
|
||||
- TRIGGERS (`citation_trigger`, `repeat_trigger`, `milestone_trigger`) only
|
||||
ever OPEN an evaluation. The first two record the source record as a
|
||||
`candidate` (decided_via "system") and hand the writer an in-band hint; a
|
||||
closed milestone is not a record an idea can hang on, so it only hints. None
|
||||
of them promotes.
|
||||
- THE AGENT evaluates a candidate against the three criteria and either
|
||||
promotes it or leaves it a candidate with the reason. A criterion with no
|
||||
support vetoes on its own; the veto is logged too, because a held candidate
|
||||
is precedent for the next one like it.
|
||||
- A PERSON reads the log, and may retire an idea or undo a decision from the
|
||||
web door. Those are the only operator acts, and neither is required.
|
||||
|
||||
PRECEDENT
|
||||
|
||||
Every promotion records the earlier decisions it was consistent with. The
|
||||
engine finds them itself — the decisions on the ideas nearest this one by
|
||||
meaning — and stores them alongside any the caller names, so "which precedents
|
||||
were consulted" is a fact about the call, not a sentence a session might skip.
|
||||
|
||||
LEDGER ROWS AND UNDO (settled here, as the milestone asked)
|
||||
|
||||
Promotion opens an `unassessed` row for every project that is a member of one
|
||||
of the idea's platforms and that the promoter can write. Leaving canon —
|
||||
by retirement, or by undoing the promotion — deletes the `unassessed` rows,
|
||||
because nobody judged them and they would only be noise. Rows somebody DID
|
||||
judge (adopted, variant, exempt, owed) are kept: each is a decision with its
|
||||
reason, and if the idea is promoted again its version moves, so every kept
|
||||
row reads as needing a recheck rather than as still agreeing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.family import (
|
||||
FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform, Platform,
|
||||
ProjectPlatform,
|
||||
)
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.project import Project
|
||||
from scribe.services import access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# --- the criteria, as product text --------------------------------------------
|
||||
#
|
||||
# These are what an evaluation is judged against, on every install. They live
|
||||
# here — and in the tool docstrings that quote them — rather than in any
|
||||
# instance's rulebook (rules 115, 119).
|
||||
|
||||
CRITERIA = (
|
||||
{
|
||||
"key": "platform_terms",
|
||||
"title": "Stated in platform terms",
|
||||
"test": (
|
||||
"Its 'when it applies' is stated in terms of a platform — what the "
|
||||
"project is built on or ships as — not one app's domain. Any project "
|
||||
"on the platform could read it and know whether it applies."
|
||||
),
|
||||
},
|
||||
{
|
||||
"key": "platform_problem",
|
||||
"title": "Answers a platform problem or a cross-project stance",
|
||||
"test": (
|
||||
"It answers a problem the platform itself causes, or a stance the "
|
||||
"operator holds across projects. One app's preference does not "
|
||||
"qualify."
|
||||
),
|
||||
},
|
||||
{
|
||||
"key": "proven",
|
||||
"title": "Proven at least once",
|
||||
"test": (
|
||||
"It has worked for real at least once — CI green, verified on a "
|
||||
"device, or shipped — and the evidence is named. An unproven idea "
|
||||
"stays a candidate."
|
||||
),
|
||||
},
|
||||
)
|
||||
CRITERIA_KEYS = tuple(c["key"] for c in CRITERIA)
|
||||
|
||||
TRIGGERS = ("citation", "repeat", "milestone")
|
||||
|
||||
# The repeat trigger's similarity floor. MEASURED, not guessed (2026-10-06,
|
||||
# #4989): against a description of one pattern known to have been built four
|
||||
# times in four projects, the records that implement it scored 0.79-0.82, and
|
||||
# the best match in a project that never built it scored 0.65. A trigger only
|
||||
# opens an evaluation, so a false positive costs one judgment, not a wrong
|
||||
# promotion. Step 5 measures the cross-language threshold against known pairs
|
||||
# and supersedes this.
|
||||
REPEAT_THRESHOLD = 0.80
|
||||
|
||||
# Words that say a citation is the SOURCE of a pattern rather than background.
|
||||
# "see #12" is a pointer; "matching #12" and "ported from #12" are lineage.
|
||||
_LINEAGE = re.compile(
|
||||
r"\b(match(?:es|ing)?|mirror(?:s|ing|ed)?|same (?:shape|pattern|approach|design) as|"
|
||||
r"ported from|copied from|borrowed from|lifted from|taken from|based on|"
|
||||
r"modell?ed on|follow(?:s|ing)? the (?:shape|pattern|approach) of|as (?:built|done) in|"
|
||||
r"reuses?|re-?implement(?:s|ing)?|like)\b",
|
||||
re.I,
|
||||
)
|
||||
# How far before a `#N` the lineage word may sit: the same clause, roughly.
|
||||
_LINEAGE_WINDOW = 80
|
||||
|
||||
# Note kinds the repeat trigger compares: recorded knowledge and shapes, not
|
||||
# the to-do list.
|
||||
_REPEAT_KINDS = ("snippet", "note")
|
||||
|
||||
MEMBER_STATES = ("declared", "detected")
|
||||
_IDEA_ACTIONS = ("propose", "promote", "revise", "retire")
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# --- reads --------------------------------------------------------------------
|
||||
|
||||
async def _platform_slugs(session, note_id: int) -> list[str]:
|
||||
rows = await session.execute(
|
||||
select(Platform.slug)
|
||||
.join(FamilyIdeaPlatform, FamilyIdeaPlatform.platform_id == Platform.id)
|
||||
.where(FamilyIdeaPlatform.note_id == note_id)
|
||||
.order_by(Platform.order_index.asc(), Platform.slug.asc())
|
||||
)
|
||||
return list(rows.scalars().all())
|
||||
|
||||
|
||||
async def _snapshot(session, idea: FamilyIdea | None) -> dict | None:
|
||||
"""The idea's state, as a decision's before/after. Slugs, never ids: an id
|
||||
inside JSON cannot be remapped by a restore (the #3182 trap)."""
|
||||
if idea is None:
|
||||
return None
|
||||
return {
|
||||
"status": idea.status,
|
||||
"applies_when": idea.applies_when or "",
|
||||
"canon_version": idea.canon_version,
|
||||
"platforms": await _platform_slugs(session, idea.note_id),
|
||||
}
|
||||
|
||||
|
||||
def _decision_dict(d: FamilyDecision, title: str | None = None) -> dict:
|
||||
out = d.to_dict()
|
||||
if title is not None:
|
||||
out["idea_title"] = title
|
||||
return out
|
||||
|
||||
|
||||
async def get_idea(user_id: int, note_id: int) -> dict | None:
|
||||
"""One idea with everything an evaluation reads: its state, platforms,
|
||||
decision history (newest first), its ledger counts and the criteria.
|
||||
None when the caller cannot read the note or it is not an idea."""
|
||||
if not await access.can_read_note(user_id, note_id):
|
||||
return None
|
||||
async with async_session() as session:
|
||||
idea = await session.get(FamilyIdea, note_id)
|
||||
note = await session.get(Note, note_id)
|
||||
if idea is None or note is None:
|
||||
return None
|
||||
decisions = (await session.execute(
|
||||
select(FamilyDecision).where(FamilyDecision.idea_id == note_id)
|
||||
.order_by(FamilyDecision.id.desc())
|
||||
)).scalars().all()
|
||||
counts = dict((await session.execute(
|
||||
select(FamilyAdoption.status, func.count())
|
||||
.where(FamilyAdoption.idea_id == note_id)
|
||||
.group_by(FamilyAdoption.status)
|
||||
)).all())
|
||||
out = idea.to_dict()
|
||||
out.update({
|
||||
"title": note.title,
|
||||
"note_type": note.note_type or "note",
|
||||
"is_task": note.is_task,
|
||||
"project_id": note.project_id,
|
||||
"platforms": await _platform_slugs(session, note_id),
|
||||
"adoptions": counts,
|
||||
"decisions": [_decision_dict(d) for d in decisions],
|
||||
"undoable_decision_id": _undoable_id(decisions),
|
||||
})
|
||||
out["criteria"] = list(CRITERIA)
|
||||
return out
|
||||
|
||||
|
||||
async def list_ideas(
|
||||
user_id: int, *, status: str | None = None, platform: str | None = None,
|
||||
limit: int = 50, offset: int = 0,
|
||||
) -> list[dict]:
|
||||
"""Ideas whose note the caller can read, newest change first."""
|
||||
async with async_session() as session:
|
||||
query = (
|
||||
select(FamilyIdea, Note.title, Note.note_type, Note.project_id, Note.status)
|
||||
.join(Note, Note.id == FamilyIdea.note_id)
|
||||
.where(access.readable_notes_clause(user_id), Note.deleted_at.is_(None))
|
||||
)
|
||||
if status:
|
||||
query = query.where(FamilyIdea.status == status)
|
||||
if platform:
|
||||
query = query.where(FamilyIdea.note_id.in_(
|
||||
select(FamilyIdeaPlatform.note_id)
|
||||
.join(Platform, Platform.id == FamilyIdeaPlatform.platform_id)
|
||||
.where(Platform.slug == platform)
|
||||
))
|
||||
rows = (await session.execute(
|
||||
query.order_by(FamilyIdea.updated_at.desc()).limit(limit).offset(offset)
|
||||
)).all()
|
||||
out = []
|
||||
for idea, title, note_type, project_id, note_status in rows:
|
||||
d = idea.to_dict()
|
||||
d.update({
|
||||
"title": title,
|
||||
"note_type": note_type or "note",
|
||||
"is_task": note_status is not None,
|
||||
"project_id": project_id,
|
||||
"platforms": await _platform_slugs(session, idea.note_id),
|
||||
})
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
async def list_decisions(
|
||||
user_id: int, *, idea_id: int | None = None, limit: int = 50, offset: int = 0,
|
||||
) -> list[dict]:
|
||||
"""The decision log, newest first, over ideas the caller can read. Each
|
||||
row says whether it is the one an undo would reverse."""
|
||||
async with async_session() as session:
|
||||
query = (
|
||||
select(FamilyDecision, Note.title)
|
||||
.join(Note, Note.id == FamilyDecision.idea_id)
|
||||
.where(access.readable_notes_clause(user_id))
|
||||
)
|
||||
if idea_id:
|
||||
query = query.where(FamilyDecision.idea_id == idea_id)
|
||||
rows = (await session.execute(
|
||||
query.order_by(FamilyDecision.id.desc()).limit(limit).offset(offset)
|
||||
)).all()
|
||||
latest = await _latest_idea_decisions(session, {d.idea_id for d, _ in rows})
|
||||
out = []
|
||||
for d, title in rows:
|
||||
item = _decision_dict(d, title)
|
||||
item["undoable"] = latest.get(d.idea_id) == d.id and _can_undo(d)
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def _can_undo(d: FamilyDecision) -> bool:
|
||||
"""An idea-level decision that changed something. A veto (a `propose`
|
||||
whose before and after agree) changed nothing, and an undo is undone by
|
||||
deciding again, not by undoing the undo."""
|
||||
return d.project_id is None and d.action in _IDEA_ACTIONS and d.before != d.after
|
||||
|
||||
|
||||
def _undoable_id(decisions_newest_first) -> int | None:
|
||||
for d in decisions_newest_first:
|
||||
if d.project_id is None:
|
||||
return d.id if _can_undo(d) else None
|
||||
return None
|
||||
|
||||
|
||||
async def _latest_idea_decisions(session, idea_ids: set[int]) -> dict[int, int]:
|
||||
if not idea_ids:
|
||||
return {}
|
||||
rows = await session.execute(
|
||||
select(FamilyDecision.idea_id, func.max(FamilyDecision.id))
|
||||
.where(FamilyDecision.idea_id.in_(idea_ids), FamilyDecision.project_id.is_(None))
|
||||
.group_by(FamilyDecision.idea_id)
|
||||
)
|
||||
return dict(rows.all())
|
||||
|
||||
|
||||
async def precedents(user_id: int, note_id: int, limit: int = 5) -> list[dict]:
|
||||
"""The decisions on the ideas nearest this one by meaning — what a new
|
||||
decision about it should be consistent with. Each idea contributes its
|
||||
latest idea-level decision. Empty when nothing similar has been decided,
|
||||
or when the embedder is unavailable."""
|
||||
from scribe.services.embeddings import embedding_text, semantic_search_notes
|
||||
|
||||
async with async_session() as session:
|
||||
note = await session.get(Note, note_id)
|
||||
if note is None:
|
||||
return []
|
||||
idea_ids = set((await session.execute(select(FamilyIdea.note_id))).scalars().all())
|
||||
idea_ids.discard(note_id)
|
||||
if not idea_ids:
|
||||
return []
|
||||
try:
|
||||
hits = await semantic_search_notes(
|
||||
user_id, embedding_text(note.title, note.body), exclude_ids={note_id},
|
||||
limit=40, threshold=0.0, scope="read", include_global_kinds=True,
|
||||
demote_superseded=False,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("precedent search failed for idea %s", note_id, exc_info=True)
|
||||
return []
|
||||
ranked = [(score, n) for score, n in hits if n.id in idea_ids][:limit]
|
||||
if not ranked:
|
||||
return []
|
||||
async with async_session() as session:
|
||||
latest = await _latest_idea_decisions(session, {n.id for _, n in ranked})
|
||||
decisions = {
|
||||
d.id: d for d in (await session.execute(
|
||||
select(FamilyDecision).where(FamilyDecision.id.in_(list(latest.values())))
|
||||
)).scalars().all()
|
||||
}
|
||||
out = []
|
||||
for score, n in ranked:
|
||||
d = decisions.get(latest.get(n.id))
|
||||
if d is not None:
|
||||
item = _decision_dict(d, n.title)
|
||||
item["similarity"] = round(float(score), 3)
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
# --- writes ---------------------------------------------------------------------
|
||||
|
||||
def _log(session, *, idea_id: int, action: str, reason: str, before, after,
|
||||
evidence: dict | None, precedent_ids: list[int] | None, decided_via: str,
|
||||
user_id: int | None, project_id: int | None = None) -> FamilyDecision:
|
||||
row = FamilyDecision(
|
||||
idea_id=idea_id, project_id=project_id, action=action, reason=reason.strip(),
|
||||
before=before, after=after, evidence=evidence or {},
|
||||
precedent_ids=list(dict.fromkeys(precedent_ids or [])),
|
||||
decided_via=decided_via, user_id=user_id,
|
||||
)
|
||||
session.add(row)
|
||||
return row
|
||||
|
||||
|
||||
async def _resolve_platforms(session, slugs: list[str]) -> dict[str, int]:
|
||||
wanted = list(dict.fromkeys(s.strip() for s in slugs if s and s.strip()))
|
||||
if not wanted:
|
||||
return {}
|
||||
rows = (await session.execute(
|
||||
select(Platform.slug, Platform.id)
|
||||
.where(Platform.slug.in_(wanted), Platform.deleted_at.is_(None))
|
||||
)).all()
|
||||
known = dict(rows)
|
||||
unknown = [s for s in wanted if s not in known]
|
||||
if unknown:
|
||||
raise ValueError(f"unknown platform(s): {', '.join(unknown)} (list_platforms)")
|
||||
return known
|
||||
|
||||
|
||||
async def _set_platforms(session, note_id: int, platform_ids) -> None:
|
||||
await session.execute(delete(FamilyIdeaPlatform).where(FamilyIdeaPlatform.note_id == note_id))
|
||||
for pid in dict.fromkeys(platform_ids):
|
||||
session.add(FamilyIdeaPlatform(note_id=note_id, platform_id=pid))
|
||||
|
||||
|
||||
async def _open_ledger(session, user_id: int, note_id: int) -> int:
|
||||
"""An `unassessed` row for every member project of the idea's platforms
|
||||
that the promoter can write and that has no row yet. Returns how many."""
|
||||
project_ids = set((await session.execute(
|
||||
select(ProjectPlatform.project_id)
|
||||
.join(FamilyIdeaPlatform, FamilyIdeaPlatform.platform_id == ProjectPlatform.platform_id)
|
||||
.join(Project, Project.id == ProjectPlatform.project_id)
|
||||
.where(
|
||||
FamilyIdeaPlatform.note_id == note_id,
|
||||
ProjectPlatform.state.in_(MEMBER_STATES),
|
||||
Project.deleted_at.is_(None),
|
||||
)
|
||||
)).scalars().all())
|
||||
answered = set((await session.execute(
|
||||
select(FamilyAdoption.project_id).where(FamilyAdoption.idea_id == note_id)
|
||||
)).scalars().all())
|
||||
opened = 0
|
||||
for pid in sorted(project_ids - answered):
|
||||
# Rule 78: a promotion reaches only the projects its promoter could
|
||||
# have written an answer into themselves.
|
||||
if await access.can_write_project(user_id, pid):
|
||||
session.add(FamilyAdoption(project_id=pid, idea_id=note_id, status="unassessed"))
|
||||
opened += 1
|
||||
return opened
|
||||
|
||||
|
||||
async def _close_unassessed(session, note_id: int) -> int:
|
||||
result = await session.execute(
|
||||
delete(FamilyAdoption).where(
|
||||
FamilyAdoption.idea_id == note_id, FamilyAdoption.status == "unassessed",
|
||||
)
|
||||
)
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
async def propose(
|
||||
user_id: int, note_id: int, *, reason: str, trigger: str = "agent",
|
||||
evidence: dict | None = None, applies_when: str | None = None,
|
||||
decided_via: str = "agent",
|
||||
) -> tuple[dict, bool]:
|
||||
"""Record a note as a family-idea CANDIDATE. Returns (idea, created).
|
||||
|
||||
Idempotent: an existing idea comes back unchanged and nothing is logged —
|
||||
a trigger firing on every edit of a record must not grow the log. Write-
|
||||
gated on the note: an idea is state on someone's record (rule 78).
|
||||
"""
|
||||
if not (reason or "").strip():
|
||||
raise ValueError("a proposal needs a reason — what makes this a family idea?")
|
||||
if not await access.can_write_note(user_id, note_id):
|
||||
raise ValueError(f"note {note_id} not found or no write access")
|
||||
async with async_session() as session:
|
||||
idea = await session.get(FamilyIdea, note_id)
|
||||
if idea is not None:
|
||||
return idea.to_dict(), False
|
||||
idea = FamilyIdea(note_id=note_id, status="candidate",
|
||||
applies_when=(applies_when or "").strip() or None)
|
||||
session.add(idea)
|
||||
await session.flush()
|
||||
_log(
|
||||
session, idea_id=note_id, action="propose", reason=reason, before=None,
|
||||
after=await _snapshot(session, idea),
|
||||
evidence={"trigger": trigger, **(evidence or {})},
|
||||
precedent_ids=None, decided_via=decided_via, user_id=user_id,
|
||||
)
|
||||
await session.commit()
|
||||
return idea.to_dict(), True
|
||||
|
||||
|
||||
def vetoes(*, applies_when: str, platforms: list[str], criteria: dict, evidence: list) -> list[str]:
|
||||
"""The criteria a promotion fails, by key. Pure.
|
||||
|
||||
Each fails ON ITS OWN when its support is missing:
|
||||
- platform_terms — no reasoning, no `applies_when`, or no platform scope;
|
||||
- platform_problem — no reasoning;
|
||||
- proven — no reasoning, or no named evidence.
|
||||
"""
|
||||
def said(key: str) -> bool:
|
||||
return bool(str(criteria.get(key) or "").strip())
|
||||
|
||||
failed = []
|
||||
if not said("platform_terms") or not (applies_when or "").strip() or not platforms:
|
||||
failed.append("platform_terms")
|
||||
if not said("platform_problem"):
|
||||
failed.append("platform_problem")
|
||||
if not said("proven") or not [e for e in evidence or [] if str(e).strip()]:
|
||||
failed.append("proven")
|
||||
return failed
|
||||
|
||||
|
||||
async def promote(
|
||||
user_id: int, note_id: int, *, applies_when: str, platforms: list[str],
|
||||
criteria: dict, evidence: list[str], reason: str,
|
||||
precedent_ids: list[int] | None = None, decided_via: str = "agent",
|
||||
) -> dict:
|
||||
"""Evaluate a record against the three criteria and promote it to canon.
|
||||
|
||||
A record nobody proposed may be promoted directly — the candidate row is
|
||||
created on the way. Any criterion without support vetoes the promotion:
|
||||
the idea stays (or becomes) a candidate and the veto is logged as a
|
||||
`propose` decision naming what failed, so it is precedent too.
|
||||
|
||||
On promotion: status canon, `applies_when` and the platform scope set, the
|
||||
version bumped if this idea has been promoted before (so rows judged
|
||||
against the earlier canon read as needing a recheck), an `unassessed`
|
||||
ledger row opened per member project, and a decision logged with the
|
||||
criteria reasoning, the evidence and the precedents consulted.
|
||||
|
||||
Raises ValueError on a malformed call (no reason, unknown platform, no
|
||||
write access, already canon) — before anything is written.
|
||||
"""
|
||||
if not (reason or "").strip():
|
||||
raise ValueError("a promotion needs a reason")
|
||||
if not await access.can_write_note(user_id, note_id):
|
||||
raise ValueError(f"note {note_id} not found or no write access")
|
||||
criteria = {k: str((criteria or {}).get(k) or "").strip() for k in CRITERIA_KEYS}
|
||||
evidence = [str(e).strip() for e in evidence or [] if str(e).strip()]
|
||||
consulted = await precedents(user_id, note_id)
|
||||
named = await _existing_decision_ids(precedent_ids or [])
|
||||
precedent_list = list(dict.fromkeys(named + [p["id"] for p in consulted]))
|
||||
|
||||
async with async_session() as session:
|
||||
known = await _resolve_platforms(session, platforms or [])
|
||||
idea = await session.get(FamilyIdea, note_id)
|
||||
if idea is not None and idea.status == "canon":
|
||||
raise ValueError(
|
||||
f"#{note_id} is already canon (version {idea.canon_version}); "
|
||||
"retire it first, or record a revision"
|
||||
)
|
||||
created_now = idea is None
|
||||
if created_now:
|
||||
idea = FamilyIdea(note_id=note_id, status="candidate")
|
||||
session.add(idea)
|
||||
await session.flush()
|
||||
# A row made by this call had no prior state: `before` says so, which
|
||||
# also makes a veto that created a candidate undoable (it changed
|
||||
# something — a record became a candidate).
|
||||
before = None if created_now else await _snapshot(session, idea)
|
||||
failed = vetoes(
|
||||
applies_when=applies_when, platforms=list(known), criteria=criteria,
|
||||
evidence=evidence,
|
||||
)
|
||||
record = {"criteria": criteria, "evidence": evidence}
|
||||
if failed:
|
||||
decision = _log(
|
||||
session, idea_id=note_id, action="propose",
|
||||
reason=f"held as a candidate — fails {', '.join(failed)}: {reason.strip()}",
|
||||
before=before, after=await _snapshot(session, idea),
|
||||
evidence={**record, "vetoed_by": failed},
|
||||
precedent_ids=precedent_list, decided_via=decided_via, user_id=user_id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(decision)
|
||||
return {
|
||||
"promoted": False, "vetoed_by": failed, "idea": idea.to_dict(),
|
||||
"decision": decision.to_dict(), "precedents": consulted,
|
||||
}
|
||||
|
||||
# Re-promotion moves the version PAST every version this idea has ever
|
||||
# held — including one an undo rolled back from — so no row judged
|
||||
# against an earlier canon can read as agreeing with this one.
|
||||
history = (await session.execute(
|
||||
select(FamilyDecision.action, FamilyDecision.after)
|
||||
.where(FamilyDecision.idea_id == note_id)
|
||||
)).all()
|
||||
if any(action == "promote" for action, _ in history):
|
||||
seen = [idea.canon_version or 1] + [
|
||||
int(after.get("canon_version") or 1) for _, after in history if after
|
||||
]
|
||||
idea.canon_version = max(seen) + 1
|
||||
idea.status = "canon"
|
||||
idea.applies_when = applies_when.strip()
|
||||
idea.updated_at = _now()
|
||||
await _set_platforms(session, note_id, known.values())
|
||||
await session.flush()
|
||||
opened = await _open_ledger(session, user_id, note_id)
|
||||
decision = _log(
|
||||
session, idea_id=note_id, action="promote", reason=reason,
|
||||
before=before, after=await _snapshot(session, idea),
|
||||
evidence={**record, "ledger_rows_opened": opened},
|
||||
precedent_ids=precedent_list, decided_via=decided_via, user_id=user_id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(decision)
|
||||
return {
|
||||
"promoted": True, "idea": idea.to_dict(), "ledger_rows_opened": opened,
|
||||
"decision": decision.to_dict(), "precedents": consulted,
|
||||
}
|
||||
|
||||
|
||||
async def _existing_decision_ids(ids: list[int]) -> list[int]:
|
||||
ids = [int(i) for i in ids if i]
|
||||
if not ids:
|
||||
return []
|
||||
async with async_session() as session:
|
||||
found = set((await session.execute(
|
||||
select(FamilyDecision.id).where(FamilyDecision.id.in_(ids))
|
||||
)).scalars().all())
|
||||
missing = [i for i in ids if i not in found]
|
||||
if missing:
|
||||
raise ValueError(f"no such family decision(s): {', '.join(map(str, missing))}")
|
||||
return ids
|
||||
|
||||
|
||||
async def retire(user_id: int, note_id: int, *, reason: str, decided_via: str = "agent") -> dict:
|
||||
"""Demote an idea. Its history and its judged ledger rows are kept; the
|
||||
rows nobody judged are closed. Undoable."""
|
||||
if not (reason or "").strip():
|
||||
raise ValueError("retiring an idea needs a reason")
|
||||
if not await access.can_write_note(user_id, note_id):
|
||||
raise ValueError(f"note {note_id} not found or no write access")
|
||||
async with async_session() as session:
|
||||
idea = await session.get(FamilyIdea, note_id)
|
||||
if idea is None:
|
||||
raise ValueError(f"#{note_id} is not a family idea")
|
||||
if idea.status == "retired":
|
||||
raise ValueError(f"#{note_id} is already retired")
|
||||
before = await _snapshot(session, idea)
|
||||
idea.status = "retired"
|
||||
idea.updated_at = _now()
|
||||
closed = await _close_unassessed(session, note_id)
|
||||
decision = _log(
|
||||
session, idea_id=note_id, action="retire", reason=reason, before=before,
|
||||
after=await _snapshot(session, idea), evidence={"ledger_rows_closed": closed},
|
||||
precedent_ids=None, decided_via=decided_via, user_id=user_id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(decision)
|
||||
return {"idea": idea.to_dict(), "decision": decision.to_dict()}
|
||||
|
||||
|
||||
async def undo(user_id: int, decision_id: int, *, reason: str, decided_via: str = "agent") -> dict:
|
||||
"""Reverse an idea-level decision, restoring the state it recorded as
|
||||
`before`. Only the LATEST idea-level decision on an idea can be undone —
|
||||
undoing an older one would rewrite a state later decisions were built on.
|
||||
The undo is itself a decision, naming the one it reverses as its
|
||||
precedent, so the history keeps both.
|
||||
|
||||
Undoing the proposal that created an idea retires it rather than deleting
|
||||
it: deleting the idea would take its decision log with it.
|
||||
"""
|
||||
if not (reason or "").strip():
|
||||
raise ValueError("an undo needs a reason")
|
||||
async with async_session() as session:
|
||||
target = await session.get(FamilyDecision, decision_id)
|
||||
if target is None:
|
||||
raise ValueError(f"no such family decision: {decision_id}")
|
||||
if not await access.can_write_note(user_id, target.idea_id):
|
||||
raise ValueError(f"decision {decision_id} not found or no write access")
|
||||
async with async_session() as session:
|
||||
latest_id = (await _latest_idea_decisions(session, {target.idea_id})).get(target.idea_id)
|
||||
if latest_id != target.id or not _can_undo(target):
|
||||
if not _can_undo(target):
|
||||
why = ("it changed nothing" if target.before == target.after
|
||||
else f"a '{target.action}' decision is not undone this way")
|
||||
else:
|
||||
why = f"decision {latest_id} came after it — undo that one first"
|
||||
raise ValueError(f"decision {decision_id} cannot be undone: {why}")
|
||||
idea = await session.get(FamilyIdea, target.idea_id)
|
||||
before = await _snapshot(session, idea)
|
||||
prior = target.before or {
|
||||
"status": "retired", "applies_when": idea.applies_when or "",
|
||||
"canon_version": idea.canon_version, "platforms": before["platforms"],
|
||||
}
|
||||
if prior["status"] == "canon" and not (prior.get("applies_when") or "").strip():
|
||||
raise ValueError("the recorded prior state is canon with no 'applies when'")
|
||||
known = await _resolve_platforms(session, prior.get("platforms") or [])
|
||||
idea.status = prior["status"]
|
||||
idea.applies_when = (prior.get("applies_when") or "").strip() or None
|
||||
idea.canon_version = prior.get("canon_version") or idea.canon_version
|
||||
idea.updated_at = _now()
|
||||
await _set_platforms(session, idea.note_id, known.values())
|
||||
await session.flush()
|
||||
ledger: dict = {}
|
||||
if idea.status == "canon":
|
||||
ledger["ledger_rows_opened"] = await _open_ledger(session, user_id, idea.note_id)
|
||||
else:
|
||||
ledger["ledger_rows_closed"] = await _close_unassessed(session, idea.note_id)
|
||||
decision = _log(
|
||||
session, idea_id=idea.note_id, action="undo", reason=reason,
|
||||
before=before, after=await _snapshot(session, idea),
|
||||
evidence={"undid_action": target.action, **ledger},
|
||||
precedent_ids=[target.id], decided_via=decided_via, user_id=user_id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(decision)
|
||||
return {"idea": idea.to_dict(), "decision": decision.to_dict()}
|
||||
|
||||
|
||||
# --- triggers -------------------------------------------------------------------
|
||||
|
||||
def lineage_citations(text: str | None) -> list[int]:
|
||||
"""The `#N`s in a text that are cited as the SOURCE of a pattern — a
|
||||
lineage word ("matching", "ported from", "same shape as", …) in the same
|
||||
clause just before the reference. Pure, in order, de-duplicated."""
|
||||
from scribe.services.record_refs import REF_RE
|
||||
|
||||
found: list[int] = []
|
||||
for m in REF_RE.finditer(text or ""):
|
||||
start = max(0, m.start() - _LINEAGE_WINDOW)
|
||||
window = text[start:m.start()]
|
||||
window = re.split(r"[\n.;]", window)[-1]
|
||||
if _LINEAGE.search(window):
|
||||
n = int(m.group(1))
|
||||
if n not in found:
|
||||
found.append(n)
|
||||
return found
|
||||
|
||||
|
||||
async def _member_platform_ids(session, project_id: int) -> set[int]:
|
||||
return set((await session.execute(
|
||||
select(ProjectPlatform.platform_id).where(
|
||||
ProjectPlatform.project_id == project_id,
|
||||
ProjectPlatform.state.in_(MEMBER_STATES),
|
||||
)
|
||||
)).scalars().all())
|
||||
|
||||
|
||||
def _evaluate_line(note_id: int) -> str:
|
||||
return (
|
||||
f"Evaluate it now: get_family_idea({note_id}) shows the three criteria and "
|
||||
"the nearest precedents; then promote_family_idea if all three hold, or "
|
||||
"leave it a candidate (promote_family_idea records which criterion "
|
||||
"failed). No one approves this — the criteria decide."
|
||||
)
|
||||
|
||||
|
||||
async def citation_trigger(user_id: int, note) -> str | None:
|
||||
"""A record that cites ANOTHER project's record as the source of its
|
||||
pattern opens an evaluation of that source as a family idea. Silent on a
|
||||
citation without lineage words, and on a citation within one project."""
|
||||
if not getattr(note, "project_id", None):
|
||||
return None
|
||||
cited = [n for n in lineage_citations(note.body) if n != note.id]
|
||||
if not cited:
|
||||
return None
|
||||
async with async_session() as session:
|
||||
rows = (await session.execute(
|
||||
select(Note.id, Note.title, Note.project_id, Project.title)
|
||||
.join(Project, Project.id == Note.project_id)
|
||||
.where(
|
||||
Note.id.in_(cited), Note.deleted_at.is_(None),
|
||||
Note.project_id.is_not(None), Note.project_id != note.project_id,
|
||||
)
|
||||
)).all()
|
||||
for source_id, source_title, _pid, project_title in sorted(rows, key=lambda r: cited.index(r[0])):
|
||||
if not await access.can_write_note(user_id, source_id):
|
||||
continue
|
||||
idea, created = await propose(
|
||||
user_id, source_id, trigger="citation", decided_via="system",
|
||||
reason=f"#{note.id} cites it as the source of its pattern, across projects",
|
||||
evidence={"cited_by": {"id": note.id, "title": note.title}},
|
||||
)
|
||||
if idea["status"] == "canon":
|
||||
return (
|
||||
f"This record follows #{source_id} \"{source_title}\" ({project_title}), "
|
||||
"which is already family canon. This project answers it through "
|
||||
"its adoption ledger rather than by copying it."
|
||||
)
|
||||
lead = "is now a family-idea candidate" if created else "is already a candidate"
|
||||
return (
|
||||
f"This record cites #{source_id} \"{source_title}\" ({project_title}) as "
|
||||
f"the source of its pattern — an idea carried between projects, which "
|
||||
f"{lead}. {_evaluate_line(source_id)}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def repeat_trigger(user_id: int, note) -> str | None:
|
||||
"""A new record whose meaning repeats a record in ANOTHER project that
|
||||
shares a platform with this one opens an evaluation of the earlier record.
|
||||
Silent when the projects share no platform, below the threshold, or when
|
||||
the project has no platforms yet."""
|
||||
from scribe.services.embeddings import embedding_text, semantic_search_notes
|
||||
|
||||
if not getattr(note, "project_id", None) or note.is_task:
|
||||
return None
|
||||
async with async_session() as session:
|
||||
mine = await _member_platform_ids(session, note.project_id)
|
||||
if not mine:
|
||||
return None
|
||||
hits = await semantic_search_notes(
|
||||
user_id, embedding_text(note.title, note.body), exclude_ids={note.id},
|
||||
limit=8, threshold=REPEAT_THRESHOLD, scope="read", note_type=_REPEAT_KINDS,
|
||||
is_task=False, demote_superseded=False,
|
||||
)
|
||||
for score, other in hits:
|
||||
if not other.project_id or other.project_id == note.project_id:
|
||||
continue
|
||||
async with async_session() as session:
|
||||
shared = mine & await _member_platform_ids(session, other.project_id)
|
||||
if not shared or not await access.can_write_note(user_id, other.id):
|
||||
continue
|
||||
idea, created = await propose(
|
||||
user_id, other.id, trigger="repeat", decided_via="system",
|
||||
reason=(f"#{note.id} repeats it in another project on a shared platform "
|
||||
f"(similarity {score:.2f})"),
|
||||
evidence={"repeated_by": {"id": note.id, "title": note.title},
|
||||
"similarity": round(float(score), 3)},
|
||||
)
|
||||
if idea["status"] == "canon":
|
||||
return (
|
||||
f"This record repeats #{other.id} \"{other.title}\", which is already "
|
||||
"family canon on a platform this project shares. Build from it."
|
||||
)
|
||||
lead = "is now a family-idea candidate" if created else "is already a candidate"
|
||||
return (
|
||||
f"This record repeats #{other.id} \"{other.title}\" from another project "
|
||||
f"on a platform this one shares (similarity {score:.2f}). Two projects "
|
||||
f"building the same idea is what family canon is for; #{other.id} {lead}. "
|
||||
f"{_evaluate_line(other.id)}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def milestone_is_open(user_id: int, milestone_id: int) -> bool:
|
||||
"""Whether a milestone is open right now — read before a status write, so
|
||||
the trigger fires on the transition into done and not on every re-save of
|
||||
a closed one. Fail-open to False: a hint must never break the write."""
|
||||
from scribe.services import milestones as milestones_svc
|
||||
|
||||
try:
|
||||
milestone = await milestones_svc.get_milestone(user_id, milestone_id)
|
||||
except Exception:
|
||||
logger.warning("milestone %s status read failed", milestone_id, exc_info=True)
|
||||
return False
|
||||
return milestone is not None and milestone.status != "done"
|
||||
|
||||
|
||||
async def milestone_trigger(user_id: int, milestone) -> str | None:
|
||||
"""A milestone closing in a project that is on a platform: the moment to
|
||||
ask whether what it built is something every project on that platform
|
||||
will face. Records nothing — a milestone is not a record an idea can hang
|
||||
on — so the hint asks for the note that would carry the idea."""
|
||||
project_id = getattr(milestone, "project_id", None)
|
||||
try:
|
||||
if not project_id or not await access.can_read_project(user_id, project_id):
|
||||
return None
|
||||
async with async_session() as session:
|
||||
names = (await session.execute(
|
||||
select(Platform.name)
|
||||
.join(ProjectPlatform, ProjectPlatform.platform_id == Platform.id)
|
||||
.where(ProjectPlatform.project_id == project_id,
|
||||
ProjectPlatform.state.in_(MEMBER_STATES),
|
||||
Platform.deleted_at.is_(None))
|
||||
.order_by(Platform.order_index.asc())
|
||||
)).scalars().all()
|
||||
except Exception:
|
||||
# Fail-open: the milestone is already closed; the hint is decoration.
|
||||
logger.warning("milestone trigger failed for %s", getattr(milestone, "id", None),
|
||||
exc_info=True)
|
||||
return None
|
||||
if not names:
|
||||
return None
|
||||
return (
|
||||
f"This milestone closed on {', '.join(names)}. If it solved something every "
|
||||
"project on those platforms will face — a problem the platform causes, or a "
|
||||
"stance held across projects — the idea belongs in the family: write it up "
|
||||
"as a note (when it applies, the traps, what proved it) and "
|
||||
"propose_family_idea it, or promote_family_idea if it already meets the "
|
||||
"three criteria. If what it built is this project's alone, nothing to do."
|
||||
)
|
||||
|
||||
|
||||
async def attach_family_hint(user_id: int, data: dict, note, *, created: bool) -> None:
|
||||
"""Ride the trigger hints on a write's response — fail-open: a hint must
|
||||
never break the write it rides on. Citation first (an explicit claim of
|
||||
lineage), then, on a create, the repeat check."""
|
||||
try:
|
||||
hint = await citation_trigger(user_id, note)
|
||||
if hint is None and created:
|
||||
hint = await repeat_trigger(user_id, note)
|
||||
if hint:
|
||||
data["family_hint"] = hint
|
||||
except Exception:
|
||||
logger.warning("family trigger failed for note %s", getattr(note, "id", None), exc_info=True)
|
||||
Reference in New Issue
Block a user