Files
life-echo/api/app/agents/chat/prompts_conversation.py
Kevin 064ad2161d refactor(eval+memoir):精简内部评测路由与服务,composite/对话摘要与 judge 能力补强
- 访谈:新增 interview_state_hints,联动 orchestrator 与提示词
- 回忆录:story_pipeline_sync/state/memory/post_commit 与 Celery 任务调整
- 基建:开发用 celery broker、compose/development 脚本、依赖注入
- eval-web:移除数据集/实验/版本等页面与流式轮询,突出 Playground
- 文档与单测同步
2026-04-08 21:36:12 +08:00

399 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
对话 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",
]