feat(models): 组织、用户、角色、客户、会话与消息模型
按组织边界定义数据关系,补齐仓储层与通用工具,迁移脚本可重复执行。
This commit is contained in:
1 parent
2aee6286d6
commit
5e0615f36d
126 files changed
+17436
No files matched your search
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, DateTime, Integer, String, Text, Boolean, Enum as SQLEnum, ForeignKey, JSON, Table, Float, func
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.database import Base
|
||||
import enum
|
||||
import json
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class AgentType(str, enum.Enum):
|
||||
CUSTOMER_SUPPORT = "customer_support"
|
||||
SALES = "sales"
|
||||
TECH_SUPPORT = "tech_support"
|
||||
GENERAL = "general"
|
||||
CUSTOM = "custom"
|
||||
|
||||
|
||||
class ChatStyle(str, enum.Enum):
|
||||
CHATBOT = "CHATBOT"
|
||||
ASK_ANYTHING = "ASK_ANYTHING"
|
||||
# Premium design presets
|
||||
GLASS = "GLASS"
|
||||
TERMINAL = "TERMINAL"
|
||||
PLAYFUL = "PLAYFUL"
|
||||
CALM_MINT = "CALM_MINT"
|
||||
AURORA = "AURORA"
|
||||
SUNRISE = "SUNRISE"
|
||||
|
||||
|
||||
class WidgetPosition(str, enum.Enum):
|
||||
FLOATING = "FLOATING"
|
||||
FIXED = "FIXED"
|
||||
|
||||
|
||||
class AgentCustomization(Base):
|
||||
__tablename__ = "agent_customizations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id", ondelete="CASCADE"))
|
||||
photo_url = Column(String)
|
||||
chat_background_color = Column(String, default="#F8F9FA")
|
||||
chat_bubble_color = Column(String, default="#E9ECEF")
|
||||
chat_text_color = Column(String, default="#212529")
|
||||
icon_url = Column(String)
|
||||
icon_color = Column(String, default="#6C757D")
|
||||
accent_color = Column(String, default="#f34611")
|
||||
font_family = Column(String, default="Inter, system-ui, sans-serif")
|
||||
custom_css = Column(Text)
|
||||
customization_metadata = Column(JSON, default={})
|
||||
chat_style = Column(SQLEnum(ChatStyle), default=ChatStyle.CHATBOT, nullable=False)
|
||||
widget_position = Column(SQLEnum(WidgetPosition), default=WidgetPosition.FLOATING, nullable=False)
|
||||
welcome_title = Column(String, nullable=True)
|
||||
welcome_subtitle = Column(String, nullable=True)
|
||||
# First in-conversation agent bubble shown on open (distinct from welcome_title/subtitle)
|
||||
welcome_message = Column(Text, nullable=True)
|
||||
chat_initiation_messages = Column(JSON, nullable=True)
|
||||
# Predefined quick-action buttons shown beneath the welcome message (list of label strings)
|
||||
quick_actions = Column(JSON, nullable=True)
|
||||
show_citations = Column(Boolean, default=False, nullable=False)
|
||||
# Optionally require the visitor's email before chatting (off by default)
|
||||
collect_email = Column(Boolean, default=False, nullable=False)
|
||||
# Small footer line telling visitors the replies come from AI. On by default:
|
||||
# the honest disclosure is the one users expect, and it is only ever hidden
|
||||
# once a human agent takes the conversation over, where it would be untrue.
|
||||
show_ai_disclaimer = Column(Boolean, default=True, nullable=False, server_default="true")
|
||||
# Lets a visitor abandon the current conversation and start a clean one. Off by
|
||||
# default: it closes the session, so a human agent mid-handover would lose the
|
||||
# thread — operators opt in when that trade is right for them.
|
||||
allow_new_chat = Column(Boolean, default=False, nullable=False, server_default="false")
|
||||
|
||||
# Relationship
|
||||
agent = relationship("Agent", back_populates="customization")
|
||||
|
||||
|
||||
|
||||
# Association table for agent-usergroup relationship
|
||||
agent_usergroup = Table(
|
||||
'agent_usergroup',
|
||||
Base.metadata,
|
||||
Column('agent_id', UUID(as_uuid=True), ForeignKey('agents.id', ondelete='CASCADE')),
|
||||
Column('group_id', UUID(as_uuid=True), ForeignKey('groups.id', ondelete='CASCADE'))
|
||||
)
|
||||
|
||||
|
||||
class Agent(Base):
|
||||
__tablename__ = "agents"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name = Column(String(100), nullable=False)
|
||||
display_name = Column(String(100), nullable=True)
|
||||
description = Column(Text)
|
||||
agent_type = Column(SQLEnum(AgentType), nullable=False)
|
||||
_instructions = Column('instructions', Text, nullable=False)
|
||||
tools = Column(Text) # Stored as JSON array of tool configurations
|
||||
is_active = Column(Boolean, default=False)
|
||||
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"))
|
||||
is_default = Column(Boolean, default=False)
|
||||
transfer_to_human = Column(Boolean, default=False, nullable=False)
|
||||
# Off means the AI never answers on this agent: every chat is queued for the
|
||||
# team from the customer's first message. Distinct from transfer_to_human,
|
||||
# which only lets the AI hand over when it decides to.
|
||||
ai_replies_enabled = Column(Boolean, default=True, nullable=False, server_default="true")
|
||||
# Offer the AI's choices as tappable buttons on messaging channels that
|
||||
# can show them (WhatsApp, Messenger, Instagram, Telegram); not the widget
|
||||
channel_quick_replies = Column(Boolean, default=False, nullable=False, server_default="false")
|
||||
ask_for_rating = Column(Boolean, default=True, nullable=True)
|
||||
# On human handoff, optionally collect the visitor's contact details
|
||||
handoff_collect_email = Column(Boolean, default=True, nullable=False)
|
||||
handoff_collect_name = Column(Boolean, default=True, nullable=False)
|
||||
enable_rate_limiting = Column(Boolean, default=False, nullable=True)
|
||||
overall_limit_per_ip = Column(Integer, default=100, nullable=True)
|
||||
requests_per_sec = Column(Float, default=1, nullable=True)
|
||||
use_workflow = Column(Boolean, default=False, nullable=True)
|
||||
active_workflow_id = Column(UUID(as_uuid=True), ForeignKey("workflows.id"), nullable=True)
|
||||
allow_attachments = Column(Boolean, default=False, nullable=False)
|
||||
# JSON array of allowed attachment type categories: ['images', 'documents', 'office', 'text']
|
||||
# If empty or null, all types are allowed when attachments are enabled
|
||||
allowed_attachment_types = Column(JSON, default=None, nullable=True)
|
||||
require_token_auth = Column(Boolean, default=False, nullable=False)
|
||||
# Per-agent switch for native AI ticketing (org plan must also allow it).
|
||||
# Defaults on so paid orgs get ticketing on every agent out of the box.
|
||||
ticketing_enabled = Column(Boolean, default=True, nullable=False, server_default="true")
|
||||
# Optional one-line business remit for the platform guardrail's topic-scope
|
||||
# line (see app.agents.guardrail_policy). Null -> derived from the agent
|
||||
# description or the organization name/domain, so it works with zero config.
|
||||
# Only the scope DESCRIPTION is tenant-editable; the enforcement text is code.
|
||||
topic_scope = Column(Text, nullable=True)
|
||||
# Tenant-editable scope rule shown under Instructions in the dashboard.
|
||||
# NULL means "use the shipped default" (guardrail_policy.DEFAULT_GUARDRAIL_PROMPT),
|
||||
# so the wording can be improved centrally without rewriting every row.
|
||||
# Editable because a code-owned topic list is a guess about what a business
|
||||
# is NOT — it refused maths for a maths tutor, algorithms for a bootcamp.
|
||||
guardrail_prompt = Column(Text, nullable=True)
|
||||
# Off means no scope rule at all. Injection and disclosure rules are not
|
||||
# covered by this toggle; they stay code-owned.
|
||||
guardrail_enabled = Column(Boolean, default=True, nullable=False, server_default="true")
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
# Relationships
|
||||
organization = relationship("Organization", back_populates="agents")
|
||||
knowledge_links = relationship(
|
||||
"KnowledgeToAgent", back_populates="agent", cascade="all, delete-orphan")
|
||||
mcp_tool_links = relationship(
|
||||
"MCPToolToAgent", back_populates="agent", cascade="all, delete-orphan")
|
||||
customization = relationship(
|
||||
"AgentCustomization", back_populates="agent", uselist=False)
|
||||
lead_capture_config = relationship(
|
||||
"LeadCaptureConfig", back_populates="agent", uselist=False,
|
||||
cascade="all, delete-orphan")
|
||||
widgets = relationship("Widget", back_populates="agent")
|
||||
chat_histories = relationship("ChatHistory", back_populates="agent")
|
||||
session_assignments = relationship("SessionToAgent", back_populates="agent")
|
||||
ratings = relationship("Rating", back_populates="agent")
|
||||
groups = relationship(
|
||||
"UserGroup",
|
||||
secondary=agent_usergroup,
|
||||
backref="agents",
|
||||
lazy="joined"
|
||||
)
|
||||
active_workflow = relationship(
|
||||
"Workflow",
|
||||
foreign_keys=[active_workflow_id],
|
||||
backref="active_agents"
|
||||
)
|
||||
workflows = relationship(
|
||||
"Workflow",
|
||||
foreign_keys="[Workflow.agent_id]",
|
||||
back_populates="agent"
|
||||
)
|
||||
@property
|
||||
def instructions(self):
|
||||
"""Get instructions as a list"""
|
||||
if not self._instructions:
|
||||
return []
|
||||
try:
|
||||
return json.loads(self._instructions)
|
||||
except json.JSONDecodeError:
|
||||
return [self._instructions]
|
||||
|
||||
@instructions.setter
|
||||
def instructions(self, value):
|
||||
"""Set instructions, converting to JSON string if needed"""
|
||||
if isinstance(value, list):
|
||||
self._instructions = json.dumps(value)
|
||||
elif isinstance(value, str):
|
||||
try:
|
||||
# Try to parse as JSON first
|
||||
parsed = json.loads(value)
|
||||
if isinstance(parsed, list):
|
||||
self._instructions = value
|
||||
else:
|
||||
self._instructions = json.dumps([value])
|
||||
except json.JSONDecodeError:
|
||||
# If not valid JSON, treat as single instruction
|
||||
self._instructions = json.dumps([value])
|
||||
else:
|
||||
raise ValueError("Instructions must be a list or string")
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
Reference in new issue
Block a user