Files
life-echo/api/app/agents/chat/prompts_conversation.py
Kevin 37df0d48ac fix(chat): 禁止开场/引导回复中的括号策略与元旁白
收紧 prompts_conversation 中开场与引导对话的格式约束,明确禁止括号内策略说明、舞台旁白与元注释,并要求仅输出自然口语。
2026-04-01 09:33:40 +08:00

582 lines
24 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, Optional
from app.agents.chat.background_voice import (
get_background_voice_chat_block,
normalize_background_voice,
)
from app.agents.chat.interview_reply_length import (
heuristic_likely_chit_chat,
heuristic_likely_emotional,
heuristic_likely_new_detail,
)
from app.agents.chat.personas import (
get_interview_persona_block,
get_opening_persona_line,
normalize_interview_persona,
)
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 _guided_voice_intro_line(background_voice: str) -> str:
"""顶部角色描述:温暖陪聊,但仍控制篇幅。"""
if normalize_background_voice(background_voice) == "default":
return (
"你是「岁月知己」,像老朋友陪用户聊人生。"
"**先真诚接住对方的话**,再决定要不要追问;短句为主,但**接住情绪比控制字数更重要**。"
)
return (
"你是「岁月知己」,像老朋友陪用户聊人生。"
"**先真诚承接对方一句,再自然推进**;短句为主,遵守下方长度档位。"
)
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 = "",
persona: str = "default",
background_voice: str = "default",
) -> 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"],
)
bv = normalize_background_voice(background_voice)
if bv == "cadre":
style_examples += (
"\n(干部/机关语境:问候稳重、不用「嗨~」;示例可参考)\n"
'"您好,想听听您的人生故事。您小时候是在哪儿长大的?哪一段印象最深?"\n\n'
'"您好。今天想从您印象最深的一件事聊起,可以吗?"'
)
elif bv == "military":
style_examples += (
"\n(军队语境:简洁、得体;不用「嗨~」;示例可参考)\n"
'"您好。想听听您的经历。您童年印象最深的一件事是什么?"\n\n'
'"您好,有空的话想聊聊您的人生故事。您小时候在哪儿长大?"'
)
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 ""
)
persona_key = normalize_interview_persona(persona)
opening_persona = get_opening_persona_line(persona_key)
persona_extra = f"\n## 访谈性格\n{opening_persona}\n" if opening_persona else ""
voice_block = get_background_voice_chat_block(background_voice)
voice_section = f"\n{voice_block}\n" if voice_block else ""
bv = normalize_background_voice(background_voice)
if bv == "default":
opening_head = (
"你是「岁月知己」。用户刚进对话,**还没说话**,请你先开口。"
"**短、像微信**,一两句问候 + 一句具体问题即可,不要排比、不要文学描写。"
)
else:
opening_head = (
"你是「岁月知己」。用户刚进对话,**还没说话**,请你先开口。"
"**短;两三句内完成问候 + 一个具体问题**;不要排比、不要文学描写。"
)
return f"""{opening_head}
{profile_section}
{topics_heading}
{persona_extra}{voice_section}
## 任务
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 _format_reply_length_section(current_mode: str) -> str:
"""软提示:本轮档位 + 三档说明(模型始终可见完整对照)。"""
safe = (
current_mode
if current_mode in ("brief", "standard", "expanded")
else "standard"
)
return f"""## 本轮回复长度
**当前档位:{safe}**
- **brief**:一两句话,简短温暖地接住对方,可以带一个小问题也可以不带。
- **standard**:承接 + 最多一个具体问题;像朋友聊天,不写长段。
- **expanded**:用户本轮分享了较多内容或情绪较浓——可以多说一两句承接对方话里的核心点,表达你听到了、你在意,再自然追问;**仍控制在两段以内**。
"""
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: Optional[Dict[str, Dict]] = None,
detected_user_stage: str = "",
user_profile_context: str = "",
persona: str = "default",
memory_evidence_text: str = "",
reply_length_mode: str = "standard",
background_voice: str = "default",
) -> str:
"""生成状态感知的对话提示词(档位由 Agent 计算的 ReplyPlan 传入,不在此重复推导)。"""
persona_key = normalize_interview_persona(persona)
persona_block = get_interview_persona_block(persona_key)
likely_new = heuristic_likely_new_detail(user_message)
likely_chit = heuristic_likely_chit_chat(user_message)
reply_length_section = _format_reply_length_section(reply_length_mode)
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 >= 5 or (
filled_count >= 3 and same_topic_turns >= 4
)
should_lighten_mood = conversation_turn > 0 and conversation_turn % 7 == 0
should_try_new_stage = filled_count >= 4 and len(empty_slots) <= 1
related_stages = STAGE_RELATED_TOPICS.get(current_stage, [])
related_stages_str = "".join([stage_name_map.get(s, s) for s in related_stages])
emotional = heuristic_likely_emotional(user_message)
if persona_block:
tone_section = f"{persona_block}\n"
else:
tone_section = ""
followup_trigger_block = """## 什么时候追问、什么时候只承接
**该追问**(承接后带 1 个具体问题):
- 出现**新的人名、新关系、新情节**,上文还没展开过;
- 用户邀你接话(如「你猜猜」);
- 本阶段仍有未聊方向,且对方话里露出可深挖的线头。
**可以只承接、不追问**
- 本轮几乎无新信息(「嗯」「对」「行」);
- 用户明确要结束或换话题;
- 再问会重复上文已说清的事。
**用户在表达情绪时**:先好好接住情绪,让对方感觉被听到、被理解;不急着追问,等情绪有着落后再自然引回。
"""
if likely_new:
followup_trigger_block += (
"\n**【本轮判定】用户补充了新细节 → 承接后须追问 1 句。**\n"
)
if emotional and not likely_new:
followup_trigger_block += (
"\n**【本轮判定】用户情绪较浓 → 先好好共情承接,不必急着追问。**\n"
)
memoir_orientation_lines = [
"## 对话方向",
"追问与承接**优先服务于人生故事与回忆录素材**,但不要让对方觉得你在走流程。",
"若用户**明显在闲聊**,以陪聊为主,**不要**用回忆录式问题打断。",
"若用户一边回忆一边开玩笑,先接情绪,再轻轻带回一个与经历相关的小问题。",
]
if likely_chit:
memoir_orientation_lines.append(
"**【本轮偏闲聊】** → 以承接与陪聊为主;若用户自然带回经历,再追问。"
)
memoir_orientation_block = "\n".join(memoir_orientation_lines) + "\n"
memory_section = ""
mem_trim = (memory_evidence_text or "").strip()
if mem_trim:
memory_section = (
"## 相关记忆摘录(仅供衔接,禁止编造)\n"
"以下为系统从用户**过往口述**中检索到的摘录,**不是**用户本轮亲口新说的内容。\n"
"承接时可自然用「你之前提过……」「上次你说到……」等口语,不要把摘录里的细节写成本轮用户新告诉你的事实;禁止编造摘录未出现的内容。\n\n"
f"{mem_trim}\n\n"
)
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:
if likely_new:
dynamic_guidance += f"\n- 若用户本轮**刚补充**新细节,请先就这一点追问一句,再自然转到未聊方向:{empty_slots_str}"
else:
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 ""
)
voice_block = get_background_voice_chat_block(background_voice)
voice_section = f"\n{voice_block}\n" if voice_block else ""
intro_line = _guided_voice_intro_line(background_voice)
prompt = f"""{intro_line}
{topic_desc}
{reply_length_section}
{profile_section}
{voice_section}
## 本阶段已聊
{filled_slots_str}
## 还可聊的方向
{empty_slots_str}
## 进度
{progress_str}
{era_context}
## 用户刚才说
"{user_message}"
{memoir_orientation_block}{memory_section}{followup_trigger_block}
{tone_section}
## 你要做的
1. **先接住对方**——一句真诚回应,不要写成总结或讲评。
2. 用户跳到别的人生阶段,跟着聊,别硬拉回。
3. **最多追问一个**具体、好答的问题(参照上方「什么时候追问」);无需追问时,只承接就好。
4. 用户回「嗯」「对」之类,结合上文理解,承接或换个新角度,不要重复上一轮问过的事。
5. 可用 [SPLIT] 分成**最多 2 条**消息。
{dynamic_guidance}{uncovered_hint}
## 不要做的
**禁止**输出括号、括号内的策略/舞台说明(例如「(先接住情绪)」「(共情)」)、思考过程或任何元注释——这些只存在于系统指令里,**绝不可**出现在你对用户说的话中;采访腔(「我注意到」「我想了解」);重复确认对方已经说过或能推断出的信息;编造对方没说的细节。
直接输出(仅自然口语,无任何括号前缀或旁白):"""
return prompt
# 别名:访谈对话专用;勿与回忆录 `app.agents.memoir.prompts.get_memoir_editor_system_prompt` 混淆
get_interview_system_prompt = get_system_prompt