Files
life-echo/api/app/agents/chat/prompts_conversation.py

461 lines
19 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_chat_block,
normalize_background_voice,
)
from app.agents.chat.occupation_context import get_occupation_chat_hint
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.agents.chat.output_rules import chat_output_rules
from app.agents.stage_constants import CHAT_STAGES, STAGE_DISPLAY_ZH, STAGE_ERA_HINTS
from app.core.config import settings
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"],
}
def _guided_voice_intro_line(background_voice: str) -> str:
"""顶部角色描述(具体「接住」写法集中在 ## 你要做的)。"""
return (
"你是「岁月知己」,像老朋友陪用户聊人生。"
"短句为主,遵守下方「本轮回复长度」档位。"
)
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 = "",
) -> str:
"""空对话时 AI 先开口的提示词"""
stage_name = STAGE_DISPLAY_ZH.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 ""
occ_hint = get_occupation_chat_hint(occupation, background_voice)
occ_section = f"\n{occ_hint}\n" if occ_hint 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}{occ_section}
## 任务
1. 简短问候。
{task_question}
3. 自然、温暖,但**字数要少**。
## 格式
- 可用 [SPLIT] 分成最多 2 条;或一条里「问候 + 问题」。
- {chat_output_rules()} 不要替用户编回答。
{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 ""
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}")
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:
"""仅输出当前档位说明,减少重复 tokens。"""
safe = (
current_mode
if current_mode in ("brief", "standard", "expanded")
else "standard"
)
mode_desc = {
"brief": "一两句话,简短温暖;可带一个小问题也可以不带。",
"standard": "承接对方 + 最多一个具体问题;像朋友聊天,不写长段。",
"expanded": "用户本轮内容或情绪较浓——可多一两句承接核心点,再自然追问;仍控制在两段以内。",
}
desc = mode_desc[safe]
return f"""## 本轮回复长度
**当前档位:{safe}**
{desc}
"""
def get_guided_conversation_prompt(
current_stage: str,
empty_slots: List[str],
filled_slots: Dict[str, str],
user_message: str,
conversation_turn_total: 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",
occupation: str = "",
) -> str:
"""生成状态感知的对话提示词。
``user_message`` 仅用于启发式(新细节/闲聊/情绪),其原文**不会**写入本提示,用户话仅以最终 HumanMessage 传入模型。
``conversation_turn_total`` 为 Redis 全量历史的轮次数,不受窗口截断影响。
"""
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)
current_stage_name = STAGE_DISPLAY_ZH.get(current_stage, current_stage)
user_stage_name = (
STAGE_DISPLAY_ZH.get(detected_user_stage, "") if detected_user_stage else ""
)
user_jumped = bool(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: List[str] = []
uncovered_stages: List[str] = []
if all_stages_coverage:
cur_cn = STAGE_DISPLAY_ZH.get(current_stage, current_stage)
progress_lines.append(f"当前阶段:{cur_cn}")
for stage in CHAT_STAGES:
cov = all_stages_coverage.get(stage, {})
filled_n = cov.get("filled", 0)
total_n = cov.get("total", 0)
sname = STAGE_DISPLAY_ZH.get(stage, stage)
if total_n <= 0:
continue
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}")
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_total > 0 and conversation_turn_total % 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_DISPLAY_ZH.get(s, s) for s in related_stages])
emotional = heuristic_likely_emotional(user_message)
tone_section = f"{persona_block}\n" if persona_block else ""
followup_trigger_block = "## 本轮追问判定\n"
followup_trigger_block += (
"总体原则见「对话方向」与「你要做的」;以下为仅本轮生效的判定:\n"
)
if likely_new:
followup_trigger_block += (
"**【本轮判定】用户补充了新细节 → 承接后须追问 1 句。**\n"
)
elif emotional:
followup_trigger_block += (
"**【本轮判定】用户情绪较浓 → 先好好共情承接,不必急着追问。**\n"
)
else:
followup_trigger_block += (
"(无特殊判定时按惯例:新线头追问一句,否则可只承接。)\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 ""
occ_hint = get_occupation_chat_hint(occupation, background_voice)
occ_section = f"\n{occ_hint}\n" if occ_hint else ""
intro_line = _guided_voice_intro_line(background_voice)
prompt = f"""{intro_line}
{topic_desc}
{reply_length_section}
{profile_section}
{voice_section}{occ_section}
## 本阶段已聊
{filled_slots_str}
## 还可聊的方向
{empty_slots_str}
## 进度
{progress_str}
{era_context}
{memoir_orientation_block}{memory_section}{followup_trigger_block}
{tone_section}
## 你要做的
1. **先接住对方**——一句真诚回应,不要写成总结或讲评。
2. 用户跳到别的人生阶段,跟着聊,别硬拉回。
3. **最多追问一个**具体、好答的问题(参照上方「本轮追问判定」);无需追问时,只承接就好。
4. 用户回「嗯」「对」之类,结合上文理解,承接或换个新角度,不要重复上一轮问过的事。
5. 可用 [SPLIT] 分成**最多 2 条**消息。
{dynamic_guidance}{uncovered_hint}
## 不要做的
{chat_output_rules()}
直接输出(仅自然口语,无任何括号前缀或旁白):"""
return prompt