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

@@ -29,6 +29,31 @@ from app.ports.llm import LLMProvider
logger = get_logger(__name__)
_FOLLOWUP_FALLBACK_ZH = "谢谢分享!能再告诉我一些吗?"
_FOLLOWUP_FALLBACK_EN = "Thanks for sharing — could you tell me a bit more?"
_GREETING_FALLBACK_ZH = "你好!在开始之前,能告诉我你是哪一年出生的吗?"
_GREETING_FALLBACK_EN = (
"Hi! Before we get started, could you tell me what year you were born?"
)
_GREETING_FALLBACK_FULL_ZH = (
"你好!在我们开始聊人生故事之前,能先简单介绍一下你自己吗?比如你是哪一年出生的?"
)
_GREETING_FALLBACK_FULL_EN = (
"Hi! Before we dive into life stories, could you introduce yourself a little — for example, what year were you born?"
)
def _profile_followup_fallback(language: str) -> str:
return _FOLLOWUP_FALLBACK_EN if language == "en" else _FOLLOWUP_FALLBACK_ZH
def _profile_greeting_fallback(language: str) -> str:
return _GREETING_FALLBACK_EN if language == "en" else _GREETING_FALLBACK_ZH
def _profile_greeting_fallback_full(language: str) -> str:
return _GREETING_FALLBACK_FULL_EN if language == "en" else _GREETING_FALLBACK_FULL_ZH
class _ProviderBackedProfileGateway:
def __init__(self, provider: LLMProvider) -> None:
@@ -173,6 +198,7 @@ class ProfileAgent:
user_message: str,
missing_fields: List[str],
conversation_id: Optional[str] = None,
language: str = "zh",
) -> Dict[str, Any]:
"""从用户消息中提取资料字段,不持久化"""
if not missing_fields:
@@ -186,15 +212,20 @@ class ProfileAgent:
)
recent = hw.window[-4:] if len(hw.window) > 4 else hw.window
parts = []
user_label = "User" if language == "en" else "用户"
asst_label = "Assistant" if language == "en" else "助手"
for msg in recent:
if isinstance(msg, HumanMessage):
parts.append(f"用户: {msg.content}")
parts.append(f"{user_label}: {msg.content}")
elif isinstance(msg, AIMessage):
parts.append(f"助手: {msg.content}")
parts.append(f"{asst_label}: {msg.content}")
recent_dialogue = "\n".join(parts) if parts else ""
try:
prompt = get_profile_extraction_prompt(
user_message, missing_fields, recent_dialogue=recent_dialogue or None
user_message,
missing_fields,
recent_dialogue=recent_dialogue or None,
language=language,
)
parsed = await self._llm_gateway.json_object(
prompt,
@@ -241,6 +272,7 @@ class ProfileAgent:
filled_fields: Dict[str, str],
nickname: str = "",
interview_stage_hint: str = "",
language: str = "zh",
) -> List[str]:
"""生成资料追问回复,不持久化(由 Orchestrator 负责)"""
try:
@@ -249,6 +281,7 @@ class ProfileAgent:
filled_fields,
nickname,
interview_stage_hint=interview_stage_hint,
language=language,
)
hw = await get_history_with_window(
conversation_id,
@@ -284,7 +317,7 @@ class ProfileAgent:
response_text,
max_segments=3,
max_chars_per_segment=settings.chat_interview_max_chars_per_segment,
fallback="谢谢分享!能再告诉我一些吗?",
fallback=_profile_followup_fallback(language),
)
log_agent_summary(
logger,
@@ -295,17 +328,20 @@ class ProfileAgent:
return segments
except Exception as e:
logger.error("生成资料跟进回复失败: {}", e)
return ["谢谢分享!能再告诉我一些吗?"]
return [_profile_followup_fallback(language)]
async def generate_profile_greeting(
self,
conversation_id: str,
missing_fields: List[str],
nickname: str = "",
language: str = "zh",
) -> List[str]:
"""生成资料收集开场白,不持久化(由 Orchestrator 负责)"""
try:
prompt = get_profile_greeting_prompt(missing_fields, nickname)
prompt = get_profile_greeting_prompt(
missing_fields, nickname, language=language
)
hw = await get_history_with_window(
conversation_id,
max_pairs=settings.chat_history_max_pairs,
@@ -313,12 +349,19 @@ class ProfileAgent:
)
messages: List[Any] = [SystemMessage(content=prompt)]
messages.extend(hw.window)
if hw.window:
messages.append(
HumanMessage(content="(请根据上文自然接话,继续资料收集开场。)")
if language == "en":
kickoff = (
"(Continue from the context above and warmly carry on the profile-gathering opener.)"
if hw.window
else "(Please deliver your profile-gathering opener.)"
)
else:
messages.append(HumanMessage(content="(请说出资料收集开场白。)"))
kickoff = (
"(请根据上文自然接话,继续资料收集开场。)"
if hw.window
else "(请说出资料收集开场白。)"
)
messages.append(HumanMessage(content=kickoff))
log_agent_payload(
logger,
"ProfileAgent.greeting.prompt",
@@ -345,7 +388,7 @@ class ProfileAgent:
response_text,
max_segments=2,
max_chars_per_segment=settings.chat_interview_max_chars_per_segment,
fallback="你好!在开始之前,能告诉我你是哪一年出生的吗?",
fallback=_profile_greeting_fallback(language),
)
log_agent_summary(
logger,
@@ -356,6 +399,4 @@ class ProfileAgent:
return segments
except Exception as e:
logger.error("生成资料收集开场白失败: {}", e)
return [
"你好!在我们开始聊人生故事之前,能先简单介绍一下你自己吗?比如你是哪一年出生的?"
]
return [_profile_greeting_fallback_full(language)]