Files
life-echo/api/app/agents/chat/prompts_conversation.py
Kevin ccdc4e4277 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>
2026-05-11 16:16:49 +08:00

515 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
对话 Agent 提示词模板(场景化承接 + 细节深挖 + 人物串联)。
"""
from typing import Dict, List, Optional
from app.agents.chat.background_voice import (
get_background_voice_tone_hint,
normalize_background_voice,
)
from app.agents.chat.occupation_context import get_occupation_chat_hint
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,
)
from app.agents.chat.prompt_layers import (
assemble_guided_prompt,
build_absolute_donts_block,
build_behavior_policy_block,
build_context_block,
build_question_outline_block,
build_reply_strategy_block,
build_style_profile_block,
)
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
# 风格示例的单一事实源已迁至 `app.agents.style_profiles.ChatStyleProfile.reply_style_examples`
# 这里**不再**维护具体字面示例,避免同一模块被当作 few-shot 锚点反复注入,导致风格过拟合。
SLOT_NAME_MAP = {
"place": "成长的地方",
"people": "重要的人",
"daily_life": "日常生活",
"emotion": "童年感受",
"turning_event": "难忘的事",
"school": "学校经历",
"city": "求学的城市",
"motivation": "学习动力",
"challenge": "遇到的挑战",
"change": "成长变化",
"job": "工作内容",
"environment": "工作环境",
"decision": "重要决定",
"pressure": "压力与困难",
"growth": "职业成长",
"relationship": "家人关系",
"conflict": "矛盾与化解",
"support": "相互支持",
"responsibility": "家庭责任",
"value": "核心价值观",
"regret": "遗憾与释怀",
"pride": "骄傲的事",
"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,
*,
birth_year: int | None = None,
era_place: str = "",
) -> str:
if not birth_year:
return ""
birth_place = (era_place or "").strip()
age_range = STAGE_ERA_HINTS.get(current_stage, (0, 30))
era_start = birth_year + age_range[0]
era_end = birth_year + age_range[1]
era_events = []
decade_events = {
1950: "新中国成立初期、土地改革、抗美援朝",
1960: "大跃进、三年自然灾害、中苏关系变化",
1970: "文化大革命、知青上山下乡、中美建交",
1980: "改革开放、恢复高考、个体经济兴起、电视普及",
1990: "社会主义市场经济、下海潮、香港回归、互联网初期",
2000: "加入WTO、房地产兴起、手机普及、北京奥运",
2010: "移动互联网爆发、微信时代、共享经济、双创浪潮",
2020: "新冠疫情、直播经济、人工智能崛起",
}
for decade, events in decade_events.items():
if era_start <= decade + 9 and era_end >= decade:
era_events.append(f"{decade}年代:{events}")
parts: List[str] = []
if era_events:
place_hint = f" {birth_place}" if birth_place else ""
parts.append(
f"时代联想(口述里一两句带过即可):约 {era_start}-{era_end}{place_hint}"
f"可提及 {era_events[0]}"
+ (f"{era_events[1]}" if len(era_events) > 1 else "")
+ ""
)
parts.append(
"时代与流行文化(开放式,自然带入):\n"
"- 可从当时的街景、媒介、校园与市井、年节习俗等**泛泛**起头,邀请用户讲自己的版本,勿替用户断言细节。\n"
"- **优先开放式**问法;少用「你是不是也……」式半封闭逼认。\n"
"- 与大事记呼应时点到为止,勿展开成长串史实。"
)
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],
user_profile_context: str = "",
persona: str = "default",
background_voice: str = "default",
occupation: str = "",
profile_birth_year: Optional[int] = None,
profile_era_place: str = "",
language: str = "zh",
) -> str:
"""空对话时 AI 先开口的提示词"""
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)
topics_heading = (
f"## 当前建议话题({stage_name}\n可以从中选一个来问:{topics_str}"
)
task_question = (
"2. 你是**主持式知己**:接着问一个**具体、好回答、有画面感**的问题,帮用户进入**人生回忆**叙述;"
"优先落在上述还未聊透的方向上。不要问太宽泛的「有什么想聊的」「最近怎么样」。"
"像把门敞开请人讲自己的故事,不要像面试第一题;一句里带一个小锚(地方、人物、物件或一天里的片段即可)。"
"不要用「下面我们聊聊…」类未承接的硬切。好问题举例:「说到童年,你脑海里最先蹦出来的是哪个画面?」"
)
else:
topics_heading = (
f"## 当前阶段({stage_name}\n"
"这一阶段的主要话题在素材侧**已有覆盖**。"
"开场仍要**回到人生故事线**:优先接续上次聊过的片段、(若有)记忆线索里出现过的事,或当前阶段里**新鲜的一小角**"
"**禁止**为了凑问题而从「童年在哪长大」等已覆盖模板重头盘问;**也不要**把泛泛近况(「今天忙吗」「最近好吗」)当成默认主线。"
)
task_question = (
"2. **问候 + 回忆向勾子**:温暖接话后,带一个与**口述回忆**有关的轻巧引子或具体问题;"
"若接不上具体事,就用当前阶段的一个**有画面的开放式起头**,仍落在人生经历上,而非纯社交寒暄。"
)
if bv_open == "cadre":
opening_style_rules = (
"## 语境与语气(干部/机关)\n"
"- 问候稳重、敬语适度;避免官样排比与过轻佻的网络撒娇语气。\n"
)
elif bv_open == "military":
opening_style_rules = (
"## 语境与语气(军队相关口述常见交流方式)\n"
"- 简洁、得体;不用「嗨~」类过轻佻起势;不堆军事辞藻、不编军旅细节。\n"
)
else:
opening_style_rules = (
"## 风格\n"
"- 像**温暖的谈话场主持人**:口语、自然、能接住人,但默认把用户带进**人生回忆**叙述;"
"可轻快,允许一点画面感,不要排比和长段文学描写。\n"
)
profile_lines: List[str] = []
if user_profile_context.strip():
profile_lines.append(user_profile_context.strip())
occ = get_occupation_chat_hint(occupation, background_voice)
if occ:
profile_lines.append(occ)
profile_section = ""
if profile_lines:
profile_section = "## 用户信息\n" + "\n".join(profile_lines) + "\n"
persona_key = normalize_interview_persona(persona)
persona_tone = get_interview_persona_tone_hint(persona_key)
voice_tone = get_background_voice_tone_hint(background_voice)
tone_bits = [t for t in (persona_tone, voice_tone) if t]
tone_paragraph = ""
if tone_bits:
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"
)
era_opening_line = ""
if (
settings.chat_era_context_enabled
and profile_birth_year is not None
and _compact_era_hint(
current_stage,
birth_year=profile_birth_year,
era_place=profile_era_place,
)
):
era_opening_line = (
"4. 用户资料里已有出生年份与时代参考时,问候里的具体问题可**轻轻带一点年代氛围**(点到为止),"
"勿写成长段描写或排比。\n"
)
return f"""{opening_head}{tone_paragraph}{profile_section}{topics_heading}
## 任务
1. 简短问候。
{task_question}
3. 自然、温暖。
{era_opening_line}
## 格式
- 可用 [SPLIT] 分成最多 2 条;或一条里「问候 + 问题」。
- {chat_output_rules()} 不要替用户编回答。
{opening_style_rules}
直接输出(仅自然口语,无 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],
filled_slots: Dict[str, str],
all_stages_coverage: Optional[Dict[str, Dict]] = None,
detected_user_stage: str = "",
user_profile_context: str = "",
persona: str = "default",
memory_evidence_text: str = "",
background_voice: str = "default",
occupation: str = "",
profile_birth_year: Optional[int] = None,
profile_era_place: str = "",
known_facts: list[KnownFact] | None = None,
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)
tone_bits = [t for t in (persona_tone, voice_tone) if t]
tone_line = ""
if tone_bits:
tone_line = " " + " ".join(tone_bits)
user_jumped = bool(detected_user_stage and detected_user_stage != current_stage)
active_stage = (
detected_user_stage if user_jumped and detected_user_stage else current_stage
)
era_line = ""
if settings.chat_era_context_enabled:
era_line = _compact_era_hint(
active_stage,
birth_year=profile_birth_year,
era_place=profile_era_place,
)
empty_slots_readable = [SLOT_NAME_MAP.get(s, s) for s in empty_slots]
# ---- Context 层:纯状态与素材 ----
topic_and_context_block = build_context_block(
current_stage=current_stage,
detected_user_stage=detected_user_stage,
empty_slots_readable=empty_slots_readable,
filled_slots=filled_slots,
slot_name_map=SLOT_NAME_MAP,
all_stages_coverage=all_stages_coverage,
user_profile_context=user_profile_context,
occupation=occupation,
background_voice=background_voice,
known_facts=known_facts,
persona_threads=persona_threads,
recent_questions=recent_questions,
memory_evidence_text=memory_evidence_text,
era_line=era_line,
)
question_outline_block = build_question_outline_block(current_stage, empty_slots)
# ---- BehaviorPolicy 层:通用行为规则(本轮模式由 TurnPlan 单独注入) ----
behavior_policy_block = build_behavior_policy_block()
reply_strategy_block = build_reply_strategy_block()
absolute_donts_block = build_absolute_donts_block(chat_output_rules())
# ---- StyleProfile 层:口吻 + 文采密度 + 成稿质量导向 ----
style_profile_block = build_style_profile_block(
persona=persona, background_voice=background_voice
)
return assemble_guided_prompt(
turn_directive_block=turn_directive_block,
topic_and_context_block=topic_and_context_block,
question_outline_block=question_outline_block,
behavior_policy_block=behavior_policy_block,
style_profile_block=style_profile_block,
reply_strategy_block=reply_strategy_block,
absolute_donts_block=absolute_donts_block,
intro_tone_line=tone_line,
)
# 运行时 prompt 生成走 `prompt_layers.assemble_guided_prompt`。
# 旧的超大 system prompt 已拆入 BehaviorPolicy / Context / StyleProfile 三层,此处不再保留快照。
__all__ = [
"SLOT_NAME_MAP",
"SLOT_NAME_MAP_EN",
"slot_name_map_for",
"get_guided_conversation_prompt",
"get_opening_prompt",
]