Add voice name flow controls and analytics
This commit is contained in:
@@ -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/*",
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
+26
-67
@@ -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,
|
||||
},
|
||||
|
||||
@@ -39,9 +39,23 @@ from services.shared.models import (
|
||||
AIAnalyticsTimeseriesPointOut,
|
||||
AIAnalyticsTotalsOut,
|
||||
AIAnalyticsWindowOut,
|
||||
VoiceNameFlowAnalyticsBreakdownsOut,
|
||||
VoiceNameFlowAnalyticsCoverageOut,
|
||||
VoiceNameFlowAnalyticsFiltersOut,
|
||||
VoiceNameFlowAnalyticsFunnelStageOut,
|
||||
VoiceNameFlowAnalyticsHandoffBreakdownOut,
|
||||
VoiceNameFlowAnalyticsLanguageBreakdownOut,
|
||||
VoiceNameFlowAnalyticsMetricsOut,
|
||||
VoiceNameFlowAnalyticsOverviewOut,
|
||||
VoiceNameFlowAnalyticsQueueBreakdownOut,
|
||||
VoiceNameFlowAnalyticsTimeseriesOut,
|
||||
VoiceNameFlowAnalyticsTimeseriesPointOut,
|
||||
VoiceNameFlowAnalyticsTotalsOut,
|
||||
AITelegramEnqueueIn,
|
||||
AITelegramPauseIn,
|
||||
HealthResponse,
|
||||
VoiceNameCollectionConfig,
|
||||
VoiceNameCollectionConfigOut,
|
||||
VoiceAIStartIn,
|
||||
VoiceAIStartOut,
|
||||
VoiceAITurnIn,
|
||||
@@ -52,6 +66,7 @@ from services.shared.sql_models import (
|
||||
AIJobRow,
|
||||
AISessionRow,
|
||||
AITurnRow,
|
||||
AsteriskCallLinkRow,
|
||||
Customer,
|
||||
CustomerExternalIdentity,
|
||||
Interaction,
|
||||
@@ -61,8 +76,13 @@ from services.shared.sql_models import (
|
||||
WhatsAppThreadRow,
|
||||
TelegramMessageRow,
|
||||
TelegramThreadRow,
|
||||
VoiceAISessionRow,
|
||||
)
|
||||
from services.ai_orchestrator_service import voice as voice_flows
|
||||
from services.ai_orchestrator_service.voice_name_config import (
|
||||
load_voice_name_collection_config,
|
||||
save_voice_name_collection_config,
|
||||
)
|
||||
|
||||
app = FastAPI(title="ai-orchestrator-service", version="1.0.0")
|
||||
|
||||
@@ -82,6 +102,14 @@ _AI_ANALYTICS_METRICS = {
|
||||
_AI_ANALYTICS_INTERVALS = {"hour", "day"}
|
||||
_AI_ANALYTICS_SLICES = {"all", "contained", "handoff", "human_touched", "closed_without_operator", "active", "error"}
|
||||
_AI_ANALYTICS_DRILLDOWN_SORT_FIELDS = {"created_at", "updated_at", "ai_latency_avg_ms", "status"}
|
||||
_VOICE_NAME_FLOW_STATUS_VALUES = {"name_obtained", "name_followup_required", "name_not_obtained"}
|
||||
_VOICE_NAME_FLOW_METRICS = {
|
||||
"scenario_calls",
|
||||
"start_capture_rate",
|
||||
"downstream_rescue_rate",
|
||||
"handoff_unconfirmed_rate",
|
||||
"manual_correction_rate",
|
||||
}
|
||||
_AI_ANALYTICS_REASON_LABELS = {
|
||||
"requested_human": "Запрос клиента на оператора",
|
||||
"knowledge_or_tool_gap": "Недостаточно знаний или tools",
|
||||
@@ -98,6 +126,14 @@ _AI_ANALYTICS_OUTCOME_LABELS = {
|
||||
"active": "Active",
|
||||
"error": "Error",
|
||||
}
|
||||
_VOICE_NAME_FLOW_FUNNEL_LABELS = {
|
||||
"scenario_calls": "Звонки в сценарии",
|
||||
"start_obtained": "Имя взято сразу",
|
||||
"needed_downstream": "Потребовался downstream AI",
|
||||
"downstream_ai_obtained": "Имя добрал downstream AI",
|
||||
"handoff_confirmed_name": "Handoff с подтверждённым именем",
|
||||
"handoff_unconfirmed_name": "Handoff без подтверждённого имени",
|
||||
}
|
||||
|
||||
|
||||
def _parse_analytics_timestamp(raw: str, field_name: str) -> datetime:
|
||||
@@ -195,6 +231,43 @@ def _analytics_filters(range_from: datetime, range_to: datetime, queue_id: str |
|
||||
)
|
||||
|
||||
|
||||
def _safe_json_loads(raw: str | None) -> dict[str, Any]:
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return {}
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
def _normalize_voice_name_language(value: str | None) -> str | None:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if not normalized or normalized == "all":
|
||||
return None
|
||||
return normalized
|
||||
|
||||
|
||||
def _voice_name_filters(range_from: datetime, range_to: datetime, queue_id: str | None, language: str | None) -> VoiceNameFlowAnalyticsFiltersOut:
|
||||
return VoiceNameFlowAnalyticsFiltersOut(
|
||||
from_ts=range_from.isoformat(),
|
||||
to_ts=range_to.isoformat(),
|
||||
queue_id=queue_id,
|
||||
language=language,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_voice_name_metric(metric: str) -> str:
|
||||
normalized = str(metric or "").strip()
|
||||
if normalized not in _VOICE_NAME_FLOW_METRICS:
|
||||
raise HTTPException(status_code=400, detail="Unsupported metric")
|
||||
return normalized
|
||||
|
||||
|
||||
def _voice_name_interval_for_window(range_from: datetime, range_to: datetime) -> str:
|
||||
return "hour" if (range_to - range_from) <= timedelta(hours=36) else "day"
|
||||
|
||||
|
||||
def _normalize_ai_handoff_reason(reason: str | None, claimed_by_user: str | None = None) -> tuple[str | None, str | None, str | None]:
|
||||
raw = (reason or "").strip() or None
|
||||
if not raw and claimed_by_user:
|
||||
@@ -396,6 +469,23 @@ def _empty_ai_analytics_timeseries(
|
||||
)
|
||||
|
||||
|
||||
def _empty_voice_name_flow_timeseries(
|
||||
*,
|
||||
range_from: datetime,
|
||||
range_to: datetime,
|
||||
metric: str,
|
||||
interval: str,
|
||||
queue_id: str | None,
|
||||
language: str | None,
|
||||
) -> VoiceNameFlowAnalyticsTimeseriesOut:
|
||||
return VoiceNameFlowAnalyticsTimeseriesOut(
|
||||
metric=metric, # type: ignore[arg-type]
|
||||
interval=interval, # type: ignore[arg-type]
|
||||
filters=_voice_name_filters(range_from, range_to, queue_id, language),
|
||||
points=[],
|
||||
)
|
||||
|
||||
|
||||
def _load_ai_analytics_snapshots(
|
||||
session,
|
||||
*,
|
||||
@@ -873,6 +963,442 @@ def _load_ai_analytics_session_detail(session, session_id: str) -> AIAnalyticsSe
|
||||
)
|
||||
|
||||
|
||||
def _voice_name_policy_decision_from_turn(turn: AITurnRow) -> dict[str, Any] | None:
|
||||
if str(turn.source_type or "").strip() != "voice_policy":
|
||||
return None
|
||||
payload = _safe_json_loads(turn.payload_json)
|
||||
decision = payload.get("decision")
|
||||
if not isinstance(decision, dict):
|
||||
return None
|
||||
metadata = decision.get("metadata")
|
||||
source = metadata if isinstance(metadata, dict) else decision
|
||||
status = str(source.get("customer_name_status") or "").strip()
|
||||
if status not in _VOICE_NAME_FLOW_STATUS_VALUES:
|
||||
return None
|
||||
value = str(source.get("customer_name_value") or "").strip() or None
|
||||
name_source = str(source.get("customer_name_source") or "").strip() or None
|
||||
language = str(source.get("language") or decision.get("language") or "").strip().lower() or None
|
||||
return {
|
||||
"ts": turn.created_at,
|
||||
"status": status,
|
||||
"value": value,
|
||||
"source": name_source,
|
||||
"language": language,
|
||||
}
|
||||
|
||||
|
||||
def _voice_name_start_event_from_timeline(row: InteractionTimeline) -> dict[str, Any] | None:
|
||||
if str(row.action or "").strip() != "voice.start.completed":
|
||||
return None
|
||||
metadata = _safe_json_loads(row.metadata_json)
|
||||
status = str(metadata.get("customer_name_status") or "").strip()
|
||||
if status not in _VOICE_NAME_FLOW_STATUS_VALUES:
|
||||
return None
|
||||
value = str(metadata.get("customer_name_value") or "").strip() or None
|
||||
name_source = str(metadata.get("customer_name_source") or "").strip() or None
|
||||
language = str(metadata.get("language") or "").strip().lower() or None
|
||||
return {
|
||||
"ts": row.timestamp,
|
||||
"status": status,
|
||||
"value": value,
|
||||
"source": name_source,
|
||||
"language": language,
|
||||
}
|
||||
|
||||
|
||||
def _voice_name_primary_outcome(start_decision: dict[str, Any], final_state: dict[str, Any]) -> str:
|
||||
if start_decision.get("status") == "name_obtained":
|
||||
return "start_obtained"
|
||||
if final_state.get("status") == "name_obtained":
|
||||
return "downstream_ai_obtained"
|
||||
if final_state.get("status") == "name_followup_required":
|
||||
return "followup_required"
|
||||
return "name_not_obtained"
|
||||
|
||||
|
||||
def _voice_name_operator_handoff(
|
||||
row: VoiceAISessionRow,
|
||||
*,
|
||||
call_row: AsteriskCallLinkRow | None,
|
||||
interaction: Interaction | None,
|
||||
) -> bool:
|
||||
handoff_reason = str(
|
||||
row.handoff_reason
|
||||
or (call_row.ai_handoff_reason if call_row else "")
|
||||
or ""
|
||||
).strip()
|
||||
has_operator_owner = bool(
|
||||
(interaction and interaction.assigned_to)
|
||||
or (call_row and (call_row.claimed_by_user or call_row.operator_extension))
|
||||
)
|
||||
if has_operator_owner:
|
||||
return True
|
||||
if handoff_reason and handoff_reason != "voice_start_completed":
|
||||
return True
|
||||
call_ai_state = str(call_row.ai_state or "").strip() if call_row else ""
|
||||
row_status = str(row.status or "").strip()
|
||||
if call_ai_state == "human_owned" or row_status == "human_owned":
|
||||
return True
|
||||
if handoff_reason and handoff_reason != "voice_start_completed":
|
||||
return call_ai_state in {"handoff_requested", "handoff_required"} or row_status in {"handoff_requested", "handoff_required"}
|
||||
return False
|
||||
|
||||
|
||||
def _voice_name_analytics_coverage_from_snapshots(snapshots: list[dict[str, Any]]) -> VoiceNameFlowAnalyticsCoverageOut:
|
||||
note = (
|
||||
"Статусы восстановления имени собраны из voice_policy turns и start-event metadata; ручное исправление считается отдельной overlay-метрикой."
|
||||
if snapshots
|
||||
else None
|
||||
)
|
||||
return VoiceNameFlowAnalyticsCoverageOut(
|
||||
sessions_with_start_decision=sum(1 for item in snapshots if item.get("start_decision")),
|
||||
sessions_with_final_ai_state=sum(1 for item in snapshots if item.get("final_state")),
|
||||
sessions_with_manual_overlay=sum(1 for item in snapshots if item.get("manual_corrected")),
|
||||
note=note,
|
||||
)
|
||||
|
||||
|
||||
def _voice_name_metric_value(metric: str, overview: VoiceNameFlowAnalyticsOverviewOut) -> float | None:
|
||||
if metric == "scenario_calls":
|
||||
return float(overview.totals.scenario_calls)
|
||||
if metric == "start_capture_rate":
|
||||
return overview.metrics.start_capture_rate
|
||||
if metric == "downstream_rescue_rate":
|
||||
return overview.metrics.downstream_rescue_rate
|
||||
if metric == "handoff_unconfirmed_rate":
|
||||
return overview.metrics.handoff_unconfirmed_rate
|
||||
return overview.metrics.manual_correction_rate
|
||||
|
||||
|
||||
def _voice_name_metric_denominator(metric: str, overview: VoiceNameFlowAnalyticsOverviewOut) -> int:
|
||||
if metric == "scenario_calls":
|
||||
return overview.totals.scenario_calls
|
||||
if metric == "start_capture_rate":
|
||||
return overview.totals.scenario_calls
|
||||
if metric == "downstream_rescue_rate":
|
||||
return overview.totals.needed_downstream
|
||||
return overview.totals.handoff_confirmed_name + overview.totals.handoff_unconfirmed_name
|
||||
|
||||
|
||||
def _build_voice_name_flow_snapshot(
|
||||
row: VoiceAISessionRow,
|
||||
*,
|
||||
call_row: AsteriskCallLinkRow | None,
|
||||
interaction: Interaction | None,
|
||||
turns: list[AITurnRow],
|
||||
start_event: InteractionTimeline | None,
|
||||
) -> dict[str, Any] | None:
|
||||
decisions = [
|
||||
item
|
||||
for item in (
|
||||
_voice_name_policy_decision_from_turn(turn)
|
||||
for turn in sorted(turns, key=lambda candidate: (candidate.created_at, candidate.turn_id))
|
||||
)
|
||||
if item is not None
|
||||
]
|
||||
fallback_start = _voice_name_start_event_from_timeline(start_event) if start_event else None
|
||||
start_decision = decisions[0] if decisions else fallback_start
|
||||
final_state = decisions[-1] if decisions else fallback_start
|
||||
if not start_decision and not final_state:
|
||||
return None
|
||||
|
||||
resolved_queue_id = (
|
||||
str(row.queue_id or "").strip()
|
||||
or (str(call_row.queue_id or "").strip() if call_row else "")
|
||||
or (str(interaction.queue_id or "").strip() if interaction else "")
|
||||
or "unknown"
|
||||
)
|
||||
resolved_language = (
|
||||
str(row.voice_start_language or "").strip().lower()
|
||||
or str(row.language or "").strip().lower()
|
||||
or (str(call_row.voice_start_language or "").strip().lower() if call_row else "")
|
||||
or str((start_decision or {}).get("language") or "").strip().lower()
|
||||
or "unknown"
|
||||
)
|
||||
manual_corrected = (
|
||||
str(row.customer_name_source or "").strip() == "manual"
|
||||
or (str(call_row.customer_name_source or "").strip() == "manual" if call_row else False)
|
||||
)
|
||||
primary_outcome = _voice_name_primary_outcome(start_decision or {}, final_state or start_decision or {})
|
||||
operator_handoff = _voice_name_operator_handoff(row, call_row=call_row, interaction=interaction)
|
||||
final_status = str((final_state or {}).get("status") or "name_not_obtained")
|
||||
return {
|
||||
"session_id": row.session_id,
|
||||
"call_id": row.call_id,
|
||||
"interaction_id": row.interaction_id or (call_row.interaction_id if call_row else None),
|
||||
"queue_id": resolved_queue_id,
|
||||
"language": resolved_language,
|
||||
"started_at": row.started_at,
|
||||
"status": row.status,
|
||||
"start_decision": start_decision,
|
||||
"final_state": final_state,
|
||||
"manual_corrected": manual_corrected,
|
||||
"primary_outcome": primary_outcome,
|
||||
"needed_downstream": primary_outcome != "start_obtained",
|
||||
"operator_handoff": operator_handoff,
|
||||
"handoff_confirmed_name": operator_handoff and final_status == "name_obtained",
|
||||
"handoff_unconfirmed_name": operator_handoff and final_status in {"name_followup_required", "name_not_obtained"},
|
||||
}
|
||||
|
||||
|
||||
def _load_voice_name_flow_snapshots(
|
||||
session,
|
||||
*,
|
||||
range_from: datetime,
|
||||
range_to: datetime,
|
||||
queue_id: str | None,
|
||||
language: str | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
rows = session.execute(
|
||||
select(VoiceAISessionRow).where(
|
||||
VoiceAISessionRow.started_at >= range_from.isoformat(),
|
||||
VoiceAISessionRow.started_at < range_to.isoformat(),
|
||||
)
|
||||
).scalars().all()
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
interaction_ids = {row.interaction_id for row in rows if row.interaction_id}
|
||||
call_ids = {row.call_id for row in rows if row.call_id}
|
||||
voice_session_ids = {row.session_id for row in rows if row.session_id}
|
||||
ai_session_ids = {row.ai_session_id for row in rows if row.ai_session_id}
|
||||
|
||||
interactions = {}
|
||||
if interaction_ids:
|
||||
interactions = {
|
||||
item.interaction_id: item
|
||||
for item in session.execute(
|
||||
select(Interaction).where(Interaction.interaction_id.in_(interaction_ids))
|
||||
).scalars().all()
|
||||
}
|
||||
|
||||
call_rows = {}
|
||||
for item in session.execute(
|
||||
select(AsteriskCallLinkRow).where(
|
||||
AsteriskCallLinkRow.call_id.in_(call_ids) if call_ids else False
|
||||
)
|
||||
).scalars().all():
|
||||
call_rows[item.call_id] = item
|
||||
if voice_session_ids:
|
||||
for item in session.execute(
|
||||
select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.voice_session_id.in_(voice_session_ids))
|
||||
).scalars().all():
|
||||
if item.call_id not in call_rows:
|
||||
call_rows[item.call_id] = item
|
||||
|
||||
turns_by_ai_session: dict[str, list[AITurnRow]] = defaultdict(list)
|
||||
if ai_session_ids:
|
||||
for turn in session.execute(
|
||||
select(AITurnRow).where(AITurnRow.session_id.in_(ai_session_ids))
|
||||
).scalars().all():
|
||||
turns_by_ai_session[turn.session_id].append(turn)
|
||||
|
||||
start_events: dict[str, InteractionTimeline] = {}
|
||||
if interaction_ids:
|
||||
timeline_rows = session.execute(
|
||||
select(InteractionTimeline).where(
|
||||
InteractionTimeline.interaction_id.in_(interaction_ids),
|
||||
InteractionTimeline.action == "voice.start.completed",
|
||||
)
|
||||
).scalars().all()
|
||||
for item in sorted(timeline_rows, key=lambda candidate: (candidate.timestamp, candidate.id)):
|
||||
if item.interaction_id not in start_events:
|
||||
start_events[item.interaction_id] = item
|
||||
|
||||
normalized_language = _normalize_voice_name_language(language)
|
||||
snapshots: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
call_row = call_rows.get(row.call_id)
|
||||
interaction = interactions.get(row.interaction_id) if row.interaction_id else None
|
||||
snapshot = _build_voice_name_flow_snapshot(
|
||||
row,
|
||||
call_row=call_row,
|
||||
interaction=interaction,
|
||||
turns=list(turns_by_ai_session.get(str(row.ai_session_id or ""), [])),
|
||||
start_event=start_events.get(row.interaction_id) if row.interaction_id else None,
|
||||
)
|
||||
if snapshot is None:
|
||||
continue
|
||||
if queue_id and snapshot["queue_id"] != queue_id:
|
||||
continue
|
||||
if normalized_language and snapshot["language"] != normalized_language:
|
||||
continue
|
||||
snapshots.append(snapshot)
|
||||
return snapshots
|
||||
|
||||
|
||||
def _voice_name_breakdown_metrics(snapshots: list[dict[str, Any]]) -> dict[str, float]:
|
||||
scenario_calls = len(snapshots)
|
||||
start_obtained = sum(1 for item in snapshots if item["primary_outcome"] == "start_obtained")
|
||||
downstream_ai_obtained = sum(1 for item in snapshots if item["primary_outcome"] == "downstream_ai_obtained")
|
||||
needed_downstream = sum(1 for item in snapshots if item.get("needed_downstream"))
|
||||
handoff_confirmed = sum(1 for item in snapshots if item.get("handoff_confirmed_name"))
|
||||
handoff_unconfirmed = sum(1 for item in snapshots if item.get("handoff_unconfirmed_name"))
|
||||
all_handoffs = handoff_confirmed + handoff_unconfirmed
|
||||
manual_corrected = sum(1 for item in snapshots if item.get("manual_corrected"))
|
||||
return {
|
||||
"start_capture_rate": _analytics_percent(start_obtained, scenario_calls),
|
||||
"downstream_rescue_rate": _analytics_percent(downstream_ai_obtained, needed_downstream),
|
||||
"handoff_unconfirmed_rate": _analytics_percent(handoff_unconfirmed, all_handoffs),
|
||||
"manual_correction_rate": _analytics_percent(manual_corrected, all_handoffs),
|
||||
}
|
||||
|
||||
|
||||
def _aggregate_voice_name_flow_overview(
|
||||
snapshots: list[dict[str, Any]],
|
||||
*,
|
||||
range_from: datetime,
|
||||
range_to: datetime,
|
||||
queue_id: str | None,
|
||||
language: str | None,
|
||||
) -> VoiceNameFlowAnalyticsOverviewOut:
|
||||
if not snapshots:
|
||||
return VoiceNameFlowAnalyticsOverviewOut(
|
||||
window=_analytics_window(range_from, range_to),
|
||||
filters=_voice_name_filters(range_from, range_to, queue_id, language),
|
||||
totals=VoiceNameFlowAnalyticsTotalsOut(),
|
||||
metrics=VoiceNameFlowAnalyticsMetricsOut(),
|
||||
breakdowns=VoiceNameFlowAnalyticsBreakdownsOut(),
|
||||
coverage=VoiceNameFlowAnalyticsCoverageOut(),
|
||||
)
|
||||
|
||||
scenario_calls = len(snapshots)
|
||||
start_obtained = sum(1 for item in snapshots if item["primary_outcome"] == "start_obtained")
|
||||
downstream_ai_obtained = sum(1 for item in snapshots if item["primary_outcome"] == "downstream_ai_obtained")
|
||||
followup_required = sum(1 for item in snapshots if item["primary_outcome"] == "followup_required")
|
||||
name_not_obtained = sum(1 for item in snapshots if item["primary_outcome"] == "name_not_obtained")
|
||||
manual_corrected = sum(1 for item in snapshots if item.get("manual_corrected"))
|
||||
handoff_confirmed_name = sum(1 for item in snapshots if item.get("handoff_confirmed_name"))
|
||||
handoff_unconfirmed_name = sum(1 for item in snapshots if item.get("handoff_unconfirmed_name"))
|
||||
needed_downstream = sum(1 for item in snapshots if item.get("needed_downstream"))
|
||||
totals = VoiceNameFlowAnalyticsTotalsOut(
|
||||
scenario_calls=scenario_calls,
|
||||
start_obtained=start_obtained,
|
||||
downstream_ai_obtained=downstream_ai_obtained,
|
||||
followup_required=followup_required,
|
||||
name_not_obtained=name_not_obtained,
|
||||
manual_corrected=manual_corrected,
|
||||
handoff_confirmed_name=handoff_confirmed_name,
|
||||
handoff_unconfirmed_name=handoff_unconfirmed_name,
|
||||
needed_downstream=needed_downstream,
|
||||
)
|
||||
metric_values = _voice_name_breakdown_metrics(snapshots)
|
||||
metrics = VoiceNameFlowAnalyticsMetricsOut(**metric_values)
|
||||
|
||||
funnel = [
|
||||
VoiceNameFlowAnalyticsFunnelStageOut(
|
||||
stage=stage, # type: ignore[arg-type]
|
||||
label=_VOICE_NAME_FLOW_FUNNEL_LABELS[stage],
|
||||
sessions=value,
|
||||
share=_analytics_percent(value, scenario_calls),
|
||||
)
|
||||
for stage, value in (
|
||||
("scenario_calls", scenario_calls),
|
||||
("start_obtained", start_obtained),
|
||||
("needed_downstream", needed_downstream),
|
||||
("downstream_ai_obtained", downstream_ai_obtained),
|
||||
("handoff_confirmed_name", handoff_confirmed_name),
|
||||
("handoff_unconfirmed_name", handoff_unconfirmed_name),
|
||||
)
|
||||
]
|
||||
|
||||
by_language_rows: list[VoiceNameFlowAnalyticsLanguageBreakdownOut] = []
|
||||
language_groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
for item in snapshots:
|
||||
language_groups[str(item["language"] or "unknown")].append(item)
|
||||
for resolved_language, items in sorted(language_groups.items(), key=lambda entry: (-len(entry[1]), entry[0])):
|
||||
breakdown_metrics = _voice_name_breakdown_metrics(items)
|
||||
by_language_rows.append(
|
||||
VoiceNameFlowAnalyticsLanguageBreakdownOut(
|
||||
language=resolved_language,
|
||||
scenario_calls=len(items),
|
||||
start_obtained=sum(1 for item in items if item["primary_outcome"] == "start_obtained"),
|
||||
downstream_ai_obtained=sum(1 for item in items if item["primary_outcome"] == "downstream_ai_obtained"),
|
||||
followup_required=sum(1 for item in items if item["primary_outcome"] == "followup_required"),
|
||||
name_not_obtained=sum(1 for item in items if item["primary_outcome"] == "name_not_obtained"),
|
||||
manual_corrected=sum(1 for item in items if item.get("manual_corrected")),
|
||||
handoff_confirmed_name=sum(1 for item in items if item.get("handoff_confirmed_name")),
|
||||
handoff_unconfirmed_name=sum(1 for item in items if item.get("handoff_unconfirmed_name")),
|
||||
**breakdown_metrics,
|
||||
)
|
||||
)
|
||||
|
||||
by_queue_rows: list[VoiceNameFlowAnalyticsQueueBreakdownOut] = []
|
||||
queue_groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
for item in snapshots:
|
||||
queue_groups[str(item["queue_id"] or "unknown")].append(item)
|
||||
for resolved_queue_id, items in sorted(queue_groups.items(), key=lambda entry: (-len(entry[1]), entry[0])):
|
||||
breakdown_metrics = _voice_name_breakdown_metrics(items)
|
||||
by_queue_rows.append(
|
||||
VoiceNameFlowAnalyticsQueueBreakdownOut(
|
||||
queue_id=resolved_queue_id,
|
||||
scenario_calls=len(items),
|
||||
start_obtained=sum(1 for item in items if item["primary_outcome"] == "start_obtained"),
|
||||
downstream_ai_obtained=sum(1 for item in items if item["primary_outcome"] == "downstream_ai_obtained"),
|
||||
followup_required=sum(1 for item in items if item["primary_outcome"] == "followup_required"),
|
||||
name_not_obtained=sum(1 for item in items if item["primary_outcome"] == "name_not_obtained"),
|
||||
manual_corrected=sum(1 for item in items if item.get("manual_corrected")),
|
||||
handoff_confirmed_name=sum(1 for item in items if item.get("handoff_confirmed_name")),
|
||||
handoff_unconfirmed_name=sum(1 for item in items if item.get("handoff_unconfirmed_name")),
|
||||
**breakdown_metrics,
|
||||
)
|
||||
)
|
||||
|
||||
all_handoffs = handoff_confirmed_name + handoff_unconfirmed_name
|
||||
handoff_rows = [
|
||||
VoiceNameFlowAnalyticsHandoffBreakdownOut(
|
||||
outcome="confirmed_name",
|
||||
label="Handoff с подтверждённым именем",
|
||||
sessions=handoff_confirmed_name,
|
||||
share=_analytics_percent(handoff_confirmed_name, all_handoffs),
|
||||
),
|
||||
VoiceNameFlowAnalyticsHandoffBreakdownOut(
|
||||
outcome="unconfirmed_name",
|
||||
label="Handoff без подтверждённого имени",
|
||||
sessions=handoff_unconfirmed_name,
|
||||
share=_analytics_percent(handoff_unconfirmed_name, all_handoffs),
|
||||
),
|
||||
]
|
||||
|
||||
return VoiceNameFlowAnalyticsOverviewOut(
|
||||
window=_analytics_window(range_from, range_to),
|
||||
filters=_voice_name_filters(range_from, range_to, queue_id, language),
|
||||
totals=totals,
|
||||
metrics=metrics,
|
||||
breakdowns=VoiceNameFlowAnalyticsBreakdownsOut(
|
||||
funnel=funnel,
|
||||
by_language=by_language_rows,
|
||||
by_queue=by_queue_rows,
|
||||
handoff=handoff_rows,
|
||||
),
|
||||
coverage=_voice_name_analytics_coverage_from_snapshots(snapshots),
|
||||
)
|
||||
|
||||
|
||||
def _load_voice_name_flow_overview(
|
||||
session,
|
||||
*,
|
||||
range_from: datetime,
|
||||
range_to: datetime,
|
||||
queue_id: str | None,
|
||||
language: str | None,
|
||||
) -> VoiceNameFlowAnalyticsOverviewOut:
|
||||
snapshots = _load_voice_name_flow_snapshots(
|
||||
session,
|
||||
range_from=range_from,
|
||||
range_to=range_to,
|
||||
queue_id=queue_id,
|
||||
language=language,
|
||||
)
|
||||
return _aggregate_voice_name_flow_overview(
|
||||
snapshots,
|
||||
range_from=range_from,
|
||||
range_to=range_to,
|
||||
queue_id=queue_id,
|
||||
language=_normalize_voice_name_language(language),
|
||||
)
|
||||
|
||||
|
||||
def _bool_env(name: str, default: bool) -> bool:
|
||||
raw = os.getenv(name)
|
||||
if raw is None:
|
||||
@@ -2605,6 +3131,85 @@ def ai_analytics_timeseries(
|
||||
)
|
||||
|
||||
|
||||
@app.get("/ai/analytics/voice-name-flow/overview", response_model=VoiceNameFlowAnalyticsOverviewOut)
|
||||
def voice_name_flow_overview(
|
||||
from_ts: str = Query(...),
|
||||
to_ts: str = Query(...),
|
||||
queue_id: str | None = None,
|
||||
language: str | None = None,
|
||||
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)),
|
||||
) -> VoiceNameFlowAnalyticsOverviewOut:
|
||||
range_from = _parse_analytics_timestamp(from_ts, "from_ts")
|
||||
range_to = _parse_analytics_timestamp(to_ts, "to_ts")
|
||||
if range_to <= range_from:
|
||||
raise HTTPException(status_code=400, detail="to_ts must be greater than from_ts")
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
return _load_voice_name_flow_overview(
|
||||
session,
|
||||
range_from=range_from,
|
||||
range_to=range_to,
|
||||
queue_id=queue_id,
|
||||
language=language,
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@app.get("/ai/analytics/voice-name-flow/timeseries", response_model=VoiceNameFlowAnalyticsTimeseriesOut)
|
||||
def voice_name_flow_timeseries(
|
||||
from_ts: str = Query(...),
|
||||
to_ts: str = Query(...),
|
||||
metric: str = Query(default="scenario_calls"),
|
||||
queue_id: str | None = None,
|
||||
language: str | None = None,
|
||||
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)),
|
||||
) -> VoiceNameFlowAnalyticsTimeseriesOut:
|
||||
range_from = _parse_analytics_timestamp(from_ts, "from_ts")
|
||||
range_to = _parse_analytics_timestamp(to_ts, "to_ts")
|
||||
if range_to <= range_from:
|
||||
raise HTTPException(status_code=400, detail="to_ts must be greater than from_ts")
|
||||
|
||||
normalized_metric = _normalize_voice_name_metric(metric)
|
||||
normalized_language = _normalize_voice_name_language(language)
|
||||
normalized_interval = _voice_name_interval_for_window(range_from, range_to)
|
||||
step = timedelta(hours=1) if normalized_interval == "hour" else timedelta(days=1)
|
||||
points: list[VoiceNameFlowAnalyticsTimeseriesPointOut] = []
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
cursor = range_from
|
||||
while cursor < range_to:
|
||||
bucket_from = cursor
|
||||
bucket_to = min(bucket_from + step, range_to)
|
||||
overview = _load_voice_name_flow_overview(
|
||||
session,
|
||||
range_from=bucket_from,
|
||||
range_to=bucket_to,
|
||||
queue_id=queue_id,
|
||||
language=normalized_language,
|
||||
)
|
||||
points.append(
|
||||
VoiceNameFlowAnalyticsTimeseriesPointOut(
|
||||
ts=bucket_from.isoformat(),
|
||||
value=_voice_name_metric_value(normalized_metric, overview),
|
||||
scenario_calls=overview.totals.scenario_calls,
|
||||
denominator=_voice_name_metric_denominator(normalized_metric, overview),
|
||||
)
|
||||
)
|
||||
cursor = bucket_to
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
return VoiceNameFlowAnalyticsTimeseriesOut(
|
||||
metric=normalized_metric, # type: ignore[arg-type]
|
||||
interval=normalized_interval, # type: ignore[arg-type]
|
||||
filters=_voice_name_filters(range_from, range_to, queue_id, normalized_language),
|
||||
points=points,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/ai/analytics/drilldown", response_model=AIAnalyticsDrilldownOut)
|
||||
def ai_analytics_drilldown(
|
||||
from_ts: str = Query(...),
|
||||
@@ -2672,6 +3277,29 @@ def start_voice_ai_session(
|
||||
return voice_flows.start_voice_session(session_id, payload)
|
||||
|
||||
|
||||
@app.get("/ai/voice/config/name-collection", response_model=VoiceNameCollectionConfigOut)
|
||||
def get_voice_name_collection_config(
|
||||
_: dict = Depends(require_roles(Role.ADMIN)),
|
||||
) -> VoiceNameCollectionConfigOut:
|
||||
session = get_session()
|
||||
try:
|
||||
return load_voice_name_collection_config(session)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@app.put("/ai/voice/config/name-collection", response_model=VoiceNameCollectionConfigOut)
|
||||
def put_voice_name_collection_config(
|
||||
payload: VoiceNameCollectionConfig,
|
||||
_: dict = Depends(require_roles(Role.ADMIN)),
|
||||
) -> VoiceNameCollectionConfigOut:
|
||||
session = get_session()
|
||||
try:
|
||||
return save_voice_name_collection_config(session, payload)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@app.post("/ai/voice/sessions/{session_id}/turns")
|
||||
def turn_voice_ai_session(
|
||||
session_id: str,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,168 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from services.shared.core import utc_now_iso
|
||||
from services.shared.models import (
|
||||
VoiceNameCollectionConfig,
|
||||
VoiceNameCollectionConfigOut,
|
||||
VoiceNameCollectionLanguageTexts,
|
||||
VoiceNameCollectionTextsConfig,
|
||||
)
|
||||
from services.shared.sql_models import VoiceNameCollectionSettingsRow
|
||||
|
||||
|
||||
VOICE_NAME_COLLECTION_SETTINGS_KEY = "global"
|
||||
|
||||
|
||||
def voice_name_collection_default_config() -> VoiceNameCollectionConfig:
|
||||
return VoiceNameCollectionConfig(
|
||||
enabled=True,
|
||||
texts=VoiceNameCollectionTextsConfig(
|
||||
ru=VoiceNameCollectionLanguageTexts(
|
||||
start_prompt="Здравствуйте. Назовите, пожалуйста, ваше имя.",
|
||||
personalized_greeting_template=(
|
||||
"Я AI-оператор компании. Здравствуйте, {name}. "
|
||||
"Коротко расскажите, с чем помочь, и я сразу начну разбираться."
|
||||
),
|
||||
confirmation_greeting_template=(
|
||||
"Я AI-оператор компании. Если я правильно расслышал, вас зовут {name}? "
|
||||
"И чем помочь?"
|
||||
),
|
||||
inline_followup_prompt="И ещё подскажите, как мне к вам обращаться?",
|
||||
),
|
||||
kz=VoiceNameCollectionLanguageTexts(
|
||||
start_prompt="Сәлеметсіз бе. Атыңызды атаңызшы.",
|
||||
personalized_greeting_template=(
|
||||
"Men kompaniyanyn AI operatoriymyn. Salemetsiz be, {name}. "
|
||||
"Suragynyzdy aitanyz, men birden komektesuge tyrisamyn."
|
||||
),
|
||||
confirmation_greeting_template=(
|
||||
"Men kompaniyanyn AI operatoriymyn. Durys estisem, atynyz {name} pa? "
|
||||
"Qalai komektesemin?"
|
||||
),
|
||||
inline_followup_prompt="Tagy bir naqtylasam, sizge qalai qaratamyn?",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _settings_row(session) -> VoiceNameCollectionSettingsRow | None:
|
||||
return session.execute(
|
||||
select(VoiceNameCollectionSettingsRow).where(
|
||||
VoiceNameCollectionSettingsRow.settings_key == VOICE_NAME_COLLECTION_SETTINGS_KEY
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _normalize_config_payload(raw: Any) -> VoiceNameCollectionConfig:
|
||||
if isinstance(raw, VoiceNameCollectionConfig):
|
||||
return raw
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("Voice name collection config payload must be an object")
|
||||
return VoiceNameCollectionConfig.model_validate(raw)
|
||||
|
||||
|
||||
def load_voice_name_collection_config(session) -> VoiceNameCollectionConfigOut:
|
||||
default_config = voice_name_collection_default_config()
|
||||
row = _settings_row(session)
|
||||
if row is None:
|
||||
return VoiceNameCollectionConfigOut(config=default_config, updated_at=None, source="defaults")
|
||||
try:
|
||||
parsed = json.loads(row.config_json or "{}")
|
||||
config = _normalize_config_payload(parsed)
|
||||
return VoiceNameCollectionConfigOut(config=config, updated_at=row.updated_at, source="database")
|
||||
except Exception:
|
||||
return VoiceNameCollectionConfigOut(config=default_config, updated_at=row.updated_at, source="defaults")
|
||||
|
||||
|
||||
def load_effective_voice_name_collection_config(session) -> VoiceNameCollectionConfig:
|
||||
return load_voice_name_collection_config(session).config
|
||||
|
||||
|
||||
def save_voice_name_collection_config(
|
||||
session,
|
||||
config_payload: VoiceNameCollectionConfig | dict[str, Any],
|
||||
) -> VoiceNameCollectionConfigOut:
|
||||
config = _normalize_config_payload(config_payload)
|
||||
row = _settings_row(session)
|
||||
now = utc_now_iso()
|
||||
if row is None:
|
||||
row = VoiceNameCollectionSettingsRow(
|
||||
settings_key=VOICE_NAME_COLLECTION_SETTINGS_KEY,
|
||||
config_json="{}",
|
||||
updated_at=now,
|
||||
)
|
||||
session.add(row)
|
||||
row.config_json = json.dumps(config.model_dump(), ensure_ascii=False)
|
||||
row.updated_at = now
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
return VoiceNameCollectionConfigOut(config=config, updated_at=row.updated_at, source="database")
|
||||
|
||||
|
||||
def voice_name_collection_texts(
|
||||
config: VoiceNameCollectionConfig,
|
||||
language: str,
|
||||
) -> VoiceNameCollectionLanguageTexts:
|
||||
if str(language or "").strip().lower() == "kz":
|
||||
return config.texts.kz
|
||||
return config.texts.ru
|
||||
|
||||
|
||||
def voice_name_collection_start_prompt(config: VoiceNameCollectionConfig, language: str) -> str:
|
||||
return voice_name_collection_texts(config, language).start_prompt
|
||||
|
||||
|
||||
def voice_name_collection_personalized_greeting(
|
||||
config: VoiceNameCollectionConfig,
|
||||
language: str,
|
||||
name: str,
|
||||
) -> str:
|
||||
return voice_name_collection_texts(config, language).personalized_greeting_template.format(name=name)
|
||||
|
||||
|
||||
def voice_name_collection_confirmation_greeting(
|
||||
config: VoiceNameCollectionConfig,
|
||||
language: str,
|
||||
name: str,
|
||||
) -> str:
|
||||
return voice_name_collection_texts(config, language).confirmation_greeting_template.format(name=name)
|
||||
|
||||
|
||||
def voice_name_collection_inline_followup(config: VoiceNameCollectionConfig, language: str) -> str:
|
||||
return voice_name_collection_texts(config, language).inline_followup_prompt
|
||||
|
||||
|
||||
def voice_name_collection_followup_markers(
|
||||
config: VoiceNameCollectionConfig,
|
||||
language: str,
|
||||
) -> tuple[str, ...]:
|
||||
texts = voice_name_collection_texts(config, language)
|
||||
markers = [texts.inline_followup_prompt]
|
||||
for template in (texts.confirmation_greeting_template, texts.personalized_greeting_template):
|
||||
for chunk in str(template or "").split("{name}"):
|
||||
normalized = chunk.strip(" ,.!?;:-")
|
||||
if len(normalized) >= 5:
|
||||
markers.append(normalized)
|
||||
markers.extend(
|
||||
[
|
||||
"как мне к вам обращаться",
|
||||
"как к вам обращаться",
|
||||
"вас зовут",
|
||||
"atyngyz",
|
||||
"qalai qaratamyn",
|
||||
]
|
||||
)
|
||||
unique_markers: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for marker in markers:
|
||||
key = marker.strip()
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
unique_markers.append(key)
|
||||
return tuple(unique_markers)
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -10,6 +10,7 @@ from services.shared.core import new_id, utc_now_iso
|
||||
from services.shared.db import get_session
|
||||
from services.shared.models import (
|
||||
CustomerCreate,
|
||||
CustomerNameUpdateIn,
|
||||
CustomerHistoryEventOut,
|
||||
CustomerHistoryOut,
|
||||
CustomerHistorySummaryOut,
|
||||
@@ -24,10 +25,12 @@ from services.shared.sql_models import (
|
||||
AsteriskCallLinkRow,
|
||||
CallRecordingRow,
|
||||
Customer,
|
||||
CustomerExternalIdentity,
|
||||
Interaction,
|
||||
InteractionTimeline,
|
||||
TelegramMessageRow,
|
||||
TelegramThreadRow,
|
||||
VoiceAISessionRow,
|
||||
VoiceTranscriptSegmentRow,
|
||||
)
|
||||
|
||||
@@ -138,7 +141,11 @@ def _to_out(row: Customer) -> CustomerOut:
|
||||
preferred_phone=row.preferred_phone,
|
||||
tags=tags,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _normalized_display_name(value: str) -> str:
|
||||
return " ".join(str(value or "").split()).strip()
|
||||
|
||||
|
||||
def _interaction_to_out(row: Interaction) -> InteractionOut:
|
||||
@@ -491,6 +498,62 @@ def search_customers(query: str | None = None, limit: int = 100) -> list[Custome
|
||||
session.close()
|
||||
|
||||
|
||||
@app.patch("/customers/{customer_id}", response_model=CustomerOut)
|
||||
def update_customer_name(customer_id: str, payload: CustomerNameUpdateIn) -> CustomerOut:
|
||||
display_name = _normalized_display_name(payload.display_name)
|
||||
if len(display_name) < 2:
|
||||
raise HTTPException(status_code=400, detail="display_name must be at least 2 chars")
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
customer = session.execute(
|
||||
select(Customer).where(Customer.customer_id == customer_id)
|
||||
).scalar_one_or_none()
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
|
||||
now = utc_now_iso()
|
||||
customer.display_name = display_name
|
||||
|
||||
identities = session.execute(
|
||||
select(CustomerExternalIdentity).where(CustomerExternalIdentity.customer_id == customer_id)
|
||||
).scalars().all()
|
||||
for identity in identities:
|
||||
identity.display_name_snapshot = display_name
|
||||
identity.updated_at = now
|
||||
|
||||
interaction_ids = session.execute(
|
||||
select(Interaction.interaction_id).where(Interaction.customer_id == customer_id)
|
||||
).scalars().all()
|
||||
if interaction_ids:
|
||||
live_calls = session.execute(
|
||||
select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.interaction_id.in_(interaction_ids))
|
||||
).scalars().all()
|
||||
for row in live_calls:
|
||||
row.caller_name = display_name
|
||||
row.customer_name_status = "name_obtained"
|
||||
row.customer_name_value = display_name
|
||||
row.customer_name_source = "manual"
|
||||
row.customer_name_resolved_at = now
|
||||
row.updated_at = now
|
||||
|
||||
voice_sessions = session.execute(
|
||||
select(VoiceAISessionRow).where(VoiceAISessionRow.interaction_id.in_(interaction_ids))
|
||||
).scalars().all()
|
||||
for row in voice_sessions:
|
||||
row.customer_name_status = "name_obtained"
|
||||
row.customer_name_value = display_name
|
||||
row.customer_name_source = "manual"
|
||||
row.customer_name_resolved_at = now
|
||||
row.updated_at = now
|
||||
|
||||
session.commit()
|
||||
session.refresh(customer)
|
||||
return _to_out(customer)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@app.get("/customers/{customer_id}/history", response_model=CustomerHistoryOut)
|
||||
def customer_history(customer_id: str) -> CustomerHistoryOut:
|
||||
session = get_session()
|
||||
|
||||
+216
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
from services.shared.core import Role
|
||||
|
||||
@@ -30,6 +30,72 @@ EventOutboxStatus = Literal["pending", "published", "failed"]
|
||||
AsteriskForwardStatus = Literal["received", "processing", "forwarded", "failed"]
|
||||
TelephonyStatus = Literal["ringing", "claimed", "connected", "ended", "failed"]
|
||||
CallActionResultStatus = Literal["ok", "failed", "rejected"]
|
||||
VoiceStartNameStatus = Literal["name_obtained", "name_not_obtained", "name_followup_required"]
|
||||
VoiceStartNameSource = Literal["known_customer", "voice_start", "voice_followup", "none"]
|
||||
VoiceNameKnownCustomerBehavior = Literal["trust_and_handoff", "confirm_in_downstream", "ask_on_start"]
|
||||
VoiceNameUnknownCustomerBehavior = Literal["ask_on_start", "skip_to_downstream"]
|
||||
VoiceNameMissingNameBehavior = Literal["ask_inline_once", "do_not_ask"]
|
||||
VoiceNameUncertainNameBehavior = Literal["confirm_then_finalize", "finalize_immediately", "discard_and_collect"]
|
||||
|
||||
|
||||
class VoiceNameCollectionLanguageTexts(BaseModel):
|
||||
start_prompt: str = Field(min_length=1)
|
||||
personalized_greeting_template: str = Field(min_length=1)
|
||||
confirmation_greeting_template: str = Field(min_length=1)
|
||||
inline_followup_prompt: str = Field(min_length=1)
|
||||
|
||||
@field_validator(
|
||||
"start_prompt",
|
||||
"personalized_greeting_template",
|
||||
"confirmation_greeting_template",
|
||||
"inline_followup_prompt",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
def _trim_required_text(cls, value: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError("Text value is required")
|
||||
return normalized
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_name_templates(self):
|
||||
if "{name}" not in self.personalized_greeting_template:
|
||||
raise ValueError("personalized_greeting_template must contain {name}")
|
||||
if "{name}" not in self.confirmation_greeting_template:
|
||||
raise ValueError("confirmation_greeting_template must contain {name}")
|
||||
return self
|
||||
|
||||
|
||||
class VoiceNameCollectionStartConfig(BaseModel):
|
||||
ask_name_on_start: bool = True
|
||||
known_customer_behavior: VoiceNameKnownCustomerBehavior = "trust_and_handoff"
|
||||
unknown_customer_behavior: VoiceNameUnknownCustomerBehavior = "ask_on_start"
|
||||
|
||||
|
||||
class VoiceNameCollectionDownstreamConfig(BaseModel):
|
||||
missing_name_behavior: VoiceNameMissingNameBehavior = "ask_inline_once"
|
||||
uncertain_name_behavior: VoiceNameUncertainNameBehavior = "confirm_then_finalize"
|
||||
finalize_on_explicit_name: bool = True
|
||||
finalize_on_confirmation: bool = True
|
||||
|
||||
|
||||
class VoiceNameCollectionTextsConfig(BaseModel):
|
||||
ru: VoiceNameCollectionLanguageTexts
|
||||
kz: VoiceNameCollectionLanguageTexts
|
||||
|
||||
|
||||
class VoiceNameCollectionConfig(BaseModel):
|
||||
enabled: bool = True
|
||||
start: VoiceNameCollectionStartConfig = Field(default_factory=VoiceNameCollectionStartConfig)
|
||||
downstream: VoiceNameCollectionDownstreamConfig = Field(default_factory=VoiceNameCollectionDownstreamConfig)
|
||||
texts: VoiceNameCollectionTextsConfig
|
||||
|
||||
|
||||
class VoiceNameCollectionConfigOut(BaseModel):
|
||||
config: VoiceNameCollectionConfig
|
||||
updated_at: str | None = None
|
||||
source: Literal["defaults", "database"] = "defaults"
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
@@ -91,6 +157,11 @@ class CustomerCreate(BaseModel):
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CustomerNameUpdateIn(BaseModel):
|
||||
display_name: str = Field(min_length=2)
|
||||
source: str | None = None
|
||||
|
||||
|
||||
class CustomerOut(CustomerCreate):
|
||||
customer_id: str
|
||||
created_at: str
|
||||
@@ -332,6 +403,10 @@ class VoiceAISummaryOut(BaseModel):
|
||||
voice_session_id: str | None = None
|
||||
status_label: str
|
||||
status_tone: Literal["answered", "handoff"]
|
||||
customer_name_status: VoiceStartNameStatus | None = None
|
||||
customer_name_value: str | None = None
|
||||
customer_name_source: VoiceStartNameSource | None = None
|
||||
voice_start_language: str | None = None
|
||||
customer_request_text: str
|
||||
ai_outcome_text: str
|
||||
handoff_reason: str
|
||||
@@ -589,6 +664,18 @@ class VoiceAITurnDecisionOut(BaseModel):
|
||||
model: str | None = None
|
||||
latency_ms: int | None = None
|
||||
status: VoiceAIState = "active"
|
||||
metadata: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class VoiceStartResult(BaseModel):
|
||||
language: str
|
||||
customer_id: str | None = None
|
||||
customer_name_status: VoiceStartNameStatus = "name_not_obtained"
|
||||
customer_name_value: str | None = None
|
||||
customer_name_source: VoiceStartNameSource = "none"
|
||||
downstream_queue_id: str | None = None
|
||||
downstream_queue_code: str | None = None
|
||||
resolved_at: str | None = None
|
||||
|
||||
|
||||
class VoiceAIStartIn(BaseModel):
|
||||
@@ -598,6 +685,7 @@ class VoiceAIStartIn(BaseModel):
|
||||
customer_id: str | None = None
|
||||
language_hint: str | None = None
|
||||
agent_profile: str = "voice_support"
|
||||
metadata: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class VoiceAIStartOut(BaseModel):
|
||||
@@ -605,6 +693,11 @@ class VoiceAIStartOut(BaseModel):
|
||||
language: str
|
||||
greeting_text: str
|
||||
disclosure_required: bool = True
|
||||
needs_handoff: bool = False
|
||||
handoff_reason: str | None = None
|
||||
summary_text: str = ""
|
||||
start_result: VoiceStartResult | None = None
|
||||
metadata: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class VoiceAIHandoffRequestIn(BaseModel):
|
||||
@@ -614,6 +707,7 @@ class VoiceAIHandoffRequestIn(BaseModel):
|
||||
target_queue_id: str | None = None
|
||||
reason: str = Field(min_length=1)
|
||||
summary: dict = Field(default_factory=dict)
|
||||
metadata: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class VoiceAICallStateUpdateIn(BaseModel):
|
||||
@@ -1044,6 +1138,7 @@ class ReportingSavedViewSnapshot(BaseModel):
|
||||
channel: str = "all"
|
||||
compareMode: str = "previous"
|
||||
trendMetric: str = "volume"
|
||||
voiceNameTrendMetric: str = "scenario_calls"
|
||||
aiTrendMetric: str = "containment_rate"
|
||||
agentTrendMetric: str = "interactions_per_agent"
|
||||
|
||||
@@ -1166,6 +1261,126 @@ class AIAnalyticsTimeseriesOut(BaseModel):
|
||||
points: list[AIAnalyticsTimeseriesPointOut] = Field(default_factory=list)
|
||||
|
||||
|
||||
class VoiceNameFlowAnalyticsFiltersOut(AIAnalyticsWindowOut):
|
||||
queue_id: str | None = None
|
||||
language: str | None = None
|
||||
|
||||
|
||||
class VoiceNameFlowAnalyticsTotalsOut(BaseModel):
|
||||
scenario_calls: int = 0
|
||||
start_obtained: int = 0
|
||||
downstream_ai_obtained: int = 0
|
||||
followup_required: int = 0
|
||||
name_not_obtained: int = 0
|
||||
manual_corrected: int = 0
|
||||
handoff_confirmed_name: int = 0
|
||||
handoff_unconfirmed_name: int = 0
|
||||
needed_downstream: int = 0
|
||||
|
||||
|
||||
class VoiceNameFlowAnalyticsMetricsOut(BaseModel):
|
||||
start_capture_rate: float = 0.0
|
||||
downstream_rescue_rate: float = 0.0
|
||||
handoff_unconfirmed_rate: float = 0.0
|
||||
manual_correction_rate: float = 0.0
|
||||
|
||||
|
||||
class VoiceNameFlowAnalyticsFunnelStageOut(BaseModel):
|
||||
stage: Literal[
|
||||
"scenario_calls",
|
||||
"start_obtained",
|
||||
"needed_downstream",
|
||||
"downstream_ai_obtained",
|
||||
"handoff_confirmed_name",
|
||||
"handoff_unconfirmed_name",
|
||||
]
|
||||
label: str
|
||||
sessions: int = 0
|
||||
share: float = 0.0
|
||||
|
||||
|
||||
class VoiceNameFlowAnalyticsLanguageBreakdownOut(BaseModel):
|
||||
language: str
|
||||
scenario_calls: int = 0
|
||||
start_obtained: int = 0
|
||||
downstream_ai_obtained: int = 0
|
||||
followup_required: int = 0
|
||||
name_not_obtained: int = 0
|
||||
manual_corrected: int = 0
|
||||
handoff_confirmed_name: int = 0
|
||||
handoff_unconfirmed_name: int = 0
|
||||
start_capture_rate: float = 0.0
|
||||
downstream_rescue_rate: float = 0.0
|
||||
handoff_unconfirmed_rate: float = 0.0
|
||||
manual_correction_rate: float = 0.0
|
||||
|
||||
|
||||
class VoiceNameFlowAnalyticsQueueBreakdownOut(BaseModel):
|
||||
queue_id: str
|
||||
scenario_calls: int = 0
|
||||
start_obtained: int = 0
|
||||
downstream_ai_obtained: int = 0
|
||||
followup_required: int = 0
|
||||
name_not_obtained: int = 0
|
||||
manual_corrected: int = 0
|
||||
handoff_confirmed_name: int = 0
|
||||
handoff_unconfirmed_name: int = 0
|
||||
start_capture_rate: float = 0.0
|
||||
downstream_rescue_rate: float = 0.0
|
||||
handoff_unconfirmed_rate: float = 0.0
|
||||
manual_correction_rate: float = 0.0
|
||||
|
||||
|
||||
class VoiceNameFlowAnalyticsHandoffBreakdownOut(BaseModel):
|
||||
outcome: Literal["confirmed_name", "unconfirmed_name"]
|
||||
label: str
|
||||
sessions: int = 0
|
||||
share: float = 0.0
|
||||
|
||||
|
||||
class VoiceNameFlowAnalyticsBreakdownsOut(BaseModel):
|
||||
funnel: list[VoiceNameFlowAnalyticsFunnelStageOut] = Field(default_factory=list)
|
||||
by_language: list[VoiceNameFlowAnalyticsLanguageBreakdownOut] = Field(default_factory=list)
|
||||
by_queue: list[VoiceNameFlowAnalyticsQueueBreakdownOut] = Field(default_factory=list)
|
||||
handoff: list[VoiceNameFlowAnalyticsHandoffBreakdownOut] = Field(default_factory=list)
|
||||
|
||||
|
||||
class VoiceNameFlowAnalyticsCoverageOut(BaseModel):
|
||||
sessions_with_start_decision: int = 0
|
||||
sessions_with_final_ai_state: int = 0
|
||||
sessions_with_manual_overlay: int = 0
|
||||
note: str | None = None
|
||||
|
||||
|
||||
class VoiceNameFlowAnalyticsOverviewOut(BaseModel):
|
||||
window: AIAnalyticsWindowOut
|
||||
filters: VoiceNameFlowAnalyticsFiltersOut
|
||||
totals: VoiceNameFlowAnalyticsTotalsOut = Field(default_factory=VoiceNameFlowAnalyticsTotalsOut)
|
||||
metrics: VoiceNameFlowAnalyticsMetricsOut = Field(default_factory=VoiceNameFlowAnalyticsMetricsOut)
|
||||
breakdowns: VoiceNameFlowAnalyticsBreakdownsOut = Field(default_factory=VoiceNameFlowAnalyticsBreakdownsOut)
|
||||
coverage: VoiceNameFlowAnalyticsCoverageOut = Field(default_factory=VoiceNameFlowAnalyticsCoverageOut)
|
||||
|
||||
|
||||
class VoiceNameFlowAnalyticsTimeseriesPointOut(BaseModel):
|
||||
ts: str
|
||||
value: float | None = None
|
||||
scenario_calls: int = 0
|
||||
denominator: int = 0
|
||||
|
||||
|
||||
class VoiceNameFlowAnalyticsTimeseriesOut(BaseModel):
|
||||
metric: Literal[
|
||||
"scenario_calls",
|
||||
"start_capture_rate",
|
||||
"downstream_rescue_rate",
|
||||
"handoff_unconfirmed_rate",
|
||||
"manual_correction_rate",
|
||||
]
|
||||
interval: Literal["hour", "day"]
|
||||
filters: VoiceNameFlowAnalyticsFiltersOut
|
||||
points: list[VoiceNameFlowAnalyticsTimeseriesPointOut] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AIAnalyticsDrilldownFiltersOut(AIAnalyticsFiltersOut):
|
||||
slice: Literal["all", "contained", "handoff", "human_touched", "closed_without_operator", "active", "error"] = "all"
|
||||
reason_key: str | None = None
|
||||
|
||||
@@ -394,6 +394,11 @@ def _apply_runtime_schema_compatibility() -> None:
|
||||
_add_column_if_missing(conn, columns, "asterisk_call_links", "ai_state", "VARCHAR(32)")
|
||||
_add_column_if_missing(conn, columns, "asterisk_call_links", "ai_handoff_reason", "TEXT")
|
||||
_add_column_if_missing(conn, columns, "asterisk_call_links", "ai_last_model_at", "VARCHAR(64)")
|
||||
_add_column_if_missing(conn, columns, "asterisk_call_links", "voice_start_language", "VARCHAR(16)")
|
||||
_add_column_if_missing(conn, columns, "asterisk_call_links", "customer_name_status", "VARCHAR(32)")
|
||||
_add_column_if_missing(conn, columns, "asterisk_call_links", "customer_name_value", "VARCHAR(256)")
|
||||
_add_column_if_missing(conn, columns, "asterisk_call_links", "customer_name_source", "VARCHAR(32)")
|
||||
_add_column_if_missing(conn, columns, "asterisk_call_links", "customer_name_resolved_at", "VARCHAR(64)")
|
||||
indexes = _table_indexes(inspector, "asterisk_call_links")
|
||||
if "idx_asterisk_call_links_voice_session_id" not in indexes:
|
||||
conn.execute(
|
||||
@@ -423,15 +428,76 @@ def _apply_runtime_schema_compatibility() -> None:
|
||||
"ON asterisk_call_links(ai_last_model_at)"
|
||||
)
|
||||
)
|
||||
if "idx_asterisk_call_links_voice_start_language" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_voice_start_language "
|
||||
"ON asterisk_call_links(voice_start_language)"
|
||||
)
|
||||
)
|
||||
if "idx_asterisk_call_links_customer_name_status" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_customer_name_status "
|
||||
"ON asterisk_call_links(customer_name_status)"
|
||||
)
|
||||
)
|
||||
if "idx_asterisk_call_links_customer_name_source" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_customer_name_source "
|
||||
"ON asterisk_call_links(customer_name_source)"
|
||||
)
|
||||
)
|
||||
if "idx_asterisk_call_links_customer_name_resolved_at" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_customer_name_resolved_at "
|
||||
"ON asterisk_call_links(customer_name_resolved_at)"
|
||||
)
|
||||
)
|
||||
|
||||
if "voice_ai_sessions" in table_names:
|
||||
columns = _table_columns(inspector, "voice_ai_sessions")
|
||||
_add_column_if_missing(conn, columns, "voice_ai_sessions", "voice_start_language", "VARCHAR(16)")
|
||||
_add_column_if_missing(conn, columns, "voice_ai_sessions", "customer_name_status", "VARCHAR(32)")
|
||||
_add_column_if_missing(conn, columns, "voice_ai_sessions", "customer_name_value", "VARCHAR(256)")
|
||||
_add_column_if_missing(conn, columns, "voice_ai_sessions", "customer_name_source", "VARCHAR(32)")
|
||||
_add_column_if_missing(conn, columns, "voice_ai_sessions", "customer_name_resolved_at", "VARCHAR(64)")
|
||||
_add_column_if_missing(conn, columns, "voice_ai_sessions", "media_uuid", "VARCHAR(64)")
|
||||
_add_column_if_missing(conn, columns, "voice_ai_sessions", "media_status", "VARCHAR(32)")
|
||||
_add_column_if_missing(conn, columns, "voice_ai_sessions", "media_connected_at", "VARCHAR(64)")
|
||||
_add_column_if_missing(conn, columns, "voice_ai_sessions", "media_ended_at", "VARCHAR(64)")
|
||||
_add_column_if_missing(conn, columns, "voice_ai_sessions", "last_media_frame_at", "VARCHAR(64)")
|
||||
indexes = _table_indexes(inspector, "voice_ai_sessions")
|
||||
if "idx_voice_ai_sessions_voice_start_language" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_voice_start_language "
|
||||
"ON voice_ai_sessions(voice_start_language)"
|
||||
)
|
||||
)
|
||||
if "idx_voice_ai_sessions_customer_name_status" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_customer_name_status "
|
||||
"ON voice_ai_sessions(customer_name_status)"
|
||||
)
|
||||
)
|
||||
if "idx_voice_ai_sessions_customer_name_source" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_customer_name_source "
|
||||
"ON voice_ai_sessions(customer_name_source)"
|
||||
)
|
||||
)
|
||||
if "idx_voice_ai_sessions_customer_name_resolved_at" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_customer_name_resolved_at "
|
||||
"ON voice_ai_sessions(customer_name_resolved_at)"
|
||||
)
|
||||
)
|
||||
if "idx_voice_ai_sessions_media_uuid" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
|
||||
@@ -120,6 +120,15 @@ class Queue(Base):
|
||||
created_at: Mapped[str] = mapped_column(String(64))
|
||||
|
||||
|
||||
class VoiceNameCollectionSettingsRow(Base):
|
||||
__tablename__ = "voice_name_collection_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
settings_key: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
config_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class RoutingCounter(Base):
|
||||
__tablename__ = "routing_counters"
|
||||
|
||||
@@ -225,6 +234,11 @@ class AsteriskCallLinkRow(Base):
|
||||
ai_state: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
ai_handoff_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ai_last_model_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
voice_start_language: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
|
||||
customer_name_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
customer_name_value: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
customer_name_source: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
customer_name_resolved_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
started_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
connected_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
ended_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
@@ -396,6 +410,11 @@ class VoiceAISessionRow(Base):
|
||||
status: Mapped[str] = mapped_column(String(32), index=True, default="queued")
|
||||
handoff_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
handoff_target_queue_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
voice_start_language: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
|
||||
customer_name_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
customer_name_value: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
customer_name_source: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
customer_name_resolved_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
media_uuid: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
media_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
media_connected_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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):
|
||||
|
||||
@@ -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": "Соедините меня с оператором",
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
+217
-6
@@ -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('Админ-консоль готова');
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<a class="nav-link" href="#queues"><span class="nav-bullet"></span>Очереди</a>
|
||||
<a class="nav-link" href="#routes"><span class="nav-bullet"></span>Маршрутизация</a>
|
||||
<a class="nav-link" href="#ivr"><span class="nav-bullet"></span>IVR</a>
|
||||
<a class="nav-link" href="#voiceNameCollection"><span class="nav-bullet"></span>Voice AI</a>
|
||||
<a class="nav-link" href="#asterisk"><span class="nav-bullet"></span>Asterisk</a>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -250,6 +251,81 @@
|
||||
<pre id="ivrRouteOutput" class="output">Предпросмотр IVR-маршрута появится здесь.</pre>
|
||||
</article>
|
||||
|
||||
<article class="panel reveal" id="voiceNameCollection">
|
||||
<h2>Voice AI: сбор имени</h2>
|
||||
<p class="hint">Глобальная админ-настройка сценария определения имени клиента для voice_start и downstream AI.</p>
|
||||
<pre id="voiceNameSummary" class="output">Сводка по текущим настройкам появится здесь.</pre>
|
||||
|
||||
<div class="inline-form split-two">
|
||||
<label class="checkbox-label"><input id="voiceNameEnabled" type="checkbox" checked /> Сценарий включён</label>
|
||||
<label class="checkbox-label"><input id="voiceNameAskOnStart" type="checkbox" checked /> Спрашивать имя на старте</label>
|
||||
</div>
|
||||
|
||||
<div class="inline-form split-two">
|
||||
<label>
|
||||
Известный клиент
|
||||
<select id="voiceNameKnownCustomerBehavior">
|
||||
<option value="trust_and_handoff">Сразу доверять имени и переводить дальше</option>
|
||||
<option value="confirm_in_downstream">Передавать имя на подтверждение в downstream</option>
|
||||
<option value="ask_on_start">Переспрашивать имя на старте</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Неизвестный клиент
|
||||
<select id="voiceNameUnknownCustomerBehavior">
|
||||
<option value="ask_on_start">Спрашивать имя на старте</option>
|
||||
<option value="skip_to_downstream">Сразу переводить в downstream</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="inline-form split-two">
|
||||
<label>
|
||||
Если имя не получено
|
||||
<select id="voiceNameMissingNameBehavior">
|
||||
<option value="ask_inline_once">Спросить inline один раз</option>
|
||||
<option value="do_not_ask">Не спрашивать inline</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Если имя неуверенное
|
||||
<select id="voiceNameUncertainNameBehavior">
|
||||
<option value="confirm_then_finalize">Подтвердить и только потом финализировать</option>
|
||||
<option value="finalize_immediately">Считать имя финальным сразу</option>
|
||||
<option value="discard_and_collect">Сбросить кандидат и собирать заново</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="inline-form split-two">
|
||||
<label class="checkbox-label"><input id="voiceNameFinalizeOnExplicitName" type="checkbox" checked /> Финализировать имя при явном ответе</label>
|
||||
<label class="checkbox-label"><input id="voiceNameFinalizeOnConfirmation" type="checkbox" checked /> Финализировать имя после подтверждения</label>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="voiceNameRuStartPrompt">Тексты RU</label>
|
||||
<textarea id="voiceNameRuStartPrompt" rows="2" class="json-textarea" placeholder="Стартовый prompt"></textarea>
|
||||
<textarea id="voiceNameRuPersonalizedGreeting" rows="3" class="json-textarea" placeholder="Персонализированное приветствие с {name}"></textarea>
|
||||
<textarea id="voiceNameRuConfirmationGreeting" rows="3" class="json-textarea" placeholder="Подтверждение имени с {name}"></textarea>
|
||||
<textarea id="voiceNameRuInlineFollowup" rows="2" class="json-textarea" placeholder="Inline follow-up"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="voiceNameKzStartPrompt">Тексты KZ</label>
|
||||
<textarea id="voiceNameKzStartPrompt" rows="2" class="json-textarea" placeholder="Стартовый prompt"></textarea>
|
||||
<textarea id="voiceNameKzPersonalizedGreeting" rows="3" class="json-textarea" placeholder="Персонализированное приветствие с {name}"></textarea>
|
||||
<textarea id="voiceNameKzConfirmationGreeting" rows="3" class="json-textarea" placeholder="Подтверждение имени с {name}"></textarea>
|
||||
<textarea id="voiceNameKzInlineFollowup" rows="2" class="json-textarea" placeholder="Inline follow-up"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="actions compact-actions">
|
||||
<button id="loadVoiceNameConfigBtn" class="btn ghost">Загрузить настройки</button>
|
||||
<button id="saveVoiceNameConfigBtn" class="btn">Сохранить настройки</button>
|
||||
<button id="resetVoiceNameConfigBtn" class="btn ghost">Сбросить форму к текущим</button>
|
||||
</div>
|
||||
<pre id="voiceNameConfigOutput" class="output">Raw payload voice name collection появится здесь.</pre>
|
||||
</article>
|
||||
|
||||
<article class="panel reveal" id="asterisk">
|
||||
<h2>Мост Asterisk</h2>
|
||||
<p class="hint">Диагностика AMI-подключения, пересланных bridge-событий и ручного повтора.</p>
|
||||
|
||||
+789
-2
File diff suppressed because it is too large
Load Diff
+74
-13
@@ -8,7 +8,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700;800&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/operator/assets/styles.css?v=track44-analyst-encoding-fix" />
|
||||
<link rel="stylesheet" href="/operator/assets/styles.css?v=track45-voice-name-flow-v1" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
@@ -32,6 +32,8 @@
|
||||
<div class="nav-group">
|
||||
<div class="nav-label">Разделы</div>
|
||||
<a class="nav-link" href="#analyticsPanel"><span class="nav-bullet"></span>Обзор</a>
|
||||
<a class="nav-link" href="#voiceNameAnalyticsPanel"><span class="nav-bullet"></span>Сбор имени</a>
|
||||
<a class="nav-link" href="#aiAnalyticsPanel"><span class="nav-bullet"></span>AI</a>
|
||||
<a class="nav-link" href="#agentAnalyticsPanel"><span class="nav-bullet"></span>Агенты</a>
|
||||
<a class="nav-link" href="#analyticsTrendBlock"><span class="nav-bullet"></span>Тренд</a>
|
||||
<a class="nav-link" href="#analyticsQueuesBlock"><span class="nav-bullet"></span>Очереди</a>
|
||||
@@ -40,7 +42,7 @@
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<strong>Рабочее место аналитика</strong>
|
||||
<p>Историческая картина по обращениям, каналам и очередям без оперативного мониторинга.</p>
|
||||
<p>Историческая картина по обращениям, каналам, очередям и качеству сценариев без перехода в операционный экран.</p>
|
||||
<p class="hint" id="sessionInfo">Сессия проверяется...</p>
|
||||
<button id="logoutBtn" class="btn ghost" type="button">Выйти</button>
|
||||
</div>
|
||||
@@ -50,7 +52,7 @@
|
||||
<header class="workspace-topbar reveal">
|
||||
<div class="analytics-topbar-intro">
|
||||
<strong>Историческая аналитика сервиса</strong>
|
||||
<p>Периоды, каналы и очереди в одном окне, без перехода в операционный экран.</p>
|
||||
<p>Периоды, каналы, очереди, AI и агентские срезы в одном окне.</p>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<div class="status-pill" id="gatewayStatus">Данные: проверка...</div>
|
||||
@@ -113,10 +115,10 @@
|
||||
<select id="analyticsChannel">
|
||||
<option value="all">Все каналы</option>
|
||||
<option value="voice">Голос</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="whatsapp">WhatsApp</option>
|
||||
<option value="webchat">Веб-чат</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="whatsapp">WhatsApp</option>
|
||||
<option value="webchat">Веб-чат</option>
|
||||
<option value="email">Email</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,6 +160,62 @@
|
||||
<div id="analyticsComparePanel" class="analytics-compare-panel" hidden></div>
|
||||
<div id="analyticsOverview" class="summary-grid analytics-overview-grid"></div>
|
||||
|
||||
<section class="analytics-voice-panel" id="voiceNameAnalyticsPanel">
|
||||
<div class="analytics-subhead">
|
||||
<div>
|
||||
<p class="eyebrow">Сценарий имени</p>
|
||||
<h3>Сценарий сбора имени в голосе</h3>
|
||||
<p class="hint">Отдельная аналитика по тому, как сценарий берёт имя на старте, как помогает downstream AI и сколько звонков уходит оператору без подтверждённого имени.</p>
|
||||
</div>
|
||||
<div class="row analytics-trend-selector">
|
||||
<label for="voiceNameAnalyticsTrendMetric">Метрика</label>
|
||||
<select id="voiceNameAnalyticsTrendMetric">
|
||||
<option value="scenario_calls">Звонки в сценарии</option>
|
||||
<option value="start_capture_rate">Имя взято сразу</option>
|
||||
<option value="downstream_rescue_rate">Имя добрал downstream AI</option>
|
||||
<option value="handoff_unconfirmed_rate">Handoff без имени</option>
|
||||
<option value="manual_correction_rate">Ручное исправление</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="voiceNameAnalyticsEmptyState" class="analytics-empty-state" hidden></div>
|
||||
<div id="voiceNameAnalyticsOverview" class="summary-grid analytics-overview-grid voice-name-analytics-overview"></div>
|
||||
|
||||
<div class="analytics-sections">
|
||||
<article class="analytics-subpanel analytics-subpanel-wide">
|
||||
<h3>Тренд по voice name-flow</h3>
|
||||
<p class="hint">Динамика показывает, как меняется качество сбора имени по выбранному периоду, очереди и голосовому потоку.</p>
|
||||
<div id="voiceNameAnalyticsTrendChart" class="analytics-chart-shell"></div>
|
||||
</article>
|
||||
|
||||
<article class="analytics-subpanel">
|
||||
<h3>Этапы сценария</h3>
|
||||
<p class="hint">Funnel помогает быстро увидеть, где чаще всего теряется имя: на старте, в follow-up или уже перед handoff.</p>
|
||||
<div id="voiceNameAnalyticsFunnel" class="analytics-table-shell"></div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="analytics-sections">
|
||||
<article class="analytics-subpanel">
|
||||
<h3>По языкам</h3>
|
||||
<p class="hint">Сравнение сценария по языкам помогает увидеть, где стартовый сбор имени работает стабильнее, а где чаще требуется downstream AI.</p>
|
||||
<div id="voiceNameAnalyticsLanguageTable" class="analytics-table-shell"></div>
|
||||
</article>
|
||||
|
||||
<article class="analytics-subpanel">
|
||||
<h3>По очередям</h3>
|
||||
<p class="hint">Срез по очередям показывает, в каких очередях voice name-flow чаще доходит до handoff без подтверждённого имени.</p>
|
||||
<div id="voiceNameAnalyticsQueueTable" class="analytics-table-shell"></div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article class="analytics-subpanel">
|
||||
<h3>Handoff по имени</h3>
|
||||
<p class="hint">Здесь видно, сколько звонков оператор получил уже с подтверждённым именем, а сколько ушло без него.</p>
|
||||
<div id="voiceNameAnalyticsHandoffTable" class="analytics-table-shell"></div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="analytics-ai-panel" id="aiAnalyticsPanel">
|
||||
<div class="analytics-subhead">
|
||||
<div>
|
||||
@@ -198,6 +256,7 @@
|
||||
<p class="hint">Сравнение полностью автоматических и затронутых оператором сессий по Telegram и WhatsApp без текстов сообщений, записей и AI-сводок.</p>
|
||||
<div id="aiAnalyticsChannelComparison" class="analytics-table-shell"></div>
|
||||
</article>
|
||||
|
||||
<div class="analytics-sections">
|
||||
<article class="analytics-subpanel">
|
||||
<h3>Исходы AI-сессий</h3>
|
||||
@@ -224,6 +283,7 @@
|
||||
<div id="agentAnalyticsEmptyState" class="analytics-empty-state" hidden></div>
|
||||
<div id="agentAnalyticsOverview" class="summary-grid analytics-overview-grid agent-analytics-overview"></div>
|
||||
<div id="agentAnalyticsStateStrip" class="agent-analytics-state-strip"></div>
|
||||
|
||||
<div class="analytics-sections">
|
||||
<article class="analytics-subpanel analytics-subpanel-wide">
|
||||
<div class="analytics-subhead">
|
||||
@@ -250,11 +310,13 @@
|
||||
<div id="agentAnalyticsTeamTable" class="analytics-table-shell"></div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article class="analytics-subpanel">
|
||||
<h3>Агенты по выбранному периоду</h3>
|
||||
<p class="hint">Клик по строке открывает полноэкранную детализацию по обращениям выбранного агента без изменения фильтров витрины.</p>
|
||||
<div id="agentAnalyticsTable" class="analytics-table-shell"></div>
|
||||
</article>
|
||||
|
||||
<div class="analytics-sections">
|
||||
<article class="analytics-subpanel">
|
||||
<h3>Смены</h3>
|
||||
@@ -286,7 +348,7 @@
|
||||
|
||||
<article class="analytics-subpanel">
|
||||
<h3>Каналы</h3>
|
||||
<p class="hint">Сравнение каналов по объему, обработке и доле ответа за выбранный период.</p>
|
||||
<p class="hint">Сравнение каналов по объёму, обработке и доле ответа за выбранный период.</p>
|
||||
<p class="hint analytics-drilldown-note">Нажмите на строку, чтобы открыть точную детализацию по обращениям этого канала.</p>
|
||||
<div id="analyticsChannelTable" class="analytics-table-shell"></div>
|
||||
</article>
|
||||
@@ -295,11 +357,10 @@
|
||||
<div class="analytics-sections">
|
||||
<article class="analytics-subpanel analytics-subpanel-wide" id="analyticsQueuesBlock">
|
||||
<h3>Очереди</h3>
|
||||
<p class="hint">Очереди отсортированы по объему. Нажмите на строку, чтобы сфокусировать всю витрину на одной очереди.</p>
|
||||
<p class="hint">Очереди отсортированы по объёму. Нажмите на строку, чтобы сфокусировать всю витрину на одной очереди.</p>
|
||||
<p class="hint analytics-drilldown-note">Клик по строке открывает точный срез по очереди и не меняет фильтры витрины.</p>
|
||||
<div id="analyticsQueueTable" class="analytics-table-shell"></div>
|
||||
</article>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
@@ -319,9 +380,9 @@
|
||||
>
|
||||
<div class="analytics-drawer-head">
|
||||
<div>
|
||||
<p class="eyebrow">Детализация обращений</p>
|
||||
<p class="eyebrow">Детализация обращений</p>
|
||||
<h2 id="analyticsDrilldownTitle">Детализация обращений</h2>
|
||||
<p id="analyticsDrilldownMeta" class="hint">Выберите карточку обращений, канал или очередь на витрине, чтобы открыть список обращений.</p>
|
||||
<p id="analyticsDrilldownMeta" class="hint">Выберите карточку обращений, канал или очередь на витрине, чтобы открыть список обращений.</p>
|
||||
</div>
|
||||
<button id="analyticsDrilldownCloseBtn" class="btn ghost" type="button">Закрыть</button>
|
||||
</div>
|
||||
@@ -387,6 +448,6 @@
|
||||
<input id="sessionUser" type="hidden" value="analyst" />
|
||||
<input id="sessionRole" type="hidden" value="analyst" />
|
||||
|
||||
<script src="/analyst/assets/app.js?v=track44-analyst-encoding-fix"></script>
|
||||
<script src="/analyst/assets/app.js?v=track45-voice-name-flow-v1"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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",
|
||||
|
||||
+484
-16
@@ -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) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="voice-ai-summary-grid">
|
||||
${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) => `<option value="${escapeHtml(item.call_id)}">${escapeHtml(item.call_id)} | ${escapeHtml(item.interaction_id)}</option>`)
|
||||
.map((item) => {
|
||||
const summary = voiceSummaryForItem(item);
|
||||
const label = `${voiceCustomerDisplayName(item, summary)} | ${item.call_id}${item.interaction_id ? ` | ${item.interaction_id}` : ''}`;
|
||||
return `<option value="${escapeHtml(item.call_id)}">${escapeHtml(label)}</option>`;
|
||||
})
|
||||
.join('')
|
||||
: '<option value="">Нет активных звонков</option>';
|
||||
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
|
||||
? `<p class="card-meta-line">имя: ${escapeHtml(nameStatusMeta.label)}</p>`
|
||||
: '';
|
||||
const nameSourceLabel = formatVoiceCustomerNameSource(summary?.customer_name_source);
|
||||
const nameSourceLine = nameSourceLabel
|
||||
? `<p class="card-meta-line">источник имени: ${escapeHtml(nameSourceLabel)}</p>`
|
||||
: '';
|
||||
const languageLabel = formatVoiceStartLanguage(summary?.voice_start_language);
|
||||
const languageLine = languageLabel
|
||||
? `<p class="card-meta-line">язык старта: ${escapeHtml(languageLabel)}</p>`
|
||||
: '';
|
||||
const badges = [
|
||||
`<span class="micro-badge queue">${escapeHtml(item.queue_code || item.queue_id)}</span>`,
|
||||
`<span class="micro-badge assignee">${escapeHtml(telephonyLabel(item.telephony_status))}</span>`,
|
||||
@@ -5442,6 +5859,9 @@ function renderLiveCallCard(item, { recent = false } = {}) {
|
||||
if (aiMeta) {
|
||||
badges.push(`<span class="micro-badge ${escapeHtml(aiMeta.className)}">${escapeHtml(aiMeta.label)}</span>`);
|
||||
}
|
||||
if (nameStatusMeta) {
|
||||
badges.push(`<span class="micro-badge ${escapeHtml(nameStatusMeta.className)}">${escapeHtml(nameStatusMeta.shortLabel)}</span>`);
|
||||
}
|
||||
if (recent && item.terminal_action) {
|
||||
badges.push(`<span class="micro-badge terminal">${escapeHtml(terminalActionLabel(item))}</span>`);
|
||||
}
|
||||
@@ -5460,20 +5880,28 @@ function renderLiveCallCard(item, { recent = false } = {}) {
|
||||
const timingLine = recent
|
||||
? `<p class="card-meta-line">завершён: ${escapeHtml(formatIsoShort(item.last_transition_at || item.ended_at || item.updated_at))}</p>`
|
||||
: `<p class="card-meta-line">соединён: ${escapeHtml(formatIsoShort(item.connected_at || item.started_at))}</p>`;
|
||||
const actions = [
|
||||
customerId ? `<button type="button" class="btn ghost" data-live-call-action="edit-name" data-call-id="${escapeHtml(item.call_id)}">Исправить имя</button>` : '',
|
||||
customerId ? `<button type="button" class="btn ghost" data-live-call-action="customer" data-call-id="${escapeHtml(item.call_id)}" data-customer-id="${escapeHtml(customerId)}">К клиенту</button>` : '',
|
||||
].filter(Boolean);
|
||||
return `
|
||||
<article class="pipeline-card live-call-card ${recent ? 'closed' : ''} ${isSelected ? 'selected' : ''}">
|
||||
<div class="card-badges">${badges.join('')}</div>
|
||||
<h3 class="card-title">${escapeHtml(item.call_id)}</h3>
|
||||
<p class="card-subtitle">обращение: ${escapeHtml(item.interaction_id)}</p>
|
||||
<p class="card-meta-line">абонент: ${escapeHtml(caller)}</p>
|
||||
<h3 class="card-title">${escapeHtml(caller)}</h3>
|
||||
<p class="card-subtitle">${escapeHtml(voiceCustomerCallSubtitle(item))}</p>
|
||||
<p class="card-meta-line">обращение: ${escapeHtml(item.interaction_id || 'не найдено')}</p>
|
||||
<p class="card-meta-line">взял в работу: ${escapeHtml(item.claimed_by_user || '-')}</p>
|
||||
<p class="card-meta-line">внутренний номер: ${escapeHtml(item.operator_extension || '-')}</p>
|
||||
<p class="card-meta-line">начат: ${escapeHtml(formatIsoShort(item.started_at))}</p>
|
||||
${timingLine}
|
||||
${nameStatusLine}
|
||||
${nameSourceLine}
|
||||
${languageLine}
|
||||
${aiReasonLine}
|
||||
${targetLine}
|
||||
${hangupLine}
|
||||
<p class="card-meta-line">запись: ${item.has_recording ? 'да' : 'нет'}</p>
|
||||
${actions.length ? `<div class="live-call-card-actions">${actions.join('')}</div>` : ''}
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
@@ -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 = `
|
||||
<div class="pipeline-board live-calls-board">
|
||||
${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();
|
||||
|
||||
+27
-2
@@ -8,7 +8,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700;800&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/operator/assets/styles.css?v=track19-unified-inbox1" />
|
||||
<link rel="stylesheet" href="/operator/assets/styles.css?v=track21-voice-name-edit1" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
@@ -563,6 +563,18 @@
|
||||
<div class="inline-form compact live-call-inspector">
|
||||
<select id="liveCallIdSelect"></select>
|
||||
</div>
|
||||
<div id="liveCallNameEditor" class="live-call-name-editor hidden">
|
||||
<div class="live-call-name-editor-head">
|
||||
<div class="live-call-name-editor-title">Исправить имя клиента</div>
|
||||
<p id="liveCallNameEditorMeta" class="hint">Выберите звонок, связанный с клиентом, чтобы исправить имя.</p>
|
||||
</div>
|
||||
<div class="live-call-name-editor-form">
|
||||
<input id="liveCallNameInput" type="text" placeholder="Введите имя клиента" />
|
||||
<button id="liveCallNameSaveBtn" class="btn" type="button">Сохранить имя</button>
|
||||
<button id="liveCallNameCancelBtn" class="btn ghost" type="button">Отмена</button>
|
||||
</div>
|
||||
<p id="liveCallNameStatus" class="hint"></p>
|
||||
</div>
|
||||
<p class="hint">Здесь остаются живые и недавние звонки, а оперативные действия по ним вынесены во всплывающее окно.</p>
|
||||
<p class="hint" id="liveCallStatusHint">Пока нет активных звонков.</p>
|
||||
<div id="liveCallsTable" class="table"></div>
|
||||
@@ -593,6 +605,18 @@
|
||||
<p id="browserPhoneCallWarning" class="call-window-warning hidden"></p>
|
||||
</div>
|
||||
<div id="browserPhoneAiSummary" class="call-window-ai-summary hidden"></div>
|
||||
<div id="browserPhoneNameEditor" class="call-window-name-editor hidden">
|
||||
<div class="call-window-name-head">
|
||||
<div class="voice-summary-label">Исправить имя клиента</div>
|
||||
<p id="browserPhoneNameHint" class="call-window-meta subtle">Имя сохранится в профиле клиента и voice-контуре.</p>
|
||||
</div>
|
||||
<div class="call-window-name-form">
|
||||
<input id="browserPhoneNameInput" type="text" placeholder="Введите имя клиента" />
|
||||
<button id="browserPhoneNameSaveBtn" class="btn" type="button">Сохранить имя</button>
|
||||
<button id="browserPhoneNameCancelBtn" class="btn ghost" type="button">Отмена</button>
|
||||
</div>
|
||||
<p id="browserPhoneNameStatus" class="call-window-meta subtle hidden"></p>
|
||||
</div>
|
||||
<div class="call-window-transfer">
|
||||
<select id="browserPhoneTransferTargetType">
|
||||
<option value="extension">внутренний номер</option>
|
||||
@@ -604,6 +628,7 @@
|
||||
<button id="browserPhoneAnswerBtn" class="btn" type="button">Ответить</button>
|
||||
<button id="browserPhoneRejectBtn" class="btn ghost" type="button">Отклонить</button>
|
||||
<button id="browserPhoneCallClaimBtn" class="btn ghost" type="button">Принять в работу</button>
|
||||
<button id="browserPhoneEditNameBtn" class="btn ghost" type="button">Исправить имя</button>
|
||||
<button id="browserPhoneMuteBtn" class="btn ghost" type="button">Выключить микрофон</button>
|
||||
<button id="browserPhoneCallTransferBtn" class="btn ghost" type="button">Передать</button>
|
||||
<button id="browserPhoneCallHangupBtn" class="btn danger" type="button">Завершить</button>
|
||||
@@ -615,6 +640,6 @@
|
||||
|
||||
<script src="/operator/assets/access-guards.js?v=track16-voice-transcript1"></script>
|
||||
<script src="/operator/assets/sip-0.21.2.min.js?v=track16-voice-transcript1"></script>
|
||||
<script src="/operator/assets/app.js?v=track38-unified-inbox1"></script>
|
||||
<script src="/operator/assets/app.js?v=track40-voice-name-edit1"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3078,6 +3078,55 @@ textarea::placeholder {
|
||||
box-shadow: 0 14px 26px rgba(51, 102, 232, 0.12);
|
||||
}
|
||||
|
||||
.live-call-card-actions {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.live-call-card-actions .btn {
|
||||
min-height: 38px;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.live-call-name-editor {
|
||||
margin: 14px 0;
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--primary-line);
|
||||
background: linear-gradient(180deg, #ffffff, #f8fbff);
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.live-call-name-editor-head {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.live-call-name-editor-title {
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.live-call-name-editor-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.live-call-name-editor-form input {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hint.error,
|
||||
.call-window-meta.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.card-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -3164,6 +3213,24 @@ textarea::placeholder {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.micro-badge.name-confirmed {
|
||||
background: rgba(16, 185, 129, 0.14);
|
||||
border-color: rgba(16, 185, 129, 0.28);
|
||||
color: #117c5f;
|
||||
}
|
||||
|
||||
.micro-badge.name-followup {
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
border-color: rgba(245, 158, 11, 0.28);
|
||||
color: #b66900;
|
||||
}
|
||||
|
||||
.micro-badge.name-missing {
|
||||
background: rgba(148, 163, 184, 0.16);
|
||||
border-color: rgba(148, 163, 184, 0.3);
|
||||
color: #55657a;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
@@ -3873,6 +3940,43 @@ textarea::placeholder {
|
||||
linear-gradient(180deg, #f9fbff, #f1f6ff);
|
||||
}
|
||||
|
||||
.analytics-voice-panel {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 20px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(40, 116, 78, 0.12);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(40, 116, 78, 0.08), transparent 34%),
|
||||
linear-gradient(180deg, #fbfefb, #f3faf5);
|
||||
}
|
||||
|
||||
.voice-name-analytics-overview {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.voice-name-card {
|
||||
border-style: solid;
|
||||
border-color: rgba(40, 116, 78, 0.12);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(244, 250, 246, 0.95));
|
||||
}
|
||||
|
||||
.voice-name-funnel-grid {
|
||||
grid-template-columns: minmax(180px, 1.4fr) repeat(2, minmax(72px, 0.8fr));
|
||||
}
|
||||
|
||||
.voice-name-language-grid {
|
||||
grid-template-columns: minmax(110px, 1fr) repeat(5, minmax(82px, 0.8fr));
|
||||
}
|
||||
|
||||
.voice-name-queue-grid {
|
||||
grid-template-columns: minmax(170px, 1.4fr) repeat(5, minmax(82px, 0.8fr));
|
||||
}
|
||||
|
||||
.voice-name-handoff-grid {
|
||||
grid-template-columns: minmax(180px, 1.4fr) repeat(2, minmax(82px, 0.8fr));
|
||||
}
|
||||
|
||||
.analytics-agent-panel {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
@@ -4647,6 +4751,38 @@ body.analytics-drilldown-open {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.call-window-name-editor {
|
||||
margin-bottom: 14px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(9, 16, 28, 0.42);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.call-window-name-head {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.call-window-name-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.call-window-name-form input {
|
||||
min-width: 0;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.call-window-name-form input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.voice-ai-summary {
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
@@ -5077,6 +5213,11 @@ body.analytics-drilldown-open {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.call-window-name-form,
|
||||
.live-call-name-editor-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.call-window-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user