feat(family): the adoption ledger - assessment, the conflict order, owed->task, recheck, the adoption matrix (milestone 463 step 4, #4990)
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 1m0s
CI & Build / integration (push) Successful in 1m8s
CI & Build / Python tests (push) Failing after 1m28s
CI & Build / Build & push image (push) Skipped
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 1m0s
CI & Build / integration (push) Successful in 1m8s
CI & Build / Python tests (push) Failing after 1m28s
CI & Build / Build & push image (push) Skipped
- services/family_adoption.py: assess one project against one canon idea by the four outcomes in order (exempt, variant, adopted, owed). Every outcome needs a reason and adopted needs evidence. The engine records the precedents itself: this idea's answers elsewhere, and this project's answers to the nearest ideas. The same answer given twice records nothing. - owed files a task in the OWING project, tagged to the System matching the idea's canonical area, naming the gap and the reference for that project's language. The task follows the answer: adopted closes it, exempt or variant cancels it, owed again reopens it. Each move is logged on the task. - the conflict order is enforced: every ground above the deciding one must say why it did not decide. The losing side is folded into the idea's note as a trap, an alternative or a condition branch, the version moves, and both rows are answered against the revision. - recheck is derived (row version != idea version). family.revise moves the version when substance changes. undo covers a project's latest answer too. - set_family_references names the reference implementations. - MCP: get/list/assess adoption, resolve_family_conflict, revise_family_idea, set_family_references. Web: GET /api/family/matrix. - UI: an adoption matrix on /family (platform filter, cell detail with reason, recheck and owed-task link) and the same matrix narrowed to one project on its Family tab. The decision log now reads project-level decisions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+162
-27
@@ -232,35 +232,52 @@ async def list_ideas(
|
||||
|
||||
|
||||
async def list_decisions(
|
||||
user_id: int, *, idea_id: int | None = None, limit: int = 50, offset: int = 0,
|
||||
user_id: int, *, idea_id: int | None = None, project_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."""
|
||||
row says whether it is the one an undo would reverse; a project's
|
||||
assessment also carries the project's title."""
|
||||
async with async_session() as session:
|
||||
query = (
|
||||
select(FamilyDecision, Note.title)
|
||||
select(FamilyDecision, Note.title, Project.title)
|
||||
.join(Note, Note.id == FamilyDecision.idea_id)
|
||||
.outerjoin(Project, Project.id == FamilyDecision.project_id)
|
||||
.where(access.readable_notes_clause(user_id))
|
||||
)
|
||||
if idea_id:
|
||||
query = query.where(FamilyDecision.idea_id == idea_id)
|
||||
if project_id:
|
||||
query = query.where(FamilyDecision.project_id == project_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})
|
||||
latest = await _latest_idea_decisions(
|
||||
session, {d.idea_id for d, _, _ in rows if d.project_id is None})
|
||||
latest_pair = await _latest_pair_decisions(
|
||||
session, {(d.idea_id, d.project_id) for d, _, _ in rows if d.project_id is not None})
|
||||
out = []
|
||||
for d, title in rows:
|
||||
for d, title, project_title in rows:
|
||||
item = _decision_dict(d, title)
|
||||
item["undoable"] = latest.get(d.idea_id) == d.id and _can_undo(d)
|
||||
if d.project_id is None:
|
||||
item["undoable"] = latest.get(d.idea_id) == d.id and _can_undo(d)
|
||||
else:
|
||||
item["project_title"] = project_title
|
||||
item["undoable"] = latest_pair.get((d.idea_id, d.project_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
|
||||
"""A decision that changed something: an idea-level one, or one
|
||||
project's assessment. A veto (a `propose` whose before and after agree)
|
||||
changed nothing, and an undo is undone by deciding again, not by undoing
|
||||
the undo."""
|
||||
if d.before == d.after:
|
||||
return False
|
||||
if d.project_id is None:
|
||||
return d.action in _IDEA_ACTIONS
|
||||
return d.action == "assess"
|
||||
|
||||
|
||||
def _undoable_id(decisions_newest_first) -> int | None:
|
||||
@@ -270,6 +287,22 @@ def _undoable_id(decisions_newest_first) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
async def _latest_pair_decisions(session, pairs: set[tuple[int, int]]) -> dict[tuple[int, int], int]:
|
||||
"""The latest decision about each (idea, project) answer — the only one of
|
||||
a project's decisions on an idea that an undo may reverse."""
|
||||
if not pairs:
|
||||
return {}
|
||||
rows = await session.execute(
|
||||
select(FamilyDecision.idea_id, FamilyDecision.project_id, func.max(FamilyDecision.id))
|
||||
.where(
|
||||
FamilyDecision.idea_id.in_({i for i, _ in pairs}),
|
||||
FamilyDecision.project_id.in_({p for _, p in pairs}),
|
||||
)
|
||||
.group_by(FamilyDecision.idea_id, FamilyDecision.project_id)
|
||||
)
|
||||
return {(i, p): d for i, p, d in rows.all() if (i, p) in pairs}
|
||||
|
||||
|
||||
async def _latest_idea_decisions(session, idea_ids: set[int]) -> dict[int, int]:
|
||||
if not idea_ids:
|
||||
return {}
|
||||
@@ -281,11 +314,10 @@ async def _latest_idea_decisions(session, idea_ids: set[int]) -> dict[int, int]:
|
||||
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."""
|
||||
async def nearest_ideas(user_id: int, note_id: int, limit: int = 5) -> list[tuple[float, Note]]:
|
||||
"""The family ideas nearest this record by meaning, best first, as
|
||||
(score, note). Empty when there are no other ideas or the embedder is
|
||||
unavailable — precedent is a help to a decision, never a gate on it."""
|
||||
from scribe.services.embeddings import embedding_text, semantic_search_notes
|
||||
|
||||
async with async_session() as session:
|
||||
@@ -305,7 +337,15 @@ async def precedents(user_id: int, note_id: int, limit: int = 5) -> list[dict]:
|
||||
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]
|
||||
return [(score, n) for score, n in hits if n.id in idea_ids][:limit]
|
||||
|
||||
|
||||
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."""
|
||||
ranked = await nearest_ideas(user_id, note_id, limit)
|
||||
if not ranked:
|
||||
return []
|
||||
async with async_session() as session:
|
||||
@@ -521,15 +561,8 @@ async def promote(
|
||||
# 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
|
||||
if await _promoted_before(session, note_id):
|
||||
idea.canon_version = await next_version(session, idea)
|
||||
idea.status = "canon"
|
||||
idea.applies_when = applies_when.strip()
|
||||
idea.updated_at = _now()
|
||||
@@ -550,6 +583,100 @@ async def promote(
|
||||
}
|
||||
|
||||
|
||||
async def _promoted_before(session, note_id: int) -> bool:
|
||||
return bool(await session.scalar(
|
||||
select(func.count()).select_from(FamilyDecision)
|
||||
.where(FamilyDecision.idea_id == note_id, FamilyDecision.action == "promote")
|
||||
))
|
||||
|
||||
|
||||
async def next_version(session, idea: FamilyIdea) -> int:
|
||||
"""One past every canon version this idea has ever held — including one
|
||||
an undo rolled back from — so no answer given against an earlier canon
|
||||
can read as agreeing with the new one. Only idea-level snapshots carry an
|
||||
idea version; an assessment's `after` holds the version it was judged
|
||||
against, which is never ahead of the idea's."""
|
||||
rows = (await session.execute(
|
||||
select(FamilyDecision.after).where(
|
||||
FamilyDecision.idea_id == idea.note_id, FamilyDecision.project_id.is_(None),
|
||||
)
|
||||
)).scalars().all()
|
||||
seen = [idea.canon_version or 1] + [int(a.get("canon_version") or 1) for a in rows if a]
|
||||
return max(seen) + 1
|
||||
|
||||
|
||||
async def _close_unreached(session, note_id: int) -> int:
|
||||
"""Delete the `unassessed` rows of projects no longer on any of the
|
||||
idea's platforms — nobody judged them, and the idea no longer reaches
|
||||
them. Judged rows stay, as history."""
|
||||
members = (
|
||||
select(ProjectPlatform.project_id)
|
||||
.join(FamilyIdeaPlatform, FamilyIdeaPlatform.platform_id == ProjectPlatform.platform_id)
|
||||
.where(FamilyIdeaPlatform.note_id == note_id, ProjectPlatform.state.in_(MEMBER_STATES))
|
||||
)
|
||||
result = await session.execute(
|
||||
delete(FamilyAdoption).where(
|
||||
FamilyAdoption.idea_id == note_id, FamilyAdoption.status == "unassessed",
|
||||
FamilyAdoption.project_id.not_in(members),
|
||||
)
|
||||
)
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
async def revise(
|
||||
user_id: int, note_id: int, *, reason: str, applies_when: str | None = None,
|
||||
platforms: list[str] | None = None, evidence: list[str] | None = None,
|
||||
decided_via: str = "agent",
|
||||
) -> dict:
|
||||
"""Record that a canon idea's SUBSTANCE changed — its note was rewritten,
|
||||
its applicability narrowed or widened, or its platforms changed.
|
||||
|
||||
The version moves, so every project's answer given against the earlier
|
||||
version reads as needing a recheck (derived, never a stored flag). A
|
||||
project newly in scope gets an `unassessed` row; an `unassessed` row of a
|
||||
project no longer in scope is closed. Undoable like any idea-level
|
||||
decision.
|
||||
|
||||
`applies_when` and `platforms` are left alone when None; given, they
|
||||
replace the old ones and may not be empty (canon is always scoped).
|
||||
"""
|
||||
if not (reason or "").strip():
|
||||
raise ValueError("a revision needs a reason — what changed in the idea?")
|
||||
if not await access.can_write_note(user_id, note_id):
|
||||
raise ValueError(f"note {note_id} not found or no write access")
|
||||
if applies_when is not None and not applies_when.strip():
|
||||
raise ValueError("canon needs an 'applies when' — leave it out to keep the current one")
|
||||
if platforms is not None and not [p for p in platforms if (p or "").strip()]:
|
||||
raise ValueError("canon needs at least one platform — leave it out to keep the current ones")
|
||||
evidence = [str(e).strip() for e in evidence or [] if str(e).strip()]
|
||||
async with async_session() as session:
|
||||
idea = await session.get(FamilyIdea, note_id)
|
||||
if idea is None or idea.status != "canon":
|
||||
raise ValueError(f"#{note_id} is not family canon — only canon is revised")
|
||||
known = await _resolve_platforms(session, platforms) if platforms is not None else None
|
||||
before = await _snapshot(session, idea)
|
||||
idea.canon_version = await next_version(session, idea)
|
||||
if applies_when is not None:
|
||||
idea.applies_when = applies_when.strip()
|
||||
if known is not None:
|
||||
await _set_platforms(session, note_id, known.values())
|
||||
idea.updated_at = _now()
|
||||
await session.flush()
|
||||
opened = await _open_ledger(session, user_id, note_id)
|
||||
closed = await _close_unreached(session, note_id)
|
||||
decision = _log(
|
||||
session, idea_id=note_id, action="revise", reason=reason, before=before,
|
||||
after=await _snapshot(session, idea),
|
||||
evidence={"evidence": evidence, "ledger_rows_opened": opened,
|
||||
"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(),
|
||||
"ledger_rows_opened": opened, "ledger_rows_closed": closed}
|
||||
|
||||
|
||||
async def _existing_decision_ids(ids: list[int]) -> list[int]:
|
||||
ids = [int(i) for i in ids if i]
|
||||
if not ids:
|
||||
@@ -592,8 +719,11 @@ async def retire(user_id: int, note_id: int, *, reason: str, decided_via: str =
|
||||
|
||||
|
||||
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 —
|
||||
"""Reverse a decision, restoring the state it recorded as `before`. A
|
||||
project's assessment is handed to the adoption ledger
|
||||
(family_adoption.undo_assessment); the rest of this is idea-level.
|
||||
|
||||
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.
|
||||
@@ -607,6 +737,11 @@ async def undo(user_id: int, decision_id: int, *, reason: str, decided_via: str
|
||||
target = await session.get(FamilyDecision, decision_id)
|
||||
if target is None:
|
||||
raise ValueError(f"no such family decision: {decision_id}")
|
||||
if target.project_id is not None:
|
||||
# One project's answer: the adoption ledger restores the row.
|
||||
from scribe.services import family_adoption
|
||||
return await family_adoption.undo_assessment(
|
||||
user_id, target, reason=reason, decided_via=decided_via)
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user