- Drop interview_reply_length and utterance_substance; always run stage LLM and memory retrieval when enabled; trim Settings fields and .env.example. - Replace guided/opening prompts with compact fact blocks plus unified behavior guidance; slim background_voice and persona to tone hints. - InterviewAgent uses fixed chat_interview max_tokens/chars/segments. Also includes stacked work: profile followup/extract path, evaluation rubric and judge schema updates, transcript SPLIT handling in execution service, user export markdown split tests, and golden case fixture.
351 lines
14 KiB
Python
351 lines
14 KiB
Python
"""
|
||
对话 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.personas import (
|
||
get_interview_persona_tone_hint,
|
||
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": "人生经验",
|
||
}
|
||
|
||
|
||
def _compact_era_hint(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"时代联想(口述里一两句带过即可):约 {era_start}-{era_end} 年{place_hint};"
|
||
f"可提及 {era_events[0]}"
|
||
+ (f";{era_events[1]}" if len(era_events) > 1 else "")
|
||
+ "。"
|
||
)
|
||
|
||
|
||
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_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"
|
||
|
||
bv = normalize_background_voice(background_voice)
|
||
opening_head = (
|
||
"你是「岁月知己」。用户刚进对话,**还没说话**,请你先开口。"
|
||
"**短、像微信**,一两句问候 + 一个具体问题即可,不要排比、不要文学描写。\n\n"
|
||
)
|
||
if bv != "default":
|
||
opening_head = (
|
||
"你是「岁月知己」。用户刚进对话,**还没说话**,请你先开口。"
|
||
"**短**;两三句内问候 + 一个具体问题;不要排比、不要文学描写。\n\n"
|
||
)
|
||
|
||
return f"""{opening_head}{tone_paragraph}{profile_section}{topics_heading}
|
||
## 任务
|
||
1. 简短问候。
|
||
{task_question}
|
||
3. 自然、温暖,但**字数要少**。
|
||
|
||
## 格式
|
||
- 可用 [SPLIT] 分成最多 2 条;或一条里「问候 + 问题」。
|
||
- {chat_output_rules()} 不要替用户编回答。
|
||
|
||
{style_examples}
|
||
|
||
直接输出(仅自然口语,无 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 = "",
|
||
) -> 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)
|
||
|
||
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] = []
|
||
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}:未聊")
|
||
elif filled_n < total_n:
|
||
progress_lines.append(f" {sname}:{filled_n}/{total_n}")
|
||
progress_str = "\n".join(progress_lines) if progress_lines else ""
|
||
|
||
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, user_profile_context)
|
||
|
||
if user_jumped:
|
||
topic_desc = (
|
||
f"你们原本在聊「{current_stage_name}」,"
|
||
f"用户自然地聊到了「{user_stage_name}」——跟着他/她的节奏,别硬拉回。"
|
||
)
|
||
else:
|
||
topic_desc = f"你们在聊「{current_stage_name}」这阶段的话题。"
|
||
|
||
user_info_parts: List[str] = []
|
||
if user_profile_context.strip():
|
||
user_info_parts.append(user_profile_context.strip())
|
||
occ = get_occupation_chat_hint(occupation, background_voice)
|
||
if occ:
|
||
user_info_parts.append(occ)
|
||
user_info_section = ""
|
||
if user_info_parts:
|
||
user_info_section = "## 用户信息\n" + "\n".join(user_info_parts) + "\n\n"
|
||
|
||
memory_section = ""
|
||
mem_trim = (memory_evidence_text or "").strip()
|
||
if mem_trim:
|
||
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 ""
|
||
|
||
return f"""你是「岁月知己」,像老朋友陪用户聊人生。短句为主,像微信聊天。{tone_line}
|
||
|
||
{topic_desc}
|
||
|
||
{user_info_section}## 当前对话状态
|
||
已聊:
|
||
{filled_slots_str}
|
||
|
||
还可聊的方向:{empty_slots_str}
|
||
|
||
{progress_block}{era_block}{memory_section}## 你要做的
|
||
- **先接住对方**——一句真诚回应,不要写成总结或讲评。
|
||
- 你自己判断该追问还是只承接:有新线头就顺着问一个具体的事;情绪浓就好好接住、不必急着追问;明显闲聊就陪聊;用户只说「嗯」「对」则结合上文承接或换个角度。
|
||
- 可以用「我能想象……」「那时候大概……」轻轻接话,但不可编造具体人名、时间、事件等你不知道的细节。
|
||
- 不要重复上一轮问过的事;用户跳到别的人生阶段,跟着聊,别硬拉回。
|
||
- 追问与承接服务于人生故事素材,但不要让对方觉得在走审问式流程;**最多**抛一个具体问题,也可以不追问。
|
||
- 可用 [SPLIT] 分成**最多 2 条**消息。
|
||
|
||
## 不要做的
|
||
{chat_output_rules()}
|
||
|
||
直接输出(仅自然口语,无 Markdown,无任何括号前缀或旁白):"""
|
||
|
||
|
||
__all__ = [
|
||
"SLOT_NAME_MAP",
|
||
"get_guided_conversation_prompt",
|
||
"get_opening_prompt",
|
||
]
|