feat: complete voice session safety and confirmation flow
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.core.config import settings
|
||||
@@ -97,6 +98,10 @@ async def test_voice_session_correct_turn_and_finalize_to_story(
|
||||
"app.services.voice_session_service.text_to_speech",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_tts,
|
||||
patch(
|
||||
"app.services.voice_session_service.generate_story_cover",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_generate_cover,
|
||||
):
|
||||
mock_generate.side_effect = [
|
||||
StoryOutput(
|
||||
@@ -113,6 +118,7 @@ async def test_voice_session_correct_turn_and_finalize_to_story(
|
||||
),
|
||||
]
|
||||
mock_tts.side_effect = [b"turn-1-audio", b"turn-2-audio"]
|
||||
mock_generate_cover.return_value = "https://example.com/voice-cover.png"
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
@@ -165,6 +171,8 @@ async def test_voice_session_correct_turn_and_finalize_to_story(
|
||||
assert session_data["status"] == "completed"
|
||||
assert session_data["final_story_id"] == story_id
|
||||
assert session_data["can_continue"] is False
|
||||
assert session_data["story_state"]["final_summary"]
|
||||
mock_generate_cover.assert_awaited_once()
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
@@ -328,14 +336,22 @@ async def test_voice_session_low_confidence_turn_requests_confirmation(
|
||||
turn_data = response.json()
|
||||
assert turn_data["status"] == "audio_ready"
|
||||
assert turn_data["requires_confirmation"] is True
|
||||
assert turn_data["confirmation_state"] == "pending"
|
||||
assert turn_data["understanding_summary"].startswith("本轮系统理解为")
|
||||
assert "请家长帮忙确认" in turn_data["confirmation_message"]
|
||||
assert turn_data["assistant_text"] == turn_data["confirmation_message"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/voice-sessions/{session_id}/turns/fallback",
|
||||
json={"transcript_text": "我要直接继续下一轮"},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
response = await client.get(f"/api/voice-sessions/{session_id}")
|
||||
assert response.status_code == 200
|
||||
session_data = response.json()
|
||||
assert session_data["latest_requires_confirmation"] is True
|
||||
assert session_data["latest_confirmation_state"] == "pending"
|
||||
assert "请家长帮忙确认" in session_data["latest_confirmation_message"]
|
||||
assert session_data["can_finalize"] is False
|
||||
assert session_data["story_state"]["narrative_segments"] == []
|
||||
@@ -349,6 +365,305 @@ async def test_voice_session_low_confidence_turn_requests_confirmation(
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
async def test_voice_session_confirmation_accept_continues_original_turn(
|
||||
db_session,
|
||||
auth_token,
|
||||
):
|
||||
async def override_get_db():
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.voice_session_service.generate_story_content",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_generate,
|
||||
patch(
|
||||
"app.services.voice_session_service.text_to_speech",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_tts,
|
||||
patch(
|
||||
"app.services.voice_session_service.transcribe_voice_audio",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_transcribe,
|
||||
):
|
||||
mock_generate.return_value = StoryOutput(
|
||||
mode="generated",
|
||||
title="小恐龙的星光之旅",
|
||||
story_text="小恐龙踩着亮晶晶的石头,朝着会唱歌的山谷慢慢走去。",
|
||||
cover_prompt_suggestion="A glowing little dinosaur walking into a musical valley",
|
||||
)
|
||||
mock_tts.side_effect = [b"confirmation-audio", b"story-audio"]
|
||||
mock_transcribe.return_value = VoiceTranscriptionResult(
|
||||
transcript_text="我想听一个会发光的小恐龙故事",
|
||||
confidence=0.44,
|
||||
provider="openai",
|
||||
)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
client.cookies.set("access_token", auth_token)
|
||||
|
||||
response = await client.post("/api/voice-sessions", json={})
|
||||
session_id = response.json()["id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/voice-sessions/{session_id}/turns",
|
||||
files={
|
||||
"audio_file": ("turn.webm", b"fake-webm-audio", "audio/webm"),
|
||||
},
|
||||
)
|
||||
turn_id = response.json()["turn_id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/voice-sessions/{session_id}/turns/{turn_id}/confirm",
|
||||
json={"action": "accept"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
turn_data = response.json()
|
||||
assert turn_data["status"] == "audio_ready"
|
||||
assert turn_data["requires_confirmation"] is False
|
||||
assert turn_data["confirmation_state"] == "accepted"
|
||||
assert "小恐龙踩着亮晶晶的石头" in turn_data["assistant_text"]
|
||||
|
||||
response = await client.get(f"/api/voice-sessions/{session_id}")
|
||||
session_data = response.json()
|
||||
assert session_data["latest_requires_confirmation"] is False
|
||||
assert session_data["can_finalize"] is True
|
||||
assert len(session_data["story_state"]["narrative_segments"]) == 1
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
async def test_voice_session_confirmation_switch_to_text_allows_follow_up_turn(
|
||||
db_session,
|
||||
auth_token,
|
||||
):
|
||||
async def override_get_db():
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.voice_session_service.generate_story_content",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_generate,
|
||||
patch(
|
||||
"app.services.voice_session_service.text_to_speech",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_tts,
|
||||
patch(
|
||||
"app.services.voice_session_service.transcribe_voice_audio",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_transcribe,
|
||||
):
|
||||
mock_generate.return_value = StoryOutput(
|
||||
mode="generated",
|
||||
title="文字修正后的故事",
|
||||
story_text="小熊轻轻推开了云朵门,发现里面藏着一座会发光的图书馆。",
|
||||
cover_prompt_suggestion="A little bear opening a glowing cloud library door",
|
||||
)
|
||||
mock_tts.side_effect = [b"confirmation-audio", b"story-audio"]
|
||||
mock_transcribe.return_value = VoiceTranscriptionResult(
|
||||
transcript_text="我想听一个小熊和云朵门的故事",
|
||||
confidence=0.4,
|
||||
provider="openai",
|
||||
)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
client.cookies.set("access_token", auth_token)
|
||||
|
||||
response = await client.post("/api/voice-sessions", json={})
|
||||
session_id = response.json()["id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/voice-sessions/{session_id}/turns",
|
||||
files={
|
||||
"audio_file": ("turn.webm", b"fake-webm-audio", "audio/webm"),
|
||||
},
|
||||
)
|
||||
turn_id = response.json()["turn_id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/voice-sessions/{session_id}/turns/{turn_id}/confirm",
|
||||
json={"action": "switch_to_text"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["confirmation_state"] == "switch_to_text"
|
||||
|
||||
response = await client.post(
|
||||
f"/api/voice-sessions/{session_id}/turns/fallback",
|
||||
json={"transcript_text": "我想听一个小熊打开云朵门去冒险的故事"},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
|
||||
response = await client.get(f"/api/voice-sessions/{session_id}")
|
||||
session_data = response.json()
|
||||
assert session_data["latest_requires_confirmation"] is False
|
||||
assert session_data["can_finalize"] is True
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
async def test_voice_session_unsafe_transcript_is_redirected_safely(
|
||||
db_session,
|
||||
auth_token,
|
||||
):
|
||||
async def override_get_db():
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
with patch(
|
||||
"app.services.voice_session_service.text_to_speech",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_tts, patch(
|
||||
"app.services.voice_session_service.generate_story_content",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_generate:
|
||||
mock_tts.return_value = b"safe-redirect-audio"
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
client.cookies.set("access_token", auth_token)
|
||||
|
||||
response = await client.post("/api/voice-sessions", json={})
|
||||
session_id = response.json()["id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/voice-sessions/{session_id}/turns/fallback",
|
||||
json={"transcript_text": "我想听一个拿着炸弹互相打的故事"},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
turn_id = response.json()["turn_id"]
|
||||
|
||||
response = await client.get(
|
||||
f"/api/voice-sessions/{session_id}/turns/{turn_id}"
|
||||
)
|
||||
turn_data = response.json()
|
||||
assert turn_data["safety_blocked"] is True
|
||||
assert "violence" in turn_data["safety_flags"]
|
||||
assert "温柔、安全" in turn_data["assistant_text"]
|
||||
|
||||
response = await client.get(f"/api/voice-sessions/{session_id}")
|
||||
session_data = response.json()
|
||||
assert session_data["story_state"]["narrative_segments"] == []
|
||||
assert "violence" in session_data["latest_safety_flags"]
|
||||
|
||||
mock_generate.assert_not_awaited()
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
async def test_voice_session_analytics_summarize_failures_and_confirmations(
|
||||
db_session,
|
||||
auth_token,
|
||||
):
|
||||
async def override_get_db():
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.voice_session_service.generate_story_content",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_generate,
|
||||
patch(
|
||||
"app.services.voice_session_service.text_to_speech",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_tts,
|
||||
patch(
|
||||
"app.services.voice_session_service.transcribe_voice_audio",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_transcribe,
|
||||
):
|
||||
mock_generate.side_effect = [
|
||||
StoryOutput(
|
||||
mode="generated",
|
||||
title="安全故事",
|
||||
story_text="第一段安全故事。",
|
||||
cover_prompt_suggestion="safe cover",
|
||||
),
|
||||
StoryOutput(
|
||||
mode="generated",
|
||||
title="确认后继续",
|
||||
story_text="第二段确认后顺利继续。",
|
||||
cover_prompt_suggestion="safe cover 2",
|
||||
),
|
||||
]
|
||||
mock_tts.side_effect = [
|
||||
RuntimeError("tts down"),
|
||||
b"confirmation-audio",
|
||||
b"confirmed-story-audio",
|
||||
]
|
||||
mock_transcribe.side_effect = [
|
||||
VoiceTranscriptionResult(
|
||||
transcript_text="我想听一个会发光的小恐龙故事",
|
||||
confidence=0.41,
|
||||
provider="openai",
|
||||
),
|
||||
HTTPException(status_code=503, detail="语音转写服务暂时不可用,请稍后重试。"),
|
||||
]
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
client.cookies.set("access_token", auth_token)
|
||||
|
||||
response = await client.post("/api/voice-sessions", json={})
|
||||
session_id = response.json()["id"]
|
||||
|
||||
await client.post(
|
||||
f"/api/voice-sessions/{session_id}/turns/fallback",
|
||||
json={"transcript_text": "先给我一段故事"},
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
f"/api/voice-sessions/{session_id}/turns",
|
||||
files={
|
||||
"audio_file": ("turn.webm", b"fake-webm-audio", "audio/webm"),
|
||||
},
|
||||
)
|
||||
turn_id = response.json()["turn_id"]
|
||||
await client.post(
|
||||
f"/api/voice-sessions/{session_id}/turns/{turn_id}/confirm",
|
||||
json={"action": "accept"},
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
f"/api/voice-sessions/{session_id}/turns",
|
||||
files={
|
||||
"audio_file": ("turn-2.webm", b"fake-webm-audio-2", "audio/webm"),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 503
|
||||
|
||||
await client.post(
|
||||
f"/api/voice-sessions/{session_id}/finalize",
|
||||
json={"save_story": True, "generate_cover": False},
|
||||
)
|
||||
|
||||
response = await client.get("/api/voice-sessions/analytics?days=30")
|
||||
assert response.status_code == 200
|
||||
analytics = response.json()
|
||||
assert analytics["total_sessions"] >= 1
|
||||
assert analytics["successful_turns"] >= 1
|
||||
assert analytics["tts_failures"] >= 1
|
||||
assert analytics["low_confidence_turns"] >= 1
|
||||
assert analytics["asr_failures"] >= 1
|
||||
assert analytics["finalized_sessions"] >= 1
|
||||
assert analytics["finalize_conversion_rate"] > 0
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
async def test_voice_session_list_orders_recent_sessions_first(
|
||||
db_session,
|
||||
auth_token,
|
||||
|
||||
Reference in New Issue
Block a user