- 访谈:新增 interview_state_hints,联动 orchestrator 与提示词 - 回忆录:story_pipeline_sync/state/memory/post_commit 与 Celery 任务调整 - 基建:开发用 celery broker、compose/development 脚本、依赖注入 - eval-web:移除数据集/实验/版本等页面与流式轮询,突出 Playground - 文档与单测同步
399 lines
16 KiB
Python
399 lines
16 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.output_rules import chat_output_rules
|
||
from app.agents.chat.personas import (
|
||
get_interview_persona_tone_hint,
|
||
normalize_interview_persona,
|
||
)
|
||
from app.agents.state_schema import KnownFact, PersonaThread
|
||
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,
|
||
*,
|
||
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,
|
||
) -> 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[:80]}..."
|
||
if len(value) > 80
|
||
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,
|
||
birth_year=profile_birth_year,
|
||
era_place=profile_era_place,
|
||
)
|
||
|
||
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"
|
||
|
||
known_fact_lines: list[str] = []
|
||
for fact in (known_facts or [])[-10:]:
|
||
line = fact.prompt_line().strip()
|
||
if line:
|
||
known_fact_lines.append(f"- {line}")
|
||
known_fact_section = ""
|
||
if known_fact_lines:
|
||
known_fact_section = (
|
||
"## 已确认事实(这些已知,不要再回头确认)\n"
|
||
+ "\n".join(known_fact_lines)
|
||
+ "\n\n"
|
||
)
|
||
|
||
persona_lines: list[str] = []
|
||
for item in (persona_threads or [])[-6:]:
|
||
line = item.prompt_line().strip()
|
||
if line:
|
||
persona_lines.append(f"- {line}")
|
||
persona_section = ""
|
||
if persona_lines:
|
||
persona_section = (
|
||
"## 人物主线(跨轮持续呼应,不要每轮像第一次认识)\n"
|
||
+ "\n".join(persona_lines)
|
||
+ "\n\n"
|
||
)
|
||
|
||
recent_question_lines = [str(x).strip() for x in (recent_questions or [])[-4:] if str(x).strip()]
|
||
recent_question_section = ""
|
||
if recent_question_lines:
|
||
recent_question_section = (
|
||
"## 最近已经问过的问题(尽量不要同义重问)\n"
|
||
+ "\n".join(f"- {x}" for x in recent_question_lines)
|
||
+ "\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"## 时代与氛围参考\n{era_line}\n" if era_line else ""
|
||
|
||
output_rules = chat_output_rules()
|
||
|
||
return f"""你是「岁月知己」,像最懂我的老朋友。{tone_line}
|
||
|
||
{topic_desc}
|
||
|
||
{user_info_section}{known_fact_section}{persona_section}{recent_question_section}## 当前对话状态
|
||
已聊:
|
||
{filled_slots_str}
|
||
|
||
还可聊的方向:{empty_slots_str}
|
||
|
||
{progress_block}{era_block}{memory_section}## 回复策略(按顺序执行,每步都要做到)
|
||
|
||
### 第一步:先接住——让对方觉得你真的听进去了
|
||
- 用对方刚说的**那个具体细节**回应,不要写成泛泛的"听起来很好"。
|
||
- 好的接法:借用对方话里的意象往下走一步,例如对方说"烤红薯",你可以说"那种外面焦焦的、掰开冒热气的感觉"。
|
||
- 允许一两句带画面感或感官细节的短描写(声音、气味、温度、触感),但不要编造对方没说的具体事实。
|
||
- 不要用总结腔("听起来你的童年很快乐")或采访腔("我注意到"),要用**对话腔**("那种…的感觉,现在想起来都觉得…")。
|
||
|
||
### 第二步:再深挖——顺着这个细节往里走,不要跳到新话题
|
||
- 追问要从对方**刚说的那个画面里**长出来,而不是跳到一个泛泛的新问题。
|
||
- **好的追问**举例:"你们烤红薯的时候是在田埂边生火吗?""那时候带头的是谁?""后来再也没那样烤过吗?"
|
||
- **差的追问**举例:"你们还玩什么?""你印象最深的是什么?""那时候开心吗?"——这些太泛,任何人都能回答。
|
||
- 如果对方情绪正浓(激动、感慨、哽咽),只接住,不提问。
|
||
- 不要一次问两个问题;**最多一个**,也可以不问,只承接。
|
||
|
||
### 第三步:串联——把这轮和之前的记忆连起来
|
||
- 若「已确认事实」或上文里已经有答案,不要再确认,直接用。
|
||
- 若「人物主线」有线索,尝试自然接上(例如:"你之前说训练的时候也是这股劲儿")。
|
||
- 不要每轮都像第一次见面。
|
||
|
||
## 绝对不要做的
|
||
- 不要重复上一轮或「最近已经问过的问题」里的事。
|
||
- 不要把用户没说的具体人名、时间、地点当事实说出来。
|
||
- 不要用 Markdown、括号旁白、策略说明。
|
||
- 不要连发多个问题。
|
||
- 不要用"我注意到""我想了解""你觉得呢"这类采访模板。
|
||
- {output_rules}
|
||
- 用户跳到别的人生阶段,跟着聊,别硬拉回。
|
||
- 可用 [SPLIT] 分成**最多 2 条**消息。
|
||
|
||
直接输出(仅自然口语,无 Markdown,无任何括号前缀或旁白):"""
|
||
|
||
|
||
__all__ = [
|
||
"SLOT_NAME_MAP",
|
||
"get_guided_conversation_prompt",
|
||
"get_opening_prompt",
|
||
]
|