217 lines
9.9 KiB
Python
217 lines
9.9 KiB
Python
"""
|
|||
|
|
Copyright 2024-2026 ReplyPier
|
||
|
|
|
||
|
|
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
|