Files
GIA/core/models.py

1587 lines
48 KiB
Python

import hashlib
import logging
import uuid
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractUser
from django.core.exceptions import ValidationError
from django.db import models
from core.clients import transport
from core.lib.notify import raw_sendmsg
logger = logging.getLogger(__name__)
SERVICE_CHOICES = (
("signal", "Signal"),
("whatsapp", "WhatsApp"),
("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)}{str(digits).zfill(4)}"
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=None, metadata=None):
"""
Send this contact a text.
"""
return await transport.send_message_raw(
self.service,
self.identifier,
text=text,
attachments=attachments or [],
metadata=dict(metadata or {}),
)
class PlatformChatLink(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
person = models.ForeignKey(Person, on_delete=models.CASCADE)
person_identifier = models.ForeignKey(
PersonIdentifier,
on_delete=models.SET_NULL,
null=True,
blank=True,
)
service = models.CharField(choices=SERVICE_CHOICES, max_length=255)
chat_identifier = models.CharField(max_length=255)
chat_jid = models.CharField(max_length=255, blank=True, null=True)
chat_name = models.CharField(max_length=255, blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["user", "service", "chat_identifier"],
name="unique_platform_chat_link",
)
]
indexes = [
models.Index(fields=["user", "service", "chat_identifier"]),
]
def clean(self):
if self.person_id and self.user_id and self.person.user_id != self.user_id:
raise ValidationError("Person must belong to the same user.")
if self.person_identifier_id:
if self.person_identifier.user_id != self.user_id:
raise ValidationError(
"Person identifier must belong to the same user."
)
if self.person_identifier.person_id != self.person_id:
raise ValidationError(
"Person identifier must belong to the selected person."
)
if self.person_identifier.service != self.service:
raise ValidationError(
"Chat links cannot be linked across platforms."
)
def save(self, *args, **kwargs):
value = str(self.chat_identifier or "").strip()
if "@" in value:
value = value.split("@", 1)[0]
self.chat_identifier = value
self.full_clean()
return super().save(*args, **kwargs)
def __str__(self):
return f"{self.person.name} ({self.service}: {self.chat_identifier})"
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)
delivered_ts = models.BigIntegerField(
null=True,
blank=True,
help_text="Delivery timestamp (unix ms) when known.",
)
read_ts = models.BigIntegerField(
null=True,
blank=True,
help_text="Read timestamp (unix ms) when known.",
)
read_source_service = models.CharField(
max_length=255,
choices=SERVICE_CHOICES,
null=True,
blank=True,
help_text="Service that reported the read receipt.",
)
read_by_identifier = models.CharField(
max_length=255,
blank=True,
null=True,
help_text="Identifier that read this message (service-native value).",
)
receipt_payload = models.JSONField(
default=dict,
blank=True,
help_text="Raw normalized delivery/read receipt metadata.",
)
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: {'<person_uuid>': {'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 WorkspaceMetricSnapshot(models.Model):
"""
Historical snapshots of workspace metrics for trend visualisation.
"""
conversation = models.ForeignKey(
WorkspaceConversation,
on_delete=models.CASCADE,
related_name="metric_snapshots",
help_text="Workspace conversation this metric snapshot belongs to.",
)
computed_at = models.DateTimeField(
auto_now_add=True,
db_index=True,
help_text="When this snapshot was persisted.",
)
source_event_ts = models.BigIntegerField(
null=True,
blank=True,
help_text="Latest message timestamp used during this metric computation.",
)
stability_state = models.CharField(
max_length=32,
choices=WorkspaceConversation.StabilityState.choices,
default=WorkspaceConversation.StabilityState.CALIBRATING,
help_text="Stability state at computation time.",
)
stability_score = models.FloatField(
null=True,
blank=True,
help_text="Stability score (0-100).",
)
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 in the sampled window.",
)
stability_sample_days = models.PositiveIntegerField(
default=0,
help_text="How many days were in the sampled window.",
)
commitment_inbound_score = models.FloatField(
null=True,
blank=True,
help_text="Commitment estimate counterpart -> user (0-100).",
)
commitment_outbound_score = models.FloatField(
null=True,
blank=True,
help_text="Commitment estimate user -> counterpart (0-100).",
)
commitment_confidence = models.FloatField(
default=0.0,
help_text="Confidence in commitment scores (0.0-1.0).",
)
inbound_messages = models.PositiveIntegerField(
default=0,
help_text="Inbound message count in the sampled window.",
)
outbound_messages = models.PositiveIntegerField(
default=0,
help_text="Outbound message count in the sampled window.",
)
reciprocity_score = models.FloatField(
null=True,
blank=True,
help_text="Balance component used for stability.",
)
continuity_score = models.FloatField(
null=True,
blank=True,
help_text="Continuity component used for stability.",
)
response_score = models.FloatField(
null=True,
blank=True,
help_text="Response-time component used for stability.",
)
volatility_score = models.FloatField(
null=True,
blank=True,
help_text="Volatility component used for stability.",
)
inbound_response_score = models.FloatField(
null=True,
blank=True,
help_text="Inbound response-lag score used for commitment.",
)
outbound_response_score = models.FloatField(
null=True,
blank=True,
help_text="Outbound response-lag score used for commitment.",
)
balance_inbound_score = models.FloatField(
null=True,
blank=True,
help_text="Inbound balance score used for commitment.",
)
balance_outbound_score = models.FloatField(
null=True,
blank=True,
help_text="Outbound balance score used for commitment.",
)
class Meta:
ordering = ("-computed_at",)
indexes = [
models.Index(fields=["conversation", "computed_at"]),
]
def __str__(self):
return f"Metrics {self.conversation_id} @ {self.computed_at.isoformat()}"
class MessageEvent(models.Model):
"""
Normalized message event used by workspace timeline and AI selection windows.
"""
SOURCE_SYSTEM_CHOICES = (
*SERVICE_CHOICES,
("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"),
# )