feat(family): an idea links to the rule topic holding its norms - set_family_topic, the readouts name the rules, the idea row shows the link (milestone 463 step 1 gap, found in step 7, #4993)
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 1m9s
CI & Build / integration (push) Successful in 1m21s
CI & Build / Python tests (push) Successful in 2m2s
CI & Build / Build & push image (push) Successful in 1m29s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 1m9s
CI & Build / integration (push) Successful in 1m21s
CI & Build / Python tests (push) Successful in 2m2s
CI & Build / Build & push image (push) Successful in 1m29s
Step 1 added family_ideas.topic_id but nothing could set it, so the note<->topic link the milestone promised existed only as a column. set_family_topic links or unlinks (0) a topic in a rulebook the caller owns, one idea per topic; the version does not move. get_family_idea and get_family_adoption list the topic's rules; list_family_ideas and the Family page name the topic. The model docstrings no longer claim the topic's rules are platform-scoped in retrieval. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -196,7 +196,7 @@ _WRITE_TOOLS = frozenset({
|
||||
"undo_family_decision",
|
||||
# family canon — the adoption ledger
|
||||
"revise_family_idea", "assess_family_adoption", "resolve_family_conflict",
|
||||
"set_family_references",
|
||||
"set_family_references", "set_family_topic",
|
||||
"bind_repo", "unbind_repo",
|
||||
# snippets, processes, the shape ledger
|
||||
"create_snippet", "update_snippet", "delete_snippet", "verify_snippet",
|
||||
|
||||
@@ -372,12 +372,31 @@ async def set_family_references(note_id: int, snippet_ids: list[int]) -> dict:
|
||||
return {"references": refs}
|
||||
|
||||
|
||||
async def set_family_topic(note_id: int, topic_id: int) -> dict:
|
||||
"""Link a family idea to the rule topic that holds its binding norms — the
|
||||
rules every project on its platforms follows. The note says when the idea
|
||||
applies and why; the topic's rules say what must hold. One idea per topic.
|
||||
get_family_idea and get_family_adoption then list those rules, so an
|
||||
assessment judges against them.
|
||||
|
||||
Use it when an idea already has rules written for it, or when you write
|
||||
rules that exist to carry one idea. The version does not move: linking
|
||||
changes where the norms are found, not what they say.
|
||||
|
||||
Args:
|
||||
note_id: the family idea.
|
||||
topic_id: a topic in a rulebook you own (list_rulebooks,
|
||||
list_topics). 0 unlinks.
|
||||
"""
|
||||
return await family_svc.set_topic(current_user_id(), note_id, topic_id)
|
||||
|
||||
|
||||
def register(mcp) -> None:
|
||||
for fn in (
|
||||
list_family_ideas, get_family_idea, propose_family_idea,
|
||||
promote_family_idea, retire_family_idea, undo_family_decision,
|
||||
list_family_decisions, revise_family_idea, get_family_adoption,
|
||||
list_family_adoptions, assess_family_adoption, resolve_family_conflict,
|
||||
set_family_references,
|
||||
set_family_references, set_family_topic,
|
||||
):
|
||||
mcp.tool(name=fn.__name__)(fn)
|
||||
|
||||
@@ -23,8 +23,9 @@ The tables, and the one job each does:
|
||||
- `family_ideas` — a note's family state: candidate, canon or retired, its
|
||||
applicability test, its canon version, its linked rule topic.
|
||||
- `family_idea_platforms` — the platforms an idea is for. The ONLY scope
|
||||
source: a linked topic takes its scope from here rather than carrying its
|
||||
own, so the two can never disagree.
|
||||
source: a linked topic carries no platforms of its own, so the two can
|
||||
never disagree. The scope decides which projects owe the idea an answer;
|
||||
the topic's rules still reach a session by ordinary rule retrieval.
|
||||
- `family_idea_references` — the reference implementations.
|
||||
- `family_adoptions` — one row per (project, idea): the project's answer.
|
||||
- `family_decisions` — the append-only log of every promotion and every
|
||||
@@ -165,8 +166,9 @@ class FamilyIdea(Base, TimestampMixin):
|
||||
comparing the two numbers, never stored as a flag that could go stale.
|
||||
|
||||
`topic_id` is the rule topic holding the idea's binding norms, if it has
|
||||
any (the note↔topic link #3236 asked for). The topic takes its scope from
|
||||
this idea; it never carries platforms of its own.
|
||||
any (the note↔topic link #3236 asked for), set by `set_topic`. The topic
|
||||
never carries platforms of its own; the idea's readouts list its rules,
|
||||
so an assessment judges a project against them.
|
||||
"""
|
||||
|
||||
__tablename__ = "family_ideas"
|
||||
|
||||
@@ -52,7 +52,9 @@ from scribe.models.family import (
|
||||
)
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.project import Project
|
||||
from scribe.models.rulebook import Rule
|
||||
from scribe.services import access
|
||||
from scribe.services import rulebooks as rulebooks_svc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -140,6 +142,26 @@ async def _platform_slugs(session, note_id: int) -> list[str]:
|
||||
return list(rows.scalars().all())
|
||||
|
||||
|
||||
async def _topic(session, user_id: int, topic_id: int | None, *, rules: bool) -> dict | None:
|
||||
"""The rule topic holding an idea's binding norms, for a reader. Rulebooks
|
||||
belong to their owner, so a topic the reader does not own reads as no
|
||||
topic."""
|
||||
if not topic_id:
|
||||
return None
|
||||
topic = await rulebooks_svc.get_topic(topic_id, user_id)
|
||||
if topic is None:
|
||||
return None
|
||||
out = {"id": topic.id, "title": topic.title, "rulebook_id": topic.rulebook_id}
|
||||
if rules:
|
||||
out["rules"] = [
|
||||
{"id": r.id, "title": r.title} for r in (await session.execute(
|
||||
select(Rule).where(Rule.topic_id == topic.id, Rule.deleted_at.is_(None))
|
||||
.order_by(Rule.order_index.asc(), Rule.id.asc())
|
||||
)).scalars().all()
|
||||
]
|
||||
return out
|
||||
|
||||
|
||||
async def _snapshot(session, idea: FamilyIdea | None) -> dict | None:
|
||||
"""The idea's state, as a decision's before/after. Slugs, never ids: an id
|
||||
inside JSON cannot be remapped by a restore (the #3182 trap)."""
|
||||
@@ -187,6 +209,7 @@ async def get_idea(user_id: int, note_id: int) -> dict | None:
|
||||
"is_task": note.is_task,
|
||||
"project_id": note.project_id,
|
||||
"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],
|
||||
"undoable_decision_id": _undoable_id(decisions),
|
||||
@@ -226,6 +249,7 @@ async def list_ideas(
|
||||
"is_task": note_status is not None,
|
||||
"project_id": project_id,
|
||||
"platforms": await _platform_slugs(session, idea.note_id),
|
||||
"topic": await _topic(session, user_id, idea.topic_id, rules=False),
|
||||
})
|
||||
out.append(d)
|
||||
return out
|
||||
@@ -677,6 +701,42 @@ async def revise(
|
||||
"ledger_rows_opened": opened, "ledger_rows_closed": closed}
|
||||
|
||||
|
||||
async def set_topic(user_id: int, note_id: int, topic_id: int) -> dict:
|
||||
"""Link an idea to the rule topic holding its binding norms, or unlink it
|
||||
with topic_id 0. The note carries the idea; the topic carries the rules a
|
||||
project on its platforms must follow. One idea per topic, since a topic's
|
||||
norms belong to one standard.
|
||||
|
||||
Not a change of substance, so the version does not move: the norms were
|
||||
rules before the link and are the same rules after it. Re-linking is the
|
||||
undo, as with the references."""
|
||||
if not await access.can_write_note(user_id, note_id):
|
||||
raise ValueError(f"note {note_id} not found or no write access")
|
||||
async with async_session() as session:
|
||||
idea = await session.get(FamilyIdea, note_id)
|
||||
if idea is None:
|
||||
raise ValueError(f"#{note_id} is not a family idea")
|
||||
if topic_id:
|
||||
if await rulebooks_svc.get_topic(topic_id, user_id) is None:
|
||||
raise ValueError(f"topic {topic_id} not found in a rulebook you own")
|
||||
holder = (await session.execute(
|
||||
select(FamilyIdea.note_id, Note.title)
|
||||
.join(Note, Note.id == FamilyIdea.note_id)
|
||||
.where(FamilyIdea.topic_id == topic_id, FamilyIdea.note_id != note_id)
|
||||
)).first()
|
||||
if holder is not None:
|
||||
raise ValueError(
|
||||
f"topic {topic_id} already carries the norms of #{holder[0]} "
|
||||
f"“{holder[1]}” — unlink it there first, or merge the two ideas"
|
||||
)
|
||||
idea.topic_id = topic_id or None
|
||||
idea.updated_at = _now()
|
||||
await session.commit()
|
||||
out = idea.to_dict()
|
||||
out["topic"] = await _topic(session, user_id, idea.topic_id, rules=True)
|
||||
return out
|
||||
|
||||
|
||||
async def _existing_decision_ids(ids: list[int]) -> list[int]:
|
||||
ids = [int(i) for i in ids if i]
|
||||
if not ids:
|
||||
|
||||
Reference in New Issue
Block a user