Add voice name flow controls and analytics
This commit is contained in:
+216
-1
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user