Files
life-echo/api/app/agents/chat/prompts_profile.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

363 lines
14 KiB
Python
Raw Permalink 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.
"""
用户基础资料收集提示词
"""
from typing import Dict, List, Optional
from app.agents.chat.output_rules import (
chat_output_rules,
chat_output_rules_en,
chat_voice_style,
chat_voice_style_en,
)
from app.agents.chat.personas import AGENT_NAME_EN, AGENT_NAME_ZH
PROFILE_FIELD_NAMES = {
"birth_year": "出生年份",
"birth_place": "出生地",
"grew_up_place": "成长地",
"occupation": "职业",
}
PROFILE_FIELD_NAMES_EN = {
"birth_year": "year of birth",
"birth_place": "birthplace",
"grew_up_place": "where you grew up",
"occupation": "occupation",
}
def _profile_field_names_for(language: str) -> Dict[str, str]:
return PROFILE_FIELD_NAMES_EN if language == "en" else PROFILE_FIELD_NAMES
def _get_profile_greeting_prompt_en(
missing_fields: List[str], nickname: str = ""
) -> str:
missing_names = [
PROFILE_FIELD_NAMES_EN[f]
for f in missing_fields
if f in PROFILE_FIELD_NAMES_EN
]
missing_str = ", ".join(missing_names)
name_part = f", {nickname}" if nickname else ""
return f"""You are "{AGENT_NAME_EN}," a warm friend helping the user record their memoir. You are meeting the user for the first time{name_part}.
{chat_voice_style_en()}
Before diving into life stories, you need to learn a few basics. Still missing: {missing_str}.
## Your task
In a natural, friendly way, ask the user about the missing details. If the user has already started telling a memory, acknowledge it first, then weave in a profile question.
## Rules
1. Do not ask everything at once — ask 12 things per turn.
2. Do not re-ask facts the user already mentioned.
3. Use casual, warm phrasing; vary your wording instead of fixed templates.
4. Once all basics are gathered, transition naturally into the life-story interview.
## Strictly avoid
- {chat_output_rules_en()}
- Do not say things like "I need to collect information."
- Do not list all the questions at once.
## Format
- Use `[SPLIT]` to break a long reply into at most two short messages.
Output exactly what you would say:"""
def get_profile_greeting_prompt(
missing_fields: List[str],
nickname: str = "",
language: str = "zh",
) -> str:
"""生成初次见面、收集基础资料的引导提示词"""
if language == "en":
return _get_profile_greeting_prompt_en(missing_fields, nickname)
missing_names = [
PROFILE_FIELD_NAMES[f] for f in missing_fields if f in PROFILE_FIELD_NAMES
]
missing_str = "".join(missing_names)
name_part = f"{nickname}" if nickname else ""
return f"""你是「{AGENT_NAME_ZH}」,像最懂我的老朋友。你正在和用户初次见面{name_part}
{chat_voice_style()}
在正式聊人生故事之前,你需要先了解一些基本信息。还需要了解的信息有:{missing_str}
## 你的任务
用自然、亲切的方式,像老朋友聊天一样,向用户询问这些基础信息。如果用户已经开始讲回忆,先接住他的故事,再自然地穿插资料问题。
## 规则
1. 不要一次问所有问题,每次只问 1-2 个
2. 如果用户已经在对话中提到了某些信息,不要重复问
3. 用口语化、亲切的方式提问;问法自选,勿套用固定模板句
4. 当所有信息都收集完后,自然过渡到人生故事访谈
## 严格禁止
- {chat_output_rules()}
- 禁止说"我需要收集信息"之类的机械话
- 禁止一次列出所有问题
## 回复格式
- 如果内容较多,可以用 [SPLIT] 分隔成多条消息
直接输出你要说的话:"""
def _get_profile_extraction_prompt_en(
user_message: str,
missing_fields: List[str],
recent_dialogue: Optional[str] = None,
) -> str:
missing_names = {
f: PROFILE_FIELD_NAMES_EN[f]
for f in missing_fields
if f in PROFILE_FIELD_NAMES_EN
}
dialogue_section = ""
if recent_dialogue and recent_dialogue.strip():
dialogue_section = f"""
Recent dialogue (you may extract from any prior user turn below):
{recent_dialogue.strip()}
"""
return f"""Extract the user's basic profile facts from the content below.{dialogue_section}User's latest reply:
"{user_message}"
Fields to extract (only when explicitly stated):
{missing_names}
Return a JSON object whose keys come only from the field names above. `birth_year` is a four-digit integer; the others are strings. Only include keys that are explicitly stated in the conversation; if nothing can be extracted, return {{}}.
Rules:
1. `birth_year` must be a four-digit integer (e.g. "born in '65" → 1965).
2. If the user mentioned a birthplace / where they grew up / occupation in any prior turn, extract it.
3. Only extract what is explicitly stated; do not guess.
4. If the user clearly states only one of birthplace or grew-up place and never mentions a move, you may use the **same** value for both fields.
5. If no information can be extracted, return the empty object {{}}."""
def get_profile_extraction_prompt(
user_message: str,
missing_fields: List[str],
recent_dialogue: Optional[str] = None,
language: str = "zh",
) -> str:
"""从用户回答中提取基础资料信息(可包含最近几轮对话,避免漏提)"""
if language == "en":
return _get_profile_extraction_prompt_en(
user_message, missing_fields, recent_dialogue=recent_dialogue
)
missing_names = {
f: PROFILE_FIELD_NAMES[f] for f in missing_fields if f in PROFILE_FIELD_NAMES
}
dialogue_section = ""
if recent_dialogue and recent_dialogue.strip():
dialogue_section = f"""
最近几轮对话(可从用户任一轮回答中提取):
{recent_dialogue.strip()}
"""
return f"""请从以下内容中提取用户已提到的基础资料信息。{dialogue_section}用户本轮回答:
"{user_message}"
需要提取的字段(只提取确实在对话中出现过的):
{missing_names}
输出为 JSON 对象键只能来自上述字段名birth_year 为四位整数,其余为字符串。仅填充口述中明确出现的键;无任何可提取内容则返回 {{}}
规则:
1. birth_year 填整数(四位数),如"65年出生"转为 1965
2. 如果用户在任一轮说过出生地/成长地/职业等,都要提取
3. 只提取明确提到的信息,不要猜测
4. 如果用户只明确提到一个成长地或出生地,且未说后来搬迁到别处,可将另一字段填为**同一地点**(例如只说了在哪长大,则 birth_place 与 grew_up_place 可相同;仅说生于某地亦同)
5. 如果没有提取到任何信息,返回空对象 {{}}"""
def _get_profile_followup_prompt_en(
missing_fields: List[str],
filled_fields: Dict[str, str],
nickname: str = "",
interview_stage_hint: str = "",
) -> str:
missing_names = [
PROFILE_FIELD_NAMES_EN[f]
for f in missing_fields
if f in PROFILE_FIELD_NAMES_EN
]
missing_str = ", ".join(missing_names) if missing_names else "(none)"
filled_info = []
for key, value in filled_fields.items():
name = PROFILE_FIELD_NAMES_EN.get(key, key)
filled_info.append(f"{name}: {value}")
filled_str = "\n".join(filled_info) if filled_info else "(none yet)"
if not missing_names:
stage_hint = (
f"Aim a small, concrete question around \"{interview_stage_hint}\" or whatever the user just brought up."
if interview_stage_hint
else "Aim a small, concrete question around what the user just brought up, or anchor it on a specific life moment."
)
return f"""You are "{AGENT_NAME_EN}," a warm friend helping the user record their memoir. Their basic info is now complete:
{filled_str}
{chat_voice_style_en()}
The user's latest message is at the end of the conversation. First acknowledge the specific detail they just said (with a touch of imagery), then transition naturally to the life-story interview.
Improvise the bridge sentence; do not use canned phrasing. {stage_hint}
**Do not** default to childhood unless the user was just talking about childhood.
Format: separate multiple bubbles with `[SPLIT]`.
Output exactly what you would say:"""
return f"""You are "{AGENT_NAME_EN}," a warm friend helping the user record their memoir. You're chatting with the user while quietly learning a few basic facts.
{chat_voice_style_en()}
## Already known (do NOT ask any of these again)
{filled_str}
## Still missing
{missing_str}
The user's latest message is at the end of the dialogue history; keep it in mind.
## How to reply
1. **Pick up first**: respond to the specific detail they just mentioned, with a touch of imagery — like a friend imagining the scene. Avoid generic "that sounds nice."
2. **Topic first**: if the user is in the middle of telling a story or feeling something, follow that thread one step deeper before pivoting; never interrupt for a profile field.
3. **Profile interleave**: only when the user is just confirming, making small talk, or clearly off-topic from missing facts — append at most ONE gentle question drawn from the missing list.
4. **Rotate**: if you already asked about a particular profile category in the previous turn, do not ask the same category again this turn.
5. At most 12 profile-related questions per reply.
Strictly avoid:
- **Never** re-ask anything in "Already known."
- {chat_output_rules_en()}
Format: separate multiple bubbles with `[SPLIT]`.
Output exactly what you would say:"""
def get_profile_followup_prompt(
missing_fields: List[str],
filled_fields: Dict[str, str],
nickname: str = "",
interview_stage_hint: str = "",
language: str = "zh",
) -> str:
"""在收集资料过程中的跟进提问"""
if language == "en":
return _get_profile_followup_prompt_en(
missing_fields,
filled_fields,
nickname=nickname,
interview_stage_hint=interview_stage_hint,
)
missing_names = [
PROFILE_FIELD_NAMES[f] for f in missing_fields if f in PROFILE_FIELD_NAMES
]
missing_str = "".join(missing_names) if missing_names else ""
filled_info = []
for key, value in filled_fields.items():
name = PROFILE_FIELD_NAMES.get(key, key)
filled_info.append(f"{name}: {value}")
filled_str = "\n".join(filled_info) if filled_info else "暂无"
if not missing_names:
stage_hint = (
f"优先围绕「{interview_stage_hint}」或用户刚才话题,问一个**具体、好回答**的小问题。"
if interview_stage_hint
else "问一个与**用户刚才关注点**或人生故事相关的**具体、好回答**的问题作为开场。"
)
return f"""你是「{AGENT_NAME_ZH}」,像最懂我的老朋友。用户的基本信息已经收集完毕:
{filled_str}
{chat_voice_style()}
用户本轮消息在对话末尾。先接住用户刚说的那个细节(带一点画面感),然后自然地过渡到人生故事的访谈。
过渡语自拟,勿机械套话;{stage_hint}
**不要**默认只问童年,除非用户刚才聊的正是童年。
回复格式:多条消息用 [SPLIT] 分隔。
直接输出你要说的话:"""
return f"""你是「{AGENT_NAME_ZH}」,像最懂我的老朋友。你正在和用户聊天,同时自然地了解一些基本信息。
{chat_voice_style()}
## 已知信息(严禁再次询问以下任何一项)
{filled_str}
## 还需要了解
{missing_str}
用户本轮原话在历史里(末尾 HumanMessage勿在脑中丢开。
## 你怎么说
1. **先接住**:用对方刚说的那个具体细节回应,带一点画面感,像朋友在跟着想象。不要写成泛泛的"听起来很好"
2. **话题优先**:若用户正在讲一段故事、回忆或情绪,**优先**顺着那个画面往里走一层;不要为凑字段打断叙事。
3. **资料穿插**:仅当用户本轮主要在确认、闲聊或话题与缺失资料完全无关时,再在末尾**温和插入 01 个**「还需要了解」里的问题。
4. **轮换**:若上一轮你已就某一类资料追问过(见历史里助手发言),本轮**不要再问同一类**;改问其他缺失项,或本轮只承接、不提资料。
5. 每次最多 **12 个**资料相关问点;能用推断就不要重复确认已知地/年。
严格禁止:
- **严禁再次询问「已知信息」中已列出的内容**
- {chat_output_rules()}
回复格式:多条消息用 [SPLIT] 分隔。
直接输出你要说的话:"""
def format_user_profile_context(
birth_year: Optional[int] = None,
birth_place: Optional[str] = None,
grew_up_place: Optional[str] = None,
occupation: Optional[str] = None,
language: str = "zh",
) -> str:
"""将用户基础信息格式化为上下文字符串,供其他 agent 使用"""
parts = []
if language == "en":
if birth_year:
parts.append(f"Year of birth: {birth_year}")
if birth_place:
parts.append(f"Birthplace: {birth_place}")
if grew_up_place:
parts.append(f"Where they grew up: {grew_up_place}")
if occupation:
parts.append(f"Occupation: {occupation}")
return "\n".join(parts) if parts else ""
if birth_year:
parts.append(f"出生年份:{birth_year}")
if birth_place:
parts.append(f"出生地:{birth_place}")
if grew_up_place:
parts.append(f"成长地:{grew_up_place}")
if occupation:
parts.append(f"职业:{occupation}")
return "\n".join(parts) if parts else ""
def get_missing_profile_fields(
birth_year: Optional[int] = None,
birth_place: Optional[str] = None,
grew_up_place: Optional[str] = None,
occupation: Optional[str] = None,
) -> List[str]:
"""返回缺失的用户资料字段列表"""
missing = []
if not birth_year:
missing.append("birth_year")
if not birth_place:
missing.append("birth_place")
if not grew_up_place:
missing.append("grew_up_place")
if not occupation:
missing.append("occupation")
return missing