ModPilot / store /models.py
ThejasRao's picture
Deploy ModPilot Investigation Engine
7302343
Raw
History Blame Contribute Delete
14.1 kB
"""SQLAlchemy 2.0 ORM models for the engine's Postgres tables.
Spec: docs/Specs.md §9.1, docs/07-DataLayer.md.
Invariant I-7: every persisted row carries `subreddit_id`. Queries that
forget to filter by `subreddit_id` violate the isolation guarantee from
[10-ReliabilityAndSafety.md] and ADR-0004.
"""
from __future__ import annotations
import uuid
from datetime import datetime # noqa: TC003 — Mapped[] annotations need runtime resolution
from sqlalchemy import (
BigInteger,
Boolean,
CheckConstraint,
DateTime,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
# === Enum value sources (strings, enforced via CheckConstraint). ===
PERSONALITIES = ("strict", "balanced", "lenient")
REGIONS = ("US", "EU", "UK", "IN", "Global")
TIER_OVERRIDES = ("auto", "fast", "standard", "deep")
RISK_TIERS = ("HIGH", "MEDIUM", "LOW")
RECOMMENDATIONS = ("REMOVE", "APPROVE", "ESCALATE", "LOCK", "NO_RECOMMENDATION")
STRATEGY_TIERS = ("FAST", "STANDARD", "DEEP")
TARGET_KINDS = ("comment", "post")
TOOL_STATUSES = ("success", "failure", "skipped", "timeout")
INVESTIGATION_STATUSES = ("pending", "completed", "failed")
USER_RISK_TIERS = ("new", "trusted", "neutral", "watched")
FEEDBACK_ACTIONS = ("REMOVE", "APPROVE", "ESCALATE", "LOCK")
FEEDBACK_SOURCES = ("verdict_card", "reddit_native")
def _check(col: str, values: tuple[str, ...], name: str) -> CheckConstraint:
quoted = ",".join(f"'{v}'" for v in values)
return CheckConstraint(f"{col} IN ({quoted})", name=name)
# === Tables =====================================================
class SubredditProfile(Base):
"""One row per subreddit install. Per docs/05-Memory.md §personality."""
__tablename__ = "subreddit_profile"
subreddit_id: Mapped[str] = mapped_column(String(20), primary_key=True)
name: Mapped[str] = mapped_column(String(64), nullable=False)
personality: Mapped[str] = mapped_column(String(16), nullable=False, default="balanced")
rules: Mapped[str] = mapped_column(Text, default="", nullable=False)
region: Mapped[str] = mapped_column(String(8), nullable=False, default="Global")
cold_start_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
calibration_weights: Mapped[dict[str, object]] = mapped_column(
JSONB, nullable=False, default=dict
)
show_cost_in_dashboard: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
kill_switch: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
tier_override: Mapped[str] = mapped_column(String(16), nullable=False, default="auto")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
__table_args__ = (
_check("personality", PERSONALITIES, "ck_subreddit_profile_personality"),
_check("region", REGIONS, "ck_subreddit_profile_region"),
_check("tier_override", TIER_OVERRIDES, "ck_subreddit_profile_tier_override"),
)
class UserMemory(Base):
"""Per-(subreddit, user) moderation memory. Exposed in UI as a tier label only."""
__tablename__ = "user_memory"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
subreddit_id: Mapped[str] = mapped_column(
String(20),
ForeignKey("subreddit_profile.subreddit_id", ondelete="CASCADE"),
nullable=False,
)
user_id: Mapped[str] = mapped_column(String(20), nullable=False)
risk_tier: Mapped[str] = mapped_column(String(16), nullable=False, default="new")
prior_violations: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
prior_approvals: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
last_seen_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
detail: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False, default=dict)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
__table_args__ = (
UniqueConstraint("subreddit_id", "user_id", name="uq_user_memory_sub_user"),
Index("ix_user_memory_subreddit", "subreddit_id"),
Index("ix_user_memory_user", "user_id"),
_check("risk_tier", USER_RISK_TIERS, "ck_user_memory_risk_tier"),
)
class ThreadMemory(Base):
"""Per-(subreddit, post) thread memory. Tracks mod actions and escalation.
Spec: docs/05-Memory.md §4.
"""
__tablename__ = "thread_memory"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
subreddit_id: Mapped[str] = mapped_column(
String(20),
ForeignKey("subreddit_profile.subreddit_id", ondelete="CASCADE"),
nullable=False,
)
post_id: Mapped[str] = mapped_column(String(20), nullable=False)
mod_actions_taken: Mapped[list[object]] = mapped_column(
JSONB, nullable=False, default=list
)
participants_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
last_summary: Mapped[str] = mapped_column(Text, nullable=False, default="")
last_summary_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
detail: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False, default=dict)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
__table_args__ = (
UniqueConstraint("subreddit_id", "post_id", name="uq_thread_memory_sub_post"),
Index("ix_thread_memory_subreddit", "subreddit_id"),
Index("ix_thread_memory_post", "post_id"),
)
class Investigation(Base):
"""One row per Engine /investigate call. The audit-trail backbone."""
__tablename__ = "investigation"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
correlation_id: Mapped[str] = mapped_column(String(80), nullable=False, unique=True)
subreddit_id: Mapped[str] = mapped_column(
String(20),
ForeignKey("subreddit_profile.subreddit_id", ondelete="CASCADE"),
nullable=False,
)
target_kind: Mapped[str] = mapped_column(String(8), nullable=False)
target_id: Mapped[str] = mapped_column(String(20), nullable=False)
target_body: Mapped[str] = mapped_column(Text, nullable=False, default="")
target_author_id: Mapped[str] = mapped_column(String(20), nullable=False, default="")
tier: Mapped[str] = mapped_column(String(16), nullable=False)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
# Verdict columns — null until reasoner completes
risk_tier: Mapped[str | None] = mapped_column(String(16), nullable=True)
recommendation: Mapped[str | None] = mapped_column(String(32), nullable=True)
calibrated_confidence: Mapped[float | None] = mapped_column(Float, nullable=True)
rationale: Mapped[str | None] = mapped_column(Text, nullable=True)
confidence_breakdown: Mapped[dict[str, object] | None] = mapped_column(JSONB, nullable=True)
model_reasoner: Mapped[str] = mapped_column(String(64), nullable=False, default="")
model_summarizer: Mapped[str] = mapped_column(String(64), nullable=False, default="")
cost_usd: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
latency_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
input_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
output_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
validation_flag: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
degraded: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
cold_start: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
evidence: Mapped[list[Evidence]] = relationship(
"Evidence", back_populates="investigation", cascade="all, delete-orphan"
)
__table_args__ = (
Index("ix_investigation_subreddit", "subreddit_id"),
Index("ix_investigation_target", "target_id"),
Index("ix_investigation_correlation", "correlation_id"),
_check("target_kind", TARGET_KINDS, "ck_investigation_target_kind"),
_check("tier", STRATEGY_TIERS, "ck_investigation_tier"),
_check("status", INVESTIGATION_STATUSES, "ck_investigation_status"),
CheckConstraint(
"risk_tier IS NULL OR risk_tier IN ('HIGH','MEDIUM','LOW')",
name="ck_investigation_risk_tier",
),
CheckConstraint(
"recommendation IS NULL OR recommendation IN "
"('REMOVE','APPROVE','ESCALATE','LOCK','NO_RECOMMENDATION')",
name="ck_investigation_recommendation",
),
CheckConstraint(
"calibrated_confidence IS NULL OR "
"(calibrated_confidence >= 0 AND calibrated_confidence <= 1)",
name="ck_investigation_confidence_range",
),
)
class Evidence(Base):
"""Tool results that fed the verdict. Stable `ev-N` ids per investigation."""
__tablename__ = "evidence"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
investigation_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("investigation.id", ondelete="CASCADE"),
nullable=False,
)
subreddit_id: Mapped[str] = mapped_column(String(20), nullable=False)
evidence_id: Mapped[str] = mapped_column(String(16), nullable=False)
tool: Mapped[str] = mapped_column(String(32), nullable=False)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="success")
summary: Mapped[str] = mapped_column(String(280), nullable=False, default="")
detail: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False, default=dict)
latency_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
investigation: Mapped[Investigation] = relationship("Investigation", back_populates="evidence")
__table_args__ = (
UniqueConstraint(
"investigation_id", "evidence_id", name="uq_evidence_investigation_evid"
),
Index("ix_evidence_investigation", "investigation_id"),
Index("ix_evidence_subreddit", "subreddit_id"),
_check("status", TOOL_STATUSES, "ck_evidence_status"),
)
class Feedback(Base):
"""Moderator alignment with a verdict. Drives cold-start counter + calibration."""
__tablename__ = "feedback"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
correlation_id: Mapped[str] = mapped_column(String(80), nullable=False)
subreddit_id: Mapped[str] = mapped_column(String(20), nullable=False)
target_id: Mapped[str] = mapped_column(String(20), nullable=False)
mod_action: Mapped[str] = mapped_column(String(16), nullable=False)
raw_action: Mapped[str] = mapped_column(String(32), nullable=False, default="")
moderator_id: Mapped[str] = mapped_column(String(20), nullable=False, default="")
moderator_name: Mapped[str] = mapped_column(String(64), nullable=False, default="")
source: Mapped[str] = mapped_column(String(32), nullable=False)
aligned: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
Index("ix_feedback_correlation", "correlation_id"),
Index("ix_feedback_subreddit", "subreddit_id"),
Index("ix_feedback_target", "target_id"),
_check("mod_action", FEEDBACK_ACTIONS, "ck_feedback_mod_action"),
_check("source", FEEDBACK_SOURCES, "ck_feedback_source"),
)
class AuditLog(Base):
"""Immutable event log. Append-only; 90d retention per docs/Specs.md §13.3."""
__tablename__ = "audit_log"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
subreddit_id: Mapped[str] = mapped_column(String(20), nullable=False)
correlation_id: Mapped[str | None] = mapped_column(String(80), nullable=True)
event_type: Mapped[str] = mapped_column(String(64), nullable=False)
actor: Mapped[str] = mapped_column(String(64), nullable=False, default="system")
detail: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False, default=dict)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
Index("ix_audit_log_subreddit_time", "subreddit_id", "created_at"),
Index("ix_audit_log_correlation", "correlation_id"),
Index("ix_audit_log_event_type", "event_type"),
)