""" 对话 Agent 提示词模板(场景化承接 + 细节深挖 + 人物串联)。 """ from typing import Dict, List, Optional from app.agents.chat.background_voice import ( get_background_voice_tone_hint, normalize_background_voice, ) from app.agents.chat.occupation_context import get_occupation_chat_hint from app.agents.chat.output_rules import chat_output_rules from app.agents.chat.personas import ( get_interview_persona_tone_hint, normalize_interview_persona, ) from app.agents.chat.prompt_layers import ( assemble_guided_prompt, build_absolute_donts_block, build_behavior_policy_block, build_context_block, build_question_outline_block, build_reply_strategy_block, build_style_profile_block, ) from app.agents.stage_constants import STAGE_DISPLAY_ZH, STAGE_ERA_HINTS from app.agents.state_schema import KnownFact, PersonaThread from app.core.config import settings # 风格示例的单一事实源已迁至 `app.agents.style_profiles.ChatStyleProfile.reply_style_examples`; # 这里**不再**维护具体字面示例,避免同一模块被当作 few-shot 锚点反复注入,导致风格过拟合。 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": "人生经验", } def _compact_era_hint( current_stage: str, *, birth_year: int | None = None, era_place: str = "", ) -> str: if not birth_year: return "" birth_place = (era_place or "").strip() 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}") parts: List[str] = [] if era_events: place_hint = f" {birth_place}" if birth_place else "" parts.append( f"时代联想(口述里一两句带过即可):约 {era_start}-{era_end} 年{place_hint};" f"可提及 {era_events[0]}" + (f";{era_events[1]}" if len(era_events) > 1 else "") + "。" ) parts.append( "时代与流行文化(开放式,自然带入):\n" "- 可从当时的街景、媒介、校园与市井、年节习俗等**泛泛**起头,邀请用户讲自己的版本,勿替用户断言细节。\n" "- **优先开放式**问法;少用「你是不是也……」式半封闭逼认。\n" "- 与大事记呼应时点到为止,勿展开成长串史实。" ) return "\n".join(parts) + "\n" 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 = "", profile_birth_year: Optional[int] = None, profile_era_place: str = "", ) -> str: """空对话时 AI 先开口的提示词""" stage_name = STAGE_DISPLAY_ZH.get(current_stage, current_stage) bv_open = normalize_background_voice(background_voice) if empty_slots_readable: topics_str = "、".join(empty_slots_readable) topics_heading = ( f"## 当前建议话题({stage_name})\n可以从中选一个来问:{topics_str}" ) task_question = ( "2. 你是**主持式知己**:接着问一个**具体、好回答、有画面感**的问题,帮用户进入**人生回忆**叙述;" "优先落在上述还未聊透的方向上。不要问太宽泛的「有什么想聊的」「最近怎么样」。" "像把门敞开请人讲自己的故事,不要像面试第一题;一句里带一个小锚(地方、人物、物件或一天里的片段即可)。" "不要用「下面我们聊聊…」类未承接的硬切。好问题举例:「说到童年,你脑海里最先蹦出来的是哪个画面?」" ) else: topics_heading = ( f"## 当前阶段({stage_name})\n" "这一阶段的主要话题在素材侧**已有覆盖**。" "开场仍要**回到人生故事线**:优先接续上次聊过的片段、(若有)记忆线索里出现过的事,或当前阶段里**新鲜的一小角**;" "**禁止**为了凑问题而从「童年在哪长大」等已覆盖模板重头盘问;**也不要**把泛泛近况(「今天忙吗」「最近好吗」)当成默认主线。" ) task_question = ( "2. **问候 + 回忆向勾子**:温暖接话后,带一个与**口述回忆**有关的轻巧引子或具体问题;" "若接不上具体事,就用当前阶段的一个**有画面的开放式起头**,仍落在人生经历上,而非纯社交寒暄。" ) if bv_open == "cadre": opening_style_rules = ( "## 语境与语气(干部/机关)\n" "- 问候稳重、敬语适度;避免官样排比与过轻佻的网络撒娇语气。\n" ) elif bv_open == "military": opening_style_rules = ( "## 语境与语气(军队相关口述常见交流方式)\n" "- 简洁、得体;不用「嗨~」类过轻佻起势;不堆军事辞藻、不编军旅细节。\n" ) else: opening_style_rules = ( "## 风格\n" "- 像**温暖的谈话场主持人**:口语、自然、能接住人,但默认把用户带进**人生回忆**叙述;" "可轻快,允许一点画面感,不要排比和长段文学描写。\n" ) profile_lines: List[str] = [] if user_profile_context.strip(): profile_lines.append(user_profile_context.strip()) occ = get_occupation_chat_hint(occupation, background_voice) if occ: profile_lines.append(occ) profile_section = "" if profile_lines: profile_section = "## 用户信息\n" + "\n".join(profile_lines) + "\n" persona_key = normalize_interview_persona(persona) persona_tone = get_interview_persona_tone_hint(persona_key) voice_tone = get_background_voice_tone_hint(background_voice) tone_bits = [t for t in (persona_tone, voice_tone) if t] tone_paragraph = "" if tone_bits: tone_paragraph = " " + " ".join(tone_bits) + "\n\n" opening_head = ( "你是「岁月知己」——主持式知己:用户刚进对话,**还没说话**,请你先开口。" "语气像老朋友,但**职责是帮对方开口讲人生故事**;两三句内问候 + **一个落在当前阶段或建议话题上的、有画面感的问题**;" "不要排比、不要长段文学描写,**不要**把泛泛问近况当主菜。\n\n" ) if bv_open != "default": opening_head = ( "你是「岁月知己」——主持式知己:用户刚进对话,**还没说话**,请你先开口。" "**短**;两三句内问候 + **一个回忆向的具体问题**;不要排比、不要文学描写。\n\n" ) era_opening_line = "" if ( settings.chat_era_context_enabled and profile_birth_year is not None and _compact_era_hint( current_stage, birth_year=profile_birth_year, era_place=profile_era_place, ) ): era_opening_line = ( "4. 用户资料里已有出生年份与时代参考时,问候里的具体问题可**轻轻带一点年代氛围**(点到为止)," "勿写成长段描写或排比。\n" ) return f"""{opening_head}{tone_paragraph}{profile_section}{topics_heading} ## 任务 1. 简短问候。 {task_question} 3. 自然、温暖。 {era_opening_line} ## 格式 - 可用 [SPLIT] 分成最多 2 条;或一条里「问候 + 问题」。 - {chat_output_rules()} 不要替用户编回答。 {opening_style_rules} 直接输出(仅自然口语,无 Markdown):""" def get_guided_conversation_prompt( current_stage: str, empty_slots: List[str], filled_slots: Dict[str, str], all_stages_coverage: Optional[Dict[str, Dict]] = None, detected_user_stage: str = "", user_profile_context: str = "", persona: str = "default", memory_evidence_text: str = "", background_voice: str = "default", occupation: str = "", profile_birth_year: Optional[int] = None, profile_era_place: str = "", known_facts: list[KnownFact] | None = None, persona_threads: list[PersonaThread] | None = None, recent_questions: list[str] | None = None, turn_directive_block: str = "", ) -> str: """生成状态感知的对话提示词;用户原话仅以 HumanMessage 传入,不写入本 system 文本。""" persona_key = normalize_interview_persona(persona) persona_tone = get_interview_persona_tone_hint(persona_key) voice_tone = get_background_voice_tone_hint(background_voice) tone_bits = [t for t in (persona_tone, voice_tone) if t] tone_line = "" if tone_bits: tone_line = " " + " ".join(tone_bits) user_jumped = bool(detected_user_stage and detected_user_stage != current_stage) active_stage = ( detected_user_stage if user_jumped and detected_user_stage else current_stage ) era_line = "" if settings.chat_era_context_enabled: era_line = _compact_era_hint( active_stage, birth_year=profile_birth_year, era_place=profile_era_place, ) empty_slots_readable = [SLOT_NAME_MAP.get(s, s) for s in empty_slots] # ---- Context 层:纯状态与素材 ---- topic_and_context_block = build_context_block( current_stage=current_stage, detected_user_stage=detected_user_stage, empty_slots_readable=empty_slots_readable, filled_slots=filled_slots, slot_name_map=SLOT_NAME_MAP, all_stages_coverage=all_stages_coverage, user_profile_context=user_profile_context, occupation=occupation, background_voice=background_voice, known_facts=known_facts, persona_threads=persona_threads, recent_questions=recent_questions, memory_evidence_text=memory_evidence_text, era_line=era_line, ) question_outline_block = build_question_outline_block(current_stage, empty_slots) # ---- BehaviorPolicy 层:通用行为规则(本轮模式由 TurnPlan 单独注入) ---- behavior_policy_block = build_behavior_policy_block() reply_strategy_block = build_reply_strategy_block() absolute_donts_block = build_absolute_donts_block(chat_output_rules()) # ---- StyleProfile 层:口吻 + 文采密度 + 成稿质量导向 ---- style_profile_block = build_style_profile_block( persona=persona, background_voice=background_voice ) return assemble_guided_prompt( turn_directive_block=turn_directive_block, topic_and_context_block=topic_and_context_block, question_outline_block=question_outline_block, behavior_policy_block=behavior_policy_block, style_profile_block=style_profile_block, reply_strategy_block=reply_strategy_block, absolute_donts_block=absolute_donts_block, intro_tone_line=tone_line, ) # 运行时 prompt 生成走 `prompt_layers.assemble_guided_prompt`。 # 旧的超大 system prompt 已拆入 BehaviorPolicy / Context / StyleProfile 三层,此处不再保留快照。 def get_re_greeting_prompt( current_stage: str, empty_slots_readable: List[str], user_profile_context: str = "", persona: str = "default", background_voice: str = "default", occupation: str = "", profile_birth_year: Optional[int] = None, profile_era_place: str = "", idle_hours: float = 6.0, ) -> str: """老对话回访问候提示词:用户带着已有历史回到对话,AI 先开口做承接式问候。""" stage_name = STAGE_DISPLAY_ZH.get(current_stage, current_stage) bv = normalize_background_voice(background_voice) if idle_hours >= 168: idle_phrase = "好一阵子没聊了" elif idle_hours >= 48: idle_phrase = "好几天没聊了" elif idle_hours >= 20: idle_phrase = "隔了一天" else: idle_phrase = "今天又见面" if empty_slots_readable: topics_str = "、".join(empty_slots_readable[:4]) topic_hint = ( f"## 当前阶段({stage_name})还可以聊\n" f"如果上次聊过的事不便直接接续,可从这些方向里挑一个落点:{topics_str}。" ) else: topic_hint = ( f"## 当前阶段({stage_name})\n" "这一阶段主要话题已有覆盖;优先回到上次聊过的人/事/地方,做温和的承接。" ) if bv == "cadre": style_note = "## 语气\n稳重、敬语适度;问候不油滑、不堆排比。" elif bv == "military": style_note = "## 语气\n简洁、得体;不过度起势、不堆军事辞藻。" else: style_note = "## 语气\n像许久未见的老朋友,温暖而克制;不要排比、不要长段文学描写。" profile_lines: List[str] = [] if user_profile_context.strip(): profile_lines.append(user_profile_context.strip()) occ = get_occupation_chat_hint(occupation, background_voice) if occ: profile_lines.append(occ) profile_section = "" if profile_lines: profile_section = "## 用户信息\n" + "\n".join(profile_lines) + "\n" persona_key = normalize_interview_persona(persona) persona_tone = get_interview_persona_tone_hint(persona_key) voice_tone = get_background_voice_tone_hint(background_voice) tone_bits = [t for t in (persona_tone, voice_tone) if t] tone_paragraph = "" if tone_bits: tone_paragraph = " " + " ".join(tone_bits) + "\n\n" head = ( "你是「岁月知己」——主持式知己。用户带着**已有的对话历史**回到这里,**还没说话**,请你先开口。" f"语境:距上次消息已经{idle_phrase}。" "**职责**:用一句温暖的承接打招呼,让对方感到「我记得你上次说过的事」,再轻轻递上一个**回忆向**的钩子,把话头交还给他。\n\n" "## 要求\n" "1. **必须**轻轻引用历史里的具体人/事/地方/物件做承接(一两个细节即可,不要罗列),不要空喊「上次聊得很好」。\n" "2. **不要**用与刚开新对话相同的「您好/你好呀」式硬开场;像「上次你说到 X,今天想接着讲讲吗?」更合适。\n" "3. 钩子要**具体、好回答、有画面感**,落在人生回忆里;不要问「最近怎么样」「今天忙吗」这种纯社交寒暄。\n" "4. 若历史里没有可用细节,可从「当前阶段还可以聊」里挑一个轻巧落点;仍要避免泛泛盘问。\n" "5. 简短:两三句内,不要排比、不要长段。\n" ) return f"""{head}{tone_paragraph}{profile_section}{topic_hint} {style_note} ## 格式 - 可用 [SPLIT] 分成最多 2 条;或一条里「承接 + 钩子」。 - {chat_output_rules()} 不要替用户编回答。 直接输出(仅自然口语,无 Markdown):""" _STAGE_TOPIC_CHIP_BANK: Dict[str, List[tuple[str, str]]] = { "childhood": [ ("place", "童年长大的地方"), ("people", "童年里重要的人"), ("daily_life", "童年的一天"), ("turning_event", "童年最难忘的一件事"), ("emotion", "童年最深的感受"), ], "education": [ ("school", "学生时代的学校"), ("city", "求学的城市"), ("motivation", "读书时的动力"), ("challenge", "求学路上的难关"), ("change", "求学带来的变化"), ], "career": [ ("job", "做过的工作"), ("environment", "工作的环境"), ("decision", "职业里的关键决定"), ("pressure", "工作中的压力"), ("growth", "职业上的成长"), ], "family": [ ("relationship", "家人之间的关系"), ("conflict", "家里的矛盾与化解"), ("support", "家人之间的相互支持"), ("responsibility", "肩上的家庭责任"), ], "later_life": [ ("value", "现在最看重的事"), ("regret", "心里的遗憾"), ("pride", "最骄傲的事"), ("lesson", "想留下的人生经验"), ], } def build_topic_chips( current_stage: str, empty_slots: List[str], *, max_chips: int = 4, ) -> List[Dict[str, str]]: """根据当前阶段与空 slot 列表生成 quick-start 话题 chips。 返回结构:[{"id": slot_key, "label": 短标签, "text": 用户点击后发出的句子}] """ stage_bank = _STAGE_TOPIC_CHIP_BANK.get(current_stage) or [] seen: set[str] = set() chips: List[Dict[str, str]] = [] # 优先从「当前阶段空 slot」挑选(与开场提问方向一致) empty_set = {s for s in empty_slots if s} for slot_key, label in stage_bank: if slot_key in empty_set and slot_key not in seen: chips.append( { "id": slot_key, "label": label, "text": f"我想聊聊{label}", } ) seen.add(slot_key) if len(chips) >= max_chips: return chips # 不足则用阶段默认话题补齐 for slot_key, label in stage_bank: if slot_key in seen: continue chips.append( { "id": slot_key, "label": label, "text": f"我想聊聊{label}", } ) seen.add(slot_key) if len(chips) >= max_chips: return chips return chips __all__ = [ "SLOT_NAME_MAP", "build_topic_chips", "get_guided_conversation_prompt", "get_opening_prompt", "get_re_greeting_prompt", ]