Add voice name flow controls and analytics

This commit is contained in:
Yera All
2026-04-05 03:26:18 +05:00
parent d15fcf7129
commit d959c2b2f0
31 changed files with 6410 additions and 152 deletions
File diff suppressed because it is too large Load Diff
+262 -8
View File
@@ -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):
+140 -3
View File
@@ -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": "Соедините меня с оператором",
+17
View File
@@ -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")
+83
View File
@@ -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")
+145
View File
@@ -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)
+45
View File
@@ -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