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
+66
View File
@@ -394,6 +394,11 @@ def _apply_runtime_schema_compatibility() -> None:
_add_column_if_missing(conn, columns, "asterisk_call_links", "ai_state", "VARCHAR(32)")
_add_column_if_missing(conn, columns, "asterisk_call_links", "ai_handoff_reason", "TEXT")
_add_column_if_missing(conn, columns, "asterisk_call_links", "ai_last_model_at", "VARCHAR(64)")
_add_column_if_missing(conn, columns, "asterisk_call_links", "voice_start_language", "VARCHAR(16)")
_add_column_if_missing(conn, columns, "asterisk_call_links", "customer_name_status", "VARCHAR(32)")
_add_column_if_missing(conn, columns, "asterisk_call_links", "customer_name_value", "VARCHAR(256)")
_add_column_if_missing(conn, columns, "asterisk_call_links", "customer_name_source", "VARCHAR(32)")
_add_column_if_missing(conn, columns, "asterisk_call_links", "customer_name_resolved_at", "VARCHAR(64)")
indexes = _table_indexes(inspector, "asterisk_call_links")
if "idx_asterisk_call_links_voice_session_id" not in indexes:
conn.execute(
@@ -423,15 +428,76 @@ def _apply_runtime_schema_compatibility() -> None:
"ON asterisk_call_links(ai_last_model_at)"
)
)
if "idx_asterisk_call_links_voice_start_language" not in indexes:
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_voice_start_language "
"ON asterisk_call_links(voice_start_language)"
)
)
if "idx_asterisk_call_links_customer_name_status" not in indexes:
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_customer_name_status "
"ON asterisk_call_links(customer_name_status)"
)
)
if "idx_asterisk_call_links_customer_name_source" not in indexes:
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_customer_name_source "
"ON asterisk_call_links(customer_name_source)"
)
)
if "idx_asterisk_call_links_customer_name_resolved_at" not in indexes:
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_customer_name_resolved_at "
"ON asterisk_call_links(customer_name_resolved_at)"
)
)
if "voice_ai_sessions" in table_names:
columns = _table_columns(inspector, "voice_ai_sessions")
_add_column_if_missing(conn, columns, "voice_ai_sessions", "voice_start_language", "VARCHAR(16)")
_add_column_if_missing(conn, columns, "voice_ai_sessions", "customer_name_status", "VARCHAR(32)")
_add_column_if_missing(conn, columns, "voice_ai_sessions", "customer_name_value", "VARCHAR(256)")
_add_column_if_missing(conn, columns, "voice_ai_sessions", "customer_name_source", "VARCHAR(32)")
_add_column_if_missing(conn, columns, "voice_ai_sessions", "customer_name_resolved_at", "VARCHAR(64)")
_add_column_if_missing(conn, columns, "voice_ai_sessions", "media_uuid", "VARCHAR(64)")
_add_column_if_missing(conn, columns, "voice_ai_sessions", "media_status", "VARCHAR(32)")
_add_column_if_missing(conn, columns, "voice_ai_sessions", "media_connected_at", "VARCHAR(64)")
_add_column_if_missing(conn, columns, "voice_ai_sessions", "media_ended_at", "VARCHAR(64)")
_add_column_if_missing(conn, columns, "voice_ai_sessions", "last_media_frame_at", "VARCHAR(64)")
indexes = _table_indexes(inspector, "voice_ai_sessions")
if "idx_voice_ai_sessions_voice_start_language" not in indexes:
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_voice_start_language "
"ON voice_ai_sessions(voice_start_language)"
)
)
if "idx_voice_ai_sessions_customer_name_status" not in indexes:
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_customer_name_status "
"ON voice_ai_sessions(customer_name_status)"
)
)
if "idx_voice_ai_sessions_customer_name_source" not in indexes:
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_customer_name_source "
"ON voice_ai_sessions(customer_name_source)"
)
)
if "idx_voice_ai_sessions_customer_name_resolved_at" not in indexes:
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_customer_name_resolved_at "
"ON voice_ai_sessions(customer_name_resolved_at)"
)
)
if "idx_voice_ai_sessions_media_uuid" not in indexes:
conn.execute(
text(
+19
View File
@@ -120,6 +120,15 @@ class Queue(Base):
created_at: Mapped[str] = mapped_column(String(64))
class VoiceNameCollectionSettingsRow(Base):
__tablename__ = "voice_name_collection_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
settings_key: Mapped[str] = mapped_column(String(64), unique=True, index=True)
config_json: Mapped[str] = mapped_column(Text, default="{}")
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class RoutingCounter(Base):
__tablename__ = "routing_counters"
@@ -225,6 +234,11 @@ class AsteriskCallLinkRow(Base):
ai_state: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
ai_handoff_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
ai_last_model_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
voice_start_language: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
customer_name_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
customer_name_value: Mapped[str | None] = mapped_column(String(256), nullable=True)
customer_name_source: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
customer_name_resolved_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
started_at: Mapped[str] = mapped_column(String(64), index=True)
connected_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
ended_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
@@ -396,6 +410,11 @@ class VoiceAISessionRow(Base):
status: Mapped[str] = mapped_column(String(32), index=True, default="queued")
handoff_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
handoff_target_queue_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
voice_start_language: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
customer_name_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
customer_name_value: Mapped[str | None] = mapped_column(String(256), nullable=True)
customer_name_source: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
customer_name_resolved_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
media_uuid: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
media_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
media_connected_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)