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:
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user