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