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
+628
View File
@@ -39,9 +39,23 @@ from services.shared.models import (
AIAnalyticsTimeseriesPointOut,
AIAnalyticsTotalsOut,
AIAnalyticsWindowOut,
VoiceNameFlowAnalyticsBreakdownsOut,
VoiceNameFlowAnalyticsCoverageOut,
VoiceNameFlowAnalyticsFiltersOut,
VoiceNameFlowAnalyticsFunnelStageOut,
VoiceNameFlowAnalyticsHandoffBreakdownOut,
VoiceNameFlowAnalyticsLanguageBreakdownOut,
VoiceNameFlowAnalyticsMetricsOut,
VoiceNameFlowAnalyticsOverviewOut,
VoiceNameFlowAnalyticsQueueBreakdownOut,
VoiceNameFlowAnalyticsTimeseriesOut,
VoiceNameFlowAnalyticsTimeseriesPointOut,
VoiceNameFlowAnalyticsTotalsOut,
AITelegramEnqueueIn,
AITelegramPauseIn,
HealthResponse,
VoiceNameCollectionConfig,
VoiceNameCollectionConfigOut,
VoiceAIStartIn,
VoiceAIStartOut,
VoiceAITurnIn,
@@ -52,6 +66,7 @@ from services.shared.sql_models import (
AIJobRow,
AISessionRow,
AITurnRow,
AsteriskCallLinkRow,
Customer,
CustomerExternalIdentity,
Interaction,
@@ -61,8 +76,13 @@ from services.shared.sql_models import (
WhatsAppThreadRow,
TelegramMessageRow,
TelegramThreadRow,
VoiceAISessionRow,
)
from services.ai_orchestrator_service import voice as voice_flows
from services.ai_orchestrator_service.voice_name_config import (
load_voice_name_collection_config,
save_voice_name_collection_config,
)
app = FastAPI(title="ai-orchestrator-service", version="1.0.0")
@@ -82,6 +102,14 @@ _AI_ANALYTICS_METRICS = {
_AI_ANALYTICS_INTERVALS = {"hour", "day"}
_AI_ANALYTICS_SLICES = {"all", "contained", "handoff", "human_touched", "closed_without_operator", "active", "error"}
_AI_ANALYTICS_DRILLDOWN_SORT_FIELDS = {"created_at", "updated_at", "ai_latency_avg_ms", "status"}
_VOICE_NAME_FLOW_STATUS_VALUES = {"name_obtained", "name_followup_required", "name_not_obtained"}
_VOICE_NAME_FLOW_METRICS = {
"scenario_calls",
"start_capture_rate",
"downstream_rescue_rate",
"handoff_unconfirmed_rate",
"manual_correction_rate",
}
_AI_ANALYTICS_REASON_LABELS = {
"requested_human": "Запрос клиента на оператора",
"knowledge_or_tool_gap": "Недостаточно знаний или tools",
@@ -98,6 +126,14 @@ _AI_ANALYTICS_OUTCOME_LABELS = {
"active": "Active",
"error": "Error",
}
_VOICE_NAME_FLOW_FUNNEL_LABELS = {
"scenario_calls": "Звонки в сценарии",
"start_obtained": "Имя взято сразу",
"needed_downstream": "Потребовался downstream AI",
"downstream_ai_obtained": "Имя добрал downstream AI",
"handoff_confirmed_name": "Handoff с подтверждённым именем",
"handoff_unconfirmed_name": "Handoff без подтверждённого имени",
}
def _parse_analytics_timestamp(raw: str, field_name: str) -> datetime:
@@ -195,6 +231,43 @@ def _analytics_filters(range_from: datetime, range_to: datetime, queue_id: str |
)
def _safe_json_loads(raw: str | None) -> dict[str, Any]:
if not raw:
return {}
try:
payload = json.loads(raw)
except (TypeError, ValueError):
return {}
return payload if isinstance(payload, dict) else {}
def _normalize_voice_name_language(value: str | None) -> str | None:
normalized = str(value or "").strip().lower()
if not normalized or normalized == "all":
return None
return normalized
def _voice_name_filters(range_from: datetime, range_to: datetime, queue_id: str | None, language: str | None) -> VoiceNameFlowAnalyticsFiltersOut:
return VoiceNameFlowAnalyticsFiltersOut(
from_ts=range_from.isoformat(),
to_ts=range_to.isoformat(),
queue_id=queue_id,
language=language,
)
def _normalize_voice_name_metric(metric: str) -> str:
normalized = str(metric or "").strip()
if normalized not in _VOICE_NAME_FLOW_METRICS:
raise HTTPException(status_code=400, detail="Unsupported metric")
return normalized
def _voice_name_interval_for_window(range_from: datetime, range_to: datetime) -> str:
return "hour" if (range_to - range_from) <= timedelta(hours=36) else "day"
def _normalize_ai_handoff_reason(reason: str | None, claimed_by_user: str | None = None) -> tuple[str | None, str | None, str | None]:
raw = (reason or "").strip() or None
if not raw and claimed_by_user:
@@ -396,6 +469,23 @@ def _empty_ai_analytics_timeseries(
)
def _empty_voice_name_flow_timeseries(
*,
range_from: datetime,
range_to: datetime,
metric: str,
interval: str,
queue_id: str | None,
language: str | None,
) -> VoiceNameFlowAnalyticsTimeseriesOut:
return VoiceNameFlowAnalyticsTimeseriesOut(
metric=metric, # type: ignore[arg-type]
interval=interval, # type: ignore[arg-type]
filters=_voice_name_filters(range_from, range_to, queue_id, language),
points=[],
)
def _load_ai_analytics_snapshots(
session,
*,
@@ -873,6 +963,442 @@ def _load_ai_analytics_session_detail(session, session_id: str) -> AIAnalyticsSe
)
def _voice_name_policy_decision_from_turn(turn: AITurnRow) -> dict[str, Any] | None:
if str(turn.source_type or "").strip() != "voice_policy":
return None
payload = _safe_json_loads(turn.payload_json)
decision = payload.get("decision")
if not isinstance(decision, dict):
return None
metadata = decision.get("metadata")
source = metadata if isinstance(metadata, dict) else decision
status = str(source.get("customer_name_status") or "").strip()
if status not in _VOICE_NAME_FLOW_STATUS_VALUES:
return None
value = str(source.get("customer_name_value") or "").strip() or None
name_source = str(source.get("customer_name_source") or "").strip() or None
language = str(source.get("language") or decision.get("language") or "").strip().lower() or None
return {
"ts": turn.created_at,
"status": status,
"value": value,
"source": name_source,
"language": language,
}
def _voice_name_start_event_from_timeline(row: InteractionTimeline) -> dict[str, Any] | None:
if str(row.action or "").strip() != "voice.start.completed":
return None
metadata = _safe_json_loads(row.metadata_json)
status = str(metadata.get("customer_name_status") or "").strip()
if status not in _VOICE_NAME_FLOW_STATUS_VALUES:
return None
value = str(metadata.get("customer_name_value") or "").strip() or None
name_source = str(metadata.get("customer_name_source") or "").strip() or None
language = str(metadata.get("language") or "").strip().lower() or None
return {
"ts": row.timestamp,
"status": status,
"value": value,
"source": name_source,
"language": language,
}
def _voice_name_primary_outcome(start_decision: dict[str, Any], final_state: dict[str, Any]) -> str:
if start_decision.get("status") == "name_obtained":
return "start_obtained"
if final_state.get("status") == "name_obtained":
return "downstream_ai_obtained"
if final_state.get("status") == "name_followup_required":
return "followup_required"
return "name_not_obtained"
def _voice_name_operator_handoff(
row: VoiceAISessionRow,
*,
call_row: AsteriskCallLinkRow | None,
interaction: Interaction | None,
) -> bool:
handoff_reason = str(
row.handoff_reason
or (call_row.ai_handoff_reason if call_row else "")
or ""
).strip()
has_operator_owner = bool(
(interaction and interaction.assigned_to)
or (call_row and (call_row.claimed_by_user or call_row.operator_extension))
)
if has_operator_owner:
return True
if handoff_reason and handoff_reason != "voice_start_completed":
return True
call_ai_state = str(call_row.ai_state or "").strip() if call_row else ""
row_status = str(row.status or "").strip()
if call_ai_state == "human_owned" or row_status == "human_owned":
return True
if handoff_reason and handoff_reason != "voice_start_completed":
return call_ai_state in {"handoff_requested", "handoff_required"} or row_status in {"handoff_requested", "handoff_required"}
return False
def _voice_name_analytics_coverage_from_snapshots(snapshots: list[dict[str, Any]]) -> VoiceNameFlowAnalyticsCoverageOut:
note = (
"Статусы восстановления имени собраны из voice_policy turns и start-event metadata; ручное исправление считается отдельной overlay-метрикой."
if snapshots
else None
)
return VoiceNameFlowAnalyticsCoverageOut(
sessions_with_start_decision=sum(1 for item in snapshots if item.get("start_decision")),
sessions_with_final_ai_state=sum(1 for item in snapshots if item.get("final_state")),
sessions_with_manual_overlay=sum(1 for item in snapshots if item.get("manual_corrected")),
note=note,
)
def _voice_name_metric_value(metric: str, overview: VoiceNameFlowAnalyticsOverviewOut) -> float | None:
if metric == "scenario_calls":
return float(overview.totals.scenario_calls)
if metric == "start_capture_rate":
return overview.metrics.start_capture_rate
if metric == "downstream_rescue_rate":
return overview.metrics.downstream_rescue_rate
if metric == "handoff_unconfirmed_rate":
return overview.metrics.handoff_unconfirmed_rate
return overview.metrics.manual_correction_rate
def _voice_name_metric_denominator(metric: str, overview: VoiceNameFlowAnalyticsOverviewOut) -> int:
if metric == "scenario_calls":
return overview.totals.scenario_calls
if metric == "start_capture_rate":
return overview.totals.scenario_calls
if metric == "downstream_rescue_rate":
return overview.totals.needed_downstream
return overview.totals.handoff_confirmed_name + overview.totals.handoff_unconfirmed_name
def _build_voice_name_flow_snapshot(
row: VoiceAISessionRow,
*,
call_row: AsteriskCallLinkRow | None,
interaction: Interaction | None,
turns: list[AITurnRow],
start_event: InteractionTimeline | None,
) -> dict[str, Any] | None:
decisions = [
item
for item in (
_voice_name_policy_decision_from_turn(turn)
for turn in sorted(turns, key=lambda candidate: (candidate.created_at, candidate.turn_id))
)
if item is not None
]
fallback_start = _voice_name_start_event_from_timeline(start_event) if start_event else None
start_decision = decisions[0] if decisions else fallback_start
final_state = decisions[-1] if decisions else fallback_start
if not start_decision and not final_state:
return None
resolved_queue_id = (
str(row.queue_id or "").strip()
or (str(call_row.queue_id or "").strip() if call_row else "")
or (str(interaction.queue_id or "").strip() if interaction else "")
or "unknown"
)
resolved_language = (
str(row.voice_start_language or "").strip().lower()
or str(row.language or "").strip().lower()
or (str(call_row.voice_start_language or "").strip().lower() if call_row else "")
or str((start_decision or {}).get("language") or "").strip().lower()
or "unknown"
)
manual_corrected = (
str(row.customer_name_source or "").strip() == "manual"
or (str(call_row.customer_name_source or "").strip() == "manual" if call_row else False)
)
primary_outcome = _voice_name_primary_outcome(start_decision or {}, final_state or start_decision or {})
operator_handoff = _voice_name_operator_handoff(row, call_row=call_row, interaction=interaction)
final_status = str((final_state or {}).get("status") or "name_not_obtained")
return {
"session_id": row.session_id,
"call_id": row.call_id,
"interaction_id": row.interaction_id or (call_row.interaction_id if call_row else None),
"queue_id": resolved_queue_id,
"language": resolved_language,
"started_at": row.started_at,
"status": row.status,
"start_decision": start_decision,
"final_state": final_state,
"manual_corrected": manual_corrected,
"primary_outcome": primary_outcome,
"needed_downstream": primary_outcome != "start_obtained",
"operator_handoff": operator_handoff,
"handoff_confirmed_name": operator_handoff and final_status == "name_obtained",
"handoff_unconfirmed_name": operator_handoff and final_status in {"name_followup_required", "name_not_obtained"},
}
def _load_voice_name_flow_snapshots(
session,
*,
range_from: datetime,
range_to: datetime,
queue_id: str | None,
language: str | None,
) -> list[dict[str, Any]]:
rows = session.execute(
select(VoiceAISessionRow).where(
VoiceAISessionRow.started_at >= range_from.isoformat(),
VoiceAISessionRow.started_at < range_to.isoformat(),
)
).scalars().all()
if not rows:
return []
interaction_ids = {row.interaction_id for row in rows if row.interaction_id}
call_ids = {row.call_id for row in rows if row.call_id}
voice_session_ids = {row.session_id for row in rows if row.session_id}
ai_session_ids = {row.ai_session_id for row in rows if row.ai_session_id}
interactions = {}
if interaction_ids:
interactions = {
item.interaction_id: item
for item in session.execute(
select(Interaction).where(Interaction.interaction_id.in_(interaction_ids))
).scalars().all()
}
call_rows = {}
for item in session.execute(
select(AsteriskCallLinkRow).where(
AsteriskCallLinkRow.call_id.in_(call_ids) if call_ids else False
)
).scalars().all():
call_rows[item.call_id] = item
if voice_session_ids:
for item in session.execute(
select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.voice_session_id.in_(voice_session_ids))
).scalars().all():
if item.call_id not in call_rows:
call_rows[item.call_id] = item
turns_by_ai_session: dict[str, list[AITurnRow]] = defaultdict(list)
if ai_session_ids:
for turn in session.execute(
select(AITurnRow).where(AITurnRow.session_id.in_(ai_session_ids))
).scalars().all():
turns_by_ai_session[turn.session_id].append(turn)
start_events: dict[str, InteractionTimeline] = {}
if interaction_ids:
timeline_rows = session.execute(
select(InteractionTimeline).where(
InteractionTimeline.interaction_id.in_(interaction_ids),
InteractionTimeline.action == "voice.start.completed",
)
).scalars().all()
for item in sorted(timeline_rows, key=lambda candidate: (candidate.timestamp, candidate.id)):
if item.interaction_id not in start_events:
start_events[item.interaction_id] = item
normalized_language = _normalize_voice_name_language(language)
snapshots: list[dict[str, Any]] = []
for row in rows:
call_row = call_rows.get(row.call_id)
interaction = interactions.get(row.interaction_id) if row.interaction_id else None
snapshot = _build_voice_name_flow_snapshot(
row,
call_row=call_row,
interaction=interaction,
turns=list(turns_by_ai_session.get(str(row.ai_session_id or ""), [])),
start_event=start_events.get(row.interaction_id) if row.interaction_id else None,
)
if snapshot is None:
continue
if queue_id and snapshot["queue_id"] != queue_id:
continue
if normalized_language and snapshot["language"] != normalized_language:
continue
snapshots.append(snapshot)
return snapshots
def _voice_name_breakdown_metrics(snapshots: list[dict[str, Any]]) -> dict[str, float]:
scenario_calls = len(snapshots)
start_obtained = sum(1 for item in snapshots if item["primary_outcome"] == "start_obtained")
downstream_ai_obtained = sum(1 for item in snapshots if item["primary_outcome"] == "downstream_ai_obtained")
needed_downstream = sum(1 for item in snapshots if item.get("needed_downstream"))
handoff_confirmed = sum(1 for item in snapshots if item.get("handoff_confirmed_name"))
handoff_unconfirmed = sum(1 for item in snapshots if item.get("handoff_unconfirmed_name"))
all_handoffs = handoff_confirmed + handoff_unconfirmed
manual_corrected = sum(1 for item in snapshots if item.get("manual_corrected"))
return {
"start_capture_rate": _analytics_percent(start_obtained, scenario_calls),
"downstream_rescue_rate": _analytics_percent(downstream_ai_obtained, needed_downstream),
"handoff_unconfirmed_rate": _analytics_percent(handoff_unconfirmed, all_handoffs),
"manual_correction_rate": _analytics_percent(manual_corrected, all_handoffs),
}
def _aggregate_voice_name_flow_overview(
snapshots: list[dict[str, Any]],
*,
range_from: datetime,
range_to: datetime,
queue_id: str | None,
language: str | None,
) -> VoiceNameFlowAnalyticsOverviewOut:
if not snapshots:
return VoiceNameFlowAnalyticsOverviewOut(
window=_analytics_window(range_from, range_to),
filters=_voice_name_filters(range_from, range_to, queue_id, language),
totals=VoiceNameFlowAnalyticsTotalsOut(),
metrics=VoiceNameFlowAnalyticsMetricsOut(),
breakdowns=VoiceNameFlowAnalyticsBreakdownsOut(),
coverage=VoiceNameFlowAnalyticsCoverageOut(),
)
scenario_calls = len(snapshots)
start_obtained = sum(1 for item in snapshots if item["primary_outcome"] == "start_obtained")
downstream_ai_obtained = sum(1 for item in snapshots if item["primary_outcome"] == "downstream_ai_obtained")
followup_required = sum(1 for item in snapshots if item["primary_outcome"] == "followup_required")
name_not_obtained = sum(1 for item in snapshots if item["primary_outcome"] == "name_not_obtained")
manual_corrected = sum(1 for item in snapshots if item.get("manual_corrected"))
handoff_confirmed_name = sum(1 for item in snapshots if item.get("handoff_confirmed_name"))
handoff_unconfirmed_name = sum(1 for item in snapshots if item.get("handoff_unconfirmed_name"))
needed_downstream = sum(1 for item in snapshots if item.get("needed_downstream"))
totals = VoiceNameFlowAnalyticsTotalsOut(
scenario_calls=scenario_calls,
start_obtained=start_obtained,
downstream_ai_obtained=downstream_ai_obtained,
followup_required=followup_required,
name_not_obtained=name_not_obtained,
manual_corrected=manual_corrected,
handoff_confirmed_name=handoff_confirmed_name,
handoff_unconfirmed_name=handoff_unconfirmed_name,
needed_downstream=needed_downstream,
)
metric_values = _voice_name_breakdown_metrics(snapshots)
metrics = VoiceNameFlowAnalyticsMetricsOut(**metric_values)
funnel = [
VoiceNameFlowAnalyticsFunnelStageOut(
stage=stage, # type: ignore[arg-type]
label=_VOICE_NAME_FLOW_FUNNEL_LABELS[stage],
sessions=value,
share=_analytics_percent(value, scenario_calls),
)
for stage, value in (
("scenario_calls", scenario_calls),
("start_obtained", start_obtained),
("needed_downstream", needed_downstream),
("downstream_ai_obtained", downstream_ai_obtained),
("handoff_confirmed_name", handoff_confirmed_name),
("handoff_unconfirmed_name", handoff_unconfirmed_name),
)
]
by_language_rows: list[VoiceNameFlowAnalyticsLanguageBreakdownOut] = []
language_groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
for item in snapshots:
language_groups[str(item["language"] or "unknown")].append(item)
for resolved_language, items in sorted(language_groups.items(), key=lambda entry: (-len(entry[1]), entry[0])):
breakdown_metrics = _voice_name_breakdown_metrics(items)
by_language_rows.append(
VoiceNameFlowAnalyticsLanguageBreakdownOut(
language=resolved_language,
scenario_calls=len(items),
start_obtained=sum(1 for item in items if item["primary_outcome"] == "start_obtained"),
downstream_ai_obtained=sum(1 for item in items if item["primary_outcome"] == "downstream_ai_obtained"),
followup_required=sum(1 for item in items if item["primary_outcome"] == "followup_required"),
name_not_obtained=sum(1 for item in items if item["primary_outcome"] == "name_not_obtained"),
manual_corrected=sum(1 for item in items if item.get("manual_corrected")),
handoff_confirmed_name=sum(1 for item in items if item.get("handoff_confirmed_name")),
handoff_unconfirmed_name=sum(1 for item in items if item.get("handoff_unconfirmed_name")),
**breakdown_metrics,
)
)
by_queue_rows: list[VoiceNameFlowAnalyticsQueueBreakdownOut] = []
queue_groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
for item in snapshots:
queue_groups[str(item["queue_id"] or "unknown")].append(item)
for resolved_queue_id, items in sorted(queue_groups.items(), key=lambda entry: (-len(entry[1]), entry[0])):
breakdown_metrics = _voice_name_breakdown_metrics(items)
by_queue_rows.append(
VoiceNameFlowAnalyticsQueueBreakdownOut(
queue_id=resolved_queue_id,
scenario_calls=len(items),
start_obtained=sum(1 for item in items if item["primary_outcome"] == "start_obtained"),
downstream_ai_obtained=sum(1 for item in items if item["primary_outcome"] == "downstream_ai_obtained"),
followup_required=sum(1 for item in items if item["primary_outcome"] == "followup_required"),
name_not_obtained=sum(1 for item in items if item["primary_outcome"] == "name_not_obtained"),
manual_corrected=sum(1 for item in items if item.get("manual_corrected")),
handoff_confirmed_name=sum(1 for item in items if item.get("handoff_confirmed_name")),
handoff_unconfirmed_name=sum(1 for item in items if item.get("handoff_unconfirmed_name")),
**breakdown_metrics,
)
)
all_handoffs = handoff_confirmed_name + handoff_unconfirmed_name
handoff_rows = [
VoiceNameFlowAnalyticsHandoffBreakdownOut(
outcome="confirmed_name",
label="Handoff с подтверждённым именем",
sessions=handoff_confirmed_name,
share=_analytics_percent(handoff_confirmed_name, all_handoffs),
),
VoiceNameFlowAnalyticsHandoffBreakdownOut(
outcome="unconfirmed_name",
label="Handoff без подтверждённого имени",
sessions=handoff_unconfirmed_name,
share=_analytics_percent(handoff_unconfirmed_name, all_handoffs),
),
]
return VoiceNameFlowAnalyticsOverviewOut(
window=_analytics_window(range_from, range_to),
filters=_voice_name_filters(range_from, range_to, queue_id, language),
totals=totals,
metrics=metrics,
breakdowns=VoiceNameFlowAnalyticsBreakdownsOut(
funnel=funnel,
by_language=by_language_rows,
by_queue=by_queue_rows,
handoff=handoff_rows,
),
coverage=_voice_name_analytics_coverage_from_snapshots(snapshots),
)
def _load_voice_name_flow_overview(
session,
*,
range_from: datetime,
range_to: datetime,
queue_id: str | None,
language: str | None,
) -> VoiceNameFlowAnalyticsOverviewOut:
snapshots = _load_voice_name_flow_snapshots(
session,
range_from=range_from,
range_to=range_to,
queue_id=queue_id,
language=language,
)
return _aggregate_voice_name_flow_overview(
snapshots,
range_from=range_from,
range_to=range_to,
queue_id=queue_id,
language=_normalize_voice_name_language(language),
)
def _bool_env(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
@@ -2605,6 +3131,85 @@ def ai_analytics_timeseries(
)
@app.get("/ai/analytics/voice-name-flow/overview", response_model=VoiceNameFlowAnalyticsOverviewOut)
def voice_name_flow_overview(
from_ts: str = Query(...),
to_ts: str = Query(...),
queue_id: str | None = None,
language: str | None = None,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)),
) -> VoiceNameFlowAnalyticsOverviewOut:
range_from = _parse_analytics_timestamp(from_ts, "from_ts")
range_to = _parse_analytics_timestamp(to_ts, "to_ts")
if range_to <= range_from:
raise HTTPException(status_code=400, detail="to_ts must be greater than from_ts")
session = get_session()
try:
return _load_voice_name_flow_overview(
session,
range_from=range_from,
range_to=range_to,
queue_id=queue_id,
language=language,
)
finally:
session.close()
@app.get("/ai/analytics/voice-name-flow/timeseries", response_model=VoiceNameFlowAnalyticsTimeseriesOut)
def voice_name_flow_timeseries(
from_ts: str = Query(...),
to_ts: str = Query(...),
metric: str = Query(default="scenario_calls"),
queue_id: str | None = None,
language: str | None = None,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)),
) -> VoiceNameFlowAnalyticsTimeseriesOut:
range_from = _parse_analytics_timestamp(from_ts, "from_ts")
range_to = _parse_analytics_timestamp(to_ts, "to_ts")
if range_to <= range_from:
raise HTTPException(status_code=400, detail="to_ts must be greater than from_ts")
normalized_metric = _normalize_voice_name_metric(metric)
normalized_language = _normalize_voice_name_language(language)
normalized_interval = _voice_name_interval_for_window(range_from, range_to)
step = timedelta(hours=1) if normalized_interval == "hour" else timedelta(days=1)
points: list[VoiceNameFlowAnalyticsTimeseriesPointOut] = []
session = get_session()
try:
cursor = range_from
while cursor < range_to:
bucket_from = cursor
bucket_to = min(bucket_from + step, range_to)
overview = _load_voice_name_flow_overview(
session,
range_from=bucket_from,
range_to=bucket_to,
queue_id=queue_id,
language=normalized_language,
)
points.append(
VoiceNameFlowAnalyticsTimeseriesPointOut(
ts=bucket_from.isoformat(),
value=_voice_name_metric_value(normalized_metric, overview),
scenario_calls=overview.totals.scenario_calls,
denominator=_voice_name_metric_denominator(normalized_metric, overview),
)
)
cursor = bucket_to
finally:
session.close()
return VoiceNameFlowAnalyticsTimeseriesOut(
metric=normalized_metric, # type: ignore[arg-type]
interval=normalized_interval, # type: ignore[arg-type]
filters=_voice_name_filters(range_from, range_to, queue_id, normalized_language),
points=points,
)
@app.get("/ai/analytics/drilldown", response_model=AIAnalyticsDrilldownOut)
def ai_analytics_drilldown(
from_ts: str = Query(...),
@@ -2672,6 +3277,29 @@ def start_voice_ai_session(
return voice_flows.start_voice_session(session_id, payload)
@app.get("/ai/voice/config/name-collection", response_model=VoiceNameCollectionConfigOut)
def get_voice_name_collection_config(
_: dict = Depends(require_roles(Role.ADMIN)),
) -> VoiceNameCollectionConfigOut:
session = get_session()
try:
return load_voice_name_collection_config(session)
finally:
session.close()
@app.put("/ai/voice/config/name-collection", response_model=VoiceNameCollectionConfigOut)
def put_voice_name_collection_config(
payload: VoiceNameCollectionConfig,
_: dict = Depends(require_roles(Role.ADMIN)),
) -> VoiceNameCollectionConfigOut:
session = get_session()
try:
return save_voice_name_collection_config(session, payload)
finally:
session.close()
@app.post("/ai/voice/sessions/{session_id}/turns")
def turn_voice_ai_session(
session_id: str,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,168 @@
from __future__ import annotations
import json
from typing import Any
from sqlalchemy import select
from services.shared.core import utc_now_iso
from services.shared.models import (
VoiceNameCollectionConfig,
VoiceNameCollectionConfigOut,
VoiceNameCollectionLanguageTexts,
VoiceNameCollectionTextsConfig,
)
from services.shared.sql_models import VoiceNameCollectionSettingsRow
VOICE_NAME_COLLECTION_SETTINGS_KEY = "global"
def voice_name_collection_default_config() -> VoiceNameCollectionConfig:
return VoiceNameCollectionConfig(
enabled=True,
texts=VoiceNameCollectionTextsConfig(
ru=VoiceNameCollectionLanguageTexts(
start_prompt="Здравствуйте. Назовите, пожалуйста, ваше имя.",
personalized_greeting_template=(
"Я AI-оператор компании. Здравствуйте, {name}. "
"Коротко расскажите, с чем помочь, и я сразу начну разбираться."
),
confirmation_greeting_template=(
"Я AI-оператор компании. Если я правильно расслышал, вас зовут {name}? "
"И чем помочь?"
),
inline_followup_prompt="И ещё подскажите, как мне к вам обращаться?",
),
kz=VoiceNameCollectionLanguageTexts(
start_prompt="Сәлеметсіз бе. Атыңызды атаңызшы.",
personalized_greeting_template=(
"Men kompaniyanyn AI operatoriymyn. Salemetsiz be, {name}. "
"Suragynyzdy aitanyz, men birden komektesuge tyrisamyn."
),
confirmation_greeting_template=(
"Men kompaniyanyn AI operatoriymyn. Durys estisem, atynyz {name} pa? "
"Qalai komektesemin?"
),
inline_followup_prompt="Tagy bir naqtylasam, sizge qalai qaratamyn?",
),
),
)
def _settings_row(session) -> VoiceNameCollectionSettingsRow | None:
return session.execute(
select(VoiceNameCollectionSettingsRow).where(
VoiceNameCollectionSettingsRow.settings_key == VOICE_NAME_COLLECTION_SETTINGS_KEY
)
).scalar_one_or_none()
def _normalize_config_payload(raw: Any) -> VoiceNameCollectionConfig:
if isinstance(raw, VoiceNameCollectionConfig):
return raw
if not isinstance(raw, dict):
raise ValueError("Voice name collection config payload must be an object")
return VoiceNameCollectionConfig.model_validate(raw)
def load_voice_name_collection_config(session) -> VoiceNameCollectionConfigOut:
default_config = voice_name_collection_default_config()
row = _settings_row(session)
if row is None:
return VoiceNameCollectionConfigOut(config=default_config, updated_at=None, source="defaults")
try:
parsed = json.loads(row.config_json or "{}")
config = _normalize_config_payload(parsed)
return VoiceNameCollectionConfigOut(config=config, updated_at=row.updated_at, source="database")
except Exception:
return VoiceNameCollectionConfigOut(config=default_config, updated_at=row.updated_at, source="defaults")
def load_effective_voice_name_collection_config(session) -> VoiceNameCollectionConfig:
return load_voice_name_collection_config(session).config
def save_voice_name_collection_config(
session,
config_payload: VoiceNameCollectionConfig | dict[str, Any],
) -> VoiceNameCollectionConfigOut:
config = _normalize_config_payload(config_payload)
row = _settings_row(session)
now = utc_now_iso()
if row is None:
row = VoiceNameCollectionSettingsRow(
settings_key=VOICE_NAME_COLLECTION_SETTINGS_KEY,
config_json="{}",
updated_at=now,
)
session.add(row)
row.config_json = json.dumps(config.model_dump(), ensure_ascii=False)
row.updated_at = now
session.commit()
session.refresh(row)
return VoiceNameCollectionConfigOut(config=config, updated_at=row.updated_at, source="database")
def voice_name_collection_texts(
config: VoiceNameCollectionConfig,
language: str,
) -> VoiceNameCollectionLanguageTexts:
if str(language or "").strip().lower() == "kz":
return config.texts.kz
return config.texts.ru
def voice_name_collection_start_prompt(config: VoiceNameCollectionConfig, language: str) -> str:
return voice_name_collection_texts(config, language).start_prompt
def voice_name_collection_personalized_greeting(
config: VoiceNameCollectionConfig,
language: str,
name: str,
) -> str:
return voice_name_collection_texts(config, language).personalized_greeting_template.format(name=name)
def voice_name_collection_confirmation_greeting(
config: VoiceNameCollectionConfig,
language: str,
name: str,
) -> str:
return voice_name_collection_texts(config, language).confirmation_greeting_template.format(name=name)
def voice_name_collection_inline_followup(config: VoiceNameCollectionConfig, language: str) -> str:
return voice_name_collection_texts(config, language).inline_followup_prompt
def voice_name_collection_followup_markers(
config: VoiceNameCollectionConfig,
language: str,
) -> tuple[str, ...]:
texts = voice_name_collection_texts(config, language)
markers = [texts.inline_followup_prompt]
for template in (texts.confirmation_greeting_template, texts.personalized_greeting_template):
for chunk in str(template or "").split("{name}"):
normalized = chunk.strip(" ,.!?;:-")
if len(normalized) >= 5:
markers.append(normalized)
markers.extend(
[
"как мне к вам обращаться",
"как к вам обращаться",
"вас зовут",
"atyngyz",
"qalai qaratamyn",
]
)
unique_markers: list[str] = []
seen: set[str] = set()
for marker in markers:
key = marker.strip()
if not key or key in seen:
continue
seen.add(key)
unique_markers.append(key)
return tuple(unique_markers)
+251 -11
View File
@@ -191,7 +191,15 @@ def _resolved_voice_language(language_hint: str | None, fallback: str | None = N
return str(language_hint or fallback or "ru").strip() or "ru"
def _default_voice_greeting(language: str | None) -> str:
def _voice_start_name_prompt(language: str | None) -> str:
if str(language or "").strip() == "kz":
return "Сәлеметсіз бе. Атыңызды атаңызшы."
return "Здравствуйте. Назовите, пожалуйста, ваше имя."
def _default_voice_greeting(language: str | None, *, agent_profile: str = "voice_support") -> str:
if str(agent_profile or "").strip() == "voice_start":
return _voice_start_name_prompt(language)
if str(language or "").strip() == "kz":
return (
"Мен компанияның AI оператормын. Сәлеметсіз бе. "
@@ -264,6 +272,23 @@ def _load_greeting_segment(session, session_id: str) -> VoiceTranscriptSegmentRo
return None
def _load_latest_greeting_segment(session, session_id: str) -> VoiceTranscriptSegmentRow | None:
rows = session.execute(
select(VoiceTranscriptSegmentRow)
.where(VoiceTranscriptSegmentRow.session_id == session_id)
.where(VoiceTranscriptSegmentRow.speaker == "assistant")
.order_by(VoiceTranscriptSegmentRow.id.desc())
).scalars().all()
for row in rows:
try:
payload = json.loads(row.payload_json or "{}")
except Exception:
payload = {}
if str(payload.get("kind") or "").strip() == "greeting":
return row
return None
def _ensure_greeting_segment(session, voice_session: VoiceAISessionRow, greeting_text: str) -> None:
if not str(greeting_text or "").strip():
return
@@ -281,6 +306,90 @@ def _ensure_greeting_segment(session, voice_session: VoiceAISessionRow, greeting
)
def _upsert_greeting_segment(session, voice_session: VoiceAISessionRow, greeting_text: str) -> None:
normalized = str(greeting_text or "").strip()
if not normalized:
return
row = _load_latest_greeting_segment(session, voice_session.session_id)
if row is None:
_ensure_greeting_segment(session, voice_session, normalized)
return
row.text = normalized
try:
payload = json.loads(row.payload_json or "{}")
except Exception:
payload = {}
payload["kind"] = "greeting"
payload.setdefault("delivery_status", "planned")
row.payload_json = json.dumps(payload, ensure_ascii=False)
row.is_final = False
def _queue_new_greeting_segment(session, voice_session: VoiceAISessionRow, greeting_text: str) -> None:
normalized = str(greeting_text or "").strip()
if not normalized:
return
_record_segment(
session,
voice_session=voice_session,
speaker="assistant",
source_type="tts",
text=normalized,
sequence_no=_next_sequence(session, voice_session.session_id),
payload={"kind": "greeting", "delivery_status": "planned"},
is_final=False,
)
def _voice_start_metadata(payload: dict[str, Any] | None) -> dict[str, str | None]:
metadata = payload if isinstance(payload, dict) else {}
return {
"voice_start_language": str(
metadata.get("voice_start_language") or metadata.get("language") or ""
).strip()
or None,
"customer_name_status": str(metadata.get("customer_name_status") or "").strip() or None,
"customer_name_value": str(metadata.get("customer_name_value") or "").strip() or None,
"customer_name_source": str(metadata.get("customer_name_source") or "").strip() or None,
"customer_name_resolved_at": str(metadata.get("customer_name_resolved_at") or "").strip() or None,
"downstream_queue_code": str(metadata.get("downstream_queue_code") or "").strip() or None,
}
def _apply_voice_start_metadata(voice_session: VoiceAISessionRow, metadata: dict[str, Any] | None) -> None:
payload = metadata if isinstance(metadata, dict) else {}
values = _voice_start_metadata(payload)
if "voice_start_language" in payload or "language" in payload:
voice_session.voice_start_language = values["voice_start_language"]
if "customer_name_status" in payload:
voice_session.customer_name_status = values["customer_name_status"]
if "customer_name_value" in payload:
voice_session.customer_name_value = values["customer_name_value"]
if "customer_name_source" in payload:
voice_session.customer_name_source = values["customer_name_source"]
if "customer_name_resolved_at" in payload:
voice_session.customer_name_resolved_at = values["customer_name_resolved_at"]
def _voice_start_metadata_from_start_response(started: dict[str, Any] | None) -> dict[str, Any]:
payload = dict((started or {}).get("metadata") or {})
result = (started or {}).get("start_result")
if isinstance(result, dict):
if result.get("language") and not payload.get("voice_start_language"):
payload["voice_start_language"] = result.get("language")
if "customer_name_status" in result and "customer_name_status" not in payload:
payload["customer_name_status"] = result.get("customer_name_status")
if "customer_name_value" in result and "customer_name_value" not in payload:
payload["customer_name_value"] = result.get("customer_name_value")
if "customer_name_source" in result and "customer_name_source" not in payload:
payload["customer_name_source"] = result.get("customer_name_source")
if result.get("resolved_at") and "customer_name_resolved_at" not in payload:
payload["customer_name_resolved_at"] = result.get("resolved_at")
if result.get("downstream_queue_code") and "downstream_queue_code" not in payload:
payload["downstream_queue_code"] = result.get("downstream_queue_code")
return payload
def _mark_last_assistant_segment_interrupted(session, session_id: str) -> None:
row = session.execute(
select(VoiceTranscriptSegmentRow)
@@ -532,6 +641,7 @@ def _set_voice_session_state(
voice_session.status = ai_state
if handoff_reason is not None:
voice_session.handoff_reason = str(handoff_reason).strip() or None
_apply_voice_start_metadata(voice_session, metadata)
voice_session.updated_at = now
if ai_state in {"completed", "closed"}:
voice_session.ended_at = voice_session.ended_at or now
@@ -562,11 +672,82 @@ def _set_voice_session_state(
)
def _request_runtime_handoff(
def _request_runtime_handoff_payload(
session_id: str,
customer_request_text: str,
decision: VoiceAITurnDecisionOut,
*,
reason: str,
summary: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
) -> None:
session = get_session()
try:
voice_session = _load_voice_session(session, session_id)
if not str(voice_session.interaction_id or "").strip():
raise RuntimeError("Voice AI session is missing interaction_id")
handoff_payload = VoiceAIHandoffRequestIn(
voice_session_id=voice_session.session_id,
ai_session_id=voice_session.ai_session_id,
interaction_id=voice_session.interaction_id,
target_queue_id=voice_session.handoff_target_queue_id,
reason=reason,
summary=summary or {},
metadata=metadata or {},
)
LOGGER.warning(
"voice_runtime.handoff_request call_id=%s session_id=%s ai_session_id=%s interaction_id=%s target_queue_id=%s reason=%s",
voice_session.call_id,
voice_session.session_id,
voice_session.ai_session_id,
voice_session.interaction_id,
voice_session.handoff_target_queue_id,
reason[:300],
)
try:
_bridge_request(
"POST",
f"/internal/voice-ai/calls/{voice_session.call_id}/handoff",
payload=handoff_payload.model_dump(),
timeout=_handoff_timeout_seconds(),
)
except Exception as exc:
LOGGER.warning(
"voice_runtime.handoff_failed call_id=%s session_id=%s ai_session_id=%s error=%s",
voice_session.call_id,
voice_session.session_id,
voice_session.ai_session_id,
str(exc)[:500],
)
raise
finally:
session.close()
def _request_runtime_handoff(
session_id: str,
customer_request_text: str,
decision: VoiceAITurnDecisionOut,
) -> None:
handoff_summary = {
"customer_request_text": customer_request_text,
"ai_outcome_text": decision.summary_text or decision.reply_text or decision.handoff_reason or "",
"recommended_next_step": "Continue the call manually and confirm the collected context.",
}
if decision.metadata:
for key in (
"customer_name_status",
"customer_name_value",
"customer_name_source",
"voice_start_language",
):
value = decision.metadata.get(key)
if value:
handoff_summary[key] = value
return _request_runtime_handoff_payload(
session_id,
reason=decision.handoff_reason or "AI requested human handoff.",
summary=handoff_summary,
metadata=decision.metadata,
)
session = get_session()
try:
voice_session = _load_voice_session(session, session_id)
@@ -578,6 +759,7 @@ def _request_runtime_handoff(
interaction_id=voice_session.interaction_id,
target_queue_id=voice_session.handoff_target_queue_id,
reason=decision.handoff_reason or "AI requested human handoff.",
metadata=decision.metadata,
summary={
"customer_request_text": customer_request_text,
"ai_outcome_text": decision.summary_text or decision.reply_text or decision.handoff_reason or "",
@@ -706,7 +888,8 @@ def _process_voice_ai_turn_sync(
decision = VoiceAITurnDecisionOut.model_validate(decision_payload)
voice_session.language = decision.language or voice_session.language
voice_session.handoff_reason = decision.handoff_reason
voice_session.status = "handoff_requested" if decision.needs_handoff else "active"
_apply_voice_start_metadata(voice_session, decision.metadata)
voice_session.status = decision.status or ("handoff_requested" if decision.needs_handoff else "active")
voice_session.updated_at = utc_now_iso()
if decision.reply_text:
_record_segment(
@@ -721,6 +904,7 @@ def _process_voice_ai_turn_sync(
"kb_refs": decision.kb_refs,
"model": decision.model,
"delivery_status": "planned",
"metadata": decision.metadata,
},
is_final=False,
)
@@ -795,19 +979,55 @@ def _complete_voice_ai_session_start(session_id: str, start_payload: VoiceAIStar
voice_session = _load_voice_session(session, session_id)
now = utc_now_iso()
greeting_text = str(started.get("greeting_text") or "").strip()
handoff_metadata = _voice_start_metadata_from_start_response(started)
needs_handoff = bool(started.get("needs_handoff"))
handoff_reason = str(started.get("handoff_reason") or "").strip() or None
voice_session.ai_session_id = str(started.get("session_id") or voice_session.ai_session_id or "").strip() or None
voice_session.language = _resolved_voice_language(started.get("language"), voice_session.language)
if greeting_text or _load_greeting_segment(session, session_id) is not None:
_apply_voice_start_metadata(voice_session, handoff_metadata)
if greeting_text:
_upsert_greeting_segment(session, voice_session, greeting_text)
if needs_handoff:
voice_session.status = "handoff_requested"
elif greeting_text or _load_greeting_segment(session, session_id) is not None:
voice_session.status = "greeting"
elif not str(voice_session.status or "").strip():
voice_session.status = "active"
voice_session.handoff_reason = None
else:
voice_session.status = "active"
voice_session.handoff_reason = handoff_reason
voice_session.updated_at = now
if greeting_text:
_ensure_greeting_segment(session, voice_session, greeting_text)
session.commit()
session.refresh(voice_session)
finally:
session.close()
_push_bridge_call_state(
voice_session,
ai_state=voice_session.status,
handoff_reason=voice_session.handoff_reason,
metadata=handoff_metadata,
)
if needs_handoff:
handoff_summary = {
"customer_request_text": "voice_start",
"ai_outcome_text": str(started.get("summary_text") or "").strip(),
"recommended_next_step": "Continue the next voice stage using the language and customer name handoff.",
}
for key in (
"customer_name_status",
"customer_name_value",
"customer_name_source",
"voice_start_language",
):
value = handoff_metadata.get(key)
if value:
handoff_summary[key] = value
_request_runtime_handoff_payload(
session_id,
reason=handoff_reason or "voice_start_completed",
summary=handoff_summary,
metadata=handoff_metadata,
)
_MEDIA_RUNTIME = AudioSocketMediaRuntime(
@@ -869,6 +1089,11 @@ def create_voice_ai_session(
payload.language_hint,
voice_session.language if voice_session is not None else None,
)
payload_metadata = payload.metadata if isinstance(payload.metadata, dict) else {}
is_voice_start = (
str(payload.agent_profile or "").strip() == "voice_start"
or str(payload_metadata.get("stage") or "").strip() == "voice_start"
)
if voice_session is None:
voice_session = VoiceAISessionRow(
session_id=new_id("avs"),
@@ -900,6 +1125,10 @@ def create_voice_ai_session(
session.add(voice_session)
should_start_async = True
else:
stage_changed = (
str(voice_session.queue_id or "").strip() != str(payload.queue_id or "").strip()
or str(voice_session.agent_profile or "").strip() != str(payload.agent_profile or "").strip()
)
voice_session.linked_id = payload.linked_id
voice_session.interaction_id = payload.interaction_id
voice_session.queue_id = payload.queue_id
@@ -908,9 +1137,19 @@ def create_voice_ai_session(
voice_session.status = "greeting"
voice_session.handoff_reason = None
voice_session.handoff_target_queue_id = payload.handoff_queue_id or voice_session.handoff_target_queue_id or payload.queue_id
if stage_changed:
voice_session.ai_session_id = None
voice_session.disclosure_played_at = None
voice_session.ended_at = None
should_start_async = stage_changed or not str(voice_session.ai_session_id or "").strip()
voice_session.updated_at = now
should_start_async = not str(voice_session.ai_session_id or "").strip()
_ensure_greeting_segment(session, voice_session, _default_voice_greeting(language))
_apply_voice_start_metadata(voice_session, payload_metadata)
greeting_text = _default_voice_greeting(language, agent_profile=payload.agent_profile)
if not is_voice_start:
if voice_session.ai_session_id is None or should_start_async:
_queue_new_greeting_segment(session, voice_session, greeting_text)
else:
_upsert_greeting_segment(session, voice_session, greeting_text)
session.commit()
start_payload = VoiceAIStartIn(
@@ -920,6 +1159,7 @@ def create_voice_ai_session(
customer_id=None,
language_hint=language,
agent_profile=payload.agent_profile,
metadata=payload_metadata,
)
if should_start_async:
threading.Thread(
+11 -1
View File
@@ -222,13 +222,23 @@ def ai_voice_config_for_queue(queue_code: str | None) -> dict[str, Any] | None:
return None
if str(item.get("mode") or "ai_first").strip().lower() != "ai_first":
return None
handoff_queue_code = str(item.get("handoff_queue_code") or key).strip() or key
stage = str(item.get("stage") or "").strip() or None
next_queue_code = str(item.get("next_queue_code") or "").strip() or None
next_queue_id = str(item.get("next_queue_id") or "").strip() or None
handoff_queue_code = str(item.get("handoff_queue_code") or next_queue_code or key).strip() or key
handoff_queue_id = queue_map().get(handoff_queue_code)
if next_queue_code and not next_queue_id:
next_queue_id = queue_map().get(next_queue_code)
if stage == "voice_start" and not handoff_queue_id:
handoff_queue_id = next_queue_id
return {
"queue_code": key,
"mode": "ai_first",
"agent_profile": str(item.get("agent_profile") or "voice_support").strip() or "voice_support",
"language": str(item.get("language") or "").strip() or None,
"stage": stage,
"next_queue_code": next_queue_code,
"next_queue_id": next_queue_id,
"handoff_queue_code": handoff_queue_code,
"handoff_queue_id": handoff_queue_id,
}
+193 -17
View File
@@ -65,8 +65,18 @@ def start_voice_ai_session(
caller_number: str | None,
caller_name: str | None,
ai_config: dict[str, Any],
extra_metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
bridge = _bridge_app()
metadata = {
"queue_code": queue_code,
"direction": "inbound",
"stage": ai_config.get("stage"),
"next_queue_code": ai_config.get("next_queue_code"),
"next_queue_id": ai_config.get("next_queue_id"),
}
if isinstance(extra_metadata, dict):
metadata.update(extra_metadata)
return bridge._post_json(
f"{bridge._ai_voice_runtime_service_url()}/internal/voice-ai/sessions",
{
@@ -79,10 +89,7 @@ def start_voice_ai_session(
"agent_profile": ai_config.get("agent_profile") or "voice_support",
"language_hint": ai_config.get("language"),
"handoff_queue_id": ai_config.get("handoff_queue_id") or queue_id,
"metadata": {
"queue_code": queue_code,
"direction": "inbound",
},
"metadata": metadata,
},
timeout_seconds=max(3.0, min(bridge._forward_timeout_seconds(), 12.0)),
max_attempts=1,
@@ -254,6 +261,99 @@ def _summary_text_from_payload(summary: dict[str, Any] | None) -> str | None:
return None
def _voice_start_metadata(payload: dict[str, Any] | None) -> dict[str, str | None]:
metadata = payload if isinstance(payload, dict) else {}
return {
"voice_start_language": _truncate(
metadata.get("voice_start_language") or metadata.get("language"),
16,
),
"customer_name_status": _truncate(metadata.get("customer_name_status"), 32),
"customer_name_value": _truncate(metadata.get("customer_name_value"), 256),
"customer_name_source": _truncate(metadata.get("customer_name_source"), 32),
"customer_name_resolved_at": _truncate(metadata.get("customer_name_resolved_at"), 64),
"downstream_queue_code": _truncate(metadata.get("downstream_queue_code"), 64),
}
def _apply_voice_start_metadata(target: Any, metadata: dict[str, Any] | None) -> None:
payload = metadata if isinstance(metadata, dict) else {}
values = _voice_start_metadata(payload)
if hasattr(target, "voice_start_language") and (
"voice_start_language" in payload or "language" in payload
):
target.voice_start_language = values["voice_start_language"]
if hasattr(target, "customer_name_status") and "customer_name_status" in payload:
target.customer_name_status = values["customer_name_status"]
if hasattr(target, "customer_name_value") and "customer_name_value" in payload:
target.customer_name_value = values["customer_name_value"]
if hasattr(target, "customer_name_source") and "customer_name_source" in payload:
target.customer_name_source = values["customer_name_source"]
if hasattr(target, "customer_name_resolved_at") and "customer_name_resolved_at" in payload:
target.customer_name_resolved_at = values["customer_name_resolved_at"]
def _set_channel_variable(channel: str, name: str, value: str | None) -> None:
bridge = _bridge_app()
bridge._ami_action(
"Setvar",
{
"Channel": channel,
"Variable": name,
"Value": str(value or ""),
},
)
def _set_handoff_channel_vars(
channel: str,
*,
queue_code: str,
reuse_existing_call: bool,
metadata: dict[str, Any] | None,
) -> None:
values = _voice_start_metadata(metadata)
_set_channel_variable(channel, "MVPCC_START_LANGUAGE", values["voice_start_language"])
_set_channel_variable(channel, "MVPCC_CUSTOMER_NAME_STATUS", values["customer_name_status"])
_set_channel_variable(channel, "MVPCC_CUSTOMER_NAME_VALUE", values["customer_name_value"])
_set_channel_variable(channel, "MVPCC_CUSTOMER_NAME_SOURCE", values["customer_name_source"])
if reuse_existing_call:
_set_channel_variable(channel, "MVPCC_AI_QUEUE_OVERRIDE", queue_code)
_set_channel_variable(channel, "MVPCC_AI_REUSE_CALL", "1")
else:
_set_channel_variable(channel, "MVPCC_AI_QUEUE_OVERRIDE", "")
_set_channel_variable(channel, "MVPCC_AI_REUSE_CALL", "")
def _start_ai_session_for_reuse_handoff(
*,
link: AsteriskCallLinkRow,
queue_code: str,
metadata: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], str] | None:
bridge = _bridge_app()
if bridge._transfer_target_map().get(queue_code) != "7100":
return None
ai_config = bridge._ai_voice_config_for_queue(queue_code)
if not ai_config:
return None
queue_id = str(bridge._queue_map().get(queue_code) or "").strip()
if not queue_id:
raise HTTPException(status_code=400, detail=f"Unknown AI queue mapping: {queue_code}")
started = start_voice_ai_session(
call_id=link.call_id,
linked_id=link.linked_id,
interaction_id=link.interaction_id,
queue_id=queue_id,
queue_code=queue_code,
caller_number=link.caller_number,
caller_name=link.caller_name,
ai_config=ai_config,
extra_metadata=metadata,
)
return started, queue_id
def request_handoff(call_id: str, body: VoiceAIHandoffRequestIn, actor: dict) -> VoiceLiveCallOut:
bridge = _bridge_app()
assert_trusted_voice_runtime_actor(actor)
@@ -299,6 +399,7 @@ def request_handoff(call_id: str, body: VoiceAIHandoffRequestIn, actor: dict) ->
body.target_queue_id or voice_session.handoff_target_queue_id or link.queue_id,
fallback_queue_code=_queue_code_for_queue_id(link.queue_id),
)
handoff_metadata = body.metadata or {}
actor_user = str(actor.get("user") or actor.get("sub") or "ai-voice-runtime").strip()
actor_role = str(actor.get("role") or "admin").strip() or "admin"
action = bridge._create_action_log(
@@ -315,9 +416,27 @@ def request_handoff(call_id: str, body: VoiceAIHandoffRequestIn, actor: dict) ->
"target_queue_code": queue_code,
"resolved_extension": target_extension,
"reason": body.reason,
"metadata": handoff_metadata,
},
)
channel = _resolve_handoff_channel(session, link)
reuse_started: dict[str, Any] | None = None
reuse_queue_id: str | None = None
if target_extension == "7100":
started = _start_ai_session_for_reuse_handoff(
link=link,
queue_code=queue_code,
metadata=handoff_metadata,
)
if started is None:
raise HTTPException(status_code=409, detail="AI reuse handoff is not configured for target queue")
reuse_started, reuse_queue_id = started
_set_handoff_channel_vars(
channel,
queue_code=queue_code,
reuse_existing_call=target_extension == "7100",
metadata=handoff_metadata,
)
LOGGER.warning(
"bridge.ai_handoff_redirect call_id=%s interaction_id=%s source_channel=%s target_extension=%s target_queue_id=%s",
call_id,
@@ -337,20 +456,44 @@ def request_handoff(call_id: str, body: VoiceAIHandoffRequestIn, actor: dict) ->
)
now = utc_now_iso()
link.voice_session_id = body.voice_session_id
link.ai_session_id = body.ai_session_id or link.ai_session_id or voice_session.ai_session_id
link.ai_state = "handoff_required"
link.voice_session_id = (
str((reuse_started or {}).get("voice_session_id") or body.voice_session_id or link.voice_session_id or "")
or None
)
if target_extension == "7100":
link.ai_session_id = str((reuse_started or {}).get("ai_session_id") or "").strip() or None
else:
link.ai_session_id = (
str((reuse_started or {}).get("ai_session_id") or body.ai_session_id or link.ai_session_id or voice_session.ai_session_id or "")
or None
)
link.ai_state = (
_truncate((reuse_started or {}).get("status"), 32) or "greeting"
if target_extension == "7100"
else "handoff_required"
)
link.ai_handoff_reason = _truncate(body.reason, 4000)
link.ai_last_model_at = now
link.claimed_by_user = None
link.claimed_at = None
link.operator_extension = None
if reuse_queue_id:
link.queue_code = queue_code
link.queue_id = reuse_queue_id
_apply_voice_start_metadata(link, handoff_metadata)
link.updated_at = now
voice_session.ai_session_id = body.ai_session_id or voice_session.ai_session_id
if target_extension == "7100":
voice_session.ai_session_id = str((reuse_started or {}).get("ai_session_id") or "").strip() or None
else:
voice_session.ai_session_id = (
str((reuse_started or {}).get("ai_session_id") or body.ai_session_id or voice_session.ai_session_id or "")
or None
)
voice_session.status = "handoff_requested"
voice_session.handoff_reason = _truncate(body.reason, 4000)
voice_session.handoff_target_queue_id = body.target_queue_id or voice_session.handoff_target_queue_id or link.queue_id
_apply_voice_start_metadata(voice_session, handoff_metadata)
voice_session.updated_at = now
if body.summary:
@@ -398,6 +541,7 @@ def request_handoff(call_id: str, body: VoiceAIHandoffRequestIn, actor: dict) ->
"ai_session_id": body.ai_session_id,
"reason": body.reason,
"target_queue_id": body.target_queue_id or voice_session.handoff_target_queue_id or link.queue_id,
**{key: value for key, value in _voice_start_metadata(handoff_metadata).items() if value},
},
)
except Exception:
@@ -443,6 +587,7 @@ def update_call_ai_state(call_id: str, body: VoiceAICallStateUpdateIn, actor: di
link.ai_state = body.ai_state
if body.handoff_reason is not None:
link.ai_handoff_reason = _truncate(body.handoff_reason, 4000)
_apply_voice_start_metadata(link, body.metadata)
link.ai_last_model_at = now
link.updated_at = now
session.commit()
@@ -561,7 +706,7 @@ def voice_ai_summary_for_call(call_id: str) -> VoiceAISummaryOut | None:
session,
voice_session_id=voice_session.session_id,
speaker="caller",
) or "Последняя реплика клиента недоступна."
) or "Последняя реплика клиента недоступна."
assistant_reply_text = _latest_segment_text(
session,
voice_session_id=voice_session.session_id,
@@ -574,6 +719,27 @@ def voice_ai_summary_for_call(call_id: str) -> VoiceAISummaryOut | None:
or _truncate(link.ai_handoff_reason, 4000)
or ""
)
customer_name_status = (
_truncate(voice_session.customer_name_status, 64)
or _truncate(link.customer_name_status, 64)
or None
)
customer_name_value = (
_truncate(voice_session.customer_name_value, 256)
or _truncate(link.customer_name_value, 256)
or None
)
customer_name_source = (
_truncate(voice_session.customer_name_source, 64)
or _truncate(link.customer_name_source, 64)
or None
)
voice_start_language = (
_truncate(voice_session.voice_start_language, 16)
or _truncate(link.voice_start_language, 16)
or _truncate(voice_session.language, 16)
or None
)
ai_outcome_text = (
assistant_reply_text
or _truncate(ai_session.summary_text if ai_session else None, 4000)
@@ -588,24 +754,34 @@ def voice_ai_summary_for_call(call_id: str) -> VoiceAISummaryOut | None:
or str(voice_session.status or "") in handoff_states
or bool(handoff_reason)
)
unresolved_name = customer_name_status in {"name_not_obtained", "name_followup_required"}
recommended_next_step = (
"Проверьте контекст звонка и продолжайте разговор вручную."
if is_handoff
else "Продолжайте разговор, учитывая уже собранный AI контекст."
)
if is_handoff and unresolved_name:
recommended_next_step = (
"Проверьте контекст звонка, продолжайте разговор вручную и уточните имя клиента."
)
return VoiceAISummaryOut(
call_id=call_id,
session_id=ai_session.session_id if ai_session else None,
voice_session_id=voice_session.session_id,
status_label=(
"AI передал звонок оператору"
"AI передал звонок оператору"
if is_handoff
else "AI ответил клиенту"
else "AI ответил клиенту"
),
status_tone="handoff" if is_handoff else "answered",
customer_name_status=customer_name_status,
customer_name_value=customer_name_value,
customer_name_source=customer_name_source,
voice_start_language=voice_start_language,
customer_request_text=customer_request_text,
ai_outcome_text=ai_outcome_text or "AI обработал обращение без дополнительной сводки.",
ai_outcome_text=ai_outcome_text or "AI обработал обращение без дополнительной сводки.",
handoff_reason=handoff_reason,
recommended_next_step=(
"Проверьте контекст звонка и продолжайте разговор вручную."
if is_handoff
else "Продолжайте разговор, учитывая уже собранный AI контекст."
),
recommended_next_step=recommended_next_step,
generated_at=(
_truncate(link.ai_last_model_at, 64)
or _truncate(voice_session.updated_at, 64)
+64 -1
View File
@@ -10,6 +10,7 @@ from services.shared.core import new_id, utc_now_iso
from services.shared.db import get_session
from services.shared.models import (
CustomerCreate,
CustomerNameUpdateIn,
CustomerHistoryEventOut,
CustomerHistoryOut,
CustomerHistorySummaryOut,
@@ -24,10 +25,12 @@ from services.shared.sql_models import (
AsteriskCallLinkRow,
CallRecordingRow,
Customer,
CustomerExternalIdentity,
Interaction,
InteractionTimeline,
TelegramMessageRow,
TelegramThreadRow,
VoiceAISessionRow,
VoiceTranscriptSegmentRow,
)
@@ -138,7 +141,11 @@ def _to_out(row: Customer) -> CustomerOut:
preferred_phone=row.preferred_phone,
tags=tags,
created_at=row.created_at,
)
)
def _normalized_display_name(value: str) -> str:
return " ".join(str(value or "").split()).strip()
def _interaction_to_out(row: Interaction) -> InteractionOut:
@@ -491,6 +498,62 @@ def search_customers(query: str | None = None, limit: int = 100) -> list[Custome
session.close()
@app.patch("/customers/{customer_id}", response_model=CustomerOut)
def update_customer_name(customer_id: str, payload: CustomerNameUpdateIn) -> CustomerOut:
display_name = _normalized_display_name(payload.display_name)
if len(display_name) < 2:
raise HTTPException(status_code=400, detail="display_name must be at least 2 chars")
session = get_session()
try:
customer = session.execute(
select(Customer).where(Customer.customer_id == customer_id)
).scalar_one_or_none()
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
now = utc_now_iso()
customer.display_name = display_name
identities = session.execute(
select(CustomerExternalIdentity).where(CustomerExternalIdentity.customer_id == customer_id)
).scalars().all()
for identity in identities:
identity.display_name_snapshot = display_name
identity.updated_at = now
interaction_ids = session.execute(
select(Interaction.interaction_id).where(Interaction.customer_id == customer_id)
).scalars().all()
if interaction_ids:
live_calls = session.execute(
select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.interaction_id.in_(interaction_ids))
).scalars().all()
for row in live_calls:
row.caller_name = display_name
row.customer_name_status = "name_obtained"
row.customer_name_value = display_name
row.customer_name_source = "manual"
row.customer_name_resolved_at = now
row.updated_at = now
voice_sessions = session.execute(
select(VoiceAISessionRow).where(VoiceAISessionRow.interaction_id.in_(interaction_ids))
).scalars().all()
for row in voice_sessions:
row.customer_name_status = "name_obtained"
row.customer_name_value = display_name
row.customer_name_source = "manual"
row.customer_name_resolved_at = now
row.updated_at = now
session.commit()
session.refresh(customer)
return _to_out(customer)
finally:
session.close()
@app.get("/customers/{customer_id}/history", response_model=CustomerHistoryOut)
def customer_history(customer_id: str) -> CustomerHistoryOut:
session = get_session()
+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)