Files
FabledScribe/tests/test_integration_family_reach.py
T
bvandeusenandClaude Opus 5.5 07e21c7ff9
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Failing after 1m53s
CI & Build / Python tests (push) Successful in 2m39s
CI & Build / Build & push image (push) Skipped
feat(family): family canon reaches the session - entry readout, retrieval reach, skill, report cue (milestone 463 step 6, #4992)
- enter_project carries a `family` key, but only when the project has
  something to answer: counts of unassessed, owed and to-recheck answers,
  each with the list_family_adoptions call that lists it. It shows on every
  entry, never by platform touch: entry is when work is chosen, and an
  unanswered idea is otherwise invisible.
- Retrieval: a widened project search (include_global_kinds) now also
  reaches the canon ideas on the project's platforms. It also reaches their
  references in the project's languages, or all of them when none matches.
  An off-platform project gets none, and the plain project filter (the
  duplicate gate) is unchanged.
- Closing a task returns `family_owed`, the owed answers filed while it was
  open, and the report cue asks for them to be named.
- New plugin skill family-canon (moment work.record) covers when to
  evaluate a promotion, answering in order, what counts as a reason, the
  precedent reflex and the conflict order. _INSTRUCTIONS, create_note,
  create_snippet, classify_shapes and reporting-back point at it.
  The plugin version is minted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:27:39 -04:00

171 lines
7.9 KiB
Python

"""A family idea reaches every project on its platform, and no other
(milestone 463 step 6).
WHY THIS IS AN INTEGRATION TEST — the lesson-reach reasoning (#3730) holds
here too: the reach is one `OR` inside the search's project filter, and only
the ROWS that come back prove it. Every note embeds identically to the query,
so scoping is the only thing that can separate them. The embedder is stubbed;
no similarity is asserted, only membership.
The corpus: an idea written in android one, canon for the Android platform,
with a Kotlin and a Python reference. Android two writes Python, so it should
reach the idea and the Python reference only. The Go service is on no shared
platform and should reach none of it.
"""
import uuid
from unittest.mock import AsyncMock, patch
import pytest
import pytest_asyncio
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.embedding import EMBEDDING_DIM, NoteEmbedding
from scribe.models.family import (
FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform, FamilyIdeaReference,
Platform, ProjectPlatform,
)
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.services import family_adoption as adoption_svc
from scribe.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL, semantic_search_notes
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
QUERY_VEC = [1.0] + [0.0] * (EMBEDDING_DIM - 1)
async def _platform(s, slug: str) -> int:
return await s.scalar(select(Platform.id).where(
Platform.slug == slug, Platform.deleted_at.is_(None)))
@pytest_asyncio.fixture
async def corpus():
tag = uuid.uuid4().hex[:8]
async with async_session() as s:
owner = await ensure_user(s, f"family_reach_owner_{tag}")
await s.flush()
android, go = await _platform(s, "android-app"), await _platform(s, "go")
a = Project(user_id=owner.id, title="android one")
b = Project(user_id=owner.id, title="android two")
c = Project(user_id=owner.id, title="go service")
s.add_all([a, b, c])
await s.flush()
s.add_all([
ProjectPlatform(project_id=a.id, platform_id=android, state="declared"),
ProjectPlatform(project_id=b.id, platform_id=android, state="declared"),
ProjectPlatform(project_id=c.id, platform_id=go, state="declared"),
])
def snippet(project, title, language):
return Note(user_id=owner.id, project_id=project.id, note_type="snippet",
title=title, body=title, data={"language": language})
rows = {
"idea": Note(user_id=owner.id, project_id=a.id, note_type="note",
title="Signed APK lane", body="one keystore, in-place update"),
"kotlin_ref": snippet(a, "installUpdate (kotlin)", "kotlin"),
"python_ref": snippet(a, "install_update (python)", "python"),
"note_on_a": Note(user_id=owner.id, project_id=a.id, note_type="note",
title="An ordinary note", body="ordinary"),
"b_own": snippet(b, "b's own python helper", "python"),
}
s.add_all(rows.values())
await s.flush()
s.add(FamilyIdea(note_id=rows["idea"].id, status="canon",
applies_when="any Android app that ships its own APK"))
await s.flush()
s.add_all([
FamilyIdeaPlatform(note_id=rows["idea"].id, platform_id=android),
FamilyIdeaReference(idea_id=rows["idea"].id, snippet_id=rows["kotlin_ref"].id),
FamilyIdeaReference(idea_id=rows["idea"].id, snippet_id=rows["python_ref"].id),
])
for note in rows.values():
s.add(NoteEmbedding(
note_id=note.id, chunk_index=0, user_id=owner.id,
embedding=QUERY_VEC, chunk_text=note.title,
chunker_version=CHUNKER_VERSION, embedding_model=EMBEDDING_MODEL,
))
ids = {k: n.id for k, n in rows.items()}
ids.update(owner=owner.id, a=a.id, b=b.id, c=c.id)
await s.commit()
return ids
async def _search(uid, **kw):
with patch("scribe.services.embeddings.get_embedding", AsyncMock(return_value=QUERY_VEC)):
hits = await semantic_search_notes(uid, "how does the app update itself", limit=20, **kw)
return {note.id for _score, note in hits}
async def test_an_idea_reaches_a_project_on_its_platform_with_the_reference_in_its_language(corpus):
found = await _search(corpus["owner"], project_id=corpus["b"], include_global_kinds=True)
assert {corpus["idea"], corpus["python_ref"], corpus["b_own"]} <= found
# Android two writes Python: the Kotlin reference stays with the idea.
assert corpus["kotlin_ref"] not in found
assert corpus["note_on_a"] not in found
async def test_an_idea_does_not_reach_a_project_on_no_shared_platform(corpus):
found = await _search(corpus["owner"], project_id=corpus["c"], include_global_kinds=True)
assert not found & {corpus["idea"], corpus["kotlin_ref"], corpus["python_ref"]}
async def test_the_reach_is_off_unless_the_search_widens_the_project(corpus):
"""The near-duplicate gate searches a project without widening it; a
family idea there would block a create on a project it was never
written in."""
found = await _search(corpus["owner"], project_id=corpus["b"])
assert found == {corpus["b_own"]}
async def test_with_no_reference_in_the_projects_language_every_reference_comes(corpus):
async with async_session() as s:
b_own = await s.get(Note, corpus["b_own"])
b_own.data = {"language": "dart"}
await s.commit()
reach = await adoption_svc.family_reach_ids(corpus["b"])
assert reach == {corpus["idea"], corpus["kotlin_ref"], corpus["python_ref"]}
async def test_a_retired_idea_reaches_nobody(corpus):
async with async_session() as s:
(await s.get(FamilyIdea, corpus["idea"])).status = "retired"
await s.commit()
assert await adoption_svc.family_reach_ids(corpus["b"]) == set()
# --- the entry readout and the owed answers a close hands back -------------------
async def test_the_entry_readout_counts_what_the_project_has_to_answer(corpus):
line = await adoption_svc.family_readout(corpus["owner"], corpus["b"])
assert (line["unassessed"], line["owed"], line["needs_recheck"]) == (1, 0, 0)
assert line["calls"]["unassessed"] == (
f'list_family_adoptions(project_id={corpus["b"]}, status="unassessed")')
# Nothing reaches the Go service, so it carries no line at all.
assert await adoption_svc.family_readout(corpus["owner"], corpus["c"]) is None
async def test_owed_since_lists_what_is_still_owed_and_only_since_then(corpus):
from datetime import datetime, timedelta, timezone
async with async_session() as s:
s.add(FamilyAdoption(project_id=corpus["b"], idea_id=corpus["idea"], status="owed",
reason="no in-place update yet", canon_version=1,
decided_via="agent"))
s.add(FamilyDecision(idea_id=corpus["idea"], project_id=corpus["b"], action="assess",
reason="no in-place update yet",
before={"status": "unassessed", "reason": "", "canon_version": None},
after={"status": "owed", "reason": "no in-place update yet",
"canon_version": 1},
evidence={}, precedent_ids=[], decided_via="agent",
user_id=corpus["owner"]))
await s.commit()
now = datetime.now(timezone.utc)
[owed] = await adoption_svc.owed_since(corpus["owner"], now - timedelta(minutes=5))
assert (owed["idea_id"], owed["project_id"], owed["project_title"]) == (
corpus["idea"], corpus["b"], "android two")
assert await adoption_svc.owed_since(corpus["owner"], now + timedelta(minutes=5)) == []