Files
arys 99d169ec67 fix: stop the no-answer retry from racing the dialplan hangup + fix event catalog validation
Two bugs found via a live test with two real registered browser softphones:

1. retry_escalation_no_answer() re-resolved the client channel via
   _resolve_handoff_channel() -> a live AMI CoreShowChannels round-trip that
   can take ~10s. The new mvpcc-transfer dialplan wait window (MusicOnHold,
   also ~10s, added to give the backend time to redirect before the final
   Hangup) was consistently LOST to this exact same duration: the backend's
   AMI Redirect fired against a channel the dialplan had already hung up
   ('Channel does not exist: PJSIP/...', confirmed in escalation timeline).
   Fixed by reusing the actively-maintained AsteriskCallLinkRow.channel_name
   directly (unchanged for a PJSIP channel across Redirect between contexts
   of the same call) instead of re-discovering it, falling back to the slow
   path only if that field is empty.

2. VoiceEventIn.event_type is a pydantic Literal restricted to 6 legacy
   values (call.started/ivr.completed/...). None of the Phase 2 event
   catalog names (AgentReserved/AgentRinging/AgentNoAnswer/AgentConnected/
   TransferCompleted/TransferFailed) were ever in it, so every single
   _emit_escalation_event() call has been failing with 422 since Phase 2
   shipped (swallowed silently by the broad except there) - confirmed by
   calling app._emit_voice_event() directly against the running service.
   Extended the Literal to include all six.
2026-08-31 01:03:47 +05:00

1643 lines
47 KiB
Python

from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from services.shared.core import Role
Channel = Literal["voice", "telegram", "whatsapp", "email", "webchat"]
InteractionStatus = Literal["new", "in_progress", "escalated", "closed", "abandoned"]
AIThreadState = Literal["queued", "thinking", "active", "handoff_required", "human_owned", "closed", "error"]
VoiceAIState = Literal[
"queued",
"greeting",
"listening",
"thinking",
"speaking",
"active",
"handoff_requested",
"handoff_required",
"human_owned",
"completed",
"closed",
"error",
]
RecordingStatus = Literal["ready", "archived", "missing"]
IvrSessionStatus = Literal["active", "completed", "abandoned"]
EventOutboxStatus = Literal["pending", "published", "failed"]
AsteriskForwardStatus = Literal["received", "processing", "forwarded", "failed"]
TelephonyStatus = Literal["ringing", "claimed", "connected", "ended", "failed"]
CallActionResultStatus = Literal["ok", "failed", "rejected"]
VoiceStartNameStatus = Literal["name_obtained", "name_not_obtained", "name_followup_required"]
VoiceStartNameSource = Literal["known_customer", "voice_start", "voice_followup", "none"]
VoiceNameKnownCustomerBehavior = Literal["trust_and_handoff", "confirm_in_downstream", "ask_on_start"]
VoiceNameUnknownCustomerBehavior = Literal["ask_on_start", "skip_to_downstream"]
VoiceNameMissingNameBehavior = Literal["ask_inline_once", "do_not_ask"]
VoiceNameUncertainNameBehavior = Literal["confirm_then_finalize", "finalize_immediately", "discard_and_collect"]
class VoiceNameCollectionLanguageTexts(BaseModel):
start_prompt: str = Field(min_length=1)
personalized_greeting_template: str = Field(min_length=1)
confirmation_greeting_template: str = Field(min_length=1)
inline_followup_prompt: str = Field(min_length=1)
@field_validator(
"start_prompt",
"personalized_greeting_template",
"confirmation_greeting_template",
"inline_followup_prompt",
mode="before",
)
@classmethod
def _trim_required_text(cls, value: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError("Text value is required")
return normalized
@model_validator(mode="after")
def _validate_name_templates(self):
if "{name}" not in self.personalized_greeting_template:
raise ValueError("personalized_greeting_template must contain {name}")
if "{name}" not in self.confirmation_greeting_template:
raise ValueError("confirmation_greeting_template must contain {name}")
return self
class VoiceNameCollectionStartConfig(BaseModel):
ask_name_on_start: bool = True
known_customer_behavior: VoiceNameKnownCustomerBehavior = "trust_and_handoff"
unknown_customer_behavior: VoiceNameUnknownCustomerBehavior = "ask_on_start"
class VoiceNameCollectionDownstreamConfig(BaseModel):
missing_name_behavior: VoiceNameMissingNameBehavior = "ask_inline_once"
uncertain_name_behavior: VoiceNameUncertainNameBehavior = "confirm_then_finalize"
finalize_on_explicit_name: bool = True
finalize_on_confirmation: bool = True
class VoiceNameCollectionTextsConfig(BaseModel):
ru: VoiceNameCollectionLanguageTexts
kz: VoiceNameCollectionLanguageTexts
class VoiceNameCollectionConfig(BaseModel):
enabled: bool = True
start: VoiceNameCollectionStartConfig = Field(default_factory=VoiceNameCollectionStartConfig)
downstream: VoiceNameCollectionDownstreamConfig = Field(default_factory=VoiceNameCollectionDownstreamConfig)
texts: VoiceNameCollectionTextsConfig
class VoiceNameCollectionConfigOut(BaseModel):
config: VoiceNameCollectionConfig
updated_at: str | None = None
source: Literal["defaults", "database"] = "defaults"
VoiceTTSProviderName = Literal["yandex", "elevenlabs", "openai"]
VoiceTTSLanguage = Literal["ru", "kz"]
class VoiceTTSLanguageConfig(BaseModel):
model_config = ConfigDict(protected_namespaces=())
voice: str | None = None
model_id: str | None = None
language_code: str | None = None
@field_validator("voice", "model_id", "language_code", mode="before")
@classmethod
def _trim_optional_text(cls, value: str | None) -> str | None:
if value is None:
return None
normalized = str(value).strip()
return normalized or None
class VoiceTTSProviderConfig(BaseModel):
ru: VoiceTTSLanguageConfig = Field(default_factory=VoiceTTSLanguageConfig)
kz: VoiceTTSLanguageConfig = Field(default_factory=VoiceTTSLanguageConfig)
class VoiceTTSVoiceOption(BaseModel):
value: str = Field(min_length=1)
label: str = Field(min_length=1)
@field_validator("value", "label", mode="before")
@classmethod
def _trim_required_option_text(cls, value: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError("Option value is required")
return normalized
class VoiceTTSConfig(BaseModel):
provider: VoiceTTSProviderName = "yandex"
yandex: VoiceTTSProviderConfig = Field(default_factory=VoiceTTSProviderConfig)
elevenlabs: VoiceTTSProviderConfig = Field(default_factory=VoiceTTSProviderConfig)
openai: VoiceTTSProviderConfig = Field(default_factory=VoiceTTSProviderConfig)
class VoiceTTSConfigOut(BaseModel):
config: VoiceTTSConfig
updated_at: str | None = None
source: Literal["defaults", "database"] = "defaults"
provider_options: list[VoiceTTSProviderName] = Field(default_factory=list)
voice_options: dict[str, dict[str, list[VoiceTTSVoiceOption]]] = Field(default_factory=dict)
class AIOperatorConfig(BaseModel):
agent_name: str = Field(default="Айнур", min_length=1)
company_name: str = Field(default="DigiOps", min_length=1)
base_system_prompt: str = Field(min_length=1)
identity_reply_ru: str = Field(min_length=1)
identity_reply_kz: str = Field(min_length=1)
voice_greeting_ru: str = Field(min_length=1)
voice_greeting_kz: str = Field(min_length=1)
@field_validator(
"agent_name",
"company_name",
"base_system_prompt",
"identity_reply_ru",
"identity_reply_kz",
"voice_greeting_ru",
"voice_greeting_kz",
mode="before",
)
@classmethod
def _trim_required_text(cls, value: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError("Text value is required")
return normalized
class AIOperatorConfigOut(BaseModel):
config: AIOperatorConfig
updated_at: str | None = None
source: Literal["defaults", "database"] = "defaults"
class HealthResponse(BaseModel):
status: str
service: str
version: str = "v1"
class LoginRequest(BaseModel):
username: str
password: str
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
role: Role
auth_source: str | None = None
provider: str | None = None
full_name: str | None = None
class UserCreate(BaseModel):
username: str = Field(min_length=3)
password: str = Field(min_length=6)
full_name: str = Field(min_length=2)
role: Role
class UserUpdate(BaseModel):
password: str | None = Field(default=None, min_length=6)
full_name: str | None = Field(default=None, min_length=2)
role: Role | None = None
class UserOut(BaseModel):
user_id: str
username: str
full_name: str
role: Role
class AuditEventIn(BaseModel):
actor: str
action: str
entity: str
metadata: dict = Field(default_factory=dict)
class AuditEvent(AuditEventIn):
event_id: str
created_at: str
class CustomerCreate(BaseModel):
display_name: str = Field(min_length=2)
phones: list[str] = Field(default_factory=list)
preferred_phone: str | None = None
tags: list[str] = Field(default_factory=list)
class CustomerNameUpdateIn(BaseModel):
display_name: str = Field(min_length=2)
source: str | None = None
class CustomerOut(CustomerCreate):
customer_id: str
created_at: str
class CustomerHistoryEventOut(BaseModel):
timestamp: str
kind: str
title: str
body: str
note: str = ""
interaction_id: str | None = None
thread_id: str | None = None
call_id: str | None = None
class CustomerHistorySummaryOut(BaseModel):
contact_points: int = 0
open_cases: int = 0
active_channels: list[str] = Field(default_factory=list)
latest_event_at: str | None = None
latest_event_title: str | None = None
primary_phone: str | None = None
primary_telegram_thread_id: str | None = None
class CustomerHistoryOut(BaseModel):
customer: CustomerOut
summary: CustomerHistorySummaryOut
interactions: list["InteractionOut"] = Field(default_factory=list)
telegram_threads: list["TelegramThreadOut"] = Field(default_factory=list)
live_calls: list["VoiceLiveCallOut"] = Field(default_factory=list)
history: list[CustomerHistoryEventOut] = Field(default_factory=list)
class InteractionCreate(BaseModel):
channel: Channel
subject: str = Field(min_length=3)
customer_id: str | None = None
queue_id: str | None = None
priority: int = Field(default=3, ge=1, le=5)
class InteractionOut(InteractionCreate):
interaction_id: str
status: InteractionStatus
assigned_to: str | None = None
created_at: str
updated_at: str
class InteractionDrilldownFiltersOut(BaseModel):
from_ts: str
to_ts: str
queue_id: str | None = None
channel: str | None = None
agent_id: str | None = None
status: str | None = None
q: str | None = None
sort_by: str | None = None
sort_dir: str | None = None
class InteractionDrilldownOut(BaseModel):
items: list[InteractionOut] = Field(default_factory=list)
total: int
limit: int
offset: int
filters: InteractionDrilldownFiltersOut
class AssignRequest(BaseModel):
assignee: str
class StatusRequest(BaseModel):
status: InteractionStatus
resolved_first_contact: bool | None = None
class EscalateRequest(BaseModel):
target_queue_id: str
class InteractionTimelineAppendIn(BaseModel):
action: str = Field(min_length=1)
metadata: dict = Field(default_factory=dict)
class QueueRule(BaseModel):
channel: Channel
priority: int = Field(default=3, ge=1, le=5)
strategy: Literal["round_robin", "least_loaded", "skill_based"] = "round_robin"
sla_seconds: int = Field(default=30, ge=5, le=3600)
class QueueCreate(BaseModel):
name: str
description: str = ""
rules: list[QueueRule] = Field(default_factory=list)
class QueueOut(QueueCreate):
queue_id: str
created_at: str
class VoiceEventIn(BaseModel):
event_type: Literal[
"call.started",
"ivr.completed",
"call.ended",
"recording.ready",
"call.connected",
"call.transferred",
"AgentReserved",
"AgentRinging",
"AgentNoAnswer",
"AgentConnected",
"TransferCompleted",
"TransferFailed",
]
call_id: str
interaction_id: str | None = None
source_event_id: str | None = None
payload: dict = Field(default_factory=dict)
class VoiceEventOut(VoiceEventIn):
event_id: str
created_at: str
class RecordingRegisterIn(BaseModel):
call_id: str
interaction_id: str | None = None
source_path: str
file_name: str | None = None
mime_type: str | None = None
duration_seconds: int | None = Field(default=None, ge=0)
recorded_at: str | None = None
source_event_id: str | None = None
class RecordingOut(BaseModel):
recording_id: str
channel: Literal["voice"]
call_id: str
interaction_id: str | None = None
source_event_id: str | None = None
file_name: str
mime_type: str
size_bytes: int
duration_seconds: int | None = None
storage_backend: Literal["local_fs"]
status: RecordingStatus
recorded_at: str
created_at: str
updated_at: str
archived_at: str | None = None
class AsteriskBridgeStatusOut(BaseModel):
status: str
ami_connected: bool
ami_host: str | None = None
last_event_at: str | None = None
queue_codes_loaded: list[str] = Field(default_factory=list)
sftp_enabled: bool = False
bridge_auth_mode: str | None = None
callcontrol_enabled: bool | None = None
webrtc_enabled: bool = False
webrtc_ws_url: str | None = None
class AsteriskEventOut(BaseModel):
bridge_event_id: str
ami_event_name: str
call_id: str
linked_id: str | None = None
interaction_id: str | None = None
recording_id: str | None = None
forward_status: AsteriskForwardStatus
payload: dict = Field(default_factory=dict)
last_error: str | None = None
created_at: str
updated_at: str
class VoiceLiveCallOut(BaseModel):
call_id: str
interaction_id: str
queue_id: str
queue_code: str
caller_number: str | None = None
caller_name: str | None = None
status: str
telephony_status: TelephonyStatus
claimed_by_user: str | None = None
claimed_at: str | None = None
operator_extension: str | None = None
channel_name: str | None = None
started_at: str
connected_at: str | None = None
ended_at: str | None = None
updated_at: str
last_transition_at: str | None = None
hangup_cause: str | None = None
terminal_action: str | None = None
terminal_target: str | None = None
voice_session_id: str | None = None
ai_session_id: str | None = None
ai_state: VoiceAIState | None = None
ai_handoff_reason: str | None = None
ai_last_model_at: str | None = None
has_recording: bool = False
class VoiceCallClaimIn(BaseModel):
operator_extension: str | None = None
class VoiceCallBlindTransferIn(BaseModel):
target_type: Literal["extension", "queue_code"] = "extension"
target_value: str = Field(min_length=1)
class VoiceCallActionOut(BaseModel):
action_id: str
call_id: str
interaction_id: str | None = None
action_type: str
actor_user: str
actor_role: str
request: dict = Field(default_factory=dict)
result_status: CallActionResultStatus
ami_action_id: str | None = None
error: str | None = None
created_at: str
class VoiceAISummaryOut(BaseModel):
call_id: str
session_id: str | None = None
voice_session_id: str | None = None
status_label: str
status_tone: Literal["answered", "handoff"]
customer_name_status: VoiceStartNameStatus | None = None
customer_name_value: str | None = None
customer_name_source: VoiceStartNameSource | None = None
voice_start_language: str | None = None
customer_request_text: str
ai_outcome_text: str
handoff_reason: str
recommended_next_step: str
generated_at: str
transcript_segments: list["VoiceAISummaryTranscriptSegmentOut"] = Field(default_factory=list)
class VoiceAISummaryTranscriptSegmentOut(BaseModel):
speaker: Literal["caller", "assistant"]
text: str
sequence_no: int
source_type: str
created_at: str
interrupted: bool = False
class BrowserSoftphoneConfigOut(BaseModel):
enabled: bool = False
ws_url: str | None = None
sip_uri: str | None = None
authorization_username: str | None = None
password: str | None = None
display_name: str | None = None
ice_servers: list[dict] = Field(default_factory=list)
operator_extension: str | None = None
class IvrFlowCreate(BaseModel):
name: str = Field(min_length=2)
description: str = ""
queue_id: str
entry_node_id: str
flow_json: dict = Field(default_factory=dict)
is_active: bool = True
class IvrFlowUpdate(BaseModel):
name: str | None = Field(default=None, min_length=2)
description: str | None = None
entry_node_id: str | None = None
flow_json: dict | None = None
is_active: bool | None = None
class IvrFlowOut(BaseModel):
flow_id: str
name: str
description: str
channel: Literal["voice"]
queue_id: str
version: int
is_active: bool
entry_node_id: str
flow_json: dict
created_at: str
updated_at: str
class IvrSessionStartIn(BaseModel):
call_id: str
queue_id: str
interaction_id: str | None = None
class IvrDtmfIn(BaseModel):
digit: str = Field(min_length=1, max_length=1)
class IvrSessionOut(BaseModel):
session_id: str
call_id: str
interaction_id: str | None = None
flow_id: str
queue_id: str
current_node_id: str
entered_digits: list[str] = Field(default_factory=list)
status: IvrSessionStatus
outcome_code: str | None = None
resolved_queue_id: str | None = None
resolved_queue_code: str | None = None
completed_at: str | None = None
created_at: str
updated_at: str
class EventEnvelope(BaseModel):
event_id: str
event_type: str
event_version: int = 1
occurred_at: str
producer: str
entity_type: str
entity_id: str
correlation_id: str | None = None
routing_key: str
payload: dict = Field(default_factory=dict)
class EventOutboxItem(BaseModel):
event_id: str
event_type: str
event_version: int
producer_service: str
entity_type: str
entity_id: str
correlation_id: str | None = None
routing_key: str
payload: dict = Field(default_factory=dict)
status: EventOutboxStatus
attempt_count: int
last_error: str | None = None
available_at: str
published_at: str | None = None
created_at: str
updated_at: str
class TelegramWebhookIn(BaseModel):
chat_id: str
text: str
customer_external_id: str | None = None
payload: dict = Field(default_factory=dict)
class TelegramWebhookOut(TelegramWebhookIn):
message_id: str
thread_id: str | None = None
interaction_id: str | None = None
direction: Literal["inbound", "outbound", "system"] = "inbound"
created_at: str
class TelegramThreadOut(BaseModel):
thread_id: str
chat_id: str
interaction_id: str
telegram_user_id: str | None = None
username: str | None = None
display_name: str | None = None
queue_id: str | None = None
status: InteractionStatus
claimed_by_user: str | None = None
claimed_at: str | None = None
ai_session_id: str | None = None
ai_state: AIThreadState | None = None
ai_handoff_reason: str | None = None
ai_last_model_at: str | None = None
last_message_at: str
last_message_preview: str
created_at: str
updated_at: str
class TelegramThreadMessageOut(BaseModel):
message_id: str
thread_id: str
interaction_id: str
chat_id: str
direction: Literal["inbound", "outbound", "system"]
text: str
telegram_message_id_external: str | None = None
operator_user: str | None = None
author_type: Literal["customer", "human", "ai", "system"] = "customer"
author_id: str | None = None
delivery_status: str | None = None
payload: dict = Field(default_factory=dict)
created_at: str
class TelegramThreadAISummaryOut(BaseModel):
thread_id: str
session_id: str
status_label: str
status_tone: Literal["answered", "handoff"]
customer_request_text: str
ai_outcome_text: str
handoff_reason: str
recommended_next_step: str
generated_at: str
class TelegramThreadReplyIn(BaseModel):
text: str = Field(min_length=1)
class TelegramThreadEscalateIn(BaseModel):
target_queue_id: str = Field(min_length=1)
class AITelegramEnqueueIn(BaseModel):
trigger_message_id: str | None = None
class AITelegramPauseIn(BaseModel):
reason: str = Field(min_length=1)
actor_user: str | None = None
class VoiceAISessionCreateIn(BaseModel):
call_id: str = Field(min_length=1)
linked_id: str | None = None
interaction_id: str = Field(min_length=1)
queue_id: str = Field(min_length=1)
caller_number: str | None = None
caller_name: str | None = None
agent_profile: str = "voice_support"
language_hint: str | None = None
handoff_queue_id: str | None = None
metadata: dict = Field(default_factory=dict)
class VoiceAISessionOut(BaseModel):
voice_session_id: str
ai_session_id: str | None = None
status: VoiceAIState
class VoiceAIMediaBridgeEventIn(BaseModel):
event_type: Literal["requested", "ended"]
media_uuid: str = Field(min_length=1)
call_id: str = Field(min_length=1)
linked_id: str | None = None
channel: str | None = None
service_address: str | None = None
reason: str | None = None
class VoiceAITelephonyEventIn(BaseModel):
event_type: Literal["call.connected", "call.ended", "recording.ready", "operator.connected"]
payload: dict = Field(default_factory=dict)
class VoiceAITurnIn(BaseModel):
voice_session_id: str = Field(min_length=1)
call_id: str = Field(min_length=1)
interaction_id: str = Field(min_length=1)
transcript_text: str = Field(min_length=1)
language: str | None = None
sequence_no: int = Field(default=1, ge=1)
barge_in: bool = False
metadata: dict = Field(default_factory=dict)
class VoiceAITurnDecisionOut(BaseModel):
language: str
intent: str
reply_text: str
confidence: float
needs_handoff: bool
handoff_reason: str | None = None
case_action: Literal["none", "close", "escalate", "keep_open"] = "keep_open"
kb_refs: list[str] = Field(default_factory=list)
summary_text: str = ""
model: str | None = None
latency_ms: int | None = None
status: VoiceAIState = "active"
metadata: dict = Field(default_factory=dict)
class VoiceStartResult(BaseModel):
language: str
customer_id: str | None = None
customer_name_status: VoiceStartNameStatus = "name_not_obtained"
customer_name_value: str | None = None
customer_name_source: VoiceStartNameSource = "none"
downstream_queue_id: str | None = None
downstream_queue_code: str | None = None
resolved_at: str | None = None
class VoiceAIStartIn(BaseModel):
voice_session_id: str = Field(min_length=1)
call_id: str = Field(min_length=1)
interaction_id: str = Field(min_length=1)
customer_id: str | None = None
language_hint: str | None = None
agent_profile: str = "voice_support"
metadata: dict = Field(default_factory=dict)
class VoiceAIStartOut(BaseModel):
session_id: str
language: str
greeting_text: str
disclosure_required: bool = True
needs_handoff: bool = False
handoff_reason: str | None = None
summary_text: str = ""
start_result: VoiceStartResult | None = None
metadata: dict = Field(default_factory=dict)
class VoiceAIHandoffRequestIn(BaseModel):
voice_session_id: str = Field(min_length=1)
ai_session_id: str | None = None
interaction_id: str = Field(min_length=1)
target_queue_id: str | None = None
reason: str = Field(min_length=1)
summary: dict = Field(default_factory=dict)
metadata: dict = Field(default_factory=dict)
class VoiceAICallStateUpdateIn(BaseModel):
voice_session_id: str | None = None
ai_session_id: str | None = None
ai_state: VoiceAIState
handoff_reason: str | None = None
metadata: dict = Field(default_factory=dict)
class TelegramThreadAIReplyIn(BaseModel):
text: str = Field(min_length=1)
agent_profile: str = "telegram_support"
model: str | None = None
trigger_message_id: str | None = None
language: str | None = None
confidence: float | None = None
kb_refs: list[str] = Field(default_factory=list)
payload: dict = Field(default_factory=dict)
class TelegramThreadAIHandoffIn(BaseModel):
reason: str = Field(min_length=1)
agent_profile: str = "telegram_support"
trigger_message_id: str | None = None
confidence: float | None = None
payload: dict = Field(default_factory=dict)
class WhatsAppWebhookIn(BaseModel):
chat_id: str
text: str = ""
external_message_id: str | None = None
whatsapp_user_id: str | None = None
phone_number: str | None = None
display_name: str | None = None
customer_id: str | None = None
external_subject: str | None = None
queue_id: str | None = None
is_group: bool = False
payload: dict = Field(default_factory=dict)
class WhatsAppWebhookOut(BaseModel):
message_id: str
chat_id: str
text: str
external_message_id: str | None = None
customer_id: str | None = None
payload: dict = Field(default_factory=dict)
thread_id: str | None = None
interaction_id: str | None = None
direction: Literal["inbound", "outbound", "system"] = "inbound"
created_at: str
class WhatsAppThreadOut(BaseModel):
thread_id: str
chat_id: str
interaction_id: str
whatsapp_user_id: str | None = None
phone_number: str | None = None
display_name: str | None = None
queue_id: str | None = None
is_group: bool = False
status: InteractionStatus
claimed_by_user: str | None = None
claimed_at: str | None = None
ai_session_id: str | None = None
ai_state: AIThreadState | None = None
ai_handoff_reason: str | None = None
ai_last_model_at: str | None = None
unread_count: int = 0
last_message_at: str
last_message_preview: str
created_at: str
updated_at: str
class WhatsAppThreadMessageOut(BaseModel):
message_id: str
thread_id: str
interaction_id: str
chat_id: str
direction: Literal["inbound", "outbound", "system"]
text: str
whatsapp_message_id_external: str | None = None
operator_user: str | None = None
author_type: Literal["customer", "human", "ai", "system"] = "customer"
author_id: str | None = None
customer_id: str | None = None
delivery_status: str | None = None
payload: dict = Field(default_factory=dict)
created_at: str
class WhatsAppThreadAISummaryOut(BaseModel):
thread_id: str
session_id: str
status_label: str
status_tone: Literal["answered", "handoff"]
customer_request_text: str
ai_outcome_text: str
handoff_reason: str
recommended_next_step: str
generated_at: str
class WhatsAppThreadReplyIn(BaseModel):
text: str = Field(min_length=1)
class WhatsAppThreadEscalateIn(BaseModel):
target_queue_id: str = Field(min_length=1)
class AIWhatsAppEnqueueIn(BaseModel):
trigger_message_id: str | None = None
class AIWhatsAppPauseIn(BaseModel):
reason: str = Field(min_length=1)
actor_user: str | None = None
class WhatsAppThreadAIReplyIn(BaseModel):
text: str = Field(min_length=1)
agent_profile: str = "whatsapp_support"
model: str | None = None
trigger_message_id: str | None = None
language: str | None = None
confidence: float | None = None
kb_refs: list[str] = Field(default_factory=list)
payload: dict = Field(default_factory=dict)
class WhatsAppThreadAIHandoffIn(BaseModel):
reason: str = Field(min_length=1)
agent_profile: str = "whatsapp_support"
trigger_message_id: str | None = None
confidence: float | None = None
payload: dict = Field(default_factory=dict)
class WebchatMessageIn(BaseModel):
session_id: str
text: str = Field(min_length=1)
visitor_name: str | None = None
customer_external_id: str | None = None
queue_id: str | None = None
priority: int = Field(default=3, ge=1, le=5)
payload: dict = Field(default_factory=dict)
class WebchatMessageOut(WebchatMessageIn):
message_id: str
interaction_id: str | None = None
created_at: str
class EmailMessageIn(BaseModel):
from_email: str
subject: str = Field(min_length=3)
body: str = Field(min_length=1)
customer_external_id: str | None = None
queue_id: str | None = None
priority: int = Field(default=3, ge=1, le=5)
payload: dict = Field(default_factory=dict)
class EmailMessageOut(EmailMessageIn):
message_id: str
interaction_id: str | None = None
created_at: str
class KBCategoryCreate(BaseModel):
name: str
description: str = ""
class KBCategoryOut(KBCategoryCreate):
category_id: str
created_at: str
class KBArticleCreate(BaseModel):
category_id: str
article_group_id: str | None = None
intent_code: str | None = None
language: str = "ru"
title: str
body: str
tags: list[str] = Field(default_factory=list)
class KBArticleUpdate(BaseModel):
article_group_id: str | None = None
intent_code: str | None = None
language: str | None = None
title: str | None = None
body: str | None = None
tags: list[str] | None = None
class KBArticleOut(KBArticleCreate):
article_id: str
created_at: str
updated_at: str
class KpiEventIn(BaseModel):
queue_id: str
channel: Channel = "voice"
agent_id: str | None = None
answered: bool
wait_seconds: int = Field(ge=0)
handle_seconds: int = Field(ge=0)
abandoned: bool = False
resolved_first_contact: bool = False
created_at: str | None = None
class ReportingInteractionFactIn(BaseModel):
interaction_id: str = Field(min_length=1)
channel: Channel | None = None
queue_id: str | None = None
agent_id: str | None = None
status: InteractionStatus | None = None
created_at: str | None = None
closed_at: str | None = None
answered: bool | None = None
abandoned: bool | None = None
wait_seconds: int | None = Field(default=None, ge=0)
handle_seconds: int | None = Field(default=None, ge=0)
resolved_first_contact: bool | None = None
source: str = Field(min_length=1)
class ReportingMetricCoverageOut(BaseModel):
status: Literal["exact", "partial", "unavailable"] = "unavailable"
supported_channels: list[str] = Field(default_factory=list)
exact_rows: int = 0
total_rows: int = 0
note: str | None = None
class ReportingKpiCoverageOut(BaseModel):
metric_status: dict[str, Literal["exact", "partial", "unavailable"]] = Field(default_factory=dict)
supported_channels: dict[str, list[str]] = Field(default_factory=dict)
exact_rows: int = 0
total_rows: int = 0
note: str | None = None
metric_details: dict[str, ReportingMetricCoverageOut] = Field(default_factory=dict)
class ReportingDrilldownFiltersOut(BaseModel):
from_ts: str
to_ts: str
metric: str
queue_id: str | None = None
channel: str | None = None
sl_threshold_seconds: int = 30
class ReportingDrilldownItemOut(InteractionOut):
answered: bool | None = None
abandoned: bool | None = None
wait_seconds: int | None = None
handle_seconds: int | None = None
within_sla: bool | None = None
resolved_first_contact: bool | None = None
class ReportingDrilldownOut(BaseModel):
items: list[ReportingDrilldownItemOut] = Field(default_factory=list)
total: int
limit: int
offset: int
metric: str
coverage: ReportingMetricCoverageOut = Field(default_factory=ReportingMetricCoverageOut)
filters: ReportingDrilldownFiltersOut
class ReportingTimeseriesFiltersOut(BaseModel):
from_ts: str
to_ts: str
metric: str
interval: Literal["hour", "day"] = "day"
queue_id: str | None = None
channel: str | None = None
sl_threshold_seconds: int = 30
class ReportingTimeseriesPointOut(BaseModel):
ts: str
value: float = 0.0
sample_size: int = 0
class ReportingTimeseriesOut(BaseModel):
metric: str
interval: Literal["hour", "day"] = "day"
filters: ReportingTimeseriesFiltersOut
points: list[ReportingTimeseriesPointOut] = Field(default_factory=list)
class ReportingAgentAnalyticsFiltersOut(BaseModel):
from_ts: str
to_ts: str
queue_id: str | None = None
channel: str | None = None
sort_by: Literal[
"interactions_total",
"answered_total",
"avg_handle_seconds",
"fcr_rate",
"last_activity_at",
"agent_id",
] = "interactions_total"
sort_dir: Literal["asc", "desc"] = "desc"
limit: int = 25
class ReportingAgentAnalyticsTotalsOut(BaseModel):
agents_total: int = 0
agents_with_activity: int = 0
interactions_total: int = 0
answered_total: int = 0
closed_total: int = 0
ready_now: int = 0
busy_now: int = 0
break_now: int = 0
offline_now: int = 0
avg_interactions_per_agent: float = 0.0
avg_handle_seconds: float | None = None
avg_fcr_rate: float | None = None
class ReportingAgentStateSnapshotOut(BaseModel):
by_state: dict[str, int] = Field(default_factory=dict)
updated_at: str | None = None
class ReportingAgentAnalyticsRowOut(BaseModel):
agent_id: str
current_state: str | None = None
current_queue_id: str | None = None
current_state_updated_at: str | None = None
dominant_queue_id: str | None = None
last_activity_at: str | None = None
interactions_total: int = 0
answered_total: int = 0
closed_total: int = 0
abandoned_total: int = 0
avg_wait_seconds: float | None = None
avg_handle_seconds: float | None = None
answer_rate: float = 0.0
fcr_rate: float | None = None
channels: list[str] = Field(default_factory=list)
class ReportingAgentAnalyticsTeamRowOut(BaseModel):
team_key: str
label: str
agents_total: int = 0
agents_with_activity: int = 0
interactions_total: int = 0
answered_total: int = 0
avg_handle_seconds: float | None = None
fcr_rate: float | None = None
ready_now: int = 0
busy_now: int = 0
break_now: int = 0
offline_now: int = 0
class ReportingAgentAnalyticsShiftRowOut(BaseModel):
shift_key: Literal["night", "day", "evening"]
label: str
agents_with_activity: int = 0
interactions_total: int = 0
answered_total: int = 0
avg_handle_seconds: float | None = None
fcr_rate: float | None = None
class ReportingAgentAnalyticsBreakdownsOut(BaseModel):
by_team: list[ReportingAgentAnalyticsTeamRowOut] = Field(default_factory=list)
by_shift: list[ReportingAgentAnalyticsShiftRowOut] = Field(default_factory=list)
class ReportingAgentAnalyticsTrendFiltersOut(BaseModel):
from_ts: str
to_ts: str
queue_id: str | None = None
channel: str | None = None
metric: Literal["agents_with_activity", "interactions_per_agent", "avg_handle_seconds", "fcr_rate"] = "interactions_per_agent"
interval: Literal["hour", "day"] = "day"
class ReportingAgentAnalyticsTrendPointOut(BaseModel):
ts: str
value: float = 0.0
agents_with_activity: int = 0
interactions_total: int = 0
class ReportingAgentAnalyticsTimeseriesOut(BaseModel):
metric: Literal["agents_with_activity", "interactions_per_agent", "avg_handle_seconds", "fcr_rate"] = "interactions_per_agent"
interval: Literal["hour", "day"] = "day"
filters: ReportingAgentAnalyticsTrendFiltersOut
points: list[ReportingAgentAnalyticsTrendPointOut] = Field(default_factory=list)
class ReportingAgentAnalyticsOverviewOut(BaseModel):
window: AIAnalyticsWindowOut
filters: ReportingAgentAnalyticsFiltersOut
totals: ReportingAgentAnalyticsTotalsOut = Field(default_factory=ReportingAgentAnalyticsTotalsOut)
state_snapshot: ReportingAgentStateSnapshotOut = Field(default_factory=ReportingAgentStateSnapshotOut)
breakdowns: ReportingAgentAnalyticsBreakdownsOut = Field(default_factory=ReportingAgentAnalyticsBreakdownsOut)
items: list[ReportingAgentAnalyticsRowOut] = Field(default_factory=list)
class ReportingSavedViewSnapshot(BaseModel):
preset: str = "7d"
fromTs: str = ""
toTs: str = ""
queueId: str = "all"
channel: str = "all"
compareMode: str = "previous"
trendMetric: str = "volume"
voiceNameTrendMetric: str = "scenario_calls"
aiTrendMetric: str = "containment_rate"
agentTrendMetric: str = "interactions_per_agent"
class ReportingSavedViewIn(BaseModel):
id: str | None = None
name: str = Field(min_length=1, max_length=160)
snapshot: ReportingSavedViewSnapshot = Field(default_factory=ReportingSavedViewSnapshot)
class ReportingSavedViewOut(BaseModel):
id: str
name: str
snapshot: ReportingSavedViewSnapshot = Field(default_factory=ReportingSavedViewSnapshot)
created_at: str
updated_at: str
class AgentStateIn(BaseModel):
agent_id: str
state: Literal["READY", "BUSY", "BREAK", "OFFLINE"]
queue_id: str | None = None
class AgentStateOut(AgentStateIn):
updated_at: str
AgentLevel = Literal["L2", "L3"]
AgentStatus = Literal["OFFLINE", "AVAILABLE", "RESERVED", "RINGING", "TALKING", "AFTER_CALL_WORK", "PAUSED"]
class AgentCreate(BaseModel):
tenant_ids: list[str] = Field(default_factory=list)
extension: str = Field(min_length=1)
endpoint: str | None = None
display_name: str = Field(min_length=1)
level: AgentLevel
skills: list[str] = Field(default_factory=list)
max_concurrent_calls: int = Field(default=1, ge=1)
enabled: bool = True
class AgentStatusUpdateIn(BaseModel):
status: AgentStatus
class AgentPoolOut(BaseModel):
agent_id: str
tenant_ids: list[str] = Field(default_factory=list)
extension: str
endpoint: str | None = None
display_name: str
level: AgentLevel
skills: list[str] = Field(default_factory=list)
status: AgentStatus
current_call_id: str | None = None
max_concurrent_calls: int
enabled: bool
calls_handled_count: int = 0
created_at: str
updated_at: str
class EscalationRequestIn(BaseModel):
target_level: AgentLevel
reason_code: str = Field(min_length=1)
topic: str | None = None
required_skills: list[str] = Field(default_factory=list)
priority: int = Field(default=3, ge=1, le=5)
summary: str | None = None
class EscalationOut(BaseModel):
escalation_id: str
call_id: str
tenant_id: str | None = None
from_level: str
to_level: str
reason_code: str
required_skills: list[str] = Field(default_factory=list)
priority: int
topic: str | None = None
summary: str | None = None
status: str
assigned_agent_id: str | None = None
attempt_count: int = 0
requested_at: str
connected_at: str | None = None
completed_at: str | None = None
class RoutingAgentStatusIn(BaseModel):
status: str = Field(min_length=1)
class RoutingAgentReserveIn(BaseModel):
call_id: str = Field(min_length=1)
level: AgentLevel
tenant_id: str | None = None
required_skills: list[str] = Field(default_factory=list)
exclude_agent_ids: list[str] = Field(default_factory=list)
class RoutingAgentReserveOut(BaseModel):
agent_id: str
extension: str
endpoint: str | None = None
display_name: str
class AIAnalyticsWindowOut(BaseModel):
from_ts: str
to_ts: str
class AIAnalyticsFiltersOut(AIAnalyticsWindowOut):
queue_id: str | None = None
channel: str | None = None
class AIAnalyticsTotalsOut(BaseModel):
sessions_started: int = 0
sessions_contained: int = 0
sessions_handoff: int = 0
sessions_closed: int = 0
sessions_closed_without_operator: int = 0
assistant_turns: int = 0
class AIAnalyticsMetricsOut(BaseModel):
containment_rate: float = 0.0
handoff_rate: float = 0.0
ai_latency_avg_ms: float | None = None
ai_latency_p95_ms: float | None = None
closed_without_operator_rate: float = 0.0
human_touched_rate: float = 0.0
class AIAnalyticsChannelBreakdownOut(BaseModel):
channel: str
sessions_started: int = 0
sessions_contained: int = 0
sessions_handoff: int = 0
sessions_closed_without_operator: int = 0
assistant_turns: int = 0
containment_rate: float = 0.0
handoff_rate: float = 0.0
closed_without_operator_rate: float = 0.0
ai_latency_avg_ms: float | None = None
ai_only_sessions: int = 0
human_touched_sessions: int = 0
class AIAnalyticsOutcomeBreakdownOut(BaseModel):
outcome: Literal["contained", "handoff", "human_touched", "closed_without_operator", "active", "error"]
label: str
sessions: int = 0
share: float = 0.0
class AIAnalyticsHandoffReasonBreakdownOut(BaseModel):
reason_key: str
label: str
sessions: int = 0
share: float = 0.0
class AIAnalyticsBreakdownsOut(BaseModel):
by_channel: list[AIAnalyticsChannelBreakdownOut] = Field(default_factory=list)
by_outcome: list[AIAnalyticsOutcomeBreakdownOut] = Field(default_factory=list)
by_handoff_reason: list[AIAnalyticsHandoffReasonBreakdownOut] = Field(default_factory=list)
class AIAnalyticsCoverageOut(BaseModel):
sessions_with_interaction_id: int = 0
sessions_with_queue_id: int = 0
sessions_with_latency_turns: int = 0
sessions_with_terminal_state: int = 0
sessions_with_handoff_reason: int = 0
class AIAnalyticsOverviewOut(BaseModel):
window: AIAnalyticsWindowOut
filters: AIAnalyticsFiltersOut
totals: AIAnalyticsTotalsOut
metrics: AIAnalyticsMetricsOut
breakdowns: AIAnalyticsBreakdownsOut
coverage: AIAnalyticsCoverageOut
class AIAnalyticsTimeseriesPointOut(BaseModel):
ts: str
value: float | None = None
sessions: int = 0
assistant_turns: int = 0
class AIAnalyticsTimeseriesOut(BaseModel):
metric: Literal["containment_rate", "handoff_rate", "ai_latency_avg_ms", "closed_without_operator_rate", "human_touched_rate"]
interval: Literal["hour", "day"]
filters: AIAnalyticsFiltersOut
points: list[AIAnalyticsTimeseriesPointOut] = Field(default_factory=list)
class VoiceNameFlowAnalyticsFiltersOut(AIAnalyticsWindowOut):
queue_id: str | None = None
language: str | None = None
class VoiceNameFlowAnalyticsTotalsOut(BaseModel):
scenario_calls: int = 0
start_obtained: int = 0
downstream_ai_obtained: int = 0
followup_required: int = 0
name_not_obtained: int = 0
manual_corrected: int = 0
handoff_confirmed_name: int = 0
handoff_unconfirmed_name: int = 0
needed_downstream: int = 0
class VoiceNameFlowAnalyticsMetricsOut(BaseModel):
start_capture_rate: float = 0.0
downstream_rescue_rate: float = 0.0
handoff_unconfirmed_rate: float = 0.0
manual_correction_rate: float = 0.0
class VoiceNameFlowAnalyticsFunnelStageOut(BaseModel):
stage: Literal[
"scenario_calls",
"start_obtained",
"needed_downstream",
"downstream_ai_obtained",
"handoff_confirmed_name",
"handoff_unconfirmed_name",
]
label: str
sessions: int = 0
share: float = 0.0
class VoiceNameFlowAnalyticsLanguageBreakdownOut(BaseModel):
language: str
scenario_calls: int = 0
start_obtained: int = 0
downstream_ai_obtained: int = 0
followup_required: int = 0
name_not_obtained: int = 0
manual_corrected: int = 0
handoff_confirmed_name: int = 0
handoff_unconfirmed_name: int = 0
start_capture_rate: float = 0.0
downstream_rescue_rate: float = 0.0
handoff_unconfirmed_rate: float = 0.0
manual_correction_rate: float = 0.0
class VoiceNameFlowAnalyticsQueueBreakdownOut(BaseModel):
queue_id: str
scenario_calls: int = 0
start_obtained: int = 0
downstream_ai_obtained: int = 0
followup_required: int = 0
name_not_obtained: int = 0
manual_corrected: int = 0
handoff_confirmed_name: int = 0
handoff_unconfirmed_name: int = 0
start_capture_rate: float = 0.0
downstream_rescue_rate: float = 0.0
handoff_unconfirmed_rate: float = 0.0
manual_correction_rate: float = 0.0
class VoiceNameFlowAnalyticsHandoffBreakdownOut(BaseModel):
outcome: Literal["confirmed_name", "unconfirmed_name"]
label: str
sessions: int = 0
share: float = 0.0
class VoiceNameFlowAnalyticsBreakdownsOut(BaseModel):
funnel: list[VoiceNameFlowAnalyticsFunnelStageOut] = Field(default_factory=list)
by_language: list[VoiceNameFlowAnalyticsLanguageBreakdownOut] = Field(default_factory=list)
by_queue: list[VoiceNameFlowAnalyticsQueueBreakdownOut] = Field(default_factory=list)
handoff: list[VoiceNameFlowAnalyticsHandoffBreakdownOut] = Field(default_factory=list)
class VoiceNameFlowAnalyticsCoverageOut(BaseModel):
sessions_with_start_decision: int = 0
sessions_with_final_ai_state: int = 0
sessions_with_manual_overlay: int = 0
note: str | None = None
class VoiceNameFlowAnalyticsOverviewOut(BaseModel):
window: AIAnalyticsWindowOut
filters: VoiceNameFlowAnalyticsFiltersOut
totals: VoiceNameFlowAnalyticsTotalsOut = Field(default_factory=VoiceNameFlowAnalyticsTotalsOut)
metrics: VoiceNameFlowAnalyticsMetricsOut = Field(default_factory=VoiceNameFlowAnalyticsMetricsOut)
breakdowns: VoiceNameFlowAnalyticsBreakdownsOut = Field(default_factory=VoiceNameFlowAnalyticsBreakdownsOut)
coverage: VoiceNameFlowAnalyticsCoverageOut = Field(default_factory=VoiceNameFlowAnalyticsCoverageOut)
class VoiceNameFlowAnalyticsTimeseriesPointOut(BaseModel):
ts: str
value: float | None = None
scenario_calls: int = 0
denominator: int = 0
class VoiceNameFlowAnalyticsTimeseriesOut(BaseModel):
metric: Literal[
"scenario_calls",
"start_capture_rate",
"downstream_rescue_rate",
"handoff_unconfirmed_rate",
"manual_correction_rate",
]
interval: Literal["hour", "day"]
filters: VoiceNameFlowAnalyticsFiltersOut
points: list[VoiceNameFlowAnalyticsTimeseriesPointOut] = Field(default_factory=list)
class AIAnalyticsDrilldownFiltersOut(AIAnalyticsFiltersOut):
slice: Literal["all", "contained", "handoff", "human_touched", "closed_without_operator", "active", "error"] = "all"
reason_key: str | None = None
status: str | None = None
q: str | None = None
sort_by: Literal["created_at", "updated_at", "ai_latency_avg_ms", "status"] = "created_at"
sort_dir: Literal["asc", "desc"] = "desc"
class AIAnalyticsDrilldownItemOut(BaseModel):
session_id: str
thread_id: str | None = None
interaction_id: str | None = None
channel: str
queue_id: str | None = None
status: str
created_at: str
updated_at: str
closed_at: str | None = None
contained: bool = False
handoff: bool = False
human_touched: bool = False
closed_without_operator: bool = False
reason_key: str | None = None
reason_label: str | None = None
raw_handoff_reason: str | None = None
assigned_to: str | None = None
claimed_by_user: str | None = None
assistant_turns: int = 0
user_turns: int = 0
tool_turns: int = 0
ai_latency_avg_ms: float | None = None
ai_latency_p95_ms: float | None = None
class AIAnalyticsDrilldownOut(BaseModel):
items: list[AIAnalyticsDrilldownItemOut] = Field(default_factory=list)
total: int
limit: int
offset: int
filters: AIAnalyticsDrilldownFiltersOut
coverage: AIAnalyticsCoverageOut = Field(default_factory=AIAnalyticsCoverageOut)
class AIAnalyticsSessionLinkedInteractionOut(BaseModel):
interaction_id: str | None = None
channel: str | None = None
queue_id: str | None = None
status: str | None = None
assigned_to: str | None = None
subject: str | None = None
created_at: str | None = None
updated_at: str | None = None
class AIAnalyticsSessionEventOut(BaseModel):
ts: str
event_type: str
label: str
role: str | None = None
source_type: str | None = None
latency_ms: int | None = None
finish_reason: str | None = None
status: str | None = None
metadata: dict = Field(default_factory=dict)
class AIAnalyticsSessionDetailOut(BaseModel):
session: AIAnalyticsDrilldownItemOut
interaction: AIAnalyticsSessionLinkedInteractionOut | None = None
timeline: list[AIAnalyticsSessionEventOut] = Field(default_factory=list)
VoiceAISummaryOut.model_rebuild()
CustomerHistoryOut.model_rebuild()
import inspect
for name, cls in list(locals().items()):
if inspect.isclass(cls) and issubclass(cls, BaseModel) and cls is not BaseModel:
try:
cls.model_rebuild()
except:
pass