fix(family): a decision read as precedent says its reason once - to_brief for precedents and idea history; the decision log keeps the full row (#5131)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m13s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Successful in 40s

Each decision stored its reason three times (reason, before.reason, after.reason)
and a promotion carried the whole criteria reasoning in evidence. Precedents
(assess, promote, get_family_adoption, get_family_idea) and get_family_idea's
history now serve FamilyDecision.to_brief(): reason once, before/after as status,
canon_version and platforms, evidence as the cited list. list_family_decisions
and the decision echoed back by a write keep to_dict. The precedent count was
already bounded at five.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 18:30:48 -04:00
co-authored by Claude Opus 5.5
parent 07169e1274
commit f006f15058
6 changed files with 88 additions and 6 deletions
+2 -1
View File
@@ -33,7 +33,8 @@ async def list_family_ideas(
async def get_family_idea(note_id: int) -> dict: async def get_family_idea(note_id: int) -> dict:
"""One family idea with what an evaluation needs: its state, platforms, """One family idea with what an evaluation needs: its state, platforms,
full decision history, ledger counts, THE THREE CRITERIA, the `ledger` decision history (brief — each reason once; list_family_decisions has
the full rows), ledger counts, THE THREE CRITERIA, the `ledger`
(every project's answer, with `needs_recheck`), and the `precedents` — (every project's answer, with `needs_recheck`), and the `precedents` —
the decisions on the ideas nearest this one by meaning. the decisions on the ideas nearest this one by meaning.
Read the precedents before deciding, and decide consistently with them Read the precedents before deciding, and decide consistently with them
+34
View File
@@ -357,3 +357,37 @@ class FamilyDecision(Base, CreatedAtMixin):
"user_id": self.user_id, "user_id": self.user_id,
"created_at": iso(self.created_at), "created_at": iso(self.created_at),
} }
def to_brief(self) -> dict:
"""The decision as a reader WEIGHING it needs it: what was decided,
why — once — and the state it moved between.
Precedents and an idea's history are read in bulk, a handful per
call, and the full row repeats itself: an assessment's reason is
stored again inside `after` (and the prior one inside `before`), and
a promotion's evidence carries the whole criteria reasoning. So a
state here is its status, version and platforms only, and evidence is
the cited list alone (#5131). The full row is `to_dict`, which the
decision log serves.
"""
return {
"id": self.id,
"idea_id": self.idea_id,
"project_id": self.project_id,
"action": self.action,
"reason": self.reason,
"before": _brief_state(self.before),
"after": _brief_state(self.after),
"evidence": list((self.evidence or {}).get("evidence") or []),
"created_at": iso(self.created_at),
}
# What a decision's before/after keeps in its brief: the state, not the prose.
_BRIEF_STATE_KEYS = ("status", "canon_version", "platforms")
def _brief_state(state: dict | None) -> dict | None:
if state is None:
return None
return {k: state[k] for k in _BRIEF_STATE_KEYS if k in state}
+4 -3
View File
@@ -211,7 +211,8 @@ async def get_idea(user_id: int, note_id: int) -> dict | None:
"platforms": await _platform_slugs(session, note_id), "platforms": await _platform_slugs(session, note_id),
"topic": await _topic(session, user_id, idea.topic_id, rules=True), "topic": await _topic(session, user_id, idea.topic_id, rules=True),
"adoptions": counts, "adoptions": counts,
"decisions": [_decision_dict(d) for d in decisions], # Brief: the history is read in bulk; the full rows are list_decisions (#5131).
"decisions": [d.to_brief() for d in decisions],
"undoable_decision_id": _undoable_id(decisions), "undoable_decision_id": _undoable_id(decisions),
}) })
out["criteria"] = list(CRITERIA) out["criteria"] = list(CRITERIA)
@@ -383,8 +384,8 @@ async def precedents(user_id: int, note_id: int, limit: int = 5) -> list[dict]:
for score, n in ranked: for score, n in ranked:
d = decisions.get(latest.get(n.id)) d = decisions.get(latest.get(n.id))
if d is not None: if d is not None:
item = _decision_dict(d, n.title) item = {**d.to_brief(), "idea_title": n.title,
item["similarity"] = round(float(score), 3) "similarity": round(float(score), 3)}
out.append(item) out.append(item)
return out return out
+1 -2
View File
@@ -545,8 +545,7 @@ async def assessment_precedents(user_id: int, project_id: int, idea_id: int,
for d, idea_title, project_title in rows: for d, idea_title, project_title in rows:
if not await access.can_read_project(user_id, d.project_id): if not await access.can_read_project(user_id, d.project_id):
continue continue
item = d.to_dict() item = {**d.to_brief(), "idea_title": idea_title, "project_title": project_title}
item.update({"idea_title": idea_title, "project_title": project_title})
if d.idea_id == idea_id: if d.idea_id == idea_id:
item["relation"] = "same idea, another project" item["relation"] = "same idea, another project"
out_same.append(item) out_same.append(item)
+44
View File
@@ -67,3 +67,47 @@ def test_a_canon_ideas_scope_lives_on_the_idea_and_nowhere_else():
assert "topic_id" in family.FamilyIdea.__table__.c assert "topic_id" in family.FamilyIdea.__table__.c
assert not {c.name for c in RulebookTopic.__table__.c} & {"platform_id", "platform_ids"} assert not {c.name for c in RulebookTopic.__table__.c} & {"platform_id", "platform_ids"}
# --- a decision read as precedent (#5131) ------------------------------------------
def _assessment():
reason = "the APK is built in CI and signed with the one release key"
return family.FamilyDecision(
id=7, idea_id=3, project_id=5, action="assess", reason=reason,
before={"status": "unassessed", "reason": "", "canon_version": None},
after={"status": "adopted", "reason": reason, "canon_version": 2},
evidence={"evidence": ["ci.yml::android-release"], "source": "shapes"},
precedent_ids=[4, 6], decided_via="agent", user_id=1,
)
def test_a_brief_carries_the_reason_once():
brief = _assessment().to_brief()
assert brief["reason"].startswith("the APK is built")
assert brief["before"] == {"status": "unassessed", "canon_version": None}
assert brief["after"] == {"status": "adopted", "canon_version": 2}
# Nothing in it says the reason a second time.
assert repr(brief).count("the APK is built") == 1
def test_a_brief_keeps_the_cited_evidence_and_drops_the_rest():
promotion = family.FamilyDecision(
id=8, idea_id=3, project_id=None, action="promote", reason="proven twice",
before=None,
after={"status": "canon", "applies_when": "an app that ships its own APK",
"canon_version": 1, "platforms": ["android-app"]},
evidence={"criteria": {"proven": "a long paragraph"}, "evidence": ["#12"],
"ledger_rows_opened": 3},
)
brief = promotion.to_brief()
assert brief["evidence"] == ["#12"]
assert brief["before"] is None
assert brief["after"] == {"status": "canon", "canon_version": 1,
"platforms": ["android-app"]}
def test_the_full_row_is_still_the_audit_shape():
full = _assessment().to_dict()
assert full["after"]["reason"] == full["reason"]
assert full["evidence"]["source"] == "shapes" and full["precedent_ids"] == [4, 6]
@@ -236,6 +236,9 @@ async def test_another_projects_answer_is_recorded_as_precedent(family):
assert first["decision"]["id"] in second["decision"]["precedent_ids"] assert first["decision"]["id"] in second["decision"]["precedent_ids"]
assert second["precedents"][0]["relation"] == "same idea, another project" assert second["precedents"][0]["relation"] == "same idea, another project"
assert second["precedents"][0]["project_title"] == "android one" assert second["precedents"][0]["project_title"] == "android one"
# A precedent says its reason once: before/after are state only (#5131).
assert second["precedents"][0]["reason"] == "the source app"
assert "reason" not in second["precedents"][0]["after"]
# --- recheck -------------------------------------------------------------------------- # --- recheck --------------------------------------------------------------------------