feat(ai): Finalize voice operator persona and voice name flow

This commit is contained in:
Yera All
2026-04-06 23:19:24 +05:00
parent cfee88cdb1
commit 1497bb0feb
31 changed files with 2175 additions and 638 deletions
+78
View File
@@ -1116,6 +1116,39 @@ def test_voice_module_import_helper_returns_app_module():
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),
@@ -1222,6 +1255,51 @@ def test_voice_kb_search_matches_relaxed_phrase_and_returns_kb_answer():
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):
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_ai_enqueue_creates_outbound_ai_reply_and_delivery_flow(monkeypatch):
monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1")
monkeypatch.setenv("AI_PROVIDER", "stub")
+89
View File
@@ -105,6 +105,95 @@ def test_create_voice_ai_session_records_greeting_without_blocking_on_orchestrat
raise AssertionError("background voice-session start did not finish in time")
def test_create_voice_ai_session_records_greeting_without_blocking_on_orchestrator(monkeypatch):
allow_start = threading.Event()
def _fake_orchestrator_request(method: str, path: str, *, payload=None, timeout=10.0):
assert method == "POST"
assert path.endswith("/start")
assert allow_start.wait(timeout=2.0)
return {
"session_id": "ais_voice_demo",
"language": "ru",
"greeting_text": "Здравствуйте. Подскажите, пожалуйста, чем помочь.",
"disclosure_required": True,
}
monkeypatch.setattr(runtime_module, "_orchestrator_request", _fake_orchestrator_request)
client = TestClient(runtime_module.app)
response_holder: dict[str, object] = {}
def _request() -> None:
response_holder["response"] = client.post(
"/internal/voice-ai/sessions",
headers=_admin_headers(),
json={
"call_id": "call_voice_runtime_1",
"linked_id": "linked_voice_runtime_1",
"interaction_id": "int_voice_runtime_1",
"queue_id": "que_voice_runtime_1",
"caller_number": "+77010000031",
"caller_name": "Runtime Caller",
"agent_profile": "voice_support",
"language_hint": "ru",
"handoff_queue_id": "que_voice_runtime_1",
"metadata": {"queue_code": "voice_lab"},
},
)
request_thread = threading.Thread(target=_request, daemon=True)
request_thread.start()
request_thread.join(timeout=0.3)
assert not request_thread.is_alive()
response = response_holder["response"]
assert response.status_code == 200
payload = response.json()
assert payload["ai_session_id"] is None
assert payload["status"] == "greeting"
session = get_session()
try:
voice_session = session.execute(
select(VoiceAISessionRow).where(VoiceAISessionRow.call_id == "call_voice_runtime_1")
).scalar_one()
assert voice_session.status == "greeting"
assert voice_session.disclosure_played_at is None
assert voice_session.last_ai_reply_at is None
greeting_segment = session.execute(
select(VoiceTranscriptSegmentRow)
.where(VoiceTranscriptSegmentRow.session_id == voice_session.session_id)
.where(VoiceTranscriptSegmentRow.speaker == "assistant")
).scalar_one()
assert "ai" not in greeting_segment.text.lower()
assert greeting_segment.is_final is False
finally:
session.close()
allow_start.set()
deadline = time.time() + 2.0
while time.time() < deadline:
session = get_session()
try:
voice_session = session.execute(
select(VoiceAISessionRow).where(VoiceAISessionRow.call_id == "call_voice_runtime_1")
).scalar_one()
if voice_session.ai_session_id == "ais_voice_demo":
greetings = session.execute(
select(VoiceTranscriptSegmentRow)
.where(VoiceTranscriptSegmentRow.session_id == voice_session.session_id)
.where(VoiceTranscriptSegmentRow.speaker == "assistant")
).scalars().all()
assert len(greetings) == 1
break
finally:
session.close()
time.sleep(0.05)
else:
raise AssertionError("background voice-session start did not finish in time")
def test_mark_reply_delivered_finalizes_only_matching_pending_assistant_segment():
now = utc_now_iso()
session = get_session()