feat(models): 组织、用户、角色、客户、会话与消息模型
按组织边界定义数据关系,补齐仓储层与通用工具,迁移脚本可重复执行。
This commit is contained in:
1 parent
2aee6286d6
commit
5e0615f36d
126 files changed
+17436
No files matched your search
@@ -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'
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 []
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
Reference in new issue
Block a user