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

@@ -9,8 +9,13 @@ from app.agents.chat.background_voice import (
normalize_background_voice,
)
from app.agents.chat.occupation_context import get_occupation_chat_hint
from app.agents.chat.output_rules import chat_output_rules
from app.agents.chat.output_rules import (
chat_output_rules,
chat_output_rules_en,
)
from app.agents.chat.personas import (
AGENT_NAME_EN,
AGENT_NAME_ZH,
get_interview_persona_tone_hint,
normalize_interview_persona,
)
@@ -23,7 +28,10 @@ from app.agents.chat.prompt_layers import (
build_reply_strategy_block,
build_style_profile_block,
)
from app.agents.stage_constants import STAGE_DISPLAY_ZH, STAGE_ERA_HINTS
from app.agents.stage_constants import (
STAGE_ERA_HINTS,
stage_display_name,
)
from app.agents.state_schema import KnownFact, PersonaThread
from app.core.config import settings
@@ -56,6 +64,36 @@ SLOT_NAME_MAP = {
"lesson": "人生经验",
}
SLOT_NAME_MAP_EN = {
"place": "where you grew up",
"people": "important people",
"daily_life": "everyday life",
"emotion": "childhood feelings",
"turning_event": "memorable moments",
"school": "school experiences",
"city": "the city you studied in",
"motivation": "what drove you",
"challenge": "challenges you faced",
"change": "how you changed",
"job": "what you did at work",
"environment": "your work environment",
"decision": "important decisions",
"pressure": "pressure and hardship",
"growth": "career growth",
"relationship": "family relationships",
"conflict": "conflicts and resolutions",
"support": "mutual support",
"responsibility": "family responsibilities",
"value": "core values",
"regret": "regrets and acceptance",
"pride": "moments you're proud of",
"lesson": "life lessons",
}
def slot_name_map_for(language: str) -> Dict[str, str]:
return SLOT_NAME_MAP_EN if language == "en" else SLOT_NAME_MAP
def _compact_era_hint(
current_stage: str,
@@ -106,6 +144,61 @@ def _compact_era_hint(
return "\n".join(parts) + "\n"
def _get_opening_prompt_en(
current_stage: str,
empty_slots_readable: List[str],
user_profile_context: str = "",
profile_birth_year: Optional[int] = None,
profile_era_place: str = "",
) -> str:
"""English-lite opening prompt; ignores persona/background-voice nuances."""
stage_name = stage_display_name(current_stage, language="en")
if empty_slots_readable:
topics_str = ", ".join(empty_slots_readable)
topics_heading = (
f"## Suggested topics for this stage ({stage_name})\n"
f"Pick one of these to ask about: {topics_str}"
)
task_question = (
"2. You are a **warm, host-style confidant**: ask one **specific, "
"easy-to-answer, vivid** question that pulls the user into telling a "
"life memory; ideally land on one of the topics above. Avoid vague "
"openers like \"How have you been?\" Open the door with one small "
"anchor (a place, a person, an object, or a tiny scene from a day)."
)
else:
topics_heading = (
f"## Current stage ({stage_name})\n"
"The main topics for this stage are largely covered. Open with "
"something tied to a previous memory or a fresh small angle of this "
"stage; do not interrogate from the start."
)
task_question = (
"2. **Greeting + a memory hook**: after a warm acknowledgement, "
"drop a light, concrete question tied to recollection — never "
"small-talk filler."
)
profile_section = ""
if user_profile_context.strip():
profile_section = "## About the user\n" + user_profile_context.strip() + "\n"
return f"""You are "{AGENT_NAME_EN}" — a warm host-style friend. The user just opened the chat and **has not said anything yet**; you speak first. Tone like an old friend, but your job is to help the user start telling their life story; in two or three short sentences, give a greeting plus **one vivid, recollection-oriented question** tied to the current stage or suggested topics. No flowery prose, no long literary descriptions, no generic small-talk.
{profile_section}{topics_heading}
## Task
1. Brief greeting.
{task_question}
3. Sound natural and warm.
## Format
- Use `[SPLIT]` to break into at most two short bubbles, or keep greeting + question in one short bubble.
- {chat_output_rules_en()} Do not write the user's answer for them.
Output (spoken-style English only, no Markdown):"""
def get_opening_prompt(
current_stage: str,
empty_slots_readable: List[str],
@@ -115,9 +208,18 @@ def get_opening_prompt(
occupation: str = "",
profile_birth_year: Optional[int] = None,
profile_era_place: str = "",
language: str = "zh",
) -> str:
"""空对话时 AI 先开口的提示词"""
stage_name = STAGE_DISPLAY_ZH.get(current_stage, current_stage)
if language == "en":
return _get_opening_prompt_en(
current_stage,
empty_slots_readable,
user_profile_context=user_profile_context,
profile_birth_year=profile_birth_year,
profile_era_place=profile_era_place,
)
stage_name = stage_display_name(current_stage, language="zh")
bv_open = normalize_background_voice(background_voice)
if empty_slots_readable:
topics_str = "".join(empty_slots_readable)
@@ -178,13 +280,13 @@ def get_opening_prompt(
tone_paragraph = " " + " ".join(tone_bits) + "\n\n"
opening_head = (
"你是「岁月知己」——主持式知己:用户刚进对话,**还没说话**,请你先开口。"
f"你是「{AGENT_NAME_ZH}」——主持式知己:用户刚进对话,**还没说话**,请你先开口。"
"语气像老朋友,但**职责是帮对方开口讲人生故事**;两三句内问候 + **一个落在当前阶段或建议话题上的、有画面感的问题**"
"不要排比、不要长段文学描写,**不要**把泛泛问近况当主菜。\n\n"
)
if bv_open != "default":
opening_head = (
"你是「岁月知己」——主持式知己:用户刚进对话,**还没说话**,请你先开口。"
f"你是「{AGENT_NAME_ZH}」——主持式知己:用户刚进对话,**还没说话**,请你先开口。"
"**短**;两三句内问候 + **一个回忆向的具体问题**;不要排比、不要文学描写。\n\n"
)
@@ -217,6 +319,92 @@ def get_opening_prompt(
直接输出(仅自然口语,无 Markdown"""
def _get_guided_conversation_prompt_en(
current_stage: str,
empty_slots: List[str],
filled_slots: Dict[str, str],
detected_user_stage: str = "",
user_profile_context: str = "",
memory_evidence_text: str = "",
recent_questions: list[str] | None = None,
turn_directive_block: str = "",
) -> str:
"""English-lite guided interview prompt (no persona/voice nuances)."""
stage_name = stage_display_name(current_stage, language="en")
detected_name = (
stage_display_name(detected_user_stage, language="en")
if detected_user_stage and detected_user_stage != current_stage
else ""
)
empty_readable = [SLOT_NAME_MAP_EN.get(s, s) for s in empty_slots]
filled_lines = []
for k, v in (filled_slots or {}).items():
name = SLOT_NAME_MAP_EN.get(k, k)
if v:
filled_lines.append(f"- {name}: {v}")
filled_block = "\n".join(filled_lines) if filled_lines else "(none yet)"
suggested_block = (
"Suggested still-open angles for this stage: " + ", ".join(empty_readable)
if empty_readable
else "Main angles for this stage are largely covered."
)
detected_line = (
f"\nThe user is currently talking about: **{detected_name}** (system was tracking **{stage_name}**)."
if detected_name
else ""
)
profile_section = ""
if user_profile_context.strip():
profile_section = "\n## About the user\n" + user_profile_context.strip()
memory_section = ""
if (memory_evidence_text or "").strip():
memory_section = (
"\n## Reference memory snippets (for continuity only — do NOT write them as the user's first-person experience this turn)\n"
+ memory_evidence_text.strip()
)
recent_q_section = ""
if recent_questions:
last = recent_questions[-4:]
recent_q_section = (
"\n## Recently asked questions (do NOT repeat these; offer a new angle)\n"
+ "\n".join(f"- {q}" for q in last)
)
directive_block = (turn_directive_block or "").strip()
directive_section = (
f"\n## This turn's plan\n{directive_block}\n" if directive_block else ""
)
return f"""{directive_section}You are "{AGENT_NAME_EN}," a warm host-style friend helping the user record a memoir. Reply in conversational English.
## Stage context
Currently tracking life stage: **{stage_name}**.{detected_line}
{suggested_block}
## Already gathered for this stage
{filled_block}{profile_section}{memory_section}{recent_q_section}
## Behaviour
- Pick up the **specific** detail the user just said (one tangible noun or short phrase) and gently push one step deeper before asking your next question.
- Prefer ONE clear, specific question per reply. Open-ended over forced A/B options.
- If the user is in the middle of a story, follow that thread; do not switch topics for the sake of coverage.
- If you previously asked about something and the user already answered, do not re-ask.
- Stay short and precise. One acknowledgement sentence + one question is the default shape.
## Strict rules
- {chat_output_rules_en()}
## Format
- Use `[SPLIT]` to split into at most two short bubbles when natural.
Reply in English only. Do not output Markdown headings."""
def get_guided_conversation_prompt(
current_stage: str,
empty_slots: List[str],
@@ -234,8 +422,20 @@ def get_guided_conversation_prompt(
persona_threads: list[PersonaThread] | None = None,
recent_questions: list[str] | None = None,
turn_directive_block: str = "",
language: str = "zh",
) -> str:
"""生成状态感知的对话提示词;用户原话仅以 HumanMessage 传入,不写入本 system 文本。"""
if language == "en":
return _get_guided_conversation_prompt_en(
current_stage=current_stage,
empty_slots=empty_slots,
filled_slots=filled_slots,
detected_user_stage=detected_user_stage,
user_profile_context=user_profile_context,
memory_evidence_text=memory_evidence_text,
recent_questions=recent_questions,
turn_directive_block=turn_directive_block,
)
persona_key = normalize_interview_persona(persona)
persona_tone = get_interview_persona_tone_hint(persona_key)
voice_tone = get_background_voice_tone_hint(background_voice)
@@ -307,6 +507,8 @@ def get_guided_conversation_prompt(
__all__ = [
"SLOT_NAME_MAP",
"SLOT_NAME_MAP_EN",
"slot_name_map_for",
"get_guided_conversation_prompt",
"get_opening_prompt",
]