Files
call-center/tests/test_ai_orchestrator_service.py
T
didar 8ced7a59e3
deploy / deploy (push) Successful in 32s
fix: let voice early-plan turns answer from the FAQ knowledge base
The speculative "early plan" turn (computed on partial ASR, before the
caller finishes talking) can win the race and get spoken as the actual
reply, but it unconditionally skipped KB search and answered common
questions (schedule/address/price/status/problem) with a hardcoded
clarifying question even when the FAQ already had the answer.

KB search is a cheap in-memory lexical scan over a DB-cached row set,
so it fits the early-plan latency budget unlike a real LLM call. Now
early-plan runs it and, on a match, answers from the KB snippet
(intent resolved via normalize_intent) instead of guessing a generic
clarifying question; with no match it falls back to the prior
behavior unchanged. operator_request is unaffected.
2026-08-31 00:38:06 +05:00

3623 lines
149 KiB
Python

import json
import time
import importlib
from types import SimpleNamespace
import httpx
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
ai_module = importlib.import_module("services.ai_orchestrator_service.app")
ai_app = ai_module.app
voice_module = importlib.import_module("services.ai_orchestrator_service.voice")
voice_config_module = importlib.import_module("services.ai_orchestrator_service.voice_name_config")
ai_operator_config_module = importlib.import_module("services.shared.ai_operator_config")
voice_tts_config_module = importlib.import_module("services.shared.voice_tts_config")
from services.interaction_service.app import app as interaction_app
from services.shared.core import new_id, utc_now_iso
from services.shared.db import get_session
from services.shared.models import VoiceAIStartIn, VoiceAITurnIn
from services.shared.sql_models import (
AIJobRow,
AIOperatorSettingsRow,
AISessionRow,
AITurnRow,
AsteriskCallLinkRow,
Customer,
CustomerExternalIdentity,
Interaction,
InteractionTimeline,
KBArticleRow,
KBCategoryRow,
TelegramMessageRow,
TelegramThreadRow,
VoiceNameCollectionSettingsRow,
VoiceTTSSettingsRow,
VoiceAISessionRow,
VoiceTranscriptSegmentRow,
WhatsAppThreadRow,
)
from services.telegram_adapter_service import app as telegram_module
from services.telegram_adapter_service.app import app as telegram_app
def admin_headers():
return {"X-User": "admin", "X-Role": "admin"}
def operator_headers(user="operator"):
return {"X-User": user, "X-Role": "operator"}
@pytest.fixture(autouse=True)
def reset_voice_name_collection_settings():
session = get_session()
try:
row = session.execute(select(VoiceNameCollectionSettingsRow)).scalar_one_or_none()
if row is not None:
session.delete(row)
session.commit()
finally:
session.close()
yield
session = get_session()
try:
row = session.execute(select(VoiceNameCollectionSettingsRow)).scalar_one_or_none()
if row is not None:
session.delete(row)
session.commit()
finally:
session.close()
@pytest.fixture(autouse=True)
def reset_voice_tts_settings():
session = get_session()
try:
row = session.execute(select(VoiceTTSSettingsRow)).scalar_one_or_none()
if row is not None:
session.delete(row)
session.commit()
finally:
session.close()
yield
session = get_session()
try:
row = session.execute(select(VoiceTTSSettingsRow)).scalar_one_or_none()
if row is not None:
session.delete(row)
session.commit()
finally:
session.close()
@pytest.fixture(autouse=True)
def reset_ai_operator_settings():
session = get_session()
try:
row = session.execute(select(AIOperatorSettingsRow)).scalar_one_or_none()
if row is not None:
session.delete(row)
session.commit()
finally:
session.close()
yield
session = get_session()
try:
row = session.execute(select(AIOperatorSettingsRow)).scalar_one_or_none()
if row is not None:
session.delete(row)
session.commit()
finally:
session.close()
def _u(value: str) -> str:
return value.encode("ascii").decode("unicode_escape")
def seed_voice_downstream_session(
*,
marker: str,
name_status: str,
name_value: str | None = None,
name_source: str = "none",
customer_display_name: str | None = None,
caller_number: str | None = None,
caller_name: str = "Voice Caller",
) -> dict[str, str]:
session = get_session()
try:
now = utc_now_iso()
resolved_caller_number = caller_number or f"+7{abs(hash(marker)) % 10_000_000_000:010d}"
interaction_id = f"{marker}_int"
customer_id = f"{marker}_cus"
call_id = f"{marker}_call"
session_id = f"{marker}_avs"
linked_id = f"{marker}_linked"
session.add(
Customer(
customer_id=customer_id,
display_name=customer_display_name or resolved_caller_number,
phones_json=f'["{resolved_caller_number}"]',
preferred_phone=resolved_caller_number,
tags_json='["voice"]',
created_at=now,
)
)
session.add(
CustomerExternalIdentity(
identity_id=f"{marker}_cei",
customer_id=customer_id,
channel="voice",
external_subject=resolved_caller_number,
display_name_snapshot=caller_name,
created_at=now,
updated_at=now,
)
)
session.add(
Interaction(
interaction_id=interaction_id,
channel="voice",
subject=f"Voice downstream {marker}",
customer_id=customer_id,
queue_id="que_voice_support",
priority=3,
status="open",
assigned_to=None,
created_at=now,
updated_at=now,
)
)
session.add(
AsteriskCallLinkRow(
call_id=call_id,
linked_id=linked_id,
queue_code="voice_support",
queue_id="que_voice_support",
interaction_id=interaction_id,
caller_number=resolved_caller_number,
caller_name=caller_name,
status="active",
telephony_status="connected",
claimed_by_user=None,
claimed_at=None,
operator_extension=None,
channel_name="PJSIP/1001-000001",
started_at=now,
connected_at=now,
ended_at=None,
updated_at=now,
voice_start_language="ru",
customer_name_status=name_status,
customer_name_value=name_value,
customer_name_source=name_source,
customer_name_resolved_at=now,
voice_session_id=session_id,
)
)
session.add(
VoiceAISessionRow(
session_id=session_id,
call_id=call_id,
linked_id=linked_id,
interaction_id=interaction_id,
customer_id=customer_id,
queue_id="que_voice_support",
ai_session_id=None,
agent_profile="voice_support",
language="ru",
asr_provider="openai",
tts_provider="yandex",
status="active",
handoff_reason=None,
handoff_target_queue_id="que_voice_support",
disclosure_played_at=now,
last_user_utterance_at=None,
last_ai_reply_at=None,
started_at=now,
updated_at=now,
ended_at=None,
voice_start_language="ru",
customer_name_status=name_status,
customer_name_value=name_value,
customer_name_source=name_source,
customer_name_resolved_at=now,
)
)
session.commit()
return {
"interaction_id": interaction_id,
"customer_id": customer_id,
"call_id": call_id,
"session_id": session_id,
"caller_number": resolved_caller_number,
}
finally:
session.close()
def seed_voice_start_session(
*,
marker: str,
language: str = "ru",
customer_display_name: str | None = None,
caller_number: str | None = None,
caller_name: str = "Voice Caller",
next_queue_code: str = "voice_support",
next_queue_id: str = "que_voice_support",
) -> dict[str, str]:
session = get_session()
try:
now = utc_now_iso()
resolved_caller_number = caller_number or f"+7{abs(hash(f'{marker}_start')) % 10_000_000_000:010d}"
interaction_id = f"{marker}_int"
customer_id = f"{marker}_cus"
call_id = f"{marker}_call"
session_id = f"{marker}_avs"
linked_id = f"{marker}_linked"
if customer_display_name is not None:
session.add(
Customer(
customer_id=customer_id,
display_name=customer_display_name,
phones_json=f'["{resolved_caller_number}"]',
preferred_phone=resolved_caller_number,
tags_json='["voice"]',
created_at=now,
)
)
session.add(
CustomerExternalIdentity(
identity_id=f"{marker}_cei",
customer_id=customer_id,
channel="voice",
external_subject=resolved_caller_number,
display_name_snapshot=caller_name,
created_at=now,
updated_at=now,
)
)
session.add(
Interaction(
interaction_id=interaction_id,
channel="voice",
subject=f"Voice start {marker}",
customer_id=customer_id if customer_display_name is not None else None,
queue_id=f"que_voice_start_{language}",
priority=3,
status="open",
assigned_to=None,
created_at=now,
updated_at=now,
)
)
session.add(
AsteriskCallLinkRow(
call_id=call_id,
linked_id=linked_id,
queue_code=f"voice_start_{language}",
queue_id=f"que_voice_start_{language}",
interaction_id=interaction_id,
caller_number=resolved_caller_number,
caller_name=caller_name,
status="active",
telephony_status="connected",
claimed_by_user=None,
claimed_at=None,
operator_extension=None,
channel_name="PJSIP/1002-000002",
started_at=now,
connected_at=now,
ended_at=None,
updated_at=now,
voice_session_id=session_id,
)
)
session.add(
VoiceAISessionRow(
session_id=session_id,
call_id=call_id,
linked_id=linked_id,
interaction_id=interaction_id,
customer_id=customer_id if customer_display_name is not None else None,
queue_id=f"que_voice_start_{language}",
ai_session_id=None,
agent_profile="voice_start",
language=language,
asr_provider="openai",
tts_provider="yandex",
status="active",
handoff_reason=None,
handoff_target_queue_id=next_queue_id,
disclosure_played_at=None,
last_user_utterance_at=None,
last_ai_reply_at=None,
started_at=now,
updated_at=now,
ended_at=None,
voice_start_language=language,
customer_name_status=None,
customer_name_value=None,
customer_name_source=None,
customer_name_resolved_at=None,
)
)
session.commit()
return {
"interaction_id": interaction_id,
"customer_id": customer_id,
"call_id": call_id,
"session_id": session_id,
"linked_id": linked_id,
"language": language,
"next_queue_code": next_queue_code,
"next_queue_id": next_queue_id,
}
finally:
session.close()
def patch_interaction_request(monkeypatch):
def fake_request(method: str, path: str, *, payload: dict | None = None) -> dict:
session = get_session()
try:
interaction_id = path.split("/")[2]
interaction = session.execute(
select(Interaction).where(Interaction.interaction_id == interaction_id)
).scalar_one()
if path.endswith("/assign"):
interaction.assigned_to = payload["assignee"]
interaction.status = "in_progress"
elif path.endswith("/status"):
interaction.status = payload["status"]
elif path.endswith("/escalate"):
interaction.status = "escalated"
interaction.queue_id = payload["target_queue_id"]
interaction.updated_at = telegram_module.utc_now_iso()
session.commit()
return {
"interaction_id": interaction.interaction_id,
"status": interaction.status,
"assigned_to": interaction.assigned_to,
"queue_id": interaction.queue_id,
}
finally:
session.close()
monkeypatch.setattr(telegram_module, "_interaction_request", fake_request)
def patch_ai_internal_calls(monkeypatch, telegram_client: TestClient, interaction_client: TestClient):
def fake_telegram_request(method: str, path: str, *, payload: dict | None = None) -> dict:
response = telegram_client.request(method, path, json=payload, headers=admin_headers())
response.raise_for_status()
return response.json()
def fake_interaction_request(method: str, path: str, *, payload: dict | None = None) -> dict:
response = interaction_client.request(method, path, json=payload, headers=admin_headers())
response.raise_for_status()
return response.json()
monkeypatch.setattr(ai_module, "_telegram_request", fake_telegram_request)
monkeypatch.setattr(ai_module, "_interaction_request", fake_interaction_request)
def seed_kb_article(
title: str,
body: str,
tag: str,
*,
language: str = "ru",
article_group_id: str | None = None,
intent_code: str | None = None,
) -> dict[str, str]:
session = get_session()
try:
now = utc_now_iso()
category_id = new_id("kbc")
article_id = new_id("kba")
resolved_group_id = article_group_id or article_id
session.add(
KBCategoryRow(
category_id=category_id,
name="Telegram AI",
description="AI test category",
created_at=now,
)
)
session.add(
KBArticleRow(
article_id=article_id,
category_id=category_id,
article_group_id=resolved_group_id,
intent_code=intent_code,
language=language,
title=title,
body=body,
tags_json=f'["{tag}"]',
created_at=now,
updated_at=now,
)
)
session.commit()
return {"article_id": article_id, "article_group_id": resolved_group_id}
finally:
session.close()
def create_inbound_thread(telegram_client: TestClient, chat_id: str, text: str) -> dict:
response = telegram_client.post(
"/integrations/telegram/webhook",
json={
"chat_id": chat_id,
"text": text,
"payload": {"telegram_user_id": f"user-{chat_id}", "username": f"user_{chat_id}"},
},
)
assert response.status_code == 200
return response.json()
def fetch_ai_summary(telegram_client: TestClient, thread_id: str, headers: dict | None = None):
return telegram_client.get(
f"/integrations/telegram/threads/{thread_id}/ai-summary",
headers=headers or admin_headers(),
)
def deliver_latest_pending_message(monkeypatch, telegram_client: TestClient, thread_id: str, external_id: int = 7001) -> None:
messages = telegram_client.get(
f"/integrations/telegram/threads/{thread_id}/messages",
headers=admin_headers(),
)
assert messages.status_code == 200
message_id = messages.json()[-1]["message_id"]
monkeypatch.setattr(
telegram_module,
"_send_telegram_message",
lambda chat_id, text: {"ok": True, "result": {"message_id": external_id, "chat": {"id": chat_id}, "text": text}},
)
telegram_module._deliver_pending_telegram_reply(message_id)
def seed_ai_analytics_dataset(marker: str) -> dict[str, str]:
session = get_session()
try:
window_from = "2040-01-01T00:00:00+00:00"
window_to = "2040-01-03T00:00:00+00:00"
queue_tg = f"{marker}_queue_tg"
queue_wa = f"{marker}_queue_wa"
queue_thread = f"{marker}_queue_thread"
interaction_tg = f"{marker}_int_tg"
interaction_wa = f"{marker}_int_wa"
interaction_tg_human = f"{marker}_int_tg_human"
interaction_thread = f"{marker}_int_thread"
thread_tg = f"{marker}_thread_tg"
thread_wa = f"{marker}_thread_wa"
thread_tg_human = f"{marker}_thread_tg_human"
thread_wa_thread_queue = f"{marker}_thread_wa_thread_queue"
session_tg = f"{marker}_sess_tg"
session_wa = f"{marker}_sess_wa"
session_tg_human = f"{marker}_sess_tg_human"
session_thread_queue = f"{marker}_sess_thread_queue"
session.add_all(
[
Interaction(
interaction_id=interaction_tg,
channel="telegram",
subject=f"{marker} contained telegram",
customer_id=f"{marker}_cust_1",
queue_id=queue_tg,
priority=3,
status="closed",
assigned_to=None,
created_at="2040-01-01T09:00:00+00:00",
updated_at="2040-01-01T09:40:00+00:00",
),
Interaction(
interaction_id=interaction_wa,
channel="whatsapp",
subject=f"{marker} whatsapp handoff",
customer_id=f"{marker}_cust_2",
queue_id=queue_wa,
priority=3,
status="in_progress",
assigned_to="agent_whatsapp",
created_at="2040-01-01T13:00:00+00:00",
updated_at="2040-01-01T13:30:00+00:00",
),
Interaction(
interaction_id=interaction_tg_human,
channel="telegram",
subject=f"{marker} telegram with operator",
customer_id=f"{marker}_cust_3",
queue_id=queue_tg,
priority=3,
status="closed",
assigned_to="agent_telegram",
created_at="2040-01-02T10:00:00+00:00",
updated_at="2040-01-02T10:40:00+00:00",
),
Interaction(
interaction_id=interaction_thread,
channel="whatsapp",
subject=f"{marker} thread queue fallback",
customer_id=f"{marker}_cust_4",
queue_id=None,
priority=3,
status="closed",
assigned_to=None,
created_at="2040-01-02T15:00:00+00:00",
updated_at="2040-01-02T15:15:00+00:00",
),
TelegramThreadRow(
thread_id=thread_tg,
chat_id=f"{marker}_chat_tg",
interaction_id=interaction_tg,
telegram_user_id=f"{marker}_tg_user",
username=f"{marker}_tg",
display_name="AI Telegram",
queue_id=queue_tg,
status="closed",
claimed_by_user=None,
claimed_at=None,
ai_session_id=session_tg,
ai_state="closed",
ai_handoff_reason=None,
ai_last_model_at="2040-01-01T09:15:00+00:00",
last_message_at="2040-01-01T09:16:00+00:00",
last_message_preview="contained",
created_at="2040-01-01T09:00:00+00:00",
updated_at="2040-01-01T09:16:00+00:00",
),
WhatsAppThreadRow(
thread_id=thread_wa,
chat_id=f"{marker}_chat_wa",
interaction_id=interaction_wa,
whatsapp_user_id=f"{marker}_wa_user",
phone_number="+77000000001",
display_name="AI WhatsApp",
queue_id=queue_wa,
is_group=False,
status="in_progress",
claimed_by_user="supervisor_wa",
claimed_at="2040-01-01T13:12:00+00:00",
ai_session_id=session_wa,
ai_state="human_owned",
ai_handoff_reason="requested by customer",
ai_last_model_at="2040-01-01T13:10:00+00:00",
last_message_at="2040-01-01T13:11:00+00:00",
last_message_preview="handoff",
created_at="2040-01-01T13:00:00+00:00",
updated_at="2040-01-01T13:12:00+00:00",
),
TelegramThreadRow(
thread_id=thread_tg_human,
chat_id=f"{marker}_chat_tg_human",
interaction_id=interaction_tg_human,
telegram_user_id=f"{marker}_tg_user_human",
username=f"{marker}_tg_human",
display_name="AI Telegram Human",
queue_id=queue_tg,
status="closed",
claimed_by_user=None,
claimed_at=None,
ai_session_id=session_tg_human,
ai_state="closed",
ai_handoff_reason=None,
ai_last_model_at="2040-01-02T10:10:00+00:00",
last_message_at="2040-01-02T10:11:00+00:00",
last_message_preview="closed with operator",
created_at="2040-01-02T10:00:00+00:00",
updated_at="2040-01-02T10:11:00+00:00",
),
WhatsAppThreadRow(
thread_id=thread_wa_thread_queue,
chat_id=f"{marker}_chat_wa_thread",
interaction_id=interaction_thread,
whatsapp_user_id=f"{marker}_wa_thread_user",
phone_number="+77000000002",
display_name="AI WhatsApp Thread Queue",
queue_id=queue_thread,
is_group=False,
status="closed",
claimed_by_user=None,
claimed_at=None,
ai_session_id=session_thread_queue,
ai_state="closed",
ai_handoff_reason=None,
ai_last_model_at="2040-01-02T15:10:00+00:00",
last_message_at="2040-01-02T15:11:00+00:00",
last_message_preview="thread fallback",
created_at="2040-01-02T15:00:00+00:00",
updated_at="2040-01-02T15:11:00+00:00",
),
AISessionRow(
session_id=session_tg,
channel="telegram",
thread_id=thread_tg,
interaction_id=interaction_tg,
customer_id=f"{marker}_cust_1",
agent_profile="telegram_support",
language="ru",
status="closed",
summary_text="",
last_user_message_id=None,
last_ai_message_id=None,
handoff_reason=None,
created_at="2040-01-01T09:00:00+00:00",
updated_at="2040-01-01T09:18:00+00:00",
closed_at="2040-01-01T09:18:00+00:00",
),
AISessionRow(
session_id=session_wa,
channel="whatsapp",
thread_id=thread_wa,
interaction_id=interaction_wa,
customer_id=f"{marker}_cust_2",
agent_profile="whatsapp_support",
language="ru",
status="human_owned",
summary_text="",
last_user_message_id=None,
last_ai_message_id=None,
handoff_reason="requested by customer",
created_at="2040-01-01T13:00:00+00:00",
updated_at="2040-01-01T13:12:00+00:00",
closed_at=None,
),
AISessionRow(
session_id=session_tg_human,
channel="telegram",
thread_id=thread_tg_human,
interaction_id=interaction_tg_human,
customer_id=f"{marker}_cust_3",
agent_profile="telegram_support",
language="ru",
status="closed",
summary_text="",
last_user_message_id=None,
last_ai_message_id=None,
handoff_reason=None,
created_at="2040-01-02T10:00:00+00:00",
updated_at="2040-01-02T10:12:00+00:00",
closed_at="2040-01-02T10:12:00+00:00",
),
AISessionRow(
session_id=session_thread_queue,
channel="whatsapp",
thread_id=thread_wa_thread_queue,
interaction_id=interaction_thread,
customer_id=f"{marker}_cust_4",
agent_profile="whatsapp_support",
language="ru",
status="closed",
summary_text="",
last_user_message_id=None,
last_ai_message_id=None,
handoff_reason=None,
created_at="2040-01-02T15:00:00+00:00",
updated_at="2040-01-02T15:12:00+00:00",
closed_at="2040-01-02T15:12:00+00:00",
),
AITurnRow(
turn_id=f"{marker}_turn_1",
session_id=session_tg,
thread_id=thread_tg,
interaction_id=interaction_tg,
role="assistant",
source_type="model",
text="reply 1",
payload_json="{}",
model="stub",
finish_reason="stop",
latency_ms=100,
created_at="2040-01-01T09:05:00+00:00",
),
AITurnRow(
turn_id=f"{marker}_turn_2",
session_id=session_tg,
thread_id=thread_tg,
interaction_id=interaction_tg,
role="assistant",
source_type="model",
text="reply 2",
payload_json="{}",
model="stub",
finish_reason="stop",
latency_ms=200,
created_at="2040-01-01T09:10:00+00:00",
),
AITurnRow(
turn_id=f"{marker}_turn_3",
session_id=session_wa,
thread_id=thread_wa,
interaction_id=interaction_wa,
role="assistant",
source_type="model",
text="reply 3",
payload_json="{}",
model="stub",
finish_reason="stop",
latency_ms=900,
created_at="2040-01-01T13:05:00+00:00",
),
AITurnRow(
turn_id=f"{marker}_turn_4",
session_id=session_wa,
thread_id=thread_wa,
interaction_id=interaction_wa,
role="assistant",
source_type="kb",
text="ignored",
payload_json="{}",
model="stub",
finish_reason="stop",
latency_ms=50,
created_at="2040-01-01T13:06:00+00:00",
),
AITurnRow(
turn_id=f"{marker}_turn_5",
session_id=session_thread_queue,
thread_id=thread_wa_thread_queue,
interaction_id=interaction_thread,
role="assistant",
source_type="model",
text="reply 5",
payload_json="{}",
model="stub",
finish_reason="stop",
latency_ms=400,
created_at="2040-01-02T15:05:00+00:00",
),
AITurnRow(
turn_id=f"{marker}_turn_6",
session_id=session_thread_queue,
thread_id=thread_wa_thread_queue,
interaction_id=interaction_thread,
role="user",
source_type="customer",
text="ignored user turn",
payload_json="{}",
model=None,
finish_reason=None,
latency_ms=999,
created_at="2040-01-02T15:04:00+00:00",
),
]
)
session.commit()
return {
"from_ts": window_from,
"to_ts": window_to,
"queue_tg": queue_tg,
"queue_wa": queue_wa,
"queue_thread": queue_thread,
}
finally:
session.close()
def cleanup_ai_analytics_dataset(marker: str) -> None:
session = get_session()
try:
session.query(AITurnRow).filter(AITurnRow.turn_id.like(f"{marker}_turn_%")).delete(synchronize_session=False)
session.query(AISessionRow).filter(AISessionRow.session_id.like(f"{marker}_sess_%")).delete(synchronize_session=False)
session.query(TelegramThreadRow).filter(TelegramThreadRow.thread_id.like(f"{marker}_thread_%")).delete(synchronize_session=False)
session.query(WhatsAppThreadRow).filter(WhatsAppThreadRow.thread_id.like(f"{marker}_thread_%")).delete(synchronize_session=False)
session.query(Interaction).filter(Interaction.interaction_id.like(f"{marker}_int_%")).delete(synchronize_session=False)
session.commit()
finally:
session.close()
def seed_voice_name_flow_analytics_dataset(marker: str) -> dict[str, str]:
session = get_session()
try:
window_from = "2041-02-01T00:00:00+00:00"
window_to = "2041-02-03T00:00:00+00:00"
queue_ru = f"{marker}_queue_ru"
queue_kz = f"{marker}_queue_kz"
queue_support = f"{marker}_queue_support"
sessions = [
{
"voice_session_id": f"{marker}_voice_start",
"ai_session_id": f"{marker}_ai_start",
"call_id": f"{marker}_call_start",
"interaction_id": f"{marker}_int_start",
"started_at": "2041-02-01T09:00:00+00:00",
"queue_id": queue_ru,
"interaction_queue_id": queue_ru,
"language": "ru",
"voice_start_language": "ru",
"status": "closed",
"handoff_reason": None,
"call_queue_id": queue_ru,
"call_voice_start_language": "ru",
"claimed_by_user": None,
"operator_extension": None,
"customer_name_source": "voice_start",
"turns": [
{
"turn_id": f"{marker}_turn_start_1",
"created_at": "2041-02-01T09:02:00+00:00",
"decision": {
"customer_name_status": "name_obtained",
"customer_name_value": "Алия",
"customer_name_source": "voice_start",
},
},
],
},
{
"voice_session_id": f"{marker}_voice_downstream",
"ai_session_id": f"{marker}_ai_downstream",
"call_id": f"{marker}_call_downstream",
"interaction_id": f"{marker}_int_downstream",
"started_at": "2041-02-01T10:00:00+00:00",
"queue_id": None,
"interaction_queue_id": None,
"language": "kz",
"voice_start_language": None,
"status": "human_owned",
"handoff_reason": "requested_human",
"call_queue_id": queue_kz,
"call_voice_start_language": "kz",
"claimed_by_user": "operator_kz",
"operator_extension": "2101",
"customer_name_source": "voice_followup",
"turns": [
{
"turn_id": f"{marker}_turn_down_1",
"created_at": "2041-02-01T10:01:00+00:00",
"decision": {
"customer_name_status": "name_followup_required",
"customer_name_source": "voice_start",
"metadata": {
"customer_name_status": "name_followup_required",
"customer_name_source": "voice_start",
},
},
},
{
"turn_id": f"{marker}_turn_down_2",
"created_at": "2041-02-01T10:03:00+00:00",
"decision": {
"customer_name_status": "name_obtained",
"customer_name_value": "Нурлан",
"customer_name_source": "voice_followup",
"metadata": {
"customer_name_status": "name_obtained",
"customer_name_value": "Нурлан",
"customer_name_source": "voice_followup",
},
},
},
],
},
{
"voice_session_id": f"{marker}_voice_followup",
"ai_session_id": f"{marker}_ai_followup",
"call_id": f"{marker}_call_followup",
"interaction_id": f"{marker}_int_followup",
"started_at": "2041-02-02T11:00:00+00:00",
"queue_id": None,
"interaction_queue_id": queue_support,
"language": None,
"voice_start_language": None,
"status": "handoff_required",
"handoff_reason": "requested_human",
"call_queue_id": None,
"call_voice_start_language": None,
"claimed_by_user": "operator_support",
"operator_extension": None,
"customer_name_source": "voice_start",
"with_call_row": False,
"turns": [
{
"turn_id": f"{marker}_turn_followup_1",
"created_at": "2041-02-02T11:02:00+00:00",
"decision": {
"customer_name_status": "name_followup_required",
"customer_name_source": "voice_start",
"metadata": {
"customer_name_status": "name_followup_required",
"customer_name_source": "voice_start",
},
},
},
],
},
{
"voice_session_id": f"{marker}_voice_missing",
"ai_session_id": None,
"call_id": f"{marker}_call_missing",
"interaction_id": f"{marker}_int_missing",
"started_at": "2041-02-02T12:00:00+00:00",
"queue_id": queue_ru,
"interaction_queue_id": queue_ru,
"language": "ru",
"voice_start_language": "ru",
"status": "closed",
"handoff_reason": None,
"call_queue_id": queue_ru,
"call_voice_start_language": "ru",
"claimed_by_user": None,
"operator_extension": None,
"customer_name_source": "voice_start",
"turns": [],
"timeline_event": {
"timestamp": "2041-02-02T12:01:00+00:00",
"metadata": {
"customer_name_status": "name_not_obtained",
"customer_name_source": "voice_start",
"language": "ru",
},
},
},
{
"voice_session_id": f"{marker}_voice_manual",
"ai_session_id": f"{marker}_ai_manual",
"call_id": f"{marker}_call_manual",
"interaction_id": f"{marker}_int_manual",
"started_at": "2041-02-02T13:00:00+00:00",
"queue_id": queue_ru,
"interaction_queue_id": queue_ru,
"language": "ru",
"voice_start_language": "ru",
"status": "human_owned",
"handoff_reason": "requested_human",
"call_queue_id": queue_ru,
"call_voice_start_language": "ru",
"claimed_by_user": "operator_manual",
"operator_extension": "2201",
"customer_name_source": "manual",
"turns": [
{
"turn_id": f"{marker}_turn_manual_1",
"created_at": "2041-02-02T13:02:00+00:00",
"decision": {
"customer_name_status": "name_not_obtained",
"customer_name_source": "voice_followup",
"metadata": {
"customer_name_status": "name_not_obtained",
"customer_name_source": "voice_followup",
},
},
},
],
},
]
entities = []
for item in sessions:
interaction_id = item["interaction_id"]
call_id = item["call_id"]
voice_session_id = item["voice_session_id"]
ai_session_id = item["ai_session_id"]
entities.append(
Interaction(
interaction_id=interaction_id,
channel="voice",
subject=f"{marker} {interaction_id}",
customer_id=f"{marker}_cust_{interaction_id}",
queue_id=item["interaction_queue_id"],
priority=3,
status="closed" if item["status"] == "closed" else "in_progress",
assigned_to=item["claimed_by_user"],
created_at=item["started_at"],
updated_at=item["started_at"],
)
)
if item.get("with_call_row", True):
entities.append(
AsteriskCallLinkRow(
call_id=call_id,
linked_id=f"{call_id}_linked",
queue_code="voice_support",
queue_id=item["call_queue_id"],
interaction_id=interaction_id,
caller_number=f"+7700{abs(hash(call_id)) % 1000000:06d}",
caller_name="Voice Caller",
status="active",
telephony_status="connected",
claimed_by_user=item["claimed_by_user"],
claimed_at=item["started_at"] if item["claimed_by_user"] else None,
operator_extension=item["operator_extension"],
channel_name="PJSIP/1001-000001",
started_at=item["started_at"],
connected_at=item["started_at"],
ended_at=None,
updated_at=item["started_at"],
voice_start_language=item["call_voice_start_language"],
customer_name_status="name_obtained" if item["customer_name_source"] == "manual" else None,
customer_name_value="Manual Name" if item["customer_name_source"] == "manual" else None,
customer_name_source=item["customer_name_source"],
customer_name_resolved_at=item["started_at"] if item["customer_name_source"] == "manual" else None,
voice_session_id=voice_session_id,
ai_state="human_owned" if item["claimed_by_user"] else "closed",
ai_handoff_reason=item["handoff_reason"],
)
)
entities.append(
VoiceAISessionRow(
session_id=voice_session_id,
call_id=call_id,
linked_id=f"{call_id}_linked",
interaction_id=interaction_id,
customer_id=f"{marker}_cust_{interaction_id}",
queue_id=item["queue_id"],
ai_session_id=ai_session_id,
agent_profile="voice_support",
language=item["language"],
asr_provider="openai",
tts_provider="yandex",
status=item["status"],
handoff_reason=item["handoff_reason"],
handoff_target_queue_id=item["interaction_queue_id"] or item["call_queue_id"],
disclosure_played_at=item["started_at"],
last_user_utterance_at=None,
last_ai_reply_at=None,
started_at=item["started_at"],
updated_at=item["started_at"],
ended_at=None,
voice_start_language=item["voice_start_language"],
customer_name_status="name_obtained" if item["customer_name_source"] == "manual" else None,
customer_name_value="Manual Name" if item["customer_name_source"] == "manual" else None,
customer_name_source=item["customer_name_source"],
customer_name_resolved_at=item["started_at"] if item["customer_name_source"] == "manual" else None,
)
)
if ai_session_id:
entities.append(
AISessionRow(
session_id=ai_session_id,
channel="voice",
thread_id=None,
interaction_id=interaction_id,
customer_id=f"{marker}_cust_{interaction_id}",
agent_profile="voice_support",
language=item["language"] or item["voice_start_language"] or item["call_voice_start_language"] or "unknown",
status=item["status"],
summary_text="",
last_user_message_id=None,
last_ai_message_id=None,
handoff_reason=item["handoff_reason"],
created_at=item["started_at"],
updated_at=item["started_at"],
closed_at=item["started_at"] if item["status"] == "closed" else None,
)
)
for turn in item["turns"]:
entities.append(
AITurnRow(
turn_id=turn["turn_id"],
session_id=ai_session_id,
thread_id=None,
interaction_id=interaction_id,
role="assistant",
source_type="voice_policy",
text="voice policy",
payload_json=json.dumps({"decision": turn["decision"]}, ensure_ascii=False),
model="stub",
finish_reason="stop",
latency_ms=120,
created_at=turn["created_at"],
)
)
if item.get("timeline_event"):
entities.append(
InteractionTimeline(
interaction_id=interaction_id,
timestamp=item["timeline_event"]["timestamp"],
action="voice.start.completed",
metadata_json=json.dumps(item["timeline_event"]["metadata"], ensure_ascii=False),
)
)
session.add_all(entities)
session.commit()
return {
"from_ts": window_from,
"to_ts": window_to,
"queue_ru": queue_ru,
"queue_kz": queue_kz,
"queue_support": queue_support,
}
finally:
session.close()
def cleanup_voice_name_flow_analytics_dataset(marker: str) -> None:
session = get_session()
try:
session.query(AITurnRow).filter(AITurnRow.turn_id.like(f"{marker}_turn_%")).delete(synchronize_session=False)
session.query(AISessionRow).filter(AISessionRow.session_id.like(f"{marker}_ai_%")).delete(synchronize_session=False)
session.query(VoiceAISessionRow).filter(VoiceAISessionRow.session_id.like(f"{marker}_voice_%")).delete(synchronize_session=False)
session.query(AsteriskCallLinkRow).filter(AsteriskCallLinkRow.call_id.like(f"{marker}_call_%")).delete(synchronize_session=False)
session.query(InteractionTimeline).filter(InteractionTimeline.interaction_id.like(f"{marker}_int_%")).delete(synchronize_session=False)
session.query(Interaction).filter(Interaction.interaction_id.like(f"{marker}_int_%")).delete(synchronize_session=False)
session.commit()
finally:
session.close()
def test_ai_language_detection_prefers_kz_letters():
assert ai_module._infer_language("Сәлем, көмек керек") == "kz"
assert ai_module._infer_language("Здравствуйте, нужна помощь") == "ru"
def test_voice_module_import_helper_returns_app_module():
loaded = voice_module._app()
assert getattr(loaded, "__name__", "") == "services.ai_orchestrator_service.app"
assert hasattr(loaded, "_customer_id_is_real")
assert hasattr(loaded, "_interaction_request")
def test_text_openai_prompt_uses_operator_persona_without_ai_disclosure():
messages = ai_module._openai_prompt(
customer=None,
interaction=SimpleNamespace(
interaction_id="int_text_prompt",
status="new",
queue_id="que_text",
subject="Need help",
customer_id="cust_text",
),
thread=SimpleNamespace(thread_id="thr_text", chat_id="chat_text", display_name="Customer"),
messages=[
SimpleNamespace(
author_type="customer",
author_id="cust_text",
direction="inbound",
text="Хочу узнать тариф",
created_at=utc_now_iso(),
)
],
kb_results=[],
language="ru",
channel_label="Telegram",
channel_key="telegram",
)
system_prompt = messages[0]["content"]
assert "AI assistant" not in system_prompt
assert "Always disclose" not in system_prompt
assert "human operator" in system_prompt
assert "Do not mention a knowledge base" in system_prompt
def test_voice_decision_reuses_recent_topic_instead_of_repeating_same_prompt():
transcript_window = [
SimpleNamespace(speaker="assistant", text=voice_module._voice_greeting("ru"), sequence_no=1),
SimpleNamespace(speaker="caller", text="График работы узнать", sequence_no=2),
SimpleNamespace(
speaker="assistant",
text="Подскажите, график работы какого филиала, адреса или города вас интересует?",
sequence_no=3,
),
SimpleNamespace(speaker="caller", text="О каком запросе?", sequence_no=4),
]
decision = voice_module._voice_decision(
language="ru",
customer=None,
interaction=SimpleNamespace(interaction_id="int_voice_test"),
transcript_text="О каком запросе?",
transcript_window=transcript_window,
kb_results=[],
disclosure_required=False,
)
assert decision["intent"] == "clarification"
assert decision["needs_handoff"] is False
assert decision["reply_text"] != transcript_window[2].text
assert "подскажите точнее" in decision["reply_text"].lower()
assert "график работы" in decision["reply_text"].lower()
def test_voice_decision_handoffs_after_repeated_clarification_loop():
transcript_window = [
SimpleNamespace(speaker="assistant", text=voice_module._voice_greeting("ru"), sequence_no=1),
SimpleNamespace(speaker="caller", text="Привет", sequence_no=2),
SimpleNamespace(
speaker="assistant",
text="Чтобы помочь быстрее, скажите в двух словах, что вам нужно: график работы, статус заявки, тариф или оператор.",
sequence_no=3,
),
SimpleNamespace(speaker="caller", text="Угу", sequence_no=4),
SimpleNamespace(
speaker="assistant",
text="Сейчас уточняю цель звонка. Скажите коротко, что именно нужно: график работы, статус заявки, тариф или оператор.",
sequence_no=5,
),
SimpleNamespace(speaker="caller", text="Не понял", sequence_no=6),
SimpleNamespace(
speaker="assistant",
text="Сейчас уточняю цель звонка. Скажите коротко, что именно нужно: график работы, статус заявки, тариф или оператор.",
sequence_no=7,
),
SimpleNamespace(speaker="caller", text="О каком запросе?", sequence_no=8),
SimpleNamespace(
speaker="assistant",
text="Сейчас уточняю цель звонка. Скажите коротко, что именно нужно: график работы, статус заявки, тариф или оператор.",
sequence_no=9,
),
]
decision = voice_module._voice_decision(
language="ru",
customer=None,
interaction=SimpleNamespace(interaction_id="int_voice_test"),
transcript_text="О каком запросе?",
transcript_window=transcript_window,
kb_results=[],
disclosure_required=False,
)
assert decision["intent"] == "handoff_request"
assert decision["needs_handoff"] is True
assert "перевожу на оператора" in decision["reply_text"].lower()
assert "нескольких попыток" in decision["handoff_reason"].lower()
def test_voice_kb_search_matches_relaxed_phrase_and_returns_kb_answer():
query = _u(r"\u0425\u043e\u0447\u0443 \u0443\u0437\u043d\u0430\u0442\u044c \u0442\u0430\u0440\u0438\u0444 relaxbasicx")
seed_kb_article(
"Tariff relaxbasicx",
"Tariff relaxbasicx activates after the request is confirmed.",
"relaxbasicx",
)
session = get_session()
try:
kb_results = ai_module._kb_search(session, query)
finally:
session.close()
assert kb_results
assert kb_results[0].title == "Tariff relaxbasicx"
decision = voice_module._voice_decision(
language="ru",
customer=None,
interaction=SimpleNamespace(interaction_id="int_voice_kb_relaxed"),
transcript_text=query,
transcript_window=[SimpleNamespace(speaker="caller", text=query, sequence_no=1)],
kb_results=kb_results,
disclosure_required=False,
)
assert decision["intent"] == "kb_answer"
assert decision["needs_handoff"] is False
assert "relaxbasicx" in decision["reply_text"].lower()
def test_voice_llm_guarded_decision_uses_operator_style_without_ai_or_kb(monkeypatch):
monkeypatch.setenv("AI_VOICE_POLICY_MODE", "llm_guarded")
monkeypatch.setattr(ai_module, "_ai_provider", lambda: "openai_compatible")
captured: dict[str, object] = {}
def _fake_structured(messages, **kwargs):
captured["messages"] = messages
return {
"language": "ru",
"intent": "kb_answer",
"reply_text": "Сейчас подскажу: филиал в Алматы работает с 9:00 до 18:00 по будням.",
"confidence": 0.88,
"needs_handoff": False,
"handoff_reason": None,
"case_action": "keep_open",
"kb_refs": ["kba_voice_1"],
"_model": "gpt-test",
"_latency_ms": 42,
"_finish_reason": "stop",
}
monkeypatch.setattr(ai_module, "_request_structured_model_decision", _fake_structured)
kb_article = SimpleNamespace(article_id="kba_voice_1", title="График работы", body="Будни 9:00-18:00")
decision = voice_module._voice_decision(
language="ru",
customer=SimpleNamespace(customer_id="cus_voice_1", display_name="Айдос"),
interaction=SimpleNamespace(interaction_id="int_voice_llm", status="new", queue_id="que_voice", subject="hours"),
transcript_text="Как работает филиал в Алматы?",
transcript_window=[SimpleNamespace(speaker="caller", text="Как работает филиал в Алматы?", sequence_no=1, source_type="voice_asr", barge_in_interrupted=False, created_at=utc_now_iso())],
kb_results=[kb_article],
disclosure_required=False,
customer_name_value="Айдос",
customer_name_status="name_obtained",
)
assert decision["intent"] == "kb_answer"
assert decision["model"] == "gpt-test"
assert "ai" not in decision["reply_text"].lower()
assert "база знаний" not in decision["reply_text"].lower()
system_prompt = captured["messages"][0]["content"]
assert "human operator" in system_prompt
assert "Do not say or imply that you are an AI" in system_prompt
def test_voice_llm_decision_echoes_kb_article_intent_code(monkeypatch):
monkeypatch.setenv("AI_VOICE_POLICY_MODE", "llm_guarded")
monkeypatch.setattr(ai_module, "_ai_provider", lambda: "openai_compatible")
def _fake_structured(messages, **kwargs):
del messages
return {
"language": "ru",
"intent": "voucher_activation",
"reply_text": "Подтвердите СМС с номера 1414 командой 21*1, затем завершите активацию в eGov.",
"confidence": 0.9,
"needs_handoff": False,
"handoff_reason": None,
"case_action": "keep_open",
"kb_refs": ["kba_voucher_1"],
"_model": "gpt-test",
"_latency_ms": 30,
"_finish_reason": "stop",
}
monkeypatch.setattr(ai_module, "_request_structured_model_decision", _fake_structured)
kb_article = SimpleNamespace(
article_id="kba_voucher_1",
title="Активация ваучера",
body="Подтвердите СМС 1414 командой 21*1, затем перейдите по ссылке и завершите в eGov Mobile.",
intent_code="VOUCHER_ACTIVATION",
)
decision = voice_module._voice_decision(
language="ru",
customer=None,
interaction=SimpleNamespace(interaction_id="int_voice_voucher", customer_id=None, status="new", queue_id="que_voice", subject="voucher"),
transcript_text="Что делать с СМС от 1414?",
transcript_window=[SimpleNamespace(speaker="caller", text="Что делать с СМС от 1414?", sequence_no=1, source_type="voice_asr", barge_in_interrupted=False, created_at=utc_now_iso())],
kb_results=[kb_article],
disclosure_required=False,
)
assert decision["intent"] == "VOUCHER_ACTIVATION"
def test_voice_llm_decision_rejects_invented_intent_not_in_kb_results(monkeypatch):
monkeypatch.setenv("AI_VOICE_POLICY_MODE", "llm_guarded")
monkeypatch.setattr(ai_module, "_ai_provider", lambda: "openai_compatible")
def _fake_structured(messages, **kwargs):
del messages
return {
"language": "ru",
"intent": "totally_made_up_intent",
"reply_text": "Подтвердите СМС с номера 1414 командой 21*1.",
"confidence": 0.9,
"needs_handoff": False,
"handoff_reason": None,
"case_action": "keep_open",
"kb_refs": ["kba_voucher_2"],
"_model": "gpt-test",
"_latency_ms": 30,
"_finish_reason": "stop",
}
monkeypatch.setattr(ai_module, "_request_structured_model_decision", _fake_structured)
kb_article = SimpleNamespace(
article_id="kba_voucher_2",
title="Активация ваучера",
body="Подтвердите СМС 1414 командой 21*1.",
intent_code="VOUCHER_ACTIVATION",
)
decision = voice_module._voice_decision(
language="ru",
customer=None,
interaction=SimpleNamespace(interaction_id="int_voice_voucher_2", customer_id=None, status="new", queue_id="que_voice", subject="voucher"),
transcript_text="Куда отправлять 21*1?",
transcript_window=[SimpleNamespace(speaker="caller", text="Куда отправлять 21*1?", sequence_no=1, source_type="voice_asr", barge_in_interrupted=False, created_at=utc_now_iso())],
kb_results=[kb_article],
disclosure_required=False,
)
assert decision["intent"] == "unknown"
def test_voice_v2_fast_conversational_adds_ack_metadata_and_compacts_reply(monkeypatch):
monkeypatch.setenv("AI_VOICE_POLICY_MODE", "v2_fast_conversational")
monkeypatch.setattr(ai_module, "_ai_provider", lambda: "openai_compatible")
def _fake_structured(messages, **kwargs):
del messages
return {
"language": "ru",
"intent": "kb_answer",
"reply_text": (
"Сейчас сориентирую по графику работы филиала в Алматы. "
"Он работает с понедельника по пятницу с 9:00 до 18:00. "
"Если нужно, подскажу и по субботе."
),
"confidence": 0.91,
"needs_handoff": False,
"handoff_reason": None,
"case_action": "keep_open",
"kb_refs": ["kba_voice_v2"],
"_model": "gpt-test",
"_latency_ms": 35,
"_finish_reason": "stop",
}
monkeypatch.setattr(ai_module, "_request_structured_model_decision", _fake_structured)
decision = voice_module._voice_decision(
language="ru",
customer=SimpleNamespace(customer_id="cus_voice_v2", display_name="Ернор"),
interaction=SimpleNamespace(interaction_id="int_voice_v2", status="new", queue_id="que_voice", subject="hours"),
transcript_text="Хочу узнать график работы филиала в Алматы",
transcript_window=[
SimpleNamespace(
speaker="caller",
text="Хочу узнать график работы филиала в Алматы",
sequence_no=1,
source_type="voice_asr",
barge_in_interrupted=False,
created_at=utc_now_iso(),
)
],
kb_results=[SimpleNamespace(article_id="kba_voice_v2", title="График", body="Будни 9:00-18:00")],
disclosure_required=False,
customer_name_value="Ернор",
customer_name_status="name_obtained",
request_metadata={"voice_v2_enabled": True, "response_plan_id": "rsp_test"},
)
assert decision["intent"] == "kb_answer"
assert decision["metadata"]["voice_v2_enabled"] is True
assert decision["metadata"]["early_intent"] == "schedule"
assert decision["metadata"]["ack_kind"] == "understanding"
assert decision["metadata"]["response_plan_id"] == "rsp_test"
assert len(decision["reply_text"]) <= 180
def test_voice_v2_off_domain_request_returns_fast_operator_fallback_without_llm(monkeypatch):
monkeypatch.setenv("AI_VOICE_POLICY_MODE", "v2_fast_conversational")
def _unexpected_llm(messages, **kwargs):
raise AssertionError(f"LLM should not be called for off-domain fallback: {messages!r}")
monkeypatch.setattr(ai_module, "_request_structured_model_decision", _unexpected_llm)
decision = voice_module._voice_decision(
language="ru",
customer=None,
interaction=SimpleNamespace(interaction_id="int_voice_off_domain", status="new", queue_id="que_voice", subject="unknown"),
transcript_text="Мне надо узнать, как работает ядерный реактор.",
transcript_window=[
SimpleNamespace(
speaker="caller",
text="Мне надо узнать, как работает ядерный реактор.",
sequence_no=1,
source_type="voice_asr",
barge_in_interrupted=False,
created_at=utc_now_iso(),
)
],
kb_results=[],
disclosure_required=False,
request_metadata={"voice_v2_enabled": True, "response_plan_id": "rsp_off_domain"},
)
assert decision["intent"] == "clarification"
assert decision["needs_handoff"] is False
assert decision["model"] == "voice_policy_off_domain"
assert "наших услуг" in decision["reply_text"].lower()
assert "оператор" in decision["reply_text"].lower()
assert "ai" not in decision["reply_text"].lower()
assert "база знаний" not in decision["reply_text"].lower()
assert decision["metadata"]["voice_v2_enabled"] is True
assert decision["metadata"]["early_intent"] == "unknown"
assert decision["metadata"]["response_plan_id"] == "rsp_off_domain"
def test_voice_v2_streaming_duplex_early_plan_returns_fast_safe_reply_without_llm(monkeypatch):
monkeypatch.setenv("AI_VOICE_POLICY_MODE", "v2_streaming_duplex")
def _unexpected_llm(messages, **kwargs):
raise AssertionError(f"LLM should not be called for early plan: {messages!r}")
monkeypatch.setattr(ai_module, "_request_structured_model_decision", _unexpected_llm)
decision = voice_module._voice_decision(
language="ru",
customer=None,
interaction=SimpleNamespace(interaction_id="int_voice_early_plan", status="new", queue_id="que_voice", subject="unknown"),
transcript_text="Расскажи, как устроен кондиционер",
transcript_window=[],
kb_results=[],
disclosure_required=False,
request_metadata={
"voice_v2_enabled": True,
"reply_phase": "early_plan",
"response_plan_id": "rsp_early",
},
)
assert decision["model"] == "voice_early_plan_off_domain"
assert decision["metadata"]["reply_phase"] == "early_plan"
assert decision["metadata"]["voice_v2_enabled"] is True
assert decision["metadata"]["response_plan_id"] == "rsp_early"
assert "кондиционер" not in decision["reply_text"].lower()
assert "оператор" in decision["reply_text"].lower()
def test_voice_v2_streaming_duplex_early_plan_returns_domain_followup_without_llm(monkeypatch):
monkeypatch.setenv("AI_VOICE_POLICY_MODE", "v2_streaming_duplex")
def _unexpected_llm(messages, **kwargs):
raise AssertionError(f"LLM should not be called for early plan: {messages!r}")
monkeypatch.setattr(ai_module, "_request_structured_model_decision", _unexpected_llm)
decision = voice_module._voice_decision(
language="ru",
customer=None,
interaction=SimpleNamespace(interaction_id="int_voice_early_schedule", status="new", queue_id="que_voice", subject="unknown"),
transcript_text="Мне надо узнать график работы",
transcript_window=[],
kb_results=[],
disclosure_required=False,
request_metadata={
"voice_v2_enabled": True,
"reply_phase": "early_plan",
"response_plan_id": "rsp_early_schedule",
"early_intent": "schedule",
},
)
assert decision["model"] == "voice_early_plan_domain"
assert decision["reply_text"]
assert decision["needs_handoff"] is False
assert decision["metadata"]["reply_phase"] == "early_plan"
assert decision["metadata"]["early_intent"] == "schedule"
def test_voice_v2_streaming_duplex_early_plan_answers_from_kb_without_llm(monkeypatch):
monkeypatch.setenv("AI_VOICE_POLICY_MODE", "v2_streaming_duplex")
def _unexpected_llm(messages, **kwargs):
raise AssertionError(f"LLM should not be called for early plan: {messages!r}")
monkeypatch.setattr(ai_module, "_request_structured_model_decision", _unexpected_llm)
kb_article = SimpleNamespace(
article_id="kba_early_schedule",
title="График работы филиалов",
body="Филиалы работают с понедельника по пятницу с 9:00 до 18:00.",
intent_code="BRANCH_SCHEDULE",
)
decision = voice_module._voice_decision(
language="ru",
customer=None,
interaction=SimpleNamespace(interaction_id="int_voice_early_schedule_kb", status="new", queue_id="que_voice", subject="unknown"),
transcript_text="Мне надо узнать график работы",
transcript_window=[],
kb_results=[kb_article],
disclosure_required=False,
request_metadata={
"voice_v2_enabled": True,
"reply_phase": "early_plan",
"response_plan_id": "rsp_early_schedule_kb",
"early_intent": "schedule",
},
)
assert decision["model"] == "voice_early_plan_kb"
assert decision["intent"] == "BRANCH_SCHEDULE"
assert decision["kb_refs"] == ["kba_early_schedule"]
assert decision["needs_handoff"] is False
assert "9:00" in decision["reply_text"] or "9" in decision["reply_text"]
assert decision["metadata"]["reply_phase"] == "early_plan"
def test_voice_v2_streaming_duplex_early_plan_operator_request_skips_kb(monkeypatch):
monkeypatch.setenv("AI_VOICE_POLICY_MODE", "v2_streaming_duplex")
def _unexpected_llm(messages, **kwargs):
raise AssertionError(f"LLM should not be called for early plan: {messages!r}")
monkeypatch.setattr(ai_module, "_request_structured_model_decision", _unexpected_llm)
kb_article = SimpleNamespace(
article_id="kba_should_not_be_used",
title="Unrelated article",
body="Should not be referenced for an operator handoff.",
intent_code="SOMETHING_ELSE",
)
decision = voice_module._voice_decision(
language="ru",
customer=None,
interaction=SimpleNamespace(interaction_id="int_voice_early_operator", status="new", queue_id="que_voice", subject="unknown"),
transcript_text="Соедините меня с оператором",
transcript_window=[],
kb_results=[kb_article],
disclosure_required=False,
request_metadata={
"voice_v2_enabled": True,
"reply_phase": "early_plan",
"response_plan_id": "rsp_early_operator",
"early_intent": "operator_request",
},
)
assert decision["intent"] == "handoff_request"
assert decision["needs_handoff"] is True
assert decision["kb_refs"] == []
def test_voice_decision_hearing_check_keeps_active_topic_without_llm(monkeypatch):
def _unexpected_llm(messages, **kwargs):
raise AssertionError(f"LLM should not be called for hearing check: {messages!r}")
monkeypatch.setattr(ai_module, "_request_structured_model_decision", _unexpected_llm)
decision = voice_module._voice_decision(
language="ru",
customer=None,
interaction=SimpleNamespace(interaction_id="int_voice_hearing", status="new", queue_id="que_voice", subject="schedule"),
transcript_text="Алло, ты меня слышишь?",
transcript_window=[
SimpleNamespace(speaker="caller", text="Мне надо узнать график работы.", sequence_no=1, source_type="voice_asr", barge_in_interrupted=False, created_at=utc_now_iso()),
SimpleNamespace(speaker="assistant", text="Подскажите, какой именно график работы вас интересует?", sequence_no=2, source_type="voice_policy", barge_in_interrupted=False, created_at=utc_now_iso()),
SimpleNamespace(speaker="caller", text="В городе Алмата.", sequence_no=3, source_type="voice_asr", barge_in_interrupted=False, created_at=utc_now_iso()),
SimpleNamespace(speaker="caller", text="Алло, ты меня слышишь?", sequence_no=4, source_type="voice_asr", barge_in_interrupted=False, created_at=utc_now_iso()),
],
kb_results=[],
disclosure_required=False,
request_metadata={"voice_v2_enabled": True, "response_plan_id": "rsp_hearing"},
)
assert decision["model"] == "voice_policy_hearing_check"
assert decision["reply_text"].startswith("Да, вас слышу.")
assert "Как я могу помочь" not in decision["reply_text"]
assert "филиал" in decision["reply_text"] or "адрес" in decision["reply_text"]
def test_voice_postprocess_reply_rewrites_false_lookup_promise():
reply_text = voice_module._voice_postprocess_reply_text(
language="ru",
transcript_text="В городе Алмата.",
transcript_window=[
SimpleNamespace(speaker="caller", text="Мне надо узнать график работы.", sequence_no=1, source_type="voice_asr", barge_in_interrupted=False, created_at=utc_now_iso()),
SimpleNamespace(speaker="caller", text="В городе Алмата.", sequence_no=2, source_type="voice_asr", barge_in_interrupted=False, created_at=utc_now_iso()),
],
reply_text="Спасибо. Я уточню график работы в Алмате. Минуточку, пожалуйста.",
kb_results=[],
needs_handoff=False,
)
assert "уточню" not in reply_text.lower()
assert "минуточ" not in reply_text.lower()
assert "филиал" in reply_text.lower() or "адрес" in reply_text.lower()
def test_voice_postprocess_reply_rewrites_midcall_greeting_with_followup_question():
reply_text = voice_module._voice_postprocess_reply_text(
language="ru",
transcript_text="Здравствуйте, мне надо узнать...",
transcript_window=[
SimpleNamespace(speaker="assistant", text=voice_module._voice_greeting("ru"), sequence_no=1, source_type="voice_policy", barge_in_interrupted=False, created_at=utc_now_iso()),
SimpleNamespace(speaker="caller", text="Здравствуйте, мне надо узнать...", sequence_no=2, source_type="voice_asr", barge_in_interrupted=False, created_at=utc_now_iso()),
],
reply_text="Здравствуйте, Ернур! О чем именно вы хотите узнать?",
kb_results=[],
needs_handoff=False,
)
normalized = reply_text.lower()
assert "здравствуйте" not in normalized
assert "о чем именно" not in normalized
def test_voice_postprocess_reply_reuses_active_topic_after_frustration_turn():
reply_text = voice_module._voice_postprocess_reply_text(
language="ru",
transcript_text="Сколько раз повторять тебе?",
transcript_window=[
SimpleNamespace(speaker="caller", text="Мне надо узнать график работы.", sequence_no=1, source_type="voice_asr", barge_in_interrupted=False, created_at=utc_now_iso()),
SimpleNamespace(speaker="caller", text="В городе Алма-Ата.", sequence_no=2, source_type="voice_asr", barge_in_interrupted=False, created_at=utc_now_iso()),
SimpleNamespace(speaker="caller", text="Меня интересует филиал в Ауэзовском районе.", sequence_no=3, source_type="voice_asr", barge_in_interrupted=False, created_at=utc_now_iso()),
SimpleNamespace(speaker="caller", text="Сколько раз повторять тебе?", sequence_no=4, source_type="voice_asr", barge_in_interrupted=False, created_at=utc_now_iso()),
],
reply_text="Пожалуйста, уточните, какая услуга вас интересует, чтобы я мог подсказать тариф.",
kb_results=[],
needs_handoff=False,
)
normalized = reply_text.lower()
assert "тариф" not in normalized
assert "услуг" not in normalized
assert "филиал" in normalized or "адрес" in normalized
def test_turn_voice_session_early_plan_does_not_persist_partial_turns(monkeypatch):
monkeypatch.setenv("AI_VOICE_POLICY_MODE", "v2_streaming_duplex")
def _unexpected_llm(messages, **kwargs):
raise AssertionError(f"LLM should not be called for early plan: {messages!r}")
monkeypatch.setattr(ai_module, "_request_structured_model_decision", _unexpected_llm)
seeded = seed_voice_downstream_session(
marker=f"voice_early_plan_{new_id('seed')}",
name_status="name_not_obtained",
customer_display_name="+77010009999",
)
decision = voice_module.turn_voice_session(
seeded["session_id"],
VoiceAITurnIn(
voice_session_id=seeded["session_id"],
call_id=seeded["call_id"],
interaction_id=seeded["interaction_id"],
transcript_text="Расскажи, как устроен кондиционер",
language="ru",
sequence_no=1,
metadata={
"voice_v2_enabled": True,
"reply_phase": "early_plan",
"response_plan_id": "rsp_early_turn",
},
),
)
assert decision.metadata["reply_phase"] == "early_plan"
assert decision.metadata["response_plan_id"] == "rsp_early_turn"
assert decision.status == "active"
session = get_session()
try:
voice_session = session.execute(
select(VoiceAISessionRow).where(VoiceAISessionRow.session_id == seeded["session_id"])
).scalar_one()
ai_turns = session.execute(
select(AITurnRow).where(AITurnRow.interaction_id == seeded["interaction_id"])
).scalars().all()
transcript_segments = session.execute(
select(VoiceTranscriptSegmentRow).where(VoiceTranscriptSegmentRow.session_id == seeded["session_id"])
).scalars().all()
assert voice_session.ai_session_id is None
assert voice_session.status == "active"
assert ai_turns == []
assert transcript_segments == []
finally:
session.close()
def test_voice_llm_prompt_includes_context_summary_and_uses_12_segments():
transcript_window = [
SimpleNamespace(
speaker="caller" if index % 2 == 0 else "assistant",
text=f"segment {index}",
sequence_no=index,
source_type="voice_policy",
barge_in_interrupted=False,
created_at=utc_now_iso(),
)
for index in range(1, 16)
]
messages = voice_module._voice_llm_prompt_messages(
language="ru",
customer=None,
interaction=SimpleNamespace(
interaction_id="int_voice_prompt",
status="open",
queue_id="que_voice",
subject="schedule",
customer_id="cus_voice_prompt",
),
transcript_text="Мне нужен график работы",
transcript_window=transcript_window,
conversation_summary_text="Customer name: Ернур | Active intent: schedule | Confirmed facts: city=Алмата",
kb_results=[],
name_value="Ернур",
name_status="name_obtained",
)
payload = json.loads(messages[1]["content"])
assert payload["conversation_summary"].startswith("Customer name: Ернур")
assert len(payload["history"]) == 12
assert payload["history"][0]["sequence_no"] == 4
def test_voice_llm_prompt_instructs_model_not_to_self_name_customer():
messages = voice_module._voice_llm_prompt_messages(
language="ru",
customer=None,
interaction=SimpleNamespace(
interaction_id="int_voice_prompt_name",
status="open",
queue_id="que_voice",
subject="schedule",
customer_id="cus_voice_prompt_name",
),
transcript_text="Мне нужен график работы",
transcript_window=[],
conversation_summary_text="",
kb_results=[],
name_value="Ернур",
name_status="name_obtained",
)
system_prompt = messages[0]["content"]
assert "addressing the customer by name" in system_prompt
def test_voice_reply_with_name_does_not_duplicate_inflected_name_form():
# The model may address the customer using a grammatically declined form of
# their name ("Данияре" instead of "Данияр"); an exact-token dedup check
# would miss this and prepend the name a second time.
reply = voice_module._voice_reply_with_name(
"ru", "Здравствуйте, Данияре! Чем могу помочь?", "Данияр"
)
assert reply == "Здравствуйте, Данияре! Чем могу помочь?"
# A reply with no mention of the customer's name still gets it prefixed once.
reply = voice_module._voice_reply_with_name("ru", "Чем могу помочь?", "Данияр")
assert reply == "Данияр, Чем могу помочь?"
def test_voice_reply_with_name_greet_mode_uses_one_of_two_fixed_forms():
# Regular turns (name already known): just the name, never a greeting word.
reply = voice_module._voice_reply_with_name("ru", "Чем могу помочь?", "Данияр", greet=False)
assert reply == "Данияр, Чем могу помочь?"
# The turn the name is first learned: exactly "Здравствуйте, {name}, ...".
reply = voice_module._voice_reply_with_name("ru", "Чем могу помочь?", "Данияр", greet=True)
assert reply == "Здравствуйте, Данияр, Чем могу помочь?"
reply = voice_module._voice_reply_with_name("kz", "Немен көмектесе аламын?", "Ерлан", greet=True)
assert reply == "Сәлеметсіз бе, Ерлан, Немен көмектесе аламын?"
# Still deduplicates even in greet mode if the model already named the customer.
reply = voice_module._voice_reply_with_name(
"ru", "Здравствуйте, Данияре! Чем могу помочь?", "Данияр", greet=True
)
assert reply == "Здравствуйте, Данияре! Чем могу помочь?"
def test_voice_postprocess_reply_uses_summary_context_when_raw_window_lost_topic():
reply_text = voice_module._voice_postprocess_reply_text(
language="ru",
transcript_text="Сколько раз повторять тебе?",
transcript_window=[
SimpleNamespace(speaker="caller", text="Сколько раз повторять тебе?", sequence_no=1, source_type="voice_asr", barge_in_interrupted=False, created_at=utc_now_iso()),
],
context_summary=json.dumps(
{
"customer_name": "Ернур",
"active_intent": "schedule",
"active_request_text": "Мне нужен график работы в городе Алмата",
"confirmed_facts": {"city": "Алмата", "branch_hint": None, "service_hint": "график работы", "request_number": None},
"open_slots": ["branch_or_address"],
},
ensure_ascii=False,
),
reply_text="Пожалуйста, уточните, какая услуга вас интересует, чтобы я мог подсказать тариф.",
kb_results=[],
needs_handoff=False,
)
normalized = reply_text.lower()
assert "тариф" not in normalized
assert "услуг" not in normalized
assert "филиал" in normalized or "адрес" in normalized
def test_voice_decision_uses_summary_city_slot_instead_of_asking_city_again(monkeypatch):
def _unexpected_llm(messages, **kwargs):
raise AssertionError(f"LLM should not be called when summary slot prompt is enough: {messages!r}")
monkeypatch.setattr(ai_module, "_request_structured_model_decision", _unexpected_llm)
decision = voice_module._voice_decision(
language="ru",
customer=SimpleNamespace(display_name="Ернур"),
interaction=SimpleNamespace(interaction_id="int_voice_summary_slot", customer_id=None, status="open", queue_id=None, subject=None),
transcript_text="Матта.",
transcript_window=[
SimpleNamespace(speaker="caller", text="Матта.", sequence_no=1, source_type="voice_asr", barge_in_interrupted=False, created_at=utc_now_iso()),
],
context_summary=json.dumps(
{
"customer_name": "Ернур",
"active_intent": "schedule",
"active_request_text": "Хочу узнать график работы",
"confirmed_facts": {"city": "Алмата", "branch_hint": None, "service_hint": "график работы", "request_number": None},
"open_slots": ["branch_or_address"],
},
ensure_ascii=False,
),
kb_results=[],
disclosure_required=False,
customer_name_value="Ернур",
customer_name_status="name_obtained",
request_metadata={"voice_v2_enabled": True},
)
assert decision["intent"] == "clarification"
assert decision["needs_handoff"] is False
assert "Алмата" in decision["reply_text"]
assert "город" in decision["reply_text"].lower()
assert "филиал" in decision["reply_text"].lower() or "адрес" in decision["reply_text"].lower()
def test_ai_enqueue_creates_outbound_ai_reply_and_delivery_flow(monkeypatch):
monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1")
monkeypatch.setenv("AI_PROVIDER", "stub")
monkeypatch.setattr(telegram_module, "_ai_enqueue_request", lambda thread_id, trigger_message_id: None)
monkeypatch.setattr(telegram_module, "_start_telegram_reply_delivery", lambda message_id: None)
telegram_client = TestClient(telegram_app)
interaction_client = TestClient(interaction_app)
ai_client = TestClient(ai_app)
patch_ai_internal_calls(monkeypatch, telegram_client, interaction_client)
seed_kb_article("Тариф Basic", "Тариф basic активируется за 5 минут после подтверждения заявки.", "basic")
created = create_inbound_thread(telegram_client, "chat_ai_reply", "basic")
enqueue = ai_client.post(
f"/ai/telegram/threads/{created['thread_id']}/enqueue",
headers=admin_headers(),
json={"trigger_message_id": created["message_id"]},
)
assert enqueue.status_code == 200
assert enqueue.json()["status"] == "done"
summary = fetch_ai_summary(telegram_client, created["thread_id"])
assert summary.status_code == 200
assert summary.json() is None
messages = telegram_client.get(
f"/integrations/telegram/threads/{created['thread_id']}/messages",
headers=admin_headers(),
)
assert messages.status_code == 200
assert messages.json()[-1]["author_type"] == "ai"
assert messages.json()[-1]["delivery_status"] == "pending"
monkeypatch.setattr(
telegram_module,
"_send_telegram_message",
lambda chat_id, text: {"ok": True, "result": {"message_id": 5551, "chat": {"id": chat_id}, "text": text}},
)
telegram_module._deliver_pending_telegram_reply(messages.json()[-1]["message_id"])
delivered = telegram_client.get(
f"/integrations/telegram/threads/{created['thread_id']}/messages",
headers=admin_headers(),
)
assert delivered.status_code == 200
assert delivered.json()[-1]["delivery_status"] == "sent"
assert delivered.json()[-1]["telegram_message_id_external"] == "5551"
def test_ai_enqueue_persists_internal_context_summary(monkeypatch):
monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1")
monkeypatch.setenv("AI_PROVIDER", "stub")
monkeypatch.setattr(telegram_module, "_ai_enqueue_request", lambda thread_id, trigger_message_id: None)
monkeypatch.setattr(telegram_module, "_start_telegram_reply_delivery", lambda message_id: None)
telegram_client = TestClient(telegram_app)
interaction_client = TestClient(interaction_app)
ai_client = TestClient(ai_app)
patch_ai_internal_calls(monkeypatch, telegram_client, interaction_client)
created = create_inbound_thread(
telegram_client,
f"chat_ai_context_summary_{new_id('chat')}",
"Хочу узнать график работы в городе Алмата",
)
enqueue = ai_client.post(
f"/ai/telegram/threads/{created['thread_id']}/enqueue",
headers=admin_headers(),
json={"trigger_message_id": created["message_id"]},
)
assert enqueue.status_code == 200
assert enqueue.json()["status"] in {"done", "handoff_required", "running"}
deadline = time.time() + 2.0
last_summary = None
while time.time() < deadline:
session = get_session()
try:
thread = session.execute(
select(TelegramThreadRow).where(TelegramThreadRow.thread_id == created["thread_id"])
).scalar_one()
if not thread.ai_session_id:
time.sleep(0.05)
continue
ai_session = session.execute(
select(AISessionRow).where(AISessionRow.session_id == thread.ai_session_id)
).scalar_one()
last_summary = json.loads(ai_session.context_summary_json or "{}")
if (
last_summary.get("active_intent") == "schedule"
and last_summary.get("confirmed_facts", {}).get("city") == "Алмата"
):
break
finally:
session.close()
time.sleep(0.05)
assert last_summary is not None
assert last_summary["active_intent"] == "schedule"
assert last_summary["confirmed_facts"]["city"] == "Алмата"
assert last_summary["open_slots"] == ["branch_or_address"]
def test_ai_enqueue_relaxed_kb_search_answers_phrase_query(monkeypatch):
monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1")
monkeypatch.setenv("AI_PROVIDER", "stub")
monkeypatch.setattr(telegram_module, "_ai_enqueue_request", lambda thread_id, trigger_message_id: None)
monkeypatch.setattr(telegram_module, "_start_telegram_reply_delivery", lambda message_id: None)
telegram_client = TestClient(telegram_app)
interaction_client = TestClient(interaction_app)
ai_client = TestClient(ai_app)
patch_ai_internal_calls(monkeypatch, telegram_client, interaction_client)
seed_kb_article(
"Tariff tgrelaxx",
"Tariff tgrelaxx activates after the request is confirmed.",
"tgrelaxx",
)
created = create_inbound_thread(
telegram_client,
"chat_ai_relaxed_kb",
_u(r"\u0425\u043e\u0447\u0443 \u0443\u0437\u043d\u0430\u0442\u044c \u0442\u0430\u0440\u0438\u0444 tgrelaxx"),
)
enqueue = ai_client.post(
f"/ai/telegram/threads/{created['thread_id']}/enqueue",
headers=admin_headers(),
json={"trigger_message_id": created["message_id"]},
)
assert enqueue.status_code == 200
assert enqueue.json()["status"] == "done"
messages = telegram_client.get(
f"/integrations/telegram/threads/{created['thread_id']}/messages",
headers=admin_headers(),
)
assert messages.status_code == 200
assert messages.json()[-1]["author_type"] == "ai"
assert "tgrelaxx" in messages.json()[-1]["text"].lower()
def test_ai_enqueue_uses_language_filtered_kb_localization(monkeypatch):
monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1")
monkeypatch.setenv("AI_PROVIDER", "stub")
monkeypatch.setattr(telegram_module, "_ai_enqueue_request", lambda thread_id, trigger_message_id: None)
monkeypatch.setattr(telegram_module, "_start_telegram_reply_delivery", lambda message_id: None)
monkeypatch.setattr(ai_module, "_infer_language", lambda text: "kz")
telegram_client = TestClient(telegram_app)
interaction_client = TestClient(interaction_app)
ai_client = TestClient(ai_app)
patch_ai_internal_calls(monkeypatch, telegram_client, interaction_client)
seeded = seed_kb_article(
"Localized sharedkbx RU",
"rulocalizedanswerx",
"sharedkbx",
language="ru",
)
seed_kb_article(
"Localized sharedkbx KZ",
"kzlocalizedanswerx",
"sharedkbx",
language="kz",
article_group_id=seeded["article_group_id"],
)
created = create_inbound_thread(telegram_client, "chat_ai_kz_localized_kb", "sharedkbx")
enqueue = ai_client.post(
f"/ai/telegram/threads/{created['thread_id']}/enqueue",
headers=admin_headers(),
json={"trigger_message_id": created["message_id"]},
)
assert enqueue.status_code == 200
assert enqueue.json()["status"] == "done"
messages = telegram_client.get(
f"/integrations/telegram/threads/{created['thread_id']}/messages",
headers=admin_headers(),
)
assert messages.status_code == 200
reply_text = messages.json()[-1]["text"].lower()
assert "kzlocalizedanswerx" in reply_text
assert "rulocalizedanswerx" not in reply_text
def test_ai_enqueue_deduplicates_same_trigger_message(monkeypatch):
monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1")
monkeypatch.setenv("AI_PROVIDER", "stub")
monkeypatch.setattr(telegram_module, "_ai_enqueue_request", lambda thread_id, trigger_message_id: None)
monkeypatch.setattr(telegram_module, "_start_telegram_reply_delivery", lambda message_id: None)
telegram_client = TestClient(telegram_app)
interaction_client = TestClient(interaction_app)
ai_client = TestClient(ai_app)
patch_ai_internal_calls(monkeypatch, telegram_client, interaction_client)
seed_kb_article("FAQ handoff", "Ответ по FAQ для повторного запроса.", "faq-dedupe")
created = create_inbound_thread(telegram_client, "chat_ai_dedupe", "faq-dedupe")
first = ai_client.post(
f"/ai/telegram/threads/{created['thread_id']}/enqueue",
headers=admin_headers(),
json={"trigger_message_id": created["message_id"]},
)
assert first.status_code == 200
second = ai_client.post(
f"/ai/telegram/threads/{created['thread_id']}/enqueue",
headers=admin_headers(),
json={"trigger_message_id": created["message_id"]},
)
assert second.status_code == 200
assert second.json()["deduplicated"] is True
deliver_latest_pending_message(monkeypatch, telegram_client, created["thread_id"], external_id=7002)
session = get_session()
try:
jobs = session.execute(
select(AIJobRow).where(AIJobRow.thread_id == created["thread_id"])
).scalars().all()
assert len(jobs) == 1
assert jobs[0].trigger_message_id == created["message_id"]
finally:
session.close()
def test_ai_handoff_required_leaves_thread_available_for_claim(monkeypatch):
patch_interaction_request(monkeypatch)
monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1")
monkeypatch.setenv("AI_PROVIDER", "stub")
monkeypatch.setattr(telegram_module, "_ai_enqueue_request", lambda thread_id, trigger_message_id: None)
monkeypatch.setattr(telegram_module, "_start_telegram_reply_delivery", lambda message_id: None)
telegram_client = TestClient(telegram_app)
interaction_client = TestClient(interaction_app)
ai_client = TestClient(ai_app)
patch_ai_internal_calls(monkeypatch, telegram_client, interaction_client)
created = create_inbound_thread(telegram_client, "chat_ai_handoff", "Хочу человека, соедините с оператором")
enqueue = ai_client.post(
f"/ai/telegram/threads/{created['thread_id']}/enqueue",
headers=admin_headers(),
json={"trigger_message_id": created["message_id"]},
)
assert enqueue.status_code == 200
assert enqueue.json()["status"] == "handoff_required"
thread = telegram_client.get(
f"/integrations/telegram/threads/{created['thread_id']}",
headers=admin_headers(),
)
assert thread.status_code == 200
assert thread.json()["ai_state"] == "handoff_required"
assert thread.json()["claimed_by_user"] is None
summary = fetch_ai_summary(telegram_client, created["thread_id"])
assert summary.status_code == 200
payload = summary.json()
assert payload["thread_id"] == created["thread_id"]
assert payload["status_label"] == "AI передал без ответа"
assert payload["status_tone"] == "handoff"
assert payload["customer_request_text"] == created["text"]
assert payload["ai_outcome_text"] == "AI передал диалог оператору без ответа клиенту."
assert payload["handoff_reason"]
assert payload["recommended_next_step"] == "Заберите чат и ответьте клиенту вручную."
claimed = telegram_client.post(
f"/integrations/telegram/threads/{created['thread_id']}/claim",
headers=operator_headers("operator_summary"),
)
assert claimed.status_code == 200
assert claimed.json()["ai_state"] == "human_owned"
summary_after_claim = fetch_ai_summary(
telegram_client,
created["thread_id"],
headers=operator_headers("operator_summary"),
)
assert summary_after_claim.status_code == 200
claim_payload = summary_after_claim.json()
assert claim_payload["session_id"] == payload["session_id"]
assert claim_payload["status_label"] == payload["status_label"]
assert claim_payload["status_tone"] == payload["status_tone"]
assert claim_payload["handoff_reason"] == payload["handoff_reason"]
assert claim_payload["recommended_next_step"] == "Продолжайте диалог вручную; AI больше не отвечает в этот thread."
returned = telegram_client.post(
f"/integrations/telegram/threads/{created['thread_id']}/return-to-ai",
headers=operator_headers("operator_summary"),
)
assert returned.status_code == 200
summary_after_return = fetch_ai_summary(telegram_client, created["thread_id"])
assert summary_after_return.status_code == 200
assert summary_after_return.json() is None
session = get_session()
try:
thread = session.execute(
select(TelegramThreadRow).where(TelegramThreadRow.thread_id == created["thread_id"])
).scalar_one()
ai_session = session.execute(
select(AISessionRow).where(AISessionRow.session_id == payload["session_id"])
).scalar_one()
assert thread.ai_state == "queued"
assert thread.ai_handoff_reason is None
assert ai_session.status == "active"
assert ai_session.handoff_reason is None
finally:
session.close()
def test_select_trigger_message_ignores_non_customer_trigger():
session = get_session()
try:
now = utc_now_iso()
thread_id = new_id("tgt")
interaction_id = new_id("int")
customer_message_id = new_id("tgm")
system_message_id = new_id("tgm")
session.add(
TelegramThreadRow(
thread_id=thread_id,
chat_id="chat_select_trigger",
interaction_id=interaction_id,
telegram_user_id="tg_select_trigger",
username="select_trigger_user",
display_name="Select Trigger",
queue_id="q_telegram",
status="new",
claimed_by_user=None,
claimed_at=None,
ai_session_id=None,
ai_state="queued",
ai_handoff_reason=None,
ai_last_model_at=None,
last_message_at=now,
last_message_preview="latest",
created_at=now,
updated_at=now,
)
)
session.add(
TelegramMessageRow(
message_id=customer_message_id,
thread_id=thread_id,
interaction_id=interaction_id,
chat_id="chat_select_trigger",
text="Customer message",
customer_external_id="cus_select_trigger",
direction="inbound",
telegram_message_id_external="1001",
operator_user=None,
author_type="customer",
author_id="tg_select_trigger",
delivery_status="received",
payload_json="{}",
created_at=now,
)
)
session.add(
TelegramMessageRow(
message_id=system_message_id,
thread_id=thread_id,
interaction_id=interaction_id,
chat_id="chat_select_trigger",
text="[Unsupported Telegram content: photo]",
customer_external_id="cus_select_trigger",
direction="system",
telegram_message_id_external="1002",
operator_user=None,
author_type="system",
author_id="tg_select_trigger",
delivery_status="received",
payload_json="{}",
created_at=now,
)
)
session.commit()
selected = ai_module._select_trigger_message(
session,
thread_id=thread_id,
trigger_message_id=system_message_id,
)
assert selected is not None
assert selected.message_id == customer_message_id
finally:
session.close()
def test_ai_reply_conflict_marks_job_human_owned_instead_of_error(monkeypatch):
monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1")
monkeypatch.setenv("AI_PROVIDER", "stub")
monkeypatch.setattr(telegram_module, "_ai_enqueue_request", lambda thread_id, trigger_message_id: None)
monkeypatch.setattr(telegram_module, "_start_telegram_reply_delivery", lambda message_id: None)
patch_interaction_request(monkeypatch)
telegram_client = TestClient(telegram_app)
interaction_client = TestClient(interaction_app)
ai_client = TestClient(ai_app)
def fake_interaction_request(method: str, path: str, *, payload: dict | None = None) -> dict:
response = interaction_client.request(method, path, json=payload, headers=admin_headers())
response.raise_for_status()
return response.json()
created = create_inbound_thread(telegram_client, "chat_ai_conflict", "basic")
def fake_telegram_request(method: str, path: str, *, payload: dict | None = None) -> dict:
if path.endswith("/ai/reply"):
claimed = telegram_client.post(
f"/integrations/telegram/threads/{created['thread_id']}/claim",
headers=operator_headers("operator_race"),
)
assert claimed.status_code == 200
request = httpx.Request(method, f"http://testserver{path}", json=payload)
response = httpx.Response(
409,
request=request,
json={"detail": "Telegram thread is owned by a human operator"},
)
raise httpx.HTTPStatusError("409 Conflict", request=request, response=response)
response = telegram_client.request(method, path, json=payload, headers=admin_headers())
response.raise_for_status()
return response.json()
monkeypatch.setattr(ai_module, "_telegram_request", fake_telegram_request)
monkeypatch.setattr(ai_module, "_interaction_request", fake_interaction_request)
seed_kb_article("Basic plan", "Basic answer.", "basic")
enqueue = ai_client.post(
f"/ai/telegram/threads/{created['thread_id']}/enqueue",
headers=admin_headers(),
json={"trigger_message_id": created["message_id"]},
)
assert enqueue.status_code == 200
assert enqueue.json()["status"] == "human_owned"
session = get_session()
try:
thread = session.execute(
select(TelegramThreadRow).where(TelegramThreadRow.thread_id == created["thread_id"])
).scalar_one()
ai_session = session.execute(
select(AISessionRow).where(AISessionRow.session_id == thread.ai_session_id)
).scalar_one()
job = session.execute(
select(AIJobRow).where(AIJobRow.thread_id == created["thread_id"]).order_by(AIJobRow.id.desc())
).scalar_one()
assert thread.ai_state == "human_owned"
assert thread.claimed_by_user == "operator_race"
assert ai_session.status == "human_owned"
assert job.status == "done"
assert thread.ai_handoff_reason is None
finally:
session.close()
def test_ai_always_reply_mode_answers_greeting_without_handoff(monkeypatch):
monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1")
monkeypatch.setenv("AI_TELEGRAM_ALWAYS_REPLY", "1")
monkeypatch.setenv("AI_PROVIDER", "stub")
monkeypatch.setattr(telegram_module, "_ai_enqueue_request", lambda thread_id, trigger_message_id: None)
monkeypatch.setattr(telegram_module, "_start_telegram_reply_delivery", lambda message_id: None)
telegram_client = TestClient(telegram_app)
interaction_client = TestClient(interaction_app)
ai_client = TestClient(ai_app)
patch_ai_internal_calls(monkeypatch, telegram_client, interaction_client)
created = create_inbound_thread(telegram_client, "chat_ai_always_reply", "Привет")
enqueue = ai_client.post(
f"/ai/telegram/threads/{created['thread_id']}/enqueue",
headers=admin_headers(),
json={"trigger_message_id": created["message_id"]},
)
assert enqueue.status_code == 200
assert enqueue.json()["status"] == "done"
thread = telegram_client.get(
f"/integrations/telegram/threads/{created['thread_id']}",
headers=admin_headers(),
)
assert thread.status_code == 200
assert thread.json()["ai_state"] == "active"
assert thread.json()["claimed_by_user"] is None
messages = telegram_client.get(
f"/integrations/telegram/threads/{created['thread_id']}/messages",
headers=admin_headers(),
)
assert messages.status_code == 200
assert messages.json()[-1]["author_type"] == "ai"
assert messages.json()[-1]["text"]
summary = fetch_ai_summary(telegram_client, created["thread_id"])
assert summary.status_code == 200
assert summary.json() is None
def test_claiming_ai_thread_transfers_to_human_and_blocks_auto_replies(monkeypatch):
monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1")
monkeypatch.setenv("AI_PROVIDER", "stub")
enqueue_calls: list[tuple[str, str | None]] = []
monkeypatch.setattr(telegram_module, "_ai_enqueue_request", lambda thread_id, trigger_message_id: enqueue_calls.append((thread_id, trigger_message_id)))
monkeypatch.setattr(telegram_module, "_start_telegram_reply_delivery", lambda message_id: None)
patch_interaction_request(monkeypatch)
telegram_client = TestClient(telegram_app)
interaction_client = TestClient(interaction_app)
ai_client = TestClient(ai_app)
patch_ai_internal_calls(monkeypatch, telegram_client, interaction_client)
seed_kb_article("Takeover FAQ", "FAQ answer before human takeover.", "takeover")
created = create_inbound_thread(telegram_client, "chat_ai_takeover", "takeover")
enqueue_calls.clear()
ai_client.post(
f"/ai/telegram/threads/{created['thread_id']}/enqueue",
headers=admin_headers(),
json={"trigger_message_id": created["message_id"]},
)
deliver_latest_pending_message(monkeypatch, telegram_client, created["thread_id"], external_id=7003)
claimed = telegram_client.post(
f"/integrations/telegram/threads/{created['thread_id']}/claim",
headers=operator_headers("operator_ai"),
)
assert claimed.status_code == 200
assert claimed.json()["ai_state"] == "human_owned"
enqueue_calls.clear()
follow_up = telegram_client.post(
"/integrations/telegram/webhook",
json={
"chat_id": "chat_ai_takeover",
"text": "ещё вопрос после takeover",
"payload": {"telegram_user_id": "user-chat_ai_takeover", "username": "takeover_user"},
},
)
assert follow_up.status_code == 200
time.sleep(0.05)
assert enqueue_calls == []
session = get_session()
try:
thread = session.execute(
select(TelegramThreadRow).where(TelegramThreadRow.thread_id == created["thread_id"])
).scalar_one()
ai_session = session.execute(
select(AISessionRow).where(AISessionRow.session_id == thread.ai_session_id)
).scalar_one()
assert thread.ai_state == "human_owned"
assert ai_session.status == "human_owned"
finally:
session.close()
def test_ai_summary_is_hidden_after_close(monkeypatch):
patch_interaction_request(monkeypatch)
monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1")
monkeypatch.setenv("AI_PROVIDER", "stub")
monkeypatch.setattr(telegram_module, "_ai_enqueue_request", lambda thread_id, trigger_message_id: None)
monkeypatch.setattr(telegram_module, "_start_telegram_reply_delivery", lambda message_id: None)
telegram_client = TestClient(telegram_app)
interaction_client = TestClient(interaction_app)
ai_client = TestClient(ai_app)
patch_ai_internal_calls(monkeypatch, telegram_client, interaction_client)
created = create_inbound_thread(telegram_client, "chat_ai_summary_close", "Хочу поговорить с человеком")
enqueue = ai_client.post(
f"/ai/telegram/threads/{created['thread_id']}/enqueue",
headers=admin_headers(),
json={"trigger_message_id": created["message_id"]},
)
assert enqueue.status_code == 200
assert enqueue.json()["status"] == "handoff_required"
claimed = telegram_client.post(
f"/integrations/telegram/threads/{created['thread_id']}/claim",
headers=operator_headers("operator_close_summary"),
)
assert claimed.status_code == 200
closed = telegram_client.post(
f"/integrations/telegram/threads/{created['thread_id']}/close",
headers=operator_headers("operator_close_summary"),
)
assert closed.status_code == 200
summary = fetch_ai_summary(telegram_client, created["thread_id"])
assert summary.status_code == 200
assert summary.json() is None
def test_ai_summary_marks_answered_when_ai_replied_before_handoff(monkeypatch):
patch_interaction_request(monkeypatch)
monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1")
monkeypatch.setenv("AI_TELEGRAM_ALWAYS_REPLY", "1")
monkeypatch.setenv("AI_PROVIDER", "stub")
monkeypatch.setattr(telegram_module, "_ai_enqueue_request", lambda thread_id, trigger_message_id: None)
monkeypatch.setattr(telegram_module, "_start_telegram_reply_delivery", lambda message_id: None)
telegram_client = TestClient(telegram_app)
interaction_client = TestClient(interaction_app)
ai_client = TestClient(ai_app)
patch_ai_internal_calls(monkeypatch, telegram_client, interaction_client)
created = create_inbound_thread(telegram_client, "chat_ai_summary_answered", "Привет")
enqueue = ai_client.post(
f"/ai/telegram/threads/{created['thread_id']}/enqueue",
headers=admin_headers(),
json={"trigger_message_id": created["message_id"]},
)
assert enqueue.status_code == 200
assert enqueue.json()["status"] == "done"
handoff = telegram_client.post(
f"/integrations/telegram/threads/{created['thread_id']}/ai/handoff",
headers=admin_headers(),
json={
"reason": "Нужен оператор после автоответа.",
"agent_profile": "telegram_support",
"trigger_message_id": created["message_id"],
"payload": {},
},
)
assert handoff.status_code == 200
summary = fetch_ai_summary(telegram_client, created["thread_id"])
assert summary.status_code == 200
payload = summary.json()
assert payload["status_label"] == "AI ответил клиенту"
assert payload["status_tone"] == "answered"
assert payload["ai_outcome_text"]
def test_ai_analytics_overview_aggregates_mixed_telegram_and_whatsapp_sessions():
marker = f"aiov_{new_id('seed')}"
seeded = seed_ai_analytics_dataset(marker)
ai_client = TestClient(ai_app)
try:
response = ai_client.get(
"/ai/analytics/overview",
params={
"from_ts": seeded["from_ts"],
"to_ts": seeded["to_ts"],
"channel": "all",
},
headers=admin_headers(),
)
assert response.status_code == 200
payload = response.json()
assert payload["totals"]["sessions_started"] == 4
assert payload["totals"]["sessions_contained"] == 2
assert payload["totals"]["sessions_handoff"] == 1
assert payload["totals"]["sessions_closed"] == 3
assert payload["totals"]["sessions_closed_without_operator"] == 2
assert payload["totals"]["assistant_turns"] == 4
assert payload["metrics"]["containment_rate"] == 50.0
assert payload["metrics"]["handoff_rate"] == 25.0
assert payload["metrics"]["ai_latency_avg_ms"] == 400.0
assert payload["metrics"]["ai_latency_p95_ms"] == 900.0
assert payload["metrics"]["closed_without_operator_rate"] == 66.67
assert payload["metrics"]["human_touched_rate"] == 50.0
assert payload["coverage"]["sessions_with_interaction_id"] == 4
assert payload["coverage"]["sessions_with_queue_id"] == 4
assert payload["coverage"]["sessions_with_latency_turns"] == 3
assert payload["coverage"]["sessions_with_terminal_state"] == 4
assert payload["coverage"]["sessions_with_handoff_reason"] == 1
by_channel = {item["channel"]: item for item in payload["breakdowns"]["by_channel"]}
assert by_channel["telegram"]["sessions_started"] == 2
assert by_channel["telegram"]["containment_rate"] == 50.0
assert by_channel["telegram"]["human_touched_sessions"] == 1
assert by_channel["telegram"]["ai_latency_avg_ms"] == 150.0
assert by_channel["whatsapp"]["sessions_started"] == 2
assert by_channel["whatsapp"]["handoff_rate"] == 50.0
assert by_channel["whatsapp"]["closed_without_operator_rate"] == 100.0
assert by_channel["whatsapp"]["ai_latency_avg_ms"] == 650.0
by_outcome = {item["outcome"]: item for item in payload["breakdowns"]["by_outcome"]}
assert by_outcome["contained"]["sessions"] == 2
assert by_outcome["handoff"]["sessions"] == 1
assert by_outcome["human_touched"]["sessions"] == 1
assert by_outcome["closed_without_operator"]["sessions"] == 2
by_reason = {item["reason_key"]: item for item in payload["breakdowns"]["by_handoff_reason"]}
assert by_reason["requested_human"]["sessions"] == 1
assert by_reason["requested_human"]["label"]
finally:
cleanup_ai_analytics_dataset(marker)
def test_ai_analytics_overview_filters_by_queue_and_channel_and_handles_unsupported_channels():
marker = f"aiflt_{new_id('seed')}"
seeded = seed_ai_analytics_dataset(marker)
ai_client = TestClient(ai_app)
try:
queue_filtered = ai_client.get(
"/ai/analytics/overview",
params={
"from_ts": seeded["from_ts"],
"to_ts": seeded["to_ts"],
"queue_id": seeded["queue_thread"],
"channel": "whatsapp",
},
headers=admin_headers(),
)
assert queue_filtered.status_code == 200
queue_payload = queue_filtered.json()
assert queue_payload["totals"]["sessions_started"] == 1
assert queue_payload["totals"]["sessions_contained"] == 1
assert queue_payload["coverage"]["sessions_with_queue_id"] == 1
assert queue_payload["breakdowns"]["by_channel"][0]["channel"] == "whatsapp"
telegram_only = ai_client.get(
"/ai/analytics/overview",
params={
"from_ts": seeded["from_ts"],
"to_ts": seeded["to_ts"],
"channel": "telegram",
"queue_id": seeded["queue_tg"],
},
headers=admin_headers(),
)
assert telegram_only.status_code == 200
assert telegram_only.json()["totals"]["sessions_started"] == 2
unsupported = ai_client.get(
"/ai/analytics/overview",
params={
"from_ts": seeded["from_ts"],
"to_ts": seeded["to_ts"],
"channel": "voice",
},
headers=admin_headers(),
)
assert unsupported.status_code == 200
unsupported_payload = unsupported.json()
assert unsupported_payload["totals"]["sessions_started"] == 0
assert unsupported_payload["breakdowns"]["by_channel"] == []
finally:
cleanup_ai_analytics_dataset(marker)
def test_ai_analytics_timeseries_returns_stable_points_and_ignores_non_model_turns():
marker = f"aits_{new_id('seed')}"
seeded = seed_ai_analytics_dataset(marker)
ai_client = TestClient(ai_app)
try:
response = ai_client.get(
"/ai/analytics/timeseries",
params={
"from_ts": seeded["from_ts"],
"to_ts": seeded["to_ts"],
"metric": "ai_latency_avg_ms",
"interval": "day",
"channel": "whatsapp",
"queue_id": seeded["queue_thread"],
},
headers=admin_headers(),
)
assert response.status_code == 200
payload = response.json()
assert payload["metric"] == "ai_latency_avg_ms"
assert payload["interval"] == "day"
assert len(payload["points"]) == 2
assert payload["points"][0]["value"] is None
assert payload["points"][0]["sessions"] == 0
assert payload["points"][0]["assistant_turns"] == 0
assert payload["points"][1]["value"] == 400.0
assert payload["points"][1]["sessions"] == 1
assert payload["points"][1]["assistant_turns"] == 1
latency_response = ai_client.get(
"/ai/analytics/timeseries",
params={
"from_ts": seeded["from_ts"],
"to_ts": seeded["to_ts"],
"metric": "ai_latency_avg_ms",
"interval": "day",
"channel": "voice",
},
headers=admin_headers(),
)
assert latency_response.status_code == 200
latency_payload = latency_response.json()
assert latency_payload["points"] == []
human_touched = ai_client.get(
"/ai/analytics/timeseries",
params={
"from_ts": seeded["from_ts"],
"to_ts": seeded["to_ts"],
"metric": "human_touched_rate",
"interval": "day",
"channel": "all",
},
headers=admin_headers(),
)
assert human_touched.status_code == 200
human_touched_payload = human_touched.json()
assert human_touched_payload["metric"] == "human_touched_rate"
assert [point["value"] for point in human_touched_payload["points"]] == [50.0, 50.0]
finally:
cleanup_ai_analytics_dataset(marker)
def test_ai_analytics_drilldown_filters_by_slice_reason_status_queue_channel_and_query():
marker = f"aidd_{new_id('seed')}"
seeded = seed_ai_analytics_dataset(marker)
ai_client = TestClient(ai_app)
try:
handoff = ai_client.get(
"/ai/analytics/drilldown",
params={
"from_ts": seeded["from_ts"],
"to_ts": seeded["to_ts"],
"slice": "handoff",
"reason_key": "requested_human",
"status": "human_owned",
"channel": "whatsapp",
"queue_id": seeded["queue_wa"],
"q": f"{marker}_sess_wa",
"sort_by": "updated_at",
"sort_dir": "desc",
},
headers=admin_headers(),
)
assert handoff.status_code == 200
payload = handoff.json()
assert payload["total"] == 1
assert payload["filters"]["slice"] == "handoff"
assert payload["filters"]["reason_key"] == "requested_human"
assert payload["filters"]["status"] == "human_owned"
assert payload["filters"]["q"] == f"{marker}_sess_wa"
assert payload["filters"]["sort_by"] == "updated_at"
assert payload["filters"]["sort_dir"] == "desc"
assert payload["items"][0]["session_id"] == f"{marker}_sess_wa"
assert payload["items"][0]["reason_key"] == "requested_human"
assert payload["items"][0]["status"] == "human_owned"
all_sorted = ai_client.get(
"/ai/analytics/drilldown",
params={
"from_ts": seeded["from_ts"],
"to_ts": seeded["to_ts"],
"slice": "all",
"sort_by": "ai_latency_avg_ms",
"sort_dir": "desc",
},
headers=admin_headers(),
)
assert all_sorted.status_code == 200
sorted_payload = all_sorted.json()
assert sorted_payload["items"][0]["session_id"] == f"{marker}_sess_wa"
assert sorted_payload["items"][0]["ai_latency_avg_ms"] == 900.0
finally:
cleanup_ai_analytics_dataset(marker)
def test_ai_analytics_session_detail_is_metadata_only_and_reason_taxonomy_supports_manual_claim():
marker = f"aidetail_{new_id('seed')}"
seeded = seed_ai_analytics_dataset(marker)
ai_client = TestClient(ai_app)
session = get_session()
try:
thread = session.execute(
select(TelegramThreadRow).where(TelegramThreadRow.thread_id == f"{marker}_thread_tg_human")
).scalar_one()
thread.claimed_by_user = "manual_takeover"
thread.ai_handoff_reason = None
session.commit()
finally:
session.close()
try:
manual_claim = ai_client.get(
"/ai/analytics/drilldown",
params={
"from_ts": seeded["from_ts"],
"to_ts": seeded["to_ts"],
"slice": "human_touched",
"q": f"{marker}_sess_tg_human",
},
headers=admin_headers(),
)
assert manual_claim.status_code == 200
manual_payload = manual_claim.json()
assert manual_payload["total"] == 1
assert manual_payload["items"][0]["reason_key"] == "manual_claim"
assert manual_payload["items"][0]["reason_label"]
detail = ai_client.get(
f"/ai/analytics/sessions/{marker}_sess_wa",
headers=admin_headers(),
)
assert detail.status_code == 200
detail_payload = detail.json()
assert detail_payload["session"]["session_id"] == f"{marker}_sess_wa"
assert detail_payload["session"]["reason_key"] == "requested_human"
assert detail_payload["interaction"]["interaction_id"] == f"{marker}_int_wa"
assert "summary_text" not in detail_payload["session"]
assert detail_payload["timeline"]
assert all("text" not in event for event in detail_payload["timeline"])
assert all("summary_text" not in event for event in detail_payload["timeline"])
finally:
cleanup_ai_analytics_dataset(marker)
def test_voice_name_flow_overview_aggregates_start_downstream_handoff_and_manual_overlay():
marker = f"vname_{new_id('seed')}"
seeded = seed_voice_name_flow_analytics_dataset(marker)
ai_client = TestClient(ai_app)
try:
response = ai_client.get(
"/ai/analytics/voice-name-flow/overview",
params={
"from_ts": seeded["from_ts"],
"to_ts": seeded["to_ts"],
},
headers=admin_headers(),
)
assert response.status_code == 200
payload = response.json()
assert payload["totals"]["scenario_calls"] == 5
assert payload["totals"]["start_obtained"] == 1
assert payload["totals"]["downstream_ai_obtained"] == 1
assert payload["totals"]["followup_required"] == 1
assert payload["totals"]["name_not_obtained"] == 2
assert payload["totals"]["manual_corrected"] == 1
assert payload["totals"]["handoff_confirmed_name"] == 1
assert payload["totals"]["handoff_unconfirmed_name"] == 2
assert payload["totals"]["needed_downstream"] == 4
assert payload["metrics"]["start_capture_rate"] == 20.0
assert payload["metrics"]["downstream_rescue_rate"] == 25.0
assert payload["metrics"]["handoff_unconfirmed_rate"] == 66.67
assert payload["metrics"]["manual_correction_rate"] == 33.33
funnel = {item["stage"]: item for item in payload["breakdowns"]["funnel"]}
assert funnel["scenario_calls"]["sessions"] == 5
assert funnel["start_obtained"]["sessions"] == 1
assert funnel["needed_downstream"]["sessions"] == 4
assert funnel["downstream_ai_obtained"]["sessions"] == 1
assert funnel["handoff_confirmed_name"]["sessions"] == 1
assert funnel["handoff_unconfirmed_name"]["sessions"] == 2
by_language = {item["language"]: item for item in payload["breakdowns"]["by_language"]}
assert by_language["ru"]["scenario_calls"] == 3
assert by_language["kz"]["downstream_ai_obtained"] == 1
assert by_language["unknown"]["followup_required"] == 1
by_queue = {item["queue_id"]: item for item in payload["breakdowns"]["by_queue"]}
assert by_queue[seeded["queue_ru"]]["scenario_calls"] == 3
assert by_queue[seeded["queue_kz"]]["downstream_ai_obtained"] == 1
assert by_queue[seeded["queue_support"]]["handoff_unconfirmed_name"] == 1
handoff = {item["outcome"]: item for item in payload["breakdowns"]["handoff"]}
assert handoff["confirmed_name"]["sessions"] == 1
assert handoff["unconfirmed_name"]["sessions"] == 2
assert payload["coverage"]["sessions_with_start_decision"] == 5
assert payload["coverage"]["sessions_with_final_ai_state"] == 5
assert payload["coverage"]["sessions_with_manual_overlay"] == 1
assert payload["coverage"]["note"]
finally:
cleanup_voice_name_flow_analytics_dataset(marker)
def test_voice_name_flow_overview_filters_by_queue_and_language_with_fallbacks():
marker = f"vnameflt_{new_id('seed')}"
seeded = seed_voice_name_flow_analytics_dataset(marker)
ai_client = TestClient(ai_app)
try:
queue_filtered = ai_client.get(
"/ai/analytics/voice-name-flow/overview",
params={
"from_ts": seeded["from_ts"],
"to_ts": seeded["to_ts"],
"queue_id": seeded["queue_kz"],
},
headers=admin_headers(),
)
assert queue_filtered.status_code == 200
queue_payload = queue_filtered.json()
assert queue_payload["totals"]["scenario_calls"] == 1
assert queue_payload["totals"]["downstream_ai_obtained"] == 1
assert queue_payload["breakdowns"]["by_queue"][0]["queue_id"] == seeded["queue_kz"]
language_filtered = ai_client.get(
"/ai/analytics/voice-name-flow/overview",
params={
"from_ts": seeded["from_ts"],
"to_ts": seeded["to_ts"],
"language": "unknown",
},
headers=admin_headers(),
)
assert language_filtered.status_code == 200
language_payload = language_filtered.json()
assert language_payload["totals"]["scenario_calls"] == 1
assert language_payload["totals"]["followup_required"] == 1
assert language_payload["breakdowns"]["by_language"][0]["language"] == "unknown"
finally:
cleanup_voice_name_flow_analytics_dataset(marker)
def test_voice_name_flow_timeseries_returns_stable_points_for_all_metrics():
marker = f"vnamets_{new_id('seed')}"
seeded = seed_voice_name_flow_analytics_dataset(marker)
ai_client = TestClient(ai_app)
expected_values = {
"scenario_calls": [2.0, 3.0],
"start_capture_rate": [50.0, 0.0],
"downstream_rescue_rate": [100.0, 0.0],
"handoff_unconfirmed_rate": [0.0, 100.0],
"manual_correction_rate": [0.0, 50.0],
}
try:
for metric, expected in expected_values.items():
response = ai_client.get(
"/ai/analytics/voice-name-flow/timeseries",
params={
"from_ts": seeded["from_ts"],
"to_ts": seeded["to_ts"],
"metric": metric,
},
headers=admin_headers(),
)
assert response.status_code == 200
payload = response.json()
assert payload["metric"] == metric
assert payload["interval"] == "day"
assert [point["value"] for point in payload["points"]] == expected
finally:
cleanup_voice_name_flow_analytics_dataset(marker)
def test_voice_name_collection_config_get_returns_defaults_when_not_persisted():
ai_client = TestClient(ai_app)
response = ai_client.get("/ai/voice/config/name-collection", headers=admin_headers())
assert response.status_code == 200
payload = response.json()
assert payload["source"] == "defaults"
assert payload["updated_at"] is None
assert payload["config"]["enabled"] is True
assert payload["config"]["start"]["known_customer_behavior"] == "trust_and_handoff"
assert payload["config"]["downstream"]["missing_name_behavior"] == "ask_inline_once"
assert "{name}" in payload["config"]["texts"]["ru"]["personalized_greeting_template"]
assert "{name}" in payload["config"]["texts"]["kz"]["confirmation_greeting_template"]
def test_voice_name_collection_config_put_persists_custom_payload():
ai_client = TestClient(ai_app)
payload = voice_config_module.voice_name_collection_default_config().model_dump()
payload["enabled"] = False
payload["start"]["known_customer_behavior"] = "confirm_in_downstream"
payload["texts"]["ru"]["start_prompt"] = "Представьтесь, пожалуйста."
response = ai_client.put(
"/ai/voice/config/name-collection",
headers=admin_headers(),
json=payload,
)
assert response.status_code == 200
saved = response.json()
assert saved["source"] == "database"
assert saved["updated_at"]
assert saved["config"]["enabled"] is False
assert saved["config"]["start"]["known_customer_behavior"] == "confirm_in_downstream"
assert saved["config"]["texts"]["ru"]["start_prompt"] == "Представьтесь, пожалуйста."
session = get_session()
try:
row = session.execute(select(VoiceNameCollectionSettingsRow)).scalar_one()
assert "Представьтесь, пожалуйста." in row.config_json
finally:
session.close()
def test_voice_name_collection_config_put_rejects_invalid_name_template():
ai_client = TestClient(ai_app)
payload = voice_config_module.voice_name_collection_default_config().model_dump()
payload["texts"]["ru"]["confirmation_greeting_template"] = "Подтвердите имя клиента."
response = ai_client.put(
"/ai/voice/config/name-collection",
headers=admin_headers(),
json=payload,
)
assert response.status_code == 422
def test_voice_tts_config_get_returns_defaults_when_not_persisted():
ai_client = TestClient(ai_app)
response = ai_client.get("/ai/voice/config/tts", headers=admin_headers())
assert response.status_code == 200
payload = response.json()
assert payload["source"] == "defaults"
assert payload["updated_at"] is None
assert payload["config"]["provider"] == "yandex"
assert "elevenlabs" in payload["provider_options"]
assert payload["voice_options"]["elevenlabs"]["ru"][0]["label"] == "Brian"
def test_voice_tts_config_put_persists_custom_payload():
ai_client = TestClient(ai_app)
payload = voice_tts_config_module.voice_tts_default_config().model_dump()
payload["provider"] = "elevenlabs"
payload["elevenlabs"]["ru"]["voice"] = "nPczCjzI2devNBz1zQrb"
payload["elevenlabs"]["ru"]["model_id"] = "eleven_v3"
payload["elevenlabs"]["kz"]["language_code"] = "kk"
response = ai_client.put(
"/ai/voice/config/tts",
headers=admin_headers(),
json=payload,
)
assert response.status_code == 200
saved = response.json()
assert saved["source"] == "database"
assert saved["config"]["provider"] == "elevenlabs"
assert saved["config"]["elevenlabs"]["ru"]["voice"] == "nPczCjzI2devNBz1zQrb"
assert saved["config"]["elevenlabs"]["ru"]["model_id"] == "eleven_v3"
session = get_session()
try:
row = session.execute(select(VoiceTTSSettingsRow)).scalar_one()
assert "elevenlabs" in row.config_json
assert "nPczCjzI2devNBz1zQrb" in row.config_json
finally:
session.close()
def test_ai_operator_config_get_returns_ainur_defaults():
ai_client = TestClient(ai_app)
response = ai_client.get("/ai/operator/config", headers=admin_headers())
assert response.status_code == 200
payload = response.json()
assert payload["source"] == "defaults"
assert payload["updated_at"] is None
assert payload["config"]["agent_name"] == "Айнур"
assert "Айнур" in payload["config"]["base_system_prompt"]
assert "Айнур" in payload["config"]["identity_reply_ru"]
def test_ai_operator_config_put_persists_custom_prompt():
ai_client = TestClient(ai_app)
payload = ai_operator_config_module.ai_operator_default_config().model_dump()
payload["company_name"] = "Kazakhtelecom"
payload["base_system_prompt"] = "Ты {agent_name}, единый оператор {company_name}."
payload["identity_reply_ru"] = {agent_name}, оператор {company_name}."
response = ai_client.put("/ai/operator/config", headers=admin_headers(), json=payload)
assert response.status_code == 200
saved = response.json()
assert saved["source"] == "database"
assert saved["config"]["company_name"] == "Kazakhtelecom"
assert saved["config"]["base_system_prompt"] == "Ты {agent_name}, единый оператор {company_name}."
session = get_session()
try:
row = session.execute(select(AIOperatorSettingsRow)).scalar_one()
assert "Kazakhtelecom" in row.config_json
finally:
session.close()
def test_identity_question_uses_same_ainur_reply_without_handoff():
operator_config = ai_operator_config_module.ai_operator_default_config()
decision = ai_module._decide_reply(
customer=None,
interaction=SimpleNamespace(),
thread=SimpleNamespace(),
messages=[SimpleNamespace(author_type="customer", text="Скажи мне, кто ты такой")],
kb_results=[],
language="ru",
operator_config=operator_config,
)
assert decision["intent"] == "identity_question"
assert "Айнур" in decision["reply_text"]
assert decision["needs_handoff"] is False
assert decision["_model"] == "operator_identity_policy"
def test_voice_start_disabled_hands_off_without_prompt():
session = get_session()
try:
config = voice_config_module.voice_name_collection_default_config().model_dump()
config["enabled"] = False
voice_config_module.save_voice_name_collection_config(session, config)
finally:
session.close()
seeded = seed_voice_start_session(marker=f"voice_start_disabled_{new_id('seed')}")
started = voice_module.start_voice_session(
seeded["session_id"],
VoiceAIStartIn(
voice_session_id=seeded["session_id"],
call_id=seeded["call_id"],
interaction_id=seeded["interaction_id"],
customer_id=None,
language_hint="ru",
agent_profile="voice_start",
metadata={
"stage": "voice_start",
"next_queue_code": seeded["next_queue_code"],
"next_queue_id": seeded["next_queue_id"],
},
),
)
assert started.needs_handoff is True
assert started.greeting_text == ""
assert started.metadata["customer_name_status"] == "name_not_obtained"
assert started.metadata["customer_name_value"] is None
def test_voice_start_known_customer_can_require_downstream_confirmation():
session = get_session()
try:
config = voice_config_module.voice_name_collection_default_config().model_dump()
config["start"]["known_customer_behavior"] = "confirm_in_downstream"
voice_config_module.save_voice_name_collection_config(session, config)
finally:
session.close()
seeded = seed_voice_start_session(
marker=f"voice_start_known_{new_id('seed')}",
customer_display_name="Айдос",
caller_name="Текущий caller",
)
started = voice_module.start_voice_session(
seeded["session_id"],
VoiceAIStartIn(
voice_session_id=seeded["session_id"],
call_id=seeded["call_id"],
interaction_id=seeded["interaction_id"],
customer_id=seeded["customer_id"],
language_hint="ru",
agent_profile="voice_start",
metadata={
"stage": "voice_start",
"next_queue_code": seeded["next_queue_code"],
"next_queue_id": seeded["next_queue_id"],
},
),
)
assert started.needs_handoff is True
assert started.start_result.customer_name_status == "name_followup_required"
assert started.start_result.customer_name_value == "Айдос"
assert started.start_result.customer_name_source == "known_customer"
def test_voice_start_unknown_customer_can_skip_start_prompt():
session = get_session()
try:
config = voice_config_module.voice_name_collection_default_config().model_dump()
config["start"]["unknown_customer_behavior"] = "skip_to_downstream"
voice_config_module.save_voice_name_collection_config(session, config)
finally:
session.close()
seeded = seed_voice_start_session(marker=f"voice_start_skip_{new_id('seed')}")
started = voice_module.start_voice_session(
seeded["session_id"],
VoiceAIStartIn(
voice_session_id=seeded["session_id"],
call_id=seeded["call_id"],
interaction_id=seeded["interaction_id"],
customer_id=None,
language_hint="ru",
agent_profile="voice_start",
metadata={
"stage": "voice_start",
"next_queue_code": seeded["next_queue_code"],
"next_queue_id": seeded["next_queue_id"],
},
),
)
assert started.needs_handoff is True
assert started.greeting_text == ""
assert started.start_result.customer_name_status == "name_not_obtained"
def test_downstream_voice_turn_can_disable_inline_name_followup():
session = get_session()
try:
config = voice_config_module.voice_name_collection_default_config().model_dump()
config["downstream"]["missing_name_behavior"] = "do_not_ask"
voice_config_module.save_voice_name_collection_config(session, config)
finally:
session.close()
seeded = seed_voice_downstream_session(
marker=f"voice_no_inline_{new_id('seed')}",
name_status="name_not_obtained",
customer_display_name="+77010009999",
)
decision = voice_module.turn_voice_session(
seeded["session_id"],
VoiceAITurnIn(
voice_session_id=seeded["session_id"],
call_id=seeded["call_id"],
interaction_id=seeded["interaction_id"],
transcript_text="Хочу узнать график работы",
language="ru",
sequence_no=1,
metadata={"voice_start_language": "ru"},
),
)
assert decision.metadata["customer_name_status"] == "name_not_obtained"
assert "как мне к вам обращаться" not in decision.reply_text.lower()
def test_downstream_voice_start_can_finalize_uncertain_name_immediately():
session = get_session()
try:
config = voice_config_module.voice_name_collection_default_config().model_dump()
config["downstream"]["uncertain_name_behavior"] = "finalize_immediately"
voice_config_module.save_voice_name_collection_config(session, config)
finally:
session.close()
seeded = seed_voice_downstream_session(
marker=f"voice_finalize_now_{new_id('seed')}",
name_status="name_followup_required",
name_value="Айдос",
name_source="voice_start",
customer_display_name="+77010009999",
)
started = voice_module.start_voice_session(
seeded["session_id"],
VoiceAIStartIn(
voice_session_id=seeded["session_id"],
call_id=seeded["call_id"],
interaction_id=seeded["interaction_id"],
customer_id=seeded["customer_id"],
language_hint="ru",
agent_profile="voice_support",
metadata={"voice_start_language": "ru"},
),
)
assert started.metadata["customer_name_status"] == "name_obtained"
assert started.metadata["customer_name_value"] == "Айдос"
assert "Айдос" in started.greeting_text
def test_downstream_voice_start_can_discard_uncertain_name_candidate():
session = get_session()
try:
config = voice_config_module.voice_name_collection_default_config().model_dump()
config["downstream"]["uncertain_name_behavior"] = "discard_and_collect"
voice_config_module.save_voice_name_collection_config(session, config)
finally:
session.close()
seeded = seed_voice_downstream_session(
marker=f"voice_discard_name_{new_id('seed')}",
name_status="name_followup_required",
name_value="Айдос",
name_source="voice_start",
customer_display_name="+77010009999",
)
started = voice_module.start_voice_session(
seeded["session_id"],
VoiceAIStartIn(
voice_session_id=seeded["session_id"],
call_id=seeded["call_id"],
interaction_id=seeded["interaction_id"],
customer_id=seeded["customer_id"],
language_hint="ru",
agent_profile="voice_support",
metadata={"voice_start_language": "ru"},
),
)
assert started.metadata["customer_name_status"] == "name_not_obtained"
assert started.metadata["customer_name_value"] is None
assert "Айдос" not in started.greeting_text
def test_custom_voice_name_texts_are_used_in_start_and_followup():
session = get_session()
try:
config = voice_config_module.voice_name_collection_default_config().model_dump()
config["texts"]["ru"]["start_prompt"] = "Представьтесь, пожалуйста."
config["texts"]["ru"]["inline_followup_prompt"] = "Как к вам обращаться сейчас?"
config["texts"]["ru"]["personalized_greeting_template"] = "Здравствуйте, {name}. Чем помочь дальше?"
config["texts"]["ru"]["confirmation_greeting_template"] = "Правильно понял, вас зовут {name}? Чем помочь?"
voice_config_module.save_voice_name_collection_config(session, config)
finally:
session.close()
start_seed = seed_voice_start_session(marker=f"voice_custom_start_{new_id('seed')}")
started = voice_module.start_voice_session(
start_seed["session_id"],
VoiceAIStartIn(
voice_session_id=start_seed["session_id"],
call_id=start_seed["call_id"],
interaction_id=start_seed["interaction_id"],
customer_id=None,
language_hint="ru",
agent_profile="voice_start",
metadata={
"stage": "voice_start",
"next_queue_code": start_seed["next_queue_code"],
"next_queue_id": start_seed["next_queue_id"],
},
),
)
assert started.greeting_text == "Представьтесь, пожалуйста."
downstream_seed = seed_voice_downstream_session(
marker=f"voice_custom_followup_{new_id('seed')}",
name_status="name_not_obtained",
customer_display_name="+77010009999",
)
decision = voice_module.turn_voice_session(
downstream_seed["session_id"],
VoiceAITurnIn(
voice_session_id=downstream_seed["session_id"],
call_id=downstream_seed["call_id"],
interaction_id=downstream_seed["interaction_id"],
transcript_text="Хочу узнать график работы",
language="ru",
sequence_no=1,
metadata={"voice_start_language": "ru"},
),
)
assert "как к вам обращаться сейчас" not in decision.reply_text.lower()
assert "город" in decision.reply_text.lower()
def test_downstream_voice_start_personalizes_greeting_and_finalizes_confirmed_name():
seeded = seed_voice_downstream_session(
marker=f"voice_start_{new_id('seed')}",
name_status="name_obtained",
name_value="айдос",
name_source="voice_start",
customer_display_name="+77010009999",
)
started = voice_module.start_voice_session(
seeded["session_id"],
VoiceAIStartIn(
voice_session_id=seeded["session_id"],
call_id=seeded["call_id"],
interaction_id=seeded["interaction_id"],
customer_id=seeded["customer_id"],
language_hint="ru",
agent_profile="voice_support",
metadata={"voice_start_language": "ru"},
),
)
assert "Айдос" in started.greeting_text
assert "как мне к вам обращаться" not in started.greeting_text.lower()
assert started.metadata["customer_name_status"] == "name_obtained"
assert started.metadata["customer_name_value"] == "Айдос"
session = get_session()
try:
customer = session.execute(
select(Customer).where(Customer.customer_id == seeded["customer_id"])
).scalar_one()
identity = session.execute(
select(CustomerExternalIdentity).where(
CustomerExternalIdentity.customer_id == seeded["customer_id"],
CustomerExternalIdentity.channel == "voice",
)
).scalar_one()
voice_session = session.execute(
select(VoiceAISessionRow).where(VoiceAISessionRow.session_id == seeded["session_id"])
).scalar_one()
assert customer.display_name == "Айдос"
assert identity.display_name_snapshot == "Айдос"
assert voice_session.customer_name_value == "Айдос"
finally:
session.close()
def test_downstream_voice_turn_adds_inline_name_followup_then_finalizes_provided_name():
seeded = seed_voice_downstream_session(
marker=f"voice_inline_{new_id('seed')}",
name_status="name_not_obtained",
customer_display_name="+77010009999",
)
first_turn = voice_module.turn_voice_session(
seeded["session_id"],
VoiceAITurnIn(
voice_session_id=seeded["session_id"],
call_id=seeded["call_id"],
interaction_id=seeded["interaction_id"],
transcript_text="Хочу узнать график работы",
language="ru",
sequence_no=1,
metadata={"voice_start_language": "ru"},
),
)
assert first_turn.metadata["customer_name_status"] == "name_not_obtained"
assert "как мне к вам обращаться" not in first_turn.reply_text.lower()
assert "город" in first_turn.reply_text.lower()
second_turn = voice_module.turn_voice_session(
seeded["session_id"],
VoiceAITurnIn(
voice_session_id=seeded["session_id"],
call_id=seeded["call_id"],
interaction_id=seeded["interaction_id"],
transcript_text="Меня зовут Айдос, хочу узнать график работы",
language="ru",
sequence_no=2,
metadata={"voice_start_language": "ru"},
),
)
assert second_turn.metadata["customer_name_status"] == "name_obtained"
assert second_turn.metadata["customer_name_value"] == "Айдос"
assert second_turn.metadata["customer_name_source"] == "voice_followup"
assert "Айдос" in second_turn.reply_text
session = get_session()
try:
customer = session.execute(
select(Customer).where(Customer.customer_id == seeded["customer_id"])
).scalar_one()
voice_session = session.execute(
select(VoiceAISessionRow).where(VoiceAISessionRow.session_id == seeded["session_id"])
).scalar_one()
assert customer.display_name == "Айдос"
assert voice_session.customer_name_status == "name_obtained"
assert voice_session.customer_name_source == "voice_followup"
assert voice_session.customer_name_value == "Айдос"
finally:
session.close()
def test_downstream_voice_turn_extracts_explicit_name_without_restarting_name_flow():
seeded = seed_voice_downstream_session(
marker=f"voice_explicit_name_intent_{new_id('seed')}",
name_status="name_not_obtained",
customer_display_name="+77010009999",
)
turn = voice_module.turn_voice_session(
seeded["session_id"],
VoiceAITurnIn(
voice_session_id=seeded["session_id"],
call_id=seeded["call_id"],
interaction_id=seeded["interaction_id"],
transcript_text="Меня зовут Ания, мне нужно узнать график работы",
language="ru",
sequence_no=1,
metadata={"voice_start_language": "ru"},
),
)
assert turn.metadata["customer_name_status"] == "name_obtained"
assert turn.metadata["customer_name_value"] == "Ания"
assert "город" in turn.reply_text.lower()
assert "филиал" in turn.reply_text.lower()
assert "какой вопрос по работе" not in turn.reply_text.lower()
assert "как мне к вам обращаться" not in turn.reply_text.lower()
@pytest.mark.parametrize(
("transcript_text", "expected_name"),
[
("Да, Айдос", "Айдос"),
("Нет, меня зовут Марат", "Марат"),
],
)
def test_downstream_voice_turn_resolves_followup_name(transcript_text: str, expected_name: str):
seeded = seed_voice_downstream_session(
marker=f"voice_followup_{new_id('seed')}",
name_status="name_followup_required",
name_value="Айдос",
name_source="voice_start",
customer_display_name="+77010009999",
)
started = voice_module.start_voice_session(
seeded["session_id"],
VoiceAIStartIn(
voice_session_id=seeded["session_id"],
call_id=seeded["call_id"],
interaction_id=seeded["interaction_id"],
customer_id=seeded["customer_id"],
language_hint="ru",
agent_profile="voice_support",
metadata={"voice_start_language": "ru"},
),
)
assert "Айдос" in started.greeting_text
decision = voice_module.turn_voice_session(
seeded["session_id"],
VoiceAITurnIn(
voice_session_id=seeded["session_id"],
call_id=seeded["call_id"],
interaction_id=seeded["interaction_id"],
transcript_text=transcript_text,
language="ru",
sequence_no=1,
metadata={"voice_start_language": "ru"},
),
)
assert decision.metadata["customer_name_status"] == "name_obtained"
assert decision.metadata["customer_name_value"] == expected_name
assert decision.metadata["customer_name_source"] == "voice_followup"
assert expected_name in decision.reply_text
session = get_session()
try:
customer = session.execute(
select(Customer).where(Customer.customer_id == seeded["customer_id"])
).scalar_one()
assert customer.display_name == expected_name
finally:
session.close()