Merge dev → main: family precedents say their reason once; the repeat trigger compares like with like #208

Merged
bvandeusen merged 2 commits from dev into main 2026-10-06 19:00:18 -04:00
6 changed files with 88 additions and 6 deletions
Showing only changes of commit f006f15058 - Show all commits
+2 -1
View File
@@ -33,7 +33,8 @@ async def list_family_ideas(
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, 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` —
the decisions on the ideas nearest this one by meaning.
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,
"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),
"topic": await _topic(session, user_id, idea.topic_id, rules=True),
"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),
})
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:
d = decisions.get(latest.get(n.id))
if d is not None:
item = _decision_dict(d, n.title)
item["similarity"] = round(float(score), 3)
item = {**d.to_brief(), "idea_title": n.title,
"similarity": round(float(score), 3)}
out.append(item)
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:
if not await access.can_read_project(user_id, d.project_id):
continue
item = d.to_dict()
item.update({"idea_title": idea_title, "project_title": project_title})
item = {**d.to_brief(), "idea_title": idea_title, "project_title": project_title}
if d.idea_id == idea_id:
item["relation"] = "same idea, another project"
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 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 second["precedents"][0]["relation"] == "same idea, another project"
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 --------------------------------------------------------------------------