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

@@ -22,6 +22,7 @@ from app.agents.chat.personas import normalize_interview_persona
from app.agents.chat.prompt_context import ChatPromptContext
from app.agents.chat.prompts_conversation import (
SLOT_NAME_MAP,
SLOT_NAME_MAP_EN,
get_opening_prompt,
)
from app.agents.chat.reply_limits import (
@@ -50,6 +51,23 @@ logger = get_logger(__name__)
# LLM 不可用或调用失败时对用户展示(不暴露异常细节、不触发 TTS
_FALLBACK_REPLY = "刚才网络不太稳,没接上。你可以再说一遍,或稍后再试。"
_FALLBACK_REPLY_EN = (
"Network glitch on my end — could you say that again, or give it another try in a moment?"
)
_OPENING_FALLBACK_ZH = "你好呀~ 又见面了。今天想从人生里哪一小段回忆开始聊聊?"
_OPENING_FALLBACK_EN = (
"Hi there — good to see you again. Where in your life would you like to start today?"
)
def _fallback_reply_for(language: str) -> str:
return _FALLBACK_REPLY_EN if language == "en" else _FALLBACK_REPLY
def _opening_fallback_for(language: str) -> str:
return _OPENING_FALLBACK_EN if language == "en" else _OPENING_FALLBACK_ZH
# 仅在「重复问句守卫」把正文削成单句兜底时追加二次 system只多调一次模型。
_DUPLICATE_GUARD_LLM_RETRY_SYSTEM_APPENDIX = """## 二次生成(纠偏)
@@ -60,6 +78,20 @@ _DUPLICATE_GUARD_LLM_RETRY_SYSTEM_APPENDIX = """## 二次生成(纠偏)
- 若要提问,须换**全新角度**,并锚在用户刚说的具体细节里;也可以本轮**完全不提问**,只并肩承接;
- **禁止**整段只有「这一段我记住了」或同类无信息套话。"""
_DUPLICATE_GUARD_LLM_RETRY_SYSTEM_APPENDIX_EN = """## Second pass (correction)
The previous reply was discarded because it repeated questions that already appeared in "recently asked questions" or restated facts already confirmed. Please **write a whole new reply**:
- Still obey every main rule above.
- Open with a half-sentence to a sentence or two that picks up the user's exact words this turn (with a touch of imagery is fine).
- **Do not** re-use the same confirmation question with only different wording.
- If you do ask a question, choose a **new angle** anchored in a specific detail the user just mentioned; you may also ask **no question** this turn and simply walk alongside what they said.
- **Do not** fall back on filler such as "I'll remember this part" or other content-free reassurance."""
def _duplicate_guard_appendix_for(language: str) -> str:
if language == "en":
return _DUPLICATE_GUARD_LLM_RETRY_SYSTEM_APPENDIX_EN
return _DUPLICATE_GUARD_LLM_RETRY_SYSTEM_APPENDIX
def _finalize_chat_segments_after_llm(
response_text: str,
@@ -68,6 +100,7 @@ def _finalize_chat_segments_after_llm(
max_chars: int,
memoir_state: MemoirStateSchema,
recent_questions: list[str],
language: str = "zh",
) -> tuple[list[str], bool]:
raw_list = segments_from_llm_response(
response_text,
@@ -82,7 +115,7 @@ def _finalize_chat_segments_after_llm(
)
if not out:
out = [response_text.strip()[:max_chars]]
out = nonempty_segments_or_fallback(out, fallback=_FALLBACK_REPLY)
out = nonempty_segments_or_fallback(out, fallback=_fallback_reply_for(language))
out, deduped = apply_duplicate_question_guard(
out,
state=memoir_state,
@@ -149,11 +182,12 @@ class InterviewAgent:
profile_era_place: str = "",
stage_switched_this_turn: bool = False,
scene_cues_for_planner: Optional[list[str]] = None,
language: str = "zh",
) -> AgentChatTurn:
"""生成状态感知的访谈回复,不持久化(由 Orchestrator 负责)"""
if not self.llm:
logger.warning("InterviewAgent: LLM 未配置,返回兜底文案")
return AgentChatTurn(messages=[_FALLBACK_REPLY], skip_tts=True)
return AgentChatTurn(messages=[_fallback_reply_for(language)], skip_tts=True)
try:
text_for_model = self._resolve_text_for_model(
user_message, normalized_user_message
@@ -245,6 +279,7 @@ class InterviewAgent:
persona_threads=memoir_state.persona_threads,
recent_questions=recent_questions or memoir_state.recent_questions,
turn_plan=turn_plan,
language=language,
)
system_prompt = ctx.guided_system_prompt()
messages: List[Any] = [SystemMessage(content=system_prompt)]
@@ -305,11 +340,12 @@ class InterviewAgent:
max_chars=max_chars,
memoir_state=memoir_state,
recent_questions=rq_base,
language=language,
)
retry_used = False
if deduped and segments_are_only_duplicate_guard_fallback(out):
retry_system = (
f"{system_prompt}\n\n{_DUPLICATE_GUARD_LLM_RETRY_SYSTEM_APPENDIX}"
f"{system_prompt}\n\n{_duplicate_guard_appendix_for(language)}"
)
retry_messages: List[Any] = [
SystemMessage(content=retry_system),
@@ -359,6 +395,7 @@ class InterviewAgent:
max_chars=max_chars,
memoir_state=memoir_state,
recent_questions=rq_base,
language=language,
)
retry_used = True
out, auto_bio = apply_autobiographical_boundary_guard(out)
@@ -394,7 +431,7 @@ class InterviewAgent:
)
except Exception as e:
logger.error("生成回应失败: {}", e, exc_info=True)
return AgentChatTurn(messages=[_FALLBACK_REPLY], skip_tts=True)
return AgentChatTurn(messages=[_fallback_reply_for(language)], skip_tts=True)
async def generate_opening_message(
self,
@@ -405,17 +442,19 @@ class InterviewAgent:
occupation: str = "",
profile_birth_year: Optional[int] = None,
profile_era_place: str = "",
language: str = "zh",
) -> List[str]:
"""生成空对话开场白,不持久化(由 Orchestrator 负责)"""
if not self.llm:
return ["你好呀~ 又见面了。今天想从人生里哪一小段回忆开始聊聊?"]
return [_opening_fallback_for(language)]
try:
narrative_state = narrative_coverage_state(memoir_state)
control_state = interview_control_state(memoir_state)
empty_slots = control_state.prompt_empty_slots_for_stage(
narrative_state, memoir_state.current_stage
)
empty_slots_readable = [SLOT_NAME_MAP.get(s, s) for s in empty_slots]
slot_table = SLOT_NAME_MAP_EN if language == "en" else SLOT_NAME_MAP
empty_slots_readable = [slot_table.get(s, s) for s in empty_slots]
persona = normalize_interview_persona(settings.chat_interview_persona)
prompt = get_opening_prompt(
current_stage=memoir_state.current_stage,
@@ -426,6 +465,7 @@ class InterviewAgent:
occupation=occupation,
profile_birth_year=profile_birth_year,
profile_era_place=profile_era_place,
language=language,
)
hw = await get_history_with_window(
conversation_id,
@@ -434,14 +474,19 @@ class InterviewAgent:
)
messages: List[Any] = [SystemMessage(content=prompt)]
messages.extend(hw.window)
if not hw.window:
messages.append(
HumanMessage(content="(对话刚开始,请自然地说出你的开场白。)")
if language == "en":
kickoff = (
"(The conversation is just starting; please greet naturally.)"
if not hw.window
else "(Continue from the context above and deliver your opening line naturally.)"
)
else:
messages.append(
HumanMessage(content="(请根据上文,自然接续并说出你的开场白。)")
kickoff = (
"(对话刚开始,请自然地说出你的开场白。)"
if not hw.window
else "(请根据上文,自然接续并说出你的开场白。)"
)
messages.append(HumanMessage(content=kickoff))
log_agent_payload(
logger,
"InterviewAgent.opening.prompt",
@@ -498,8 +543,8 @@ class InterviewAgent:
segments = out if out else [response_text.strip()[:max_chars]]
return nonempty_segments_or_fallback(
segments,
fallback="你好呀~ 又见面了。今天想从人生里哪一小段回忆开始聊聊?",
fallback=_opening_fallback_for(language),
)
except Exception as e:
logger.error("生成开场白失败: {}", e, exc_info=True)
return ["你好呀~ 又见面了。今天想从人生里哪一小段回忆开始聊聊?"]
return [_opening_fallback_for(language)]