Add voice name flow controls and analytics

This commit is contained in:
Yera All
2026-04-05 03:26:18 +05:00
parent d15fcf7129
commit d959c2b2f0
31 changed files with 6410 additions and 152 deletions
+216 -1
View File
@@ -2,7 +2,7 @@
from typing import Literal
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator, model_validator
from services.shared.core import Role
@@ -30,6 +30,72 @@ 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"
class HealthResponse(BaseModel):
@@ -91,6 +157,11 @@ class CustomerCreate(BaseModel):
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
@@ -332,6 +403,10 @@ class VoiceAISummaryOut(BaseModel):
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
@@ -589,6 +664,18 @@ class VoiceAITurnDecisionOut(BaseModel):
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):
@@ -598,6 +685,7 @@ class VoiceAIStartIn(BaseModel):
customer_id: str | None = None
language_hint: str | None = None
agent_profile: str = "voice_support"
metadata: dict = Field(default_factory=dict)
class VoiceAIStartOut(BaseModel):
@@ -605,6 +693,11 @@ class VoiceAIStartOut(BaseModel):
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):
@@ -614,6 +707,7 @@ class VoiceAIHandoffRequestIn(BaseModel):
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):
@@ -1044,6 +1138,7 @@ class ReportingSavedViewSnapshot(BaseModel):
channel: str = "all"
compareMode: str = "previous"
trendMetric: str = "volume"
voiceNameTrendMetric: str = "scenario_calls"
aiTrendMetric: str = "containment_rate"
agentTrendMetric: str = "interactions_per_agent"
@@ -1166,6 +1261,126 @@ class AIAnalyticsTimeseriesOut(BaseModel):
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