1480 lines
60 KiB
Python
1480 lines
60 KiB
Python
import time
|
|
import importlib
|
|
from types import SimpleNamespace
|
|
|
|
import httpx
|
|
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")
|
|
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.sql_models import (
|
|
AIJobRow,
|
|
AISessionRow,
|
|
AITurnRow,
|
|
Interaction,
|
|
KBArticleRow,
|
|
KBCategoryRow,
|
|
TelegramMessageRow,
|
|
TelegramThreadRow,
|
|
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"}
|
|
|
|
|
|
def _u(value: str) -> str:
|
|
return value.encode("ascii").decode("unicode_escape")
|
|
|
|
|
|
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,
|
|
) -> 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,
|
|
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 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_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_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_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)
|
|
|
|
|