mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-28 07:37:56 +09:00
302 lines
12 KiB
Python
302 lines
12 KiB
Python
"""AI assistant persistence models (core primitive — powered by Prompture).
|
|
|
|
Three tables back the in-panel assistant:
|
|
|
|
- ``AiConversation`` — one chat thread per user. Holds the full Prompture
|
|
``Conversation.export()`` blob so a thread can be resumed across requests
|
|
(tools are re-supplied on resume; only the message/usage state is stored).
|
|
- ``AiMessage`` — denormalized per-turn rows for fast list/transcript rendering
|
|
without re-parsing the export blob (also replays tool-call cards).
|
|
- ``AiPendingAction`` — audit/record of a guarded write-tool confirmation. The
|
|
live approve/deny coordination happens in-memory (see ai_service streaming),
|
|
but every request is persisted here for visibility and audit.
|
|
|
|
JSON columns use the store-as-Text + property pattern from ``plugin.py`` to stay
|
|
dialect-agnostic (SQLite/PostgreSQL).
|
|
"""
|
|
import json
|
|
import uuid
|
|
from datetime import datetime, timedelta
|
|
|
|
from app import db
|
|
from app.models.json_column_mixin import JsonColumnMixin
|
|
from app.models.mixins import TimestampMixin
|
|
|
|
|
|
def _new_id() -> str:
|
|
"""Opaque, URL-safe conversation id (also used as Prompture conversation_id)."""
|
|
return uuid.uuid4().hex
|
|
|
|
|
|
class AiConversation(JsonColumnMixin, TimestampMixin, db.Model):
|
|
"""A single AI chat thread owned by a user."""
|
|
__tablename__ = 'ai_conversations'
|
|
|
|
MODE_ASSISTANT = 'assistant' # tools + page context
|
|
MODE_SIMPLE = 'simple' # plain chat, no tools
|
|
|
|
id = db.Column(db.String(64), primary_key=True, default=_new_id)
|
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), index=True, nullable=False)
|
|
title = db.Column(db.String(256))
|
|
mode = db.Column(db.String(16), default=MODE_ASSISTANT)
|
|
model_name = db.Column(db.String(384)) # 'provider/model' snapshot at creation
|
|
connection_id = db.Column(db.String(64), db.ForeignKey('ai_provider_connections.id'), index=True)
|
|
export_json = db.Column(db.Text) # Prompture conv.export(strip_images=True)
|
|
last_page = db.Column(db.String(256)) # last route the assistant saw (for resume context)
|
|
management_json = db.Column(db.Text)
|
|
|
|
@property
|
|
def management(self):
|
|
return self._json_read('management_json', {})
|
|
|
|
@management.setter
|
|
def management(self, value):
|
|
self.management_json = json.dumps(value)
|
|
|
|
messages = db.relationship(
|
|
'AiMessage', backref='conversation', cascade='all, delete-orphan',
|
|
order_by='AiMessage.created_at', lazy='dynamic',
|
|
)
|
|
pending_actions = db.relationship(
|
|
'AiPendingAction', backref='conversation', cascade='all, delete-orphan', lazy='dynamic',
|
|
)
|
|
# Parent-side backref so the delete-cascade policy covers the NOT NULL FK:
|
|
# a user's conversations die with the account.
|
|
user = db.relationship('User',
|
|
backref=db.backref('ai_conversations', lazy='dynamic'))
|
|
|
|
@property
|
|
def export(self) -> dict:
|
|
return self._json_read('export_json')
|
|
|
|
@export.setter
|
|
def export(self, value) -> None:
|
|
self.export_json = json.dumps(value) if value is not None else None
|
|
|
|
def to_dict(self, include_messages: bool = False) -> dict:
|
|
data = {
|
|
'id': self.id,
|
|
'user_id': self.user_id,
|
|
'title': self.title or 'New chat',
|
|
'mode': self.mode,
|
|
'model_name': self.model_name,
|
|
'connection_id': self.connection_id,
|
|
'last_page': self.last_page,
|
|
'management': self.management,
|
|
'message_count': self.messages.count(),
|
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
|
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
|
}
|
|
if include_messages:
|
|
data['messages'] = [m.to_dict() for m in self.messages]
|
|
return data
|
|
|
|
|
|
class AiProviderConnection(JsonColumnMixin, TimestampMixin, db.Model):
|
|
"""Admin-managed AI connection. All driver configuration is encrypted."""
|
|
__tablename__ = 'ai_provider_connections'
|
|
|
|
id = db.Column(db.String(64), primary_key=True, default=_new_id)
|
|
name = db.Column(db.String(100), nullable=False)
|
|
provider = db.Column(db.String(64), nullable=False)
|
|
model = db.Column(db.String(256), nullable=False)
|
|
config_encrypted = db.Column(db.Text, nullable=False)
|
|
model_catalog_json = db.Column(db.Text)
|
|
conversations = db.relationship('AiConversation', backref='connection')
|
|
|
|
@property
|
|
def model_catalog(self):
|
|
return self._json_read('model_catalog_json', [])
|
|
|
|
@model_catalog.setter
|
|
def model_catalog(self, value):
|
|
self.model_catalog_json = json.dumps(value)
|
|
|
|
@property
|
|
def config(self):
|
|
config = self._json_read('_decrypted_config_json', None, expect=dict)
|
|
if config is None:
|
|
raise ValueError('Invalid saved AI connection configuration.')
|
|
return config
|
|
|
|
@property
|
|
def _decrypted_config_json(self):
|
|
from app.utils.crypto import decrypt_secret
|
|
return decrypt_secret(self.config_encrypted)
|
|
|
|
@config.setter
|
|
def config(self, value):
|
|
from app.utils.crypto import encrypt_secret
|
|
self.config_encrypted = encrypt_secret(json.dumps(value))
|
|
|
|
|
|
class AiMessage(JsonColumnMixin, db.Model):
|
|
"""One persisted turn in a conversation (user, assistant, or tool)."""
|
|
__tablename__ = 'ai_messages'
|
|
|
|
ROLE_USER = 'user'
|
|
ROLE_ASSISTANT = 'assistant'
|
|
ROLE_TOOL = 'tool'
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
conversation_id = db.Column(
|
|
db.String(64), db.ForeignKey('ai_conversations.id', ondelete='CASCADE'), index=True, nullable=False,
|
|
)
|
|
role = db.Column(db.String(16), nullable=False)
|
|
content = db.Column(db.Text)
|
|
attachments_json = db.Column(db.Text) # reference manifest; never resolved context
|
|
tool_calls_json = db.Column(db.Text) # [{id,name,input,output,is_error}] for ToolCallCard replay
|
|
usage_json = db.Column(db.Text) # {input_tokens, output_tokens, cost}
|
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
|
|
|
@property
|
|
def tool_calls(self) -> list:
|
|
return self._json_read('tool_calls_json', [])
|
|
|
|
@tool_calls.setter
|
|
def tool_calls(self, value) -> None:
|
|
self.tool_calls_json = json.dumps(value) if value else None
|
|
|
|
@property
|
|
def attachments(self) -> list:
|
|
return self._json_read('attachments_json', [])
|
|
|
|
@attachments.setter
|
|
def attachments(self, value) -> None:
|
|
self.attachments_json = json.dumps(value) if value else None
|
|
|
|
@property
|
|
def usage(self) -> dict:
|
|
return self._json_read('usage_json')
|
|
|
|
@usage.setter
|
|
def usage(self, value) -> None:
|
|
self.usage_json = json.dumps(value) if value else None
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
'id': self.id,
|
|
'conversation_id': self.conversation_id,
|
|
'role': self.role,
|
|
'content': self.content,
|
|
'attachments': self.attachments,
|
|
'tool_calls': self.tool_calls,
|
|
'usage': self.usage,
|
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
|
}
|
|
|
|
|
|
class AiRun(JsonColumnMixin, db.Model):
|
|
"""Durable per-turn accounting, retained when a transcript is deleted."""
|
|
__tablename__ = 'ai_runs'
|
|
|
|
id = db.Column(db.String(64), primary_key=True, default=_new_id)
|
|
conversation_id = db.Column(db.String(64), db.ForeignKey('ai_conversations.id', ondelete='SET NULL'), index=True)
|
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), index=True)
|
|
workspace_id = db.Column(db.Integer, db.ForeignKey('workspaces.id', ondelete='SET NULL'), index=True)
|
|
conversation = db.relationship('AiConversation', backref=db.backref('runs', lazy='dynamic'))
|
|
user = db.relationship('User', backref=db.backref('ai_runs', lazy='dynamic'))
|
|
workspace = db.relationship('Workspace', backref=db.backref('ai_runs', lazy='dynamic'))
|
|
connection_id = db.Column(db.String(64), index=True) # immutable reporting snapshot
|
|
model = db.Column(db.String(384))
|
|
profile = db.Column(db.String(16), nullable=False, default='standard')
|
|
workflow = db.Column(db.String(16), nullable=False, default='chat')
|
|
strategy = db.Column(db.String(32), nullable=False, default='explicit')
|
|
reason = db.Column(db.String(512))
|
|
status = db.Column(db.String(24), nullable=False, default='running', index=True)
|
|
cost = db.Column(db.Float, nullable=False, default=0)
|
|
budget_charge = db.Column(db.Float, nullable=False, default=0)
|
|
reserved_cost = db.Column(db.Float, nullable=False, default=0)
|
|
total_tokens = db.Column(db.Integer, nullable=False, default=0)
|
|
duration_ms = db.Column(db.Float, nullable=False, default=0)
|
|
usage_json = db.Column(db.Text)
|
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
|
|
completed_at = db.Column(db.DateTime)
|
|
|
|
@property
|
|
def usage(self):
|
|
return self._json_read('usage_json', {})
|
|
|
|
@usage.setter
|
|
def usage(self, value):
|
|
self.usage_json = json.dumps(value)
|
|
|
|
def to_dict(self):
|
|
return {**{key: getattr(self, key) for key in (
|
|
'id', 'conversation_id', 'user_id', 'workspace_id', 'connection_id', 'model',
|
|
'profile', 'workflow', 'strategy', 'reason', 'status', 'cost', 'budget_charge',
|
|
'reserved_cost', 'total_tokens', 'duration_ms')},
|
|
'usage': self.usage, 'created_at': self.created_at.isoformat() + 'Z',
|
|
'completed_at': self.completed_at.isoformat() + 'Z' if self.completed_at else None}
|
|
|
|
|
|
class AiPendingAction(JsonColumnMixin, db.Model):
|
|
"""A guarded write-tool action awaiting human confirmation.
|
|
|
|
Live approve/deny is coordinated in-memory by the streaming worker; this
|
|
row is the durable record (status + result) for audit and admin visibility.
|
|
"""
|
|
__tablename__ = 'ai_pending_actions'
|
|
|
|
STATUS_PENDING = 'pending'
|
|
STATUS_APPROVED = 'approved'
|
|
STATUS_DENIED = 'denied'
|
|
STATUS_EXPIRED = 'expired'
|
|
STATUS_EXECUTED = 'executed'
|
|
STATUS_FAILED = 'failed'
|
|
|
|
DEFAULT_TTL_SECONDS = 300
|
|
|
|
id = db.Column(db.String(64), primary_key=True) # action token (secrets.token_urlsafe)
|
|
conversation_id = db.Column(
|
|
db.String(64), db.ForeignKey('ai_conversations.id', ondelete='CASCADE'), index=True, nullable=False,
|
|
)
|
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), index=True, nullable=False)
|
|
# Parent-side backref so the delete-cascade policy covers the NOT NULL FK.
|
|
user = db.relationship('User',
|
|
backref=db.backref('ai_pending_actions', lazy='dynamic'))
|
|
tool_name = db.Column(db.String(128), nullable=False) # qualified name, e.g. core__restart_docker_container
|
|
plugin_slug = db.Column(db.String(128)) # None => built-in tool
|
|
params_json = db.Column(db.Text)
|
|
summary = db.Column(db.Text)
|
|
status = db.Column(db.String(16), default=STATUS_PENDING)
|
|
result_json = db.Column(db.Text)
|
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
|
expires_at = db.Column(db.DateTime)
|
|
|
|
@property
|
|
def params(self) -> dict:
|
|
return self._json_read('params_json')
|
|
|
|
@params.setter
|
|
def params(self, value) -> None:
|
|
self.params_json = json.dumps(value) if value else None
|
|
|
|
@property
|
|
def result(self):
|
|
return self._json_read('result_json', None)
|
|
|
|
@result.setter
|
|
def result(self, value) -> None:
|
|
self.result_json = json.dumps(value) if value is not None else None
|
|
|
|
def is_expired(self) -> bool:
|
|
return self.expires_at is not None and datetime.utcnow() > self.expires_at
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
'action_token': self.id,
|
|
'conversation_id': self.conversation_id,
|
|
'tool_name': self.tool_name,
|
|
'plugin_slug': self.plugin_slug,
|
|
'params': self.params,
|
|
'summary': self.summary,
|
|
'status': self.status,
|
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
|
'expires_at': self.expires_at.isoformat() if self.expires_at else None,
|
|
}
|
|
|
|
@classmethod
|
|
def make_expiry(cls, ttl_seconds: int | None = None) -> datetime:
|
|
return datetime.utcnow() + timedelta(seconds=ttl_seconds or cls.DEFAULT_TTL_SECONDS)
|