""" 对话 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.stage_constants import CHAT_STAGES, STAGE_DISPLAY_ZH 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 条;或一条里「问候 + 问题」。 - **禁止**括号、括号内策略/旁白(如「(先接住情绪)」)、思考过程;不要替用户编回答。 {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), # chapter / 防御性 key:与 belief 同档年龄参照 "beliefs": (30, 60), "summary": (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: """仅输出当前档位说明,减少重复 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} ## 不要做的 **禁止**输出括号、括号内的策略/舞台说明(例如「(先接住情绪)」「(共情)」)、思考过程或任何元注释——这些只存在于系统指令里,**绝不可**出现在你对用户说的话中;采访腔(「我注意到」「我想了解」);重复确认对方已经说过或能推断出的信息;编造对方没说的细节。 直接输出(仅自然口语,无任何括号前缀或旁白):""" return prompt