From f006f150584253fe9e0cbc98a2ee280c81d2dec6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 18:30:48 -0400 Subject: [PATCH 1/2] 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) 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 --- src/scribe/mcp/tools/family.py | 3 +- src/scribe/models/family.py | 34 ++++++++++++++++++ src/scribe/services/family.py | 7 ++-- src/scribe/services/family_adoption.py | 3 +- tests/test_family_models.py | 44 +++++++++++++++++++++++ tests/test_integration_family_adoption.py | 3 ++ 6 files changed, 88 insertions(+), 6 deletions(-) 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 -------------------------------------------------------------------------- -- 2.54.0 From 5323a1fbc269a3a4e1c625e800effe9ce9468f93 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 18:32:55 -0400 Subject: [PATCH 2/2] fix(family): the repeat trigger compares a record only with its own kind - a snippet with snippets, a note with notes (#5130) A dated release log (a note) matched a signed-APK CI snippet at 0.83 on shared release vocabulary and was opened as a family candidate, then came back as a precedent. One chunk of a long narrative scores like the shape it mentions, so a cross-kind score does not mean "the same thing made twice". The search is filtered to the new record's kind and the loop checks it too. Code built from an idea written elsewhere still reaches the engine through the citation trigger and the shape ledger. Co-Authored-By: Claude Opus 5.5 --- src/scribe/services/family.py | 26 ++++++++++++++++------ tests/test_integration_family_promotion.py | 20 +++++++++++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/scribe/services/family.py b/src/scribe/services/family.py index e0ed02ad..b0fb7a0c 100644 --- a/src/scribe/services/family.py +++ b/src/scribe/services/family.py @@ -119,7 +119,15 @@ _LINEAGE = re.compile( _LINEAGE_WINDOW = 80 # Note kinds the repeat trigger compares: recorded knowledge and shapes, not -# the to-do list. +# the to-do list — and each only against its own kind (#5130). A repeat is the +# same KIND of thing made twice: two shapes built, or two ideas written down. +# Across kinds the score stops meaning that. One chunk of a long narrative +# note that mentions a shape's vocabulary scores like the shape itself: a +# dated release log matched a signed-APK CI snippet at 0.83, above this +# trigger's floor, and opened the log as a candidate. The cross-kind relation +# that IS real — code built from an idea written elsewhere — has its own +# routes: the citation trigger when the lineage is named, and the shape ledger +# once the idea is canon. _REPEAT_KINDS = ("snippet", "note") MEMBER_STATES = ("declared", "detected") @@ -928,13 +936,15 @@ async def citation_trigger(user_id: int, note) -> str | 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.""" + """A new record whose meaning repeats a record OF THE SAME KIND 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, when the project has no platforms yet, or when the only match + is a different kind (`_REPEAT_KINDS` says why).""" from scribe.services.embeddings import embedding_text, semantic_search_notes - if not getattr(note, "project_id", None) or note.is_task: + kind = getattr(note, "note_type", None) or "note" + if not getattr(note, "project_id", None) or note.is_task or kind not in _REPEAT_KINDS: return None async with async_session() as session: mine = await _member_platform_ids(session, note.project_id) @@ -942,12 +952,14 @@ async def repeat_trigger(user_id: int, note) -> str | None: 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, + limit=8, threshold=REPEAT_THRESHOLD, scope="read", note_type=kind, is_task=False, demote_superseded=False, ) for score, other in hits: if not other.project_id or other.project_id == note.project_id: continue + if (other.note_type or "note") != kind: + 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): diff --git a/tests/test_integration_family_promotion.py b/tests/test_integration_family_promotion.py index 31cddbb4..39dc8479 100644 --- a/tests/test_integration_family_promotion.py +++ b/tests/test_integration_family_promotion.py @@ -278,6 +278,26 @@ async def test_a_repeat_with_no_shared_platform_is_silent(family): assert await _idea(family["note"]) is None +async def test_a_repeat_across_kinds_is_silent(family): + """A snippet whose only match is a NOTE is not a repeat (#5130): a dated + release log scored 0.83 against a signed-APK CI snippet on shared + vocabulary and was opened as a candidate. The stub hands back the + cross-kind hit directly, so this pins the trigger's own check, not the + search filter in front of it.""" + async with async_session() as s: + snippet = Note(user_id=family["owner"], project_id=family["b"], note_type="snippet", + title="android-release lane", body="sign with the one release key") + s.add(snippet) + await s.commit() + await s.refresh(snippet) + original = await s.get(Note, family["note"]) + assert original.note_type == "note" + with patch("scribe.services.embeddings.semantic_search_notes", + AsyncMock(return_value=[(0.86, original)])): + assert await family_svc.repeat_trigger(family["owner"], snippet) is None + assert await _idea(family["note"]) is None + + async def test_a_milestone_closing_on_a_platform_asks_and_one_off_platform_does_not(family): async with async_session() as s: on_platform = Milestone(user_id=family["owner"], project_id=family["a"], title="m1") -- 2.54.0