feat(voice): add duplex streaming v2 pipeline

This commit is contained in:
Yera All
2026-04-11 17:38:21 +05:00
parent fc976804e4
commit b75b76fae6
10 changed files with 1219 additions and 206 deletions
+86
View File
@@ -33,6 +33,7 @@ from services.shared.sql_models import (
VoiceNameCollectionSettingsRow,
VoiceTTSSettingsRow,
VoiceAISessionRow,
VoiceTranscriptSegmentRow,
WhatsAppThreadRow,
)
from services.telegram_adapter_service import app as telegram_module
@@ -1419,6 +1420,91 @@ def test_voice_v2_off_domain_request_returns_fast_operator_fallback_without_llm(
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):
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_turn_voice_session_early_plan_does_not_persist_partial_turns(monkeypatch):
monkeypatch.setenv("AI_VOICE_POLICY_MODE", "v2_streaming_duplex")
def _unexpected_llm(messages):
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_ai_enqueue_creates_outbound_ai_reply_and_delivery_flow(monkeypatch):
monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1")
monkeypatch.setenv("AI_PROVIDER", "stub")