Files
life-echo/api/app/agents/chat/prompts_conversation.py
Kevin e4bf0710c7 feat(memory,conversation): 记忆富化/证据包、时间线幂等字段与对话分段全链路
数据库
- 新增迁移 0003:timeline_events.memory_source_id 外键 → memory_sources,便于按 ingest 源做时间线幂等

后端 - 记忆
- 新增 ingest 后 LLM 富化(摘要/事实/时间线),可配置开关与最大字符数
- 新增证据包组装:合并 chunk、摘要、事实、时间线、故事等检索结果;支持空 query 时是否仍带 rolling 等开关
- repo/retriever/service/router/schemas/summarizer/timeline/extractor 等扩展;文档 memory-retrieval.md 更新

后端 - 对话 WS
- 增加 PING/PONG;分段 ASR 日志与空音频处理;转写失败与「无助手回复」错误提示更明确
- 助手多段回复持久化使用统一分隔符,与分段逻辑一致

后端 - Agent
- reply_limits:按 [SPLIT] 与段落拆段,并保证非空 fallback,供 WS 与 TTS 多段下发

后端 - 回忆录任务
- transcript ingest 记录 source_id;任务成功结?
2026-03-27 16:24:43 +08:00

449 lines
17 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 enum import Enum
from typing import Dict, List
from app.core.config import settings
class ConversationStage(str, Enum):
"""对话阶段枚举"""
CHILDHOOD = "childhood" # 童年
EDUCATION = "education" # 教育
CAREER = "career" # 事业
FAMILY = "family" # 家庭
BELIEFS = "beliefs" # 信念
SUMMARY = "summary" # 人生总结
# 访谈问题库(每阶段 23 条短问句,供参考;主流程仍由模型与槽位驱动)
INTERVIEW_QUESTIONS: Dict[ConversationStage, List[str]] = {
ConversationStage.CHILDHOOD: [
"你是在哪儿长大的?小时候印象最深的一件事是什么?",
"小时候家里是怎样的?父母对你影响大吗?",
],
ConversationStage.EDUCATION: [
"求学阶段印象最深的是哪段经历?",
"有没有哪位老师或同学对你影响特别大?毕业后最初想做什么?",
],
ConversationStage.CAREER: [
"第一次工作还记得吗?当时心情怎样?",
"事业里遇到过最大的挑战是什么?怎么挺过来的?有没有特别自豪的时刻?",
],
ConversationStage.FAMILY: [
"和伴侣是怎么认识的?做父母时最难忘或最骄傲的瞬间?",
"家庭在你的人生里扮演什么角色?",
],
ConversationStage.BELIEFS: [
"有没有一直坚守的信念或座右铭?哪些价值观对你最重要?",
"你怎样理解成功与幸福?低谷时是什么支撑你?",
],
ConversationStage.SUMMARY: [
"回顾一生,最重要的经验或教训是什么?最感激的人与事有哪些?",
"若能对年轻时的自己说一句话,你会说什么?",
],
}
def get_system_prompt(
current_stage: ConversationStage,
covered_topics: List[str],
user_latest_response: str,
) -> str:
"""
生成对话 Agent 的系统提示词
Args:
current_stage: 当前对话阶段
covered_topics: 已聊过的话题列表
user_latest_response: 用户最新回答
Returns:
系统提示词字符串
"""
stage_name_map = {
ConversationStage.CHILDHOOD: "童年",
ConversationStage.EDUCATION: "教育",
ConversationStage.CAREER: "事业",
ConversationStage.FAMILY: "家庭",
ConversationStage.BELIEFS: "信念",
ConversationStage.SUMMARY: "人生总结",
}
covered_topics_str = "".join(covered_topics) if covered_topics else "暂无"
prompt = f"""你是「岁月知己」,像老朋友一样陪用户聊人生。**回复要短**,像微信聊天,不要长篇、不要文学腔。
规则:先简短接住对方一句,**最多再问一个具体问题**;禁止括号与思考过程;禁止采访腔(如「我注意到」「我想了解」);**不要重复确认**对方刚说过或上文已能推断的信息。
当前阶段:{stage_name_map.get(current_stage, current_stage.value)}
已聊话题:{covered_topics_str}
直接输出对用户说的话。"""
return prompt
def get_questions_for_stage(stage: ConversationStage) -> List[str]:
"""获取指定阶段的所有问题"""
return INTERVIEW_QUESTIONS.get(stage, [])
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": "人生经验",
}
STAGE_RELATED_TOPICS = {
"childhood": ["family", "education"],
"education": ["childhood", "career"],
"career": ["education", "family", "belief"],
"family": ["childhood", "career", "belief"],
"belief": ["career", "family"],
}
LIGHT_TOPICS = [
"有什么爱好或者特别喜欢的消遣方式吗?",
"最近有什么让你开心的事吗?",
"有没有什么趣事想分享?",
"平时喜欢看什么书或者电影吗?",
]
RESPONSE_STYLES = [
"empathy",
"curious",
"reflection",
"lighthearted",
"connection",
]
def get_opening_prompt(
current_stage: str,
empty_slots_readable: List[str],
user_profile_context: str = "",
) -> str:
"""空对话时 AI 先开口的提示词"""
stage_name_map = {
"childhood": "童年时光",
"education": "求学经历",
"career": "职业生涯",
"family": "家庭生活",
"belief": "人生信念",
}
stage_name = stage_name_map.get(current_stage, current_stage)
if empty_slots_readable:
topics_str = "".join(empty_slots_readable)
topics_heading = (
f"## 当前建议话题({stage_name}\n可以从中选一个来问:{topics_str}"
)
task_question = (
"2. **必须问一个问题**:接着问一个**具体、好回答**的问题,引导用户开始分享;"
"优先落在上述还未聊透的方向上。不要问太宽泛的「有什么想聊的」。"
)
_opening_examples = {
"childhood": (
"示例(仅供参考风格):\n"
'"你好呀~ 想听听你的人生故事。你小时候是在哪儿长大的?那边有什么特别让你怀念的?"\n\n'
'"在的!今天想聊聊你。你童年里印象最深的一件事是什么?"'
),
"education": (
"示例(仅供参考风格):\n"
'"嗨~ 想听听你求学那段日子。你印象最深的是哪段学校时光?"\n\n'
'"在呢!你读书时有没有一位老师或同学,到现在还会想起?"'
),
"career": (
"示例(仅供参考风格):\n"
'"你好呀~ 想听听你工作这条路上故事。你第一份工作还记得吗,当时什么心情?"\n\n'
'"在的!你现在或过去做过的工作里,哪一段你最想先聊聊?"'
),
"family": (
"示例(仅供参考风格):\n"
'"嗨~ 想听听你家里的事。和家里人相处时,有没有特别暖或难忘的一刻?"\n\n'
'"在呢!如果用一个词形容你心里的「家」,你会想到什么?"'
),
"belief": (
"示例(仅供参考风格):\n"
'"你好呀~ 想听听你心里看重的东西。有没有一句你一直信到现在的话?"\n\n'
'"在的!你觉得自己走到今天,最放不下或最骄傲的是什么?"'
),
}
style_examples = _opening_examples.get(
current_stage,
_opening_examples["childhood"],
)
else:
topics_heading = (
f"## 当前阶段({stage_name}\n"
"访谈结构化槽位里,这一阶段的主要问题在素材侧**已有覆盖**。"
"开场要像老朋友重逢:接近况、接续上次聊过的事、或任何用户可能想提起的新片段;"
"**禁止**为了凑问题而默认再从「童年在哪长大」等已覆盖模板重头盘问。"
)
task_question = (
"2. **问候 + 轻巧引子**:用一句温暖的话接上对话;若自然,可以问一个与近况、"
"想续上的回忆、或新冒出来的小事有关的问题。若不适合追问,问候 + 一句开放式引子即可。"
)
style_examples = (
"示例(仅供参考风格):\n"
'"嘿,又见面啦~ 今天有没有哪件事突然从脑子里冒出来,想跟我说说?"\n\n'
'"在的!上次聊到那儿我还记着,你后来还有想起什么细节吗?"'
)
profile_section = (
f"\n## 用户基本信息\n{user_profile_context}\n" if user_profile_context else ""
)
return f"""你是「岁月知己」。用户刚进对话,**还没说话**,请你先开口。**短、像微信**,一两句问候 + 一句具体问题即可,不要排比、不要文学描写。
{profile_section}
{topics_heading}
## 任务
1. 简短问候。
{task_question}
3. 自然、温暖,但**字数要少**。
## 格式
- 可用 [SPLIT] 分成最多 2 条;或一条里「问候 + 问题」。
- 禁止括号、思考过程;不要替用户编回答。
{style_examples}
直接输出:"""
def _build_era_context(current_stage: str, user_profile_context: str) -> str:
"""根据用户的人生阶段和出生年份,生成对应时代的历史/政治/文化背景提示"""
if not user_profile_context:
return ""
birth_year = None
birth_place = ""
for line in user_profile_context.split("\n"):
if "出生年份" in line:
try:
birth_year = int(line.split("")[1].strip().replace("", ""))
except (ValueError, IndexError):
pass
if "出生地" in line or "成长地" in line:
birth_place = line.split("")[1].strip() if "" in line else ""
if not birth_year:
return ""
stage_era_map = {
"childhood": (0, 12),
"education": (6, 22),
"career": (18, 50),
"family": (20, 50),
"belief": (30, 60),
}
age_range = stage_era_map.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}")
if not era_events:
return ""
place_hint = f" {birth_place}" if birth_place else ""
# 短提示即可,避免模型写长段「时代散文」
return (
f"\n## 时代参考(一两句带过即可,勿长篇)\n"
f"{era_start}-{era_end}{place_hint};可联想:{era_events[0]}"
+ (f"{era_events[1]}" if len(era_events) > 1 else "")
+ "\n"
)
def get_guided_conversation_prompt(
current_stage: str,
empty_slots: List[str],
filled_slots: Dict[str, str],
user_message: str,
conversation_turn: int = 0,
same_topic_turns: int = 0,
all_stages_coverage: Dict[str, Dict] = None,
detected_user_stage: str = "",
user_profile_context: str = "",
) -> str:
"""生成状态感知的对话提示词"""
stage_name_map = {
"childhood": "童年时光",
"education": "求学经历",
"career": "职业生涯",
"family": "家庭生活",
"belief": "人生信念",
}
current_stage_name = stage_name_map.get(current_stage, current_stage)
user_stage_name = (
stage_name_map.get(detected_user_stage, "") if detected_user_stage else ""
)
user_jumped = detected_user_stage and detected_user_stage != current_stage
empty_slots_readable = [SLOT_NAME_MAP.get(s, s) for s in empty_slots]
empty_slots_str = (
"".join(empty_slots_readable) if empty_slots_readable else "已聊得很充分"
)
filled_info = []
for key, value in filled_slots.items():
readable_key = SLOT_NAME_MAP.get(key, key)
filled_info.append(
f"{readable_key}: {value[:50]}..."
if len(value) > 50
else f"{readable_key}: {value}"
)
filled_slots_str = "\n".join(filled_info) if filled_info else "刚开始聊"
progress_lines = []
uncovered_stages = []
if all_stages_coverage:
for stage in ["childhood", "education", "career", "family", "belief"]:
cov = all_stages_coverage.get(stage, {})
filled_n = cov.get("filled", 0)
total_n = cov.get("total", 0)
sname = stage_name_map.get(stage, stage)
if filled_n == 0:
progress_lines.append(f" {sname}:还没聊到")
uncovered_stages.append(sname)
elif filled_n < total_n:
progress_lines.append(f" {sname}:聊了一些({filled_n}/{total_n}")
else:
progress_lines.append(f" {sname}:已聊得很充分 ✓")
progress_str = "\n".join(progress_lines) if progress_lines else ""
filled_count = len(filled_slots)
should_switch_topic = same_topic_turns >= 3 or (
filled_count >= 2 and same_topic_turns >= 2
)
should_lighten_mood = conversation_turn > 0 and conversation_turn % 5 == 0
should_try_new_stage = filled_count >= 3 and len(empty_slots) <= 2
related_stages = STAGE_RELATED_TOPICS.get(current_stage, [])
related_stages_str = "".join([stage_name_map.get(s, s) for s in related_stages])
style = RESPONSE_STYLES[conversation_turn % len(RESPONSE_STYLES)]
style_guidance = {
"empathy": "共情一两句即可",
"curious": "若还有未展开的细节可好奇问一个点;若上文已说清或可自然推断,只承接或换角度,**勿为凑问题而追问**",
"reflection": "可一句简短感慨,勿讲大道理",
"lighthearted": "轻松一点,别讲段子太长",
"connection": "可提「我也有过类似感受」一句,勿编造具体经历细节",
}.get(style, "")
dynamic_guidance = ""
if user_jumped:
dynamic_guidance += f"""
- **用户正在聊「{user_stage_name}」的话题,跟着他/她的节奏走,不要试图拉回「{current_stage_name}」**
- 顺着用户的思路,帮他/她把这个话题聊深聊透
- 这是很自然的事情,人回忆往事经常会跳跃,你要做的是陪伴和倾听"""
else:
if should_lighten_mood:
dynamic_guidance += "\n- 聊了一会儿了,可以适当轻松一下,聊点有趣的"
if should_switch_topic and empty_slots_readable:
dynamic_guidance += (
f"\n- 这个话题聊得差不多了,可以自然转到:{empty_slots_str}"
)
if should_try_new_stage and related_stages:
dynamic_guidance += (
f"\n- 如果自然的话,可以尝试聊聊相关的话题,比如{related_stages_str}"
)
uncovered_hint = ""
if not user_jumped and uncovered_stages and should_try_new_stage:
uncovered_hint = f"\n- 还没聊到的人生阶段有:{''.join(uncovered_stages)},如果聊天中有自然的契机,可以轻轻带一句,但不要刻意"
if user_jumped:
topic_desc = f"你们原本在聊「{current_stage_name}」,但用户自然地聊到了「{user_stage_name}」的内容"
else:
topic_desc = f"你们聊到了「{current_stage_name}」这个话题"
profile_section = ""
if user_profile_context:
profile_section = f"\n## 用户基本信息\n{user_profile_context}\n"
active_stage = (
detected_user_stage if user_jumped and detected_user_stage else current_stage
)
era_context = (
_build_era_context(active_stage, user_profile_context)
if settings.chat_era_context_enabled
else ""
)
prompt = f"""你是「岁月知己」,陪用户聊人生。**像微信:短句、少修辞、别写小作文。**
{topic_desc}
{profile_section}
## 本阶段已聊
{filled_slots_str}
## 还可聊的方向
{empty_slots_str}
## 进度
{progress_str}
{era_context}
## 用户刚才说
"{user_message}"
## 本轮语气
{style_guidance}
## 任务(短)
1. 先简短回应一句,不要总结成长文。
2. 用户若跳到别的人生阶段,跟着他聊,别硬拉回。
3. 需要追问时**只问一个**具体小问题;**不必每轮都问**;若用户已说明或语境已能推出(如谁买的、和谁),**别再为同一件事做 yes/no 确认**。
4. 用户只回简短肯定/否定(如「是的」「对」)时,**结合上文**理解,承接即可或问**新**角度,勿重复上一句已问过的事。
5. 可用 [SPLIT] 分成**最多 2 条**消息,每条都很短。
{dynamic_guidance}{uncovered_hint}
## 禁止
括号/思考过程;采访腔;**重复确认**用户档案、**上文已说**或**强暗示下已可知**的事实(包括无信息量的「是不是他/她…」式追问);别编用户没说的细节。
直接输出([SPLIT] 可选,最多 2 段):"""
return prompt
# 别名:访谈对话专用;勿与回忆录 `app.agents.memoir.prompts.get_memoir_editor_system_prompt` 混淆
get_interview_system_prompt = get_system_prompt