feat(ai): Finalize voice operator persona and voice name flow
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user