"""Rulebook / topic / rule service layer — single source of truth used by both routes/rulebooks.py and mcp/tools/rulebooks.py. Ownership enforcement: every function takes user_id and scopes through rulebooks.owner_user_id. Functions return models or model.to_dict() output depending on the caller's needs (mirroring services/events.py pattern). """ from __future__ import annotations import logging from typing import Optional from sqlalchemy import select from fabledassistant.models import async_session from fabledassistant.models.rulebook import Rulebook logger = logging.getLogger(__name__) # ── Rulebook CRUD ──────────────────────────────────────────────────────── async def create_rulebook( user_id: int, title: str, description: str = "", ) -> Rulebook: """Create a new rulebook owned by user_id. Returns the persisted model.""" async with async_session() as session: rb = Rulebook( owner_user_id=user_id, title=title, description=description or None, ) session.add(rb) await session.commit() await session.refresh(rb) return rb async def list_rulebooks(user_id: int) -> list[Rulebook]: """List rulebooks owned by user_id, ordered by title.""" async with async_session() as session: result = await session.execute( select(Rulebook) .where(Rulebook.owner_user_id == user_id, Rulebook.deleted_at.is_(None)) .order_by(Rulebook.title) ) return list(result.scalars().all()) async def get_rulebook(rulebook_id: int, user_id: int) -> Optional[Rulebook]: """Get a rulebook by id, scoped to user_id. None if not owned or not found.""" async with async_session() as session: result = await session.execute( select(Rulebook).where( Rulebook.id == rulebook_id, Rulebook.owner_user_id == user_id, Rulebook.deleted_at.is_(None), ) ) return result.scalar_one_or_none() async def update_rulebook( rulebook_id: int, user_id: int, **fields, ) -> Optional[Rulebook]: """Partial update. Returns updated rulebook or None if not found.""" async with async_session() as session: result = await session.execute( select(Rulebook).where( Rulebook.id == rulebook_id, Rulebook.owner_user_id == user_id, ) ) rb = result.scalar_one_or_none() if rb is None: return None allowed = {"title", "description", "always_on"} for key, value in fields.items(): if key in allowed and value is not None: setattr(rb, key, value) await session.commit() await session.refresh(rb) return rb async def delete_rulebook(rulebook_id: int, user_id: int) -> None: """Delete a rulebook. Cascade-deletes topics, rules, subscriptions.""" async with async_session() as session: result = await session.execute( select(Rulebook).where( Rulebook.id == rulebook_id, Rulebook.owner_user_id == user_id, ) ) rb = result.scalar_one_or_none() if rb is None: return await session.delete(rb) await session.commit() async def find_rulebook_by_title( user_id: int, title: str, ) -> Optional[Rulebook]: """Used by the port script for the dupe-guard. None if not found.""" async with async_session() as session: result = await session.execute( select(Rulebook).where( Rulebook.owner_user_id == user_id, Rulebook.title == title, Rulebook.deleted_at.is_(None), ) ) return result.scalar_one_or_none() # ── Topic CRUD ────────────────────────────────────────────────────────── from fabledassistant.models.rulebook import RulebookTopic async def _assert_rulebook_owned(session, rulebook_id: int, user_id: int) -> None: """Raise ValueError if rulebook doesn't exist or isn't owned by user. Centralizes ownership check used by all topic/rule operations. """ result = await session.execute( select(Rulebook).where( Rulebook.id == rulebook_id, Rulebook.owner_user_id == user_id, Rulebook.deleted_at.is_(None), ) ) if result.scalar_one_or_none() is None: raise ValueError(f"rulebook {rulebook_id} not found") async def create_topic( rulebook_id: int, user_id: int, title: str, description: str = "", order_index: int = 0, ) -> RulebookTopic: async with async_session() as session: await _assert_rulebook_owned(session, rulebook_id, user_id) topic = RulebookTopic( rulebook_id=rulebook_id, title=title, description=description or None, order_index=order_index, ) session.add(topic) await session.commit() await session.refresh(topic) return topic async def list_topics(rulebook_id: int, user_id: int) -> list[RulebookTopic]: async with async_session() as session: await _assert_rulebook_owned(session, rulebook_id, user_id) result = await session.execute( select(RulebookTopic) .where( RulebookTopic.rulebook_id == rulebook_id, RulebookTopic.deleted_at.is_(None), ) .order_by(RulebookTopic.order_index, RulebookTopic.title) ) return list(result.scalars().all()) async def get_topic(topic_id: int, user_id: int) -> Optional[RulebookTopic]: """Get a topic, scoped via the rulebook owner.""" async with async_session() as session: result = await session.execute( select(RulebookTopic) .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id) .where( RulebookTopic.id == topic_id, Rulebook.owner_user_id == user_id, RulebookTopic.deleted_at.is_(None), Rulebook.deleted_at.is_(None), ) ) return result.scalar_one_or_none() async def update_topic( topic_id: int, user_id: int, **fields, ) -> Optional[RulebookTopic]: async with async_session() as session: result = await session.execute( select(RulebookTopic) .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id) .where( RulebookTopic.id == topic_id, Rulebook.owner_user_id == user_id, ) ) topic = result.scalar_one_or_none() if topic is None: return None allowed = {"title", "description", "order_index"} for key, value in fields.items(): if key in allowed and value is not None: setattr(topic, key, value) await session.commit() await session.refresh(topic) return topic async def delete_topic(topic_id: int, user_id: int) -> None: async with async_session() as session: result = await session.execute( select(RulebookTopic) .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id) .where( RulebookTopic.id == topic_id, Rulebook.owner_user_id == user_id, ) ) topic = result.scalar_one_or_none() if topic is None: return await session.delete(topic) await session.commit() # ── Rule CRUD ────────────────────────────────────────────────────────── from fabledassistant.models.rulebook import Rule async def _assert_topic_owned(session, topic_id: int, user_id: int) -> None: """Raise ValueError if topic doesn't exist or isn't in user's rulebook.""" result = await session.execute( select(RulebookTopic) .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id) .where( RulebookTopic.id == topic_id, Rulebook.owner_user_id == user_id, RulebookTopic.deleted_at.is_(None), Rulebook.deleted_at.is_(None), ) ) if result.scalar_one_or_none() is None: raise ValueError(f"topic {topic_id} not found") async def _assert_project_owned(session, project_id: int, user_id: int) -> None: """Raise ValueError if project doesn't exist or isn't owned by user.""" from fabledassistant.models.project import Project result = await session.execute( select(Project).where( Project.id == project_id, Project.user_id == user_id, Project.deleted_at.is_(None), ) ) if result.scalar_one_or_none() is None: raise ValueError(f"project {project_id} not found") async def create_rule( topic_id: int, user_id: int, title: str, statement: str, why: str = "", how_to_apply: str = "", order_index: int = 0, ) -> Rule: async with async_session() as session: await _assert_topic_owned(session, topic_id, user_id) rule = Rule( topic_id=topic_id, title=title, statement=statement, why=why or None, how_to_apply=how_to_apply or None, order_index=order_index, ) session.add(rule) await session.commit() await session.refresh(rule) return rule async def create_project_rule( project_id: int, user_id: int, title: str, statement: str, why: str = "", how_to_apply: str = "", order_index: int = 0, ) -> Rule: """Create a rule scoped to a single project (no rulebook ceremony). Project-scoped rules apply only to the named project; they don't propagate via rulebook subscriptions. Topic_id is left NULL — the CHECK constraint enforces exactly-one of (topic_id, project_id). """ async with async_session() as session: await _assert_project_owned(session, project_id, user_id) rule = Rule( project_id=project_id, title=title, statement=statement, why=why or None, how_to_apply=how_to_apply or None, order_index=order_index, ) session.add(rule) await session.commit() await session.refresh(rule) return rule async def list_rules( user_id: int, rulebook_id: int | None = None, topic_id: int | None = None, project_id: int | None = None, ) -> list[Rule]: """List rules filtered by any of the three IDs. All filters are ownership-scoped. When project_id is set, the result includes both rulebook rules reached via project_rulebook_subscriptions AND project-scoped rules (Rule.project_id). When rulebook_id or topic_id is set, project-scoped rules are excluded by construction (they have neither). With no filter, only rulebook rules are returned — adding all of a user's project-scoped rules unprompted would surprise existing callers. """ from fabledassistant.models.rulebook import project_rulebook_subscriptions async with async_session() as session: stmt = ( select(Rule) .join(RulebookTopic, Rule.topic_id == RulebookTopic.id) .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id) .where( Rulebook.owner_user_id == user_id, Rule.deleted_at.is_(None), RulebookTopic.deleted_at.is_(None), Rulebook.deleted_at.is_(None), ) ) if topic_id: stmt = stmt.where(Rule.topic_id == topic_id) if rulebook_id: stmt = stmt.where(RulebookTopic.rulebook_id == rulebook_id) if project_id: stmt = ( stmt.join( project_rulebook_subscriptions, project_rulebook_subscriptions.c.rulebook_id == Rulebook.id, ) .where(project_rulebook_subscriptions.c.project_id == project_id) ) stmt = stmt.order_by( Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title, ) result = await session.execute(stmt) rulebook_rules = list(result.scalars().all()) if not project_id: return rulebook_rules # Project-scoped rules (topic_id IS NULL, project_id matches). # Verifies ownership by joining Project on user_id. from fabledassistant.models.project import Project proj_stmt = ( select(Rule) .join(Project, Rule.project_id == Project.id) .where( Project.user_id == user_id, Rule.project_id == project_id, Rule.deleted_at.is_(None), Project.deleted_at.is_(None), ) .order_by(Rule.order_index, Rule.title) ) proj_result = await session.execute(proj_stmt) return rulebook_rules + list(proj_result.scalars().all()) async def list_always_on_rules(user_id: int, limit: int = 100) -> list[Rule]: """Return all rules from rulebooks flagged always_on for the user. Called by the MCP tool of the same name at session start to load the standing rules that apply regardless of which project (if any) is in scope. Ordering matches list_rules so results are stable across calls. """ async with async_session() as session: result = await session.execute( select(Rule) .join(RulebookTopic, Rule.topic_id == RulebookTopic.id) .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id) .where( Rulebook.owner_user_id == user_id, Rulebook.always_on.is_(True), Rule.deleted_at.is_(None), RulebookTopic.deleted_at.is_(None), Rulebook.deleted_at.is_(None), ) .order_by( Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title, ) .limit(limit) ) return list(result.scalars().all()) async def _fetch_owned_rule(session, rule_id: int, user_id: int) -> Optional[Rule]: """Fetch a rule by id, scoped to user owning either its rulebook (via topic) or its project (via project_id). Honors soft-delete. Returns None when not found or not owned. """ from fabledassistant.models.project import Project # Path A — rulebook rule. rulebook_rule = (await session.execute( select(Rule) .join(RulebookTopic, Rule.topic_id == RulebookTopic.id) .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id) .where( Rule.id == rule_id, Rulebook.owner_user_id == user_id, Rule.deleted_at.is_(None), RulebookTopic.deleted_at.is_(None), Rulebook.deleted_at.is_(None), ) )).scalar_one_or_none() if rulebook_rule is not None: return rulebook_rule # Path B — project-scoped rule. project_rule = (await session.execute( select(Rule) .join(Project, Rule.project_id == Project.id) .where( Rule.id == rule_id, Project.user_id == user_id, Rule.deleted_at.is_(None), Project.deleted_at.is_(None), ) )).scalar_one_or_none() return project_rule async def get_rule(rule_id: int, user_id: int) -> Optional[Rule]: async with async_session() as session: return await _fetch_owned_rule(session, rule_id, user_id) async def update_rule(rule_id: int, user_id: int, **fields) -> Optional[Rule]: async with async_session() as session: rule = await _fetch_owned_rule(session, rule_id, user_id) if rule is None: return None allowed = {"title", "statement", "why", "how_to_apply", "order_index"} for key, value in fields.items(): if key in allowed and value is not None: setattr(rule, key, value) await session.commit() await session.refresh(rule) return rule async def delete_rule(rule_id: int, user_id: int) -> None: async with async_session() as session: rule = await _fetch_owned_rule(session, rule_id, user_id) if rule is None: return await session.delete(rule) await session.commit() # ── Subscriptions + get_applicable_rules ─────────────────────────────── from sqlalchemy import insert, delete as sql_delete async def subscribe_project( project_id: int, rulebook_id: int, user_id: int, ) -> None: """Add a subscription. Idempotent — duplicates raise; we swallow.""" from fabledassistant.models.rulebook import project_rulebook_subscriptions async with async_session() as session: await _assert_rulebook_owned(session, rulebook_id, user_id) # ON CONFLICT DO NOTHING via try/except to keep dialect-agnostic. try: await session.execute( insert(project_rulebook_subscriptions).values( project_id=project_id, rulebook_id=rulebook_id, ) ) await session.commit() except Exception: await session.rollback() # PK collision = already subscribed; fine. async def unsubscribe_project( project_id: int, rulebook_id: int, user_id: int, ) -> None: from fabledassistant.models.rulebook import project_rulebook_subscriptions async with async_session() as session: await _assert_rulebook_owned(session, rulebook_id, user_id) await session.execute( sql_delete(project_rulebook_subscriptions).where( project_rulebook_subscriptions.c.project_id == project_id, project_rulebook_subscriptions.c.rulebook_id == rulebook_id, ) ) await session.commit() async def get_applicable_rules( project_id: int, user_id: int, limit: int = 50, ) -> dict: """Return rules applicable to a project — both via rulebook subscriptions and project-scoped rules (Rule.project_id matches). Shape: { "rules": [{id, title, statement, topic_title, rulebook_title}, ...], "project_rules": [{id, title, statement}, ...], "truncated": bool, "subscribed_rulebooks": [{id, title}, ...] } `rules` is the subscription-derived set (legacy shape preserved). `project_rules` is the project-scoped set; empty list when none exist. """ from fabledassistant.models.rulebook import project_rulebook_subscriptions async with async_session() as session: # Subscribed rulebooks for the project (ownership-scoped). sub_q = ( select(Rulebook.id, Rulebook.title) .join( project_rulebook_subscriptions, project_rulebook_subscriptions.c.rulebook_id == Rulebook.id, ) .where( project_rulebook_subscriptions.c.project_id == project_id, Rulebook.owner_user_id == user_id, Rulebook.deleted_at.is_(None), ) .order_by(Rulebook.title) ) sub_rows = (await session.execute(sub_q)).all() subscribed_rulebooks = [ {"id": rb_id, "title": rb_title} for rb_id, rb_title in sub_rows ] # Applicable rules (limit + 1 so we can detect truncation). rules_q = ( select( Rule.id, Rule.title, Rule.statement, RulebookTopic.title.label("topic_title"), Rulebook.title.label("rulebook_title"), ) .join(RulebookTopic, Rule.topic_id == RulebookTopic.id) .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id) .join( project_rulebook_subscriptions, project_rulebook_subscriptions.c.rulebook_id == Rulebook.id, ) .where( project_rulebook_subscriptions.c.project_id == project_id, Rulebook.owner_user_id == user_id, Rule.deleted_at.is_(None), RulebookTopic.deleted_at.is_(None), Rulebook.deleted_at.is_(None), ) .order_by( Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title, ) .limit(limit + 1) ) rule_rows = (await session.execute(rules_q)).all() truncated = len(rule_rows) > limit rules = [ { "id": rid, "title": rtitle, "statement": stmt, "topic_title": tt, "rulebook_title": rbt, } for rid, rtitle, stmt, tt, rbt in rule_rows[:limit] ] # Project-scoped rules — verifies ownership via Project.user_id. from fabledassistant.models.project import Project proj_rules_q = ( select(Rule.id, Rule.title, Rule.statement) .join(Project, Rule.project_id == Project.id) .where( Project.user_id == user_id, Rule.project_id == project_id, Rule.deleted_at.is_(None), Project.deleted_at.is_(None), ) .order_by(Rule.order_index, Rule.title) ) proj_rule_rows = (await session.execute(proj_rules_q)).all() project_rules = [ {"id": rid, "title": rtitle, "statement": stmt} for rid, rtitle, stmt in proj_rule_rows ] return { "rules": rules, "project_rules": project_rules, "truncated": truncated, "subscribed_rulebooks": subscribed_rulebooks, }