From d959c2b2f0c358fe180e1da6ee54166b56131fc9 Mon Sep 17 00:00:00 2001 From: Yera All Date: Sun, 5 Apr 2026 03:26:18 +0500 Subject: [PATCH] Add voice name flow controls and analytics --- gateway/app.py | 2 + .../0021_voice_start_identity_postgres.sql | 29 + .../sql/0021_voice_start_identity_sqlite.sql | 29 + ..._voice_name_collection_config_postgres.sql | 12 + ...22_voice_name_collection_config_sqlite.sql | 12 + scripts/demo_seed.py | 93 +- services/ai_orchestrator_service/app.py | 628 +++++++++ services/ai_orchestrator_service/voice.py | 928 ++++++++++++- .../voice_name_config.py | 168 +++ services/ai_voice_runtime_service/app.py | 262 +++- services/asterisk_bridge_service/config.py | 12 +- services/asterisk_bridge_service/voice_ai.py | 210 ++- services/customer_service/app.py | 65 +- services/shared/models.py | 217 ++- services/shared/sql_init.py | 66 + services/shared/sql_models.py | 19 + tests/test_ai_orchestrator_service.py | 1221 +++++++++++++++++ tests/test_ai_voice_runtime_service.py | 270 +++- tests/test_asterisk_bridge_service.py | 143 +- tests/test_demo_ready_scripts.py | 17 + tests/test_gateway_ui.py | 83 ++ tests/test_operator_core.py | 145 ++ tests/test_voice_start_policy.py | 45 + ui/admin/app.js | 223 ++- ui/admin/index.html | 76 + ui/analyst/app.js | 791 ++++++++++- ui/analyst/index.html | 87 +- ui/analyst/mock-analytics.json | 39 + ui/operator/app.js | 500 ++++++- ui/operator/index.html | 29 +- ui/operator/styles.css | 141 ++ 31 files changed, 6410 insertions(+), 152 deletions(-) create mode 100644 migrations/sql/0021_voice_start_identity_postgres.sql create mode 100644 migrations/sql/0021_voice_start_identity_sqlite.sql create mode 100644 migrations/sql/0022_voice_name_collection_config_postgres.sql create mode 100644 migrations/sql/0022_voice_name_collection_config_sqlite.sql create mode 100644 services/ai_orchestrator_service/voice_name_config.py create mode 100644 tests/test_voice_start_policy.py diff --git a/gateway/app.py b/gateway/app.py index 83c982e..c3e54cb 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -266,6 +266,8 @@ def contracts() -> dict: "/ai/whatsapp/threads/*", "/ai/analytics/overview", "/ai/analytics/timeseries", + "/ai/analytics/voice-name-flow/overview", + "/ai/analytics/voice-name-flow/timeseries", "/ai/analytics/drilldown", "/ai/analytics/sessions/*", ], diff --git a/migrations/sql/0021_voice_start_identity_postgres.sql b/migrations/sql/0021_voice_start_identity_postgres.sql new file mode 100644 index 0000000..b55cf3d --- /dev/null +++ b/migrations/sql/0021_voice_start_identity_postgres.sql @@ -0,0 +1,29 @@ +ALTER TABLE asterisk_call_links ADD COLUMN IF NOT EXISTS voice_start_language TEXT NULL; +ALTER TABLE asterisk_call_links ADD COLUMN IF NOT EXISTS customer_name_status TEXT NULL; +ALTER TABLE asterisk_call_links ADD COLUMN IF NOT EXISTS customer_name_value TEXT NULL; +ALTER TABLE asterisk_call_links ADD COLUMN IF NOT EXISTS customer_name_source TEXT NULL; +ALTER TABLE asterisk_call_links ADD COLUMN IF NOT EXISTS customer_name_resolved_at TEXT NULL; + +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_voice_start_language +ON asterisk_call_links(voice_start_language); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_customer_name_status +ON asterisk_call_links(customer_name_status); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_customer_name_source +ON asterisk_call_links(customer_name_source); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_customer_name_resolved_at +ON asterisk_call_links(customer_name_resolved_at); + +ALTER TABLE voice_ai_sessions ADD COLUMN IF NOT EXISTS voice_start_language TEXT NULL; +ALTER TABLE voice_ai_sessions ADD COLUMN IF NOT EXISTS customer_name_status TEXT NULL; +ALTER TABLE voice_ai_sessions ADD COLUMN IF NOT EXISTS customer_name_value TEXT NULL; +ALTER TABLE voice_ai_sessions ADD COLUMN IF NOT EXISTS customer_name_source TEXT NULL; +ALTER TABLE voice_ai_sessions ADD COLUMN IF NOT EXISTS customer_name_resolved_at TEXT NULL; + +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_voice_start_language +ON voice_ai_sessions(voice_start_language); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_customer_name_status +ON voice_ai_sessions(customer_name_status); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_customer_name_source +ON voice_ai_sessions(customer_name_source); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_customer_name_resolved_at +ON voice_ai_sessions(customer_name_resolved_at); diff --git a/migrations/sql/0021_voice_start_identity_sqlite.sql b/migrations/sql/0021_voice_start_identity_sqlite.sql new file mode 100644 index 0000000..5aa6448 --- /dev/null +++ b/migrations/sql/0021_voice_start_identity_sqlite.sql @@ -0,0 +1,29 @@ +ALTER TABLE asterisk_call_links ADD COLUMN voice_start_language TEXT NULL; +ALTER TABLE asterisk_call_links ADD COLUMN customer_name_status TEXT NULL; +ALTER TABLE asterisk_call_links ADD COLUMN customer_name_value TEXT NULL; +ALTER TABLE asterisk_call_links ADD COLUMN customer_name_source TEXT NULL; +ALTER TABLE asterisk_call_links ADD COLUMN customer_name_resolved_at TEXT NULL; + +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_voice_start_language +ON asterisk_call_links(voice_start_language); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_customer_name_status +ON asterisk_call_links(customer_name_status); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_customer_name_source +ON asterisk_call_links(customer_name_source); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_customer_name_resolved_at +ON asterisk_call_links(customer_name_resolved_at); + +ALTER TABLE voice_ai_sessions ADD COLUMN voice_start_language TEXT NULL; +ALTER TABLE voice_ai_sessions ADD COLUMN customer_name_status TEXT NULL; +ALTER TABLE voice_ai_sessions ADD COLUMN customer_name_value TEXT NULL; +ALTER TABLE voice_ai_sessions ADD COLUMN customer_name_source TEXT NULL; +ALTER TABLE voice_ai_sessions ADD COLUMN customer_name_resolved_at TEXT NULL; + +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_voice_start_language +ON voice_ai_sessions(voice_start_language); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_customer_name_status +ON voice_ai_sessions(customer_name_status); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_customer_name_source +ON voice_ai_sessions(customer_name_source); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_customer_name_resolved_at +ON voice_ai_sessions(customer_name_resolved_at); diff --git a/migrations/sql/0022_voice_name_collection_config_postgres.sql b/migrations/sql/0022_voice_name_collection_config_postgres.sql new file mode 100644 index 0000000..9b41268 --- /dev/null +++ b/migrations/sql/0022_voice_name_collection_config_postgres.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS voice_name_collection_settings ( + id SERIAL PRIMARY KEY, + settings_key VARCHAR(64) NOT NULL UNIQUE, + config_json TEXT NOT NULL DEFAULT '{}', + updated_at VARCHAR(64) NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_voice_name_collection_settings_key +ON voice_name_collection_settings(settings_key); + +CREATE INDEX IF NOT EXISTS idx_voice_name_collection_settings_updated_at +ON voice_name_collection_settings(updated_at); diff --git a/migrations/sql/0022_voice_name_collection_config_sqlite.sql b/migrations/sql/0022_voice_name_collection_config_sqlite.sql new file mode 100644 index 0000000..7590258 --- /dev/null +++ b/migrations/sql/0022_voice_name_collection_config_sqlite.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS voice_name_collection_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + settings_key TEXT NOT NULL UNIQUE, + config_json TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_voice_name_collection_settings_key +ON voice_name_collection_settings(settings_key); + +CREATE INDEX IF NOT EXISTS idx_voice_name_collection_settings_updated_at +ON voice_name_collection_settings(updated_at); diff --git a/scripts/demo_seed.py b/scripts/demo_seed.py index 1622e44..d8c492d 100644 --- a/scripts/demo_seed.py +++ b/scripts/demo_seed.py @@ -49,7 +49,12 @@ def build_seed_plan(tag: str) -> dict[str, Any]: } -def _demo_ivr_flow_document(plan: dict[str, Any], *, sales_queue_id: str, support_queue_id: str) -> dict[str, Any]: +def _demo_ivr_flow_document( + plan: dict[str, Any], + *, + voice_start_kz_queue_id: str, + voice_start_ru_queue_id: str, +) -> dict[str, Any]: return { "nodes": [ { @@ -71,72 +76,26 @@ def _demo_ivr_flow_document(plan: dict[str, Any], *, sales_queue_id: str, suppor "invalid_target_node_id": "root", "no_input_target_node_id": "root", "options": [ - {"digit": "1", "target_node_id": "menu_kz"}, - {"digit": "2", "target_node_id": "menu_ru"}, + {"digit": "1", "target_node_id": "voice_start_kz"}, + {"digit": "2", "target_node_id": "voice_start_ru"}, ], }, { - "node_id": "menu_ru", - "prompt_text": plan["ivr_ru_menu_prompt"], - "prompt_audio_key": "ivr/demo-menu-ru", - "is_terminal": False, - "invalid_target_node_id": "menu_ru", - "no_input_target_node_id": "menu_ru", - "options": [ - {"digit": "1", "target_node_id": "sales_ru"}, - {"digit": "2", "target_node_id": "support_ru"}, - ], - }, - { - "node_id": "menu_kz", - "prompt_text": plan["ivr_kz_menu_prompt"], - "prompt_audio_key": "ivr/demo-menu-kz", - "is_terminal": False, - "invalid_target_node_id": "menu_kz", - "no_input_target_node_id": "menu_kz", - "options": [ - {"digit": "1", "target_node_id": "sales_kz"}, - {"digit": "2", "target_node_id": "support_kz"}, - ], - }, - { - "node_id": "sales_ru", - "prompt_text": "Переводим в отдел продаж.", - "prompt_audio_key": "ivr/demo-sales-ru", + "node_id": "voice_start_ru", + "prompt_text": "Передаем звонок в русскоязычный стартовый voice-сценарий.", "is_terminal": True, - "outcome_code": "sales_route_ru", - "resolved_queue_id": sales_queue_id, - "resolved_queue_code": "ivr_sales_ai_ru", + "outcome_code": "voice_start_ru", + "resolved_queue_id": voice_start_ru_queue_id, + "resolved_queue_code": "voice_start_ru", "options": [], }, { - "node_id": "support_ru", - "prompt_text": "Переводим в службу поддержки.", - "prompt_audio_key": "ivr/demo-support-ru", + "node_id": "voice_start_kz", + "prompt_text": "Қоңырауды қазақ тіліндегі бастапқы voice-сценарийге өткізіп жатырмыз.", "is_terminal": True, - "outcome_code": "support_route_ru", - "resolved_queue_id": support_queue_id, - "resolved_queue_code": "ivr_support_ai_ru", - "options": [], - }, - { - "node_id": "sales_kz", - "prompt_text": "Сату бөліміне қосып жатырмыз.", - "prompt_audio_key": "ivr/demo-sales-kz", - "is_terminal": True, - "outcome_code": "sales_route_kz", - "resolved_queue_id": sales_queue_id, - "resolved_queue_code": "ivr_sales_ai_kz", - "options": [], - }, - { - "node_id": "support_kz", - "prompt_text": "Қолдау қызметіне қосып жатырмыз.", - "prompt_audio_key": "ivr/demo-support-kz", - "is_terminal": True, - "outcome_code": "support_route_kz", - "resolved_queue_id": support_queue_id, - "resolved_queue_code": "ivr_support_ai_kz", + "outcome_code": "voice_start_kz", + "resolved_queue_id": voice_start_kz_queue_id, + "resolved_queue_code": "voice_start_kz", "options": [], }, ] @@ -216,28 +175,28 @@ def seed_demo(base_url: str) -> dict[str, Any]: "/proxy/routing/queues", headers=admin, json={ - "name": "IVR Sales Queue", - "description": "Prepared automatically for IVR routing demo", + "name": "Voice Start KZ Queue", + "description": "Prepared automatically for voice-start routing demo", "rules": [ {"channel": "voice", "priority": 3, "strategy": "round_robin", "sla_seconds": 25}, ], }, ) - _raise_for_status(sales_queue, "sales queue create") + _raise_for_status(sales_queue, "voice_start_kz queue create") sales_queue_id = sales_queue.json()["queue_id"] support_queue = client.post( "/proxy/routing/queues", headers=admin, json={ - "name": "IVR Support Queue", - "description": "Prepared automatically for IVR routing demo", + "name": "Voice Start RU Queue", + "description": "Prepared automatically for voice-start routing demo", "rules": [ {"channel": "voice", "priority": 3, "strategy": "round_robin", "sla_seconds": 35}, ], }, ) - _raise_for_status(support_queue, "support queue create") + _raise_for_status(support_queue, "voice_start_ru queue create") support_queue_id = support_queue.json()["queue_id"] customer = client.post( @@ -351,8 +310,8 @@ def seed_demo(base_url: str) -> dict[str, Any]: "entry_node_id": "root", "flow_json": _demo_ivr_flow_document( plan, - sales_queue_id=sales_queue_id, - support_queue_id=support_queue_id, + voice_start_kz_queue_id=sales_queue_id, + voice_start_ru_queue_id=support_queue_id, ), "is_active": True, }, diff --git a/services/ai_orchestrator_service/app.py b/services/ai_orchestrator_service/app.py index e8c8c80..891669e 100644 --- a/services/ai_orchestrator_service/app.py +++ b/services/ai_orchestrator_service/app.py @@ -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, diff --git a/services/ai_orchestrator_service/voice.py b/services/ai_orchestrator_service/voice.py index 6aaba8d..571d87e 100644 --- a/services/ai_orchestrator_service/voice.py +++ b/services/ai_orchestrator_service/voice.py @@ -10,7 +10,7 @@ from sqlalchemy import select from services.shared.core import new_id, utc_now_iso from services.shared.db import get_session -from services.shared.models import VoiceAIStartIn, VoiceAIStartOut, VoiceAITurnDecisionOut, VoiceAITurnIn +from services.shared.models import VoiceAIStartIn, VoiceAIStartOut, VoiceAITurnDecisionOut, VoiceAITurnIn, VoiceStartResult from services.shared.sql_models import ( AISessionRow, AITurnRow, @@ -22,6 +22,14 @@ from services.shared.sql_models import ( VoiceAISessionRow, VoiceTranscriptSegmentRow, ) +from services.ai_orchestrator_service.voice_name_config import ( + load_effective_voice_name_collection_config, + voice_name_collection_confirmation_greeting, + voice_name_collection_followup_markers, + voice_name_collection_inline_followup, + voice_name_collection_personalized_greeting, + voice_name_collection_start_prompt, +) def _app(): @@ -179,6 +187,563 @@ def _resolve_or_create_voice_customer_id( return customer.customer_id +def _voice_start_stage(agent_profile: str | None, metadata: dict[str, Any] | None) -> bool: + payload = metadata if isinstance(metadata, dict) else {} + if str(agent_profile or "").strip() == "voice_start": + return True + return str(payload.get("stage") or "").strip() == "voice_start" + + +def _voice_start_language(language_hint: str | None, metadata: dict[str, Any] | None, fallback: str | None = None) -> str: + payload = metadata if isinstance(metadata, dict) else {} + return ( + str(payload.get("voice_start_language") or language_hint or fallback or "ru").strip() + or "ru" + ) + + +def _voice_start_name_prompt(language: str, config) -> str: + return voice_name_collection_start_prompt(config, language) + + +def _voice_start_downstream_queue(metadata: dict[str, Any] | None, voice_session: VoiceAISessionRow) -> tuple[str | None, str | None]: + payload = metadata if isinstance(metadata, dict) else {} + queue_code = str(payload.get("downstream_queue_code") or payload.get("next_queue_code") or "").strip() or None + queue_id = str(payload.get("downstream_queue_id") or payload.get("next_queue_id") or voice_session.handoff_target_queue_id or "").strip() or None + return queue_code, queue_id + + +def _display_name_looks_trusted( + customer: Customer | None, + caller_number: str | None, + caller_name: str | None, +) -> bool: + display_name = str(getattr(customer, "display_name", "") or "").strip() + if not display_name: + return False + normalized_display = _voice_text_key(display_name) + normalized_number = _voice_text_key(_normalize_phone(caller_number) or caller_number) + normalized_caller_name = _voice_text_key(caller_name) + if not normalized_display: + return False + if normalized_display == normalized_number or normalized_display == normalized_caller_name: + return False + if re.fullmatch(r"\+?\d[\d\s\-()]{4,}", display_name): + return False + placeholder_markers = { + "caller", + "client", + "customer", + "unknown", + "anonymous", + "\u043a\u043b\u0438\u0435\u043d\u0442", + "\u043d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u044b\u0439", + "\u0430\u0431\u043e\u043d\u0435\u043d\u0442", + } + tokens = [token for token in re.findall(r"[^\W\d_]+", normalized_display, flags=re.UNICODE) if token] + if not tokens: + return False + return not all(token in placeholder_markers for token in tokens) + + +def _canonical_name(text: str) -> str: + words = [part for part in re.split(r"\s+", text.strip()) if part] + return " ".join(word[:1].upper() + word[1:].lower() if len(word) > 1 else word.upper() for word in words) + + +def _normalize_name_candidate(text: str | None) -> str | None: + raw = str(text or "").strip(" \t\r\n,.;:!?\"'()[]{}") + if not raw or any(ch.isdigit() for ch in raw): + return None + words = re.findall(r"[^\W\d_]+(?:[-'][^\W\d_]+)*", raw, flags=re.UNICODE) + if not words or len(words) > 4: + return None + lowered = [_voice_text_key(word) for word in words] + stop_words = { + "\u043c\u0435\u043d\u044f", + "\u0437\u043e\u0432\u0443\u0442", + "\u044d\u0442\u043e", + "\u044f", + "\u043c\u043e\u0435", + "\u0438\u043c\u044f", + "\u043c\u0435\u043d\u0456\u04a3", + "\u0430\u0442\u044b\u043c", + "\u0430\u0442\u044b\u043c\u044b", + "\u0430\u0442\u044b\u043c", + "\u0431\u043e\u043b\u0430\u0434\u044b", + } + filtered = [word for word, lowered_word in zip(words, lowered) if lowered_word not in stop_words] + if not filtered: + return None + invalid_tokens = { + "\u0434\u0430", + "\u043d\u0435\u0442", + "\u0430\u043b\u043b\u043e", + "\u043f\u0440\u0438\u0432\u0435\u0442", + "\u0445\u043e\u0447\u0443", + "\u0443\u0437\u043d\u0430\u0442\u044c", + "\u043f\u043e\u043c\u043e\u0449\u044c", + "\u0432\u043e\u043f\u0440\u043e\u0441", + "\u0442\u0430\u0440\u0438\u0444", + "\u0441\u0442\u0430\u0442\u0443\u0441", + "\u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440", + "\u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0430", + "\u043a\u0435\u0440\u0435\u043a", + "\u0441\u04b1\u0440\u0430\u049b", + "\u043a\u04e9\u043c\u0435\u043a", + "\u0442\u0430\u0440\u0438\u0444", + } + if any(_voice_text_key(word) in invalid_tokens for word in filtered): + return None + return _canonical_name(" ".join(filtered)) + + +def _name_followup_needed(text: str) -> bool: + normalized = _voice_text_key(text) + request_markers = ( + "\u0445\u043e\u0442\u0435\u043b", + "\u043d\u0443\u0436\u043d", + "\u043f\u043e\u043c\u043e\u0433", + "\u0432\u043e\u043f\u0440\u043e\u0441", + "\u043f\u0440\u043e\u0431\u043b\u0435\u043c", + "\u0442\u0430\u0440\u0438\u0444", + "\u0441\u0442\u0430\u0442\u0443\u0441", + "\u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440", + "\u043a\u0430\u0436\u0435\u0442\u0441\u044f", + "\u0431\u043e\u043b\u0430\u0434\u044b", + "\u043a\u0435\u0440\u0435\u043a", + "\u0441\u04b1\u0440\u0430\u0493", + "\u043a\u04e9\u043c\u0435\u043a", + "\u0442\u0430\u0440\u0438\u0444", + "\u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440", + ) + return any(marker in normalized for marker in request_markers) + + +def _extract_name_candidate(text: str | None, language: str) -> tuple[str | None, bool]: + raw = str(text or "").strip() + if not raw: + return None, False + normalized = _voice_text_key(raw) + explicit_patterns = [ + r"(?:\u043c\u0435\u043d\u044f\s+\u0437\u043e\u0432\u0443\u0442|my name is|i am|this is)\s+(.+)", + r"(?:\u044f|it's me)\s+(.+)", + r"(?:\u043c\u0435\u043d\u0456\u04a3\s+\u0430\u0442\u044b\u043c|mening atym|aty\u043c)\s+(.+)", + r"(?:\u043c\u0435\u043d)\s+(.+)", + ] + for pattern in explicit_patterns: + match = re.search(pattern, normalized, flags=re.IGNORECASE) + if not match: + continue + tail = match.group(1).strip() + tail_words = re.findall(r"[^\W\d_]+(?:[-'][^\W\d_]+)*", tail, flags=re.UNICODE) + cutoff_tokens = { + "\u0445\u043e\u0442\u0435\u043b", + "\u0445\u043e\u0447\u0443", + "\u043d\u0443\u0436\u043d\u043e", + "\u0442\u0430\u0440\u0438\u0444", + "\u0441\u0442\u0430\u0442\u0443\u0441", + "\u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440", + "\u0432\u043e\u043f\u0440\u043e\u0441", + "\u043a\u0435\u0440\u0435\u043a", + "\u0441\u04b1\u0440\u0430\u0493", + "\u043a\u04e9\u043c\u0435\u043a", + } + candidate_words: list[str] = [] + for word in tail_words: + if _voice_text_key(word) in cutoff_tokens: + break + candidate_words.append(word) + if len(candidate_words) >= 3: + break + candidate = _normalize_name_candidate(" ".join(candidate_words)) or _normalize_name_candidate(tail) + if candidate: + return candidate, _name_followup_needed(raw) + + candidate = _normalize_name_candidate(raw) + if candidate: + word_count = len(re.findall(r"[^\W\d_]+(?:[-'][^\W\d_]+)*", raw, flags=re.UNICODE)) + if word_count <= 3 and not _name_followup_needed(raw): + return candidate, False + return candidate, True + return None, False + + +def _voice_start_name_outcome(text: str | None, language: str) -> tuple[str, str | None, str]: + raw = str(text or "").strip() + if not raw or _voice_is_low_signal_caller_text(raw): + return "name_not_obtained", None, "none" + candidate, needs_followup = _extract_name_candidate(raw, language) + if candidate and not needs_followup: + return "name_obtained", candidate, "voice_start" + if candidate: + return "name_followup_required", candidate, "voice_start" + if _name_followup_needed(raw): + return "name_followup_required", None, "none" + return "name_not_obtained", None, "none" + + +def _voice_start_result_metadata(result: VoiceStartResult) -> dict[str, Any]: + return { + "voice_start_language": result.language, + "customer_name_status": result.customer_name_status, + "customer_name_value": result.customer_name_value, + "customer_name_source": result.customer_name_source, + "customer_name_resolved_at": result.resolved_at, + "downstream_queue_code": result.downstream_queue_code, + "downstream_queue_id": result.downstream_queue_id, + "customer_id": result.customer_id, + } + + +def _persist_voice_start_result( + session, + *, + voice_session: VoiceAISessionRow, + interaction: Interaction, + result: VoiceStartResult, +) -> None: + now = result.resolved_at or utc_now_iso() + voice_session.voice_start_language = result.language + voice_session.customer_name_status = result.customer_name_status + voice_session.customer_name_value = result.customer_name_value + voice_session.customer_name_source = result.customer_name_source + voice_session.customer_name_resolved_at = now + voice_session.updated_at = now + link = session.execute( + select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == voice_session.call_id) + ).scalar_one_or_none() + if link is not None: + link.voice_start_language = result.language + link.customer_name_status = result.customer_name_status + link.customer_name_value = result.customer_name_value + link.customer_name_source = result.customer_name_source + link.customer_name_resolved_at = now + link.updated_at = now + _append_timeline( + interaction.interaction_id, + "voice.start.completed", + { + "call_id": voice_session.call_id, + "voice_session_id": voice_session.session_id, + "ai_session_id": voice_session.ai_session_id, + "language": result.language, + "customer_id": result.customer_id, + "customer_name_status": result.customer_name_status, + "customer_name_value": result.customer_name_value, + "customer_name_source": result.customer_name_source, + "downstream_queue_code": result.downstream_queue_code, + "downstream_queue_id": result.downstream_queue_id, + }, + ) + + +def _voice_short_name(name: str | None) -> str | None: + canonical = _normalize_name_candidate(name) + if not canonical: + raw = str(name or "").strip() + if not raw: + return None + canonical = _canonical_name(raw) + return canonical.split(" ", 1)[0].strip() or None + + +def _voice_personalized_greeting(language: str, name: str | None, config) -> str: + short_name = _voice_short_name(name) + if not short_name: + return _voice_greeting(language) + return voice_name_collection_personalized_greeting(config, language, short_name) + + +def _voice_name_confirmation_greeting(language: str, name: str | None, config) -> str: + short_name = _voice_short_name(name) + if not short_name: + return _voice_greeting(language) + return voice_name_collection_confirmation_greeting(config, language, short_name) + + +def _voice_inline_name_followup(language: str, config) -> str: + return voice_name_collection_inline_followup(config, language) + + +def _voice_name_followup_asked(transcript_window: list[VoiceTranscriptSegmentRow], language: str, config) -> bool: + markers = tuple(_voice_text_key(marker) for marker in voice_name_collection_followup_markers(config, language)) + for segment in transcript_window: + if segment.speaker != "assistant": + continue + normalized = _voice_text_key(segment.text) + if any(marker in normalized for marker in markers): + return True + return False + + +def _voice_explicit_name_candidate(text: str | None) -> str | None: + raw = str(text or "").strip() + if not raw: + return None + normalized = _voice_text_key(raw) + patterns = ( + r"(?:меня зовут|мое имя|это|my name is|i am|this is)\s+(.+)", + r"(?:менің атым|аты[мң]?|mening atym)\s+(.+)", + ) + for pattern in patterns: + match = re.search(pattern, normalized, flags=re.IGNORECASE) + if not match: + continue + tail = match.group(1).strip() + candidate = _normalize_name_candidate(tail) + if candidate: + return candidate + return None + + +def _voice_is_name_confirmation(text: str | None, current_name: str | None) -> bool: + normalized = _voice_text_key(text) + if not normalized or not current_name: + return False + yes_markers = { + "да", + "ага", + "верно", + "правильно", + "именно", + "точно", + "иә", + "ия", + "дурыс", + "durys", + "yes", + "correct", + "right", + } + if normalized in yes_markers: + return True + current_keys = { + _voice_text_key(current_name), + _voice_text_key(_voice_short_name(current_name) or ""), + } + if normalized in current_keys: + return True + normalized_tokens = {token for token in normalized.split(" ") if token} + if normalized_tokens & yes_markers and normalized_tokens & {key for key in current_keys if key}: + return True + return any(marker in normalized for marker in ("да это", "верно это", "ия бұл", "дұрыс")) + + +def _voice_has_name_correction(text: str | None) -> bool: + normalized = _voice_text_key(text) + correction_markers = ( + "нет", + "неа", + "не так", + "ошибка", + "не правильно", + "жок", + "жоқ", + "меня зовут", + "мое имя", + "менің атым", + "аты", + ) + return any(marker in normalized for marker in correction_markers) + + +def _voice_reply_with_name(language: str, reply_text: str, name: str | None) -> str: + short_name = _voice_short_name(name) + if not short_name: + return reply_text + prefix = _voice_disclosure_prefix(language) + normalized_short = _voice_text_key(short_name) + if reply_text.startswith(prefix): + rest = reply_text[len(prefix) :].lstrip() + if _voice_text_key(rest).startswith(normalized_short): + return reply_text + return f"{prefix}{short_name}, {rest}" + if _voice_text_key(reply_text).startswith(normalized_short): + return reply_text + return f"{short_name}, {reply_text}" + + +def _voice_name_metadata( + *, + language: str, + customer_id: str | None, + status: str, + value: str | None, + source: str, + resolved_at: str | None, +) -> dict[str, Any]: + return { + "voice_start_language": language, + "customer_id": customer_id, + "customer_name_status": status, + "customer_name_value": value, + "customer_name_source": source, + "customer_name_resolved_at": resolved_at, + } + + +def _persist_voice_name_state( + session, + *, + voice_session: VoiceAISessionRow, + status: str, + value: str | None, + source: str, + resolved_at: str | None, +) -> None: + voice_session.customer_name_status = status + voice_session.customer_name_value = value + voice_session.customer_name_source = source + voice_session.customer_name_resolved_at = resolved_at + link = session.execute( + select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == voice_session.call_id) + ).scalar_one_or_none() + if link is not None: + link.customer_name_status = status + link.customer_name_value = value + link.customer_name_source = source + link.customer_name_resolved_at = resolved_at + link.updated_at = utc_now_iso() + + +def _finalize_customer_name( + session, + *, + customer: Customer | None, + customer_id: str | None, + call_id: str, + final_name: str | None, + resolved_at: str, +) -> str | None: + canonical_name = _normalize_name_candidate(final_name) + if not canonical_name: + return None + if customer is None and customer_id: + customer = session.execute( + select(Customer).where(Customer.customer_id == customer_id) + ).scalar_one_or_none() + if customer is not None: + customer.display_name = canonical_name + identities = session.execute( + select(CustomerExternalIdentity).where( + CustomerExternalIdentity.customer_id == (customer.customer_id if customer else customer_id), + CustomerExternalIdentity.channel == "voice", + ) + ).scalars().all() + for identity in identities: + identity.display_name_snapshot = canonical_name + identity.updated_at = resolved_at + caller_number, _ = _resolve_caller_from_call(session, call_id) + if customer is not None and caller_number: + _ensure_voice_identity( + session, + customer_id=customer.customer_id, + caller_number=caller_number, + caller_name=canonical_name, + now=resolved_at, + ) + return canonical_name + + +def _voice_downstream_name_update( + *, + language: str, + transcript_text: str, + transcript_window: list[VoiceTranscriptSegmentRow], + current_status: str, + current_name: str | None, + current_source: str, + current_resolved_at: str | None, + now: str, + config, +) -> dict[str, Any]: + status = str(current_status or "name_not_obtained").strip() or "name_not_obtained" + name_value = _normalize_name_candidate(current_name) or (str(current_name or "").strip() or None) + source = str(current_source or "none").strip() or "none" + resolved_at = str(current_resolved_at or "").strip() or None + action = "ignored" + + explicit_candidate = _voice_explicit_name_candidate(transcript_text) + candidate, needs_followup = _extract_name_candidate(transcript_text, language) + candidate = explicit_candidate or candidate + candidate_key = _voice_text_key(candidate) + current_key = _voice_text_key(name_value) + uncertain_behavior = config.downstream.uncertain_name_behavior + + if status == "name_followup_required" and uncertain_behavior == "discard_and_collect": + status = "name_not_obtained" + name_value = None + source = "none" + resolved_at = None + + if status == "name_followup_required": + if uncertain_behavior == "finalize_immediately" and name_value: + action = "finalize_existing" + candidate = name_value + elif name_value and _voice_is_name_confirmation(transcript_text, name_value): + action = "confirm" + candidate = name_value + elif candidate and name_value and candidate_key and candidate_key != current_key: + action = "correct" + elif candidate: + action = "provide" if not name_value else "confirm" + candidate = candidate or name_value + elif status == "name_obtained": + if candidate and candidate_key and candidate_key != current_key and ( + _voice_has_name_correction(transcript_text) or explicit_candidate is not None + ): + action = "correct" + else: + if explicit_candidate: + action = "provide" + elif candidate and not needs_followup: + action = "provide" + + finalizable = False + if action == "confirm": + finalizable = config.downstream.finalize_on_confirmation and bool(candidate or name_value) + elif action in {"correct", "provide"}: + finalizable = config.downstream.finalize_on_explicit_name and bool(candidate or name_value) + elif action == "finalize_existing": + finalizable = bool(name_value) + + if finalizable: + status = "name_obtained" + name_value = _normalize_name_candidate(candidate or name_value) + if action == "finalize_existing": + source = str(current_source or "none").strip() or "none" + else: + source = current_source if action == "confirm" and current_status == "name_obtained" else "voice_followup" + resolved_at = now + elif action in {"confirm", "correct", "provide"} and bool(candidate or name_value): + status = "name_followup_required" + name_value = _normalize_name_candidate(candidate or name_value) + if action == "confirm": + source = str(current_source or "none").strip() or "none" + resolved_at = resolved_at or now + else: + source = "voice_followup" + resolved_at = now + + inline_followup = ( + config.downstream.missing_name_behavior == "ask_inline_once" + and status == "name_not_obtained" + and action == "ignored" + and not _voice_name_followup_asked(transcript_window, language, config) + and not _voice_is_low_signal_caller_text(transcript_text) + ) + return { + "status": status, + "value": name_value, + "source": source, + "resolved_at": resolved_at, + "action": action, + "finalizable": finalizable, + "inline_followup": inline_followup, + } + + def _voice_recent_segments( session, *, @@ -430,7 +995,11 @@ def _ensure_voice_ai_session( existing = session.execute( select(AISessionRow).where(AISessionRow.session_id == voice_session.ai_session_id) ).scalar_one_or_none() - if existing and existing.status not in {"closed", "error"}: + if ( + existing + and existing.status not in {"closed", "error"} + and str(existing.agent_profile or "").strip() == str(agent_profile or "").strip() + ): existing.call_id = voice_session.call_id existing.interaction_id = interaction.interaction_id existing.customer_id = customer_id @@ -438,6 +1007,11 @@ def _ensure_voice_ai_session( existing.agent_profile = agent_profile existing.updated_at = utc_now_iso() return existing, False + if existing and existing.status not in {"closed", "error"}: + now = utc_now_iso() + existing.status = "closed" + existing.closed_at = now + existing.updated_at = now now = utc_now_iso() ai_session = AISessionRow( @@ -649,7 +1223,13 @@ def start_voice_session(session_id: str, payload: VoiceAIStartIn) -> VoiceAIStar if interaction is None: raise HTTPException(status_code=404, detail="Interaction not found") - language = str(payload.language_hint or voice_session.language or "ru").strip() or "ru" + metadata = payload.metadata if isinstance(payload.metadata, dict) else {} + language = _voice_start_language( + payload.language_hint, + metadata, + voice_session.voice_start_language or voice_session.language, + ) + config = load_effective_voice_name_collection_config(session) customer_id = payload.customer_id or _resolve_or_create_voice_customer_id( session, interaction=interaction, @@ -669,12 +1249,138 @@ def start_voice_session(session_id: str, payload: VoiceAIStartIn) -> VoiceAIStar agent_profile=payload.agent_profile or voice_session.agent_profile, ) now = utc_now_iso() + voice_session.customer_id = customer_id voice_session.language = language voice_session.status = "greeting" voice_session.updated_at = now if customer and not interaction.customer_id: interaction.customer_id = customer.customer_id interaction.updated_at = now + if _voice_start_stage(payload.agent_profile or voice_session.agent_profile, metadata): + caller_number, caller_name = _resolve_caller_from_call(session, payload.call_id) + downstream_queue_code, downstream_queue_id = _voice_start_downstream_queue(metadata, voice_session) + trusted_name = customer.display_name if _display_name_looks_trusted(customer, caller_number, caller_name) else None + + def _complete_voice_start_handoff( + *, + status: str, + value: str | None, + source: str, + summary_text: str, + ) -> VoiceAIStartOut: + result = VoiceStartResult( + language=language, + customer_id=customer_id, + customer_name_status=status, + customer_name_value=value, + customer_name_source=source, + downstream_queue_id=downstream_queue_id, + downstream_queue_code=downstream_queue_code, + resolved_at=now, + ) + _persist_voice_start_result( + session, + voice_session=voice_session, + interaction=interaction, + result=result, + ) + voice_session.status = "handoff_requested" + ai_session.status = "handoff_required" + ai_session.summary_text = summary_text + ai_session.handoff_reason = "voice_start_completed" + ai_session.updated_at = now + session.commit() + if created: + _append_timeline( + interaction.interaction_id, + "ai.session_started", + { + "call_id": payload.call_id, + "voice_session_id": voice_session.session_id, + "ai_session_id": ai_session.session_id, + "language": language, + }, + ) + response_metadata = _voice_start_result_metadata(result) + return VoiceAIStartOut( + session_id=ai_session.session_id, + language=language, + greeting_text="", + disclosure_required=False, + needs_handoff=True, + handoff_reason="voice_start_completed", + summary_text=summary_text, + start_result=result, + metadata=response_metadata, + ) + + if not config.enabled: + return _complete_voice_start_handoff( + status="name_not_obtained", + value=None, + source="none", + summary_text="Voice start name collection is disabled and handed off without a name.", + ) + + if trusted_name and config.start.known_customer_behavior == "trust_and_handoff": + return _complete_voice_start_handoff( + status="name_obtained", + value=trusted_name, + source="known_customer", + summary_text="Voice start completed using a trusted known customer name.", + ) + + if trusted_name and config.start.known_customer_behavior == "confirm_in_downstream": + return _complete_voice_start_handoff( + status="name_followup_required", + value=trusted_name, + source="known_customer", + summary_text="Voice start handed off a known customer name for downstream confirmation.", + ) + + should_ask_on_start = False + if config.start.ask_name_on_start: + if trusted_name: + should_ask_on_start = config.start.known_customer_behavior == "ask_on_start" + else: + should_ask_on_start = config.start.unknown_customer_behavior == "ask_on_start" + + if not should_ask_on_start: + return _complete_voice_start_handoff( + status="name_not_obtained", + value=None, + source="none", + summary_text="Voice start skipped name collection and handed off without a name.", + ) + + voice_session.status = "greeting" + ai_session.status = "active" + ai_session.summary_text = "" + ai_session.handoff_reason = None + ai_session.updated_at = now + session.commit() + if created: + _append_timeline( + interaction.interaction_id, + "ai.session_started", + { + "call_id": payload.call_id, + "voice_session_id": voice_session.session_id, + "ai_session_id": ai_session.session_id, + "language": language, + }, + ) + return VoiceAIStartOut( + session_id=ai_session.session_id, + language=language, + greeting_text=_voice_start_name_prompt(language, config), + disclosure_required=voice_session.disclosure_played_at is None, + metadata={ + "voice_start_language": language, + "downstream_queue_code": downstream_queue_code, + "downstream_queue_id": downstream_queue_id, + }, + ) session.commit() if created: _append_timeline( @@ -687,11 +1393,86 @@ def start_voice_session(session_id: str, payload: VoiceAIStartIn) -> VoiceAIStar "language": language, }, ) + name_status = str(voice_session.customer_name_status or "name_not_obtained").strip() or "name_not_obtained" + name_value = _normalize_name_candidate(voice_session.customer_name_value) or ( + str(voice_session.customer_name_value or "").strip() or None + ) + name_source = str(voice_session.customer_name_source or "none").strip() or "none" + name_resolved_at = str(voice_session.customer_name_resolved_at or "").strip() or None + if name_status == "name_obtained" and name_value: + finalized_name = _finalize_customer_name( + session, + customer=customer, + customer_id=customer_id, + call_id=payload.call_id, + final_name=name_value, + resolved_at=now, + ) + if finalized_name: + name_value = finalized_name + name_resolved_at = now + _persist_voice_name_state( + session, + voice_session=voice_session, + status=name_status, + value=name_value, + source=name_source, + resolved_at=name_resolved_at, + ) + greeting_text = _voice_greeting(language) + if name_status == "name_followup_required": + if config.downstream.uncertain_name_behavior == "finalize_immediately" and name_value: + finalized_name = _finalize_customer_name( + session, + customer=customer, + customer_id=customer_id, + call_id=payload.call_id, + final_name=name_value, + resolved_at=now, + ) + if finalized_name: + name_status = "name_obtained" + name_value = finalized_name + name_resolved_at = now + _persist_voice_name_state( + session, + voice_session=voice_session, + status=name_status, + value=name_value, + source=name_source, + resolved_at=name_resolved_at, + ) + elif config.downstream.uncertain_name_behavior == "discard_and_collect": + name_status = "name_not_obtained" + name_value = None + name_source = "none" + name_resolved_at = None + _persist_voice_name_state( + session, + voice_session=voice_session, + status=name_status, + value=name_value, + source=name_source, + resolved_at=name_resolved_at, + ) + if name_status == "name_obtained" and name_value: + greeting_text = _voice_personalized_greeting(language, name_value, config) + elif name_status == "name_followup_required": + greeting_text = _voice_name_confirmation_greeting(language, name_value, config) + session.commit() return VoiceAIStartOut( session_id=ai_session.session_id, language=language, - greeting_text=_voice_greeting(language), + greeting_text=greeting_text, disclosure_required=voice_session.disclosure_played_at is None, + metadata=_voice_name_metadata( + language=voice_session.voice_start_language or language, + customer_id=customer_id, + status=name_status, + value=name_value, + source=name_source, + resolved_at=name_resolved_at, + ), ) finally: session.close() @@ -732,6 +1513,7 @@ def turn_voice_session(session_id: str, payload: VoiceAITurnIn) -> VoiceAITurnDe agent_profile=voice_session.agent_profile, ) now = utc_now_iso() + voice_session.customer_id = customer_id voice_session.last_user_utterance_at = now voice_session.status = "thinking" voice_session.updated_at = now @@ -740,6 +1522,7 @@ def turn_voice_session(session_id: str, payload: VoiceAITurnIn) -> VoiceAITurnDe ai_session.interaction_id = interaction.interaction_id ai_session.customer_id = customer_id ai_session.language = str(payload.language or voice_session.language or "ru").strip() or "ru" + config = load_effective_voice_name_collection_config(session) _record_voice_ai_turn( session, @@ -771,10 +1554,123 @@ def turn_voice_session(session_id: str, payload: VoiceAITurnIn) -> VoiceAITurnDe voice_session_id=voice_session.session_id, limit=_voice_max_context_segments(), ) + if _voice_start_stage(voice_session.agent_profile, payload.metadata): + language = ai_session.language or "ru" + downstream_queue_code, downstream_queue_id = _voice_start_downstream_queue(payload.metadata, voice_session) + if config.enabled and config.start.ask_name_on_start: + status, name_value, name_source = _voice_start_name_outcome(payload.transcript_text, language) + else: + status, name_value, name_source = "name_not_obtained", None, "none" + result = VoiceStartResult( + language=language, + customer_id=customer_id, + customer_name_status=status, + customer_name_value=name_value, + customer_name_source=name_source, + downstream_queue_id=downstream_queue_id, + downstream_queue_code=downstream_queue_code, + resolved_at=now, + ) + _persist_voice_start_result( + session, + voice_session=voice_session, + interaction=interaction, + result=result, + ) + decision_metadata = _voice_start_result_metadata(result) + summary_text = ( + f"Voice start completed with status {status}." + if not name_value + else f"Voice start completed with status {status} and candidate name {name_value}." + ) + _record_voice_ai_turn( + session, + ai_session_id=ai_session.session_id, + interaction_id=interaction.interaction_id, + role="assistant", + source_type="voice_policy", + text=summary_text, + payload={ + "voice_session_id": payload.voice_session_id, + "call_id": payload.call_id, + "decision": { + "language": language, + "customer_name_status": status, + "customer_name_value": name_value, + "customer_name_source": name_source, + "downstream_queue_code": downstream_queue_code, + "downstream_queue_id": downstream_queue_id, + }, + }, + model="voice_start_policy", + latency_ms=1, + ) + voice_session.language = language + voice_session.status = "handoff_requested" + voice_session.handoff_reason = "voice_start_completed" + voice_session.updated_at = now + ai_session.status = "handoff_required" + ai_session.handoff_reason = "voice_start_completed" + ai_session.summary_text = summary_text + ai_session.updated_at = now + session.commit() + return VoiceAITurnDecisionOut( + language=language, + intent="voice_start_identity", + reply_text="", + confidence=1.0 if status == "name_obtained" else 0.55, + needs_handoff=True, + handoff_reason="voice_start_completed", + case_action="keep_open", + kb_refs=[], + summary_text=summary_text, + model="voice_start_policy", + latency_ms=1, + status="handoff_requested", + metadata=decision_metadata, + ) timeline_window = _voice_recent_timeline( session, interaction_id=interaction.interaction_id, ) + current_name_status = str(voice_session.customer_name_status or "name_not_obtained").strip() or "name_not_obtained" + current_name_value = _normalize_name_candidate(voice_session.customer_name_value) or ( + str(voice_session.customer_name_value or "").strip() or None + ) + current_name_source = str(voice_session.customer_name_source or "none").strip() or "none" + current_name_resolved_at = str(voice_session.customer_name_resolved_at or "").strip() or None + name_update = _voice_downstream_name_update( + language=ai_session.language or "ru", + transcript_text=payload.transcript_text, + transcript_window=transcript_window, + current_status=current_name_status, + current_name=current_name_value, + current_source=current_name_source, + current_resolved_at=current_name_resolved_at, + now=now, + config=config, + ) + if name_update["finalizable"]: + finalized_name = _finalize_customer_name( + session, + customer=customer, + customer_id=customer_id, + call_id=payload.call_id, + final_name=name_update["value"], + resolved_at=now, + ) + if finalized_name: + name_update["value"] = finalized_name + name_update["resolved_at"] = now + _persist_voice_name_state( + session, + voice_session=voice_session, + status=name_update["status"], + value=name_update["value"], + source=name_update["source"], + resolved_at=name_update["resolved_at"], + ) + kb_results = app._kb_search( session, payload.transcript_text, @@ -790,6 +1686,28 @@ def turn_voice_session(session_id: str, payload: VoiceAITurnIn) -> VoiceAITurnDe kb_results=kb_results, disclosure_required=disclosure_required, ) + decision_metadata = _voice_name_metadata( + language=voice_session.voice_start_language or ai_session.language or "ru", + customer_id=customer_id, + status=name_update["status"], + value=name_update["value"], + source=name_update["source"], + resolved_at=name_update["resolved_at"], + ) + if name_update["status"] == "name_obtained" and name_update["value"]: + decision["reply_text"] = _voice_reply_with_name( + decision["language"], + decision["reply_text"], + name_update["value"], + ) + elif name_update["inline_followup"] and not decision["needs_handoff"]: + inline_followup = _voice_inline_name_followup(decision["language"], config) + decision["reply_text"] = ( + f"{decision['reply_text']} {inline_followup}".strip() + if str(decision["reply_text"] or "").strip() + else inline_followup + ) + decision["metadata"] = decision_metadata _record_voice_ai_turn( session, ai_session_id=ai_session.session_id, @@ -835,6 +1753,7 @@ def turn_voice_session(session_id: str, payload: VoiceAITurnIn) -> VoiceAITurnDe "confidence": decision["confidence"], "kb_refs": decision["kb_refs"], "handoff_reason": decision["handoff_reason"], + **decision_metadata, }, ) return VoiceAITurnDecisionOut( @@ -850,6 +1769,7 @@ def turn_voice_session(session_id: str, payload: VoiceAITurnIn) -> VoiceAITurnDe model=decision["model"], latency_ms=decision["latency_ms"], status="handoff_requested" if decision["needs_handoff"] else "active", + metadata=decision_metadata, ) except HTTPException: raise diff --git a/services/ai_orchestrator_service/voice_name_config.py b/services/ai_orchestrator_service/voice_name_config.py new file mode 100644 index 0000000..413c718 --- /dev/null +++ b/services/ai_orchestrator_service/voice_name_config.py @@ -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) diff --git a/services/ai_voice_runtime_service/app.py b/services/ai_voice_runtime_service/app.py index a781dea..260344d 100644 --- a/services/ai_voice_runtime_service/app.py +++ b/services/ai_voice_runtime_service/app.py @@ -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( diff --git a/services/asterisk_bridge_service/config.py b/services/asterisk_bridge_service/config.py index eeaa16f..aa26fd0 100644 --- a/services/asterisk_bridge_service/config.py +++ b/services/asterisk_bridge_service/config.py @@ -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, } diff --git a/services/asterisk_bridge_service/voice_ai.py b/services/asterisk_bridge_service/voice_ai.py index 4198acc..68227d6 100644 --- a/services/asterisk_bridge_service/voice_ai.py +++ b/services/asterisk_bridge_service/voice_ai.py @@ -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) diff --git a/services/customer_service/app.py b/services/customer_service/app.py index fcac210..126c9b0 100644 --- a/services/customer_service/app.py +++ b/services/customer_service/app.py @@ -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() diff --git a/services/shared/models.py b/services/shared/models.py index 70119e2..0b1630b 100644 --- a/services/shared/models.py +++ b/services/shared/models.py @@ -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 diff --git a/services/shared/sql_init.py b/services/shared/sql_init.py index dea5493..a5721d5 100644 --- a/services/shared/sql_init.py +++ b/services/shared/sql_init.py @@ -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( diff --git a/services/shared/sql_models.py b/services/shared/sql_models.py index 0e348f0..e15eee1 100644 --- a/services/shared/sql_models.py +++ b/services/shared/sql_models.py @@ -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) diff --git a/tests/test_ai_orchestrator_service.py b/tests/test_ai_orchestrator_service.py index dac88f3..0f25929 100644 --- a/tests/test_ai_orchestrator_service.py +++ b/tests/test_ai_orchestrator_service.py @@ -1,26 +1,36 @@ +import json import time import importlib from types import SimpleNamespace import httpx +import pytest from fastapi.testclient import TestClient from sqlalchemy import select ai_module = importlib.import_module("services.ai_orchestrator_service.app") ai_app = ai_module.app voice_module = importlib.import_module("services.ai_orchestrator_service.voice") +voice_config_module = importlib.import_module("services.ai_orchestrator_service.voice_name_config") from services.interaction_service.app import app as interaction_app from services.shared.core import new_id, utc_now_iso from services.shared.db import get_session +from services.shared.models import VoiceAIStartIn, VoiceAITurnIn from services.shared.sql_models import ( AIJobRow, AISessionRow, AITurnRow, + AsteriskCallLinkRow, + Customer, + CustomerExternalIdentity, Interaction, + InteractionTimeline, KBArticleRow, KBCategoryRow, TelegramMessageRow, TelegramThreadRow, + VoiceNameCollectionSettingsRow, + VoiceAISessionRow, WhatsAppThreadRow, ) from services.telegram_adapter_service import app as telegram_module @@ -35,10 +45,276 @@ def operator_headers(user="operator"): return {"X-User": user, "X-Role": "operator"} +@pytest.fixture(autouse=True) +def reset_voice_name_collection_settings(): + session = get_session() + try: + row = session.execute(select(VoiceNameCollectionSettingsRow)).scalar_one_or_none() + if row is not None: + session.delete(row) + session.commit() + finally: + session.close() + yield + session = get_session() + try: + row = session.execute(select(VoiceNameCollectionSettingsRow)).scalar_one_or_none() + if row is not None: + session.delete(row) + session.commit() + finally: + session.close() + + def _u(value: str) -> str: return value.encode("ascii").decode("unicode_escape") +def seed_voice_downstream_session( + *, + marker: str, + name_status: str, + name_value: str | None = None, + name_source: str = "none", + customer_display_name: str | None = None, + caller_number: str | None = None, + caller_name: str = "Voice Caller", +) -> dict[str, str]: + session = get_session() + try: + now = utc_now_iso() + resolved_caller_number = caller_number or f"+7{abs(hash(marker)) % 10_000_000_000:010d}" + interaction_id = f"{marker}_int" + customer_id = f"{marker}_cus" + call_id = f"{marker}_call" + session_id = f"{marker}_avs" + linked_id = f"{marker}_linked" + session.add( + Customer( + customer_id=customer_id, + display_name=customer_display_name or resolved_caller_number, + phones_json=f'["{resolved_caller_number}"]', + preferred_phone=resolved_caller_number, + tags_json='["voice"]', + created_at=now, + ) + ) + session.add( + CustomerExternalIdentity( + identity_id=f"{marker}_cei", + customer_id=customer_id, + channel="voice", + external_subject=resolved_caller_number, + display_name_snapshot=caller_name, + created_at=now, + updated_at=now, + ) + ) + session.add( + Interaction( + interaction_id=interaction_id, + channel="voice", + subject=f"Voice downstream {marker}", + customer_id=customer_id, + queue_id="que_voice_support", + priority=3, + status="open", + assigned_to=None, + created_at=now, + updated_at=now, + ) + ) + session.add( + AsteriskCallLinkRow( + call_id=call_id, + linked_id=linked_id, + queue_code="voice_support", + queue_id="que_voice_support", + interaction_id=interaction_id, + caller_number=resolved_caller_number, + caller_name=caller_name, + status="active", + telephony_status="connected", + claimed_by_user=None, + claimed_at=None, + operator_extension=None, + channel_name="PJSIP/1001-000001", + started_at=now, + connected_at=now, + ended_at=None, + updated_at=now, + voice_start_language="ru", + customer_name_status=name_status, + customer_name_value=name_value, + customer_name_source=name_source, + customer_name_resolved_at=now, + voice_session_id=session_id, + ) + ) + session.add( + VoiceAISessionRow( + session_id=session_id, + call_id=call_id, + linked_id=linked_id, + interaction_id=interaction_id, + customer_id=customer_id, + queue_id="que_voice_support", + ai_session_id=None, + agent_profile="voice_support", + language="ru", + asr_provider="openai", + tts_provider="yandex", + status="active", + handoff_reason=None, + handoff_target_queue_id="que_voice_support", + disclosure_played_at=now, + last_user_utterance_at=None, + last_ai_reply_at=None, + started_at=now, + updated_at=now, + ended_at=None, + voice_start_language="ru", + customer_name_status=name_status, + customer_name_value=name_value, + customer_name_source=name_source, + customer_name_resolved_at=now, + ) + ) + session.commit() + return { + "interaction_id": interaction_id, + "customer_id": customer_id, + "call_id": call_id, + "session_id": session_id, + "caller_number": resolved_caller_number, + } + finally: + session.close() + + +def seed_voice_start_session( + *, + marker: str, + language: str = "ru", + customer_display_name: str | None = None, + caller_number: str | None = None, + caller_name: str = "Voice Caller", + next_queue_code: str = "voice_support", + next_queue_id: str = "que_voice_support", +) -> dict[str, str]: + session = get_session() + try: + now = utc_now_iso() + resolved_caller_number = caller_number or f"+7{abs(hash(f'{marker}_start')) % 10_000_000_000:010d}" + interaction_id = f"{marker}_int" + customer_id = f"{marker}_cus" + call_id = f"{marker}_call" + session_id = f"{marker}_avs" + linked_id = f"{marker}_linked" + + if customer_display_name is not None: + session.add( + Customer( + customer_id=customer_id, + display_name=customer_display_name, + phones_json=f'["{resolved_caller_number}"]', + preferred_phone=resolved_caller_number, + tags_json='["voice"]', + created_at=now, + ) + ) + session.add( + CustomerExternalIdentity( + identity_id=f"{marker}_cei", + customer_id=customer_id, + channel="voice", + external_subject=resolved_caller_number, + display_name_snapshot=caller_name, + created_at=now, + updated_at=now, + ) + ) + + session.add( + Interaction( + interaction_id=interaction_id, + channel="voice", + subject=f"Voice start {marker}", + customer_id=customer_id if customer_display_name is not None else None, + queue_id=f"que_voice_start_{language}", + priority=3, + status="open", + assigned_to=None, + created_at=now, + updated_at=now, + ) + ) + session.add( + AsteriskCallLinkRow( + call_id=call_id, + linked_id=linked_id, + queue_code=f"voice_start_{language}", + queue_id=f"que_voice_start_{language}", + interaction_id=interaction_id, + caller_number=resolved_caller_number, + caller_name=caller_name, + status="active", + telephony_status="connected", + claimed_by_user=None, + claimed_at=None, + operator_extension=None, + channel_name="PJSIP/1002-000002", + started_at=now, + connected_at=now, + ended_at=None, + updated_at=now, + voice_session_id=session_id, + ) + ) + session.add( + VoiceAISessionRow( + session_id=session_id, + call_id=call_id, + linked_id=linked_id, + interaction_id=interaction_id, + customer_id=customer_id if customer_display_name is not None else None, + queue_id=f"que_voice_start_{language}", + ai_session_id=None, + agent_profile="voice_start", + language=language, + asr_provider="openai", + tts_provider="yandex", + status="active", + handoff_reason=None, + handoff_target_queue_id=next_queue_id, + disclosure_played_at=None, + last_user_utterance_at=None, + last_ai_reply_at=None, + started_at=now, + updated_at=now, + ended_at=None, + voice_start_language=language, + customer_name_status=None, + customer_name_value=None, + customer_name_source=None, + customer_name_resolved_at=None, + ) + ) + session.commit() + return { + "interaction_id": interaction_id, + "customer_id": customer_id, + "call_id": call_id, + "session_id": session_id, + "linked_id": linked_id, + "language": language, + "next_queue_code": next_queue_code, + "next_queue_id": next_queue_id, + } + finally: + session.close() + + def patch_interaction_request(monkeypatch): def fake_request(method: str, path: str, *, payload: dict | None = None) -> dict: session = get_session() @@ -495,6 +771,339 @@ def cleanup_ai_analytics_dataset(marker: str) -> None: session.close() +def seed_voice_name_flow_analytics_dataset(marker: str) -> dict[str, str]: + session = get_session() + try: + window_from = "2041-02-01T00:00:00+00:00" + window_to = "2041-02-03T00:00:00+00:00" + queue_ru = f"{marker}_queue_ru" + queue_kz = f"{marker}_queue_kz" + queue_support = f"{marker}_queue_support" + + sessions = [ + { + "voice_session_id": f"{marker}_voice_start", + "ai_session_id": f"{marker}_ai_start", + "call_id": f"{marker}_call_start", + "interaction_id": f"{marker}_int_start", + "started_at": "2041-02-01T09:00:00+00:00", + "queue_id": queue_ru, + "interaction_queue_id": queue_ru, + "language": "ru", + "voice_start_language": "ru", + "status": "closed", + "handoff_reason": None, + "call_queue_id": queue_ru, + "call_voice_start_language": "ru", + "claimed_by_user": None, + "operator_extension": None, + "customer_name_source": "voice_start", + "turns": [ + { + "turn_id": f"{marker}_turn_start_1", + "created_at": "2041-02-01T09:02:00+00:00", + "decision": { + "customer_name_status": "name_obtained", + "customer_name_value": "Алия", + "customer_name_source": "voice_start", + }, + }, + ], + }, + { + "voice_session_id": f"{marker}_voice_downstream", + "ai_session_id": f"{marker}_ai_downstream", + "call_id": f"{marker}_call_downstream", + "interaction_id": f"{marker}_int_downstream", + "started_at": "2041-02-01T10:00:00+00:00", + "queue_id": None, + "interaction_queue_id": None, + "language": "kz", + "voice_start_language": None, + "status": "human_owned", + "handoff_reason": "requested_human", + "call_queue_id": queue_kz, + "call_voice_start_language": "kz", + "claimed_by_user": "operator_kz", + "operator_extension": "2101", + "customer_name_source": "voice_followup", + "turns": [ + { + "turn_id": f"{marker}_turn_down_1", + "created_at": "2041-02-01T10:01:00+00:00", + "decision": { + "customer_name_status": "name_followup_required", + "customer_name_source": "voice_start", + "metadata": { + "customer_name_status": "name_followup_required", + "customer_name_source": "voice_start", + }, + }, + }, + { + "turn_id": f"{marker}_turn_down_2", + "created_at": "2041-02-01T10:03:00+00:00", + "decision": { + "customer_name_status": "name_obtained", + "customer_name_value": "Нурлан", + "customer_name_source": "voice_followup", + "metadata": { + "customer_name_status": "name_obtained", + "customer_name_value": "Нурлан", + "customer_name_source": "voice_followup", + }, + }, + }, + ], + }, + { + "voice_session_id": f"{marker}_voice_followup", + "ai_session_id": f"{marker}_ai_followup", + "call_id": f"{marker}_call_followup", + "interaction_id": f"{marker}_int_followup", + "started_at": "2041-02-02T11:00:00+00:00", + "queue_id": None, + "interaction_queue_id": queue_support, + "language": None, + "voice_start_language": None, + "status": "handoff_required", + "handoff_reason": "requested_human", + "call_queue_id": None, + "call_voice_start_language": None, + "claimed_by_user": "operator_support", + "operator_extension": None, + "customer_name_source": "voice_start", + "with_call_row": False, + "turns": [ + { + "turn_id": f"{marker}_turn_followup_1", + "created_at": "2041-02-02T11:02:00+00:00", + "decision": { + "customer_name_status": "name_followup_required", + "customer_name_source": "voice_start", + "metadata": { + "customer_name_status": "name_followup_required", + "customer_name_source": "voice_start", + }, + }, + }, + ], + }, + { + "voice_session_id": f"{marker}_voice_missing", + "ai_session_id": None, + "call_id": f"{marker}_call_missing", + "interaction_id": f"{marker}_int_missing", + "started_at": "2041-02-02T12:00:00+00:00", + "queue_id": queue_ru, + "interaction_queue_id": queue_ru, + "language": "ru", + "voice_start_language": "ru", + "status": "closed", + "handoff_reason": None, + "call_queue_id": queue_ru, + "call_voice_start_language": "ru", + "claimed_by_user": None, + "operator_extension": None, + "customer_name_source": "voice_start", + "turns": [], + "timeline_event": { + "timestamp": "2041-02-02T12:01:00+00:00", + "metadata": { + "customer_name_status": "name_not_obtained", + "customer_name_source": "voice_start", + "language": "ru", + }, + }, + }, + { + "voice_session_id": f"{marker}_voice_manual", + "ai_session_id": f"{marker}_ai_manual", + "call_id": f"{marker}_call_manual", + "interaction_id": f"{marker}_int_manual", + "started_at": "2041-02-02T13:00:00+00:00", + "queue_id": queue_ru, + "interaction_queue_id": queue_ru, + "language": "ru", + "voice_start_language": "ru", + "status": "human_owned", + "handoff_reason": "requested_human", + "call_queue_id": queue_ru, + "call_voice_start_language": "ru", + "claimed_by_user": "operator_manual", + "operator_extension": "2201", + "customer_name_source": "manual", + "turns": [ + { + "turn_id": f"{marker}_turn_manual_1", + "created_at": "2041-02-02T13:02:00+00:00", + "decision": { + "customer_name_status": "name_not_obtained", + "customer_name_source": "voice_followup", + "metadata": { + "customer_name_status": "name_not_obtained", + "customer_name_source": "voice_followup", + }, + }, + }, + ], + }, + ] + + entities = [] + for item in sessions: + interaction_id = item["interaction_id"] + call_id = item["call_id"] + voice_session_id = item["voice_session_id"] + ai_session_id = item["ai_session_id"] + + entities.append( + Interaction( + interaction_id=interaction_id, + channel="voice", + subject=f"{marker} {interaction_id}", + customer_id=f"{marker}_cust_{interaction_id}", + queue_id=item["interaction_queue_id"], + priority=3, + status="closed" if item["status"] == "closed" else "in_progress", + assigned_to=item["claimed_by_user"], + created_at=item["started_at"], + updated_at=item["started_at"], + ) + ) + if item.get("with_call_row", True): + entities.append( + AsteriskCallLinkRow( + call_id=call_id, + linked_id=f"{call_id}_linked", + queue_code="voice_support", + queue_id=item["call_queue_id"], + interaction_id=interaction_id, + caller_number=f"+7700{abs(hash(call_id)) % 1000000:06d}", + caller_name="Voice Caller", + status="active", + telephony_status="connected", + claimed_by_user=item["claimed_by_user"], + claimed_at=item["started_at"] if item["claimed_by_user"] else None, + operator_extension=item["operator_extension"], + channel_name="PJSIP/1001-000001", + started_at=item["started_at"], + connected_at=item["started_at"], + ended_at=None, + updated_at=item["started_at"], + voice_start_language=item["call_voice_start_language"], + customer_name_status="name_obtained" if item["customer_name_source"] == "manual" else None, + customer_name_value="Manual Name" if item["customer_name_source"] == "manual" else None, + customer_name_source=item["customer_name_source"], + customer_name_resolved_at=item["started_at"] if item["customer_name_source"] == "manual" else None, + voice_session_id=voice_session_id, + ai_state="human_owned" if item["claimed_by_user"] else "closed", + ai_handoff_reason=item["handoff_reason"], + ) + ) + entities.append( + VoiceAISessionRow( + session_id=voice_session_id, + call_id=call_id, + linked_id=f"{call_id}_linked", + interaction_id=interaction_id, + customer_id=f"{marker}_cust_{interaction_id}", + queue_id=item["queue_id"], + ai_session_id=ai_session_id, + agent_profile="voice_support", + language=item["language"], + asr_provider="openai", + tts_provider="yandex", + status=item["status"], + handoff_reason=item["handoff_reason"], + handoff_target_queue_id=item["interaction_queue_id"] or item["call_queue_id"], + disclosure_played_at=item["started_at"], + last_user_utterance_at=None, + last_ai_reply_at=None, + started_at=item["started_at"], + updated_at=item["started_at"], + ended_at=None, + voice_start_language=item["voice_start_language"], + customer_name_status="name_obtained" if item["customer_name_source"] == "manual" else None, + customer_name_value="Manual Name" if item["customer_name_source"] == "manual" else None, + customer_name_source=item["customer_name_source"], + customer_name_resolved_at=item["started_at"] if item["customer_name_source"] == "manual" else None, + ) + ) + if ai_session_id: + entities.append( + AISessionRow( + session_id=ai_session_id, + channel="voice", + thread_id=None, + interaction_id=interaction_id, + customer_id=f"{marker}_cust_{interaction_id}", + agent_profile="voice_support", + language=item["language"] or item["voice_start_language"] or item["call_voice_start_language"] or "unknown", + status=item["status"], + summary_text="", + last_user_message_id=None, + last_ai_message_id=None, + handoff_reason=item["handoff_reason"], + created_at=item["started_at"], + updated_at=item["started_at"], + closed_at=item["started_at"] if item["status"] == "closed" else None, + ) + ) + for turn in item["turns"]: + entities.append( + AITurnRow( + turn_id=turn["turn_id"], + session_id=ai_session_id, + thread_id=None, + interaction_id=interaction_id, + role="assistant", + source_type="voice_policy", + text="voice policy", + payload_json=json.dumps({"decision": turn["decision"]}, ensure_ascii=False), + model="stub", + finish_reason="stop", + latency_ms=120, + created_at=turn["created_at"], + ) + ) + if item.get("timeline_event"): + entities.append( + InteractionTimeline( + interaction_id=interaction_id, + timestamp=item["timeline_event"]["timestamp"], + action="voice.start.completed", + metadata_json=json.dumps(item["timeline_event"]["metadata"], ensure_ascii=False), + ) + ) + + session.add_all(entities) + session.commit() + return { + "from_ts": window_from, + "to_ts": window_to, + "queue_ru": queue_ru, + "queue_kz": queue_kz, + "queue_support": queue_support, + } + finally: + session.close() + + +def cleanup_voice_name_flow_analytics_dataset(marker: str) -> None: + session = get_session() + try: + session.query(AITurnRow).filter(AITurnRow.turn_id.like(f"{marker}_turn_%")).delete(synchronize_session=False) + session.query(AISessionRow).filter(AISessionRow.session_id.like(f"{marker}_ai_%")).delete(synchronize_session=False) + session.query(VoiceAISessionRow).filter(VoiceAISessionRow.session_id.like(f"{marker}_voice_%")).delete(synchronize_session=False) + session.query(AsteriskCallLinkRow).filter(AsteriskCallLinkRow.call_id.like(f"{marker}_call_%")).delete(synchronize_session=False) + session.query(InteractionTimeline).filter(InteractionTimeline.interaction_id.like(f"{marker}_int_%")).delete(synchronize_session=False) + session.query(Interaction).filter(Interaction.interaction_id.like(f"{marker}_int_%")).delete(synchronize_session=False) + session.commit() + finally: + session.close() + + def test_ai_language_detection_prefers_kz_letters(): assert ai_module._infer_language("Сәлем, көмек керек") == "kz" assert ai_module._infer_language("Здравствуйте, нужна помощь") == "ru" @@ -1477,3 +2086,615 @@ def test_ai_analytics_session_detail_is_metadata_only_and_reason_taxonomy_suppor cleanup_ai_analytics_dataset(marker) +def test_voice_name_flow_overview_aggregates_start_downstream_handoff_and_manual_overlay(): + marker = f"vname_{new_id('seed')}" + seeded = seed_voice_name_flow_analytics_dataset(marker) + ai_client = TestClient(ai_app) + + try: + response = ai_client.get( + "/ai/analytics/voice-name-flow/overview", + params={ + "from_ts": seeded["from_ts"], + "to_ts": seeded["to_ts"], + }, + headers=admin_headers(), + ) + assert response.status_code == 200 + payload = response.json() + + assert payload["totals"]["scenario_calls"] == 5 + assert payload["totals"]["start_obtained"] == 1 + assert payload["totals"]["downstream_ai_obtained"] == 1 + assert payload["totals"]["followup_required"] == 1 + assert payload["totals"]["name_not_obtained"] == 2 + assert payload["totals"]["manual_corrected"] == 1 + assert payload["totals"]["handoff_confirmed_name"] == 1 + assert payload["totals"]["handoff_unconfirmed_name"] == 2 + assert payload["totals"]["needed_downstream"] == 4 + + assert payload["metrics"]["start_capture_rate"] == 20.0 + assert payload["metrics"]["downstream_rescue_rate"] == 25.0 + assert payload["metrics"]["handoff_unconfirmed_rate"] == 66.67 + assert payload["metrics"]["manual_correction_rate"] == 33.33 + + funnel = {item["stage"]: item for item in payload["breakdowns"]["funnel"]} + assert funnel["scenario_calls"]["sessions"] == 5 + assert funnel["start_obtained"]["sessions"] == 1 + assert funnel["needed_downstream"]["sessions"] == 4 + assert funnel["downstream_ai_obtained"]["sessions"] == 1 + assert funnel["handoff_confirmed_name"]["sessions"] == 1 + assert funnel["handoff_unconfirmed_name"]["sessions"] == 2 + + by_language = {item["language"]: item for item in payload["breakdowns"]["by_language"]} + assert by_language["ru"]["scenario_calls"] == 3 + assert by_language["kz"]["downstream_ai_obtained"] == 1 + assert by_language["unknown"]["followup_required"] == 1 + + by_queue = {item["queue_id"]: item for item in payload["breakdowns"]["by_queue"]} + assert by_queue[seeded["queue_ru"]]["scenario_calls"] == 3 + assert by_queue[seeded["queue_kz"]]["downstream_ai_obtained"] == 1 + assert by_queue[seeded["queue_support"]]["handoff_unconfirmed_name"] == 1 + + handoff = {item["outcome"]: item for item in payload["breakdowns"]["handoff"]} + assert handoff["confirmed_name"]["sessions"] == 1 + assert handoff["unconfirmed_name"]["sessions"] == 2 + + assert payload["coverage"]["sessions_with_start_decision"] == 5 + assert payload["coverage"]["sessions_with_final_ai_state"] == 5 + assert payload["coverage"]["sessions_with_manual_overlay"] == 1 + assert payload["coverage"]["note"] + finally: + cleanup_voice_name_flow_analytics_dataset(marker) + + +def test_voice_name_flow_overview_filters_by_queue_and_language_with_fallbacks(): + marker = f"vnameflt_{new_id('seed')}" + seeded = seed_voice_name_flow_analytics_dataset(marker) + ai_client = TestClient(ai_app) + + try: + queue_filtered = ai_client.get( + "/ai/analytics/voice-name-flow/overview", + params={ + "from_ts": seeded["from_ts"], + "to_ts": seeded["to_ts"], + "queue_id": seeded["queue_kz"], + }, + headers=admin_headers(), + ) + assert queue_filtered.status_code == 200 + queue_payload = queue_filtered.json() + assert queue_payload["totals"]["scenario_calls"] == 1 + assert queue_payload["totals"]["downstream_ai_obtained"] == 1 + assert queue_payload["breakdowns"]["by_queue"][0]["queue_id"] == seeded["queue_kz"] + + language_filtered = ai_client.get( + "/ai/analytics/voice-name-flow/overview", + params={ + "from_ts": seeded["from_ts"], + "to_ts": seeded["to_ts"], + "language": "unknown", + }, + headers=admin_headers(), + ) + assert language_filtered.status_code == 200 + language_payload = language_filtered.json() + assert language_payload["totals"]["scenario_calls"] == 1 + assert language_payload["totals"]["followup_required"] == 1 + assert language_payload["breakdowns"]["by_language"][0]["language"] == "unknown" + finally: + cleanup_voice_name_flow_analytics_dataset(marker) + + +def test_voice_name_flow_timeseries_returns_stable_points_for_all_metrics(): + marker = f"vnamets_{new_id('seed')}" + seeded = seed_voice_name_flow_analytics_dataset(marker) + ai_client = TestClient(ai_app) + + expected_values = { + "scenario_calls": [2.0, 3.0], + "start_capture_rate": [50.0, 0.0], + "downstream_rescue_rate": [100.0, 0.0], + "handoff_unconfirmed_rate": [0.0, 100.0], + "manual_correction_rate": [0.0, 50.0], + } + + try: + for metric, expected in expected_values.items(): + response = ai_client.get( + "/ai/analytics/voice-name-flow/timeseries", + params={ + "from_ts": seeded["from_ts"], + "to_ts": seeded["to_ts"], + "metric": metric, + }, + headers=admin_headers(), + ) + assert response.status_code == 200 + payload = response.json() + assert payload["metric"] == metric + assert payload["interval"] == "day" + assert [point["value"] for point in payload["points"]] == expected + finally: + cleanup_voice_name_flow_analytics_dataset(marker) + + +def test_voice_name_collection_config_get_returns_defaults_when_not_persisted(): + ai_client = TestClient(ai_app) + + response = ai_client.get("/ai/voice/config/name-collection", headers=admin_headers()) + + assert response.status_code == 200 + payload = response.json() + assert payload["source"] == "defaults" + assert payload["updated_at"] is None + assert payload["config"]["enabled"] is True + assert payload["config"]["start"]["known_customer_behavior"] == "trust_and_handoff" + assert payload["config"]["downstream"]["missing_name_behavior"] == "ask_inline_once" + assert "{name}" in payload["config"]["texts"]["ru"]["personalized_greeting_template"] + assert "{name}" in payload["config"]["texts"]["kz"]["confirmation_greeting_template"] + + +def test_voice_name_collection_config_put_persists_custom_payload(): + ai_client = TestClient(ai_app) + payload = voice_config_module.voice_name_collection_default_config().model_dump() + payload["enabled"] = False + payload["start"]["known_customer_behavior"] = "confirm_in_downstream" + payload["texts"]["ru"]["start_prompt"] = "Представьтесь, пожалуйста." + + response = ai_client.put( + "/ai/voice/config/name-collection", + headers=admin_headers(), + json=payload, + ) + + assert response.status_code == 200 + saved = response.json() + assert saved["source"] == "database" + assert saved["updated_at"] + assert saved["config"]["enabled"] is False + assert saved["config"]["start"]["known_customer_behavior"] == "confirm_in_downstream" + assert saved["config"]["texts"]["ru"]["start_prompt"] == "Представьтесь, пожалуйста." + + session = get_session() + try: + row = session.execute(select(VoiceNameCollectionSettingsRow)).scalar_one() + assert "Представьтесь, пожалуйста." in row.config_json + finally: + session.close() + + +def test_voice_name_collection_config_put_rejects_invalid_name_template(): + ai_client = TestClient(ai_app) + payload = voice_config_module.voice_name_collection_default_config().model_dump() + payload["texts"]["ru"]["confirmation_greeting_template"] = "Подтвердите имя клиента." + + response = ai_client.put( + "/ai/voice/config/name-collection", + headers=admin_headers(), + json=payload, + ) + + assert response.status_code == 422 + + +def test_voice_start_disabled_hands_off_without_prompt(): + session = get_session() + try: + config = voice_config_module.voice_name_collection_default_config().model_dump() + config["enabled"] = False + voice_config_module.save_voice_name_collection_config(session, config) + finally: + session.close() + + seeded = seed_voice_start_session(marker=f"voice_start_disabled_{new_id('seed')}") + + started = voice_module.start_voice_session( + seeded["session_id"], + VoiceAIStartIn( + voice_session_id=seeded["session_id"], + call_id=seeded["call_id"], + interaction_id=seeded["interaction_id"], + customer_id=None, + language_hint="ru", + agent_profile="voice_start", + metadata={ + "stage": "voice_start", + "next_queue_code": seeded["next_queue_code"], + "next_queue_id": seeded["next_queue_id"], + }, + ), + ) + + assert started.needs_handoff is True + assert started.greeting_text == "" + assert started.metadata["customer_name_status"] == "name_not_obtained" + assert started.metadata["customer_name_value"] is None + + +def test_voice_start_known_customer_can_require_downstream_confirmation(): + session = get_session() + try: + config = voice_config_module.voice_name_collection_default_config().model_dump() + config["start"]["known_customer_behavior"] = "confirm_in_downstream" + voice_config_module.save_voice_name_collection_config(session, config) + finally: + session.close() + + seeded = seed_voice_start_session( + marker=f"voice_start_known_{new_id('seed')}", + customer_display_name="Айдос", + caller_name="Текущий caller", + ) + + started = voice_module.start_voice_session( + seeded["session_id"], + VoiceAIStartIn( + voice_session_id=seeded["session_id"], + call_id=seeded["call_id"], + interaction_id=seeded["interaction_id"], + customer_id=seeded["customer_id"], + language_hint="ru", + agent_profile="voice_start", + metadata={ + "stage": "voice_start", + "next_queue_code": seeded["next_queue_code"], + "next_queue_id": seeded["next_queue_id"], + }, + ), + ) + + assert started.needs_handoff is True + assert started.start_result.customer_name_status == "name_followup_required" + assert started.start_result.customer_name_value == "Айдос" + assert started.start_result.customer_name_source == "known_customer" + + +def test_voice_start_unknown_customer_can_skip_start_prompt(): + session = get_session() + try: + config = voice_config_module.voice_name_collection_default_config().model_dump() + config["start"]["unknown_customer_behavior"] = "skip_to_downstream" + voice_config_module.save_voice_name_collection_config(session, config) + finally: + session.close() + + seeded = seed_voice_start_session(marker=f"voice_start_skip_{new_id('seed')}") + + started = voice_module.start_voice_session( + seeded["session_id"], + VoiceAIStartIn( + voice_session_id=seeded["session_id"], + call_id=seeded["call_id"], + interaction_id=seeded["interaction_id"], + customer_id=None, + language_hint="ru", + agent_profile="voice_start", + metadata={ + "stage": "voice_start", + "next_queue_code": seeded["next_queue_code"], + "next_queue_id": seeded["next_queue_id"], + }, + ), + ) + + assert started.needs_handoff is True + assert started.greeting_text == "" + assert started.start_result.customer_name_status == "name_not_obtained" + + +def test_downstream_voice_turn_can_disable_inline_name_followup(): + session = get_session() + try: + config = voice_config_module.voice_name_collection_default_config().model_dump() + config["downstream"]["missing_name_behavior"] = "do_not_ask" + voice_config_module.save_voice_name_collection_config(session, config) + finally: + session.close() + + seeded = seed_voice_downstream_session( + marker=f"voice_no_inline_{new_id('seed')}", + name_status="name_not_obtained", + customer_display_name="+77010009999", + ) + + decision = voice_module.turn_voice_session( + seeded["session_id"], + VoiceAITurnIn( + voice_session_id=seeded["session_id"], + call_id=seeded["call_id"], + interaction_id=seeded["interaction_id"], + transcript_text="Хочу узнать график работы", + language="ru", + sequence_no=1, + metadata={"voice_start_language": "ru"}, + ), + ) + + assert decision.metadata["customer_name_status"] == "name_not_obtained" + assert "как мне к вам обращаться" not in decision.reply_text.lower() + + +def test_downstream_voice_start_can_finalize_uncertain_name_immediately(): + session = get_session() + try: + config = voice_config_module.voice_name_collection_default_config().model_dump() + config["downstream"]["uncertain_name_behavior"] = "finalize_immediately" + voice_config_module.save_voice_name_collection_config(session, config) + finally: + session.close() + + seeded = seed_voice_downstream_session( + marker=f"voice_finalize_now_{new_id('seed')}", + name_status="name_followup_required", + name_value="Айдос", + name_source="voice_start", + customer_display_name="+77010009999", + ) + + started = voice_module.start_voice_session( + seeded["session_id"], + VoiceAIStartIn( + voice_session_id=seeded["session_id"], + call_id=seeded["call_id"], + interaction_id=seeded["interaction_id"], + customer_id=seeded["customer_id"], + language_hint="ru", + agent_profile="voice_support", + metadata={"voice_start_language": "ru"}, + ), + ) + + assert started.metadata["customer_name_status"] == "name_obtained" + assert started.metadata["customer_name_value"] == "Айдос" + assert "Айдос" in started.greeting_text + + +def test_downstream_voice_start_can_discard_uncertain_name_candidate(): + session = get_session() + try: + config = voice_config_module.voice_name_collection_default_config().model_dump() + config["downstream"]["uncertain_name_behavior"] = "discard_and_collect" + voice_config_module.save_voice_name_collection_config(session, config) + finally: + session.close() + + seeded = seed_voice_downstream_session( + marker=f"voice_discard_name_{new_id('seed')}", + name_status="name_followup_required", + name_value="Айдос", + name_source="voice_start", + customer_display_name="+77010009999", + ) + + started = voice_module.start_voice_session( + seeded["session_id"], + VoiceAIStartIn( + voice_session_id=seeded["session_id"], + call_id=seeded["call_id"], + interaction_id=seeded["interaction_id"], + customer_id=seeded["customer_id"], + language_hint="ru", + agent_profile="voice_support", + metadata={"voice_start_language": "ru"}, + ), + ) + + assert started.metadata["customer_name_status"] == "name_not_obtained" + assert started.metadata["customer_name_value"] is None + assert "Айдос" not in started.greeting_text + + +def test_custom_voice_name_texts_are_used_in_start_and_followup(): + session = get_session() + try: + config = voice_config_module.voice_name_collection_default_config().model_dump() + config["texts"]["ru"]["start_prompt"] = "Представьтесь, пожалуйста." + config["texts"]["ru"]["inline_followup_prompt"] = "Как к вам обращаться сейчас?" + config["texts"]["ru"]["personalized_greeting_template"] = "Здравствуйте, {name}. Чем помочь дальше?" + config["texts"]["ru"]["confirmation_greeting_template"] = "Правильно понял, вас зовут {name}? Чем помочь?" + voice_config_module.save_voice_name_collection_config(session, config) + finally: + session.close() + + start_seed = seed_voice_start_session(marker=f"voice_custom_start_{new_id('seed')}") + started = voice_module.start_voice_session( + start_seed["session_id"], + VoiceAIStartIn( + voice_session_id=start_seed["session_id"], + call_id=start_seed["call_id"], + interaction_id=start_seed["interaction_id"], + customer_id=None, + language_hint="ru", + agent_profile="voice_start", + metadata={ + "stage": "voice_start", + "next_queue_code": start_seed["next_queue_code"], + "next_queue_id": start_seed["next_queue_id"], + }, + ), + ) + assert started.greeting_text == "Представьтесь, пожалуйста." + + downstream_seed = seed_voice_downstream_session( + marker=f"voice_custom_followup_{new_id('seed')}", + name_status="name_not_obtained", + customer_display_name="+77010009999", + ) + decision = voice_module.turn_voice_session( + downstream_seed["session_id"], + VoiceAITurnIn( + voice_session_id=downstream_seed["session_id"], + call_id=downstream_seed["call_id"], + interaction_id=downstream_seed["interaction_id"], + transcript_text="Хочу узнать график работы", + language="ru", + sequence_no=1, + metadata={"voice_start_language": "ru"}, + ), + ) + assert "Как к вам обращаться сейчас?" in decision.reply_text + + +def test_downstream_voice_start_personalizes_greeting_and_finalizes_confirmed_name(): + seeded = seed_voice_downstream_session( + marker=f"voice_start_{new_id('seed')}", + name_status="name_obtained", + name_value="айдос", + name_source="voice_start", + customer_display_name="+77010009999", + ) + + started = voice_module.start_voice_session( + seeded["session_id"], + VoiceAIStartIn( + voice_session_id=seeded["session_id"], + call_id=seeded["call_id"], + interaction_id=seeded["interaction_id"], + customer_id=seeded["customer_id"], + language_hint="ru", + agent_profile="voice_support", + metadata={"voice_start_language": "ru"}, + ), + ) + + assert "Айдос" in started.greeting_text + assert "как мне к вам обращаться" not in started.greeting_text.lower() + assert started.metadata["customer_name_status"] == "name_obtained" + assert started.metadata["customer_name_value"] == "Айдос" + + session = get_session() + try: + customer = session.execute( + select(Customer).where(Customer.customer_id == seeded["customer_id"]) + ).scalar_one() + identity = session.execute( + select(CustomerExternalIdentity).where( + CustomerExternalIdentity.customer_id == seeded["customer_id"], + CustomerExternalIdentity.channel == "voice", + ) + ).scalar_one() + voice_session = session.execute( + select(VoiceAISessionRow).where(VoiceAISessionRow.session_id == seeded["session_id"]) + ).scalar_one() + assert customer.display_name == "Айдос" + assert identity.display_name_snapshot == "Айдос" + assert voice_session.customer_name_value == "Айдос" + finally: + session.close() + + +def test_downstream_voice_turn_adds_inline_name_followup_then_finalizes_provided_name(): + seeded = seed_voice_downstream_session( + marker=f"voice_inline_{new_id('seed')}", + name_status="name_not_obtained", + customer_display_name="+77010009999", + ) + + first_turn = voice_module.turn_voice_session( + seeded["session_id"], + VoiceAITurnIn( + voice_session_id=seeded["session_id"], + call_id=seeded["call_id"], + interaction_id=seeded["interaction_id"], + transcript_text="Хочу узнать график работы", + language="ru", + sequence_no=1, + metadata={"voice_start_language": "ru"}, + ), + ) + + assert first_turn.metadata["customer_name_status"] == "name_not_obtained" + assert "как мне к вам обращаться" in first_turn.reply_text.lower() + + second_turn = voice_module.turn_voice_session( + seeded["session_id"], + VoiceAITurnIn( + voice_session_id=seeded["session_id"], + call_id=seeded["call_id"], + interaction_id=seeded["interaction_id"], + transcript_text="Меня зовут Айдос, хочу узнать график работы", + language="ru", + sequence_no=2, + metadata={"voice_start_language": "ru"}, + ), + ) + + assert second_turn.metadata["customer_name_status"] == "name_obtained" + assert second_turn.metadata["customer_name_value"] == "Айдос" + assert second_turn.metadata["customer_name_source"] == "voice_followup" + assert "Айдос" in second_turn.reply_text + + session = get_session() + try: + customer = session.execute( + select(Customer).where(Customer.customer_id == seeded["customer_id"]) + ).scalar_one() + voice_session = session.execute( + select(VoiceAISessionRow).where(VoiceAISessionRow.session_id == seeded["session_id"]) + ).scalar_one() + assert customer.display_name == "Айдос" + assert voice_session.customer_name_status == "name_obtained" + assert voice_session.customer_name_source == "voice_followup" + assert voice_session.customer_name_value == "Айдос" + finally: + session.close() + + +@pytest.mark.parametrize( + ("transcript_text", "expected_name"), + [ + ("Да, Айдос", "Айдос"), + ("Нет, меня зовут Марат", "Марат"), + ], +) +def test_downstream_voice_turn_resolves_followup_name(transcript_text: str, expected_name: str): + seeded = seed_voice_downstream_session( + marker=f"voice_followup_{new_id('seed')}", + name_status="name_followup_required", + name_value="Айдос", + name_source="voice_start", + customer_display_name="+77010009999", + ) + + started = voice_module.start_voice_session( + seeded["session_id"], + VoiceAIStartIn( + voice_session_id=seeded["session_id"], + call_id=seeded["call_id"], + interaction_id=seeded["interaction_id"], + customer_id=seeded["customer_id"], + language_hint="ru", + agent_profile="voice_support", + metadata={"voice_start_language": "ru"}, + ), + ) + assert "Айдос" in started.greeting_text + + decision = voice_module.turn_voice_session( + seeded["session_id"], + VoiceAITurnIn( + voice_session_id=seeded["session_id"], + call_id=seeded["call_id"], + interaction_id=seeded["interaction_id"], + transcript_text=transcript_text, + language="ru", + sequence_no=1, + metadata={"voice_start_language": "ru"}, + ), + ) + + assert decision.metadata["customer_name_status"] == "name_obtained" + assert decision.metadata["customer_name_value"] == expected_name + assert decision.metadata["customer_name_source"] == "voice_followup" + assert expected_name in decision.reply_text + + session = get_session() + try: + customer = session.execute( + select(Customer).where(Customer.customer_id == seeded["customer_id"]) + ).scalar_one() + assert customer.display_name == expected_name + finally: + session.close() diff --git a/tests/test_ai_voice_runtime_service.py b/tests/test_ai_voice_runtime_service.py index 4c85788..2a4e836 100644 --- a/tests/test_ai_voice_runtime_service.py +++ b/tests/test_ai_voice_runtime_service.py @@ -8,6 +8,7 @@ from sqlalchemy import select import services.ai_voice_runtime_service.app as runtime_module from services.shared.core import utc_now_iso from services.shared.db import get_session +from services.shared.models import VoiceAIStartIn from services.shared.sql_models import VoiceAISessionRow, VoiceTranscriptSegmentRow @@ -218,6 +219,12 @@ def test_process_voice_ai_turn_triggers_bridge_handoff(monkeypatch): "model": "stub-voice", "latency_ms": 1, "status": "handoff_requested", + "metadata": { + "voice_start_language": "ru", + "customer_name_status": "name_obtained", + "customer_name_value": "Айдос", + "customer_name_source": "voice_followup", + }, } monkeypatch.setattr(runtime_module, "_bridge_request", _fake_bridge_request) @@ -273,6 +280,9 @@ def test_process_voice_ai_turn_triggers_bridge_handoff(monkeypatch): assert response.json()["needs_handoff"] is True assert handoffs assert handoffs[0]["path"] == "/internal/voice-ai/calls/call_voice_runtime_handoff/handoff" + assert handoffs[0]["payload"]["summary"]["customer_name_status"] == "name_obtained" + assert handoffs[0]["payload"]["summary"]["customer_name_value"] == "Айдос" + assert handoffs[0]["payload"]["summary"]["customer_name_source"] == "voice_followup" assert handoffs[0]["payload"]["reason"] == "Нужен живой оператор." session = get_session() @@ -335,15 +345,259 @@ def test_register_media_bridge_updates_voice_session(monkeypatch): finally: session.close() + +def test_complete_voice_ai_session_start_persists_voice_start_result_and_requests_handoff(monkeypatch): + bridge_calls: list[dict] = [] + + def _fake_orchestrator_request(method: str, path: str, *, payload=None, timeout=10.0): + assert method == "POST" + assert path.endswith("/start") + return { + "session_id": "ais_voice_start_done", + "language": "kz", + "greeting_text": "", + "disclosure_required": False, + "needs_handoff": True, + "handoff_reason": "voice_start_completed", + "summary_text": "Voice start completed with status name_followup_required and candidate name Айдос.", + "start_result": { + "language": "kz", + "customer_id": "cus_voice_start", + "customer_name_status": "name_followup_required", + "customer_name_value": "Айдос", + "customer_name_source": "voice_start", + "downstream_queue_id": "que_voice_support_kz", + "downstream_queue_code": "voice_support_kz", + "resolved_at": "2040-01-01T10:00:00+00:00", + }, + } + + def _fake_bridge_request(method: str, path: str, *, payload=None, timeout=10.0): + bridge_calls.append({"method": method, "path": path, "payload": payload, "timeout": timeout}) + return {"ok": True} + + monkeypatch.setattr(runtime_module, "_orchestrator_request", _fake_orchestrator_request) + monkeypatch.setattr(runtime_module, "_bridge_request", _fake_bridge_request) + + now = utc_now_iso() + session = get_session() + try: + session.add( + VoiceAISessionRow( + session_id="avs_voice_start_complete", + call_id="call_voice_start_complete", + linked_id="linked_voice_start_complete", + interaction_id="int_voice_start_complete", + customer_id=None, + queue_id="que_voice_start_kz", + ai_session_id=None, + agent_profile="voice_start", + language="kz", + asr_provider="openai", + tts_provider="openai", + status="greeting", + handoff_reason=None, + handoff_target_queue_id="que_voice_support_kz", + disclosure_played_at=None, + last_user_utterance_at=None, + last_ai_reply_at=None, + started_at=now, + updated_at=now, + ended_at=None, + ) + ) + session.commit() + finally: + session.close() + + runtime_module._complete_voice_ai_session_start( + "avs_voice_start_complete", + VoiceAIStartIn( + voice_session_id="avs_voice_start_complete", + call_id="call_voice_start_complete", + interaction_id="int_voice_start_complete", + customer_id=None, + language_hint="kz", + agent_profile="voice_start", + metadata={"stage": "voice_start", "next_queue_code": "voice_support_kz"}, + ), + ) + + session = get_session() + try: + voice_session = session.execute( + select(VoiceAISessionRow).where(VoiceAISessionRow.session_id == "avs_voice_start_complete") + ).scalar_one() + assert voice_session.ai_session_id == "ais_voice_start_done" + assert voice_session.status == "handoff_requested" + assert voice_session.voice_start_language == "kz" + assert voice_session.customer_name_status == "name_followup_required" + assert voice_session.customer_name_value == "Айдос" + assert voice_session.customer_name_source == "voice_start" + finally: + session.close() + + assert [call["path"] for call in bridge_calls] == [ + "/internal/voice-ai/calls/call_voice_start_complete/state", + "/internal/voice-ai/calls/call_voice_start_complete/handoff", + ] + assert bridge_calls[1]["payload"]["metadata"]["customer_name_status"] == "name_followup_required" + assert bridge_calls[1]["payload"]["metadata"]["customer_name_value"] == "Айдос" + + + +def test_complete_voice_ai_session_start_handles_disabled_name_collection_without_greeting(monkeypatch): + bridge_calls: list[dict] = [] + + def _fake_orchestrator_request(method: str, path: str, *, payload=None, timeout=10.0): + assert method == "POST" + assert path.endswith("/start") + return { + "session_id": "ais_voice_start_disabled", + "language": "ru", + "greeting_text": "", + "disclosure_required": False, + "needs_handoff": True, + "handoff_reason": "voice_start_completed", + "summary_text": "Voice start name collection is disabled and handed off without a name.", + "start_result": { + "language": "ru", + "customer_id": None, + "customer_name_status": "name_not_obtained", + "customer_name_value": None, + "customer_name_source": "none", + "downstream_queue_id": "que_voice_support_disabled", + "downstream_queue_code": "voice_support_disabled", + "resolved_at": "2040-01-01T11:00:00+00:00", + }, + } + + def _fake_bridge_request(method: str, path: str, *, payload=None, timeout=10.0): + bridge_calls.append({"method": method, "path": path, "payload": payload, "timeout": timeout}) + return {"ok": True} + + monkeypatch.setattr(runtime_module, "_orchestrator_request", _fake_orchestrator_request) + monkeypatch.setattr(runtime_module, "_bridge_request", _fake_bridge_request) + + now = utc_now_iso() + session = get_session() + try: + session.add( + VoiceAISessionRow( + session_id="avs_voice_start_disabled", + call_id="call_voice_start_disabled", + linked_id="linked_voice_start_disabled", + interaction_id="int_voice_start_disabled", + customer_id=None, + queue_id="que_voice_start_disabled", + ai_session_id=None, + agent_profile="voice_start", + language="ru", + asr_provider="openai", + tts_provider="openai", + status="greeting", + handoff_reason=None, + handoff_target_queue_id="que_voice_support_disabled", + disclosure_played_at=None, + last_user_utterance_at=None, + last_ai_reply_at=None, + started_at=now, + updated_at=now, + ended_at=None, + ) + ) + session.commit() + finally: + session.close() + + runtime_module._complete_voice_ai_session_start( + "avs_voice_start_disabled", + VoiceAIStartIn( + voice_session_id="avs_voice_start_disabled", + call_id="call_voice_start_disabled", + interaction_id="int_voice_start_disabled", + customer_id=None, + language_hint="ru", + agent_profile="voice_start", + metadata={"stage": "voice_start", "next_queue_code": "voice_support_disabled"}, + ), + ) + + session = get_session() + try: + voice_session = session.execute( + select(VoiceAISessionRow).where(VoiceAISessionRow.session_id == "avs_voice_start_disabled") + ).scalar_one() + assert voice_session.ai_session_id == "ais_voice_start_disabled" + assert voice_session.status == "handoff_requested" + assert voice_session.customer_name_status == "name_not_obtained" + segments = session.execute( + select(VoiceTranscriptSegmentRow) + .where(VoiceTranscriptSegmentRow.session_id == "avs_voice_start_disabled") + .where(VoiceTranscriptSegmentRow.speaker == "assistant") + ).scalars().all() + assert segments == [] + finally: + session.close() + + assert [call["path"] for call in bridge_calls] == [ + "/internal/voice-ai/calls/call_voice_start_disabled/state", + "/internal/voice-ai/calls/call_voice_start_disabled/handoff", + ] + + +def test_register_media_bridge_updates_voice_session_round_trip(monkeypatch): + closed: list[tuple[str, str]] = [] + monkeypatch.setattr( + runtime_module._MEDIA_RUNTIME, + "close_session_sync", + lambda session_id, *, reason: closed.append((session_id, reason)), + ) + + session = get_session() + try: + session.add( + VoiceAISessionRow( + session_id="avs_media_bridge_runtime_rt", + call_id="call_media_bridge_runtime_rt", + linked_id="linked_media_bridge_runtime_rt", + interaction_id="int_media_bridge_runtime_rt", + customer_id=None, + queue_id="que_media_bridge_runtime_rt", + ai_session_id="ais_media_bridge_runtime_rt", + agent_profile="voice_support", + language="ru", + asr_provider="openai", + tts_provider="openai", + status="greeting", + handoff_reason=None, + handoff_target_queue_id="que_media_bridge_runtime_rt", + media_uuid=None, + media_status=None, + media_connected_at=None, + media_ended_at=None, + last_media_frame_at=None, + disclosure_played_at=None, + last_user_utterance_at=None, + last_ai_reply_at=None, + started_at=utc_now_iso(), + updated_at=utc_now_iso(), + ended_at=None, + ) + ) + session.commit() + finally: + session.close() + client = TestClient(runtime_module.app) requested = client.post( - "/internal/voice-ai/sessions/avs_media_bridge_runtime/media-bridge", + "/internal/voice-ai/sessions/avs_media_bridge_runtime_rt/media-bridge", headers=_admin_headers(), json={ "event_type": "requested", "media_uuid": "75f4d61f-f674-4bb4-91c1-8ddfcf2fc2b4", - "call_id": "call_media_bridge_runtime", - "linked_id": "linked_media_bridge_runtime", + "call_id": "call_media_bridge_runtime_rt", + "linked_id": "linked_media_bridge_runtime_rt", "channel": "PJSIP/1001-00000001", "service_address": "127.0.0.1:9019", }, @@ -353,7 +607,7 @@ def test_register_media_bridge_updates_voice_session(monkeypatch): session = get_session() try: voice_session = session.execute( - select(VoiceAISessionRow).where(VoiceAISessionRow.session_id == "avs_media_bridge_runtime") + select(VoiceAISessionRow).where(VoiceAISessionRow.session_id == "avs_media_bridge_runtime_rt") ).scalar_one() assert voice_session.media_uuid == "75f4d61f-f674-4bb4-91c1-8ddfcf2fc2b4" assert voice_session.media_status == "requested" @@ -361,20 +615,20 @@ def test_register_media_bridge_updates_voice_session(monkeypatch): session.close() ended = client.post( - "/internal/voice-ai/sessions/avs_media_bridge_runtime/media-bridge", + "/internal/voice-ai/sessions/avs_media_bridge_runtime_rt/media-bridge", headers=_admin_headers(), json={ "event_type": "ended", "media_uuid": "75f4d61f-f674-4bb4-91c1-8ddfcf2fc2b4", - "call_id": "call_media_bridge_runtime", - "linked_id": "linked_media_bridge_runtime", + "call_id": "call_media_bridge_runtime_rt", + "linked_id": "linked_media_bridge_runtime_rt", "channel": "PJSIP/1001-00000001", "service_address": "127.0.0.1:9019", "reason": "audiosocket_closed", }, ) assert ended.status_code == 200 - assert closed == [("avs_media_bridge_runtime", "audiosocket_closed")] + assert closed == [("avs_media_bridge_runtime_rt", "audiosocket_closed")] def test_push_voice_ai_telephony_event_call_ended_returns_detached_safe_payload(monkeypatch): diff --git a/tests/test_asterisk_bridge_service.py b/tests/test_asterisk_bridge_service.py index bcf9ae6..c871455 100644 --- a/tests/test_asterisk_bridge_service.py +++ b/tests/test_asterisk_bridge_service.py @@ -861,6 +861,140 @@ def test_call_started_uses_api_fallback_after_create_failure(monkeypatch, tmp_pa session.close() +def test_voice_ai_summary_exposes_customer_name_state_for_operator(tmp_path): + now = utc_now_iso() + call_id = f"call_voice_summary_{tmp_path.name}" + session_id = f"avs_voice_summary_{tmp_path.name}" + ai_session_id = f"ais_voice_summary_{tmp_path.name}" + interaction_id = f"int_voice_summary_{tmp_path.name}" + + session = get_session() + try: + session.add( + AsteriskCallLinkRow( + call_id=call_id, + linked_id=f"linked_voice_summary_{tmp_path.name}", + queue_code="voice_support", + queue_id="que_voice_support", + interaction_id=interaction_id, + caller_number="+77010001122", + caller_name="Summary Caller", + status="active", + telephony_status="connected", + claimed_by_user=None, + claimed_at=None, + operator_extension=None, + channel_name="PJSIP/1001-000099", + started_at=now, + connected_at=now, + ended_at=None, + updated_at=now, + voice_session_id=session_id, + ai_session_id=ai_session_id, + ai_state="handoff_required", + ai_handoff_reason="customer requested operator", + ai_last_model_at=now, + voice_start_language="ru", + customer_name_status="name_followup_required", + customer_name_value="Айдос", + customer_name_source="voice_start", + customer_name_resolved_at=now, + ) + ) + session.add( + AISessionRow( + session_id=ai_session_id, + channel="voice", + call_id=call_id, + thread_id=None, + interaction_id=interaction_id, + customer_id="cus_voice_summary", + agent_profile="voice_support", + language="ru", + status="handoff_required", + summary_text="AI collected context and asked for operator handoff.", + last_user_message_id=None, + last_ai_message_id=None, + handoff_reason="customer requested operator", + created_at=now, + updated_at=now, + closed_at=None, + ) + ) + session.add( + VoiceAISessionRow( + session_id=session_id, + call_id=call_id, + linked_id=f"linked_voice_summary_{tmp_path.name}", + interaction_id=interaction_id, + customer_id="cus_voice_summary", + queue_id="que_voice_support", + ai_session_id=ai_session_id, + agent_profile="voice_support", + language="ru", + asr_provider="openai", + tts_provider="yandex", + status="handoff_requested", + handoff_reason="customer requested operator", + handoff_target_queue_id="que_voice_support", + disclosure_played_at=now, + last_user_utterance_at=now, + last_ai_reply_at=now, + started_at=now, + updated_at=now, + ended_at=None, + voice_start_language="ru", + customer_name_status="name_followup_required", + customer_name_value="Айдос", + customer_name_source="voice_start", + customer_name_resolved_at=now, + ) + ) + session.add_all( + [ + VoiceTranscriptSegmentRow( + segment_id=f"{session_id}_seg_1", + session_id=session_id, + call_id=call_id, + interaction_id=interaction_id, + sequence_no=1, + speaker="caller", + source_type="voice_asr", + text="Соедините с оператором", + confidence=0.96, + is_final=True, + barge_in_interrupted=False, + payload_json="{}", + created_at=now, + ), + VoiceTranscriptSegmentRow( + segment_id=f"{session_id}_seg_2", + session_id=session_id, + call_id=call_id, + interaction_id=interaction_id, + sequence_no=2, + speaker="assistant", + source_type="voice_policy", + text="Сейчас переведу вас на оператора.", + confidence=None, + is_final=True, + barge_in_interrupted=False, + payload_json="{}", + created_at=now, + ), + ] + ) + session.commit() + finally: + session.close() + + summary = bridge_module._voice_ai_summary_for_call(call_id) + + assert summary is not None + assert summary.customer_name_status == "name_followup_required" + assert summary.customer_name_value == "Айдос" + assert summary.customer_name_source == "voice_start" + assert summary.voice_start_language == "ru" def test_call_started_re_raises_original_create_error_when_all_fallbacks_fail(monkeypatch, tmp_path): monkeypatch.setenv("ASTERISK_QUEUE_MAP_JSON", '{"lab":"que_lab"}') @@ -3153,9 +3287,12 @@ def test_voice_ai_handoff_revives_false_ended_call_when_channel_is_resolvable(mo assert result.call_id == call_id assert ami_calls - assert ami_calls[0][0] == "Redirect" - assert ami_calls[0][1]["Channel"] == "PJSIP/1001-0000077" - assert ami_calls[0][1]["Exten"] == "2001" + assert [call[0] for call in ami_calls[:4]] == ["Setvar", "Setvar", "Setvar", "Setvar"] + assert ami_calls[0][1]["Variable"] == "MVPCC_START_LANGUAGE" + assert ami_calls[3][1]["Variable"] == "MVPCC_CUSTOMER_NAME_SOURCE" + assert ami_calls[-1][0] == "Redirect" + assert ami_calls[-1][1]["Channel"] == "PJSIP/1001-0000077" + assert ami_calls[-1][1]["Exten"] == "2001" payload = { "status_label": "AI передал звонок оператору", "customer_request_text": "Соедините меня с оператором", diff --git a/tests/test_demo_ready_scripts.py b/tests/test_demo_ready_scripts.py index f3c3051..f3fca3c 100644 --- a/tests/test_demo_ready_scripts.py +++ b/tests/test_demo_ready_scripts.py @@ -54,6 +54,23 @@ def test_demo_seed_plan_contains_demo_keyword(): assert plan["ivr_kz_menu_prompt"] == "Сату бөлімі үшін 1 басыңыз. Қолдау қызметі үшін 2 басыңыз." +def test_demo_ivr_flow_routes_language_to_voice_start_queues(): + plan = demo_seed.build_seed_plan("TAG123") + flow = demo_seed._demo_ivr_flow_document( + plan, + voice_start_kz_queue_id="que_kz", + voice_start_ru_queue_id="que_ru", + ) + + root = flow["nodes"][0] + assert root["options"] == [ + {"digit": "1", "target_node_id": "voice_start_kz"}, + {"digit": "2", "target_node_id": "voice_start_ru"}, + ] + assert flow["nodes"][1]["resolved_queue_code"] == "voice_start_ru" + assert flow["nodes"][2]["resolved_queue_code"] == "voice_start_kz" + + def test_prepare_demo_script_resets_local_data_and_prints_backstage_hints(): script = (Path(__file__).resolve().parents[1] / "scripts" / "prepare_demo.ps1").read_text(encoding="utf-8") diff --git a/tests/test_gateway_ui.py b/tests/test_gateway_ui.py index 2f92391..365bed6 100644 --- a/tests/test_gateway_ui.py +++ b/tests/test_gateway_ui.py @@ -84,6 +84,8 @@ def test_contracts_shape(): assert "/ai/whatsapp/threads/*" in payload["stage_16"] assert "/ai/analytics/overview" in payload["stage_16"] assert "/ai/analytics/timeseries" in payload["stage_16"] + assert "/ai/analytics/voice-name-flow/overview" in payload["stage_16"] + assert "/ai/analytics/voice-name-flow/timeseries" in payload["stage_16"] assert "/ai/analytics/drilldown" in payload["stage_16"] assert "/ai/analytics/sessions/*" in payload["stage_16"] assert "/asterisk/live-calls/*/ai-summary" in payload["stage_17"] @@ -300,6 +302,51 @@ def test_operator_ui_contains_browser_softphone_popup_controls(): assert "function stopBrowserPhoneRingtone" in app_js +def test_operator_ui_can_fix_customer_name_from_call_views(): + app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8") + index_html = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "index.html").read_text(encoding="utf-8") + styles_css = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "styles.css").read_text(encoding="utf-8") + assert 'id="liveCallNameEditor"' in index_html + assert 'id="liveCallNameInput"' in index_html + assert 'id="liveCallNameSaveBtn"' in index_html + assert 'id="browserPhoneNameEditor"' in index_html + assert 'id="browserPhoneNameInput"' in index_html + assert 'id="browserPhoneNameSaveBtn"' in index_html + assert 'id="browserPhoneEditNameBtn"' in index_html + assert "function openLiveCallNameEditor" in app_js + assert "function saveLiveCallCustomerName" in app_js + assert "function applyCustomerNamePatchLocally" in app_js + assert "function updateLiveCallNameEditorsUi" in app_js + assert "function handleLiveCallTableClick" in app_js + assert "api('customer', `customers/${encodeURIComponent(customerId)}`" in app_js + assert "customer_name_source: 'manual'" in app_js + assert ".live-call-name-editor {" in styles_css + assert ".call-window-name-editor {" in styles_css + assert ".live-call-card-actions {" in styles_css + + +def test_operator_ui_surfaces_voice_name_state_across_call_views(): + app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8") + styles_css = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "styles.css").read_text(encoding="utf-8") + index_html = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "index.html").read_text(encoding="utf-8") + assert "function voiceCustomerNameStatusMeta" in app_js + assert "function formatVoiceCustomerNameSource" in app_js + assert "function formatVoiceStartLanguage" in app_js + assert "function voiceCustomerDisplayName" in app_js + assert "function voiceCustomerIncomingMeta" in app_js + assert "function refreshVoiceSummaryDependentViews" in app_js + assert "Источник имени" in app_js + assert "Язык старта" in app_js + assert "Имя подтверждено" in app_js + assert "Имя нужно уточнить" in app_js + assert "Без подтверждения" in app_js + assert ".micro-badge.name-confirmed {" in styles_css + assert ".micro-badge.name-followup {" in styles_css + assert ".micro-badge.name-missing {" in styles_css + assert "/operator/assets/styles.css?v=track21-voice-name-edit1" in index_html + assert "/operator/assets/app.js?v=track40-voice-name-edit1" in index_html + + def test_operator_ui_hides_whatsapp_behind_feature_flag(): app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8") index_html = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "index.html").read_text(encoding="utf-8") @@ -410,6 +457,15 @@ def test_analyst_ui_contains_analytics_dashboard_contract(): assert 'id="analyticsRefreshBtn"' in index_html assert 'id="analyticsResetBtn"' in index_html assert 'id="analyticsOverview"' in index_html + assert 'id="voiceNameAnalyticsPanel"' in index_html + assert 'id="voiceNameAnalyticsOverview"' in index_html + assert 'id="voiceNameAnalyticsTrendMetric"' in index_html + assert 'id="voiceNameAnalyticsTrendChart"' in index_html + assert 'id="voiceNameAnalyticsFunnel"' in index_html + assert 'id="voiceNameAnalyticsLanguageTable"' in index_html + assert 'id="voiceNameAnalyticsQueueTable"' in index_html + assert 'id="voiceNameAnalyticsHandoffTable"' in index_html + assert 'id="voiceNameAnalyticsEmptyState"' in index_html assert 'id="analyticsTrendMetric"' in index_html assert 'id="analyticsTrendChart"' in index_html assert 'id="analyticsChannelTable"' in index_html @@ -470,6 +526,8 @@ def test_analyst_ui_contains_analytics_dashboard_contract(): assert "api('routing', 'queues')" in app_js assert "api('ai', `ai/analytics/overview?" in app_js assert "api('ai', `ai/analytics/timeseries?" in app_js + assert "api('ai', `ai/analytics/voice-name-flow/overview?" in app_js + assert "api('ai', `ai/analytics/voice-name-flow/timeseries?" in app_js assert "api('ai', `ai/analytics/drilldown?" in app_js assert "api('ai', `ai/analytics/sessions/${encodeURIComponent(sessionId)}`)" in app_js assert "api('interaction'," in app_js @@ -485,6 +543,14 @@ def test_analyst_ui_contains_analytics_dashboard_contract(): assert "function renderAnalyticsNarrative" in app_js assert "function renderAnalyticsComparePanel" in app_js assert "function renderAnalyticsTrendChart" in app_js + assert "function renderVoiceNameAnalyticsOverview" in app_js + assert "function renderVoiceNameAnalyticsTrendChart" in app_js + assert "function renderVoiceNameAnalyticsFunnel" in app_js + assert "function renderVoiceNameAnalyticsLanguageTable" in app_js + assert "function renderVoiceNameAnalyticsQueueTable" in app_js + assert "function renderVoiceNameAnalyticsHandoffTable" in app_js + assert "function loadVoiceNameAnalyticsTrend" in app_js + assert "function voiceNameAnalyticsSupportedChannel" in app_js assert "function renderAiAnalyticsTrendChart" in app_js assert "function renderAiAnalyticsOverview" in app_js assert "function renderAiAnalyticsOutcomeTable" in app_js @@ -506,6 +572,7 @@ def test_analyst_ui_contains_analytics_dashboard_contract(): assert "const ANALYTICS_MOCK_DATA_URL = '/analyst/assets/mock-analytics.json';" in app_js assert "await ensureAnalyticsMockDataLoaded();" in app_js assert mock_json.exists() + assert '"voice_name_flow"' in mock_json.read_text(encoding="utf-8") assert "window.history.replaceState(null, '', nextUrl);" in app_js assert "url.searchParams.set('dd_mode', drilldown.mode);" in app_js assert "url.searchParams.set('dd_selected', drilldown.selectedId);" in app_js @@ -614,6 +681,22 @@ def test_admin_ui_contains_ivr_block_and_endpoints(): assert 'id="previewIvrRouteBtn"' in index_html +def test_admin_ui_contains_voice_name_collection_block_and_endpoints(): + app_js = (Path(__file__).resolve().parents[1] / "ui" / "admin" / "app.js").read_text(encoding="utf-8") + index_html = (Path(__file__).resolve().parents[1] / "ui" / "admin" / "index.html").read_text(encoding="utf-8") + assert "api('ai', 'ai/voice/config/name-collection')" in app_js + assert "function loadVoiceNameConfig()" in app_js + assert "function saveVoiceNameConfig()" in app_js + assert "function resetVoiceNameConfigForm()" in app_js + assert "function serializeVoiceNameConfigForm()" in app_js + assert 'id="voiceNameCollection"' in index_html + assert 'id="loadVoiceNameConfigBtn"' in index_html + assert 'id="saveVoiceNameConfigBtn"' in index_html + assert 'id="resetVoiceNameConfigBtn"' in index_html + assert 'id="voiceNameSummary"' in index_html + assert 'id="voiceNameConfigOutput"' in index_html + + def test_admin_ui_contains_asterisk_bridge_diagnostics(): app_js = (Path(__file__).resolve().parents[1] / "ui" / "admin" / "app.js").read_text(encoding="utf-8") index_html = (Path(__file__).resolve().parents[1] / "ui" / "admin" / "index.html").read_text(encoding="utf-8") diff --git a/tests/test_operator_core.py b/tests/test_operator_core.py index 27d91ca..4608b37 100644 --- a/tests/test_operator_core.py +++ b/tests/test_operator_core.py @@ -8,9 +8,12 @@ from services.shared.db import get_session from services.shared.sql_models import ( AsteriskCallLinkRow, CallRecordingRow, + Customer, + CustomerExternalIdentity, Queue, TelegramMessageRow, TelegramThreadRow, + VoiceAISessionRow, VoiceTranscriptSegmentRow, ) @@ -254,6 +257,148 @@ def test_customer_history_aggregates_telegram_and_voice_context(): assert any("оператор" in (item["note"] or "") for item in payload["history"]) +def test_operator_can_fix_customer_name_and_sync_voice_context(): + customer_client = TestClient(customer_app) + interaction_client = TestClient(interaction_app) + + customer = customer_client.post( + "/customers", + json={ + "display_name": "Номер 1001", + "phones": ["+77010001001"], + "preferred_phone": "+77010001001", + }, + ) + assert customer.status_code == 200 + customer_id = customer.json()["customer_id"] + + interaction = interaction_client.post( + "/interactions", + json={ + "channel": "voice", + "subject": "Inbound call 1001", + "customer_id": customer_id, + "queue_id": "q_voice", + "priority": 2, + }, + headers={"X-User": "operator", "X-Role": "operator"}, + ) + assert interaction.status_code == 200 + interaction_id = interaction.json()["interaction_id"] + + session = get_session() + try: + session.add( + CustomerExternalIdentity( + identity_id="cei_voice_fix_name", + customer_id=customer_id, + channel="voice", + external_subject="+77010001001", + display_name_snapshot="Номер 1001", + created_at="2026-04-05T09:00:00+00:00", + updated_at="2026-04-05T09:00:00+00:00", + ) + ) + session.add( + AsteriskCallLinkRow( + call_id="call_fix_name_customer", + linked_id="linked_fix_name_customer", + queue_code="voice_support", + queue_id="q_voice", + interaction_id=interaction_id, + caller_number="+77010001001", + caller_name="Номер 1001", + status="active", + telephony_status="connected", + claimed_by_user="operator_a", + claimed_at="2026-04-05T09:01:00+00:00", + operator_extension="2001", + channel_name="PJSIP/2001-000010", + voice_session_id="vas_fix_name_customer", + ai_session_id="ais_fix_name_customer", + ai_state="human_owned", + ai_handoff_reason="Оператор уточняет имя клиента", + ai_last_model_at="2026-04-05T09:02:00+00:00", + voice_start_language="ru", + customer_name_status="name_followup_required", + customer_name_value="Айдос?", + customer_name_source="voice_followup", + customer_name_resolved_at="2026-04-05T09:02:00+00:00", + started_at="2026-04-05T09:00:00+00:00", + connected_at="2026-04-05T09:01:00+00:00", + ended_at=None, + updated_at="2026-04-05T09:02:00+00:00", + ) + ) + session.add( + VoiceAISessionRow( + session_id="vas_fix_name_customer", + call_id="call_fix_name_customer", + linked_id="linked_fix_name_customer", + interaction_id=interaction_id, + customer_id=customer_id, + queue_id="q_voice", + ai_session_id="ais_fix_name_customer", + agent_profile="voice_support", + language="ru", + asr_provider="mock", + tts_provider="mock", + status="handoff_requested", + handoff_reason="Оператор уточняет имя клиента", + handoff_target_queue_id="q_voice", + voice_start_language="ru", + customer_name_status="name_followup_required", + customer_name_value="Айдос?", + customer_name_source="voice_followup", + customer_name_resolved_at="2026-04-05T09:02:00+00:00", + media_uuid=None, + media_status=None, + media_connected_at=None, + media_ended_at=None, + last_media_frame_at=None, + disclosure_played_at=None, + last_user_utterance_at=None, + last_ai_reply_at=None, + started_at="2026-04-05T09:00:00+00:00", + updated_at="2026-04-05T09:02:00+00:00", + ended_at=None, + ) + ) + session.commit() + finally: + session.close() + + updated = customer_client.patch( + f"/customers/{customer_id}", + json={"display_name": "Айдос", "source": "manual"}, + ) + assert updated.status_code == 200 + assert updated.json()["display_name"] == "Айдос" + + session = get_session() + try: + customer_row = session.query(Customer).filter(Customer.customer_id == customer_id).one() + identity_row = session.query(CustomerExternalIdentity).filter(CustomerExternalIdentity.customer_id == customer_id).one() + call_row = session.query(AsteriskCallLinkRow).filter(AsteriskCallLinkRow.call_id == "call_fix_name_customer").one() + voice_row = session.query(VoiceAISessionRow).filter(VoiceAISessionRow.call_id == "call_fix_name_customer").one() + assert customer_row.display_name == "Айдос" + assert identity_row.display_name_snapshot == "Айдос" + assert call_row.caller_name == "Айдос" + assert call_row.customer_name_status == "name_obtained" + assert call_row.customer_name_value == "Айдос" + assert call_row.customer_name_source == "manual" + assert voice_row.customer_name_status == "name_obtained" + assert voice_row.customer_name_value == "Айдос" + assert voice_row.customer_name_source == "manual" + finally: + session.close() + + history = customer_client.get(f"/customers/{customer_id}/history") + assert history.status_code == 200 + assert history.json()["customer"]["display_name"] == "Айдос" + assert history.json()["live_calls"][0]["caller_name"] == "Айдос" + + def test_queue_rules_and_route(): routing_client = TestClient(routing_app) diff --git a/tests/test_voice_start_policy.py b/tests/test_voice_start_policy.py new file mode 100644 index 0000000..5239f98 --- /dev/null +++ b/tests/test_voice_start_policy.py @@ -0,0 +1,45 @@ +import importlib.util +from pathlib import Path +from types import SimpleNamespace + + +VOICE_MODULE_PATH = Path(__file__).resolve().parents[1] / "services" / "ai_orchestrator_service" / "voice.py" +VOICE_SPEC = importlib.util.spec_from_file_location("voice_start_policy_under_test", VOICE_MODULE_PATH) +assert VOICE_SPEC is not None and VOICE_SPEC.loader is not None +voice_policy = importlib.util.module_from_spec(VOICE_SPEC) +VOICE_SPEC.loader.exec_module(voice_policy) + + +def test_voice_start_name_outcome_obtains_clear_name(): + status, value, source = voice_policy._voice_start_name_outcome("Меня зовут Айдос", "ru") + + assert status == "name_obtained" + assert value == "Айдос" + assert source == "voice_start" + + +def test_voice_start_name_outcome_marks_low_signal_as_not_obtained(): + status, value, source = voice_policy._voice_start_name_outcome("Алло", "ru") + + assert status == "name_not_obtained" + assert value is None + assert source == "none" + + +def test_voice_start_name_outcome_marks_mixed_name_and_request_for_followup(): + status, value, source = voice_policy._voice_start_name_outcome( + "Меня зовут Айдос, хотел узнать тариф", + "ru", + ) + + assert status == "name_followup_required" + assert value == "Айдос" + assert source == "voice_start" + + +def test_display_name_looks_trusted_rejects_phone_and_accepts_real_name(): + customer = SimpleNamespace(display_name="+77010000001") + assert voice_policy._display_name_looks_trusted(customer, "+77010000001", None) is False + + named_customer = SimpleNamespace(display_name="Алия") + assert voice_policy._display_name_looks_trusted(named_customer, "+77010000001", None) is True diff --git a/ui/admin/app.js b/ui/admin/app.js index d08387e..8be7830 100644 --- a/ui/admin/app.js +++ b/ui/admin/app.js @@ -10,6 +10,7 @@ const state = { loginPath: '/auth/oidc/start?return_mode=popup', providerLabel: 'Keycloak', }, + voiceNameConfig: null, }; const $ = (id) => document.getElementById(id); @@ -290,8 +291,8 @@ function defaultIvrFlowDocument() { invalid_target_node_id: 'root', no_input_target_node_id: 'root', options: [ - { digit: '1', target_node_id: 'menu_kz' }, - { digit: '2', target_node_id: 'menu_ru' }, + { digit: '1', target_node_id: 'voice_start_kz' }, + { digit: '2', target_node_id: 'voice_start_ru' }, ], }, { @@ -362,9 +363,60 @@ function defaultIvrFlowDocument() { }; } +function defaultVoiceStartIvrFlowDocument() { + return { + nodes: [ + { + node_id: 'root', + prompt_text: + '\u0421\u0430\u043b\u0430\u043c\u0430\u0442\u0441\u044b\u0437 \u0431\u0430! \u049a\u0430\u0437\u0430\u049b \u0442\u0456\u043b\u0456\u043d \u0442\u0430\u04a3\u0434\u0430\u0443 \u04af\u0448\u0456\u043d \u0431\u0456\u0440 \u0446\u0438\u0444\u0440\u044b\u043d \u0442\u0435\u0440\u0456\u04a3\u0456\u0437. \u0417\u0434\u0440\u0430\u0432\u0441\u0442\u0432\u0443\u0439\u0442\u0435! \u0414\u043e\u0431\u0440\u043e \u043f\u043e\u0436\u0430\u043b\u043e\u0432\u0430\u0442\u044c \u0432 \u043a\u043e\u043d\u0442\u0430\u043a\u0442-\u0446\u0435\u043d\u0442\u0440. \u0414\u043b\u044f \u0440\u0443\u0441\u0441\u043a\u043e\u0433\u043e \u044f\u0437\u044b\u043a\u0430 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u0446\u0438\u0444\u0440\u0443 \u0434\u0432\u0430.', + prompt_sequence: [ + { + prompt_audio_key: 'ivr/demo-language-kz', + prompt_text: + '\u0421\u0430\u043b\u0430\u043c\u0430\u0442\u0441\u044b\u0437 \u0431\u0430! \u049a\u0430\u0437\u0430\u049b \u0442\u0456\u043b\u0456\u043d \u0442\u0430\u04a3\u0434\u0430\u0443 \u04af\u0448\u0456\u043d \u0431\u0456\u0440 \u0446\u0438\u0444\u0440\u044b\u043d \u0442\u0435\u0440\u0456\u04a3\u0456\u0437.', + language: 'kz', + }, + { + prompt_audio_key: 'ivr/demo-language-ru', + prompt_text: + '\u0417\u0434\u0440\u0430\u0432\u0441\u0442\u0432\u0443\u0439\u0442\u0435! \u0414\u043e\u0431\u0440\u043e \u043f\u043e\u0436\u0430\u043b\u043e\u0432\u0430\u0442\u044c \u0432 \u043a\u043e\u043d\u0442\u0430\u043a\u0442-\u0446\u0435\u043d\u0442\u0440. \u0414\u043b\u044f \u0440\u0443\u0441\u0441\u043a\u043e\u0433\u043e \u044f\u0437\u044b\u043a\u0430 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u0446\u0438\u0444\u0440\u0443 \u0434\u0432\u0430.', + language: 'ru', + }, + ], + is_terminal: false, + invalid_target_node_id: 'root', + no_input_target_node_id: 'root', + options: [ + { digit: '1', target_node_id: 'voice_start_kz' }, + { digit: '2', target_node_id: 'voice_start_ru' }, + ], + }, + { + node_id: 'voice_start_kz', + prompt_text: '\u049a\u043e\u04a3\u044b\u0440\u0430\u0443\u0434\u044b \u049b\u0430\u0437\u0430\u049b \u0442\u0456\u043b\u0456\u043d\u0434\u0435\u0433\u0456 \u0431\u0430\u0441\u0442\u0430\u043f\u049b\u044b voice-\u0441\u0446\u0435\u043d\u0430\u0440\u0438\u0439\u0433\u0435 \u04e9\u0442\u043a\u0456\u0437\u0456\u043f \u0436\u0430\u0442\u044b\u0440\u043c\u044b\u0437.', + is_terminal: true, + outcome_code: 'voice_start_kz', + resolved_queue_id: 'voice_start_kz_line', + resolved_queue_code: 'voice_start_kz', + options: [], + }, + { + node_id: 'voice_start_ru', + prompt_text: '\u041f\u0435\u0440\u0435\u0434\u0430\u0435\u043c \u0437\u0432\u043e\u043d\u043e\u043a \u0432 \u0440\u0443\u0441\u0441\u043a\u043e\u044f\u0437\u044b\u0447\u043d\u044b\u0439 \u0441\u0442\u0430\u0440\u0442\u043e\u0432\u044b\u0439 voice-\u0441\u0446\u0435\u043d\u0430\u0440\u0438\u0439.', + is_terminal: true, + outcome_code: 'voice_start_ru', + resolved_queue_id: 'voice_start_ru_line', + resolved_queue_code: 'voice_start_ru', + options: [], + }, + ], + }; +} + function ensureDefaultIvrFlowJson() { if (!$('ivrFlowJson').value.trim()) { - $('ivrFlowJson').value = JSON.stringify(defaultIvrFlowDocument(), null, 2); + $('ivrFlowJson').value = JSON.stringify(defaultVoiceStartIvrFlowDocument(), null, 2); } } @@ -444,7 +496,7 @@ function syncIvrFlowFields(flow) { $('ivrQueueId').value = flow.queue_id || $('ivrQueueId').value; $('ivrEntryNodeId').value = flow.entry_node_id || $('ivrEntryNodeId').value; $('ivrIsActive').checked = Boolean(flow.is_active); - $('ivrFlowJson').value = JSON.stringify(flow.flow_json || defaultIvrFlowDocument(), null, 2); + $('ivrFlowJson').value = JSON.stringify(flow.flow_json || defaultVoiceStartIvrFlowDocument(), null, 2); } function syncIvrSessionFields(payload) { @@ -458,6 +510,146 @@ function syncIvrSessionFields(payload) { $('ivrTestInteractionId').value = session.interaction_id || $('ivrTestInteractionId').value; } +function serializeVoiceNameConfigForm() { + return { + enabled: $('voiceNameEnabled').checked, + start: { + ask_name_on_start: $('voiceNameAskOnStart').checked, + known_customer_behavior: $('voiceNameKnownCustomerBehavior').value, + unknown_customer_behavior: $('voiceNameUnknownCustomerBehavior').value, + }, + downstream: { + missing_name_behavior: $('voiceNameMissingNameBehavior').value, + uncertain_name_behavior: $('voiceNameUncertainNameBehavior').value, + finalize_on_explicit_name: $('voiceNameFinalizeOnExplicitName').checked, + finalize_on_confirmation: $('voiceNameFinalizeOnConfirmation').checked, + }, + texts: { + ru: { + start_prompt: $('voiceNameRuStartPrompt').value.trim(), + personalized_greeting_template: $('voiceNameRuPersonalizedGreeting').value.trim(), + confirmation_greeting_template: $('voiceNameRuConfirmationGreeting').value.trim(), + inline_followup_prompt: $('voiceNameRuInlineFollowup').value.trim(), + }, + kz: { + start_prompt: $('voiceNameKzStartPrompt').value.trim(), + personalized_greeting_template: $('voiceNameKzPersonalizedGreeting').value.trim(), + confirmation_greeting_template: $('voiceNameKzConfirmationGreeting').value.trim(), + inline_followup_prompt: $('voiceNameKzInlineFollowup').value.trim(), + }, + }, + }; +} + +function syncVoiceNameConfigFields(payload) { + const config = payload?.config || payload; + if (!config) { + return; + } + $('voiceNameEnabled').checked = Boolean(config.enabled); + $('voiceNameAskOnStart').checked = Boolean(config.start?.ask_name_on_start); + $('voiceNameKnownCustomerBehavior').value = config.start?.known_customer_behavior || 'trust_and_handoff'; + $('voiceNameUnknownCustomerBehavior').value = config.start?.unknown_customer_behavior || 'ask_on_start'; + $('voiceNameMissingNameBehavior').value = config.downstream?.missing_name_behavior || 'ask_inline_once'; + $('voiceNameUncertainNameBehavior').value = config.downstream?.uncertain_name_behavior || 'confirm_then_finalize'; + $('voiceNameFinalizeOnExplicitName').checked = Boolean(config.downstream?.finalize_on_explicit_name); + $('voiceNameFinalizeOnConfirmation').checked = Boolean(config.downstream?.finalize_on_confirmation); + $('voiceNameRuStartPrompt').value = config.texts?.ru?.start_prompt || ''; + $('voiceNameRuPersonalizedGreeting').value = config.texts?.ru?.personalized_greeting_template || ''; + $('voiceNameRuConfirmationGreeting').value = config.texts?.ru?.confirmation_greeting_template || ''; + $('voiceNameRuInlineFollowup').value = config.texts?.ru?.inline_followup_prompt || ''; + $('voiceNameKzStartPrompt').value = config.texts?.kz?.start_prompt || ''; + $('voiceNameKzPersonalizedGreeting').value = config.texts?.kz?.personalized_greeting_template || ''; + $('voiceNameKzConfirmationGreeting').value = config.texts?.kz?.confirmation_greeting_template || ''; + $('voiceNameKzInlineFollowup').value = config.texts?.kz?.inline_followup_prompt || ''; +} + +function renderVoiceNameConfigSummary(payload) { + const config = payload?.config || payload; + const source = payload?.source || 'defaults'; + const updatedAt = payload?.updated_at || 'не сохранялось'; + const knownLabels = { + trust_and_handoff: 'известное имя сразу принимается и передаётся дальше', + confirm_in_downstream: 'известное имя уходит на подтверждение в downstream', + ask_on_start: 'известного клиента всё равно спрашиваем на старте', + }; + const unknownLabels = { + ask_on_start: 'неизвестного клиента спрашиваем на старте', + skip_to_downstream: 'неизвестного клиента сразу передаём в downstream', + }; + const missingLabels = { + ask_inline_once: 'если имя не получено, AI спросит inline один раз', + do_not_ask: 'если имя не получено, AI inline не спрашивает', + }; + const uncertainLabels = { + confirm_then_finalize: 'неуверенное имя нужно подтвердить', + finalize_immediately: 'неуверенное имя считается финальным сразу', + discard_and_collect: 'неуверенное имя сбрасывается и собирается заново', + }; + $('voiceNameSummary').textContent = [ + `Сценарий: ${config?.enabled ? 'включён' : 'выключен'}`, + `Стартовый вопрос про имя: ${config?.start?.ask_name_on_start ? 'да' : 'нет'}`, + `Известный клиент: ${knownLabels[config?.start?.known_customer_behavior] || config?.start?.known_customer_behavior || 'не задано'}`, + `Неизвестный клиент: ${unknownLabels[config?.start?.unknown_customer_behavior] || config?.start?.unknown_customer_behavior || 'не задано'}`, + `Если имя не получено: ${missingLabels[config?.downstream?.missing_name_behavior] || config?.downstream?.missing_name_behavior || 'не задано'}`, + `Если имя неуверенное: ${uncertainLabels[config?.downstream?.uncertain_name_behavior] || config?.downstream?.uncertain_name_behavior || 'не задано'}`, + `Финализация по явному имени: ${config?.downstream?.finalize_on_explicit_name ? 'да' : 'нет'}`, + `Финализация по подтверждению: ${config?.downstream?.finalize_on_confirmation ? 'да' : 'нет'}`, + `Источник конфигурации: ${source}`, + `Последнее обновление: ${updatedAt}`, + ].join('\n'); +} + +async function loadVoiceNameConfig() { + try { + const data = await api('ai', 'ai/voice/config/name-collection'); + state.voiceNameConfig = data; + syncVoiceNameConfigFields(data); + renderVoiceNameConfigSummary(data); + $('voiceNameConfigOutput').textContent = JSON.stringify(data, null, 2); + log('Настройки voice name collection загружены', { + source: data.source, + updated_at: data.updated_at, + }); + } catch (err) { + $('voiceNameConfigOutput').textContent = err.message; + log('Не удалось загрузить настройки voice name collection', { error: err.message }); + } +} + +async function saveVoiceNameConfig() { + try { + const payload = serializeVoiceNameConfigForm(); + const data = await api('ai', 'ai/voice/config/name-collection', { + method: 'PUT', + body: JSON.stringify(payload), + }); + state.voiceNameConfig = data; + syncVoiceNameConfigFields(data); + renderVoiceNameConfigSummary(data); + $('voiceNameConfigOutput').textContent = JSON.stringify(data, null, 2); + log('Настройки voice name collection сохранены', { + source: data.source, + updated_at: data.updated_at, + }); + } catch (err) { + $('voiceNameConfigOutput').textContent = err.message; + log('Не удалось сохранить настройки voice name collection', { error: err.message }); + } +} + +function resetVoiceNameConfigForm() { + const snapshot = state.voiceNameConfig; + if (!snapshot) { + $('voiceNameConfigOutput').textContent = 'Сначала загрузите текущие настройки voice name collection.'; + return; + } + syncVoiceNameConfigFields(snapshot); + renderVoiceNameConfigSummary(snapshot); + $('voiceNameConfigOutput').textContent = JSON.stringify(snapshot, null, 2); + log('Форма voice name collection сброшена к текущим настройкам'); +} + function parseIvrFlowJson() { ensureDefaultIvrFlowJson(); return JSON.parse($('ivrFlowJson').value || '{}'); @@ -980,7 +1172,14 @@ function wire() { $('loginBtn').addEventListener('click', login); $('corporateLoginBtn').addEventListener('click', startCorporateLogin); $('refreshBtn').addEventListener('click', async () => { - await Promise.all([loadUsers(), loadQueues(), loadIvrFlows(), loadAsteriskStatus(), loadAsteriskEvents()]); + await Promise.all([ + loadUsers(), + loadQueues(), + loadIvrFlows(), + loadVoiceNameConfig(), + loadAsteriskStatus(), + loadAsteriskEvents(), + ]); log('Данные админ-консоли обновлены'); }); $('loadUsersBtn').addEventListener('click', loadUsers); @@ -1002,6 +1201,9 @@ function wire() { $('sendIvrDigitBtn').addEventListener('click', sendIvrDigit); $('loadIvrSessionBtn').addEventListener('click', loadIvrSession); $('previewIvrRouteBtn').addEventListener('click', previewIvrRoute); + $('loadVoiceNameConfigBtn').addEventListener('click', loadVoiceNameConfig); + $('saveVoiceNameConfigBtn').addEventListener('click', saveVoiceNameConfig); + $('resetVoiceNameConfigBtn').addEventListener('click', resetVoiceNameConfigForm); $('loadAsteriskStatusBtn').addEventListener('click', loadAsteriskStatus); $('loadAsteriskEventsBtn').addEventListener('click', loadAsteriskEvents); $('loadAsteriskEventDetailBtn').addEventListener('click', loadAsteriskEventDetail); @@ -1022,7 +1224,16 @@ async function init() { wire(); ensureDefaultIvrFlowJson(); updateSessionInfo(); - await Promise.all([checkGateway(), loadOidcConfig(), loadUsers(), loadQueues(), loadIvrFlows(), loadAsteriskStatus(), loadAsteriskEvents()]); + await Promise.all([ + checkGateway(), + loadOidcConfig(), + loadUsers(), + loadQueues(), + loadIvrFlows(), + loadVoiceNameConfig(), + loadAsteriskStatus(), + loadAsteriskEvents(), + ]); log('Админ-консоль готова'); } diff --git a/ui/admin/index.html b/ui/admin/index.html index c1801f4..68742f3 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -35,6 +35,7 @@ Очереди Маршрутизация IVR + Voice AI Asterisk @@ -250,6 +251,81 @@
Предпросмотр IVR-маршрута появится здесь.
+
+

Voice AI: сбор имени

+

Глобальная админ-настройка сценария определения имени клиента для voice_start и downstream AI.

+
Сводка по текущим настройкам появится здесь.
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + +
+
Raw payload voice name collection появится здесь.
+
+

Мост Asterisk

Диагностика AMI-подключения, пересланных bridge-событий и ручного повтора.

diff --git a/ui/analyst/app.js b/ui/analyst/app.js index 92d0a7b..8f8bd16 100644 --- a/ui/analyst/app.js +++ b/ui/analyst/app.js @@ -12,11 +12,15 @@ const state = { channel: 'all', compareMode: 'previous', trendMetric: 'volume', + voiceNameTrendMetric: 'scenario_calls', aiTrendMetric: 'containment_rate', agentTrendMetric: 'interactions_per_agent', overview: null, compare: null, trend: [], + voiceNameOverview: null, + voiceNameCompare: null, + voiceNameTrend: [], aiOverview: null, aiCompare: null, aiTrend: [], @@ -38,6 +42,8 @@ const state = { mockDataError: '', loading: false, error: '', + voiceNameError: '', + voiceNameTrendError: '', aiError: '', agentError: '', agentTrendError: '', @@ -81,6 +87,13 @@ const DRILLDOWN_PAGE_SIZE = 12; const DRILLDOWN_EXPORT_BATCH_SIZE = 100; const DRILLDOWN_EXPORT_SOFT_CAP = 1000; const ANALYTICS_TREND_OPTIONS = ['volume', 'SL', 'ASA', 'Abandon']; +const VOICE_NAME_TREND_OPTIONS = [ + 'scenario_calls', + 'start_capture_rate', + 'downstream_rescue_rate', + 'handoff_unconfirmed_rate', + 'manual_correction_rate', +]; const AI_ANALYTICS_TREND_OPTIONS = [ 'containment_rate', 'handoff_rate', @@ -104,6 +117,7 @@ const ANALYTICS_DEEP_LINK_KEYS = [ 'channel', 'compare', 'trend', + 'voice_name_trend', 'ai_trend', 'agent_trend', 'view', @@ -159,6 +173,36 @@ const ANALYTICS_TREND_LABELS = { Abandon: 'Потери', }; +const VOICE_NAME_TREND_LABELS = { + scenario_calls: 'Звонки в сценарии', + start_capture_rate: 'Имя взято сразу', + downstream_rescue_rate: 'Имя добрал AI после follow-up', + handoff_unconfirmed_rate: 'Передача без подтверждённого имени', + manual_correction_rate: 'Ручное исправление', +}; + +const VOICE_NAME_METRIC_META = { + scenario_calls: { unit: 'count', better: 'up', note: 'Все voice-звонки, прошедшие через сценарий сбора имени.' }, + start_capture_rate: { unit: 'pct', better: 'up', note: 'Доля звонков, где имя удалось взять сразу на стартовом этапе.' }, + downstream_rescue_rate: { unit: 'pct', better: 'up', note: 'Доля кейсов, где AI успешно добрал имя после стартового follow-up.' }, + handoff_unconfirmed_rate: { unit: 'pct', better: 'down', note: 'Доля звонков, переданных оператору без подтверждённого имени.' }, + manual_correction_rate: { unit: 'pct', better: 'down', note: 'Как часто оператору пришлось исправлять имя вручную.' }, +}; + +const VOICE_NAME_FUNNEL_LABELS = { + scenario_calls: 'Звонки в сценарии', + start_obtained: 'Имя взято сразу', + needed_downstream: 'Потребовался AI после старта', + downstream_ai_obtained: 'Имя добрал AI после follow-up', + handoff_confirmed_name: 'Передача с подтверждённым именем', + handoff_unconfirmed_name: 'Передача без подтверждённого имени', +}; + +const VOICE_NAME_HANDOFF_LABELS = { + confirmed_name: 'Передача с подтверждённым именем', + unconfirmed_name: 'Передача без подтверждённого имени', +}; + const ANALYTICS_METRIC_META = { total: { unit: 'count', better: 'up', note: 'Все обращения за выбранный период' }, answered: { unit: 'count', better: 'up', note: 'Обращения, обработанные без потери' }, @@ -622,6 +666,64 @@ function emptyAgentAnalyticsTimeseries(metric = 'interactions_per_agent', interv }; } +function emptyVoiceNameAnalyticsOverview(fromTs = null, toTs = null, queueId = null, language = null) { + return { + window: { + from_ts: fromTs, + to_ts: toTs, + }, + filters: { + from_ts: fromTs, + to_ts: toTs, + queue_id: queueId, + language, + }, + totals: { + scenario_calls: 0, + start_obtained: 0, + downstream_ai_obtained: 0, + followup_required: 0, + name_not_obtained: 0, + manual_corrected: 0, + handoff_confirmed_name: 0, + handoff_unconfirmed_name: 0, + needed_downstream: 0, + }, + metrics: { + start_capture_rate: 0, + downstream_rescue_rate: 0, + handoff_unconfirmed_rate: 0, + manual_correction_rate: 0, + }, + breakdowns: { + funnel: [], + by_language: [], + by_queue: [], + handoff: [], + }, + coverage: { + sessions_with_start_decision: 0, + sessions_with_final_ai_state: 0, + sessions_with_manual_overlay: 0, + note: null, + }, + }; +} + +function emptyVoiceNameAnalyticsTimeseries(metric = 'scenario_calls', interval = 'day', fromTs = null, toTs = null, queueId = null, language = null) { + return { + metric, + interval, + filters: { + from_ts: fromTs, + to_ts: toTs, + queue_id: queueId, + language, + }, + points: [], + }; +} + function emptyAnalyticsMetricCoverage() { return { status: 'unavailable', @@ -734,6 +836,7 @@ function syncAnalyticsStateFromControls() { state.analytics.channel = $('analyticsChannel').value || 'all'; state.analytics.compareMode = $('analyticsCompareMode').value || 'previous'; state.analytics.trendMetric = $('analyticsTrendMetric').value || 'volume'; + state.analytics.voiceNameTrendMetric = $('voiceNameAnalyticsTrendMetric')?.value || 'scenario_calls'; state.analytics.aiTrendMetric = $('aiAnalyticsTrendMetric')?.value || 'containment_rate'; state.analytics.agentTrendMetric = $('agentAnalyticsTrendMetric')?.value || 'interactions_per_agent'; } @@ -745,6 +848,9 @@ function syncAnalyticsControlsFromState() { $('analyticsChannel').value = state.analytics.channel || 'all'; $('analyticsCompareMode').value = state.analytics.compareMode || 'previous'; $('analyticsTrendMetric').value = state.analytics.trendMetric || 'volume'; + if ($('voiceNameAnalyticsTrendMetric')) { + $('voiceNameAnalyticsTrendMetric').value = state.analytics.voiceNameTrendMetric || 'scenario_calls'; + } if ($('aiAnalyticsTrendMetric')) { $('aiAnalyticsTrendMetric').value = state.analytics.aiTrendMetric || 'containment_rate'; } @@ -763,6 +869,7 @@ function resetAnalyticsFilters() { state.analytics.channel = 'all'; state.analytics.compareMode = 'previous'; state.analytics.trendMetric = 'volume'; + state.analytics.voiceNameTrendMetric = 'scenario_calls'; state.analytics.aiTrendMetric = 'containment_rate'; state.analytics.agentTrendMetric = 'interactions_per_agent'; state.analytics.activeViewId = ''; @@ -783,6 +890,7 @@ function analyticsCurrentSnapshot() { channel: state.analytics.channel || 'all', compareMode: state.analytics.compareMode || 'previous', trendMetric: state.analytics.trendMetric || 'volume', + voiceNameTrendMetric: state.analytics.voiceNameTrendMetric || 'scenario_calls', aiTrendMetric: state.analytics.aiTrendMetric || 'containment_rate', agentTrendMetric: state.analytics.agentTrendMetric || 'interactions_per_agent', }; @@ -1297,6 +1405,232 @@ function buildMockAiTrend(rangeMeta, metric) { }; } +function mockVoiceNameSeed() { + const seed = analyticsMockData().voice_name_flow; + return seed && typeof seed === 'object' ? seed : {}; +} + +function voiceNameMetricRatesFromTotals(totals) { + const scenarioCalls = Number(totals.scenario_calls || 0); + const neededDownstream = Number(totals.needed_downstream || Math.max(0, scenarioCalls - Number(totals.start_obtained || 0))); + const allHandoffs = Number(totals.handoff_confirmed_name || 0) + Number(totals.handoff_unconfirmed_name || 0); + return { + start_capture_rate: scenarioCalls ? Number(((Number(totals.start_obtained || 0) / scenarioCalls) * 100).toFixed(2)) : 0, + downstream_rescue_rate: neededDownstream ? Number(((Number(totals.downstream_ai_obtained || 0) / neededDownstream) * 100).toFixed(2)) : 0, + handoff_unconfirmed_rate: allHandoffs ? Number(((Number(totals.handoff_unconfirmed_name || 0) / allHandoffs) * 100).toFixed(2)) : 0, + manual_correction_rate: allHandoffs ? Number(((Number(totals.manual_corrected || 0) / allHandoffs) * 100).toFixed(2)) : 0, + }; +} + +function buildMockVoiceNameOverview(rangeMeta, options = {}) { + const previous = Boolean(options.previous); + const channel = options.channel ?? state.analytics.channel; + if (!(channel === 'all' || channel === 'voice')) { + return emptyVoiceNameAnalyticsOverview( + (previous ? rangeMeta.previous.from : rangeMeta.current.from).toISOString(), + (previous ? rangeMeta.previous.to : rangeMeta.current.to).toISOString(), + state.analytics.queueId === 'all' ? null : state.analytics.queueId, + null, + ); + } + const seed = mockVoiceNameSeed(); + const totalSeed = seed.totals || {}; + const queueId = options.queueId ?? state.analytics.queueId; + const factor = mockAnalyticsScale(previous ? rangeMeta.previous : rangeMeta.current) + * (previous ? 0.91 : 1) + * (queueId === 'all' ? 1 : Math.max(0.48, mockAnalyticsQueueFactor(queueId))); + const scenarioCalls = Math.max(0, Math.round(Number(totalSeed.scenario_calls || 180) * factor)); + const startRatio = Number(totalSeed.start_obtained || 110) / Math.max(Number(totalSeed.scenario_calls || 180), 1); + const downstreamRatio = Number(totalSeed.downstream_ai_obtained || 36) / Math.max(Number(totalSeed.scenario_calls || 180), 1); + const followupRatio = Number(totalSeed.followup_required || 20) / Math.max(Number(totalSeed.scenario_calls || 180), 1); + const handoffConfirmedRatio = Number(totalSeed.handoff_confirmed_name || 12) / Math.max(Number(totalSeed.scenario_calls || 180), 1); + const handoffUnconfirmedRatio = Number(totalSeed.handoff_unconfirmed_name || 26) / Math.max(Number(totalSeed.scenario_calls || 180), 1); + const manualRateSeed = Number(totalSeed.manual_corrected || 10) / Math.max((Number(totalSeed.handoff_confirmed_name || 12) + Number(totalSeed.handoff_unconfirmed_name || 26)), 1); + + const startObtained = Math.min(scenarioCalls, Math.round(scenarioCalls * startRatio)); + const downstreamAiObtained = Math.max(0, Math.round(scenarioCalls * downstreamRatio)); + const followupRequired = Math.max(0, Math.round(scenarioCalls * followupRatio)); + const nameNotObtained = Math.max(0, scenarioCalls - startObtained - downstreamAiObtained - followupRequired); + const neededDownstream = Math.max(0, scenarioCalls - startObtained); + const handoffConfirmedName = Math.max(0, Math.round(scenarioCalls * handoffConfirmedRatio)); + const handoffUnconfirmedName = Math.max(0, Math.round(scenarioCalls * handoffUnconfirmedRatio)); + const allHandoffs = Math.max(0, handoffConfirmedName + handoffUnconfirmedName); + const manualCorrected = Math.min(allHandoffs, Math.round(allHandoffs * manualRateSeed)); + const totals = { + scenario_calls: scenarioCalls, + start_obtained: startObtained, + downstream_ai_obtained: downstreamAiObtained, + followup_required: followupRequired, + name_not_obtained: nameNotObtained, + manual_corrected: manualCorrected, + handoff_confirmed_name: handoffConfirmedName, + handoff_unconfirmed_name: handoffUnconfirmedName, + needed_downstream: neededDownstream, + }; + const metrics = voiceNameMetricRatesFromTotals(totals); + + const languageSeeds = Array.isArray(seed.languages) ? seed.languages : []; + const languageRows = languageSeeds.map((item) => { + const share = Number(item.share || 0); + const calls = Math.max(0, Math.round(scenarioCalls * share)); + const startCaptured = Math.min(calls, Math.round(calls * Number(item.start_capture_rate || 0) / 100)); + const downstreamNeeded = Math.max(0, calls - startCaptured); + const downstreamCaptured = Math.min(downstreamNeeded, Math.round(downstreamNeeded * Number(item.downstream_rescue_rate || 0) / 100)); + const unresolved = Math.max(0, calls - startCaptured - downstreamCaptured); + const followup = Math.round(unresolved * 0.58); + const missing = Math.max(0, unresolved - followup); + const handoffs = Math.round(unresolved * 0.72); + const unconfirmed = Math.min(handoffs, Math.round(handoffs * Number(item.handoff_unconfirmed_rate || 0) / 100)); + const confirmed = Math.max(0, handoffs - unconfirmed); + const manual = Math.min(handoffs, Math.round(handoffs * Number(item.manual_correction_rate || 0) / 100)); + return { + language: item.language || 'unknown', + scenario_calls: calls, + start_obtained: startCaptured, + downstream_ai_obtained: downstreamCaptured, + followup_required: followup, + name_not_obtained: missing, + manual_corrected: manual, + handoff_confirmed_name: confirmed, + handoff_unconfirmed_name: unconfirmed, + ...voiceNameMetricRatesFromTotals({ + scenario_calls: calls, + start_obtained: startCaptured, + downstream_ai_obtained: downstreamCaptured, + needed_downstream: downstreamNeeded, + handoff_confirmed_name: confirmed, + handoff_unconfirmed_name: unconfirmed, + manual_corrected: manual, + }), + }; + }).filter((item) => item.scenario_calls > 0); + + const queueRows = mockAnalyticsQueueOptions() + .filter((item) => queueId === 'all' || item.queue_id === queueId) + .map((item, index) => { + const queueFactor = Math.max(0.28, mockAnalyticsQueueFactor(item.queue_id)); + const calls = Math.max(0, Math.round(scenarioCalls * (0.2 + index * 0.12) * queueFactor)); + const startCaptured = Math.min(calls, Math.round(calls * (0.69 - index * 0.04))); + const downstreamNeeded = Math.max(0, calls - startCaptured); + const downstreamCaptured = Math.min(downstreamNeeded, Math.round(downstreamNeeded * (0.45 - index * 0.05))); + const unresolved = Math.max(0, calls - startCaptured - downstreamCaptured); + const followup = Math.round(unresolved * 0.57); + const missing = Math.max(0, unresolved - followup); + const handoffs = Math.round(unresolved * (0.64 + index * 0.04)); + const unconfirmed = Math.min(handoffs, Math.round(handoffs * (0.38 + index * 0.05))); + const confirmed = Math.max(0, handoffs - unconfirmed); + const manual = Math.min(handoffs, Math.round(handoffs * (0.17 + index * 0.03))); + return { + queue_id: item.queue_id, + scenario_calls: calls, + start_obtained: startCaptured, + downstream_ai_obtained: downstreamCaptured, + followup_required: followup, + name_not_obtained: missing, + manual_corrected: manual, + handoff_confirmed_name: confirmed, + handoff_unconfirmed_name: unconfirmed, + ...voiceNameMetricRatesFromTotals({ + scenario_calls: calls, + start_obtained: startCaptured, + downstream_ai_obtained: downstreamCaptured, + needed_downstream: downstreamNeeded, + handoff_confirmed_name: confirmed, + handoff_unconfirmed_name: unconfirmed, + manual_corrected: manual, + }), + }; + }) + .filter((item) => item.scenario_calls > 0); + + return { + window: { + from_ts: (previous ? rangeMeta.previous.from : rangeMeta.current.from).toISOString(), + to_ts: (previous ? rangeMeta.previous.to : rangeMeta.current.to).toISOString(), + }, + filters: { + from_ts: (previous ? rangeMeta.previous.from : rangeMeta.current.from).toISOString(), + to_ts: (previous ? rangeMeta.previous.to : rangeMeta.current.to).toISOString(), + queue_id: queueId === 'all' ? null : queueId, + language: null, + }, + totals, + metrics, + breakdowns: { + funnel: [ + { stage: 'scenario_calls', label: 'Звонки в сценарии', sessions: scenarioCalls, share: 100 }, + { stage: 'start_obtained', label: 'Имя взято сразу', sessions: startObtained, share: scenarioCalls ? Number(((startObtained / scenarioCalls) * 100).toFixed(2)) : 0 }, + { stage: 'needed_downstream', label: 'Потребовался AI после старта', sessions: neededDownstream, share: scenarioCalls ? Number(((neededDownstream / scenarioCalls) * 100).toFixed(2)) : 0 }, + { stage: 'downstream_ai_obtained', label: 'Имя добрал AI после follow-up', sessions: downstreamAiObtained, share: scenarioCalls ? Number(((downstreamAiObtained / scenarioCalls) * 100).toFixed(2)) : 0 }, + { stage: 'handoff_confirmed_name', label: 'Передача с подтверждённым именем', sessions: handoffConfirmedName, share: scenarioCalls ? Number(((handoffConfirmedName / scenarioCalls) * 100).toFixed(2)) : 0 }, + { stage: 'handoff_unconfirmed_name', label: 'Передача без подтверждённого имени', sessions: handoffUnconfirmedName, share: scenarioCalls ? Number(((handoffUnconfirmedName / scenarioCalls) * 100).toFixed(2)) : 0 }, + ], + by_language: languageRows, + by_queue: queueRows, + handoff: [ + { outcome: 'confirmed_name', label: 'Передача с подтверждённым именем', sessions: handoffConfirmedName, share: allHandoffs ? Number(((handoffConfirmedName / allHandoffs) * 100).toFixed(2)) : 0 }, + { outcome: 'unconfirmed_name', label: 'Передача без подтверждённого имени', sessions: handoffUnconfirmedName, share: allHandoffs ? Number(((handoffUnconfirmedName / allHandoffs) * 100).toFixed(2)) : 0 }, + ], + }, + coverage: { + sessions_with_start_decision: Math.round(scenarioCalls * 0.98), + sessions_with_final_ai_state: Math.round(scenarioCalls * 0.94), + sessions_with_manual_overlay: manualCorrected, + note: analyticsMockData().coverage_note || 'Показаны демонстрационные данные по name-flow.', + }, + }; +} + +function buildMockVoiceNameTrend(rangeMeta, metric) { + const interval = analyticsTimeseriesIntervalForRange(rangeMeta); + const baseOverview = buildMockVoiceNameOverview(rangeMeta); + const points = []; + let cursor = new Date(interval === 'hour' + ? rangeMeta.current.from.getTime() + : startOfDay(rangeMeta.current.from).getTime()); + while (cursor < rangeMeta.current.to) { + const index = points.length; + const scenarioCalls = Math.max(4, Math.round((baseOverview.totals.scenario_calls || 0) / Math.max(5, index + 5) * (0.92 + (index % 4) * 0.06))); + const neededDownstream = Math.max(0, Math.round(scenarioCalls * 0.35)); + const handoffs = Math.max(1, Math.round(scenarioCalls * 0.18)); + let value = 0; + let denominator = scenarioCalls; + if (metric === 'start_capture_rate') { + value = 61 + (index % 4) * 2.4; + } else if (metric === 'downstream_rescue_rate') { + value = 42 + (index % 5) * 2.1; + denominator = neededDownstream; + } else if (metric === 'handoff_unconfirmed_rate') { + value = 34 + (index % 4) * 2.6; + denominator = handoffs; + } else if (metric === 'manual_correction_rate') { + value = 15 + (index % 3) * 1.8; + denominator = handoffs; + } else { + value = scenarioCalls; + denominator = scenarioCalls; + } + points.push({ + ts: cursor.toISOString(), + value: Number(value.toFixed(metric === 'scenario_calls' ? 0 : 2)), + scenario_calls: scenarioCalls, + denominator, + }); + cursor = new Date(cursor.getTime() + (interval === 'hour' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000)); + } + return { + metric, + interval, + filters: { + from_ts: rangeMeta.current.from.toISOString(), + to_ts: rangeMeta.current.to.toISOString(), + queue_id: state.analytics.queueId === 'all' ? null : state.analytics.queueId, + language: null, + }, + points, + }; +} + function buildMockInteractionDrilldownData(filters, limit, offset, mode) { const normalized = normalizeAnalyticsDrilldownFilters(filters); const queueOptions = mockAnalyticsQueueOptions(); @@ -1509,11 +1843,14 @@ function buildMockAnalyticsDashboard(rangeMeta) { ? analyticsMockData().dashboard_coverage.supported_filters : [], }, + voiceNameOverview: buildMockVoiceNameOverview(rangeMeta), + voiceNameCompare: buildMockVoiceNameOverview(rangeMeta, { previous: true }), aiOverview: buildMockAiOverview(rangeMeta), aiCompare: buildMockAiOverview(rangeMeta, { previous: true }), agentOverview: buildMockAgentOverview(rangeMeta), agentCompare: buildMockAgentOverview(rangeMeta, { previous: true }), trend: buildMockTrendPayloads(rangeMeta, state.analytics.trendMetric || 'volume'), + voiceNameTrend: buildMockVoiceNameTrend(rangeMeta, state.analytics.voiceNameTrendMetric || 'scenario_calls'), aiTrend: buildMockAiTrend(rangeMeta, state.analytics.aiTrendMetric || 'containment_rate'), agentTrend: buildMockAgentTrend(rangeMeta, state.analytics.agentTrendMetric || 'interactions_per_agent'), queueRows: buildMockQueueRows(queueOptions, rangeMeta), @@ -1534,6 +1871,7 @@ function applyAnalyticsSnapshot(snapshot = {}) { state.analytics.channel = snapshot.channel || 'all'; state.analytics.compareMode = snapshot.compareMode || 'previous'; state.analytics.trendMetric = snapshot.trendMetric || 'volume'; + state.analytics.voiceNameTrendMetric = snapshot.voiceNameTrendMetric || 'scenario_calls'; state.analytics.aiTrendMetric = snapshot.aiTrendMetric || 'containment_rate'; state.analytics.agentTrendMetric = snapshot.agentTrendMetric || 'interactions_per_agent'; } @@ -1557,6 +1895,9 @@ function sanitizeAnalyticsSnapshot(snapshot = {}) { channel: validChannel ? snapshot.channel : 'all', compareMode: snapshot.compareMode === 'off' ? 'off' : 'previous', trendMetric: ANALYTICS_TREND_OPTIONS.includes(snapshot.trendMetric) ? snapshot.trendMetric : 'volume', + voiceNameTrendMetric: VOICE_NAME_TREND_OPTIONS.includes(snapshot.voiceNameTrendMetric) + ? snapshot.voiceNameTrendMetric + : 'scenario_calls', aiTrendMetric: AI_ANALYTICS_TREND_OPTIONS.includes(snapshot.aiTrendMetric) ? snapshot.aiTrendMetric : 'containment_rate', @@ -1610,6 +1951,9 @@ function syncAnalyticsUrlState() { if (snapshot.trendMetric !== 'volume') { url.searchParams.set('trend', snapshot.trendMetric); } + if (snapshot.voiceNameTrendMetric !== 'scenario_calls') { + url.searchParams.set('voice_name_trend', snapshot.voiceNameTrendMetric); + } if (snapshot.aiTrendMetric !== 'containment_rate') { url.searchParams.set('ai_trend', snapshot.aiTrendMetric); } @@ -1677,6 +2021,7 @@ function parseAnalyticsDeepLinkState() { 'channel', 'compare', 'trend', + 'voice_name_trend', 'ai_trend', 'agent_trend', ].some((key) => params.has(key)); @@ -1688,6 +2033,7 @@ function parseAnalyticsDeepLinkState() { channel: params.get('channel') || 'all', compareMode: params.get('compare') || 'previous', trendMetric: params.get('trend') || 'volume', + voiceNameTrendMetric: params.get('voice_name_trend') || 'scenario_calls', aiTrendMetric: params.get('ai_trend') || 'containment_rate', agentTrendMetric: params.get('agent_trend') || 'interactions_per_agent', }); @@ -1811,6 +2157,7 @@ function normalizeSavedAnalyticsView(item = {}) { channel: item.snapshot?.channel || 'all', compareMode: item.snapshot?.compareMode || 'previous', trendMetric: item.snapshot?.trendMetric || 'volume', + voiceNameTrendMetric: item.snapshot?.voiceNameTrendMetric || 'scenario_calls', aiTrendMetric: item.snapshot?.aiTrendMetric || 'containment_rate', agentTrendMetric: item.snapshot?.agentTrendMetric || 'interactions_per_agent', }, @@ -1971,6 +2318,35 @@ async function fetchAgentAnalyticsTimeseries(range, metric, interval, overrides return api('reporting', `reports/agents/timeseries?${params.toString()}`); } +function voiceNameAnalyticsSupportedChannel(channel = state.analytics.channel) { + return channel === 'all' || channel === 'voice'; +} + +function voiceNameAnalyticsQuery(range, overrides = {}) { + const params = new URLSearchParams(); + params.set('from_ts', range.from.toISOString()); + params.set('to_ts', range.to.toISOString()); + const queueId = overrides.queueId ?? state.analytics.queueId; + const language = overrides.language ?? null; + if (queueId && queueId !== 'all') { + params.set('queue_id', queueId); + } + if (language) { + params.set('language', language); + } + return params.toString(); +} + +async function fetchVoiceNameAnalyticsOverview(range, overrides = {}) { + return api('ai', `ai/analytics/voice-name-flow/overview?${voiceNameAnalyticsQuery(range, overrides)}`); +} + +async function fetchVoiceNameAnalyticsTimeseries(range, metric, overrides = {}) { + const params = new URLSearchParams(voiceNameAnalyticsQuery(range, overrides)); + params.set('metric', metric || state.analytics.voiceNameTrendMetric || 'scenario_calls'); + return api('ai', `ai/analytics/voice-name-flow/timeseries?${params.toString()}`); +} + async function fetchAnalyticsMetricDrilldown(filters, limit, offset) { return api('reporting', `reports/drilldown?${analyticsMetricDrilldownQuery(filters, limit, offset)}`); } @@ -2272,6 +2648,51 @@ function analyticsDelta(metric, currentPayload, previousPayload) { return { text, tone }; } +function voiceNameAnalyticsMetricLabel(metric) { + return VOICE_NAME_TREND_LABELS[metric] || metric; +} + +function voiceNameAnalyticsMetricValue(metric, payload = state.analytics.voiceNameOverview || emptyVoiceNameAnalyticsOverview()) { + if (metric === 'scenario_calls') { + return Number(payload?.totals?.scenario_calls || 0); + } + return Number(payload?.metrics?.[metric] || 0); +} + +function formatVoiceNameAnalyticsMetric(metric, value) { + const meta = VOICE_NAME_METRIC_META[metric] || { unit: 'count' }; + if (value === null || value === undefined || Number.isNaN(Number(value))) { + return '—'; + } + if (meta.unit === 'pct') { + return `${formatAnalyticsNumber(value, 2)}%`; + } + return formatAnalyticsNumber(value, 0); +} + +function formatVoiceNameAnalyticsAxisValue(metric, value) { + const meta = VOICE_NAME_METRIC_META[metric] || { unit: 'count' }; + return formatAnalyticsNumber(value, meta.unit === 'pct' ? 1 : 0); +} + +function voiceNameAnalyticsDelta(metric, currentPayload, previousPayload) { + const current = voiceNameAnalyticsMetricValue(metric, currentPayload); + const previous = voiceNameAnalyticsMetricValue(metric, previousPayload); + const diff = Number(current || 0) - Number(previous || 0); + const meta = VOICE_NAME_METRIC_META[metric] || { unit: 'count', better: 'neutral' }; + let text = signedDelta(diff, meta.unit === 'pct' ? 1 : 0); + if (meta.unit === 'pct') { + text = `${text} п.п.`; + } + let tone = 'neutral'; + if (meta.better === 'up') { + tone = diff > 0 ? 'positive' : diff < 0 ? 'negative' : 'neutral'; + } else if (meta.better === 'down') { + tone = diff < 0 ? 'positive' : diff > 0 ? 'negative' : 'neutral'; + } + return { text, tone }; +} + function aiAnalyticsMetricLabel(metric) { return AI_ANALYTICS_TREND_LABELS[metric] || metric; } @@ -3025,6 +3446,283 @@ function renderAnalyticsOverview() { ].join(''); } +function voiceNameLanguageLabel(language) { + if (language === 'ru') { + return 'Русский'; + } + if (language === 'kz') { + return 'Казахский'; + } + if (language === 'unknown' || !language) { + return 'Не определён'; + } + return String(language); +} + +function voiceNameAnalyticsMetricDetail(metric, payload) { + const totals = payload?.totals || {}; + const allHandoffs = Number(totals.handoff_confirmed_name || 0) + Number(totals.handoff_unconfirmed_name || 0); + if (metric === 'scenario_calls') { + return `${formatAnalyticsNumber(totals.scenario_calls || 0, 0)} звонков прошли через сценарий`; + } + if (metric === 'start_capture_rate') { + return `${formatAnalyticsNumber(totals.start_obtained || 0, 0)} из ${formatAnalyticsNumber(totals.scenario_calls || 0, 0)} звонков`; + } + if (metric === 'downstream_rescue_rate') { + return `${formatAnalyticsNumber(totals.downstream_ai_obtained || 0, 0)} из ${formatAnalyticsNumber(totals.needed_downstream || 0, 0)} случаев`; + } + if (metric === 'handoff_unconfirmed_rate') { + return `${formatAnalyticsNumber(totals.handoff_unconfirmed_name || 0, 0)} из ${formatAnalyticsNumber(allHandoffs || 0, 0)} передач`; + } + return `${formatAnalyticsNumber(totals.manual_corrected || 0, 0)} из ${formatAnalyticsNumber(allHandoffs || 0, 0)} передач`; +} + +function renderVoiceNameAnalyticsCard(label, metric, currentPayload, previousPayload) { + const value = voiceNameAnalyticsMetricValue(metric, currentPayload); + const showCompare = state.analytics.compareMode === 'previous'; + const delta = showCompare ? voiceNameAnalyticsDelta(metric, currentPayload, previousPayload) : null; + const note = VOICE_NAME_METRIC_META[metric]?.note || ''; + const detail = voiceNameAnalyticsMetricDetail(metric, currentPayload); + return ` +
+
+
${label}
+ ${showCompare ? `${delta.text}` : ''} +
+
${formatVoiceNameAnalyticsMetric(metric, value)}
+ ${detail ? `
${detail}
` : ''} + ${note ? `
${note}
` : ''} +
+ `; +} + +function renderVoiceNameAnalyticsOverview() { + const box = $('voiceNameAnalyticsOverview'); + if (!box) { + return; + } + if (!voiceNameAnalyticsSupportedChannel() || state.analytics.voiceNameError) { + box.innerHTML = ''; + return; + } + const current = state.analytics.voiceNameOverview || emptyVoiceNameAnalyticsOverview(); + if (!state.analytics.loading && !Number(current?.totals?.scenario_calls || 0)) { + box.innerHTML = ''; + return; + } + const previous = state.analytics.voiceNameCompare || emptyVoiceNameAnalyticsOverview(); + box.innerHTML = [ + renderVoiceNameAnalyticsCard('Звонки в сценарии', 'scenario_calls', current, previous), + renderVoiceNameAnalyticsCard('Имя взято сразу', 'start_capture_rate', current, previous), + renderVoiceNameAnalyticsCard('Имя добрал AI после follow-up', 'downstream_rescue_rate', current, previous), + renderVoiceNameAnalyticsCard('Передача без подтверждённого имени', 'handoff_unconfirmed_rate', current, previous), + renderVoiceNameAnalyticsCard('Ручное исправление оператором', 'manual_correction_rate', current, previous), + ].join(''); +} + +function renderVoiceNameAnalyticsTrendChart() { + const container = $('voiceNameAnalyticsTrendChart'); + if (!container) { + return; + } + if (!voiceNameAnalyticsSupportedChannel()) { + container.innerHTML = ''; + return; + } + if (state.analytics.voiceNameTrendError && !state.analytics.voiceNameTrend?.points?.length) { + container.innerHTML = `
Не удалось обновить тренд по voice name-flow: ${escapeHtml(state.analytics.voiceNameTrendError)}
`; + return; + } + const metric = state.analytics.voiceNameTrendMetric || 'scenario_calls'; + const trend = state.analytics.voiceNameTrend || emptyVoiceNameAnalyticsTimeseries(metric); + const items = Array.isArray(trend.points) ? trend.points : []; + const current = state.analytics.voiceNameOverview || emptyVoiceNameAnalyticsOverview(); + const previous = state.analytics.voiceNameCompare || emptyVoiceNameAnalyticsOverview(); + const delta = state.analytics.compareMode === 'previous' + ? voiceNameAnalyticsDelta(metric, current, previous) + : null; + const lastPoint = [...items].reverse().find((item) => item.value !== null && item.value !== undefined) || null; + renderTrendChart(container, { + title: voiceNameAnalyticsMetricLabel(metric), + items, + valueAccessor: (item) => item.value, + labelAccessor: (item) => formatAnalyticsBucketLabel(item.ts, trend.interval || 'day'), + axisFormatter: (value) => formatVoiceNameAnalyticsAxisValue(metric, value), + valueFormatter: (value) => formatVoiceNameAnalyticsMetric(metric, value), + summaryText: lastPoint + ? `${formatVoiceNameAnalyticsMetric(metric, lastPoint.value)} в последней точке` + : '—', + metaBadge: delta ? `${escapeHtml(delta.text)}` : '', + ariaLabel: 'График voice name-flow аналитики', + emptyText: 'За выбранный период недостаточно точек для анализа сценария сбора имени.', + }); +} + +function renderVoiceNameAnalyticsFunnel() { + const container = $('voiceNameAnalyticsFunnel'); + if (!container) { + return; + } + if (!voiceNameAnalyticsSupportedChannel() || state.analytics.voiceNameError) { + container.innerHTML = ''; + return; + } + const rows = Array.isArray(state.analytics.voiceNameOverview?.breakdowns?.funnel) + ? state.analytics.voiceNameOverview.breakdowns.funnel + : []; + if (!rows.length) { + container.innerHTML = '
Нет данных по этапам сценария за выбранный период.
'; + return; + } + container.innerHTML = ` +
+ Этап + Сессии + Доля +
+ ${rows.map((item) => ` +
+ ${escapeHtml(VOICE_NAME_FUNNEL_LABELS[item.stage] || item.label || item.stage)} + ${formatAnalyticsNumber(item.sessions || 0, 0)} + ${formatAnalyticsNumber(item.share || 0, 2)}% +
+ `).join('')} + `; +} + +function renderVoiceNameAnalyticsLanguageTable() { + const container = $('voiceNameAnalyticsLanguageTable'); + if (!container) { + return; + } + if (!voiceNameAnalyticsSupportedChannel() || state.analytics.voiceNameError) { + container.innerHTML = ''; + return; + } + const rows = Array.isArray(state.analytics.voiceNameOverview?.breakdowns?.by_language) + ? state.analytics.voiceNameOverview.breakdowns.by_language + : []; + if (!rows.length) { + container.innerHTML = '
По языкам пока нет достаточных данных.
'; + return; + } + container.innerHTML = ` +
+ Язык + Звонки + Старт + AI после follow-up + Передача без имени + Ручное исправление +
+ ${rows.map((item) => ` +
+ ${escapeHtml(voiceNameLanguageLabel(item.language))} + ${formatAnalyticsNumber(item.scenario_calls || 0, 0)} + ${formatAnalyticsNumber(item.start_capture_rate || 0, 2)}% + ${formatAnalyticsNumber(item.downstream_rescue_rate || 0, 2)}% + ${formatAnalyticsNumber(item.handoff_unconfirmed_rate || 0, 2)}% + ${formatAnalyticsNumber(item.manual_correction_rate || 0, 2)}% +
+ `).join('')} + `; +} + +function renderVoiceNameAnalyticsQueueTable() { + const container = $('voiceNameAnalyticsQueueTable'); + if (!container) { + return; + } + if (!voiceNameAnalyticsSupportedChannel() || state.analytics.voiceNameError) { + container.innerHTML = ''; + return; + } + const rows = Array.isArray(state.analytics.voiceNameOverview?.breakdowns?.by_queue) + ? state.analytics.voiceNameOverview.breakdowns.by_queue + : []; + if (!rows.length) { + container.innerHTML = '
По очередям пока нет данных по voice name-flow.
'; + return; + } + container.innerHTML = ` +
+ Очередь + Звонки + Старт + AI после follow-up + Передача без имени + Ручное исправление +
+ ${rows.map((item) => ` +
+ ${escapeHtml(analyticsQueueName(item.queue_id))}${escapeHtml(item.queue_id)} + ${formatAnalyticsNumber(item.scenario_calls || 0, 0)} + ${formatAnalyticsNumber(item.start_capture_rate || 0, 2)}% + ${formatAnalyticsNumber(item.downstream_rescue_rate || 0, 2)}% + ${formatAnalyticsNumber(item.handoff_unconfirmed_rate || 0, 2)}% + ${formatAnalyticsNumber(item.manual_correction_rate || 0, 2)}% +
+ `).join('')} + `; +} + +function renderVoiceNameAnalyticsHandoffTable() { + const container = $('voiceNameAnalyticsHandoffTable'); + if (!container) { + return; + } + if (!voiceNameAnalyticsSupportedChannel() || state.analytics.voiceNameError) { + container.innerHTML = ''; + return; + } + const rows = Array.isArray(state.analytics.voiceNameOverview?.breakdowns?.handoff) + ? state.analytics.voiceNameOverview.breakdowns.handoff + : []; + if (!rows.length) { + container.innerHTML = '
Разрез по передачам пока пуст для выбранного окна.
'; + return; + } + container.innerHTML = ` +
+ Итог передачи + Звонки + Доля +
+ ${rows.map((item) => ` +
+ ${escapeHtml(VOICE_NAME_HANDOFF_LABELS[item.outcome] || item.label || item.outcome)} + ${formatAnalyticsNumber(item.sessions || 0, 0)} + ${formatAnalyticsNumber(item.share || 0, 2)}% +
+ `).join('')} + `; +} + +function renderVoiceNameAnalyticsEmptyState() { + const box = $('voiceNameAnalyticsEmptyState'); + if (!box) { + return; + } + if (!voiceNameAnalyticsSupportedChannel()) { + box.hidden = false; + box.textContent = 'Аналитика voice name-flow доступна только для срезов “Все каналы” и “Голос”.'; + return; + } + if (state.analytics.voiceNameError) { + box.hidden = false; + box.textContent = `Не удалось обновить voice name-flow аналитику: ${state.analytics.voiceNameError}`; + return; + } + const total = Number(state.analytics.voiceNameOverview?.totals?.scenario_calls || 0); + if (!state.analytics.loading && total === 0) { + box.hidden = false; + box.textContent = 'За выбранный период не найдено звонков, прошедших через сценарий сбора имени.'; + return; + } + box.hidden = true; + box.textContent = ''; +} + function aiAnalyticsMetricDetail(metric, payload) { const totals = payload?.totals || {}; if (metric === 'containment_rate') { @@ -3371,6 +4069,39 @@ function formatAgentAnalyticsTrendValue(metric, value) { return formatAnalyticsNumber(value, 0); } +async function loadVoiceNameAnalyticsTrend(rangeMeta, requestId) { + const metric = state.analytics.voiceNameTrendMetric || 'scenario_calls'; + const interval = analyticsTimeseriesIntervalForRange(rangeMeta); + if (!voiceNameAnalyticsSupportedChannel()) { + state.analytics.voiceNameTrendError = ''; + return emptyVoiceNameAnalyticsTimeseries( + metric, + interval, + rangeMeta.current.from.toISOString(), + rangeMeta.current.to.toISOString(), + state.analytics.queueId === 'all' ? null : state.analytics.queueId, + null, + ); + } + try { + const payload = await fetchVoiceNameAnalyticsTimeseries(rangeMeta.current, metric); + if (requestId !== state.analytics.requestId) { + return emptyVoiceNameAnalyticsTimeseries(metric, interval); + } + return payload || emptyVoiceNameAnalyticsTimeseries(metric, interval); + } catch (err) { + state.analytics.voiceNameTrendError = err.message; + return emptyVoiceNameAnalyticsTimeseries( + metric, + interval, + rangeMeta.current.from.toISOString(), + rangeMeta.current.to.toISOString(), + state.analytics.queueId === 'all' ? null : state.analytics.queueId, + null, + ); + } +} + async function loadAgentAnalyticsTrend(rangeMeta, requestId) { const metric = state.analytics.agentTrendMetric || 'interactions_per_agent'; const interval = analyticsTimeseriesIntervalForRange(rangeMeta); @@ -5999,6 +6730,13 @@ function renderAnalyticsDashboard(rangeMeta = analyticsRangeFromControls()) { renderAnalyticsNarrative(rangeMeta); renderAnalyticsComparePanel(); renderAnalyticsOverview(); + renderVoiceNameAnalyticsEmptyState(); + renderVoiceNameAnalyticsOverview(); + renderVoiceNameAnalyticsTrendChart(); + renderVoiceNameAnalyticsFunnel(); + renderVoiceNameAnalyticsLanguageTable(); + renderVoiceNameAnalyticsQueueTable(); + renderVoiceNameAnalyticsHandoffTable(); renderAiAnalyticsEmptyState(); renderAiAnalyticsOverview(); renderAiAnalyticsTrendChart(); @@ -6027,6 +6765,8 @@ async function loadAnalyticsDashboard(_silent = false) { state.analytics.lastRangeMeta = rangeMeta; state.analytics.loading = true; state.analytics.error = ''; + state.analytics.voiceNameError = ''; + state.analytics.voiceNameTrendError = ''; state.analytics.aiError = ''; state.analytics.agentError = ''; state.analytics.agentTrendError = ''; @@ -6043,6 +6783,15 @@ async function loadAnalyticsDashboard(_silent = false) { state.analytics.overview = payload.overview; state.analytics.compare = state.analytics.compareMode === 'previous' ? payload.compare : emptyKpiEnvelope(rangeMeta.previous.from.toISOString(), rangeMeta.previous.to.toISOString()); state.analytics.coverage = payload.coverage; + state.analytics.voiceNameOverview = payload.voiceNameOverview; + state.analytics.voiceNameCompare = state.analytics.compareMode === 'previous' + ? payload.voiceNameCompare + : emptyVoiceNameAnalyticsOverview( + rangeMeta.previous.from.toISOString(), + rangeMeta.previous.to.toISOString(), + state.analytics.queueId === 'all' ? null : state.analytics.queueId, + null, + ); state.analytics.aiOverview = payload.aiOverview; state.analytics.aiCompare = state.analytics.compareMode === 'previous' ? payload.aiCompare : emptyAiAnalyticsOverview(rangeMeta.previous.from.toISOString(), rangeMeta.previous.to.toISOString(), state.analytics.channel, state.analytics.queueId === 'all' ? null : state.analytics.queueId); state.analytics.agentOverview = payload.agentOverview; @@ -6051,6 +6800,7 @@ async function loadAnalyticsDashboard(_silent = false) { state.analytics.channelRows = payload.channelRows; state.analytics.queueRows = payload.queueRows; state.analytics.trend = payload.trend; + state.analytics.voiceNameTrend = payload.voiceNameTrend; state.analytics.aiTrend = payload.aiTrend; state.analytics.agentTrend = payload.agentTrend; state.analytics.loading = false; @@ -6082,6 +6832,18 @@ async function loadAnalyticsDashboard(_silent = false) { aiAnalyticsSupportedChannel() ? state.analytics.channel : state.analytics.channel, state.analytics.queueId === 'all' ? null : state.analytics.queueId, ); + const emptyVoiceCurrent = emptyVoiceNameAnalyticsOverview( + rangeMeta.current.from.toISOString(), + rangeMeta.current.to.toISOString(), + state.analytics.queueId === 'all' ? null : state.analytics.queueId, + null, + ); + const emptyVoicePrevious = emptyVoiceNameAnalyticsOverview( + rangeMeta.previous.from.toISOString(), + rangeMeta.previous.to.toISOString(), + state.analytics.queueId === 'all' ? null : state.analytics.queueId, + null, + ); const emptyAgentCurrent = emptyAgentAnalyticsOverview( rangeMeta.current.from.toISOString(), rangeMeta.current.to.toISOString(), @@ -6103,6 +6865,15 @@ async function loadAnalyticsDashboard(_silent = false) { const aiComparePromise = aiAnalyticsSupportedChannel() && state.analytics.compareMode === 'previous' ? fetchAiAnalyticsOverview(rangeMeta.previous).catch(() => emptyAiPrevious) : Promise.resolve(emptyAiPrevious); + const voiceNameOverviewPromise = voiceNameAnalyticsSupportedChannel() + ? fetchVoiceNameAnalyticsOverview(rangeMeta.current).catch((err) => { + state.analytics.voiceNameError = err.message; + return emptyVoiceCurrent; + }) + : Promise.resolve(emptyVoiceCurrent); + const voiceNameComparePromise = voiceNameAnalyticsSupportedChannel() && state.analytics.compareMode === 'previous' + ? fetchVoiceNameAnalyticsOverview(rangeMeta.previous).catch(() => emptyVoicePrevious) + : Promise.resolve(emptyVoicePrevious); const agentOverviewPromise = fetchAgentAnalyticsOverview(rangeMeta.current).catch((err) => { state.analytics.agentError = err.message; return emptyAgentCurrent; @@ -6110,11 +6881,13 @@ async function loadAnalyticsDashboard(_silent = false) { const agentComparePromise = state.analytics.compareMode === 'previous' ? fetchAgentAnalyticsOverview(rangeMeta.previous).catch(() => emptyAgentPrevious) : Promise.resolve(emptyAgentPrevious); - const [current, previous, coverage, queues, aiOverview, aiCompare, agentOverview, agentCompare] = await Promise.all([ + const [current, previous, coverage, queues, voiceNameOverview, voiceNameCompare, aiOverview, aiCompare, agentOverview, agentCompare] = await Promise.all([ fetchAnalyticsKpi(rangeMeta.current), previousWindowPromise, api('reporting', 'reports/coverage').catch(() => ({ implemented_metrics: [], dimensions: [], supported_filters: [] })), queuePromise, + voiceNameOverviewPromise, + voiceNameComparePromise, aiOverviewPromise, aiComparePromise, agentOverviewPromise, @@ -6128,6 +6901,8 @@ async function loadAnalyticsDashboard(_silent = false) { state.analytics.overview = current || emptyKpiEnvelope(rangeMeta.current.from.toISOString(), rangeMeta.current.to.toISOString()); state.analytics.compare = previous || emptyKpiEnvelope(rangeMeta.previous.from.toISOString(), rangeMeta.previous.to.toISOString()); state.analytics.coverage = coverage || { implemented_metrics: [], dimensions: [], supported_filters: [] }; + state.analytics.voiceNameOverview = voiceNameOverview || emptyVoiceCurrent; + state.analytics.voiceNameCompare = voiceNameCompare || emptyVoicePrevious; state.analytics.aiOverview = aiOverview || emptyAiCurrent; state.analytics.aiCompare = aiCompare || emptyAiPrevious; state.analytics.agentOverview = agentOverview || emptyAgentCurrent; @@ -6144,9 +6919,10 @@ async function loadAnalyticsDashboard(_silent = false) { })) .sort((a, b) => b.total - a.total || a.channel.localeCompare(b.channel)); - const [trend, queueRows, aiTrend, agentTrend] = await Promise.all([ + const [trend, queueRows, voiceNameTrend, aiTrend, agentTrend] = await Promise.all([ loadAnalyticsTrend(rangeMeta, requestId), loadAnalyticsQueueRows(rangeMeta, queues, requestId), + loadVoiceNameAnalyticsTrend(rangeMeta, requestId), loadAiAnalyticsTrend(rangeMeta, requestId), loadAgentAnalyticsTrend(rangeMeta, requestId), ]); @@ -6157,6 +6933,7 @@ async function loadAnalyticsDashboard(_silent = false) { state.analytics.trend = trend; state.analytics.queueRows = queueRows; + state.analytics.voiceNameTrend = voiceNameTrend; state.analytics.aiTrend = aiTrend; state.analytics.agentTrend = agentTrend; state.analytics.loading = false; @@ -6173,12 +6950,15 @@ async function loadAnalyticsDashboard(_silent = false) { state.analytics.error = err.message; state.analytics.overview = emptyKpiEnvelope(); state.analytics.compare = emptyKpiEnvelope(); + state.analytics.voiceNameOverview = emptyVoiceNameAnalyticsOverview(); + state.analytics.voiceNameCompare = emptyVoiceNameAnalyticsOverview(); state.analytics.aiOverview = emptyAiAnalyticsOverview(); state.analytics.aiCompare = emptyAiAnalyticsOverview(); state.analytics.agentOverview = emptyAgentAnalyticsOverview(); state.analytics.agentCompare = emptyAgentAnalyticsOverview(); state.analytics.coverage = { implemented_metrics: [], dimensions: [], supported_filters: [] }; state.analytics.trend = []; + state.analytics.voiceNameTrend = emptyVoiceNameAnalyticsTimeseries(state.analytics.voiceNameTrendMetric || 'scenario_calls'); state.analytics.aiTrend = emptyAiAnalyticsTimeseries(state.analytics.aiTrendMetric || 'containment_rate'); state.analytics.agentTrend = emptyAgentAnalyticsTimeseries(state.analytics.agentTrendMetric || 'interactions_per_agent'); state.analytics.channelRows = []; @@ -7008,6 +7788,13 @@ function wire() { renderAnalyticsTrendChart(); syncAnalyticsUrlState(); }); + $('voiceNameAnalyticsTrendMetric')?.addEventListener('change', () => { + syncAnalyticsStateFromControls(); + detachActiveAnalyticsView(); + renderSavedAnalyticsViews(); + syncAnalyticsUrlState(); + void loadAnalyticsDashboard(); + }); $('aiAnalyticsTrendMetric').addEventListener('change', () => { syncAnalyticsStateFromControls(); detachActiveAnalyticsView(); diff --git a/ui/analyst/index.html b/ui/analyst/index.html index cb3e9e0..0c217ad 100644 --- a/ui/analyst/index.html +++ b/ui/analyst/index.html @@ -8,7 +8,7 @@ - +
@@ -32,6 +32,8 @@ @@ -158,6 +160,62 @@
+
+
+
+

Сценарий имени

+

Сценарий сбора имени в голосе

+

Отдельная аналитика по тому, как сценарий берёт имя на старте, как помогает downstream AI и сколько звонков уходит оператору без подтверждённого имени.

+
+
+ + +
+
+ +
+ +
+
+

Тренд по voice name-flow

+

Динамика показывает, как меняется качество сбора имени по выбранному периоду, очереди и голосовому потоку.

+
+
+ +
+

Этапы сценария

+

Funnel помогает быстро увидеть, где чаще всего теряется имя: на старте, в follow-up или уже перед handoff.

+
+
+
+ +
+
+

По языкам

+

Сравнение сценария по языкам помогает увидеть, где стартовый сбор имени работает стабильнее, а где чаще требуется downstream AI.

+
+
+ +
+

По очередям

+

Срез по очередям показывает, в каких очередях voice name-flow чаще доходит до handoff без подтверждённого имени.

+
+
+
+ +
+

Handoff по имени

+

Здесь видно, сколько звонков оператор получил уже с подтверждённым именем, а сколько ушло без него.

+
+
+
+
@@ -198,6 +256,7 @@

Сравнение полностью автоматических и затронутых оператором сессий по Telegram и WhatsApp без текстов сообщений, записей и AI-сводок.

+

Исходы AI-сессий

@@ -224,6 +283,7 @@
+
@@ -250,11 +310,13 @@
+

Агенты по выбранному периоду

Клик по строке открывает полноэкранную детализацию по обращениям выбранного агента без изменения фильтров витрины.

+

Смены

@@ -286,7 +348,7 @@

Каналы

-

Сравнение каналов по объему, обработке и доле ответа за выбранный период.

+

Сравнение каналов по объёму, обработке и доле ответа за выбранный период.

Нажмите на строку, чтобы открыть точную детализацию по обращениям этого канала.

@@ -295,11 +357,10 @@

Очереди

-

Очереди отсортированы по объему. Нажмите на строку, чтобы сфокусировать всю витрину на одной очереди.

+

Очереди отсортированы по объёму. Нажмите на строку, чтобы сфокусировать всю витрину на одной очереди.

Клик по строке открывает точный срез по очереди и не меняет фильтры витрины.

-
@@ -319,9 +380,9 @@ >
-

Детализация обращений

+

Детализация обращений

Детализация обращений

-

Выберите карточку обращений, канал или очередь на витрине, чтобы открыть список обращений.

+

Выберите карточку обращений, канал или очередь на витрине, чтобы открыть список обращений.

@@ -387,6 +448,6 @@ - + diff --git a/ui/analyst/mock-analytics.json b/ui/analyst/mock-analytics.json index 9f68c5e..ca08964 100644 --- a/ui/analyst/mock-analytics.json +++ b/ui/analyst/mock-analytics.json @@ -78,6 +78,45 @@ "closed_without_operator_rate": 48.6 } ], + "voice_name_flow": { + "totals": { + "scenario_calls": 196, + "start_obtained": 118, + "downstream_ai_obtained": 34, + "followup_required": 18, + "name_not_obtained": 26, + "manual_corrected": 12, + "handoff_confirmed_name": 14, + "handoff_unconfirmed_name": 28, + "needed_downstream": 78 + }, + "languages": [ + { + "language": "ru", + "share": 0.58, + "start_capture_rate": 66.2, + "downstream_rescue_rate": 47.4, + "handoff_unconfirmed_rate": 31.8, + "manual_correction_rate": 14.6 + }, + { + "language": "kz", + "share": 0.29, + "start_capture_rate": 61.7, + "downstream_rescue_rate": 51.3, + "handoff_unconfirmed_rate": 28.4, + "manual_correction_rate": 11.8 + }, + { + "language": "unknown", + "share": 0.13, + "start_capture_rate": 44.8, + "downstream_rescue_rate": 36.1, + "handoff_unconfirmed_rate": 46.5, + "manual_correction_rate": 23.0 + } + ] + }, "agent_shift_rows": [ { "shift_key": "night", diff --git a/ui/operator/app.js b/ui/operator/app.js index bbf9a0e..33b4469 100644 --- a/ui/operator/app.js +++ b/ui/operator/app.js @@ -22,6 +22,15 @@ pendingAction: '', aiSummaries: {}, aiSummaryPending: {}, + nameEditor: { + open: false, + mode: 'panel', + callId: '', + customerId: '', + draft: '', + saving: false, + error: '', + }, }, customers: { items: [], @@ -3796,14 +3805,17 @@ function buildUnifiedInboxTelegramItem(thread) { } function buildUnifiedInboxCallItem(item) { + const summary = voiceSummaryForItem(item); const aiMeta = voiceAiStatusMeta(item); const interaction = interactionById(item.interaction_id || ''); const customerId = interaction?.customer_id || ''; - const caller = item.caller_name || item.caller_number || item.call_id || 'Неизвестный абонент'; + const caller = voiceCustomerDisplayName(item, summary); + const nameStatusMeta = voiceCustomerNameStatusMeta(summary?.customer_name_status); const badges = [ renderUnifiedInboxBadge('Голос', 'channel'), renderUnifiedInboxBadge(telephonyLabel(item.telephony_status), 'assignee'), aiMeta ? renderUnifiedInboxBadge(aiMeta.label, aiMeta.className) : '', + nameStatusMeta ? renderUnifiedInboxBadge(nameStatusMeta.shortLabel, nameStatusMeta.className) : '', item.claimed_by_user ? renderUnifiedInboxBadge(item.claimed_by_user, 'owner') : '', ].filter(Boolean); return { @@ -3812,14 +3824,16 @@ function buildUnifiedInboxCallItem(item) { bucket: 'calls', sortValue: unifiedInboxSortValue(item.started_at, item.updated_at), title: caller, - subtitle: item.call_id, + subtitle: voiceCustomerCallSubtitle(item), badges, metaLines: [ `Клиент: ${customerDisplayName(customerId)}`, + `Контакт: ${voiceCustomerCallSubtitle(item)}`, + summary ? `Имя: ${voiceCustomerNameStateLine(summary) || 'без подтверждения'}` : '', `Обращение: ${item.interaction_id || 'не найдено'}`, `Начат: ${formatIsoShort(item.started_at || item.connected_at)}`, item.ai_handoff_reason ? `AI: ${item.ai_handoff_reason}` : `Статус: ${telephonyLabel(item.telephony_status)}`, - ], + ].filter(Boolean), customerId, interactionId: item.interaction_id || '', callId: item.call_id, @@ -3945,6 +3959,27 @@ function focusLiveCall(callId) { ensureVoiceAiSummaryLoaded(relatedCall); } +function handleLiveCallTableClick(event) { + const button = event.target.closest('[data-live-call-action]'); + if (!button) { + return; + } + const callId = button.dataset.callId || ''; + if (!callId) { + return; + } + if (button.dataset.liveCallAction === 'edit-name') { + openLiveCallNameEditor(callId, 'panel'); + return; + } + if (button.dataset.liveCallAction === 'customer') { + const customerId = button.dataset.customerId || ''; + if (customerId) { + openCustomerProfile(customerId); + } + } +} + async function openUnifiedInboxItem(button) { const kind = button.dataset.inboxKind || ''; const interactionId = button.dataset.interactionId || ''; @@ -4008,6 +4043,223 @@ function selectedLiveCallItem() { return state.liveCalls.items.find((item) => item.call_id === callId) || null; } +function liveCallById(callId) { + if (!callId) { + return null; + } + return state.liveCalls.items.find((item) => item.call_id === callId) + || state.liveCalls.recentItems.find((item) => item.call_id === callId) + || null; +} + +function liveCallCustomerId(item) { + return interactionById(item?.interaction_id || '')?.customer_id || ''; +} + +function closeLiveCallNameEditor() { + state.liveCalls.nameEditor.open = false; + state.liveCalls.nameEditor.callId = ''; + state.liveCalls.nameEditor.customerId = ''; + state.liveCalls.nameEditor.draft = ''; + state.liveCalls.nameEditor.saving = false; + state.liveCalls.nameEditor.error = ''; + updateLiveCallNameEditorsUi(); +} + +function openLiveCallNameEditor(callId, mode = 'panel') { + const item = liveCallById(callId); + const customerId = liveCallCustomerId(item); + if (!item || !customerId) { + log('Нельзя исправить имя: звонок не привязан к клиенту', { call_id: callId || '-' }); + return; + } + state.liveCalls.nameEditor.open = true; + state.liveCalls.nameEditor.mode = mode; + state.liveCalls.nameEditor.callId = item.call_id; + state.liveCalls.nameEditor.customerId = customerId; + state.liveCalls.nameEditor.draft = voiceCustomerDisplayName(item); + state.liveCalls.nameEditor.saving = false; + state.liveCalls.nameEditor.error = ''; + if (mode === 'panel') { + focusLiveCall(item.call_id); + } + updateLiveCallNameEditorsUi(); +} + +function applyCustomerNamePatchLocally({ customerId, callId, displayName }) { + const normalizedName = String(displayName || '').trim(); + if (!customerId || !normalizedName) { + return; + } + state.customers.items = state.customers.items.map((item) => ( + item.customer_id === customerId ? { ...item, display_name: normalizedName } : item + )); + const history = state.customers.historyById[customerId]; + if (history?.customer) { + history.customer.display_name = normalizedName; + } + const interactionIds = new Set( + state.interactions + .filter((item) => item.customer_id === customerId) + .map((item) => item.interaction_id) + .filter(Boolean), + ); + const patchCall = (item) => { + if (!item) { + return item; + } + const matchesCall = callId && item.call_id === callId; + const matchesCustomer = interactionIds.has(item.interaction_id); + if (!matchesCall && !matchesCustomer) { + return item; + } + return { + ...item, + caller_name: normalizedName, + }; + }; + state.liveCalls.items = state.liveCalls.items.map(patchCall); + state.liveCalls.recentItems = state.liveCalls.recentItems.map(patchCall); + if (history?.live_calls) { + history.live_calls = history.live_calls.map((item) => ({ + ...item, + caller_name: interactionIds.has(item.interaction_id) || item.call_id === callId ? normalizedName : item.caller_name, + })); + } + const affectedCallIds = new Set( + [...state.liveCalls.items, ...state.liveCalls.recentItems] + .filter((item) => interactionIds.has(item.interaction_id) || item.call_id === callId) + .map((item) => item.call_id) + .filter(Boolean), + ); + affectedCallIds.forEach((id) => { + if (!state.liveCalls.aiSummaries[id]) { + return; + } + state.liveCalls.aiSummaries[id] = { + ...state.liveCalls.aiSummaries[id], + customer_name_status: 'name_obtained', + customer_name_value: normalizedName, + customer_name_source: 'manual', + }; + }); + renderCustomerList(); + refreshVoiceSummaryDependentViews(); +} + +async function saveLiveCallCustomerName() { + const { customerId, callId } = state.liveCalls.nameEditor; + const displayName = String(state.liveCalls.nameEditor.draft || '').trim().replace(/\s+/g, ' '); + if (!customerId || !callId) { + return; + } + if (displayName.length < 2) { + state.liveCalls.nameEditor.error = 'Введите имя клиента минимум из 2 символов.'; + updateLiveCallNameEditorsUi(); + return; + } + state.liveCalls.nameEditor.saving = true; + state.liveCalls.nameEditor.error = ''; + updateLiveCallNameEditorsUi(); + try { + const data = await api('customer', `customers/${encodeURIComponent(customerId)}`, { + method: 'PATCH', + body: JSON.stringify({ display_name: displayName, source: 'manual' }), + }); + applyCustomerNamePatchLocally({ + customerId, + callId, + displayName: data?.display_name || displayName, + }); + closeLiveCallNameEditor(); + ensureCustomerHistoryLoaded(customerId, { force: true }).catch(() => {}); + loadVoiceAiSummary(callId, { force: true, silent: true }).catch(() => {}); + refreshLiveCallsInBackground(); + log('Имя клиента обновлено оператором', { customer_id: customerId, call_id: callId, name: data?.display_name || displayName }); + } catch (err) { + state.liveCalls.nameEditor.saving = false; + state.liveCalls.nameEditor.error = err.message || 'Не удалось сохранить имя клиента.'; + updateLiveCallNameEditorsUi(); + } +} + +function liveCallNameEditorMeta(item, customerId) { + if (!item || !customerId) { + return 'Выберите звонок, связанный с клиентом, чтобы исправить имя.'; + } + return `${voiceCustomerCallSubtitle(item)} • клиент ${customerId}`; +} + +function updateLiveCallNameEditorsUi() { + const panel = $('liveCallNameEditor'); + const panelInput = $('liveCallNameInput'); + const panelMeta = $('liveCallNameEditorMeta'); + const panelStatus = $('liveCallNameStatus'); + const panelSave = $('liveCallNameSaveBtn'); + const panelCancel = $('liveCallNameCancelBtn'); + const popup = $('browserPhoneNameEditor'); + const popupInput = $('browserPhoneNameInput'); + const popupMeta = $('browserPhoneNameHint'); + const popupStatus = $('browserPhoneNameStatus'); + const popupSave = $('browserPhoneNameSaveBtn'); + const popupCancel = $('browserPhoneNameCancelBtn'); + const editor = state.liveCalls.nameEditor; + const item = liveCallById(editor.callId); + const meta = liveCallNameEditorMeta(item, editor.customerId); + const statusText = editor.error || (editor.saving ? 'Сохраняем имя клиента...' : ''); + + if (panel) { + const panelVisible = editor.open && editor.mode === 'panel'; + panel.classList.toggle('hidden', !panelVisible); + if (panelInput) { + if (panelInput.value !== editor.draft) { + panelInput.value = editor.draft; + } + panelInput.disabled = editor.saving; + } + if (panelMeta) { + panelMeta.textContent = meta; + } + if (panelStatus) { + panelStatus.textContent = statusText; + panelStatus.classList.toggle('error', Boolean(editor.error)); + } + if (panelSave) { + panelSave.disabled = editor.saving || !editor.customerId; + panelSave.textContent = editor.saving ? 'Сохраняем...' : 'Сохранить имя'; + } + if (panelCancel) { + panelCancel.disabled = editor.saving; + } + } + + if (popup) { + const popupVisible = editor.open && editor.mode === 'popup'; + popup.classList.toggle('hidden', !popupVisible); + if (popupInput) { + if (popupInput.value !== editor.draft) { + popupInput.value = editor.draft; + } + popupInput.disabled = editor.saving; + } + if (popupMeta) { + popupMeta.textContent = meta; + } + if (popupStatus) { + popupStatus.textContent = statusText; + popupStatus.classList.toggle('hidden', !statusText); + popupStatus.classList.toggle('error', Boolean(editor.error)); + } + if (popupSave) { + popupSave.disabled = editor.saving || !editor.customerId; + popupSave.textContent = editor.saving ? 'Сохраняем...' : 'Сохранить имя'; + } + if (popupCancel) { + popupCancel.disabled = editor.saving; + } + } +} + function isClaimableLiveCall(item) { if (!item) { return false; @@ -4088,7 +4340,7 @@ async function loadVoiceAiSummary(callId, options = {}) { return null; } finally { delete state.liveCalls.aiSummaryPending[callId]; - updateBrowserPhoneUi(); + refreshVoiceSummaryDependentViews(); } } @@ -4117,6 +4369,135 @@ function renderVoiceAiSummaryField(label, value) { `; } +function formatVoiceCustomerNameStatus(status) { + switch (String(status || '').trim()) { + case 'name_obtained': + return 'Подтверждено'; + case 'name_followup_required': + return 'Нужно уточнить'; + case 'name_not_obtained': + return 'Не подтверждено'; + default: + return String(status || '').trim(); + } +} + +function voiceCustomerNameStatusMeta(status) { + switch (String(status || '').trim()) { + case 'name_obtained': + return { label: 'Имя подтверждено', shortLabel: 'Имя подтверждено', className: 'name-confirmed' }; + case 'name_followup_required': + return { label: 'Имя нужно уточнить', shortLabel: 'Уточнить имя', className: 'name-followup' }; + case 'name_not_obtained': + return { label: 'Имя не подтверждено', shortLabel: 'Без подтверждения', className: 'name-missing' }; + default: { + const fallback = String(status || '').trim(); + return fallback ? { label: fallback, shortLabel: fallback, className: 'name-missing' } : null; + } + } +} + +function formatVoiceCustomerNameSource(source) { + switch (String(source || '').trim()) { + case 'known_customer': + return 'Из карточки клиента'; + case 'voice_start': + return 'Стартовый этап'; + case 'voice_followup': + return 'Уточнил AI'; + case 'manual': + return 'Оператор'; + case 'external_identity': + return 'Из voice identity'; + default: + return String(source || '').trim(); + } +} + +function formatVoiceStartLanguage(language) { + switch (String(language || '').trim()) { + case 'ru': + return 'Русский'; + case 'kz': + return 'Қазақша'; + default: + return String(language || '').trim(); + } +} + +function voiceSummaryForItem(item) { + const callId = String(item?.call_id || '').trim(); + return callId ? voiceAiSummaryForCall(callId) : null; +} + +function voiceCustomerDisplayName(item, summary = voiceSummaryForItem(item)) { + const preferredName = String(summary?.customer_name_value || '').trim(); + if (preferredName) { + return preferredName; + } + const callerName = String(item?.caller_name || '').trim(); + if (callerName) { + return callerName; + } + const callerNumber = String(item?.caller_number || '').trim(); + if (callerNumber) { + return callerNumber; + } + const callId = String(item?.call_id || '').trim(); + if (callId) { + return callId; + } + return 'Неизвестный абонент'; +} + +function voiceCustomerCallSubtitle(item) { + const parts = [ + String(item?.caller_number || '').trim(), + String(item?.call_id || '').trim(), + ].filter(Boolean); + return parts.join(' • ') || 'Звонок без номера'; +} + +function voiceCustomerNameStateLine(summary) { + if (!summary) { + return ''; + } + const parts = []; + const statusMeta = voiceCustomerNameStatusMeta(summary.customer_name_status); + if (statusMeta?.label) { + parts.push(statusMeta.label); + } + const sourceLabel = formatVoiceCustomerNameSource(summary.customer_name_source); + if (sourceLabel) { + parts.push(sourceLabel); + } + const languageLabel = formatVoiceStartLanguage(summary.voice_start_language); + if (languageLabel) { + parts.push(languageLabel); + } + return parts.join(' • '); +} + +function voiceCustomerIncomingMeta(item, summary = voiceSummaryForItem(item)) { + const parts = []; + const callerNumber = String(item?.caller_number || '').trim(); + if (callerNumber) { + parts.push(`Номер: ${callerNumber}`); + } + const nameState = voiceCustomerNameStateLine(summary); + if (nameState) { + parts.push(nameState); + } + return parts.join(' • ') || `call_id: ${String(item?.call_id || '—').trim() || '—'}`; +} + +function refreshVoiceSummaryDependentViews() { + updateLiveCallSelector(state.liveCalls.items); + renderLiveCallsTable(state.liveCalls.items, state.liveCalls.recentItems); + renderUnifiedInbox(); + updateBrowserPhoneUi(); +} + function renderVoiceAiTranscript(summary) { const segments = Array.isArray(summary?.transcript_segments) ? summary.transcript_segments.filter((segment) => segment && String(segment.text || '').trim()) @@ -4183,6 +4564,10 @@ function renderVoiceAiSummary(summary, pending = false) {
+ ${renderVoiceAiSummaryField('Имя клиента', summary?.customer_name_value)} + ${renderVoiceAiSummaryField('Статус имени', formatVoiceCustomerNameStatus(summary?.customer_name_status))} + ${renderVoiceAiSummaryField('Источник имени', formatVoiceCustomerNameSource(summary?.customer_name_source))} + ${renderVoiceAiSummaryField('Язык старта', formatVoiceStartLanguage(summary?.voice_start_language))} ${renderVoiceAiSummaryField('Запрос клиента', summary?.customer_request_text)} ${renderVoiceAiSummaryField('Что сделал AI', summary?.ai_outcome_text)} ${renderVoiceAiSummaryField('Причина передачи', summary?.handoff_reason)} @@ -4488,11 +4873,14 @@ function browserPhoneCallSummary(item) { if (!item) { return 'Ожидаем карточку звонка из bridge...'; } + const summary = voiceSummaryForItem(item); const aiMeta = voiceAiStatusMeta(item); + const nameState = voiceCustomerNameStateLine(summary); const parts = [ item.queue_code || item.queue_id || 'queue?', item.interaction_id || 'interaction?', item.operator_extension ? `ext ${item.operator_extension}` : '', + nameState, aiMeta ? aiMeta.label : '', ].filter(Boolean); return parts.join(' • '); @@ -4567,6 +4955,7 @@ function updateBrowserPhoneUi() { const answerBtn = $('browserPhoneAnswerBtn'); const rejectBtn = $('browserPhoneRejectBtn'); const claimBtn = $('browserPhoneCallClaimBtn'); + const editNameBtn = $('browserPhoneEditNameBtn'); const transferBtn = $('browserPhoneCallTransferBtn'); const hangupBtn = $('browserPhoneCallHangupBtn'); const transferType = $('browserPhoneTransferTargetType'); @@ -4579,6 +4968,8 @@ function updateBrowserPhoneUi() { const popupAiSummary = $('browserPhoneAiSummary'); const popupTimer = $('browserPhoneCallTimer'); const popupWarning = $('browserPhoneCallWarning'); + const popupAiSummaryData = popupCall ? voiceAiSummaryForCall(popupCall.call_id) : null; + const popupAiSummaryPending = popupCall ? voiceAiSummaryPending(popupCall.call_id) : false; $('browserPhoneRegistrationState').textContent = state.browserPhone.status; $('browserPhoneOperatorExtension').textContent = config?.operator_extension || '—'; @@ -4601,6 +4992,7 @@ function updateBrowserPhoneUi() { const active = popupPhase === 'in-call'; const canClaim = Boolean(popupCall && isClaimableLiveCall(popupCall) && !incoming && !connecting && !ending && !state.browserPhone.autoClaimInFlight); const canControl = Boolean(popupCall && canControlLiveCall(popupCall) && !connecting && !ending); + const popupCustomerId = liveCallCustomerId(popupCall); overlay.classList.toggle('hidden', !browserPhonePopupVisible()); popupState.textContent = errorState @@ -4614,18 +5006,18 @@ function updateBrowserPhoneUi() { : active ? 'Разговор в браузере' : 'Browser call'; - popupTitle.textContent = popupCall?.caller_number || popupCall?.caller_name || browserPhoneIncomingLabel(state.browserPhone.session).replace('Входящий звонок: ', ''); - popupIncomingText.textContent = state.browserPhone.session - ? browserPhoneIncomingLabel(state.browserPhone.session) - : popupCall - ? `call_id: ${popupCall.call_id}` + popupTitle.textContent = popupCall + ? voiceCustomerDisplayName(popupCall, popupAiSummaryData) + : browserPhoneIncomingLabel(state.browserPhone.session).replace('Входящий звонок: ', ''); + popupIncomingText.textContent = popupCall + ? voiceCustomerIncomingMeta(popupCall, popupAiSummaryData) + : state.browserPhone.session + ? browserPhoneIncomingLabel(state.browserPhone.session) : 'SIP invite ещё не поступал.'; popupMeta.textContent = browserPhoneCallSummary(popupCall); if (popupCall) { ensureVoiceAiSummaryLoaded(popupCall); } - const popupAiSummaryData = popupCall ? voiceAiSummaryForCall(popupCall.call_id) : null; - const popupAiSummaryPending = popupCall ? voiceAiSummaryPending(popupCall.call_id) : false; popupAiSummary.innerHTML = renderVoiceAiSummary(popupAiSummaryData, popupAiSummaryPending); popupAiSummary.classList.toggle('hidden', !popupAiSummary.innerHTML.trim()); popupTimer.textContent = incoming @@ -4641,6 +5033,7 @@ function updateBrowserPhoneUi() { answerBtn.classList.toggle('hidden', !incoming); rejectBtn.classList.toggle('hidden', !incoming); claimBtn.classList.toggle('hidden', !canClaim); + editNameBtn.classList.toggle('hidden', !popupCall || !popupCustomerId); muteBtn.classList.toggle('hidden', !(active || connecting)); transferBtn.classList.toggle('hidden', !(canControl || ending || errorState)); hangupBtn.classList.toggle('hidden', !(popupCall || hasSession || ending || errorState || connecting)); @@ -4650,12 +5043,18 @@ function updateBrowserPhoneUi() { answerBtn.disabled = !incoming || Boolean(pendingAction); rejectBtn.disabled = !incoming || Boolean(pendingAction); claimBtn.disabled = !canClaim || Boolean(pendingAction) || state.browserPhone.autoClaimInFlight; + editNameBtn.disabled = !popupCall || !popupCustomerId || state.liveCalls.nameEditor.saving; muteBtn.disabled = !hasSession; muteBtn.textContent = state.browserPhone.muted ? 'Включить микрофон' : 'Выключить микрофон'; transferBtn.disabled = !canControl || Boolean(pendingAction) || ending || connecting || !transferValue.value.trim(); hangupBtn.disabled = (!popupCall && !hasSession) || Boolean(pendingAction) || ending; transferType.disabled = !canControl || Boolean(pendingAction) || connecting; transferValue.disabled = !canControl || Boolean(pendingAction) || connecting; + if (state.liveCalls.nameEditor.mode === 'popup' && (!popupCall || state.liveCalls.nameEditor.callId !== popupCall.call_id)) { + closeLiveCallNameEditor(); + } else { + updateLiveCallNameEditorsUi(); + } } function renderBrowserDeviceOptions(selectId, devices, preferredId, placeholder) { @@ -5413,7 +5812,11 @@ function updateLiveCallSelector(items) { const sorted = [...items]; select.innerHTML = sorted.length ? sorted - .map((item) => ``) + .map((item) => { + const summary = voiceSummaryForItem(item); + const label = `${voiceCustomerDisplayName(item, summary)} | ${item.call_id}${item.interaction_id ? ` | ${item.interaction_id}` : ''}`; + return ``; + }) .join('') : ''; if (sorted.length) { @@ -5433,8 +5836,22 @@ function updateLiveCallSelector(items) { function renderLiveCallCard(item, { recent = false } = {}) { const isSelected = item.call_id === state.liveCalls.selectedCallId; - const caller = item.caller_number || item.caller_name || 'неизвестно'; + const summary = voiceSummaryForItem(item); + const caller = voiceCustomerDisplayName(item, summary); const aiMeta = voiceAiStatusMeta(item); + const customerId = liveCallCustomerId(item); + const nameStatusMeta = voiceCustomerNameStatusMeta(summary?.customer_name_status); + const nameStatusLine = nameStatusMeta + ? `

имя: ${escapeHtml(nameStatusMeta.label)}

` + : ''; + const nameSourceLabel = formatVoiceCustomerNameSource(summary?.customer_name_source); + const nameSourceLine = nameSourceLabel + ? `

источник имени: ${escapeHtml(nameSourceLabel)}

` + : ''; + const languageLabel = formatVoiceStartLanguage(summary?.voice_start_language); + const languageLine = languageLabel + ? `

язык старта: ${escapeHtml(languageLabel)}

` + : ''; const badges = [ `${escapeHtml(item.queue_code || item.queue_id)}`, `${escapeHtml(telephonyLabel(item.telephony_status))}`, @@ -5442,6 +5859,9 @@ function renderLiveCallCard(item, { recent = false } = {}) { if (aiMeta) { badges.push(`${escapeHtml(aiMeta.label)}`); } + if (nameStatusMeta) { + badges.push(`${escapeHtml(nameStatusMeta.shortLabel)}`); + } if (recent && item.terminal_action) { badges.push(`${escapeHtml(terminalActionLabel(item))}`); } @@ -5460,20 +5880,28 @@ function renderLiveCallCard(item, { recent = false } = {}) { const timingLine = recent ? `

завершён: ${escapeHtml(formatIsoShort(item.last_transition_at || item.ended_at || item.updated_at))}

` : `

соединён: ${escapeHtml(formatIsoShort(item.connected_at || item.started_at))}

`; + const actions = [ + customerId ? `` : '', + customerId ? `` : '', + ].filter(Boolean); return `
${badges.join('')}
-

${escapeHtml(item.call_id)}

-

обращение: ${escapeHtml(item.interaction_id)}

-

абонент: ${escapeHtml(caller)}

+

${escapeHtml(caller)}

+

${escapeHtml(voiceCustomerCallSubtitle(item))}

+

обращение: ${escapeHtml(item.interaction_id || 'не найдено')}

взял в работу: ${escapeHtml(item.claimed_by_user || '-')}

внутренний номер: ${escapeHtml(item.operator_extension || '-')}

начат: ${escapeHtml(formatIsoShort(item.started_at))}

${timingLine} + ${nameStatusLine} + ${nameSourceLine} + ${languageLine} ${aiReasonLine} ${targetLine} ${hangupLine}

запись: ${item.has_recording ? 'да' : 'нет'}

+ ${actions.length ? `
${actions.join('')}
` : ''}
`; } @@ -5494,6 +5922,8 @@ function renderLiveCallColumn(title, items, emptyMessage, options = {}) { } function renderLiveCallsTable(activeItems, recentItems) { + activeItems.forEach((item) => ensureVoiceAiSummaryLoaded(item)); + recentItems.forEach((item) => ensureVoiceAiSummaryLoaded(item)); $('liveCallsTable').innerHTML = `
${renderLiveCallColumn('Активные звонки', activeItems, 'Активных звонков нет.')} @@ -5538,6 +5968,7 @@ function applyLiveCallCollections(activeItems, recentItems, options = {}) { } updateLiveCallSelector(state.liveCalls.items); renderLiveCallsTable(state.liveCalls.items, state.liveCalls.recentItems); + updateLiveCallNameEditorsUi(); syncBrowserPhonePopupLifecycle(); renderCustomerSpotlight(); renderUnifiedInbox(); @@ -5950,6 +6381,22 @@ function wire() { $('loadInteractionsBtn').addEventListener('click', loadInteractions); $('loadLiveCallsBtn').addEventListener('click', () => loadLiveCalls(true)); $('loadLiveCallActionsBtn').addEventListener('click', loadLiveCallActions); + $('liveCallsTable').addEventListener('click', handleLiveCallTableClick); + $('liveCallNameInput').addEventListener('input', (event) => { + state.liveCalls.nameEditor.draft = event.target.value || ''; + if (state.liveCalls.nameEditor.error) { + state.liveCalls.nameEditor.error = ''; + } + updateLiveCallNameEditorsUi(); + }); + $('liveCallNameSaveBtn').addEventListener('click', () => { + saveLiveCallCustomerName().catch((err) => { + state.liveCalls.nameEditor.saving = false; + state.liveCalls.nameEditor.error = err.message || 'Не удалось сохранить имя клиента.'; + updateLiveCallNameEditorsUi(); + }); + }); + $('liveCallNameCancelBtn').addEventListener('click', closeLiveCallNameEditor); $('browserPhoneStatusBtn').addEventListener('click', toggleBrowserPhoneSettings); $('browserPhoneCallSettingsBtn').addEventListener('click', toggleBrowserPhoneSettings); $('browserPhoneConnectBtn').addEventListener('click', connectBrowserSoftphone); @@ -5958,8 +6405,29 @@ function wire() { $('browserPhoneAnswerBtn').addEventListener('click', answerBrowserSoftphoneCall); $('browserPhoneRejectBtn').addEventListener('click', rejectBrowserSoftphoneCall); $('browserPhoneCallClaimBtn').addEventListener('click', claimBrowserPopupCall); + $('browserPhoneEditNameBtn').addEventListener('click', () => { + const popupCall = browserPhoneActivePopupCall(); + if (popupCall?.call_id) { + openLiveCallNameEditor(popupCall.call_id, 'popup'); + } + }); $('browserPhoneCallTransferBtn').addEventListener('click', transferBrowserPopupCall); $('browserPhoneCallHangupBtn').addEventListener('click', hangupBrowserPopupCall); + $('browserPhoneNameInput').addEventListener('input', (event) => { + state.liveCalls.nameEditor.draft = event.target.value || ''; + if (state.liveCalls.nameEditor.error) { + state.liveCalls.nameEditor.error = ''; + } + updateLiveCallNameEditorsUi(); + }); + $('browserPhoneNameSaveBtn').addEventListener('click', () => { + saveLiveCallCustomerName().catch((err) => { + state.liveCalls.nameEditor.saving = false; + state.liveCalls.nameEditor.error = err.message || 'Не удалось сохранить имя клиента.'; + updateLiveCallNameEditorsUi(); + }); + }); + $('browserPhoneNameCancelBtn').addEventListener('click', closeLiveCallNameEditor); $('browserPhoneMicSelect').addEventListener('change', () => { state.browserPhone.micDeviceId = $('browserPhoneMicSelect').value; stopBrowserPhoneLocalStream(); diff --git a/ui/operator/index.html b/ui/operator/index.html index 0201327..f8184a8 100644 --- a/ui/operator/index.html +++ b/ui/operator/index.html @@ -8,7 +8,7 @@ - +
@@ -563,6 +563,18 @@
+

Здесь остаются живые и недавние звонки, а оперативные действия по ним вынесены во всплывающее окно.

Пока нет активных звонков.

@@ -593,6 +605,18 @@
+