Merge commit e95582a: PR #20 proactive chat, topic chips, low-info turn plan

- Merge staging workflow parent and resolve conflicts with English/i18n and WS pool
- Re-greeting: language-aware fallbacks and prompts; router passes user_language
- RealtimeSession: topic suggestion callbacks + TTS sync path preserved

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin
2026-05-12 11:02:58 +08:00
21 changed files with 1047 additions and 97 deletions

View File

@@ -505,10 +505,259 @@ def get_guided_conversation_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", "肩上的家庭责任"),
],
"later_life": [
("value", "现在最看重的事"),
("regret", "心里的遗憾"),
("pride", "最骄傲的事"),
("lesson", "想留下的人生经验"),
],
}
def build_topic_chips(
current_stage: str,
empty_slots: List[str],
*,
max_chips: int = 4,
) -> List[Dict[str, str]]:
"""根据当前阶段与空 slot 列表生成 quick-start 话题 chips。
返回结构:[{"id": slot_key, "label": 短标签, "text": 用户点击后发出的句子}]
"""
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, label in stage_bank:
if slot_key in empty_set and slot_key not in seen:
chips.append(
{
"id": slot_key,
"label": label,
"text": f"我想聊聊{label}",
}
)
seen.add(slot_key)
if len(chips) >= max_chips:
return chips
# 不足则用阶段默认话题补齐
for slot_key, label in stage_bank:
if slot_key in seen:
continue
chips.append(
{
"id": slot_key,
"label": label,
"text": f"我想聊聊{label}",
}
)
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",
]