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..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") @@ -211,7 +219,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 +392,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 @@ -927,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) @@ -941,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/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 -------------------------------------------------------------------------- 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")