Renames src/fabledassistant -> src/scribe and all imports, plus the default DB name and DB user/password (fabled -> scribe) in config + compose. 952 refs / 154 files. Reverses the old 'internal name stays fabledassistant' convention. Code-only: live databases are still physically named 'fabledassistant'. Deployed environments must set POSTGRES_DB / POSTGRES_USER (or rename the DB) since the defaults now resolve to 'scribe'. Repo (FabledScribe), git host (fabledsword), MCP (fabled-git) and the image name (fabledscribe) are intentionally unchanged. ruff check src/ clean locally; CI (typecheck + pytest) is the gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
782 lines
29 KiB
Python
782 lines
29 KiB
Python
"""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 scribe.models import async_session
|
|
from scribe.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 scribe.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 scribe.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 scribe.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 _assert_rulebook_rule_owned(session, rule_id: int, user_id: int) -> None:
|
|
"""Raise ValueError if rule isn't a rulebook rule the user owns.
|
|
|
|
Project-scoped rules (Rule.project_id set, topic_id NULL) are NOT
|
|
suppressible — they belong to the project; delete them instead. This
|
|
helper deliberately excludes them.
|
|
"""
|
|
from scribe.models.rulebook import Rule
|
|
result = 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),
|
|
)
|
|
)
|
|
if result.scalar_one_or_none() is None:
|
|
raise ValueError(f"rule {rule_id} not found or not a rulebook rule")
|
|
|
|
|
|
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 scribe.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 scribe.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 scribe.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 scribe.models.rulebook import project_rulebook_subscriptions
|
|
|
|
async with async_session() as session:
|
|
await _assert_project_owned(session, project_id, user_id)
|
|
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 scribe.models.rulebook import project_rulebook_subscriptions
|
|
|
|
async with async_session() as session:
|
|
await _assert_project_owned(session, project_id, user_id)
|
|
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()
|
|
|
|
|
|
# ── Suppressions — project-level mute of rulebook rules / topics ────────
|
|
|
|
async def suppress_rule_for_project(
|
|
project_id: int, rule_id: int, user_id: int,
|
|
) -> None:
|
|
"""Mute one rulebook rule for one project. Idempotent."""
|
|
from scribe.models.rulebook import project_rule_suppressions
|
|
|
|
async with async_session() as session:
|
|
await _assert_project_owned(session, project_id, user_id)
|
|
await _assert_rulebook_rule_owned(session, rule_id, user_id)
|
|
try:
|
|
await session.execute(
|
|
insert(project_rule_suppressions).values(
|
|
project_id=project_id, rule_id=rule_id,
|
|
)
|
|
)
|
|
await session.commit()
|
|
except Exception:
|
|
await session.rollback() # PK collision = already suppressed; fine.
|
|
|
|
|
|
async def unsuppress_rule_for_project(
|
|
project_id: int, rule_id: int, user_id: int,
|
|
) -> None:
|
|
"""Unmute one rulebook rule for one project. Idempotent."""
|
|
from scribe.models.rulebook import project_rule_suppressions
|
|
|
|
async with async_session() as session:
|
|
await _assert_project_owned(session, project_id, user_id)
|
|
await session.execute(
|
|
sql_delete(project_rule_suppressions).where(
|
|
project_rule_suppressions.c.project_id == project_id,
|
|
project_rule_suppressions.c.rule_id == rule_id,
|
|
)
|
|
)
|
|
await session.commit()
|
|
|
|
|
|
async def suppress_topic_for_project(
|
|
project_id: int, topic_id: int, user_id: int,
|
|
) -> None:
|
|
"""Mute every rule under one topic for one project. Idempotent."""
|
|
from scribe.models.rulebook import project_topic_suppressions
|
|
|
|
async with async_session() as session:
|
|
await _assert_project_owned(session, project_id, user_id)
|
|
await _assert_topic_owned(session, topic_id, user_id)
|
|
try:
|
|
await session.execute(
|
|
insert(project_topic_suppressions).values(
|
|
project_id=project_id, topic_id=topic_id,
|
|
)
|
|
)
|
|
await session.commit()
|
|
except Exception:
|
|
await session.rollback()
|
|
|
|
|
|
async def unsuppress_topic_for_project(
|
|
project_id: int, topic_id: int, user_id: int,
|
|
) -> None:
|
|
"""Unmute a topic for one project. Idempotent."""
|
|
from scribe.models.rulebook import project_topic_suppressions
|
|
|
|
async with async_session() as session:
|
|
await _assert_project_owned(session, project_id, user_id)
|
|
await session.execute(
|
|
sql_delete(project_topic_suppressions).where(
|
|
project_topic_suppressions.c.project_id == project_id,
|
|
project_topic_suppressions.c.topic_id == topic_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), with suppressed rules
|
|
and suppressed topics filtered out.
|
|
|
|
Shape:
|
|
{
|
|
"rules": [{id, title, statement,
|
|
topic_id, topic_title,
|
|
rulebook_id, rulebook_title}, ...],
|
|
"project_rules": [{id, title, statement}, ...],
|
|
"suppressed_rules": [{id, title,
|
|
topic_id, topic_title,
|
|
rulebook_id, rulebook_title}, ...],
|
|
"suppressed_topics": [{id, title,
|
|
rulebook_id, rulebook_title}, ...],
|
|
"truncated": bool,
|
|
"subscribed_rulebooks": [{id, title}, ...]
|
|
}
|
|
|
|
`rules` is the subscription-derived set with project-level suppressions
|
|
applied. `project_rules` is the project-scoped set (never suppressed —
|
|
delete instead). `suppressed_rules` / `suppressed_topics` carry the
|
|
titles + rulebook context callers need to display what was filtered
|
|
without round-tripping for names.
|
|
"""
|
|
from scribe.models.rulebook import (
|
|
project_rulebook_subscriptions,
|
|
project_rule_suppressions,
|
|
project_topic_suppressions,
|
|
)
|
|
|
|
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
|
|
]
|
|
|
|
# Suppressed rules — joined to topic + rulebook so callers can render
|
|
# context without a follow-up lookup. Ownership-scoped via rulebook.
|
|
suppressed_rules_q = (
|
|
select(
|
|
Rule.id, Rule.title,
|
|
RulebookTopic.id.label("topic_id"),
|
|
RulebookTopic.title.label("topic_title"),
|
|
Rulebook.id.label("rulebook_id"),
|
|
Rulebook.title.label("rulebook_title"),
|
|
)
|
|
.join(project_rule_suppressions, project_rule_suppressions.c.rule_id == Rule.id)
|
|
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
|
|
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
|
|
.where(
|
|
project_rule_suppressions.c.project_id == project_id,
|
|
Rulebook.owner_user_id == user_id,
|
|
)
|
|
.order_by(Rulebook.title, RulebookTopic.title, Rule.title)
|
|
)
|
|
suppressed_rule_rows = (await session.execute(suppressed_rules_q)).all()
|
|
suppressed_rules = [
|
|
{"id": rid, "title": rt, "topic_id": ti, "topic_title": tt,
|
|
"rulebook_id": rbi, "rulebook_title": rbt}
|
|
for rid, rt, ti, tt, rbi, rbt in suppressed_rule_rows
|
|
]
|
|
suppressed_rule_ids = [r["id"] for r in suppressed_rules]
|
|
|
|
# Suppressed topics — joined to rulebook for context.
|
|
suppressed_topics_q = (
|
|
select(
|
|
RulebookTopic.id, RulebookTopic.title,
|
|
Rulebook.id.label("rulebook_id"),
|
|
Rulebook.title.label("rulebook_title"),
|
|
)
|
|
.join(project_topic_suppressions, project_topic_suppressions.c.topic_id == RulebookTopic.id)
|
|
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
|
|
.where(
|
|
project_topic_suppressions.c.project_id == project_id,
|
|
Rulebook.owner_user_id == user_id,
|
|
)
|
|
.order_by(Rulebook.title, RulebookTopic.title)
|
|
)
|
|
suppressed_topic_rows = (await session.execute(suppressed_topics_q)).all()
|
|
suppressed_topics = [
|
|
{"id": tid, "title": tt, "rulebook_id": rbi, "rulebook_title": rbt}
|
|
for tid, tt, rbi, rbt in suppressed_topic_rows
|
|
]
|
|
suppressed_topic_ids = [t["id"] for t in suppressed_topics]
|
|
|
|
# Applicable rules (limit + 1 so we can detect truncation). Filter
|
|
# in SQL so truncation reflects the post-suppression count, not the
|
|
# raw subscription count.
|
|
rules_q = (
|
|
select(
|
|
Rule.id, Rule.title, Rule.statement,
|
|
RulebookTopic.id.label("topic_id"),
|
|
RulebookTopic.title.label("topic_title"),
|
|
Rulebook.id.label("rulebook_id"),
|
|
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)
|
|
)
|
|
if suppressed_rule_ids:
|
|
rules_q = rules_q.where(Rule.id.notin_(suppressed_rule_ids))
|
|
if suppressed_topic_ids:
|
|
rules_q = rules_q.where(Rule.topic_id.notin_(suppressed_topic_ids))
|
|
rule_rows = (await session.execute(rules_q)).all()
|
|
truncated = len(rule_rows) > limit
|
|
rules = [
|
|
{
|
|
"id": rid, "title": rtitle, "statement": stmt,
|
|
"topic_id": ti, "topic_title": tt,
|
|
"rulebook_id": rbi, "rulebook_title": rbt,
|
|
}
|
|
for rid, rtitle, stmt, ti, tt, rbi, rbt in rule_rows[:limit]
|
|
]
|
|
|
|
# Project-scoped rules — verifies ownership via Project.user_id.
|
|
from scribe.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,
|
|
"suppressed_rules": suppressed_rules,
|
|
"suppressed_topics": suppressed_topics,
|
|
"truncated": truncated,
|
|
"subscribed_rulebooks": subscribed_rulebooks,
|
|
}
|