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:
Kevin
2026-05-11 16:16:49 +08:00
parent 5ce29aad64
commit ccdc4e4277
64 changed files with 3233 additions and 208 deletions

View 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