feat(i18n): persist language preference and thread through chat, memoir, TTS
- Add users.language_preference (Alembic 0018, default zh); capture at signup/SMS only; expose on auth and profile APIs - Lite English prompts for chat and memoir; localized stage labels and agent names (Life Echo / 岁月知己) - Tencent TTS: language-aware synthesis, ModelType=1 for 501004, English chunking - WebSocket pipeline: emit all AGENT_RESPONSE segments when TTS cancels; INFO logs for tts_this_turn and TTS decisions; on-demand TTS logging - Expo: device language on auth, i18n tiers/agent name, [SPLIT] streaming UX fixes - Tests for migration, prompts, pipeline, router tts_this_turn, reply segments Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
165
api/tests/test_ws_router_tts_this_turn_passthrough.py
Normal file
165
api/tests/test_ws_router_tts_this_turn_passthrough.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""路由器 → pipeline 的 ``tts_this_turn`` 字段传递契约测试。
|
||||
|
||||
回归保护:FE(``app-expo/src/core/ws/client.ts`` 与
|
||||
``app-expo/src/features/conversation/realtime-session.ts``)一律以蛇形 ``tts_this_turn``
|
||||
作为 WebSocket payload 的 ``data`` 字段名;后端 ``ws/router.py`` 三个对话入口分支也按
|
||||
``data.get("tts_this_turn")`` 取值并传递给 pipeline。
|
||||
|
||||
这里不依赖完整 WebSocket runtime,只在两处验证契约:
|
||||
|
||||
1. **数据形状契约**:用 FE 实际发送的 payload 形状(snake_case key)调用
|
||||
``data.get("tts_this_turn")``,断言能取到 True;同时驼峰 ``ttsThisTurn`` 应取不到,
|
||||
防止后续有人把后端 key 改成驼峰。
|
||||
2. **wrapper 透传契约**:直接驱动 ``process_persisted_user_segment_response``
|
||||
(路由 TEXT / AUDIO_MESSAGE 分支唯一会调用的 pipeline 包装),mock 内部 DB +
|
||||
``process_user_message``,断言 ``tts_this_turn=True`` 被原样传到 ``process_user_message``。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.features.conversation.ws import pipeline as ws_pipeline
|
||||
|
||||
|
||||
# ── 数据形状契约 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_router_text_payload_extracts_tts_this_turn_snake_case() -> None:
|
||||
"""FE 发送 ``{type:'text', data:{text, tts_this_turn:true}}``,后端按蛇形读取。"""
|
||||
fe_payload_data = {"text": "hi", "tts_this_turn": True}
|
||||
assert bool(fe_payload_data.get("tts_this_turn")) is True
|
||||
|
||||
# 缺省 / 显式 false:均应取到 False
|
||||
assert bool({"text": "hi"}.get("tts_this_turn")) is False
|
||||
assert bool({"text": "hi", "tts_this_turn": False}.get("tts_this_turn")) is False
|
||||
|
||||
# 驼峰回归保护:FE 历史曾误传过驼峰,蛇形 get 必须取不到,避免静默吞掉
|
||||
assert bool({"text": "hi", "ttsThisTurn": True}.get("tts_this_turn")) is False
|
||||
|
||||
|
||||
def test_router_audio_segment_payload_extracts_tts_this_turn_snake_case() -> None:
|
||||
"""FE ``sendAudioSegment`` 也按 ``data.tts_this_turn`` 输出,蛇形保持一致。"""
|
||||
fe_payload_data = {
|
||||
"audio_base64": "AAA",
|
||||
"segment_index": 0,
|
||||
"voice_session_id": "vs-1",
|
||||
"is_last": True,
|
||||
"duration": 3,
|
||||
"tts_this_turn": True,
|
||||
}
|
||||
assert bool(fe_payload_data.get("tts_this_turn")) is True
|
||||
|
||||
|
||||
# ── wrapper 透传契约 ────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_persisted_user_segment_response_passes_tts_this_turn(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""路由 TEXT 分支 → ``process_persisted_user_segment_response`` → ``process_user_message``。"""
|
||||
captured: dict = {}
|
||||
|
||||
async def _spy_process_user_message(*args, **kwargs) -> None:
|
||||
captured.update(kwargs)
|
||||
# 兼容位置参数(实际调用是关键字调用,这里只为防御性记录)
|
||||
captured["_args"] = args
|
||||
|
||||
monkeypatch.setattr(ws_pipeline, "process_user_message", _spy_process_user_message)
|
||||
|
||||
conversation = SimpleNamespace(
|
||||
id="conv-1",
|
||||
user_id="user-1",
|
||||
deleted_at=None,
|
||||
last_message_at=None,
|
||||
)
|
||||
user = SimpleNamespace(id="user-1", language_preference="zh")
|
||||
segment = SimpleNamespace(
|
||||
id="seg-1",
|
||||
conversation_id="conv-1",
|
||||
user_input_text="说说童年",
|
||||
created_at=None,
|
||||
)
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
get=AsyncMock(
|
||||
side_effect=lambda model, oid: {
|
||||
"conv-1": conversation,
|
||||
"user-1": user,
|
||||
"seg-1": segment,
|
||||
}.get(oid)
|
||||
)
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def _fake_session_local():
|
||||
yield fake_db
|
||||
|
||||
monkeypatch.setattr(ws_pipeline, "AsyncSessionLocal", _fake_session_local)
|
||||
|
||||
await ws_pipeline.process_persisted_user_segment_response(
|
||||
conversation_id="conv-1",
|
||||
user_id="user-1",
|
||||
segment_id="seg-1",
|
||||
tts_this_turn=True,
|
||||
)
|
||||
|
||||
assert captured.get("tts_this_turn") is True, (
|
||||
"router TEXT/AUDIO_MESSAGE 分支必须把 tts_this_turn=True 透传到 process_user_message"
|
||||
)
|
||||
assert captured.get("conversation_id") == "conv-1"
|
||||
assert captured.get("user_message") == "说说童年"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_persisted_user_segment_response_default_tts_this_turn_false(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""缺省(未开本轮朗读)必须以 False 透传,不能被默默改成 True。"""
|
||||
captured: dict = {}
|
||||
|
||||
async def _spy_process_user_message(*_args, **kwargs) -> None:
|
||||
captured.update(kwargs)
|
||||
|
||||
monkeypatch.setattr(ws_pipeline, "process_user_message", _spy_process_user_message)
|
||||
|
||||
conversation = SimpleNamespace(
|
||||
id="conv-2", user_id="user-2", deleted_at=None, last_message_at=None
|
||||
)
|
||||
user = SimpleNamespace(id="user-2", language_preference="zh")
|
||||
segment = SimpleNamespace(
|
||||
id="seg-2",
|
||||
conversation_id="conv-2",
|
||||
user_input_text="x",
|
||||
created_at=None,
|
||||
)
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
get=AsyncMock(
|
||||
side_effect=lambda model, oid: {
|
||||
"conv-2": conversation,
|
||||
"user-2": user,
|
||||
"seg-2": segment,
|
||||
}.get(oid)
|
||||
)
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def _fake_session_local():
|
||||
yield fake_db
|
||||
|
||||
monkeypatch.setattr(ws_pipeline, "AsyncSessionLocal", _fake_session_local)
|
||||
|
||||
await ws_pipeline.process_persisted_user_segment_response(
|
||||
conversation_id="conv-2",
|
||||
user_id="user-2",
|
||||
segment_id="seg-2",
|
||||
# 不传 tts_this_turn → 默认 False
|
||||
)
|
||||
|
||||
assert captured.get("tts_this_turn") is False
|
||||
Reference in New Issue
Block a user