feat(models): 组织、用户、角色、客户、会话与消息模型

按组织边界定义数据关系,补齐仓储层与通用工具,迁移脚本可重复执行。
This commit is contained in:
WangShen007 committed 2026-09-23 22:05:00 +08:00
1 parent 2aee6286d6
commit 5e0615f36d
126 files changed
+17436

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
"""
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 .organization import (
OrganizationBase,
OrganizationCreate,
OrganizationUpdate,
OrganizationResponse
)
__all__ = [
'OrganizationBase',
'OrganizationCreate',
'OrganizationUpdate',
'OrganizationResponse',
'AgentSessionBase',
'AgentSessionCreate',
'AgentSessionUpdate',
'AgentSessionResponse'
]
+163
View File
@@ -0,0 +1,163 @@
"""
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 datetime import datetime
from pydantic import BaseModel, Field
from typing import List, Optional, Dict
from enum import Enum
from uuid import UUID
from app.models.agent import AgentType
from app.agents.guardrail_policy import GUARDRAIL_PROMPT_MAX
from app.models.schemas.agent_customization import CustomizationResponse
from app.models.schemas.user_group import UserGroupResponse
class KnowledgeItem(BaseModel):
id: int
name: str
type: str
class AgentBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = None
agent_type: AgentType
instructions: List[str]
tools: Optional[List[Dict]] = None
is_active: bool = True
is_default: bool = False
transfer_to_human: bool = False
ai_replies_enabled: bool = True
channel_quick_replies: bool = False
ask_for_rating: bool = False
handoff_collect_email: bool = True
handoff_collect_name: bool = True
enable_rate_limiting: bool = False
overall_limit_per_ip: int = Field(default=100, description="Maximum number of requests allowed per IP address")
requests_per_sec: float = Field(default=1.0, description="Number of requests allowed per second")
use_workflow: bool = False
active_workflow_id: Optional[UUID] = None
display_name: Optional[str] = None
allow_attachments: bool = False
allowed_attachment_types: Optional[List[str]] = Field(
default=None,
description="List of allowed attachment type categories: 'images', 'documents', 'office', 'text'. If null/empty, all types allowed."
)
require_token_auth: bool = False
ticketing_enabled: bool = True
topic_scope: Optional[str] = Field(
default=None, max_length=500,
description="Optional one-line business remit used by the platform guardrail's topic-scope line."
)
guardrail_prompt: Optional[str] = Field(
default=None, max_length=GUARDRAIL_PROMPT_MAX,
description="Scope rule for this agent. Null uses the platform default; edit it to fit your business."
)
guardrail_enabled: bool = Field(
default=True,
description="Apply the scope rule. Injection and disclosure protections are always on."
)
class AgentCreate(AgentBase):
organization_id: Optional[UUID] = None
class AgentUpdate(BaseModel):
display_name: Optional[str] = None
instructions: Optional[List[str]] = None
is_active: Optional[bool] = None
transfer_to_human: Optional[bool] = None
ai_replies_enabled: Optional[bool] = None
channel_quick_replies: Optional[bool] = None
ask_for_rating: Optional[bool] = None
handoff_collect_email: Optional[bool] = None
handoff_collect_name: Optional[bool] = None
enable_rate_limiting: Optional[bool] = None
overall_limit_per_ip: Optional[int] = None
requests_per_sec: Optional[float] = None
use_workflow: Optional[bool] = None
active_workflow_id: Optional[UUID] = None
allow_attachments: Optional[bool] = None
allowed_attachment_types: Optional[List[str]] = Field(
default=None,
description="List of allowed attachment type categories: 'images', 'documents', 'office', 'text'. If null/empty, all types allowed."
)
require_token_auth: Optional[bool] = None
ticketing_enabled: Optional[bool] = None
topic_scope: Optional[str] = Field(default=None, max_length=500)
guardrail_prompt: Optional[str] = Field(default=None, max_length=GUARDRAIL_PROMPT_MAX)
guardrail_enabled: Optional[bool] = None
class AgentKnowledge(BaseModel):
id: int
name: str
type: str
class AgentRosterItem(BaseModel):
"""An AI agent as the inbox filter needs it: a name to group chats by.
Deliberately none of AgentResponse's configuration — `instructions`, and
especially `guardrail_prompt`, which tells a reader how to phrase a bypass.
"""
id: UUID
name: str
display_name: Optional[str] = None
is_active: bool = True
class Config:
from_attributes = True
class AgentResponse(BaseModel):
id: UUID
name: str
display_name: Optional[str]
description: Optional[str]
agent_type: AgentType
instructions: List[str]
is_active: bool
organization_id: UUID
knowledge: List[AgentKnowledge] = []
transfer_to_human: bool = False
ai_replies_enabled: bool = True
channel_quick_replies: bool = False
ask_for_rating: bool = False
handoff_collect_email: bool = True
handoff_collect_name: bool = True
enable_rate_limiting: Optional[bool] = None
overall_limit_per_ip: Optional[int] = None
requests_per_sec: Optional[float] = None
use_workflow: Optional[bool] = False
active_workflow_id: Optional[UUID] = None
allow_attachments: bool = False
allowed_attachment_types: Optional[List[str]] = None
require_token_auth: bool = False
ticketing_enabled: bool = True
topic_scope: Optional[str] = None
guardrail_prompt: Optional[str] = None
guardrail_enabled: bool = True
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class AgentWithCustomizationResponse(AgentResponse):
customization: Optional[CustomizationResponse] = None
groups: List[UserGroupResponse] = []
@@ -0,0 +1,172 @@
"""
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 pydantic import BaseModel, Field, field_serializer, field_validator
from typing import Optional, Dict, List
from typing_extensions import Annotated
from uuid import UUID
import enum
from app.core.s3 import sign_s3_url, strip_s3_signature
# Cap launcher/welcome copy so it can't balloon the widget (the launcher nudge is
# also clamped to 4 lines client-side). Limits match the frontend input maxlengths
# so the UI never lets a user type something the API would reject.
InitiationMessage = Annotated[str, Field(max_length=100)]
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 WidgetDisplayMode(str, enum.Enum):
FLOATING = "floating"
SIDEBAR_LEFT = "sidebar-left"
SIDEBAR_RIGHT = "sidebar-right"
SEARCH_BAR = "search-bar"
class WidgetDisplaySide(str, enum.Enum):
LEFT = "left"
RIGHT = "right"
class WidgetDisplayConfig(BaseModel):
"""Dashboard "Widget placement" defaults, stored in customization_metadata.
The embed loader (chattermate.js) merges these under any options the
installing developer set on the page. Bounds keep saved values renderable
on any reasonable screen; the loader clamps again client-side.
"""
mode: Optional[WidgetDisplayMode] = None
side: Optional[WidgetDisplaySide] = None
launcher: Optional[bool] = None
width: Optional[int] = Field(default=None, ge=280, le=800)
height: Optional[int] = Field(default=None, ge=400, le=900)
sidebar_width: Optional[int] = Field(default=None, ge=320, le=640)
search_placeholder: Optional[str] = Field(default=None, max_length=80)
offset_bottom: Optional[int] = Field(default=None, ge=0, le=200)
offset_side: Optional[int] = Field(default=None, ge=0, le=200)
z_index: Optional[int] = Field(default=None, ge=1, le=2147483647)
# Predefined chat initiation messages
DEFAULT_CHAT_INITIATIONS = [
"👋 Hi! Need help? Ask me anything!",
"💬 Have a question? I'm here to help!",
"🤝 Welcome! How can I assist you today?",
"✨ Got questions? Let's chat!",
"👨‍💼 Need support? Click to chat with us!"
]
# Predefined quick-action buttons (clicking sends the label as a message)
DEFAULT_QUICK_ACTIONS = [
"Track my order",
"Start a return",
"Talk to a human"
]
class CustomizationBase(BaseModel):
photo_url: Optional[str] = None
@field_validator('photo_url')
@classmethod
def _strip_photo_url_signature(cls, v: Optional[str]) -> Optional[str]:
"""Keep the bare S3 URL as the stored value.
CustomizationResponse serializes photo_url signed, and the agent
customization form POSTs the value it was given straight back, so
without this the expiring signature would be written to the column.
"""
return strip_s3_signature(v)
chat_background_color: Optional[str] = "#F8F9FA"
chat_bubble_color: Optional[str] = "#E9ECEF"
chat_text_color: Optional[str] = "#212529"
icon_url: Optional[str] = None
icon_color: Optional[str] = "#6C757D"
accent_color: Optional[str] = "#f34611"
font_family: Optional[str] = "Inter, system-ui, sans-serif"
custom_css: Optional[str] = None
customization_metadata: Optional[Dict] = {}
chat_style: Optional[ChatStyle] = ChatStyle.CHATBOT
widget_position: Optional[WidgetPosition] = WidgetPosition.FLOATING
# NOTE: length caps live on CustomizationCreate (input), NOT here — the response
# model also inherits this base, and existing rows may exceed a newly-lowered cap;
# enforcing on the response would 500 the whole agent list.
welcome_title: Optional[str] = None
welcome_subtitle: Optional[str] = None
welcome_message: Optional[str] = None
chat_initiation_messages: Optional[List[str]] = None
quick_actions: Optional[List[str]] = None
show_citations: Optional[bool] = False
collect_email: Optional[bool] = False
show_ai_disclaimer: Optional[bool] = True
allow_new_chat: Optional[bool] = False
class CustomizationCreate(CustomizationBase):
# Enforce length caps on write only, so the UI can't submit values the widget
# can't display. Reads (CustomizationResponse) intentionally stay unconstrained.
welcome_title: Optional[str] = Field(default=None, max_length=100)
welcome_subtitle: Optional[str] = Field(default=None, max_length=250)
welcome_message: Optional[str] = Field(default=None, max_length=500)
chat_initiation_messages: Optional[List[InitiationMessage]] = None
@field_validator('customization_metadata')
@classmethod
def _validate_widget_display(cls, v: Optional[Dict]) -> Optional[Dict]:
"""Validate and normalize the structured widget_display key on write.
The rest of customization_metadata stays free-form (avatar_style etc.);
widget_display is consumed by the embed loader on customer pages, so bad
values must be rejected here. Unknown keys inside it are dropped and
unset keys aren't stored. An explicit null clears the settings.
"""
if not v or v.get('widget_display') is None:
return v
validated = WidgetDisplayConfig.model_validate(v['widget_display'])
# mode='json' turns enums into plain strings — the dict goes straight into
# a JSON column, so it must hold JSON primitives, not enum members.
v['widget_display'] = validated.model_dump(mode='json', exclude_none=True)
return v
class CustomizationResponse(CustomizationBase):
id: int
agent_id: UUID
@field_serializer('photo_url')
def _sign_photo_url(self, v: Optional[str]) -> Optional[str]:
"""Sign on the way out, every response. Signing is a local HMAC (~0.05ms),
so there is nothing to gain by caching the result in the database."""
return sign_s3_url(v) if v else v
class Config:
from_attributes = True
+58
View File
@@ -0,0 +1,58 @@
"""
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 pydantic import BaseModel, SecretStr
from typing import Optional, Dict
from app.models.ai_config import AIModelType
from uuid import UUID
class AIConfigBase(BaseModel):
model_type: AIModelType
model_name: str
settings: Optional[Dict] = {}
class AIConfigCreate(AIConfigBase):
api_key: SecretStr
# Required for OPENAI_COMPATIBLE (a self-hosted or third-party endpoint);
# ignored by providers that don't need it.
base_url: Optional[str] = None
class AIConfigUpdate(BaseModel):
model_type: Optional[AIModelType] = None
model_name: Optional[str] = None
api_key: Optional[SecretStr] = None
settings: Optional[Dict] = None
base_url: Optional[str] = None
class AIConfigResponse(BaseModel):
id: int
organization_id: UUID
model_type: AIModelType
model_name: str
is_active: bool
settings: Dict = {}
class Config:
from_attributes = True
class AISetupResponse(BaseModel):
message: str
config: AIConfigResponse
+229
View File
@@ -0,0 +1,229 @@
"""
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 datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, field_validator
from app.channels.constants import DEFAULT_TEMPLATE_LANGUAGE
class ChannelAccountOut(BaseModel):
"""Connected channel account as exposed to the settings UI (no secrets)."""
id: UUID
channel_type: str
external_account_id: str
display_name: Optional[str] = None
is_active: bool
agent_id: Optional[UUID] = None # agent currently routed to this account
created_at: Optional[datetime] = None
# For channels the customer must point at us (email inbound-parse, SMS,
# Meta accounts from the customer's own app): the exact webhook URL to
# configure on their provider, and the verify token Meta asks for.
webhook_url: Optional[str] = None
# The platform refused the account's credentials; replies fail until the
# account is reconnected
needs_reconnect: bool = False
webhook_verify_token: Optional[str] = None
# WhatsApp from the customer's own app: whether Meta took our per-number
# callback (else the customer sets the URL and token by hand)
webhook_auto_configured: Optional[bool] = None
class Config:
from_attributes = True
class TelegramConnectRequest(BaseModel):
bot_token: str
class OwnMetaAppSecret(BaseModel):
"""The secret of the customer's own Meta app, for manual credentials:
Meta signs that app's webhooks with it. Blank means not given."""
app_secret: Optional[str] = None
@field_validator("app_secret")
@classmethod
def _blank_is_none(cls, value: Optional[str]) -> Optional[str]:
return (value or "").strip() or None
class WhatsAppConnectRequest(OwnMetaAppSecret):
"""Manual WhatsApp Cloud API credentials, from the customer's own Meta app."""
phone_number_id: str
access_token: str
waba_id: Optional[str] = None
display_name: Optional[str] = None
class MessengerConnectRequest(OwnMetaAppSecret):
page_id: str
page_access_token: str
display_name: Optional[str] = None
class InstagramConnectRequest(BaseModel):
ig_id: str
page_access_token: str
display_name: Optional[str] = None
class TemplateSendRequest(BaseModel):
"""Reopen an expired WhatsApp window with an approved template."""
session_id: UUID
template_name: str
language: str = DEFAULT_TEMPLATE_LANGUAGE
components: Optional[list] = None
class OutboundConversationRequest(BaseModel):
"""Start a WhatsApp conversation with a phone number — the customer has
not messaged us; an approved template is the only thing Meta will deliver.
customer_id links the conversation to a person the agent picked in People;
without it the customer is resolved by phone or created. customer_name
only names a newly created person (never renames an existing one)."""
to: str
template_name: str
language: str = DEFAULT_TEMPLATE_LANGUAGE
components: Optional[list] = None
customer_id: Optional[UUID] = None
customer_name: Optional[str] = None
class OutboundConversationOut(BaseModel):
"""The session the outbound send created (or reused): everything else —
the inbox thread, template resend, AI takeover on reply — keys off it."""
session_id: UUID
class EmbeddedSignupRequest(BaseModel):
"""What the Embedded Signup JS SDK hands back: a short-lived code to trade
for the customer's business token, plus the assets it created."""
code: str
waba_id: str
phone_number_id: str
display_name: Optional[str] = None
class MessengerSignupRequest(BaseModel):
"""The code Facebook Login for Business hands back. Unlike WhatsApp's
Embedded Signup it names no assets — the Pages are ours to look up.
redirect_uri is the OAuth callback URL the login popup used; the code is
bound to it, so the token exchange must send the exact same value back. The
client owns it (it must also be a registered Valid OAuth Redirect URI)."""
code: str
redirect_uri: str
class InstagramLoginRequest(BaseModel):
"""What Instagram Business Login hands back. No Page and no asset ids — the
token itself identifies the single account that signed in, so unlike the
Messenger flow there is nothing to list or pick.
redirect_uri is the OAuth callback the popup used; the code is bound to it,
so the token exchange must send the exact same value back."""
code: str
redirect_uri: str
display_name: Optional[str] = None
class MessengerPageOut(BaseModel):
"""A Page offered in the picker. Its access token is deliberately absent:
it travels back inside the opaque signup_token instead."""
id: str
name: str
class MessengerSignupPagesOut(BaseModel):
"""Step 1's result: the Pages to choose from, plus an opaque token that
carries their access tokens (encrypted) into step 2."""
pages: list[MessengerPageOut]
signup_token: str
class MessengerSignupConnectRequest(BaseModel):
"""Step 2: the Page picked out of step 1's list. page_id is a selector into
that list, not a credential — the token is the one we sealed."""
signup_token: str
page_id: str
display_name: Optional[str] = None
class EmbeddedSignupConfigOut(BaseModel):
"""Tells the connect UI whether to offer Embedded Signup. Everything but
`enabled` is None when it isn't, so nothing leaks to orgs without it."""
enabled: bool
config_id: Optional[str] = None
app_id: Optional[str] = None
graph_version: str
class TemplateOut(BaseModel):
"""A message template as it exists on the customer's WhatsApp Business
Account. Only APPROVED templates can actually be sent."""
id: Optional[str] = None
name: str
status: Optional[str] = None
category: Optional[str] = None
language: Optional[str] = None
components: Optional[list] = None
class TemplateLibraryOut(BaseModel):
"""A deep link into Meta's Template Library for one WhatsApp Business
Account — which is where templates get written. We list and send them; Meta
authors them."""
url: str
class AgentChannelConfigRequest(BaseModel):
agent_id: UUID
is_active: bool = True
class EmailConnectRequest(BaseModel):
inbound_address: str # e.g. support@acme.com (the address customers write to)
display_name: Optional[str] = None
# Optional per-inbox outbound SMTP. When omitted, replies use the platform
# SMTP settings. Provide these to send from the inbox's own domain with
# correct SPF/DKIM alignment.
smtp_host: Optional[str] = None
smtp_port: Optional[int] = None
smtp_username: Optional[str] = None
smtp_password: Optional[str] = None
from_email: Optional[str] = None # defaults to inbound_address
smtp_use_ssl: Optional[bool] = None # None = auto (port 465 → implicit TLS)
class SmsConnectRequest(BaseModel):
provider: str # 'twilio' | 'vonage' | 'messagebird' | ...
phone_number: str # sender number / id
credentials: dict = {} # provider-specific secrets
class SmsProviderInfo(BaseModel):
name: str
label: str
fields: list # [{key,label,secret,optional}] for the UI
class LineConnectRequest(BaseModel):
channel_secret: str
channel_access_token: str
+386
View File
@@ -0,0 +1,386 @@
"""
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 pydantic import BaseModel, Field, field_validator, model_validator
from typing import List, Optional, Dict, Any
from uuid import UUID
from datetime import datetime
from enum import Enum
from app.models.session_to_agent import SessionStatus
from app.channels.constants import MAX_CHOICE_LENGTH, MAX_CHOICE_OPTIONS
class CustomerInfo(BaseModel):
id: UUID
email: Optional[str]
full_name: Optional[str]
# Integrator-supplied fields (e.g. student_name, center_name) set via
# POST /generate-token's `custom_data`, shown to agents in the chat inbox.
meta_data: Optional[Dict[str, Any]] = None
class AgentInfo(BaseModel):
id: UUID
name: str
display_name: Optional[str]
# False means this agent never answers with AI, so an unclaimed chat of its
# is waiting for a person rather than being handled.
ai_replies_enabled: bool = True
class TransferReasonType(str, Enum):
UNABLE_TO_ANSWER = "UNABLE_TO_ANSWER"
NEED_MORE_INFO = "NEED_MORE_INFO"
KNOWLEDGE_GAP = "KNOWLEDGE_GAP"
NEED_TO_CALL = "NEED_TO_CALL"
NEED_TO_EMAIL = "NEED_TO_EMAIL"
NEED_TO_MEET = "NEED_TO_MEET"
FRUSTRATED = "FRUSTRATED"
REPEAT_INSTRUCTIONS = "REPEAT_INSTRUCTIONS"
DIRECT_REQUEST = "DIRECT_REQUEST"
HIGH_PRIORITY_ISSUE = "HIGH_PRIORITY_ISSUE"
COMPLIANCE_ISSUE = "COMPLIANCE_ISSUE"
class EndChatReasonType(str, Enum):
ISSUE_RESOLVED = "ISSUE_RESOLVED"
CUSTOMER_REQUEST = "CUSTOMER_REQUEST"
CONFIRMATION_RECEIVED = "CONFIRMATION_RECEIVED"
FAREWELL = "FAREWELL"
THANK_YOU = "THANK_YOU"
NATURAL_CONCLUSION = "NATURAL_CONCLUSION"
TASK_COMPLETED = "TASK_COMPLETED"
# Define a model for a single Shopify product image
class ShopifyProductImage(BaseModel):
src: Optional[str] = Field(default=None)
alt: Optional[str] = Field(default=None)
class Config:
json_encoders = {
UUID: str # Convert UUID to string
}
from_attributes = True
# Define a model for a single Shopify product
class ShopifyProduct(BaseModel):
id: Optional[str] = Field(default=None)
title: Optional[str] = Field(default=None)
description: Optional[str] = Field(default=None)
handle: Optional[str] = Field(default=None)
product_type: Optional[str] = Field(default=None)
vendor: Optional[str] = Field(default=None)
total_inventory: Optional[int] = Field(default=None)
price: Optional[str] = Field(default=None)
price_max: Optional[str] = Field(default=None)
currency: Optional[str] = Field(default=None)
image: Optional[ShopifyProductImage] = Field(default=None)
tags: Optional[List[str]] = Field(default_factory=list)
created_at: Optional[str] = Field(default=None)
updated_at: Optional[str] = Field(default=None)
price_formatted: Optional[str] = Field(default=None)
variant_title: Optional[str] = Field(default=None)
class Config:
json_encoders = {
UUID: str # Convert UUID to string
}
from_attributes = True
# LLM Response Model - ONLY cache key and product IDs (ultra-minimal)
class ShopifyOutputDataLLM(BaseModel):
"""Shopify output model for LLM responses - ONLY cache key and product IDs to minimize tokens"""
product_cache_key: str = Field(description="Redis cache key - REQUIRED when Shopify tools return products")
product_ids: List[str] = Field(description="List of product IDs - REQUIRED when Shopify tools return products")
class Config:
json_encoders = {
UUID: str
}
from_attributes = True
# Socket/Frontend Response Model - WITH products field (what frontend receives)
class ShopifyOutputData(BaseModel):
"""Shopify output model for socket/frontend responses - includes full product data"""
products: Optional[List[ShopifyProduct]] = Field(default_factory=list)
search_query: Optional[str] = Field(default=None)
search_type: Optional[str] = Field(default=None)
total_count: Optional[int] = Field(default=None)
has_more: Optional[bool] = Field(default=None)
shop_domain: Optional[str] = Field(default=None, description="Shopify shop domain for constructing product URLs")
product_cache_key: Optional[str] = Field(default=None, description="Redis cache key for backend reference")
product_ids: Optional[List[str]] = Field(default=None, description="List of product IDs for backend reference")
class Config:
json_encoders = {
UUID: str # Convert UUID to string
}
from_attributes = True
def dict(self, *args, **kwargs):
# Override dict method to ensure proper serialization
d = super().dict(*args, **kwargs)
# Convert any remaining non-serializable objects to strings
return d
def json(self, *args, **kwargs):
# Override json method to ensure proper serialization
return self.dict()
class Message(BaseModel):
id: Optional[int] = None
message: Optional[str] = None
message_type: str
created_at: datetime
attributes: Optional[dict] = None
# Channel messages: the platform's id and its latest delivery receipt
external_message_id: Optional[str] = None
channel_status: Optional[str] = None
shopify_output: Optional[ShopifyOutputData] = None
end_chat: Optional[bool] = None
end_chat_reason: Optional[EndChatReasonType] = None
end_chat_description: Optional[str] = None
agent_name: Optional[str] = None
user_name: Optional[str] = None
attachments: Optional[list] = None # List of file attachments with signed URLs
@model_validator(mode='before')
@classmethod
def validate_end_chat_reason(cls, data: Dict[str, Any]) -> Dict[str, Any]:
"""Validate end_chat_reason field to ensure it's a valid enum value or None."""
if isinstance(data, dict) and 'end_chat_reason' in data and data['end_chat_reason'] is not None:
valid_reasons = [reason.value for reason in EndChatReasonType]
if data['end_chat_reason'] not in valid_reasons:
# If invalid value, set to None
data['end_chat_reason'] = None
return data
class Config:
from_attributes = True
json_encoders = {
UUID: str,
datetime: lambda v: v.isoformat()
}
class ChatOverviewResponse(BaseModel):
customer: CustomerInfo
agent: AgentInfo
last_message: Optional[str] = None
updated_at: datetime
message_count: int
status: SessionStatus
channel: str = 'web'
group_id: Optional[UUID]
# The human agent handling the chat, if one has taken it over. None means
# the AI still has it — the inbox needs this to label the row.
user_id: Optional[UUID] = None
user_name: Optional[str] = None
session_id: UUID
class ChatDetailResponse(BaseModel):
customer: CustomerInfo
agent: AgentInfo
messages: List[Message]
status: SessionStatus
channel: str = 'web'
# Which connected channel account this conversation arrived on; None for web.
channel_account_id: Optional[UUID] = None
group_id: Optional[UUID]
session_id: UUID
user_id: Optional[UUID]
user_name: Optional[str]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
json_encoders = {
UUID: str,
datetime: lambda v: v.isoformat()
}
class SourceRef(BaseModel):
"""A knowledge-base source used to ground an answer (for citation chips)."""
name: str
type: str
class ChatResponse(BaseModel):
message: str = Field(description="The response from the agent. IMPORTANT: When shopify_output is present, DO NOT include product images, URLs, prices, or product details in this field - all product info should ONLY go in the shopify_output field. Keep the message conversational.")
transfer_to_human: bool = Field(description="Whether to transfer the conversation to a human")
end_chat: bool = Field(description="Whether to end the chat and request rating")
transfer_reason: Optional[TransferReasonType] = Field(default=None, description="Transfer reason Type should be one of the following: UNABLE_TO_ANSWER, NEED_MORE_INFO, KNOWLEDGE_GAP, NEED_TO_CALL, NEED_TO_EMAIL, NEED_TO_MEET, FRUSTRATED, REPEAT_INSTRUCTIONS, DIRECT_REQUEST, HIGH_PRIORITY_ISSUE, COMPLIANCE_ISSUE")
transfer_description: Optional[str] = Field(default=None, description="Detailed description for the transfer")
end_chat_reason: Optional[EndChatReasonType] = Field(default=None, description="End chat reason Type should be one of the following: ISSUE_RESOLVED, CUSTOMER_REQUEST, CONFIRMATION_RECEIVED, FAREWELL, THANK_YOU, NATURAL_CONCLUSION, TASK_COMPLETED")
end_chat_description: Optional[str] = Field(default=None, description="Detailed description for ending the chat")
request_rating: bool = Field(description="Whether to request a rating from the customer")
# Ticket creation fields
create_ticket: bool = Field(description="Whether to create a ticket in the integrated system (Jira, Zendesk, etc.)")
ticket_summary: Optional[str] = Field(default=None, description="Summary/title for the ticket to be created")
ticket_description: Optional[str] = Field(default=None, description="Detailed description for the ticket to be created")
integration_type: Optional[str] = Field(default=None, description="Type of integration to use for ticket creation")
ticket_id: Optional[str] = Field(default=None, description="ID of the created ticket (filled after creation)")
ticket_status: Optional[str] = Field(default=None, description="Status of the created ticket (filled after creation)")
ticket_priority: Optional[str] = Field(default=None, description="Priority level for the ticket (e.g., 'High', 'Medium', 'Low')")
# Shopify Output Field - Uses LLM model (no products field)
shopify_output: Optional[ShopifyOutputDataLLM] = Field(default=None, description="Shopify product cache info. ONLY include: product_cache_key, product_ids, shop_domain, total_count. DO NOT include products array.")
# Knowledge-base citations (system-managed). The LLM must NOT populate this; it is
# filled automatically from the knowledge search tool after the response is generated.
sources: Optional[List[SourceRef]] = Field(default=None, description="System-managed knowledge citations. Do not populate — set automatically.")
# System-managed: set when a handoff/ticket happens so the widget shows the contact form
# (even when no agents are available and transfer_to_human ends up False).
request_contact: Optional[bool] = Field(default=False, description="System-managed. Do not populate — set automatically on handoff.")
# LLM-set (only when lead capture is enabled): the agent collects details
# conversationally and reports them here, mirroring how transfer_to_human works.
request_lead_capture: bool = Field(default=False, description="Set to true ONLY when you have collected the visitor's contact details (at least a valid email in lead_email) and — if consent is required — they have explicitly agreed to be contacted. This records the lead.")
# Explicit scalar fields for the standard contact details. These are used INSTEAD of a
# free-form dict because OpenAI strict structured outputs cannot emit an open-ended
# object (additionalProperties) — a Dict field always comes back empty. Named scalar
# fields populate reliably. The widget records the lead from these.
lead_email: Optional[str] = Field(default=None, description="The visitor's email address exactly as they gave it, e.g. 'jane@acme.com'. Fill this as soon as they share it.")
lead_name: Optional[str] = Field(default=None, description="The visitor's name, if they share it.")
lead_company: Optional[str] = Field(default=None, description="The visitor's company/organization, if they share it.")
lead_phone: Optional[str] = Field(default=None, description="The visitor's phone number, if they share it.")
lead_data: Optional[Dict[str, Any]] = Field(default=None, description="System-managed. Do not populate — use lead_email/lead_name/lead_company/lead_phone instead.")
lead_summary: Optional[str] = Field(default=None, description="A 1-2 sentence qualification summary of this lead from the whole conversation (e.g. '40-person fintech replacing Intercom, ~800 Shopify orders/week, wants a demo this week').")
lead_consent: bool = Field(default=False, description="Set to true only when the visitor has explicitly agreed to be contacted / share their details.")
class Config:
from_attributes = True
@model_validator(mode='before')
@classmethod
def normalize_fields(cls, data: Dict[str, Any]) -> Dict[str, Any]:
"""Normalize incoming fields and provide default values for missing fields."""
if isinstance(data, dict):
normalized = dict(data)
key_mappings = {
'requestRating': 'request_rating',
'requestrating': 'request_rating',
'transferToHuman': 'transfer_to_human',
'transfertohuman': 'transfer_to_human',
'endChat': 'end_chat',
'endchat': 'end_chat',
'createTicket': 'create_ticket',
'createticket': 'create_ticket',
'requestLeadCapture': 'request_lead_capture',
'requestleadcapture': 'request_lead_capture',
'transferReason': 'transfer_reason',
'transferreason': 'transfer_reason',
'transferDescription': 'transfer_description',
'transferdescription': 'transfer_description',
'endChatReason': 'end_chat_reason',
'endchatreason': 'end_chat_reason',
'endChatDescription': 'end_chat_description',
'endchatdescription': 'end_chat_description',
'ticketSummary': 'ticket_summary',
'ticketsummary': 'ticket_summary',
'ticketDescription': 'ticket_description',
'ticketdescription': 'ticket_description',
'integrationType': 'integration_type',
'integrationtype': 'integration_type',
'ticketId': 'ticket_id',
'ticketid': 'ticket_id',
'ticketStatus': 'ticket_status',
'ticketstatus': 'ticket_status',
'ticketpriority': 'ticket_priority',
'content': 'message',
# Map incoming shopifyOutput/shopify_output to the new field name
'shopifyOutput': 'shopify_output',
'shopifyoutput': 'shopify_output',
}
for key in list(normalized.keys()):
if key in key_mappings:
normalized[key_mappings[key]] = normalized.pop(key)
# Drop explicit nulls so each field's default applies. Models often emit
# `"transfer_to_human": null` / `"create_ticket": null` for the flags they
# aren't setting; without this, pydantic rejects null on the non-nullable
# boolean fields and the whole response is discarded (losing end_chat,
# lead_email, etc.). Optional fields default to None anyway, so dropping is safe.
normalized = {k: v for k, v in normalized.items() if v is not None}
# Set default values for missing fields
if 'message' not in normalized:
normalized['message'] = "No response generated"
if 'transfer_to_human' not in normalized:
normalized['transfer_to_human'] = False
if 'end_chat' not in normalized:
normalized['end_chat'] = False
if 'request_rating' not in normalized:
normalized['request_rating'] = False
if 'create_ticket' not in normalized:
normalized['create_ticket'] = False
# Validate enum fields
# Validate end_chat_reason
if 'end_chat_reason' in normalized and normalized['end_chat_reason'] is not None:
valid_reasons = [reason.value for reason in EndChatReasonType]
if normalized['end_chat_reason'] not in valid_reasons:
# If invalid value, set to None
normalized['end_chat_reason'] = None
# Validate transfer_reason
if 'transfer_reason' in normalized and normalized['transfer_reason'] is not None:
valid_reasons = [reason.value for reason in TransferReasonType]
if normalized['transfer_reason'] not in valid_reasons:
# If invalid value, set to None
normalized['transfer_reason'] = None
return normalized
return data
# Keys a model might wrap a choice in when it returns objects, not strings
_CHOICE_TEXT_KEYS = ("title", "text", "label")
class ChannelChatResponse(ChatResponse):
"""The reply schema for agents on a channel that can show tappable
choices — the only ones whose schema offers them, so every other agent's
output contract is unchanged."""
# A list of plain strings: strict structured outputs handle it on every
# provider, unlike a list of objects.
quick_replies: Optional[List[str]] = Field(default=None, description="Leave empty unless your instructions tell you to offer choices. The short choices the customer can tap, in order.")
@field_validator('quick_replies', mode='before')
@classmethod
def clean_quick_replies(cls, value: Any) -> Optional[List[str]]:
"""Keep usable options only, never raise: a validation error here
would turn the whole reply into the generic error message."""
if not isinstance(value, list):
return None
options: List[str] = []
seen = set()
for item in value:
text = _choice_text(item)
if text and text.casefold() not in seen:
seen.add(text.casefold())
options.append(text)
return options[:MAX_CHOICE_OPTIONS] or None
def _choice_text(item: Any) -> str:
"""One choice as clean text: a string, or the text inside an object a
model wrapped it in; anything else is dropped rather than shown raw."""
if isinstance(item, dict):
item = next((item[k] for k in _CHOICE_TEXT_KEYS if isinstance(item.get(k), str)), None)
if not isinstance(item, str):
return ""
return " ".join(item.split())[:MAX_CHOICE_LENGTH]
+57
View File
@@ -0,0 +1,57 @@
"""
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 datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, ConfigDict
class CrmConnectionOut(BaseModel):
"""A connected CRM as shown on the integrations page. Never exposes
credentials — only identity and health."""
model_config = ConfigDict(from_attributes=True)
provider: str
status: str
display_name: Optional[str] = None
external_account_id: str
last_error: Optional[str] = None
created_at: Optional[datetime] = None
recent_failures: int = 0
class CrmTestResult(BaseModel):
ok: bool
account_name: Optional[str] = None
error: Optional[str] = None
class CrmCustomerSyncOut(BaseModel):
"""One CRM link for a person, shown on the People drawer."""
model_config = ConfigDict(from_attributes=True)
provider: str
contact_id: Optional[str] = None
record_url: Optional[str] = None
synced_at: Optional[datetime] = None
class PersonCrmStatus(BaseModel):
"""CRM state for a person: which CRMs the org has connected, and where this
person is already synced."""
connected_providers: List[str]
synced: List[CrmCustomerSyncOut]
+202
View File
@@ -0,0 +1,202 @@
"""
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 datetime import datetime
from typing import List, Literal, Optional
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator
# Reuse the ORM enums so the API contract can never drift from the DB.
from app.models.faq import (
DEFAULT_FAQ_CATEGORY,
FAQ_META_DESCRIPTION_MAX_LENGTH,
FAQ_META_TITLE_MAX_LENGTH,
FAQ_SLUG_MAX_LENGTH,
FAQ_URL_PATH_MAX_LENGTH,
FAQStatus,
)
from app.models.schemas.pagination import Pagination
from app.utils.urls import normalize_url
MAX_QUESTION_LENGTH = 300
# Articles imported as-is (help-center pages with steps/images) routinely run
# long; the column is TEXT and the public renderer paginates nothing, so the
# cap only guards against runaway payloads.
MAX_ANSWER_LENGTH = 20000
MAX_BULK_IDS = 200
class SeoFields(BaseModel):
"""Optional per-article SEO overrides, shared by create and update.
Blank strings are coerced to None so clearing a field in the admin UI
restores the derived default instead of publishing an empty tag. `slug` is
normalized and de-duplicated server-side (see resolve_faq_slug) — validation
here only bounds the input, to exactly what the columns accept (values that
are too long are rejected rather than silently truncated).
"""
slug: Optional[str] = Field(default=None, max_length=FAQ_SLUG_MAX_LENGTH)
# A URL path carried over from a help center the org migrated from, so the
# article stays reachable at the URL it already ranks for. Accepts a full
# URL or a bare path; normalized and checked server-side (normalize_url_path
# / resolve_faq_url_path). Blank clears it, restoring /a/{slug}.
url_path: Optional[str] = Field(default=None, max_length=FAQ_URL_PATH_MAX_LENGTH)
meta_title: Optional[str] = Field(default=None, max_length=FAQ_META_TITLE_MAX_LENGTH)
meta_description: Optional[str] = Field(
default=None, max_length=FAQ_META_DESCRIPTION_MAX_LENGTH
)
@field_validator("slug", "url_path", "meta_title", "meta_description")
@classmethod
def _blank_to_none(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return None
return v.strip() or None
class FAQBase(BaseModel):
question: str = Field(min_length=1, max_length=MAX_QUESTION_LENGTH)
answer: str = Field(min_length=1, max_length=MAX_ANSWER_LENGTH)
category: str = Field(default=DEFAULT_FAQ_CATEGORY, min_length=1, max_length=100)
@field_validator("question", "answer", "category")
@classmethod
def _strip(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("must not be blank")
return v
class FAQCreate(FAQBase, SeoFields):
status: FAQStatus = FAQStatus.DRAFT
class FAQUpdate(SeoFields):
"""Partial update — omitted fields keep their current values (apply with
model_dump(exclude_unset=True)), so e.g. a status-only PATCH can't silently
reset the category to its default."""
question: Optional[str] = Field(default=None, min_length=1, max_length=MAX_QUESTION_LENGTH)
answer: Optional[str] = Field(default=None, min_length=1, max_length=MAX_ANSWER_LENGTH)
category: Optional[str] = Field(default=None, min_length=1, max_length=100)
status: Optional[FAQStatus] = None
@field_validator("question", "answer", "category")
@classmethod
def _strip(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return None
v = v.strip()
if not v:
raise ValueError("must not be blank")
return v
class FAQResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
question: str
answer: str
category: str
slug: Optional[str] = None
url_path: Optional[str] = None
source_url: Optional[str] = None
meta_title: Optional[str] = None
meta_description: Optional[str] = None
status: FAQStatus
knowledge_id: Optional[int] = None
source_label: Optional[str] = None
helpful_yes: int = 0
helpful_no: int = 0
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class FAQListResponse(BaseModel):
faqs: List[FAQResponse]
pagination: Pagination
class FAQBulkStatusRequest(BaseModel):
faq_ids: List[UUID] = Field(min_length=1, max_length=MAX_BULK_IDS)
status: FAQStatus
class FAQBulkDeleteRequest(BaseModel):
faq_ids: List[UUID] = Field(min_length=1, max_length=MAX_BULK_IDS)
class GenerateRequest(BaseModel):
"""Optional narrowing to specific knowledge sources; empty = new sources
only (those without FAQs yet)."""
knowledge_ids: Optional[List[int]] = Field(default=None, max_length=MAX_BULK_IDS)
class GenerationSourceResponse(BaseModel):
"""One knowledge source in the generation picker."""
id: int
name: str
source_type: str
has_faqs: bool
pages: int
estimated_calls: int
class GenerationEstimateResponse(BaseModel):
"""Confirm-dialog / source-picker numbers for a generation run.
remaining_credits is None when unlimited (OSS, no cap, or own model key)."""
total_sources: int
new_sources: int
pages: int
estimated_calls: int
metered: bool
remaining_credits: Optional[int] = None
sources: List[GenerationSourceResponse] = []
class ImportRequest(BaseModel):
url: str = Field(min_length=1, max_length=2048)
# qa = single page, LLM extracts Q&A pairs (uses credits).
# articles = crawl the page's linked articles and import each as-is
# (HTML -> Markdown, images re-hosted, no LLM).
mode: Literal["qa", "articles"] = "qa"
# articles mode only: serve each imported article at the URL path it already
# has on the source help center, so the org's search rankings and inbound
# links survive the migration. Ignored for qa.
preserve_urls: bool = False
@field_validator("url")
@classmethod
def _https_url(cls, v: str) -> str:
# Scheme-level validation only; the import worker re-checks the URL
# against the SSRF guards in app.knowledge.url_safety before fetching.
return normalize_url(v, require_https=True)
class GenerationJobResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
job_type: str
status: str
stage: str
progress_percentage: float
faqs_created: int
source_url: Optional[str] = None
error: Optional[str] = None
created_at: Optional[datetime] = None
+151
View File
@@ -0,0 +1,151 @@
"""
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.
"""
import re
from datetime import datetime
from typing import List, Optional
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.models.help_center import DomainStatus, SSLStatus
from app.utils.urls import normalize_url
MAX_HEADER_LINKS = 6
_HEX_COLOR_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$")
# RFC 1035-ish hostname: labels of letters/digits/hyphens, at least one dot.
# The TLD alternation admits punycode labels (xn--...) that IDNA encoding of
# internationalized domains produces.
_HOSTNAME_RE = re.compile(
r"^(?=.{4,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,}|xn--[a-z0-9-]{2,})$"
)
class HeaderLink(BaseModel):
label: str = Field(min_length=1, max_length=40)
url: str = Field(min_length=1, max_length=2048)
@field_validator("url")
@classmethod
def _url(cls, v: str) -> str:
# Raises on blank/whitespace input rather than returning None into a
# non-optional str field.
return normalize_url(v)
class HelpCenterSettingsUpdate(BaseModel):
enabled: Optional[bool] = None
title: Optional[str] = Field(default=None, max_length=120)
description: Optional[str] = Field(default=None, max_length=300)
brand_color: Optional[str] = None
header_links: Optional[List[HeaderLink]] = Field(default=None, max_length=MAX_HEADER_LINKS)
cta_text: Optional[str] = Field(default=None, max_length=40)
cta_url: Optional[str] = Field(default=None, max_length=2048)
cta_enabled: Optional[bool] = None
auto_generate: Optional[bool] = None
agent_id: Optional[UUID] = None
ai_search_enabled: Optional[bool] = None
chat_widget_enabled: Optional[bool] = None
@field_validator("brand_color")
@classmethod
def _hex_color(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return None
v = v.strip()
if not _HEX_COLOR_RE.match(v):
raise ValueError("brand_color must be a hex color like #4338CA")
return v
@field_validator("cta_url")
@classmethod
def _cta_url(cls, v: Optional[str]) -> Optional[str]:
# Blank clears the CTA link; anything else is normalized.
if v is None or not v.strip():
return None
return normalize_url(v)
class HelpCenterAgentOption(BaseModel):
"""Org agents offered in the AI-search agent selector."""
id: UUID
name: str
has_widget: bool
class DnsRecord(BaseModel):
"""One row of the DNS-records table in the admin UI."""
type: str # CNAME | TXT
host: str
value: str
verified: bool
class DomainStatusResponse(BaseModel):
custom_domain: Optional[str] = None
domain_status: DomainStatus
ssl_status: SSLStatus
records: List[DnsRecord] = []
domain_verified_at: Optional[datetime] = None
class HelpCenterSettingsResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
enabled: bool
slug: Optional[str] = None
title: Optional[str] = None
description: Optional[str] = None
logo_url: Optional[str] = None
favicon_url: Optional[str] = None
brand_color: str
header_links: List[HeaderLink] = []
cta_text: Optional[str] = None
cta_url: Optional[str] = None
cta_enabled: bool
auto_generate: bool
agent_id: Optional[UUID] = None
ai_search_enabled: bool
chat_widget_enabled: bool
# Enriched by the API layer (not ORM columns):
live_url: Optional[str] = None
published_count: int = 0
plan_allowed: bool = True
agents: List[HelpCenterAgentOption] = []
domain: Optional[DomainStatusResponse] = None
@field_validator("header_links", mode="before")
@classmethod
def _coerce_null_json(cls, v):
return v or []
class DomainRequest(BaseModel):
domain: str = Field(min_length=4, max_length=255)
@field_validator("domain")
@classmethod
def _hostname(cls, v: str) -> str:
v = v.strip().lower().rstrip(".")
v = re.sub(r"^https?://", "", v).split("/")[0]
try:
v = v.encode("idna").decode("ascii")
except UnicodeError as exc:
raise ValueError("domain is not a valid hostname") from exc
if not _HOSTNAME_RE.match(v):
raise ValueError("domain is not a valid hostname")
return v
+154
View File
@@ -0,0 +1,154 @@
"""
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 typing import List, Optional
from pydantic import BaseModel, Field
class TriageResult(BaseModel):
"""Structured output of the AI triage pass over a new ticket."""
# Rewritten, clearer title. The original is preserved on the ticket.
title: str = Field(max_length=500)
priority: str = Field(description="urgent, high, medium or low")
severity: Optional[int] = Field(default=None, ge=1, le=3)
# Short intent classification, e.g. "billing_issue", "bug_report",
# "feature_request", "account_access", "outage".
intent: str = Field(max_length=100)
# 1-3 sentence internal summary of the issue.
summary: str = Field(max_length=2000)
# ["payments", "ledger"] — up to two short lowercase topic tags.
tags: List[str] = Field(default_factory=list, max_length=3)
confidence: float = Field(ge=0.0, le=1.0)
class HypothesisSpec(BaseModel):
"""One proposed root-cause hypothesis, before testing."""
title: str = Field(max_length=300)
rationale: str = Field(max_length=2000)
class HypothesisPlan(BaseModel):
"""Output of the hypothesis-generation step (H1..Hn, most likely first)."""
hypotheses: List[HypothesisSpec] = Field(min_length=1, max_length=5)
class HypothesisVerdict(BaseModel):
"""Outcome of testing a single hypothesis with bounded tool calls."""
status: str = Field(description="validated, invalidated or inconclusive")
confidence: float = Field(ge=0.0, le=1.0)
# What the evidence showed, citing the queries/results that decided it.
conclusion: str = Field(max_length=4000)
class RCATimelineEntry(BaseModel):
time: str = Field(max_length=100)
event: str = Field(max_length=500)
class RCAResult(BaseModel):
"""Structured root-cause analysis synthesized from the tested hypotheses
and captured evidence."""
summary: str = Field(max_length=4000)
impact: Optional[str] = Field(default=None, max_length=4000)
timeline: List[RCATimelineEntry] = Field(default_factory=list, max_length=20)
investigation_log: Optional[str] = Field(default=None, max_length=6000)
contributing_factors: List[str] = Field(default_factory=list, max_length=10)
# Must cite hypotheses inline, e.g. "[H1 · 0.92]".
conclusion: str = Field(max_length=6000)
remediation: Optional[str] = Field(default=None, max_length=4000)
prevention: Optional[str] = Field(default=None, max_length=4000)
# Plain-language section a human reviews and sends to the customer.
customer_summary: str = Field(max_length=4000)
confidence: float = Field(ge=0.0, le=1.0)
TRIAGE_JSON_TOOL_SCHEMA = {
"type": "object",
"properties": {
"title": {"type": "string", "description": "Clear, specific rewritten title."},
"priority": {"type": "string", "enum": ["urgent", "high", "medium", "low"]},
"severity": {"type": "integer", "minimum": 1, "maximum": 3},
"intent": {"type": "string", "description": "Short intent slug, e.g. bug_report."},
"summary": {"type": "string", "description": "1-3 sentence internal summary."},
"tags": {"type": "array", "items": {"type": "string"}, "description": "Up to two short lowercase topic tags."},
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
},
"required": ["title", "priority", "intent", "summary", "confidence"],
"additionalProperties": False,
}
HYPOTHESES_JSON_TOOL_SCHEMA = {
"type": "object",
"properties": {
"hypotheses": {
"type": "array",
"minItems": 1,
"maxItems": 5,
"items": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "Short testable hypothesis."},
"rationale": {"type": "string", "description": "Why this is suspected and what evidence would confirm it."},
},
"required": ["title", "rationale"],
"additionalProperties": False,
},
},
},
"required": ["hypotheses"],
"additionalProperties": False,
}
VERDICT_JSON_TOOL_SCHEMA = {
"type": "object",
"properties": {
"status": {"type": "string", "enum": ["validated", "invalidated", "inconclusive"]},
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
"conclusion": {"type": "string", "description": "What the evidence showed, citing queries/results."},
},
"required": ["status", "confidence", "conclusion"],
"additionalProperties": False,
}
RCA_JSON_TOOL_SCHEMA = {
"type": "object",
"properties": {
"summary": {"type": "string"},
"impact": {"type": "string"},
"timeline": {
"type": "array",
"items": {
"type": "object",
"properties": {"time": {"type": "string"}, "event": {"type": "string"}},
"required": ["time", "event"],
"additionalProperties": False,
},
},
"investigation_log": {"type": "string"},
"contributing_factors": {"type": "array", "items": {"type": "string"}},
"conclusion": {"type": "string", "description": "Cites hypotheses inline, e.g. [H1 · 0.92]."},
"remediation": {"type": "string"},
"prevention": {"type": "string"},
"customer_summary": {"type": "string", "description": "Plain language for the customer."},
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
},
"required": ["summary", "conclusion", "customer_summary", "confidence"],
"additionalProperties": False,
}
+32
View File
@@ -0,0 +1,32 @@
"""
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 pydantic import BaseModel
from typing import Optional, List, Any
from app.models.schemas.agent import AgentResponse
class AgentWithJiraConfig(AgentResponse):
"""Model for agent data with Jira configuration."""
tools: Optional[List[Any]] = None
is_default: bool = False
jira_enabled: bool = False
jira_project_key: Optional[str] = None
jira_issue_type_id: Optional[str] = None
groups: List[Any] = []
organization: Optional[Any] = None
class Config:
arbitrary_types_allowed = True
@@ -0,0 +1,69 @@
"""
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 pydantic import BaseModel, ConfigDict, Field, field_validator
from typing import Optional, List, Dict, Any
from uuid import UUID
# Reuse the ORM enums so the API contract can never drift from the DB.
from app.models.lead_capture import LeadAssignmentMode, CrmSyncTarget
# Guardrails to keep the config small.
MAX_FIELDS = 30
class LeadField(BaseModel):
"""A detail to collect. Standard fields (email/name/company/phone) carry
standard=True; custom fields add a label and optional allowed values — which
the agent uses to steer/normalise the answer in conversation (no form)."""
key: str
standard: bool = False
enabled: bool = True
required: bool = False
label: Optional[str] = Field(default=None, max_length=100)
options: Optional[List[str]] = None
class LeadCaptureConfigBase(BaseModel):
enabled: bool = False
# GDPR: require the visitor's explicit consent before recording a lead.
require_consent: bool = True
# Optional free-text steering, appended to the agent's prompt.
guidance: Optional[str] = Field(default=None, max_length=2000)
fields: List[LeadField] = Field(default_factory=list, max_length=MAX_FIELDS)
# Routing config — persisted only; no CRM/Slack/assignment side effects in phase 1.
assignment_mode: LeadAssignmentMode = LeadAssignmentMode.NONE
assignment_target_user_id: Optional[UUID] = None
crm_sync_target: CrmSyncTarget = CrmSyncTarget.NONE
slack_notify_enabled: bool = False
class LeadCaptureConfigUpdate(LeadCaptureConfigBase):
"""Full-replace payload for the whole Lead Capture tab (mirrors how the
customization tab saves its entire object in one call)."""
pass
class LeadCaptureConfigResponse(LeadCaptureConfigBase):
model_config = ConfigDict(from_attributes=True)
agent_id: UUID
@field_validator("fields", mode="before")
@classmethod
def _coerce_null_json(cls, v):
# JSON column defaults to NULL in the DB; present it as an empty list.
return v or []
+202
View File
@@ -0,0 +1,202 @@
"""
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 datetime import datetime
from pydantic import BaseModel, Field, field_serializer, validator
from typing import List, Optional, Dict, Union
from enum import Enum
from uuid import UUID
from app.models.mcp_tool import MCPTransportType
class MCPTransportTypeEnum(str, Enum):
STDIO = "stdio"
SSE = "sse"
HTTP = "http"
# Stand-in sent to the client in place of stored environment/header values.
# An update that sends it back means "keep what is stored", so correcting an
# unrelated field never costs a credential rotation — providers typically show
# an API key exactly once.
SECRET_MASK = "********"
# Ceiling on a connector's usage guidance. Enforced here so an over-long value
# is a clean 422, and again at injection so a row written before this existed
# can never blow the prompt.
MAX_USAGE_GUIDANCE_CHARS = 2000
def mask_secret_values(values: Optional[Dict[str, str]]) -> Optional[Dict[str, str]]:
"""Replace every value with the mask, keeping the keys so the operator can
still see which variables are set."""
if not values:
return values
return {key: SECRET_MASK for key in values}
def unmask_secret_values(
incoming: Optional[Dict[str, str]], stored: Optional[Dict[str, str]]
) -> Optional[Dict[str, str]]:
"""Resolve an incoming secret map against what is stored: a masked value
keeps the stored one, anything else is a deliberate replacement. A masked
key with nothing stored behind it is dropped rather than written empty."""
if not incoming:
return incoming
stored = stored or {}
resolved = {}
for key, value in incoming.items():
if value != SECRET_MASK:
resolved[key] = value
elif key in stored:
resolved[key] = stored[key]
return resolved
class MCPToolBase(BaseModel):
name: str = Field(..., min_length=1, max_length=255, description="Display name for the MCP server")
description: Optional[str] = Field(None, description="Optional description of what this MCP server does")
usage_guidance: Optional[str] = Field(
None, max_length=MAX_USAGE_GUIDANCE_CHARS,
description="What this source holds and how to query it — given to the AI as tool guidance",
)
transport_type: MCPTransportTypeEnum
enabled: bool = Field(default=True, description="Whether this MCP tool is enabled")
# STDIO transport fields
command: Optional[str] = Field(None, description="Command to run the MCP server (e.g., 'npx', 'uvx', 'node')")
args: Optional[List[str]] = Field(None, description="Arguments to pass to the MCP server")
env_vars: Optional[Dict[str, str]] = Field(None, description="Environment variables as key-value pairs")
# SSE/HTTP transport fields
url: Optional[str] = Field(None, description="Server URL for SSE/HTTP transport")
headers: Optional[Dict[str, str]] = Field(None, description="HTTP headers as key-value pairs")
timeout: Optional[int] = Field(None, ge=1, le=300, description="Connection timeout in seconds (1-300)")
sse_read_timeout: Optional[int] = Field(None, ge=1, le=600, description="SSE read timeout in seconds (1-600)")
terminate_on_close: Optional[bool] = Field(default=True, description="Whether to terminate connection when client is closed (HTTP only)")
@validator('command')
def validate_stdio_command(cls, v, values):
if values.get('transport_type') == MCPTransportTypeEnum.STDIO and not v:
raise ValueError('Command is required for STDIO transport')
return v
@validator('url')
def validate_url(cls, v, values):
if values.get('transport_type') in [MCPTransportTypeEnum.SSE, MCPTransportTypeEnum.HTTP] and not v:
raise ValueError('URL is required for SSE/HTTP transport')
return v
class MCPToolCreate(MCPToolBase):
organization_id: Optional[UUID] = None
class MCPToolUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
usage_guidance: Optional[str] = Field(
None, max_length=MAX_USAGE_GUIDANCE_CHARS,
description="What this source holds and how to query it — given to the AI as tool guidance",
)
transport_type: Optional[MCPTransportTypeEnum] = None
enabled: Optional[bool] = None
# STDIO transport fields
command: Optional[str] = None
args: Optional[List[str]] = None
env_vars: Optional[Dict[str, str]] = None
# SSE/HTTP transport fields
url: Optional[str] = None
headers: Optional[Dict[str, str]] = None
timeout: Optional[int] = Field(None, ge=1, le=300)
sse_read_timeout: Optional[int] = Field(None, ge=1, le=600)
terminate_on_close: Optional[bool] = None
class MCPToolResponse(BaseModel):
id: int
name: str
description: Optional[str]
# Deliberately not masked — guidance is prompt text, not a credential.
usage_guidance: Optional[str]
transport_type: MCPTransportTypeEnum
enabled: bool
# STDIO transport fields
command: Optional[str]
args: Optional[List[str]]
env_vars: Optional[Dict[str, str]]
# SSE/HTTP transport fields
url: Optional[str]
headers: Optional[Dict[str, str]]
timeout: Optional[int]
sse_read_timeout: Optional[int]
terminate_on_close: Optional[bool]
organization_id: UUID
created_at: datetime
updated_at: datetime
@field_serializer("env_vars", "headers")
def _mask_secrets(self, values: Optional[Dict[str, str]], _info):
"""Credentials never leave the server. The edit form round-trips the
mask, and the update endpoint resolves it back to the stored value."""
return mask_secret_values(values)
class Config:
from_attributes = True
class MCPToolReferencesResponse(BaseModel):
"""Where a connector is in use, so a delete confirmation can name what it
is about to break instead of asking blind."""
agents: List[str] = []
used_in_investigations: bool = False
class MCPToolToAgentCreate(BaseModel):
mcp_tool_id: int
agent_id: UUID
class MCPToolToAgentResponse(BaseModel):
id: int
mcp_tool_id: int
agent_id: UUID
created_at: datetime
mcp_tool: MCPToolResponse
class Config:
from_attributes = True
class AgentMCPToolsResponse(BaseModel):
id: UUID
name: str
mcp_tools: List[MCPToolResponse] = []
class Config:
from_attributes = True
class MCPToolTestResponse(BaseModel):
"""Result of a one-off connection test against a configured tool."""
success: bool
functions: List[str] = []
error: Optional[str] = None
@@ -0,0 +1,51 @@
"""
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 pydantic import BaseModel, ConfigDict
from datetime import datetime
from typing import Optional, Dict
from app.models.notification import NotificationType
from uuid import UUID
class NotificationResponse(BaseModel):
id: int
user_id: UUID
type: NotificationType
title: str
message: str
notification_metadata: Optional[Dict] = None
is_read: bool
created_at: datetime
class Config:
from_attributes = True
class NotificationSettingsOut(BaseModel):
"""A user's chat push preferences."""
notify_new_chat: bool
notify_chat_transfer: bool
notify_chat_assigned: bool
model_config = ConfigDict(from_attributes=True)
class NotificationSettingsUpdate(BaseModel):
"""Partial update — only the toggles the caller sent are applied."""
notify_new_chat: Optional[bool] = None
notify_chat_transfer: Optional[bool] = None
notify_chat_assigned: Optional[bool] = None
@@ -0,0 +1,88 @@
"""
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 pydantic import BaseModel, EmailStr
from typing import Optional, Dict, TypedDict
from uuid import UUID
from app.models.schemas.user import UserResponse
class BusinessHours(TypedDict):
start: str
end: str
enabled: bool
class BusinessHoursDict(TypedDict):
monday: BusinessHours
tuesday: BusinessHours
wednesday: BusinessHours
thursday: BusinessHours
friday: BusinessHours
saturday: BusinessHours
sunday: BusinessHours
class OrganizationBase(BaseModel):
name: str
domain: str
timezone: Optional[str] = 'UTC'
business_hours: Optional[BusinessHoursDict] = {
'monday': {'start': '09:00', 'end': '17:00', 'enabled': True},
'tuesday': {'start': '09:00', 'end': '17:00', 'enabled': True},
'wednesday': {'start': '09:00', 'end': '17:00', 'enabled': True},
'thursday': {'start': '09:00', 'end': '17:00', 'enabled': True},
'friday': {'start': '09:00', 'end': '17:00', 'enabled': True},
'saturday': {'start': '09:00', 'end': '17:00', 'enabled': False},
'sunday': {'start': '09:00', 'end': '17:00', 'enabled': False}
}
settings: Optional[Dict] = {}
class OrganizationCreate(OrganizationBase):
admin_email: EmailStr
admin_name: str
admin_password: str
class OrganizationUpdate(BaseModel):
name: Optional[str] = None
domain: Optional[str] = None
timezone: Optional[str] = None
business_hours: Optional[BusinessHoursDict] = None
settings: Optional[Dict] = None
class OrganizationCreateResponse(OrganizationBase):
id: UUID
is_active: bool
access_token: Optional[str] = None
refresh_token: Optional[str] = None
token_type: Optional[str] = None
user: UserResponse
class Config:
from_attributes = True
class OrganizationResponse(OrganizationBase):
id: UUID
is_active: bool
access_token: Optional[str] = None
refresh_token: Optional[str] = None
token_type: Optional[str] = None
+37
View File
@@ -0,0 +1,37 @@
"""
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.
"""
import math
from pydantic import BaseModel
class Pagination(BaseModel):
"""Standard list-endpoint pagination block (shape matches the knowledge
API's inline dicts: total / page / page_size / total_pages)."""
total: int
page: int
page_size: int
total_pages: int
@classmethod
def build(cls, total: int, page: int, page_size: int) -> "Pagination":
return cls(
total=total,
page=page,
page_size=page_size,
total_pages=max(1, math.ceil(total / page_size)) if total else 0,
)
+95
View File
@@ -0,0 +1,95 @@
"""
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 pydantic import BaseModel
from typing import Optional, List, Dict, Any
from uuid import UUID
from datetime import datetime
from app.models.customer import LeadStage
class PersonListItem(BaseModel):
id: UUID
name: Optional[str] = None
email: Optional[str] = None
phone: Optional[str] = None
is_anonymous: bool = False
lead_stage: LeadStage
qualified: bool = False
source: Optional[Dict[str, Any]] = None
captured_at: Optional[datetime] = None
last_activity: Optional[datetime] = None
synced: bool = False # phase 1: CRM sync not implemented
class PeopleListResponse(BaseModel):
items: List[PersonListItem]
total: int
page: int
page_size: int
class PeopleStats(BaseModel):
total_people: int
# Engaged-but-unidentified browser sessions — the lead-capture funnel's
# raw top, shown as a count beside the tabs, not as directory rows.
anonymous: int = 0
new_leads_7d: int
customers: int
synced_to_crm: int = 0 # phase 1: always 0
class TimelineEntry(BaseModel):
stage: str
at: datetime
class PersonConversation(BaseModel):
session_id: UUID
agent_name: Optional[str] = None
status: Optional[str] = None
last_message: Optional[str] = None
created_at: Optional[datetime] = None
class PersonDetail(BaseModel):
id: UUID
name: Optional[str] = None
email: Optional[str] = None
phone: Optional[str] = None
# True when something can reach/recognize this person (real email, phone,
# or a qualifying capture). Gates Mark-as-customer and outbound actions.
identified: bool = False
is_anonymous: bool = False
lead_stage: LeadStage
qualified: bool = False
source: Optional[Dict[str, Any]] = None
created_at: Optional[datetime] = None
lead_qualified_at: Optional[datetime] = None
meta_data: Optional[Dict[str, Any]] = None
# AI-written qualification summary from the conversation.
summary: Optional[str] = None
# Contact/detail fields captured across submissions.
captured_attributes: Dict[str, Any] = {}
timeline: List[TimelineEntry] = []
conversations: List[PersonConversation] = []
class PersonUpdateRequest(BaseModel):
"""Drawer edit: both fields optional; phone "" clears the number."""
full_name: Optional[str] = None
phone: Optional[str] = None
+59
View File
@@ -0,0 +1,59 @@
"""
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 uuid import UUID
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
class PermissionResponse(BaseModel):
id: int
name: str
description: Optional[str]
class Config:
from_attributes = True
class RoleBase(BaseModel):
name: str
description: Optional[str] = None
is_default: Optional[bool] = False
class RoleCreate(RoleBase):
permissions: List["PermissionResponse"]
class RoleUpdate(RoleBase):
permissions: Optional[List["PermissionResponse"]] = None
class SimpleRoleResponse(BaseModel):
id: int
name: str
class Config:
from_attributes = True
class RoleResponse(RoleBase):
id: int
organization_id: Optional[UUID] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
permissions: Optional[List["PermissionResponse"]] = None
class Config:
from_attributes = True
RoleResponse.update_forward_refs()
@@ -0,0 +1,29 @@
"""
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 app.models.schemas.shopify.shopify_shop import (
ShopifyShopBase,
ShopifyShopCreate,
ShopifyShopUpdate,
ShopifyShopInDB,
ShopifyShop,
)
from .agent_shopify_config import (
AgentShopifyConfigBase,
AgentShopifyConfigCreate,
AgentShopifyConfigUpdate,
AgentShopifyConfig
)
@@ -0,0 +1,60 @@
"""
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 pydantic import BaseModel
from typing import Optional
from datetime import datetime
class AgentShopifyConfigBase(BaseModel):
"""Base Shopify agent configuration schema."""
enabled: bool
shop_id: Optional[str] = None
class AgentShopifyConfigCreate(AgentShopifyConfigBase):
"""Schema for creating a new Shopify agent configuration."""
agent_id: str
class AgentShopifyConfigUpdate(AgentShopifyConfigBase):
"""Schema for updating a Shopify agent configuration."""
pass
class ShopifyShopInfo(BaseModel):
"""Basic info about a Shopify shop."""
id: str
shop_domain: str
class Config:
orm_mode = True
class AgentShopifyConfigInDB(AgentShopifyConfigBase):
"""Schema for Shopify agent configuration stored in the database."""
id: str
agent_id: str
created_at: datetime
updated_at: datetime
class Config:
orm_mode = True
class AgentShopifyConfig(AgentShopifyConfigInDB):
"""Schema for API response."""
shop: Optional[ShopifyShopInfo] = None
@@ -0,0 +1,62 @@
"""
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 pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
from uuid import UUID
class ShopifyShopBase(BaseModel):
"""Base schema for ShopifyShop"""
shop_domain: str = Field(..., description="Shopify shop domain")
organization_id: Optional[str] = Field(None, description="ID of the organization this shop belongs to")
class ShopifyShopCreate(ShopifyShopBase):
"""Schema for creating a shopify shop"""
access_token: Optional[str] = Field(None, description="Shopify access token")
scope: Optional[str] = Field(None, description="Shopify OAuth scopes")
is_installed: bool = Field(False, description="Whether the app is installed on this shop")
oauth_state: Optional[str] = Field(None, description="CSRF protection state parameter")
oauth_state_expiry: Optional[datetime] = Field(None, description="CSRF protection state parameter expiry")
class ShopifyShopUpdate(BaseModel):
"""Schema for updating a shopify shop"""
access_token: Optional[str] = None
scope: Optional[str] = None
is_installed: Optional[bool] = None
organization_id: Optional[str] = None
oauth_state: Optional[str] = None
oauth_state_expiry: Optional[datetime] = None
class ShopifyShopInDB(ShopifyShopBase):
"""Schema for shopify shop from database"""
id: str
scope: Optional[str] = None
is_installed: bool
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# Add JSON encoders to convert UUID to string
json_encoders = {
UUID: str # Convert UUID to string
}
class ShopifyShop(ShopifyShopInDB):
"""Schema for shopify shop response"""
pass
+390
View File
@@ -0,0 +1,390 @@
"""
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 datetime import datetime
from typing import List, Optional
from uuid import UUID
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
# Reuse the ORM enums so the API contract can never drift from the DB.
from app.models.investigation import InvestigationRunStatus, InvestigationRunType
from app.models.ticket import ResolutionOutcome, TicketPriority, TicketSource, TicketStatus
from app.models.ticket_activity import TicketActivityType, TicketActorType
from app.models.schemas.pagination import Pagination
MAX_TITLE_LENGTH = 500
MAX_DESCRIPTION_LENGTH = 20000
MAX_COMMENT_LENGTH = 10000
MAX_TAGS = 5
class TicketCreate(BaseModel):
title: str = Field(min_length=1, max_length=MAX_TITLE_LENGTH)
description: Optional[str] = Field(default=None, max_length=MAX_DESCRIPTION_LENGTH)
priority: TicketPriority = TicketPriority.MEDIUM
severity: Optional[int] = Field(default=None, ge=1, le=3)
tags: Optional[List[str]] = Field(default=None, max_length=MAX_TAGS)
customer_id: Optional[UUID] = None
# Manual tickets: resolve/create the customer by email so direct-email
# notifications have somewhere to go. Ignored when customer_id is set.
customer_email: Optional[EmailStr] = None
customer_name: Optional[str] = Field(default=None, max_length=200)
session_id: Optional[UUID] = None
assignee_user_id: Optional[UUID] = None
group_id: Optional[UUID] = None
@field_validator("title")
@classmethod
def _strip_title(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("must not be blank")
return v
class TicketUpdate(BaseModel):
"""Partial update — omitted fields keep their current values (apply with
model_dump(exclude_unset=True))."""
title: Optional[str] = Field(default=None, min_length=1, max_length=MAX_TITLE_LENGTH)
description: Optional[str] = Field(default=None, max_length=MAX_DESCRIPTION_LENGTH)
# Set/replace the ticket's customer by email (find-or-create in the org).
customer_email: Optional[EmailStr] = None
customer_name: Optional[str] = Field(default=None, max_length=200)
status: Optional[TicketStatus] = None
priority: Optional[TicketPriority] = None
severity: Optional[int] = Field(default=None, ge=1, le=3)
tags: Optional[List[str]] = Field(default=None, max_length=MAX_TAGS)
assignee_user_id: Optional[UUID] = None
group_id: Optional[UUID] = None
resolution_outcome: Optional[ResolutionOutcome] = None
resolution_summary: Optional[str] = Field(default=None, max_length=MAX_DESCRIPTION_LENGTH)
customer_resolution_message: Optional[str] = Field(default=None, max_length=MAX_DESCRIPTION_LENGTH)
class TicketCommentCreate(BaseModel):
body: str = Field(min_length=1, max_length=MAX_COMMENT_LENGTH)
is_internal: bool = True
@field_validator("body")
@classmethod
def _strip_body(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("must not be blank")
return v
class TicketReopen(BaseModel):
reason: Optional[str] = Field(default=None, max_length=MAX_COMMENT_LENGTH)
class TicketResolve(BaseModel):
outcome: ResolutionOutcome = ResolutionOutcome.FIXED
resolution_summary: Optional[str] = Field(default=None, max_length=MAX_DESCRIPTION_LENGTH)
customer_message: Optional[str] = Field(default=None, max_length=MAX_DESCRIPTION_LENGTH)
class TicketUserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
full_name: Optional[str] = None
email: Optional[str] = None
class TicketCustomerOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
email: Optional[str] = None
full_name: Optional[str] = None
class TicketActivityOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
activity_type: TicketActivityType
actor_type: TicketActorType
actor_user_id: Optional[UUID] = None
actor_name: Optional[str] = None
body: Optional[str] = None
is_internal: bool = True
activity_metadata: Optional[dict] = None
created_at: Optional[datetime] = None
class InvestigationRunOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
run_type: InvestigationRunType
status: InvestigationRunStatus
trigger: str
error: Optional[str] = None
tool_calls_used: int = 0
max_tool_calls: int = 25
llm_calls: int = 0
input_tokens: int = 0
output_tokens: int = 0
metered: bool = False
model_name: Optional[str] = None
# {"configured": N, "loaded": M, "failed": [{"name", "error"}]}
connector_status: Optional[dict] = None
started_at: Optional[datetime] = None
finished_at: Optional[datetime] = None
created_at: Optional[datetime] = None
class HypothesisOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
idx: int
title: str
rationale: Optional[str] = None
status: str
confidence: Optional[float] = None
conclusion: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class InvestigationEventOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
hypothesis_id: Optional[UUID] = None
seq: int
event_type: str
label: Optional[str] = None
tool_name: Optional[str] = None
connector_name: Optional[str] = None
tool_input: Optional[str] = None
tool_result: Optional[str] = None
duration_ms: Optional[int] = None
error: Optional[str] = None
created_at: Optional[datetime] = None
class RCADocumentOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
run_id: Optional[UUID] = None
version: int
summary: Optional[str] = None
impact: Optional[str] = None
timeline: Optional[list] = None
investigation_log: Optional[str] = None
contributing_factors: Optional[list] = None
conclusion: Optional[str] = None
remediation: Optional[str] = None
prevention: Optional[str] = None
customer_summary: Optional[str] = None
confidence: Optional[float] = None
is_partial: bool = False
generated_by: str = "ai"
reviewed_by_user_id: Optional[UUID] = None
reviewed_by_name: Optional[str] = None
reviewed_at: Optional[datetime] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class ProposalOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
run_id: Optional[UUID] = None
summary: str
customer_message: Optional[str] = None
confidence: Optional[float] = None
status: str
decided_by_user_id: Optional[UUID] = None
decided_by_name: Optional[str] = None
decided_at: Optional[datetime] = None
reject_reason: Optional[str] = None
created_at: Optional[datetime] = None
class ProposalRejectRequest(BaseModel):
reason: Optional[str] = Field(default=None, max_length=MAX_COMMENT_LENGTH)
# Re-enqueue a refined investigation with the reason as context.
reinvestigate: bool = False
class InvestigationDetailResponse(BaseModel):
"""Glass-box payload for the ticket detail page: the (latest or requested)
investigation run with its hypotheses, evidence events and RCA document."""
run: Optional[InvestigationRunOut] = None
hypotheses: List[HypothesisOut] = []
events: List[InvestigationEventOut] = []
rca: Optional[RCADocumentOut] = None
# Latest proposal (any status) — drives the approval banner.
proposal: Optional[ProposalOut] = None
class RcaUpdate(BaseModel):
customer_summary: Optional[str] = Field(default=None, max_length=MAX_DESCRIPTION_LENGTH)
# Stamp the caller as reviewer ("generated by AI · reviewed by …").
mark_reviewed: bool = False
class InvestigateRequest(BaseModel):
run_type: InvestigationRunType = InvestigationRunType.INVESTIGATION
# Free-text context for the run, e.g. what to focus on this time.
context_note: Optional[str] = Field(default=None, max_length=4000)
class TicketOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
ticket_number: int
display_number: str
organization_id: UUID
customer_id: Optional[UUID] = None
title: str
original_title: Optional[str] = None
description: Optional[str] = None
status: TicketStatus
priority: TicketPriority
severity: Optional[int] = None
source: TicketSource
intent: Optional[str] = None
triage_confidence: Optional[float] = None
ai_summary: Optional[str] = None
tags: Optional[List[str]] = None
assignee_user_id: Optional[UUID] = None
group_id: Optional[UUID] = None
agent_id: Optional[UUID] = None
duplicate_of_ticket_id: Optional[UUID] = None
resolution_outcome: Optional[ResolutionOutcome] = None
resolution_summary: Optional[str] = None
customer_resolution_message: Optional[str] = None
first_response_at: Optional[datetime] = None
resolved_at: Optional[datetime] = None
closed_at: Optional[datetime] = None
confirmation_requested_at: Optional[datetime] = None
reopened_count: int = 0
csat_requested_at: Optional[datetime] = None
csat_score: Optional[int] = None
csat_responded_at: Optional[datetime] = None
external_ref_type: Optional[str] = None
external_ref_id: Optional[str] = None
external_ref_url: Optional[str] = None
created_by_user_id: Optional[UUID] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
# Derived UI fields.
ai_state: Optional[str] = None
sla_due_at: Optional[datetime] = None
assignee: Optional[TicketUserOut] = None
customer: Optional[TicketCustomerOut] = None
class TicketListItem(BaseModel):
"""Slim row for the list view."""
model_config = ConfigDict(from_attributes=True)
id: UUID
ticket_number: int
display_number: str
title: str
status: TicketStatus
priority: TicketPriority
tags: Optional[List[str]] = None
assignee_user_id: Optional[UUID] = None
assignee_name: Optional[str] = None
ai_state: Optional[str] = None
sla_due_at: Optional[datetime] = None
resolved_at: Optional[datetime] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class TicketListResponse(BaseModel):
tickets: List[TicketListItem]
pagination: Pagination
class TicketDetailResponse(BaseModel):
ticket: TicketOut
activities: List[TicketActivityOut]
runs: List[InvestigationRunOut]
linked_session_ids: List[UUID]
possible_duplicates: List[TicketListItem] = []
# Whether any outbound path to the customer exists (linked conversation or
# customer email) — gates the "send to customer" affordances in the UI.
can_notify_customer: bool = False
class TicketStats(BaseModel):
open: int
awaiting_approval: int
sla_breaching: int
ai_resolved_pct_7d: Optional[float] = None
# CSAT over the trailing csat_window_days, overall and split by resolver.
csat_avg: Optional[float] = None
csat_ai_avg: Optional[float] = None
csat_human_avg: Optional[float] = None
csat_responses: int = 0
csat_window_days: int = 30
class SlaTarget(BaseModel):
first_response_minutes: int = Field(gt=0, le=60 * 24 * 30)
resolution_minutes: int = Field(gt=0, le=60 * 24 * 90)
class TicketSettingsOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
autonomy_level: int
auto_investigate_on_create: bool
min_confidence_to_auto_resolve: float
confirmation_timeout_hours: int
csat_enabled: bool
sla_targets: Optional[dict] = None
created_template: Optional[str] = None
resolved_template: Optional[str] = None
jira_escalation_enabled: bool
jira_escalation_priority: Optional[str] = None
investigation_mcp_tool_ids: Optional[List[int]] = None
alert_webhook_enabled: bool
# Only exposed to settings managers; forms the intake URL path.
alert_webhook_secret: Optional[str] = None
max_tool_calls_per_run: int
max_runs_per_ticket: int
class TicketSettingsUpdate(BaseModel):
autonomy_level: Optional[int] = Field(default=None, ge=1, le=3)
auto_investigate_on_create: Optional[bool] = None
min_confidence_to_auto_resolve: Optional[float] = Field(default=None, ge=0.0, le=1.0)
confirmation_timeout_hours: Optional[int] = Field(default=None, ge=1, le=24 * 30)
csat_enabled: Optional[bool] = None
sla_targets: Optional[dict] = None
created_template: Optional[str] = Field(default=None, max_length=2000)
resolved_template: Optional[str] = Field(default=None, max_length=2000)
jira_escalation_enabled: Optional[bool] = None
jira_escalation_priority: Optional[TicketPriority] = None
investigation_mcp_tool_ids: Optional[List[int]] = None
alert_webhook_enabled: Optional[bool] = None
max_tool_calls_per_run: Optional[int] = Field(default=None, ge=1, le=100)
max_runs_per_ticket: Optional[int] = Field(default=None, ge=1, le=10)
@field_validator("sla_targets")
@classmethod
def _validate_sla(cls, v: Optional[dict]) -> Optional[dict]:
if v is None:
return v
for priority, target in v.items():
if priority not in [p.value for p in TicketPriority]:
raise ValueError(f"unknown priority '{priority}'")
SlaTarget.model_validate(target)
return v
@@ -0,0 +1,187 @@
"""
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.
"""
import re
from datetime import datetime
from typing import Dict, List, Optional
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator
ENGINES = ("postgresql", "mysql")
ROW_SCOPE_KEYS = ("email", "phone")
class DBConnectorColumnOut(BaseModel):
name: str
type: str
class DBConnectorTableOut(BaseModel):
schema_name: str = Field(alias="schema")
table: str
columns: List[DBConnectorColumnOut]
model_config = ConfigDict(populate_by_name=True)
class SSHTunnelFields(BaseModel):
"""Optional SSH bastion/jump-host tunnel. Auth is by private key
(preferred) or password; secrets are write-only."""
ssh_enabled: bool = False
ssh_host: Optional[str] = Field(default=None, max_length=500)
ssh_port: int = Field(default=22, ge=1, le=65535)
ssh_username: Optional[str] = Field(default=None, max_length=200)
ssh_password: Optional[str] = Field(default=None, max_length=1000)
ssh_private_key: Optional[str] = Field(default=None, max_length=20000)
ssh_private_key_passphrase: Optional[str] = Field(default=None, max_length=1000)
class DBConnectorDiscoverRequest(SSHTunnelFields):
"""Pre-save connection test: credentials go over TLS, are used once for
discovery, and are never persisted by this endpoint."""
engine: str = Field(pattern=f"^({'|'.join(ENGINES)})$")
host: str = Field(min_length=1, max_length=500)
port: int = Field(ge=1, le=65535)
database: str = Field(min_length=1, max_length=200)
username: str = Field(min_length=1, max_length=200)
password: str = Field(min_length=1, max_length=500)
class DBConnectorDiscoverResponse(BaseModel):
ok: bool
error: Optional[str] = None
tables: List[DBConnectorTableOut] = []
_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_$]*$")
_TABLE_REF = re.compile(r"^[A-Za-z_][A-Za-z0-9_$]*(\.[A-Za-z_][A-Za-z0-9_$]*)?$")
ROW_SCOPE_DESCRIPTION = (
'Tables restricted to the ticket customer\'s own rows, as '
'{"schema.table": "customer_column"}. Listed tables are rewritten into a '
"pre-filtered subquery; tables left out are readable in full."
)
def validate_row_scope(value: Optional[Dict[str, str]]) -> Optional[Dict[str, str]]:
"""Both halves land in generated SQL as identifiers. sqlglot quotes them,
but a bad value should be refused at the edge rather than quoted and
executed — a typo'd column silently scopes nothing useful."""
if value is None:
return None
if len(value) > 500:
raise ValueError("Too many row-scope entries")
cleaned: Dict[str, str] = {}
for table, column in value.items():
table = (table or "").strip()
column = (column or "").strip()
if not table or not column:
continue
if not _TABLE_REF.match(table):
raise ValueError(f"Invalid table reference in row scope: {table!r}")
if not _IDENTIFIER.match(column):
raise ValueError(f"Invalid column name in row scope: {column!r}")
cleaned[table.lower()] = column
return cleaned
class DBConnectorCreate(SSHTunnelFields):
name: str = Field(min_length=1, max_length=200)
engine: str = Field(pattern=f"^({'|'.join(ENGINES)})$")
host: str = Field(min_length=1, max_length=500)
port: int = Field(ge=1, le=65535)
database: str = Field(min_length=1, max_length=200)
username: str = Field(min_length=1, max_length=200)
password: str = Field(min_length=1, max_length=500)
enabled: bool = True
# Nothing is queryable unless explicitly listed ("schema.table").
allowed_tables: List[str] = Field(default_factory=list, max_length=500)
masked_columns: List[str] = Field(default_factory=list, max_length=500)
row_scope: Dict[str, str] = Field(
default_factory=dict, description=ROW_SCOPE_DESCRIPTION
)
row_scope_key: str = Field(
default="email", pattern=f"^({'|'.join(ROW_SCOPE_KEYS)})$"
)
max_rows: int = Field(default=100, ge=1, le=1000)
statement_timeout_ms: int = Field(default=5000, ge=100, le=30000)
@field_validator("row_scope")
@classmethod
def _check_row_scope(cls, value):
return validate_row_scope(value) or {}
class DBConnectorUpdate(BaseModel):
name: Optional[str] = Field(default=None, min_length=1, max_length=200)
host: Optional[str] = Field(default=None, min_length=1, max_length=500)
port: Optional[int] = Field(default=None, ge=1, le=65535)
database: Optional[str] = Field(default=None, min_length=1, max_length=200)
username: Optional[str] = Field(default=None, min_length=1, max_length=200)
# Omit to keep the stored password.
password: Optional[str] = Field(default=None, min_length=1, max_length=500)
enabled: Optional[bool] = None
allowed_tables: Optional[List[str]] = Field(default=None, max_length=500)
masked_columns: Optional[List[str]] = Field(default=None, max_length=500)
row_scope: Optional[Dict[str, str]] = Field(
default=None, description=ROW_SCOPE_DESCRIPTION
)
row_scope_key: Optional[str] = Field(
default=None, pattern=f"^({'|'.join(ROW_SCOPE_KEYS)})$"
)
max_rows: Optional[int] = Field(default=None, ge=1, le=1000)
statement_timeout_ms: Optional[int] = Field(default=None, ge=100, le=30000)
# SSH tunnel — omit a secret to keep the stored one.
ssh_enabled: Optional[bool] = None
ssh_host: Optional[str] = Field(default=None, max_length=500)
ssh_port: Optional[int] = Field(default=None, ge=1, le=65535)
ssh_username: Optional[str] = Field(default=None, max_length=200)
ssh_password: Optional[str] = Field(default=None, max_length=1000)
ssh_private_key: Optional[str] = Field(default=None, max_length=20000)
ssh_private_key_passphrase: Optional[str] = Field(default=None, max_length=1000)
@field_validator("row_scope")
@classmethod
def _check_row_scope(cls, value):
return validate_row_scope(value)
class DBConnectorOut(BaseModel):
"""Never includes the password in any form."""
model_config = ConfigDict(from_attributes=True)
id: UUID
name: str
engine: str
host: str
port: int
database: str
username: str
enabled: bool
allowed_tables: Optional[List[str]] = None
masked_columns: Optional[List[str]] = None
row_scope: Optional[Dict[str, str]] = None
row_scope_key: str = "email"
max_rows: int
statement_timeout_ms: int
# SSH tunnel — connection details only, never the key/password.
ssh_enabled: bool = False
ssh_host: Optional[str] = None
ssh_port: int = 22
ssh_username: Optional[str] = None
last_test_at: Optional[datetime] = None
last_test_ok: Optional[bool] = None
created_at: Optional[datetime] = None
+145
View File
@@ -0,0 +1,145 @@
"""
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 pydantic import BaseModel, EmailStr, field_serializer, field_validator
from typing import Optional, List
from uuid import UUID
from datetime import datetime
from app.core.s3 import sign_s3_url
from app.core.security import validate_password_strength
from app.models.schemas.role import RoleResponse
class UserBase(BaseModel):
email: EmailStr
full_name: str
is_active: bool = True
profile_pic: Optional[str] = None
is_online: bool = False
last_seen: Optional[datetime] = None
class ChatScopeFields(BaseModel):
"""The two chat-scope toggles on the user form.
Both default to None, meaning "whatever the chosen role already grants".
A client that predates these fields keeps its old behaviour; the form
sends explicit values because it renders the role's current scope.
"""
see_all_ai_chats: Optional[bool] = None
see_all_org_chats: Optional[bool] = None
class UserCreate(UserBase, ChatScopeFields):
password: str
role_id: int
@field_validator('password')
@classmethod
def _check_strength(cls, value: str) -> str:
# The password an admin picks for a new teammate clears the same bar as
# one set by a reset — otherwise the policy is one invite away from
# being bypassed.
return validate_password_strength(value)
class UserUpdate(ChatScopeFields):
email: Optional[EmailStr] = None
full_name: Optional[str] = None
password: Optional[str] = None
is_active: Optional[bool] = None
current_password: Optional[str] = None
role_id: Optional[int] = None
profile_pic: Optional[str] = None
is_online: Optional[bool] = None
class TeammateResponse(BaseModel):
"""A colleague as the inbox needs them: enough to pick one and render them.
No role, no permissions, no timestamps — an agent listing who they can hand
a chat to has no business reading the org's permission matrix, which is
what UserResponse would give them.
"""
id: UUID
full_name: Optional[str] = None
email: EmailStr
profile_pic: Optional[str] = None
is_online: Optional[bool] = False
@field_serializer('profile_pic')
def _sign_profile_pic(self, v: Optional[str]) -> Optional[str]:
return sign_s3_url(v) if v else v
class Config:
from_attributes = True
class AdminPasswordReset(BaseModel):
"""An admin setting a new password for someone else in their organization.
No current_password: the admin does not know it — that is the point of a
reset. The policy check lives here because nobody is around to see the
form's strength meter when the call arrives from a script.
"""
new_password: str
@field_validator('new_password')
@classmethod
def _check_strength(cls, value: str) -> str:
return validate_password_strength(value)
class UserStatusUpdate(BaseModel):
is_online: bool
class UserGroupResponse(BaseModel):
name: str
description: Optional[str] = None
class UserResponse(UserBase):
id: UUID
organization_id: Optional[UUID] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
is_online: Optional[bool] = None
last_seen: Optional[datetime] = None
profile_pic: Optional[str] = None
is_active: Optional[bool] = None
groups: Optional[List[UserGroupResponse]] = None
role: Optional[RoleResponse] = None
@field_serializer('profile_pic')
def _sign_profile_pic(self, v: Optional[str]) -> Optional[str]:
"""Sign on the way out, every response — never stored signed."""
return sign_s3_url(v) if v else v
class Config:
from_attributes = True
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str
user: UserResponse
class Config:
from_attributes = True
+40
View File
@@ -0,0 +1,40 @@
"""
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 pydantic import BaseModel
from typing import Optional, List
from uuid import UUID
from app.models.schemas.user import UserResponse
class UserGroupBase(BaseModel):
name: str
description: Optional[str] = None
class UserGroupCreate(UserGroupBase):
pass
class UserGroupUpdate(UserGroupBase):
name: Optional[str] = None
class UserGroupResponse(UserGroupBase):
id: UUID
organization_id: UUID
class Config:
from_attributes = True
class UserGroupWithUsers(UserGroupResponse):
users: List[UserResponse]
+95
View File
@@ -0,0 +1,95 @@
"""
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 pydantic import BaseModel, field_serializer
from typing import List, Optional
from uuid import UUID
from app.core.s3 import sign_s3_url
class WidgetBase(BaseModel):
name: str
# Optional agent ID for widget configuration
agent_id: Optional[UUID] = None
class WidgetCreate(WidgetBase):
pass
class AgentCustomizationResponse(BaseModel):
chat_background_color: Optional[str] = None
chat_text_color: Optional[str] = None
chat_bubble_color: Optional[str] = None
accent_color: Optional[str] = None
font_family: Optional[str] = None
photo_url: Optional[str] = None
chat_style: Optional[str] = "CHATBOT"
widget_position: Optional[str] = "FLOATING"
welcome_title: Optional[str] = None
welcome_subtitle: Optional[str] = None
welcome_message: Optional[str] = None
chat_initiation_messages: Optional[List[str]] = None
quick_actions: Optional[List[str]] = None
show_citations: Optional[bool] = None
collect_email: Optional[bool] = None
show_ai_disclaimer: Optional[bool] = None
allow_new_chat: Optional[bool] = None
# Free-form extras (avatar_style, widget_display placement defaults, ...)
customization_metadata: Optional[dict] = None
@field_serializer('photo_url')
def _sign_photo_url(self, v: Optional[str]) -> Optional[str]:
"""Sign on the way out, every response — never stored signed."""
return sign_s3_url(v) if v else v
class Config:
from_attributes = True
class AgentResponse(BaseModel):
id: UUID
name: str
display_name: Optional[str] = None
customization: Optional[AgentCustomizationResponse] = None
workflow: bool = False
class HumanAgentResponse(BaseModel):
human_agent_name: Optional[str] = None
human_agent_profile_pic: Optional[str] = None
@field_serializer('human_agent_profile_pic')
def _sign_profile_pic(self, v: Optional[str]) -> Optional[str]:
"""Sign on the way out, every response — never stored signed."""
return sign_s3_url(v) if v else v
class Config:
from_attributes = True
class WidgetResponse(BaseModel):
id: str
organization_id: UUID
agent: AgentResponse
human_agent: Optional[HumanAgentResponse] = None
# Include agent ID in response if set
agent_id: Optional[UUID] = None
token: Optional[str] = None
class Config:
from_attributes = True
+67
View File
@@ -0,0 +1,67 @@
"""
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 pydantic import BaseModel, Field
from typing import Optional
from uuid import UUID
from datetime import datetime
class WidgetAppBase(BaseModel):
"""Base schema with common fields"""
name: str = Field(..., min_length=1, max_length=100, description="App name (e.g., 'Production Widget')")
description: Optional[str] = Field(None, max_length=500, description="Optional description")
class WidgetAppCreate(WidgetAppBase):
"""Schema for creating a new widget app"""
pass
class WidgetAppUpdate(BaseModel):
"""Schema for updating widget app"""
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = None
is_active: Optional[bool] = None
class WidgetAppResponse(WidgetAppBase):
"""Schema for widget app response (without API key)"""
id: UUID
organization_id: UUID
created_by: UUID
is_active: bool
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class WidgetAppWithKeyResponse(WidgetAppResponse):
"""
Schema for newly created app (includes API key ONCE).
IMPORTANT: Only use this for create/regenerate responses.
Never log or persist the api_key field.
"""
api_key: str = Field(..., description="API key - SAVE THIS! It won't be shown again.")
class WidgetAppListResponse(BaseModel):
"""Schema for list of widget apps"""
total: int
apps: list[WidgetAppResponse]
+202
View File
@@ -0,0 +1,202 @@
"""
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 pydantic import BaseModel, Field, validator
from typing import Optional, List, Dict, Any, Union, Literal
from uuid import UUID
from datetime import datetime
from app.models.workflow import WorkflowStatus
from app.models.workflow_node import NodeType, ActionType
# ==============================
# Workflow schemas
# ==============================
class WorkflowBase(BaseModel):
name: str
description: Optional[str] = None
status: Optional[WorkflowStatus] = WorkflowStatus.DRAFT
is_template: bool = False
default_language: str = "en"
canvas_data: Dict[str, Any] = Field(default_factory=dict)
settings: Dict[str, Any] = Field(default_factory=dict)
class WorkflowCreate(WorkflowBase):
agent_id: UUID
# ==============================
# Operation schemas for batch updates
# ==============================
class WorkflowNodeOperation(BaseModel):
operation: Literal["create", "update", "delete"]
id: Optional[UUID] = None # Required for update/delete, optional for create
node_type: Optional[NodeType] = None
name: Optional[str] = None
description: Optional[str] = None
position_x: Optional[float] = None
position_y: Optional[float] = None
config: Optional[Dict[str, Any]] = None
# Landing Page node fields
landing_page_heading: Optional[str] = None
landing_page_content: Optional[str] = None
class WorkflowConnectionOperation(BaseModel):
operation: Literal["create", "update", "delete"]
id: Optional[UUID] = None # Required for update/delete, optional for create
source_node_id: Optional[UUID] = None
target_node_id: Optional[UUID] = None
label: Optional[str] = None
condition: Optional[str] = None
priority: Optional[int] = None
connection_metadata: Optional[Dict[str, Any]] = None
class WorkflowUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
status: Optional[WorkflowStatus] = None
is_template: Optional[bool] = None
default_language: Optional[str] = None
canvas_data: Optional[Dict[str, Any]] = None
settings: Optional[Dict[str, Any]] = None
agent_id: Optional[UUID] = None
nodes: Optional[List[WorkflowNodeOperation]] = None
connections: Optional[List[WorkflowConnectionOperation]] = None
class WorkflowResponse(WorkflowBase):
id: UUID
organization_id: UUID
agent_id: UUID
created_by: UUID
created_at: datetime
updated_at: datetime
class Config:
orm_mode = True
# ==============================
# WorkflowNode schemas
# ==============================
class WorkflowNodeBase(BaseModel):
workflow_id: UUID
node_type: NodeType
name: str
description: Optional[str] = None
position_x: float = 0
position_y: float = 0
config: Dict[str, Any] = Field(default_factory=dict)
# Message node fields
message_text: Optional[str] = None
# Condition node fields
condition_expression: Optional[str] = None
# Action node fields
action_type: Optional[ActionType] = None
action_config: Optional[Dict[str, Any]] = None
# Transfer node fields
transfer_rules: Optional[Dict[str, Any]] = None
# Wait node fields
wait_duration: Optional[int] = None # Duration in seconds
wait_until_condition: Optional[str] = None
# Landing Page node fields
landing_page_heading: Optional[str] = None
landing_page_content: Optional[str] = None
class WorkflowNodeCreate(WorkflowNodeBase):
pass
class WorkflowNodeUpdate(BaseModel):
node_type: Optional[NodeType] = None
name: Optional[str] = None
description: Optional[str] = None
position_x: Optional[float] = None
position_y: Optional[float] = None
config: Optional[Dict[str, Any]] = None
# Landing Page node fields
landing_page_heading: Optional[str] = None
landing_page_content: Optional[str] = None
class WorkflowNodeResponse(WorkflowNodeBase):
id: UUID
created_at: datetime
updated_at: datetime
class Config:
orm_mode = True
# ==============================
# WorkflowConnection schemas
# ==============================
class WorkflowConnectionBase(BaseModel):
workflow_id: UUID
source_node_id: UUID
target_node_id: UUID
label: Optional[str] = None
condition: Optional[str] = None
priority: int = 0
connection_metadata: Dict[str, Any] = Field(default_factory=dict)
class WorkflowConnectionCreate(WorkflowConnectionBase):
pass
class WorkflowConnectionUpdate(BaseModel):
label: Optional[str] = None
condition: Optional[str] = None
priority: Optional[int] = None
connection_metadata: Optional[Dict[str, Any]] = None
class WorkflowConnectionResponse(WorkflowConnectionBase):
id: UUID
created_at: datetime
updated_at: datetime
class Config:
orm_mode = True
# ==============================
# Additional composite schemas
# ==============================
class WorkflowDetailResponse(WorkflowResponse):
nodes: List[WorkflowNodeResponse]
connections: List[WorkflowConnectionResponse]
class Config:
orm_mode = True