diff --git a/api/app/agents/chat/interview_agent.py b/api/app/agents/chat/interview_agent.py index bcee84c..3e3e3b8 100644 --- a/api/app/agents/chat/interview_agent.py +++ b/api/app/agents/chat/interview_agent.py @@ -224,6 +224,8 @@ class InterviewAgent: user_profile_context: str = "", background_voice: str = "default", occupation: str = "", + profile_birth_year: Optional[int] = None, + profile_era_place: str = "", ) -> List[str]: """生成空对话开场白,不持久化(由 Orchestrator 负责)""" if not self.llm: @@ -239,6 +241,8 @@ class InterviewAgent: persona=persona, background_voice=background_voice, occupation=occupation, + profile_birth_year=profile_birth_year, + profile_era_place=profile_era_place, ) hw = await get_history_with_window( conversation_id, diff --git a/api/app/agents/chat/orchestrator.py b/api/app/agents/chat/orchestrator.py index f3fec02..7f14ad8 100644 --- a/api/app/agents/chat/orchestrator.py +++ b/api/app/agents/chat/orchestrator.py @@ -403,6 +403,8 @@ class ChatOrchestrator: user_profile_context: str = "", background_voice: str = "default", occupation: str = "", + profile_birth_year: Optional[int] = None, + profile_era_place: str = "", ) -> List[str]: """ 委托 InterviewAgent 生成访谈开场白(持久化由调用方 ConversationHistoryStore 负责)。 @@ -413,4 +415,6 @@ class ChatOrchestrator: user_profile_context=user_profile_context, background_voice=background_voice, occupation=occupation, + profile_birth_year=profile_birth_year, + profile_era_place=profile_era_place, ) diff --git a/api/app/agents/chat/personas.py b/api/app/agents/chat/personas.py index 01158d1..b54407a 100644 --- a/api/app/agents/chat/personas.py +++ b/api/app/agents/chat/personas.py @@ -26,8 +26,14 @@ def get_interview_persona_tone_hint(persona: str) -> str: if key == "default": return "" if key == "warm_listener": - return "偏倾听与承接,语气柔和、少打断;不审问感,一次最多一个具体问题。" - return "爱把人往一个具体细节里带;短句像微信,一次最多一个具体问题,不重复上文已清楚的事。" + return ( + "偏倾听与承接,语气柔和、少打断;不审问感,一次最多一个具体问题。" + "对方愿意展开时,可温和多问一层意义或影响。" + ) + return ( + "爱把人往一个具体细节里带;事实清楚后可追问对自我认知或后来选择的影响;" + "短句像微信,一次最多一个具体问题,不重复上文已清楚的事。" + ) def get_interview_persona_block(persona: str) -> str: diff --git a/api/app/agents/chat/prompts_conversation.py b/api/app/agents/chat/prompts_conversation.py index dc7d40c..6b31e7c 100644 --- a/api/app/agents/chat/prompts_conversation.py +++ b/api/app/agents/chat/prompts_conversation.py @@ -76,16 +76,22 @@ def _compact_era_hint( 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"时代联想(口述里一两句带过即可):约 {era_start}-{era_end} 年{place_hint};" - f"可提及 {era_events[0]}" - + (f";{era_events[1]}" if len(era_events) > 1 else "") - + "。" + 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( @@ -95,9 +101,12 @@ def get_opening_prompt( 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 = ( @@ -107,50 +116,6 @@ def get_opening_prompt( "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" @@ -162,10 +127,21 @@ def get_opening_prompt( "2. **问候 + 轻巧引子**:温暖接话;若自然可问一个与近况或回忆有关的问题," "不适合追问时问候 + 开放式引子即可。" ) - style_examples = ( - "示例(仅供参考风格):\n" - '"嘿,又见面啦~ 今天有没有哪件事突然从脑子里冒出来,想跟我说说?"\n或\n' - '"在的!上次聊到那儿我还记着,你后来还有想起什么细节吗?"' + + 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] = [] @@ -186,29 +162,42 @@ def get_opening_prompt( if tone_bits: tone_paragraph = " " + " ".join(tone_bits) + "\n\n" - bv = normalize_background_voice(background_voice) opening_head = ( "你是「岁月知己」。用户刚进对话,**还没说话**,请你先开口。" "**短、像微信**,一两句问候 + 一个具体问题即可,不要排比、不要文学描写。\n\n" ) - if bv != "default": + 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()} 不要替用户编回答。 -{style_examples} - +{opening_style_rules} 直接输出(仅自然口语,无 Markdown):""" @@ -310,13 +299,13 @@ def get_guided_conversation_prompt( memory_section = ( "## 相关记忆摘录(仅供衔接,禁止编造)\n" "以下为系统从用户**过往口述**中检索到的摘录,**不是**用户本轮亲口新说的内容。\n" - "承接时可自然用「你之前提过……」等口语,不要把摘录里的细节写成本轮用户新说的;" + "承接时可点明来自先前口述,不要把摘录里的细节写成本轮用户新说的;" "禁止编造摘录未出现的内容。\n\n" f"{mem_trim}\n\n" ) progress_block = f"## 进度\n{progress_str}\n" if progress_str else "" - era_block = f"{era_line}\n" if era_line else "" + era_block = f"## 时代与氛围参考\n{era_line}\n" if era_line else "" return f"""你是「岁月知己」,像老朋友陪用户聊人生。短句为主,像微信聊天。{tone_line} @@ -330,8 +319,10 @@ def get_guided_conversation_prompt( {progress_block}{era_block}{memory_section}## 你要做的 - **先接住对方**——一句真诚回应,不要写成总结或讲评。 +- **共情与轻量自我表露**:在接住的基础上,可用**一两句极短**的第一人称情绪承接(不展开成故事),**不得**编造具体时间、地点、人物与事件等你不知道的细节。 +- **意义向深挖(看准时机)**:当对方已讲出较具体的情节、人或选择时,可温和多问一层——当时怎么看这件事、后来有没有反过来影响性格或抉择;与「还可聊的方向」并存时,优先用这类意义问题**补缺口**,而非机械换话题。**情绪仍浓时**只承接、不深问。 - 你自己判断该追问还是只承接:有新线头就顺着问一个具体的事;情绪浓就好好接住、不必急着追问;明显闲聊就陪聊;用户只说「嗯」「对」则结合上文承接或换个角度。 -- 可以用「我能想象……」「那时候大概……」轻轻接话,但不可编造具体人名、时间、事件等你不知道的细节。 +- 可泛泛接话以承接氛围或感受,但不可编造具体人名、时间、事件等你不知道的细节。 - 不要重复上一轮问过的事;用户跳到别的人生阶段,跟着聊,别硬拉回。 - 追问与承接服务于人生故事素材,但不要让对方觉得在走审问式流程;**最多**抛一个具体问题,也可以不追问。 - 可用 [SPLIT] 分成**最多 2 条**消息。 diff --git a/api/app/agents/chat/prompts_profile.py b/api/app/agents/chat/prompts_profile.py index 16fc66c..d81e23f 100644 --- a/api/app/agents/chat/prompts_profile.py +++ b/api/app/agents/chat/prompts_profile.py @@ -32,14 +32,9 @@ def get_profile_greeting_prompt(missing_fields: List[str], nickname: str = "") - ## 规则 1. 不要一次问所有问题,每次只问 1-2 个 2. 如果用户已经在对话中提到了某些信息,不要重复问 -3. 用口语化、亲切的方式提问 +3. 用口语化、亲切的方式提问;问法自选,勿套用固定模板句 4. 当所有信息都收集完后,自然过渡到人生故事访谈 -## 提问示例 -- "你是哪一年出生的呀?" -- "你是在哪里出生的?小时候也是在那里长大的吗?" -- "你现在是做什么工作的呀?或者之前主要从事什么职业?" - ## 严格禁止 - {chat_output_rules()} - 禁止说"我需要收集信息"之类的机械话 @@ -75,13 +70,7 @@ def get_profile_extraction_prompt( 需要提取的字段(只提取确实在对话中出现过的): {missing_names} -输出示例(只含确实提到的字段;无则 {{}}): -{{ - "birth_year": 1965, - "birth_place": "湖南长沙", - "grew_up_place": "湖南长沙", - "occupation": "教师" -}} +输出为 JSON 对象:键只能来自上述字段名;birth_year 为四位整数,其余为字符串。仅填充口述中明确出现的键;无任何可提取内容则返回 {{}}。 规则: 1. birth_year 填整数(四位数),如"65年出生"转为 1965 @@ -119,7 +108,7 @@ def get_profile_followup_prompt( {filled_str} 用户本轮消息在对话末尾。请对用户的回答做出温暖的回应,然后自然地过渡到人生故事的访谈。 -可以说类似「了解了!那我们现在开始聊聊你的人生故事吧」这样的话;{stage_hint} +过渡语自拟,勿机械套话;{stage_hint} **不要**默认只问童年,除非用户刚才聊的正是童年。 回复格式:多条消息用 [SPLIT] 分隔。 diff --git a/api/app/agents/chat/stage_prompts.py b/api/app/agents/chat/stage_prompts.py index 62716c9..0c1126d 100644 --- a/api/app/agents/chat/stage_prompts.py +++ b/api/app/agents/chat/stage_prompts.py @@ -19,14 +19,14 @@ def get_chat_stage_detection_prompt(user_message: str, current_stage: str) -> st allowed = "、".join(CHAT_STAGES) return f"""你是访谈助手。根据用户**本轮**话语,判断其**主要**在谈论哪一段人生经历。 -系统当前跟踪的阶段(仅供参考,不要默认沿用;以用户实质内容为准):{current_stage} +系统当前跟踪的阶段(勿默认沿用;以用户实质内容为准):{current_stage} 可选阶段(detected_stage 的值必须恰好为下列之一):{allowed} 用户话语: "{user_message}" -输出形状示例:{{"detected_stage":"education"}} +仅输出一行 JSON,且只含键 detected_stage,值为上列英文阶段键之一。 规则: 1. 根据**本轮**与人生故事相关的实质内容判断主阶段;不要因系统当前阶段而强行归类。 diff --git a/api/app/features/conversation/ws/router.py b/api/app/features/conversation/ws/router.py index b03d573..5d85dc2 100644 --- a/api/app/features/conversation/ws/router.py +++ b/api/app/features/conversation/ws/router.py @@ -200,6 +200,7 @@ async def websocket_endpoint( grew_up_place=user.grew_up_place, occupation=user.occupation, ) + era_place = (user.grew_up_place or user.birth_place or "") or "" opening_messages = ( await chat_orchestrator.generate_opening_message( conversation_id=conversation_id, @@ -209,6 +210,8 @@ async def websocket_endpoint( user.occupation ), occupation=user.occupation or "", + profile_birth_year=user.birth_year, + profile_era_place=era_place, ) ) ai_msg_id = await ConversationHistoryStore( diff --git a/api/tests/test_interview_prompts.py b/api/tests/test_interview_prompts.py index cfe21c6..ec4cb6d 100644 --- a/api/tests/test_interview_prompts.py +++ b/api/tests/test_interview_prompts.py @@ -43,6 +43,36 @@ def test_guided_prompt_mentions_empathy_and_self_judgment(): ) assert "接住对方" in p assert "你自己判断" in p or "该追问" in p + assert "共情与轻量自我表露" in p + assert "意义向深挖" in p + + +def test_guided_prompt_era_popculture_open_questions_when_birth_year(): + p = get_guided_conversation_prompt( + current_stage="childhood", + empty_slots=["place"], + filled_slots={}, + detected_user_stage="childhood", + user_profile_context="", + persona="default", + profile_birth_year=1985, + profile_era_place="潍坊", + ) + assert "时代与氛围参考" in p + assert "流行文化" in p + assert "开放式" in p + + +def test_opening_prompt_includes_era_task_when_birth_year_configured(): + p = get_opening_prompt( + current_stage="childhood", + empty_slots_readable=["成长的地方"], + user_profile_context="出生年份:1985年", + persona="default", + profile_birth_year=1985, + profile_era_place="潍坊", + ) + assert "年代氛围" in p def test_guided_prompt_persona_tone_warm_listener(): @@ -102,7 +132,7 @@ def test_guided_prompt_military_tone_in_system(): assert "简洁" in p or "利落" in p or "得体" in p -def test_opening_prompt_military_has_examples_note() -> None: +def test_opening_prompt_military_style_rules_not_dialogue_samples() -> None: p = get_opening_prompt( current_stage="childhood", empty_slots_readable=["成长的地方"], @@ -110,7 +140,8 @@ def test_opening_prompt_military_has_examples_note() -> None: persona="default", background_voice="military", ) - assert "军队语境" in p + assert "军队相关" in p + assert "示例" not in p def test_format_history_string_includes_system_for_debug_logs() -> None: