配置 SSOT(TOML + .env) 统一错误契约 Auth 与事务边界 Redis / Celery 可靠性:业务 Redis(DB/0)与 Celery broker/backend(DB/1)显式拆分;连接池、sync client 可观测性(OpenTelemetry + LGTM)
771 lines
31 KiB
Python
771 lines
31 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,
|
||
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",
|
||
]
|