## Summary - Closes Phase 4 of [#62](#62): `evals/suite.json` (≥40 graded questions), `run_evals` management command, and manually-triggered `.gitea/workflows/run-evals.yml`. - Emits versioned WS `status` frames during grounded chat (evaluating / searching / reading_sources / refining / writing) for [chat_web_app#96](ai_ml_operations/chat_web_app#96). - Implements [#63](#63): Redis/Celery optional infra, `AgentRun`/`AgentStep`, tool registry (SSRF-safe `fetch_url`, tenant-scoped docs), LangGraph orchestrator, progress frames, REST `GET/POST /api/agent_runs/…`, gated by `ALLOW_AGENTIC_TASKS` (default off). ## Test plan - [x] `SKIP_RAG_INIT=1 uv run python manage.py test` for evals, ws frames, agent tools, consumers, grounding - [ ] Manual: with `ALLOW_AGENTIC_TASKS=false`, chat identical to today - [ ] Manual: status frames visible in FE with #96 branch - [ ] Manual (GPU): `python manage.py run_evals --runs 3` - [ ] Manual: `ALLOW_AGENTIC_TASKS=true` multi-step research prompt creates AgentRun + framesReviewed-on: #71
795 lines
26 KiB
Python
795 lines
26 KiB
Python
from django.db import models
|
|
from django.contrib.auth.models import AbstractUser
|
|
from django.utils import timezone
|
|
from autoslug import AutoSlugField
|
|
from chat_backend.storage import DatabaseStorage
|
|
import uuid
|
|
|
|
# Create your models here.
|
|
|
|
DB_FILE_STORAGE = DatabaseStorage()
|
|
|
|
|
|
class TimeInfoBase(models.Model):
|
|
|
|
created = models.DateTimeField(default=timezone.now)
|
|
last_modified = models.DateTimeField(default=timezone.now)
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
def save(self, *args, **kwargs):
|
|
if not kwargs.pop("skip_last_modified", False) and not hasattr(
|
|
self, "skip_last_modified"
|
|
):
|
|
self.last_modified = timezone.now()
|
|
if kwargs.get("update_fields") is not None:
|
|
kwargs["update_fields"] = list(
|
|
{*kwargs["update_fields"], "last_modified"}
|
|
)
|
|
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
class LLMModels(TimeInfoBase):
|
|
name = models.CharField(max_length=254, default="")
|
|
port = models.IntegerField(help_text="This specifies the port that the LLM runs on")
|
|
description = models.CharField(
|
|
max_length=512,
|
|
default="",
|
|
help_text="A description for the LLM. Limit is 512 characters",
|
|
)
|
|
|
|
|
|
class Company(TimeInfoBase):
|
|
name = models.TextField(max_length=256)
|
|
state = models.TextField(max_length=2)
|
|
zipcode = models.TextField(max_length=5)
|
|
address = models.TextField(max_length=256)
|
|
available_llms = models.ForeignKey(
|
|
"LLMModels",
|
|
on_delete=models.CASCADE,
|
|
blank=True,
|
|
null=True,
|
|
help_text="A list of LLMs that company can use",
|
|
)
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
|
|
class CustomUser(AbstractUser):
|
|
company = models.ForeignKey(
|
|
Company, on_delete=models.CASCADE, blank=True, null=True
|
|
)
|
|
is_company_manager = models.BooleanField(
|
|
help_text="Allows the edit/add/remove of users for a company", default=False
|
|
)
|
|
deleted = models.BooleanField(help_text="This is to hid accounts", default=False)
|
|
has_signed_tos = models.BooleanField(
|
|
default=False, help_text="If the user has signed the TOS"
|
|
)
|
|
slug = AutoSlugField(populate_from="email")
|
|
conversation_order = models.BooleanField(
|
|
default=True, help_text="How the conversations should display"
|
|
)
|
|
|
|
def get_set_password_url(self):
|
|
from django.conf import settings
|
|
|
|
base = settings.FRONTEND_BASE_URL.rstrip("/")
|
|
return f"{base}/set_password/?slug={self.slug}"
|
|
|
|
|
|
class UserAuthEvent(models.Model):
|
|
"""Audit trail for auth / account / subscription actions (user admin)."""
|
|
|
|
class EventType(models.TextChoices):
|
|
PASSWORD_RESET_REQUESTED = (
|
|
"password_reset_requested",
|
|
"Password reset requested",
|
|
)
|
|
PASSWORD_SET = ("password_set", "Password set")
|
|
INVITE_SENT = ("invite_sent", "Invite sent")
|
|
ACCOUNT_DELETED = ("account_deleted", "Account deleted")
|
|
SUBSCRIPTION_STARTED = ("subscription_started", "Subscription started")
|
|
SUBSCRIPTION_UPDATED = ("subscription_updated", "Subscription updated")
|
|
|
|
user = models.ForeignKey(
|
|
CustomUser,
|
|
on_delete=models.CASCADE,
|
|
related_name="auth_events",
|
|
)
|
|
event_type = models.CharField(max_length=64, choices=EventType.choices)
|
|
created = models.DateTimeField(default=timezone.now, db_index=True)
|
|
detail = models.CharField(max_length=512, blank=True, default="")
|
|
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["-created"]
|
|
|
|
def __str__(self):
|
|
return f"{self.get_event_type_display()} @ {self.created.isoformat()}"
|
|
|
|
@classmethod
|
|
def log(cls, user, event_type, *, detail="", ip_address=None):
|
|
return cls.objects.create(
|
|
user=user,
|
|
event_type=event_type,
|
|
detail=detail or "",
|
|
ip_address=ip_address,
|
|
)
|
|
|
|
|
|
class OutboundEmail(models.Model):
|
|
"""Record of emails queued/sent by the app (visible in admin)."""
|
|
|
|
class Kind(models.TextChoices):
|
|
PASSWORD_RESET = "password_reset", "Password reset"
|
|
INVITE = "invite", "Invite"
|
|
FEEDBACK = "feedback", "Feedback"
|
|
|
|
class Status(models.TextChoices):
|
|
QUEUED = "queued", "Queued"
|
|
SENDING = "sending", "Sending"
|
|
SENT = "sent", "Sent to SMTP"
|
|
FAILED = "failed", "Failed"
|
|
|
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
kind = models.CharField(max_length=32, choices=Kind.choices)
|
|
status = models.CharField(
|
|
max_length=16, choices=Status.choices, default=Status.QUEUED, db_index=True
|
|
)
|
|
to_email = models.EmailField()
|
|
from_email = models.EmailField()
|
|
subject = models.CharField(max_length=255)
|
|
html_template = models.CharField(max_length=255)
|
|
text_template = models.CharField(max_length=255)
|
|
context = models.JSONField(default=dict, blank=True)
|
|
error_message = models.TextField(blank=True, default="")
|
|
user = models.ForeignKey(
|
|
CustomUser,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="outbound_emails",
|
|
)
|
|
created = models.DateTimeField(default=timezone.now, db_index=True)
|
|
updated = models.DateTimeField(auto_now=True)
|
|
sent_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["-created"]
|
|
|
|
def __str__(self):
|
|
return f"{self.subject} → {self.to_email} ({self.status})"
|
|
|
|
|
|
class OAuthIdentity(TimeInfoBase):
|
|
"""Linked IdP identity + tokens (SSO now; Drive OAuth reuse later — #11)."""
|
|
|
|
class Provider(models.TextChoices):
|
|
GOOGLE = "google", "Google"
|
|
MICROSOFT = "microsoft", "Microsoft"
|
|
|
|
user = models.ForeignKey(
|
|
CustomUser,
|
|
on_delete=models.CASCADE,
|
|
related_name="oauth_identities",
|
|
)
|
|
provider = models.CharField(max_length=32, choices=Provider.choices)
|
|
subject = models.CharField(
|
|
max_length=255,
|
|
help_text="OIDC subject (sub) from the identity provider",
|
|
)
|
|
email = models.EmailField(blank=True, default="")
|
|
access_token = models.TextField(blank=True, default="")
|
|
refresh_token = models.TextField(blank=True, default="")
|
|
token_expires_at = models.DateTimeField(null=True, blank=True)
|
|
scopes = models.TextField(blank=True, default="")
|
|
raw_profile = models.JSONField(default=dict, blank=True)
|
|
|
|
class Meta:
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=["provider", "subject"],
|
|
name="uniq_oauth_provider_subject",
|
|
),
|
|
models.UniqueConstraint(
|
|
fields=["provider", "user"],
|
|
name="uniq_oauth_provider_user",
|
|
),
|
|
]
|
|
verbose_name_plural = "OAuth identities"
|
|
|
|
def __str__(self):
|
|
return f"{self.provider}:{self.subject} → {self.user_id}"
|
|
|
|
|
|
FEEDBACK_CHOICE = (
|
|
("SUBMITTED", "Submitted"),
|
|
("RESOLVED", "Resolved"),
|
|
("DEFFERED", "Deffered"),
|
|
("CLOSED", "Closed"),
|
|
)
|
|
|
|
FEEDBACK_CATEGORIES = (
|
|
("NOT_DEFINED", "Not defined"),
|
|
("BUG", "Bug"),
|
|
("ENHANCEMENT", "Enhancement"),
|
|
("OTHER", "Other"),
|
|
("MAX_CATEGORIES", "Max Categories"),
|
|
)
|
|
|
|
|
|
class Feedback(TimeInfoBase):
|
|
title = models.TextField(max_length=64, default="")
|
|
user = models.ForeignKey(
|
|
CustomUser, on_delete=models.CASCADE, blank=True, null=True
|
|
)
|
|
text = models.TextField(max_length=512)
|
|
status = models.CharField(
|
|
max_length=24, choices=FEEDBACK_CHOICE, default="SUBMITTED"
|
|
)
|
|
category = models.CharField(
|
|
max_length=24, choices=FEEDBACK_CATEGORIES, default="NOT_DEFINED"
|
|
)
|
|
|
|
def get_user_email(self):
|
|
if self.user:
|
|
return self.user.email
|
|
else:
|
|
return ""
|
|
|
|
|
|
MONTH_CHOICES = (
|
|
("JANUARY", "January"),
|
|
("FEBRUARY", "February"),
|
|
("MARCH", "March"),
|
|
# ....
|
|
("DECEMBER", "December"),
|
|
)
|
|
|
|
month = models.CharField(max_length=9, choices=MONTH_CHOICES, default="JANUARY")
|
|
|
|
|
|
class Announcement(TimeInfoBase):
|
|
class Status(models.TextChoices):
|
|
default = "DEFAULT", "default"
|
|
warning = "WARNING", "warning"
|
|
info = "INFO", "info"
|
|
danger = "DANGER", "danger"
|
|
|
|
status = models.CharField(
|
|
max_length=7, choices=Status.choices, default=Status.default
|
|
)
|
|
message = models.TextField(max_length=256)
|
|
start_date_time = models.DateTimeField(auto_now=True)
|
|
end_date_time = models.DateTimeField(auto_now=True)
|
|
|
|
|
|
class Conversation(TimeInfoBase):
|
|
user = models.ForeignKey(
|
|
CustomUser, on_delete=models.CASCADE, blank=True, null=True
|
|
)
|
|
title = models.CharField(
|
|
max_length=64, help_text="The title for the conversation", default=""
|
|
)
|
|
deleted = models.BooleanField(
|
|
help_text="This is to hide conversations", default=False
|
|
)
|
|
|
|
def get_user_email(self):
|
|
if self.user:
|
|
return self.user.email
|
|
else:
|
|
return ""
|
|
|
|
def __str__(self):
|
|
return self.title
|
|
|
|
|
|
class Prompt(TimeInfoBase):
|
|
message = models.CharField(max_length=100 * 1024, help_text="The text for a prompt")
|
|
user_created = models.BooleanField(
|
|
help_text="True if was created by the user. False if it was generate by the LLM"
|
|
)
|
|
conversation = models.ForeignKey(
|
|
"Conversation", on_delete=models.CASCADE, blank=True, null=True
|
|
)
|
|
file = models.FileField(
|
|
upload_to="prompt_files/",
|
|
storage=DB_FILE_STORAGE,
|
|
blank=True,
|
|
null=True,
|
|
help_text="file for the prompt (stored in database)",
|
|
)
|
|
file_type = models.CharField(
|
|
max_length=16,
|
|
blank=True,
|
|
null=True,
|
|
help_text="file type of the file for the prompt",
|
|
)
|
|
citations = models.JSONField(
|
|
default=list,
|
|
blank=True,
|
|
help_text=(
|
|
"Structured source citations for grounded answers (#62). "
|
|
"List of {index, title, url, published_at}."
|
|
),
|
|
)
|
|
|
|
def get_conversation_title(self):
|
|
if self.conversation:
|
|
return self.conversation.title
|
|
else:
|
|
return ""
|
|
|
|
def file_exists(self):
|
|
return self.file != None and self.file.storage.exists(self.file.name)
|
|
|
|
|
|
class PromptFeedback(TimeInfoBase):
|
|
"""Per-message thumbs rating for an assistant Prompt (chat_backend#67).
|
|
|
|
Distinct from app-wide ``Feedback`` (product bugs). Joinable to
|
|
``PromptMetric`` via ``prompt_id`` for per-model accuracy slices.
|
|
"""
|
|
|
|
class Rating(models.TextChoices):
|
|
UP = "up", "Up"
|
|
DOWN = "down", "Down"
|
|
|
|
class Reason(models.TextChoices):
|
|
INCORRECT = "incorrect", "Incorrect"
|
|
OUT_OF_DATE = "out_of_date", "Out of date"
|
|
DIDNT_FOLLOW_INSTRUCTIONS = (
|
|
"didnt_follow_instructions",
|
|
"Didn't follow instructions",
|
|
)
|
|
UNSAFE = "unsafe", "Unsafe"
|
|
OTHER = "other", "Other"
|
|
|
|
prompt = models.ForeignKey(
|
|
Prompt,
|
|
on_delete=models.CASCADE,
|
|
related_name="prompt_feedbacks",
|
|
help_text="Assistant prompt being rated",
|
|
)
|
|
user = models.ForeignKey(
|
|
CustomUser,
|
|
on_delete=models.CASCADE,
|
|
related_name="prompt_feedbacks",
|
|
)
|
|
rating = models.CharField(max_length=8, choices=Rating.choices)
|
|
reason = models.CharField(
|
|
max_length=64,
|
|
choices=Reason.choices,
|
|
blank=True,
|
|
null=True,
|
|
)
|
|
comment = models.TextField(max_length=1024, blank=True, null=True)
|
|
|
|
class Meta:
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=("prompt", "user"),
|
|
name="uniq_prompt_feedback_prompt_user",
|
|
)
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"PromptFeedback(prompt={self.prompt_id}, user={self.user_id}, {self.rating})"
|
|
|
|
|
|
class PromptMetric(TimeInfoBase):
|
|
PROMPT_METRIC_CHOICES = (
|
|
("CREATED", "Created"),
|
|
("SUBMITTED", "Submitted"),
|
|
("PROCESSED", "Processed"),
|
|
("FINISHED", "Finished"),
|
|
("MAX_PROMPT_METRIC_CHOICES", "Max Prompt Metric Choices"),
|
|
)
|
|
prompt_id = models.IntegerField(help_text="The id of the prompt this matches to")
|
|
conversation_id = models.IntegerField(
|
|
help_text="The id of the conversation this matches to"
|
|
)
|
|
event = models.CharField(
|
|
max_length=26, choices=PROMPT_METRIC_CHOICES, default="CREATED"
|
|
)
|
|
model_name = models.CharField(max_length=215, help_text="The name of the model")
|
|
start_time = models.DateTimeField()
|
|
end_time = models.DateTimeField(blank=True, null=True)
|
|
prompt_length = models.IntegerField(
|
|
help_text="How many characters are in the prompt"
|
|
)
|
|
reponse_length = models.IntegerField(
|
|
blank=True, null=True, help_text="How many characters are in the response"
|
|
)
|
|
tokens_in = models.IntegerField(
|
|
blank=True,
|
|
null=True,
|
|
help_text=(
|
|
"Prompt/input tokens reported by the LLM provider usage payload. "
|
|
"Null when the provider did not report usage (never estimated)."
|
|
),
|
|
)
|
|
tokens_out = models.IntegerField(
|
|
blank=True,
|
|
null=True,
|
|
help_text=(
|
|
"Completion/output tokens reported by the LLM provider usage payload. "
|
|
"Null when the provider did not report usage (never estimated)."
|
|
),
|
|
)
|
|
has_file = models.BooleanField(help_text="Is there a file")
|
|
file_type = models.CharField(
|
|
max_length=16, help_text="The file type, if any", blank=True, null=True
|
|
)
|
|
|
|
def get_duration(self):
|
|
if self.start_time and self.end_time:
|
|
difference = self.end_time - self.start_time
|
|
return difference.seconds
|
|
return 0
|
|
|
|
|
|
# Document Models
|
|
class DocumentWorkspace(TimeInfoBase):
|
|
"""RAG document container: company (business) or user (personal) owned (#46, #55)."""
|
|
|
|
name = models.CharField(max_length=255)
|
|
company = models.ForeignKey(
|
|
Company,
|
|
on_delete=models.CASCADE,
|
|
null=True,
|
|
blank=True,
|
|
related_name="document_workspaces",
|
|
)
|
|
user = models.ForeignKey(
|
|
"CustomUser",
|
|
on_delete=models.CASCADE,
|
|
null=True,
|
|
blank=True,
|
|
related_name="personal_workspaces",
|
|
help_text="Set for personal RAG workspaces; null for company workspaces.",
|
|
)
|
|
|
|
class Meta:
|
|
constraints = [
|
|
models.CheckConstraint(
|
|
condition=(
|
|
models.Q(company__isnull=False, user__isnull=True)
|
|
| models.Q(company__isnull=True, user__isnull=False)
|
|
),
|
|
name="document_workspace_company_xor_user",
|
|
),
|
|
models.UniqueConstraint(
|
|
fields=["user"],
|
|
condition=models.Q(user__isnull=False),
|
|
name="uniq_personal_document_workspace_user",
|
|
),
|
|
]
|
|
|
|
def __str__(self):
|
|
if self.user_id:
|
|
return f"DocumentWorkspace(personal user={self.user_id})"
|
|
return f"DocumentWorkspace(company={self.company_id})"
|
|
|
|
|
|
class DriveConnection(TimeInfoBase):
|
|
"""A linked Google Drive / Microsoft OneDrive-SharePoint account (#47-#52).
|
|
|
|
``user`` is null for company-only connections set up by a company manager
|
|
(kind=company); personal connections always have ``user`` set.
|
|
|
|
``company`` is required for kind=company. Personal connections may have
|
|
``company`` null when the user is not attached to a company (#55).
|
|
"""
|
|
|
|
class Provider(models.TextChoices):
|
|
GOOGLE = "google", "Google"
|
|
MICROSOFT = "microsoft", "Microsoft"
|
|
|
|
class Kind(models.TextChoices):
|
|
PERSONAL = "personal", "Personal"
|
|
COMPANY = "company", "Company"
|
|
|
|
class SyncStatus(models.TextChoices):
|
|
OK = "ok", "Ok"
|
|
ERROR = "error", "Error"
|
|
PENDING = "pending", "Pending"
|
|
NEVER = "never", "Never"
|
|
|
|
user = models.ForeignKey(
|
|
"CustomUser",
|
|
on_delete=models.CASCADE,
|
|
null=True,
|
|
blank=True,
|
|
related_name="drive_connections",
|
|
help_text="Null for company-only connections owned by manager setup.",
|
|
)
|
|
company = models.ForeignKey(
|
|
Company,
|
|
on_delete=models.CASCADE,
|
|
null=True,
|
|
blank=True,
|
|
related_name="drive_connections",
|
|
help_text="Required for company connections; optional for personal (#55).",
|
|
)
|
|
provider = models.CharField(max_length=32, choices=Provider.choices)
|
|
kind = models.CharField(
|
|
max_length=16, choices=Kind.choices, default=Kind.PERSONAL
|
|
)
|
|
access_token = models.TextField(blank=True, default="")
|
|
refresh_token = models.TextField(blank=True, default="")
|
|
token_expires_at = models.DateTimeField(null=True, blank=True)
|
|
scopes = models.TextField(blank=True, default="")
|
|
external_account_email = models.EmailField(blank=True, default="")
|
|
selected_resource_ids = models.JSONField(
|
|
default=list,
|
|
blank=True,
|
|
help_text="Selected folder/drive/site ids to sync (empty = root/default).",
|
|
)
|
|
selected_resource_labels = models.JSONField(
|
|
default=list,
|
|
blank=True,
|
|
help_text="Human-readable labels matching selected_resource_ids, for the FE.",
|
|
)
|
|
last_sync_at = models.DateTimeField(null=True, blank=True)
|
|
last_sync_status = models.CharField(
|
|
max_length=16, choices=SyncStatus.choices, default=SyncStatus.NEVER
|
|
)
|
|
last_sync_error = models.TextField(blank=True, default="")
|
|
# Progress for FE progress bar while last_sync_status=pending (#59).
|
|
sync_total = models.PositiveIntegerField(
|
|
default=0,
|
|
help_text="Remote files discovered for the current/last sync run.",
|
|
)
|
|
sync_processed = models.PositiveIntegerField(
|
|
default=0,
|
|
help_text="Files finished in the current/last sync (includes skips).",
|
|
)
|
|
sync_added = models.PositiveIntegerField(default=0)
|
|
sync_updated = models.PositiveIntegerField(default=0)
|
|
sync_failed = models.PositiveIntegerField(default=0)
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
constraints = [
|
|
models.CheckConstraint(
|
|
condition=(
|
|
models.Q(kind="personal", user__isnull=False)
|
|
| models.Q(
|
|
kind="company",
|
|
company__isnull=False,
|
|
user__isnull=True,
|
|
)
|
|
),
|
|
name="drive_connection_kind_owner_consistency",
|
|
),
|
|
models.UniqueConstraint(
|
|
fields=["user", "provider"],
|
|
condition=models.Q(kind="personal"),
|
|
name="uniq_personal_drive_connection_user_provider",
|
|
),
|
|
models.UniqueConstraint(
|
|
fields=["company", "provider"],
|
|
condition=models.Q(kind="company"),
|
|
name="uniq_company_drive_connection_company_provider",
|
|
),
|
|
]
|
|
|
|
def __str__(self):
|
|
return (
|
|
f"DriveConnection({self.provider}/{self.kind}) "
|
|
f"company={self.company_id} user={self.user_id}"
|
|
)
|
|
|
|
|
|
class Document(TimeInfoBase):
|
|
class Source(models.TextChoices):
|
|
UPLOAD = "upload", "Upload"
|
|
GOOGLE_DRIVE = "google_drive", "Google Drive"
|
|
ONEDRIVE = "onedrive", "OneDrive"
|
|
SHAREPOINT = "sharepoint", "SharePoint"
|
|
GOOGLE_SHARED_DRIVE = "google_shared_drive", "Google Shared Drive"
|
|
|
|
workspace = models.ForeignKey(DocumentWorkspace, on_delete=models.CASCADE)
|
|
file = models.FileField(
|
|
upload_to="documents/",
|
|
storage=DB_FILE_STORAGE,
|
|
help_text="uploaded document bytes (stored in database)",
|
|
)
|
|
uploaded_at = models.DateTimeField(auto_now_add=True)
|
|
processed = models.BooleanField(default=False)
|
|
active = models.BooleanField(default=False)
|
|
source = models.CharField(
|
|
max_length=32, choices=Source.choices, default=Source.UPLOAD
|
|
)
|
|
remote_file_id = models.CharField(max_length=255, blank=True, default="")
|
|
remote_etag = models.CharField(max_length=255, blank=True, default="")
|
|
remote_name = models.CharField(max_length=512, blank=True, default="")
|
|
drive_connection = models.ForeignKey(
|
|
DriveConnection,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="documents",
|
|
)
|
|
sync_error = models.TextField(blank=True, default="")
|
|
|
|
class Meta:
|
|
indexes = [
|
|
models.Index(fields=["drive_connection", "remote_file_id"]),
|
|
]
|
|
|
|
|
|
class AgentRun(TimeInfoBase):
|
|
"""A long-running, multi-step agentic task turn (#63).
|
|
|
|
``user``/``company`` mirror the tenant scope of the triggering chat turn
|
|
(never trust a bare ``conversation_id`` — see ``chat_tenant_scope``).
|
|
Progress is broadcast on the Redis channel-layer group
|
|
:meth:`channel_group_name` so a reconnecting client can resubscribe.
|
|
"""
|
|
|
|
class Status(models.TextChoices):
|
|
PENDING = "pending", "Pending"
|
|
PLANNING = "planning", "Planning"
|
|
RUNNING = "running", "Running"
|
|
COMPLETED = "completed", "Completed"
|
|
FAILED = "failed", "Failed"
|
|
CANCELLED = "cancelled", "Cancelled"
|
|
|
|
user = models.ForeignKey(
|
|
CustomUser,
|
|
on_delete=models.CASCADE,
|
|
related_name="agent_runs",
|
|
)
|
|
company = models.ForeignKey(
|
|
Company,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="agent_runs",
|
|
)
|
|
conversation = models.ForeignKey(
|
|
"Conversation",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="agent_runs",
|
|
)
|
|
prompt = models.ForeignKey(
|
|
"Prompt",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="agent_runs",
|
|
help_text="The user Prompt that triggered this run, if any.",
|
|
)
|
|
goal = models.TextField(help_text="Natural-language user request/goal.")
|
|
title = models.CharField(
|
|
max_length=255,
|
|
blank=True,
|
|
default="",
|
|
help_text="Short human-readable title (from the plan, or the goal).",
|
|
)
|
|
status = models.CharField(
|
|
max_length=16, choices=Status.choices, default=Status.PENDING, db_index=True
|
|
)
|
|
plan = models.JSONField(
|
|
default=list,
|
|
blank=True,
|
|
help_text="Ordered list of {step_id, title, tool} planner steps.",
|
|
)
|
|
result = models.TextField(
|
|
blank=True, default="", help_text="Final synthesised answer."
|
|
)
|
|
error = models.TextField(blank=True, default="")
|
|
model_orchestrator = models.CharField(max_length=215, blank=True, default="")
|
|
model_subagent = models.CharField(max_length=215, blank=True, default="")
|
|
max_plan_steps = models.PositiveIntegerField(default=8)
|
|
max_iterations = models.PositiveIntegerField(default=12)
|
|
wall_clock_seconds = models.PositiveIntegerField(default=600)
|
|
tool_call_count = models.PositiveIntegerField(default=0)
|
|
iteration_count = models.PositiveIntegerField(default=0)
|
|
cancel_requested = models.BooleanField(
|
|
default=False,
|
|
help_text="Set by the cancel endpoint/frame; worker loop polls this.",
|
|
)
|
|
started_at = models.DateTimeField(null=True, blank=True)
|
|
completed_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["-created"]
|
|
|
|
def __str__(self) -> str:
|
|
return f"AgentRun({self.pk}, user={self.user_id}, {self.status})"
|
|
|
|
def channel_group_name(self) -> str:
|
|
"""Redis channel-layer group so reconnecting clients get updates (#63)."""
|
|
return f"agent_run_{self.pk}"
|
|
|
|
@property
|
|
def is_terminal(self) -> bool:
|
|
return self.status in {
|
|
self.Status.COMPLETED,
|
|
self.Status.FAILED,
|
|
self.Status.CANCELLED,
|
|
}
|
|
|
|
def mark_cancelled(self) -> None:
|
|
self.cancel_requested = True
|
|
self.status = self.Status.CANCELLED
|
|
self.completed_at = self.completed_at or timezone.now()
|
|
self.save(
|
|
update_fields=[
|
|
"cancel_requested",
|
|
"status",
|
|
"completed_at",
|
|
"last_modified",
|
|
]
|
|
)
|
|
|
|
|
|
class AgentStep(TimeInfoBase):
|
|
"""A single planner step (optionally decomposed into sub-agent steps)."""
|
|
|
|
class Status(models.TextChoices):
|
|
PENDING = "pending", "Pending"
|
|
RUNNING = "running", "Running"
|
|
COMPLETED = "completed", "Completed"
|
|
FAILED = "failed", "Failed"
|
|
SKIPPED = "skipped", "Skipped"
|
|
CANCELLED = "cancelled", "Cancelled"
|
|
|
|
run = models.ForeignKey(
|
|
AgentRun,
|
|
on_delete=models.CASCADE,
|
|
related_name="steps",
|
|
)
|
|
parent_step = models.ForeignKey(
|
|
"self",
|
|
on_delete=models.CASCADE,
|
|
null=True,
|
|
blank=True,
|
|
related_name="sub_steps",
|
|
help_text="Set when this step was produced by a sub-agent (#63).",
|
|
)
|
|
index = models.PositiveIntegerField(default=0, help_text="Order within the plan.")
|
|
title = models.CharField(max_length=255, blank=True, default="")
|
|
status = models.CharField(
|
|
max_length=16, choices=Status.choices, default=Status.PENDING, db_index=True
|
|
)
|
|
is_subagent = models.BooleanField(default=False)
|
|
tool_name = models.CharField(max_length=64, blank=True, default="")
|
|
tool_input = models.JSONField(default=dict, blank=True)
|
|
tool_output = models.TextField(
|
|
blank=True,
|
|
default="",
|
|
help_text="Truncated to AGENT_TOOL_OUTPUT_MAX_CHARS.",
|
|
)
|
|
error = models.TextField(blank=True, default="")
|
|
started_at = models.DateTimeField(null=True, blank=True)
|
|
completed_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["index", "created"]
|
|
|
|
def __str__(self) -> str:
|
|
return f"AgentStep(run={self.run_id}, index={self.index}, {self.status})"
|
|
|
|
|
|
class StoredFile(TimeInfoBase):
|
|
"""Blob store for DatabaseStorage — prompt attachments and documents."""
|
|
|
|
name = models.CharField(max_length=512, unique=True, db_index=True)
|
|
content = models.BinaryField()
|
|
size = models.PositiveBigIntegerField(default=0)
|
|
content_type = models.CharField(max_length=255, blank=True, default="")
|
|
|
|
def __str__(self):
|
|
return self.name
|