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