Files
life-echo/api/app/agents/chat/prompts_conversation.py
Sully 53e0065e3e refactor(api): TOML 配置 SSOT、统一错误契约、Auth/事务加固与可观测性 (#33)
配置 SSOT(TOML + .env)
统一错误契约
Auth 与事务边界
Redis / Celery 可靠性:业务 Redis(DB/0)与 Celery broker/backend(DB/1)显式拆分;连接池、sync client
可观测性(OpenTelemetry + LGTM)
2026-05-22 13:44:50 +08:00

771 lines
31 KiB
Python
Raw Permalink 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,
chat_output_rules_en,
)
from app.agents.chat.personas import (
AGENT_NAME_EN,
AGENT_NAME_ZH,
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_ERA_HINTS,
stage_display_name,
)
from app.agents.state_schema import KnownFact, PersonaThread
from app.core.config import settings
from app.features.conversation.constants import chat
# 风格示例的单一事实源已迁至 `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": "人生经验",
}
SLOT_NAME_MAP_EN = {
"place": "where you grew up",
"people": "important people",
"daily_life": "everyday life",
"emotion": "childhood feelings",
"turning_event": "memorable moments",
"school": "school experiences",
"city": "the city you studied in",
"motivation": "what drove you",
"challenge": "challenges you faced",
"change": "how you changed",
"job": "what you did at work",
"environment": "your work environment",
"decision": "important decisions",
"pressure": "pressure and hardship",
"growth": "career growth",
"relationship": "family relationships",
"conflict": "conflicts and resolutions",
"support": "mutual support",
"responsibility": "family responsibilities",
"value": "core values",
"regret": "regrets and acceptance",
"pride": "moments you're proud of",
"lesson": "life lessons",
}
def slot_name_map_for(language: str) -> Dict[str, str]:
return SLOT_NAME_MAP_EN if language == "en" else SLOT_NAME_MAP
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_en(
current_stage: str,
empty_slots_readable: List[str],
user_profile_context: str = "",
profile_birth_year: Optional[int] = None,
profile_era_place: str = "",
) -> str:
"""English-lite opening prompt; ignores persona/background-voice nuances."""
stage_name = stage_display_name(current_stage, language="en")
if empty_slots_readable:
topics_str = ", ".join(empty_slots_readable)
topics_heading = (
f"## Suggested topics for this stage ({stage_name})\n"
f"Pick one of these to ask about: {topics_str}"
)
task_question = (
"2. You are a **warm, host-style confidant**: ask one **specific, "
"easy-to-answer, vivid** question that pulls the user into telling a "
"life memory; ideally land on one of the topics above. Avoid vague "
"openers like \"How have you been?\" Open the door with one small "
"anchor (a place, a person, an object, or a tiny scene from a day)."
)
else:
topics_heading = (
f"## Current stage ({stage_name})\n"
"The main topics for this stage are largely covered. Open with "
"something tied to a previous memory or a fresh small angle of this "
"stage; do not interrogate from the start."
)
task_question = (
"2. **Greeting + a memory hook**: after a warm acknowledgement, "
"drop a light, concrete question tied to recollection — never "
"small-talk filler."
)
profile_section = ""
if user_profile_context.strip():
profile_section = "## About the user\n" + user_profile_context.strip() + "\n"
return f"""You are "{AGENT_NAME_EN}" — a warm host-style friend. The user just opened the chat and **has not said anything yet**; you speak first. Tone like an old friend, but your job is to help the user start telling their life story; in two or three short sentences, give a greeting plus **one vivid, recollection-oriented question** tied to the current stage or suggested topics. No flowery prose, no long literary descriptions, no generic small-talk.
{profile_section}{topics_heading}
## Task
1. Brief greeting.
{task_question}
3. Sound natural and warm.
## Format
- Use `[SPLIT]` to break into at most two short bubbles, or keep greeting + question in one short bubble.
- {chat_output_rules_en()} Do not write the user's answer for them.
Output (spoken-style English only, no Markdown):"""
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 = "",
language: str = "zh",
) -> str:
"""空对话时 AI 先开口的提示词"""
if language == "en":
return _get_opening_prompt_en(
current_stage,
empty_slots_readable,
user_profile_context=user_profile_context,
profile_birth_year=profile_birth_year,
profile_era_place=profile_era_place,
)
stage_name = stage_display_name(current_stage, language="zh")
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 = (
f"你是「{AGENT_NAME_ZH}」——主持式知己:用户刚进对话,**还没说话**,请你先开口。"
"语气像老朋友,但**职责是帮对方开口讲人生故事**;两三句内问候 + **一个落在当前阶段或建议话题上的、有画面感的问题**"
"不要排比、不要长段文学描写,**不要**把泛泛问近况当主菜。\n\n"
)
if bv_open != "default":
opening_head = (
f"你是「{AGENT_NAME_ZH}」——主持式知己:用户刚进对话,**还没说话**,请你先开口。"
"**短**;两三句内问候 + **一个回忆向的具体问题**;不要排比、不要文学描写。\n\n"
)
era_opening_line = ""
if (
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_en(
current_stage: str,
empty_slots: List[str],
filled_slots: Dict[str, str],
detected_user_stage: str = "",
user_profile_context: str = "",
memory_evidence_text: str = "",
recent_questions: list[str] | None = None,
turn_directive_block: str = "",
) -> str:
"""English-lite guided interview prompt (no persona/voice nuances)."""
stage_name = stage_display_name(current_stage, language="en")
detected_name = (
stage_display_name(detected_user_stage, language="en")
if detected_user_stage and detected_user_stage != current_stage
else ""
)
empty_readable = [SLOT_NAME_MAP_EN.get(s, s) for s in empty_slots]
filled_lines = []
for k, v in (filled_slots or {}).items():
name = SLOT_NAME_MAP_EN.get(k, k)
if v:
filled_lines.append(f"- {name}: {v}")
filled_block = "\n".join(filled_lines) if filled_lines else "(none yet)"
suggested_block = (
"Suggested still-open angles for this stage: " + ", ".join(empty_readable)
if empty_readable
else "Main angles for this stage are largely covered."
)
detected_line = (
f"\nThe user is currently talking about: **{detected_name}** (system was tracking **{stage_name}**)."
if detected_name
else ""
)
profile_section = ""
if user_profile_context.strip():
profile_section = "\n## About the user\n" + user_profile_context.strip()
memory_section = ""
if (memory_evidence_text or "").strip():
memory_section = (
"\n## Reference memory snippets (for continuity only — do NOT write them as the user's first-person experience this turn)\n"
+ memory_evidence_text.strip()
)
recent_q_section = ""
if recent_questions:
last = recent_questions[-4:]
recent_q_section = (
"\n## Recently asked questions (do NOT repeat these; offer a new angle)\n"
+ "\n".join(f"- {q}" for q in last)
)
directive_block = (turn_directive_block or "").strip()
directive_section = (
f"\n## This turn's plan\n{directive_block}\n" if directive_block else ""
)
return f"""{directive_section}You are "{AGENT_NAME_EN}," a warm host-style friend helping the user record a memoir. Reply in conversational English.
## Stage context
Currently tracking life stage: **{stage_name}**.{detected_line}
{suggested_block}
## Already gathered for this stage
{filled_block}{profile_section}{memory_section}{recent_q_section}
## Behaviour
- Pick up the **specific** detail the user just said (one tangible noun or short phrase) and gently push one step deeper before asking your next question.
- Prefer ONE clear, specific question per reply. Open-ended over forced A/B options.
- If the user is in the middle of a story, follow that thread; do not switch topics for the sake of coverage.
- If you previously asked about something and the user already answered, do not re-ask.
- Stay short and precise. One acknowledgement sentence + one question is the default shape.
## Strict rules
- {chat_output_rules_en()}
## Format
- Use `[SPLIT]` to split into at most two short bubbles when natural.
Reply in English only. Do not output Markdown headings."""
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 = "",
language: str = "zh",
) -> str:
"""生成状态感知的对话提示词;用户原话仅以 HumanMessage 传入,不写入本 system 文本。"""
if language == "en":
return _get_guided_conversation_prompt_en(
current_stage=current_stage,
empty_slots=empty_slots,
filled_slots=filled_slots,
detected_user_stage=detected_user_stage,
user_profile_context=user_profile_context,
memory_evidence_text=memory_evidence_text,
recent_questions=recent_questions,
turn_directive_block=turn_directive_block,
)
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 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_en(
current_stage: str,
empty_slots_readable: List[str],
user_profile_context: str = "",
background_voice: str = "default",
occupation: str = "",
idle_hours: float = 6.0,
) -> str:
"""English re-greeting; mirrors Chinese structure with lighter persona nuance."""
stage_name = stage_display_name(current_stage, language="en")
bv = normalize_background_voice(background_voice)
if idle_hours >= 168:
idle_phrase = "it's been quite a while"
elif idle_hours >= 48:
idle_phrase = "it's been several days"
elif idle_hours >= 20:
idle_phrase = "about a day has passed"
else:
idle_phrase = "only a little time has passed"
if empty_slots_readable:
topics_str = ", ".join(empty_slots_readable[:4])
topic_hint = (
f"## You can still explore ({stage_name})\n"
f"If picking up last time feels hard, gently land on one of these: {topics_str}."
)
else:
topic_hint = (
f"## Current focus ({stage_name})\n"
"Most beats here are covered; prefer returning to a concrete person, place, or moment from before."
)
if bv == "cadre":
style_note = "## Tone\nSteady and respectful; no slick slogans or stacked parallelisms."
elif bv == "military":
style_note = "## Tone\nCrisp and appropriate; no dramatic military flourishes."
else:
style_note = "## Tone\nLike an old friend you have not seen in a bit: warm, restrained, no preachy lists."
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 = "## About the user\n" + "\n".join(profile_lines) + "\n"
head = (
f'You are "{AGENT_NAME_EN}" — a host-style confidant. The user returns with **existing chat history** and has **not spoken yet** — you speak first. '
f"Context: {idle_phrase} since their last message.\n\n"
"**Job**: offer a warm reopening that shows you remember something specific they shared, then a light, memory-oriented hook you hand back to them.\n\n"
"## Requirements\n"
"1. **Must** reference one or two concrete details from the history (a person, place, object, or beat) — do not genericize with \"we had a great chat last time.\"\n"
"2. **Do not** reuse a brand-new-chat hello; something like \"Last time you mentioned X — want to continue?\" fits better.\n"
"3. The hook should be **specific, easy to answer, and visual**, tied to life memory — not \"how have you been\" small talk.\n"
"4. If history offers no usable threads, pick a small landing from the stage hints; still avoid vague interrogation.\n"
"5. Keep it short: two or three sentences, no long paragraphs.\n"
)
return f"""{head}{profile_section}{topic_hint}
{style_note}
## Format
- Use `[SPLIT]` for at most two short bubbles, or one bubble with reopening + hook.
- {chat_output_rules_en()} Do not write the user's answer for them.
Output (spoken English only, no Markdown):"""
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,
language: str = "zh",
) -> str:
"""老对话回访问候提示词用户带着已有历史回到对话AI 先开口做承接式问候。"""
if language == "en":
return _get_re_greeting_prompt_en(
current_stage=current_stage,
empty_slots_readable=empty_slots_readable,
user_profile_context=user_profile_context,
background_voice=background_voice,
occupation=occupation,
idle_hours=idle_hours,
)
stage_name = stage_display_name(current_stage, language="zh")
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", "肩上的家庭责任"),
],
"belief": [
("value", "现在最看重的事"),
("regret", "心里的遗憾"),
("pride", "最骄傲的事"),
("lesson", "想留下的人生经验"),
],
}
def build_topic_chips(
current_stage: str,
empty_slots: List[str],
*,
max_chips: int = 4,
language: str = "zh",
) -> List[Dict[str, str]]:
"""根据当前阶段与空 slot 列表生成 quick-start 话题 chips。
返回结构:[{"id": slot_key, "label": 短标签, "text": 用户点击后发出的句子}]
"""
slot_labels = slot_name_map_for(language)
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, zh_label in stage_bank:
if slot_key in empty_set and slot_key not in seen:
label = (
zh_label if language != "en" else slot_labels.get(slot_key, zh_label)
)
text = (
f"I'd like to talk about {label}."
if language == "en"
else f"我想聊聊{label}"
)
chips.append({"id": slot_key, "label": label, "text": text})
seen.add(slot_key)
if len(chips) >= max_chips:
return chips
# 不足则用阶段默认话题补齐
for slot_key, zh_label in stage_bank:
if slot_key in seen:
continue
label = (
zh_label if language != "en" else slot_labels.get(slot_key, zh_label)
)
text = (
f"I'd like to talk about {label}."
if language == "en"
else f"我想聊聊{label}"
)
chips.append({"id": slot_key, "label": label, "text": text})
seen.add(slot_key)
if len(chips) >= max_chips:
return chips
return chips
__all__ = [
"SLOT_NAME_MAP",
"SLOT_NAME_MAP_EN",
"slot_name_map_for",
"build_topic_chips",
"get_guided_conversation_prompt",
"get_opening_prompt",
"get_re_greeting_prompt",
]