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")