diff --git a/src/scribe/mcp/tools/family.py b/src/scribe/mcp/tools/family.py index 30e25e2d..23b3a35a 100644 --- a/src/scribe/mcp/tools/family.py +++ b/src/scribe/mcp/tools/family.py @@ -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 diff --git a/src/scribe/models/family.py b/src/scribe/models/family.py index d89bba83..72202387 100644 --- a/src/scribe/models/family.py +++ b/src/scribe/models/family.py @@ -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} diff --git a/src/scribe/services/family.py b/src/scribe/services/family.py index 0f328802..e0ed02ad 100644 --- a/src/scribe/services/family.py +++ b/src/scribe/services/family.py @@ -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 diff --git a/src/scribe/services/family_adoption.py b/src/scribe/services/family_adoption.py index e4313fb3..435b1a84 100644 --- a/src/scribe/services/family_adoption.py +++ b/src/scribe/services/family_adoption.py @@ -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) diff --git a/tests/test_family_models.py b/tests/test_family_models.py index 050d17df..c45ec23d 100644 --- a/tests/test_family_models.py +++ b/tests/test_family_models.py @@ -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] diff --git a/tests/test_integration_family_adoption.py b/tests/test_integration_family_adoption.py index 8c911784..dda4a712 100644 --- a/tests/test_integration_family_adoption.py +++ b/tests/test_integration_family_adoption.py @@ -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 --------------------------------------------------------------------------