feat(family): family canon reaches the session - entry readout, retrieval reach, skill, report cue (milestone 463 step 6, #4992)
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
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
- 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>
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
"""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)) == []
|
||||
Reference in New Issue
Block a user