import logging import hashlib import uuid from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.models import AbstractUser from django.db import models from core.clients import signalapi from core.lib.notify import raw_sendmsg logger = logging.getLogger(__name__) SERVICE_CHOICES = ( ("signal", "Signal"), ("xmpp", "XMPP"), ("instagram", "Instagram"), ) MBTI_CHOICES = ( ("INTJ", "INTJ - Architect"), ("INTP", "INTP - Logician"), ("ENTJ", "ENTJ - Commander"), ("ENTP", "ENTP - Debater"), ("INFJ", "INFJ - Advocate"), ("INFP", "INFP - Mediator"), ("ENFJ", "ENFJ - Protagonist"), ("ENFP", "ENFP - Campaigner"), ("ISTJ", "ISTJ - Logistician"), ("ISFJ", "ISFJ - Defender"), ("ESTJ", "ESTJ - Executive"), ("ESFJ", "ESFJ - Consul"), ("ISTP", "ISTP - Virtuoso"), ("ISFP", "ISFP - Adventurer"), ("ESTP", "ESTP - Entrepreneur"), ("ESFP", "ESFP - Entertainer"), ) MODEL_CHOICES = ( ("gpt-4o-mini", "GPT 4o Mini"), ("gpt-4o", "GPT 4o"), ) def _attribute_display_id(kind, *parts): """ Build a deterministic short display id from object attributes. Format: - 3 lowercase letters - 4 fixed digits Example: `kqa1042` """ raw = "|".join([kind, *[str(part or "") for part in parts]]) digest = hashlib.sha1(raw.encode("utf-8")).hexdigest() n_letters = int(digest[:8], 16) letters = [] for _ in range(3): letters.append(chr(ord("a") + (n_letters % 26))) n_letters //= 26 digits = int(digest[8:16], 16) % 10000 return f"{''.join(letters)}{digits:04d}" def get_default_workspace_user_pk(): """ Fallback owner used when adding non-null `user` FKs to existing rows. """ user_pk = ( get_user_model().objects.order_by("id").values_list("id", flat=True).first() ) return user_pk or 1 class User(AbstractUser): # Stripe customer ID stripe_id = models.CharField(max_length=255, null=True, blank=True) customer_id = models.UUIDField(default=uuid.uuid4, null=True, blank=True) billing_provider_id = models.CharField(max_length=255, null=True, blank=True) email = models.EmailField(unique=True) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._original = self def get_notification_settings(self): return NotificationSettings.objects.get_or_create(user=self)[0] def sendmsg(self, *args, **kwargs): notification_settings = self.get_notification_settings() if notification_settings.ntfy_topic is None: # No topic set, so don't send return else: topic = notification_settings.ntfy_topic raw_sendmsg(*args, **kwargs, url=notification_settings.ntfy_url, topic=topic) class NotificationSettings(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE) ntfy_topic = models.CharField(max_length=255, null=True, blank=True) ntfy_url = models.CharField(max_length=255, null=True, blank=True) def __str__(self): return f"Notification settings for {self.user}" class Chat(models.Model): source_number = models.CharField(max_length=32, null=True, blank=True) source_uuid = models.CharField(max_length=255, null=True, blank=True) source_name = models.CharField(max_length=255, null=True, blank=True) account = models.CharField(max_length=32, null=True, blank=True) class AI(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) base_url = models.CharField(max_length=255, null=True, blank=True) api_key = models.CharField(max_length=255, null=True, blank=True) model = models.CharField(max_length=255, choices=MODEL_CHOICES) def __str__(self): return f"{self.id} - {self.model}" class Person(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) user = models.ForeignKey(User, on_delete=models.CASCADE) name = models.CharField(max_length=255) summary = models.TextField(blank=True, null=True) profile = models.TextField(blank=True, null=True) revealed = models.TextField(blank=True, null=True) dislikes = models.TextField(blank=True, null=True) likes = models.TextField(blank=True, null=True) # -1 (disliked) to +1 (trusted) sentiment = models.FloatField(default=0.0) timezone = models.CharField(max_length=50, blank=True, null=True) last_interaction = models.DateTimeField(blank=True, null=True) def __str__(self): return self.name class PersonIdentifier(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) identifier = models.CharField(max_length=255) service = models.CharField(choices=SERVICE_CHOICES, max_length=255) person = models.ForeignKey(Person, on_delete=models.CASCADE) def __str__(self): return f"{self.person} ({self.service})" async def send(self, text, attachments=[]): """ Send this contact a text. """ if self.service == "signal": ts = await signalapi.send_message_raw( self.identifier, text, attachments, ) print("SENT") return ts else: raise NotImplementedError(f"Service not implemented: {self.service}") class ChatSession(models.Model): """Represents an ongoing chat session for persisted message history.""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) user = models.ForeignKey(User, on_delete=models.CASCADE) identifier = models.ForeignKey(PersonIdentifier, on_delete=models.CASCADE) last_interaction = models.DateTimeField(blank=True, null=True) summary = models.TextField(blank=True, null=True) def __str__(self): return f"{self.identifier.person.name} ({self.identifier.service})" class QueuedMessage(models.Model): """Stores individual messages linked to a ChatSession.""" user = models.ForeignKey(User, on_delete=models.CASCADE) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) session = models.ForeignKey(ChatSession, on_delete=models.CASCADE) manipulation = models.ForeignKey("core.Manipulation", on_delete=models.CASCADE) ts = models.BigIntegerField() # Use Unix timestamp sender_uuid = models.CharField(max_length=255, blank=True, null=True) # Signal UUID text = models.TextField(blank=True, null=True) custom_author = models.CharField(max_length=255, blank=True, null=True) class Meta: ordering = ["ts"] class Message(models.Model): """Stores individual messages linked to a ChatSession.""" user = models.ForeignKey(User, on_delete=models.CASCADE) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) session = models.ForeignKey(ChatSession, on_delete=models.CASCADE) ts = models.BigIntegerField() # Use Unix timestamp sender_uuid = models.CharField(max_length=255, blank=True, null=True) # Signal UUID text = models.TextField(blank=True, null=True) custom_author = models.CharField(max_length=255, blank=True, null=True) class Meta: ordering = ["ts"] class Group(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) user = models.ForeignKey(User, on_delete=models.CASCADE) name = models.CharField(max_length=255) people = models.ManyToManyField(Person) def __str__(self): return self.name class Persona(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) user = models.ForeignKey(User, on_delete=models.CASCADE) # Core Identity # id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) alias = models.CharField( max_length=255, blank=True, null=True ) # Preferred name or persona alias mbti = models.CharField(max_length=255, choices=MBTI_CHOICES, blank=True, null=True) # -1: assertive, +1: assertive mbti_identity = models.FloatField(default=0.0) # Key Behavioral Traits for Chat Responses inner_story = models.TextField( blank=True, null=True ) # Internal philosophy & worldview core_values = models.TextField( blank=True, null=True ) # What drives their decisions & interactions communication_style = models.TextField( blank=True, null=True ) # How they speak & interact flirting_style = models.TextField( blank=True, null=True ) # How they express attraction humor_style = models.CharField( max_length=50, choices=[ ("dry", "Dry"), ("dark", "Dark"), ("playful", "Playful"), ("teasing", "Teasing"), ("sarcastic", "Sarcastic"), ("intellectual", "Intellectual"), ], blank=True, null=True, ) # Defines their approach to humor # Conversational Preferences likes = models.TextField(blank=True, null=True) # Topics they enjoy discussing dislikes = models.TextField(blank=True, null=True) # Topics or behaviors they avoid tone = models.CharField( max_length=50, choices=[ ("formal", "Formal"), ("casual", "Casual"), ("witty", "Witty"), ("serious", "Serious"), ("warm", "Warm"), ("detached", "Detached"), ], blank=True, null=True, ) # Defines preferred conversational tone # Emotional & Strategic Interaction response_tactics = models.TextField( blank=True, null=True ) # How they handle gaslighting, guilt-tripping, etc. persuasion_tactics = models.TextField( blank=True, null=True ) # How they convince others boundaries = models.TextField( blank=True, null=True ) # What they refuse to tolerate in conversations trust = models.IntegerField( default=50 ) # Percentage of initial trust given in interactions adaptability = models.IntegerField( default=70 ) # How easily they shift tones or styles def __str__(self): return f"{self.alias} ({self.mbti}) [{self.tone} {self.humor_style}]" class Manipulation(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) user = models.ForeignKey(User, on_delete=models.CASCADE) name = models.CharField(max_length=255) group = models.ForeignKey(Group, on_delete=models.CASCADE) # self = models.ForeignKey(Group, on_delete=models.CASCADE) ai = models.ForeignKey(AI, on_delete=models.CASCADE) persona = models.ForeignKey(Persona, on_delete=models.CASCADE) enabled = models.BooleanField(default=False) filter_enabled = models.BooleanField(default=False) mode = models.CharField( max_length=50, choices=[ ("active", "Send replies to messages"), ("instant", "Click link to send reply"), ("prospective", "Click link to open page"), ("notify", "Send notification of ideal reply only"), ("mutate", "Change messages sent on XMPP using the persona"), ("silent", "Do not generate or send replies"), ], blank=True, null=True, ) def __str__(self): return f"{self.name} [{self.group}]" class WorkspaceConversation(models.Model): """ Canonical conversation workspace used by the UI. This is intentionally distinct from ChatSession: - ChatSession is operational history for existing manip/reply flows. - WorkspaceConversation is analysis/workspace context for AI tooling. TODO: Identify conversation "active periods" dynamically using observed mutual response cadence (global + per-person baseline), rather than fixed windows. """ class StabilityState(models.TextChoices): CALIBRATING = "calibrating", "Calibrating" STABLE = "stable", "Stable" WATCH = "watch", "Watch" FRAGILE = "fragile", "Fragile" id = models.UUIDField( primary_key=True, default=uuid.uuid4, editable=False, help_text="Stable identifier for this workspace conversation.", ) user = models.ForeignKey( User, on_delete=models.CASCADE, related_name="workspace_conversations", help_text="Owner of this conversation workspace.", ) title = models.CharField( max_length=255, blank=True, default="", help_text="Human-friendly label shown in the workspace sidebar.", ) platform_type = models.CharField( max_length=255, choices=SERVICE_CHOICES, default="signal", help_text="Primary transport for this conversation (reuses SERVICE_CHOICES).", ) platform_thread_id = models.CharField( max_length=255, blank=True, default="", help_text="Platform-native thread/group identifier when available.", ) participants = models.ManyToManyField( Person, related_name="workspace_conversations", blank=True, help_text="Resolved people participating in this conversation.", ) participant_feedback = models.JSONField( default=dict, blank=True, help_text=( "Per-person interaction feedback map keyed by person UUID. " "Example: {'': {'state': 'withdrawing', 'note': 'short replies'}}." ), ) last_event_ts = models.BigIntegerField( null=True, blank=True, help_text="Latest message timestamp (unix ms) currently known.", ) last_ai_run_at = models.DateTimeField( null=True, blank=True, help_text="Last time any AIRequest finished for this conversation.", ) stability_state = models.CharField( max_length=32, choices=StabilityState.choices, default=StabilityState.CALIBRATING, help_text="UI label for relationship stability, baseline-aware.", ) stability_score = models.FloatField( null=True, blank=True, help_text="Relationship stability score (0-100). Null while calibrating.", ) stability_confidence = models.FloatField( default=0.0, help_text="Confidence in stability_score (0.0-1.0).", ) stability_sample_messages = models.PositiveIntegerField( default=0, help_text="How many messages were used to compute stability.", ) stability_sample_days = models.PositiveIntegerField( default=0, help_text="How many calendar days of data were used for stability.", ) stability_last_computed_at = models.DateTimeField( null=True, blank=True, help_text="Timestamp of the latest stability computation.", ) commitment_outbound_score = models.FloatField( null=True, blank=True, help_text=( "Estimated commitment score for user -> counterpart direction (0-100). " "Null while calibrating." ), ) commitment_inbound_score = models.FloatField( null=True, blank=True, help_text=( "Estimated commitment score for counterpart -> user direction (0-100). " "Null while calibrating." ), ) commitment_confidence = models.FloatField( default=0.0, help_text="Confidence in commitment scores (0.0-1.0).", ) commitment_last_computed_at = models.DateTimeField( null=True, blank=True, help_text="Timestamp of the latest commitment computation.", ) created_at = models.DateTimeField( auto_now_add=True, help_text="Row creation timestamp.", ) def __str__(self): return self.title or f"{self.platform_type}:{self.id}" class MessageEvent(models.Model): """ Normalized message event used by workspace timeline and AI selection windows. """ SOURCE_SYSTEM_CHOICES = ( ("signal", "Signal"), ("xmpp", "XMPP"), ("workspace", "Workspace"), ("ai", "AI"), ) DIRECTION_CHOICES = ( ("in", "Inbound"), ("out", "Outbound"), ) id = models.UUIDField( primary_key=True, default=uuid.uuid4, editable=False, help_text="Stable identifier for this message event.", ) user = models.ForeignKey( User, on_delete=models.CASCADE, related_name="workspace_message_events", default=get_default_workspace_user_pk, help_text="Owner of this message event row (required for restricted CRUD filtering).", ) conversation = models.ForeignKey( WorkspaceConversation, on_delete=models.CASCADE, related_name="events", help_text=( "AI workspace conversation this message belongs to. " "This is not the transport-native thread object." ), ) source_system = models.CharField( max_length=32, choices=SOURCE_SYSTEM_CHOICES, default="signal", help_text="System that produced this event record.", ) ts = models.BigIntegerField( db_index=True, help_text="Event timestamp (unix ms) as reported by source_system.", ) direction = models.CharField( max_length=8, choices=DIRECTION_CHOICES, help_text=( "Direction relative to workspace owner: " "'in' from counterpart(s), 'out' from user/bot side." ), ) sender_uuid = models.CharField( max_length=255, blank=True, default="", db_index=True, help_text="Source sender UUID/identifier for correlation.", ) text = models.TextField( blank=True, default="", help_text="Normalized message text body.", ) attachments = models.JSONField( default=list, blank=True, help_text="Attachment metadata list associated with this message.", ) raw_payload_ref = models.JSONField( default=dict, blank=True, help_text="Raw source payload or reference pointer for audit/debug.", ) created_at = models.DateTimeField( auto_now_add=True, help_text="Row creation timestamp.", ) class Meta: ordering = ["ts"] class AIRequest(models.Model): """ User-initiated AI run against a selected message window. TODO: Resolve message window dynamically based on available model context budget and content size (chunk-aware), not fixed hard caps. """ STATUS_CHOICES = ( ("queued", "Queued"), ("running", "Running"), ("done", "Done"), ("failed", "Failed"), ) OPERATION_CHOICES = ( ("summarise", "Summarise"), ("draft_reply", "Draft Reply"), ("critique", "Critique"), ("repair", "Repair"), ("extract_patterns", "Extract Patterns"), ("memory_propose", "Memory Propose"), ("state_detect", "State Detect"), ("rewrite_style", "Rewrite Style"), ("send_readiness", "Send Readiness"), ("timeline_brief", "Timeline Brief"), ) id = models.UUIDField( primary_key=True, default=uuid.uuid4, editable=False, help_text="Stable identifier for this AI request.", ) user = models.ForeignKey( User, on_delete=models.CASCADE, related_name="workspace_ai_requests", help_text="User who initiated this request.", ) conversation = models.ForeignKey( WorkspaceConversation, on_delete=models.CASCADE, related_name="ai_requests", help_text="Conversation analyzed by this request.", ) window_spec = models.JSONField( default=dict, help_text=( "Selection spec (last_n/since_ts/between_ts/include_attachments/etc). " "Should be dynamically resolved by available context/token budget." ), ) message_ids = models.JSONField( default=list, blank=True, help_text="Resolved ordered MessageEvent IDs included in this run.", ) user_notes = models.TextField( blank=True, default="", help_text="Optional user intent/context notes injected into the prompt.", ) operation = models.CharField( max_length=32, choices=OPERATION_CHOICES, help_text="Requested AI operation type.", ) policy_snapshot = models.JSONField( default=dict, blank=True, help_text=( "Effective manipulation/policy values captured at request time, " "so results remain auditable even if policies change later." ), ) status = models.CharField( max_length=16, choices=STATUS_CHOICES, default="queued", help_text="Worker lifecycle state for this request.", ) error = models.TextField( blank=True, default="", help_text="Error details when status='failed'.", ) created_at = models.DateTimeField( auto_now_add=True, help_text="Request creation timestamp.", ) started_at = models.DateTimeField( null=True, blank=True, help_text="Worker start timestamp.", ) finished_at = models.DateTimeField( null=True, blank=True, help_text="Worker completion timestamp.", ) class AIResult(models.Model): """ Persisted output payload for a completed AIRequest. """ id = models.UUIDField( primary_key=True, default=uuid.uuid4, editable=False, help_text="Stable identifier for this AI result payload.", ) user = models.ForeignKey( User, on_delete=models.CASCADE, related_name="workspace_ai_results", default=get_default_workspace_user_pk, help_text="Owner of this AI result row (required for restricted CRUD filtering).", ) ai_request = models.OneToOneField( AIRequest, on_delete=models.CASCADE, related_name="result", help_text="Owning AI request for this result.", ) working_summary = models.TextField( blank=True, default="", help_text="Conversation working summary generated for this run.", ) draft_replies = models.JSONField( default=list, blank=True, help_text="Draft reply candidates, typically with tone and rationale.", ) interaction_signals = models.JSONField( default=list, blank=True, help_text=( "Structured positive/neutral/risk signals inferred for this run. " "Example item: {'label':'repair_attempt','valence':'positive','message_event_ids':[...]}." ), ) memory_proposals = models.JSONField( default=list, blank=True, help_text="Proposed memory entries, typically requiring user approval.", ) citations = models.JSONField( default=list, blank=True, help_text="Referenced MessageEvent IDs supporting generated claims.", ) created_at = models.DateTimeField( auto_now_add=True, help_text="Result creation timestamp.", ) class MemoryItem(models.Model): """ Durable/semi-durable memory used to provide continuity across AI runs. """ MEMORY_KIND_CHOICES = ( ("fact", "Durable Fact/Preference"), ("state", "Relationship State"), ("summary", "Conversation Working Summary"), ) STATUS_CHOICES = ( ("proposed", "Proposed"), ("active", "Active"), ("deprecated", "Deprecated"), ) id = models.UUIDField( primary_key=True, default=uuid.uuid4, editable=False, help_text="Stable identifier for this memory item.", ) user = models.ForeignKey( User, on_delete=models.CASCADE, related_name="workspace_memory_items", help_text="Owner of the memory item.", ) conversation = models.ForeignKey( WorkspaceConversation, on_delete=models.CASCADE, related_name="memory_items", help_text="Conversation scope this memory item belongs to.", ) memory_kind = models.CharField( max_length=16, choices=MEMORY_KIND_CHOICES, help_text="Memory kind: fact/state/summary.", ) status = models.CharField( max_length=16, choices=STATUS_CHOICES, default="proposed", help_text="Lifecycle state, especially for approval-gated memories.", ) content = models.JSONField( default=dict, blank=True, help_text="Structured memory payload (schema can evolve by type).", ) source_request = models.ForeignKey( AIRequest, on_delete=models.SET_NULL, null=True, blank=True, help_text="AIRequest that originated this memory, if any.", ) created_at = models.DateTimeField( auto_now_add=True, help_text="Row creation timestamp.", ) updated_at = models.DateTimeField( auto_now=True, help_text="Last update timestamp.", ) class AIResultSignal(models.Model): """ Message-linked evidence signal produced by an AIResult. This lets the UI point to concrete messages (good or bad) rather than generic flags. """ VALENCE_CHOICES = ( ("positive", "Positive"), ("neutral", "Neutral"), ("risk", "Risk"), ) id = models.UUIDField( primary_key=True, default=uuid.uuid4, editable=False, help_text="Stable identifier for this result signal.", ) user = models.ForeignKey( User, on_delete=models.CASCADE, related_name="workspace_ai_result_signals", help_text="Owner of this signal row (required for restricted CRUD filtering).", ) ai_result = models.ForeignKey( AIResult, on_delete=models.CASCADE, related_name="signals", help_text="AI result that produced this signal.", ) message_event = models.ForeignKey( MessageEvent, on_delete=models.SET_NULL, null=True, blank=True, related_name="ai_signals", help_text="Optional specific message event referenced by this signal.", ) label = models.CharField( max_length=128, help_text="Short signal label, e.g. 'withdrawing', 'repair_attempt'.", ) valence = models.CharField( max_length=16, choices=VALENCE_CHOICES, default="neutral", help_text="Signal polarity: positive, neutral, or risk.", ) score = models.FloatField( null=True, blank=True, help_text="Optional model confidence/strength (0.0-1.0).", ) rationale = models.TextField( blank=True, default="", help_text="Human-readable explanation for this signal.", ) created_at = models.DateTimeField( auto_now_add=True, help_text="Row creation timestamp.", ) class PatternMitigationPlan(models.Model): """ Stores a mitigation plan generated from extracted interaction patterns. The plan is the parent container for rules, games, mitigation chat, and artifact exports. """ STATUS_CHOICES = ( ("draft", "Draft"), ("active", "Active"), ("archived", "Archived"), ) CREATION_MODE_CHOICES = ( ("auto", "Auto"), ("guided", "Guided"), ) id = models.UUIDField( primary_key=True, default=uuid.uuid4, editable=False, help_text="Stable identifier for this mitigation plan.", ) user = models.ForeignKey( User, on_delete=models.CASCADE, related_name="pattern_mitigation_plans", help_text="Owner of this plan.", ) conversation = models.ForeignKey( WorkspaceConversation, on_delete=models.CASCADE, related_name="mitigation_plans", help_text="Workspace conversation this plan belongs to.", ) source_ai_result = models.ForeignKey( AIResult, on_delete=models.SET_NULL, null=True, blank=True, related_name="mitigation_plans", help_text="AI result that initiated this plan, if any.", ) title = models.CharField( max_length=255, blank=True, default="", help_text="Display title for this plan.", ) objective = models.TextField( blank=True, default="", help_text="High-level objective this plan is meant to achieve.", ) fundamental_items = models.JSONField( default=list, blank=True, help_text="Foundational agreed items/principles for this plan.", ) creation_mode = models.CharField( max_length=16, choices=CREATION_MODE_CHOICES, default="auto", help_text="Whether plan artifacts were generated automatically or user-guided.", ) status = models.CharField( max_length=16, choices=STATUS_CHOICES, default="draft", help_text="Lifecycle status of the plan.", ) created_at = models.DateTimeField( auto_now_add=True, help_text="Row creation timestamp.", ) updated_at = models.DateTimeField( auto_now=True, help_text="Last update timestamp.", ) class PatternMitigationRule(models.Model): """ Rule artifact attached to a mitigation plan. """ id = models.UUIDField( primary_key=True, default=uuid.uuid4, editable=False, help_text="Stable identifier for this rule.", ) user = models.ForeignKey( User, on_delete=models.CASCADE, related_name="pattern_mitigation_rules", help_text="Owner of this rule.", ) plan = models.ForeignKey( PatternMitigationPlan, on_delete=models.CASCADE, related_name="rules", help_text="Parent mitigation plan.", ) title = models.CharField( max_length=255, help_text="Rule title.", ) content = models.TextField( blank=True, default="", help_text="Rule definition/details.", ) enabled = models.BooleanField( default=True, help_text="Whether this rule is currently enabled.", ) created_at = models.DateTimeField( auto_now_add=True, help_text="Row creation timestamp.", ) @property def display_id(self): return _attribute_display_id( "rule", self.plan_id, self.title, self.content, self.enabled, ) class PatternMitigationGame(models.Model): """ Game artifact attached to a mitigation plan. """ id = models.UUIDField( primary_key=True, default=uuid.uuid4, editable=False, help_text="Stable identifier for this game.", ) user = models.ForeignKey( User, on_delete=models.CASCADE, related_name="pattern_mitigation_games", help_text="Owner of this game.", ) plan = models.ForeignKey( PatternMitigationPlan, on_delete=models.CASCADE, related_name="games", help_text="Parent mitigation plan.", ) title = models.CharField( max_length=255, help_text="Game title.", ) instructions = models.TextField( blank=True, default="", help_text="Gameplay/instruction text.", ) enabled = models.BooleanField( default=True, help_text="Whether this game is currently enabled.", ) created_at = models.DateTimeField( auto_now_add=True, help_text="Row creation timestamp.", ) @property def display_id(self): return _attribute_display_id( "game", self.plan_id, self.title, self.instructions, self.enabled, ) class PatternMitigationCorrection(models.Model): """ Shared clarification artifact used to prevent circular misunderstandings. """ PERSPECTIVE_CHOICES = ( ("third_person", "Third Person"), ("second_person", "Second Person"), ("first_person", "First Person"), ) SHARE_TARGET_CHOICES = ( ("self", "Self"), ("other", "Other Party"), ("both", "Both Parties"), ) LANGUAGE_STYLE_CHOICES = ( ("same", "Same Language"), ("adapted", "Adapted Language"), ) id = models.UUIDField( primary_key=True, default=uuid.uuid4, editable=False, help_text="Stable identifier for this correction item.", ) user = models.ForeignKey( User, on_delete=models.CASCADE, related_name="pattern_mitigation_corrections", help_text="Owner of this correction item.", ) plan = models.ForeignKey( PatternMitigationPlan, on_delete=models.CASCADE, related_name="corrections", help_text="Parent mitigation plan.", ) title = models.CharField( max_length=255, help_text="Correction title. Example: 'Assumption vs intent mismatch'.", ) clarification = models.TextField( blank=True, default="", help_text=( "Joint clarification text intended to reduce interpretation drift. " "Example: 'When you say \"you ignore me\", I hear fear of disconnection, not blame.'" ), ) source_phrase = models.TextField( blank=True, default="", help_text=( "Situation/message fragment this correction responds to. " "Example: 'she says: \"you never listen\"' or 'you say: \"you are dismissing me\"'." ), ) perspective = models.CharField( max_length=32, choices=PERSPECTIVE_CHOICES, default="third_person", help_text=( "Narrative perspective used when framing this correction. " "Examples: third person ('she says'), second person ('you say'), first person ('I say')." ), ) share_target = models.CharField( max_length=16, choices=SHARE_TARGET_CHOICES, default="both", help_text="Who this insight is intended to be shared with. Example: self, other, or both.", ) language_style = models.CharField( max_length=16, choices=LANGUAGE_STYLE_CHOICES, default="adapted", help_text=( "Whether to keep wording identical or adapt it per recipient. " "Example: same text for both parties, or softened/adapted wording for recipient." ), ) enabled = models.BooleanField( default=True, help_text="Whether this correction item is currently enabled.", ) created_at = models.DateTimeField( auto_now_add=True, help_text="Row creation timestamp.", ) @property def display_id(self): return _attribute_display_id( "correction", self.plan_id, self.title, self.source_phrase, self.clarification, self.perspective, self.share_target, self.language_style, self.enabled, ) class PatternMitigationMessage(models.Model): """ Conversation log between user and AI within a mitigation plan. """ ROLE_CHOICES = ( ("user", "User"), ("assistant", "Assistant"), ("system", "System"), ) id = models.UUIDField( primary_key=True, default=uuid.uuid4, editable=False, help_text="Stable identifier for this mitigation message.", ) user = models.ForeignKey( User, on_delete=models.CASCADE, related_name="pattern_mitigation_messages", help_text="Owner of this message.", ) plan = models.ForeignKey( PatternMitigationPlan, on_delete=models.CASCADE, related_name="messages", help_text="Parent mitigation plan.", ) role = models.CharField( max_length=16, choices=ROLE_CHOICES, help_text="Message speaker role.", ) text = models.TextField( help_text="Message content.", ) created_at = models.DateTimeField( auto_now_add=True, help_text="Row creation timestamp.", ) class Meta: ordering = ["created_at"] class PatternMitigationAutoSettings(models.Model): """ Automation controls for mitigation analysis in a workspace conversation. These settings let the user enable periodic/triggered checks for pattern violations, optional correction creation, and optional notifications. """ id = models.UUIDField( primary_key=True, default=uuid.uuid4, editable=False, help_text="Stable identifier for this automation settings row.", ) user = models.ForeignKey( User, on_delete=models.CASCADE, related_name="pattern_mitigation_auto_settings", help_text="Owner of this automation settings row.", ) conversation = models.OneToOneField( WorkspaceConversation, on_delete=models.CASCADE, related_name="mitigation_auto_settings", help_text="Conversation scope this automation config applies to.", ) enabled = models.BooleanField( default=False, help_text="Master toggle for mitigation automation in this conversation.", ) auto_pattern_recognition = models.BooleanField( default=True, help_text="Run pattern/violation recognition automatically when triggered.", ) auto_create_mitigation = models.BooleanField( default=False, help_text="Create a baseline mitigation plan automatically when missing.", ) auto_create_corrections = models.BooleanField( default=False, help_text="Create correction items automatically from detected violations.", ) auto_notify_enabled = models.BooleanField( default=False, help_text="Send NTFY notifications when new violations are detected.", ) ntfy_topic_override = models.CharField( max_length=255, null=True, blank=True, help_text="Optional NTFY topic override for automation notifications.", ) ntfy_url_override = models.CharField( max_length=255, null=True, blank=True, help_text="Optional NTFY server URL override for automation notifications.", ) sample_message_window = models.PositiveIntegerField( default=40, help_text="How many recent messages to include in each automation check.", ) check_cooldown_seconds = models.PositiveIntegerField( default=300, help_text="Minimum seconds between automatic checks for this conversation.", ) last_checked_event_ts = models.BigIntegerField( null=True, blank=True, help_text="Latest source message timestamp included in automation checks.", ) last_run_at = models.DateTimeField( null=True, blank=True, help_text="Timestamp when automation last ran.", ) last_result_summary = models.TextField( blank=True, default="", help_text="Human-readable summary from the last automation run.", ) created_at = models.DateTimeField( auto_now_add=True, help_text="Row creation timestamp.", ) updated_at = models.DateTimeField( auto_now=True, help_text="Last update timestamp.", ) def __str__(self): return f"Auto settings for {self.conversation_id}" class PatternArtifactExport(models.Model): """ Export protocol record for rules/games/rulebooks generated from a plan. """ ARTIFACT_TYPE_CHOICES = ( ("rulebook", "Rulebook"), ("rules", "Rules"), ("games", "Games"), ("corrections", "Corrections"), ) FORMAT_CHOICES = ( ("markdown", "Markdown"), ("json", "JSON"), ("text", "Text"), ) id = models.UUIDField( primary_key=True, default=uuid.uuid4, editable=False, help_text="Stable identifier for this export artifact.", ) user = models.ForeignKey( User, on_delete=models.CASCADE, related_name="pattern_artifact_exports", help_text="Owner of this export artifact.", ) plan = models.ForeignKey( PatternMitigationPlan, on_delete=models.CASCADE, related_name="exports", help_text="Source mitigation plan.", ) artifact_type = models.CharField( max_length=32, choices=ARTIFACT_TYPE_CHOICES, help_text="Artifact category being exported.", ) export_format = models.CharField( max_length=16, choices=FORMAT_CHOICES, default="markdown", help_text="Serialized output format.", ) protocol_version = models.CharField( max_length=32, default="artifact-v1", help_text="Artifact export protocol version.", ) payload = models.TextField( blank=True, default="", help_text="Serialized artifact body/content.", ) meta = models.JSONField( default=dict, blank=True, help_text="Additional export metadata (counts, hints, source IDs).", ) created_at = models.DateTimeField( auto_now_add=True, help_text="Row creation timestamp.", ) # class Perms(models.Model): # class Meta: # permissions = ( # ("bypass_hashing", "Can bypass field hashing"), # # ("bypass_blacklist", "Can bypass the blacklist"), # # ("bypass_encryption", "Can bypass field encryption"), # # ("bypass_obfuscation", "Can bypass field obfuscation"), # # ("bypass_delay", "Can bypass data delay"), # # ("bypass_randomisation", "Can bypass data randomisation"), # # ("post_irc", "Can post to IRC"), # ("post_discord", "Can post to Discord"), # ("query_search", "Can search with query strings"), # # ("use_insights", "Can use the Insights page"), # ("index_int", "Can use the internal index"), # ("index_meta", "Can use the meta index"), # ("restricted_sources", "Can access restricted sources"), # )