- 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:
@@ -24,6 +24,7 @@ from app.agents.chat.prompts_conversation import (
|
||||
SLOT_NAME_MAP,
|
||||
SLOT_NAME_MAP_EN,
|
||||
get_opening_prompt,
|
||||
get_re_greeting_prompt,
|
||||
)
|
||||
from app.agents.chat.reply_limits import (
|
||||
nonempty_segments_or_fallback,
|
||||
@@ -69,6 +70,16 @@ def _opening_fallback_for(language: str) -> str:
|
||||
return _OPENING_FALLBACK_EN if language == "en" else _OPENING_FALLBACK_ZH
|
||||
|
||||
|
||||
_RE_GREETING_FALLBACK_ZH = "上次聊到的事我还记着,今天想继续往下讲讲吗?"
|
||||
_RE_GREETING_FALLBACK_EN = (
|
||||
"I still remember what we touched on last time — want to keep going today?"
|
||||
)
|
||||
|
||||
|
||||
def _re_greeting_fallback_for(language: str) -> str:
|
||||
return _RE_GREETING_FALLBACK_EN if language == "en" else _RE_GREETING_FALLBACK_ZH
|
||||
|
||||
|
||||
# 仅在「重复问句守卫」把正文削成单句兜底时追加二次 system,只多调一次模型。
|
||||
_DUPLICATE_GUARD_LLM_RETRY_SYSTEM_APPENDIX = """## 二次生成(纠偏)
|
||||
上一版模型输出因包含与「最近已问过的问题」或「已确认事实」重复的问句,已被系统弃用。请**重新写一整条回复**:
|
||||
@@ -548,3 +559,123 @@ class InterviewAgent:
|
||||
except Exception as e:
|
||||
logger.error("生成开场白失败: {}", e, exc_info=True)
|
||||
return [_opening_fallback_for(language)]
|
||||
|
||||
async def generate_re_greeting_message(
|
||||
self,
|
||||
conversation_id: str,
|
||||
memoir_state: MemoirStateSchema,
|
||||
idle_hours: float,
|
||||
user_profile_context: str = "",
|
||||
background_voice: str = "default",
|
||||
occupation: str = "",
|
||||
profile_birth_year: Optional[int] = None,
|
||||
profile_era_place: str = "",
|
||||
language: str = "zh",
|
||||
) -> List[str]:
|
||||
"""老对话回访问候:用户带着已有历史回到对话时,AI 主动做承接式开场。
|
||||
|
||||
与 generate_opening_message 的差异:prompt 明确告知有历史 + 距上次的时间感受,
|
||||
要求轻轻引用历史里的具体细节,不能用首次见面式硬开场。
|
||||
"""
|
||||
if not self.llm:
|
||||
return [_re_greeting_fallback_for(language)]
|
||||
try:
|
||||
narrative_state = narrative_coverage_state(memoir_state)
|
||||
control_state = interview_control_state(memoir_state)
|
||||
empty_slots = control_state.prompt_empty_slots_for_stage(
|
||||
narrative_state, memoir_state.current_stage
|
||||
)
|
||||
slot_table = SLOT_NAME_MAP_EN if language == "en" else SLOT_NAME_MAP
|
||||
empty_slots_readable = [slot_table.get(s, s) for s in empty_slots]
|
||||
persona = normalize_interview_persona(settings.chat_interview_persona)
|
||||
prompt = get_re_greeting_prompt(
|
||||
current_stage=memoir_state.current_stage,
|
||||
empty_slots_readable=empty_slots_readable,
|
||||
user_profile_context=user_profile_context,
|
||||
persona=persona,
|
||||
background_voice=background_voice,
|
||||
occupation=occupation,
|
||||
profile_birth_year=profile_birth_year,
|
||||
profile_era_place=profile_era_place,
|
||||
idle_hours=idle_hours,
|
||||
language=language,
|
||||
)
|
||||
hw = await get_history_with_window(
|
||||
conversation_id,
|
||||
max_pairs=settings.chat_history_max_pairs,
|
||||
max_chars=settings.chat_history_max_chars,
|
||||
)
|
||||
messages: List[Any] = [SystemMessage(content=prompt)]
|
||||
messages.extend(hw.window)
|
||||
re_greet_tail = (
|
||||
"(用户回到这个已有历史的对话,还没说话。"
|
||||
"请基于上文做温和的承接式回访问候。)"
|
||||
if language != "en"
|
||||
else (
|
||||
"(The user returned to this conversation with prior history and has not spoken yet. "
|
||||
"Give a gentle, grounded re-greeting based on the conversation above.)"
|
||||
)
|
||||
)
|
||||
messages.append(HumanMessage(content=re_greet_tail))
|
||||
log_agent_payload(
|
||||
logger,
|
||||
"InterviewAgent.re_greeting.prompt",
|
||||
format_history_string(
|
||||
messages,
|
||||
omit_system_body=settings.agent_log_omit_system_message_body,
|
||||
),
|
||||
)
|
||||
re_greet_llm = self.llm.bind(
|
||||
max_tokens=settings.chat_opening_max_tokens,
|
||||
temperature=float(settings.chat_interview_temperature),
|
||||
)
|
||||
llm_t0 = time.perf_counter()
|
||||
with agent_span(
|
||||
logger,
|
||||
"InterviewAgent.re_greeting.llm",
|
||||
conversation_id=conversation_id,
|
||||
):
|
||||
logger.info(
|
||||
"event=chat_prompt_built agent=InterviewAgent.generate_re_greeting_message "
|
||||
"prompt_chars={} history_pairs_total={} history_pairs_windowed={} idle_hours={:.2f}",
|
||||
_message_contents_char_count(messages),
|
||||
hw.turn_total,
|
||||
len(hw.window) // 2,
|
||||
idle_hours,
|
||||
)
|
||||
response = await re_greet_llm.ainvoke(messages)
|
||||
logger.info(
|
||||
"event=chat_llm_done agent=InterviewAgent.generate_re_greeting_message "
|
||||
"response_latency_ms={:.2f}",
|
||||
(time.perf_counter() - llm_t0) * 1000,
|
||||
)
|
||||
response_text = (
|
||||
response.content if hasattr(response, "content") else str(response)
|
||||
)
|
||||
log_agent_payload(
|
||||
logger, "InterviewAgent.re_greeting.raw_response", response_text
|
||||
)
|
||||
raw_list = segments_from_llm_response(response_text, max_segments=2)
|
||||
if not raw_list:
|
||||
raw_list = [response_text.strip()]
|
||||
max_chars = int(settings.chat_interview_max_chars_per_segment)
|
||||
out = truncate_chat_segments(
|
||||
raw_list,
|
||||
max_segments=2,
|
||||
max_chars_per_segment=max_chars,
|
||||
)
|
||||
log_agent_summary(
|
||||
logger,
|
||||
"InterviewAgent.re_greeting segments={} conversation_id={} idle_hours={:.2f}",
|
||||
len(out),
|
||||
conversation_id,
|
||||
idle_hours,
|
||||
)
|
||||
segments = out if out else [response_text.strip()[:max_chars]]
|
||||
return nonempty_segments_or_fallback(
|
||||
segments,
|
||||
fallback=_re_greeting_fallback_for(language),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("生成回访问候失败: {}", e, exc_info=True)
|
||||
return [_re_greeting_fallback_for(language)]
|
||||
|
||||
@@ -14,7 +14,7 @@ from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from app.agents.chat.prompts_conversation import SLOT_NAME_MAP
|
||||
from app.agents.stage_constants import STAGE_SLOT_KEYS
|
||||
from app.agents.stage_constants import STAGE_KEYWORD_WEIGHTS, STAGE_SLOT_KEYS
|
||||
|
||||
InterviewTurnMode = Literal[
|
||||
"emotion_first",
|
||||
@@ -66,6 +66,7 @@ class InterviewTurnPlan:
|
||||
secondary_focus: FocusPrimary | None = None
|
||||
focus_summary: str = ""
|
||||
focus_source: FocusSource = "rule"
|
||||
low_information_reply: bool = False
|
||||
|
||||
# ---- 语义属性:供 prompt_layers / interview_agent 等调用方消费,禁止重复立法 ----
|
||||
|
||||
@@ -323,6 +324,17 @@ _ASSISTANT_IDENTITY_QUESTION_MARKERS: tuple[str, ...] = (
|
||||
"你也是",
|
||||
)
|
||||
|
||||
_LOW_INFORMATION_REPLY_MAX_CHARS = 8
|
||||
_LOW_INFORMATION_REPLY_CHARS: frozenset[str] = frozenset(
|
||||
# 这不是短语白名单,而是一组低信息的应声/确认/语气字符。
|
||||
# 只要短回复里出现不在此集合中的字,就会被视为有潜在叙事信号。
|
||||
"嗯唔呃哦噢喔啊呀呢嘛吧哈"
|
||||
"对是的了好行可允许以"
|
||||
"没错确实不否无有还太很挺就"
|
||||
"什么这样那样当当然"
|
||||
)
|
||||
_ACK_STRIP_CHARS = " \t\r\n,。!?!?、,.;;::~~…"
|
||||
|
||||
|
||||
def _is_emotion_heavy(text: str) -> bool:
|
||||
t = (text or "").strip()
|
||||
@@ -351,6 +363,32 @@ def _is_ambiguous_or_needs_slow_pace(text: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _normalized_short_reply(text: str) -> str:
|
||||
return "".join(ch for ch in (text or "").strip() if ch not in _ACK_STRIP_CHARS)
|
||||
|
||||
|
||||
def _has_substantive_short_reply_signal(compact: str) -> bool:
|
||||
"""短回复里的叙事实质信号:地点/人事词、阶段关键词、年份数字等。"""
|
||||
if any(ch.isdigit() for ch in compact):
|
||||
return True
|
||||
if any(("a" <= ch.lower() <= "z") for ch in compact):
|
||||
return True
|
||||
for weighted_keywords in STAGE_KEYWORD_WEIGHTS.values():
|
||||
if any(keyword and keyword in compact for keyword, _ in weighted_keywords):
|
||||
return True
|
||||
return any(ch not in _LOW_INFORMATION_REPLY_CHARS for ch in compact)
|
||||
|
||||
|
||||
def _is_low_information_reply(text: str) -> bool:
|
||||
"""识别短、低信息、没有新增叙事素材的确认/应声回复。"""
|
||||
compact = _normalized_short_reply(text)
|
||||
if not compact:
|
||||
return False
|
||||
if len(compact) > _LOW_INFORMATION_REPLY_MAX_CHARS:
|
||||
return False
|
||||
return not _has_substantive_short_reply_signal(compact)
|
||||
|
||||
|
||||
def _is_too_vague_for_memoir_push(text: str) -> bool:
|
||||
"""过短或仍含糊时,不进入 memoir_push。"""
|
||||
t = (text or "").strip()
|
||||
@@ -396,6 +434,7 @@ def plan_interview_turn(
|
||||
)
|
||||
um = (normalized_user_message or "").strip()
|
||||
asks_assistant_identity = _is_asking_assistant_identity_or_life(um)
|
||||
low_information_reply = _is_low_information_reply(um)
|
||||
reply_shape: ReplyShape = "flexible"
|
||||
if any(
|
||||
k in um
|
||||
@@ -403,6 +442,45 @@ def plan_interview_turn(
|
||||
):
|
||||
reply_shape = "ack_then_question"
|
||||
|
||||
if low_information_reply:
|
||||
slot = primary_empty_slot(current_stage, empty_slots)
|
||||
if slot:
|
||||
readable = SLOT_NAME_MAP.get(slot, slot)
|
||||
return InterviewTurnPlan(
|
||||
mode="memoir_push",
|
||||
anchor_slot_key=slot,
|
||||
anchor_slot_readable=readable,
|
||||
anchor_snippet=snippet,
|
||||
anchor_source_kind=anchor_source_kind,
|
||||
assistant_identity_question=asks_assistant_identity,
|
||||
memory_usage=mem_use,
|
||||
reply_shape="ack_then_question",
|
||||
primary_focus=_focus_primary_for_mode("memoir_push"),
|
||||
focus_summary=(
|
||||
f"用户只做了简短确认;短接一句后,不澄清“{um}”,"
|
||||
f"主动从「{readable}」打开一个具体、好回答的新回忆话题"
|
||||
),
|
||||
focus_source="rule",
|
||||
low_information_reply=True,
|
||||
)
|
||||
return InterviewTurnPlan(
|
||||
mode="follow_user_only",
|
||||
anchor_slot_key=None,
|
||||
anchor_slot_readable="(本阶段主要叙述槽已有素材)请回到上文最近的人/事/地方或情绪线,主动打开一个纵深问题",
|
||||
anchor_snippet=snippet,
|
||||
anchor_source_kind=anchor_source_kind,
|
||||
assistant_identity_question=asks_assistant_identity,
|
||||
memory_usage=mem_use,
|
||||
reply_shape="ack_then_question",
|
||||
primary_focus=_focus_primary_for_mode("follow_user_only"),
|
||||
focus_summary=(
|
||||
f"用户只做了简短确认;短接一句后,不澄清“{um}”,"
|
||||
"回到上文最近的具体线索,主动递一个新的回忆追问"
|
||||
),
|
||||
focus_source="rule",
|
||||
low_information_reply=True,
|
||||
)
|
||||
|
||||
if _is_emotion_heavy(normalized_user_message):
|
||||
slot = primary_empty_slot(current_stage, empty_slots)
|
||||
readable = (
|
||||
@@ -534,6 +612,17 @@ def format_interview_turn_directive_block(plan: InterviewTurnPlan) -> str:
|
||||
else:
|
||||
shape_block = ""
|
||||
|
||||
ack_block = ""
|
||||
if plan.low_information_reply:
|
||||
ack_block = (
|
||||
"- **低信息短回复处理**:用户本轮只是简短确认、应声或泛泛回应,没有新增叙事素材。"
|
||||
"不要把这个短答本身当成需要澄清的内容,不要反复追问「你是说……吗」,"
|
||||
"也不要停在原地等用户继续补充。\n"
|
||||
"- 先用半句自然接住,再主动从「主追问方向」、上文最近的具体人/事/地方,"
|
||||
"或过往记忆线索里挑一个**具体、好回答**的新回忆话题;若挂钩线索为空,"
|
||||
"允许直接借当前阶段未聊方向起问,但禁止编造用户没说过的细节。\n"
|
||||
)
|
||||
|
||||
if plan.mode == "emotion_first":
|
||||
mode_rules = (
|
||||
"- **情绪优先**:本轮以承接、并肩与安全感为主,**不要**为推进「叙述槽大纲」而追加信息搜集型追问。\n"
|
||||
@@ -557,13 +646,29 @@ def format_interview_turn_directive_block(plan: InterviewTurnPlan) -> str:
|
||||
"- **跟话头**:本轮禁止问卷式首开、禁止重启式盘点;顺着用户刚展开的画面、人物或情绪自然往下。\n"
|
||||
"- 若带问句,最多**一个**,且必须**从用户原词或下面摘录**长出来,禁止空泛「还有吗」。"
|
||||
)
|
||||
if plan.low_information_reply:
|
||||
mode_rules = (
|
||||
"- **跟话头 + 主动续话**:用户本轮只是简短确认,没有新素材;"
|
||||
"不要围着短答本身澄清,也不要重复上一问等对方补充。\n"
|
||||
"- 若带问句,最多**一个**,优先从上文最近的具体人/事/地方或下面摘录长出来;"
|
||||
"若无可用摘录,就从当前阶段已聊内容的纵深处打开一个新回忆话题。"
|
||||
)
|
||||
else:
|
||||
mode_rules = (
|
||||
"- **回忆推进(memoir_push)**:对用户可见回复中须有**恰好一个**开放式回忆追问,\n"
|
||||
" 且意图明显在补足下面「主追问方向」;问句必须挂住**用户本轮原词**或**挂钩摘录**(二者至少其一,且**优先原词**)。\n"
|
||||
"- **禁止**把用户仍含糊的表达改写成确定事实或关系;禁止用日常寒暄替代该追问;"
|
||||
"仍遵守全篇「最多一个问句」「禁止晚会硬切」。"
|
||||
)
|
||||
if plan.low_information_reply:
|
||||
mode_rules = (
|
||||
"- **回忆推进(memoir_push)**:用户本轮只是简短确认,"
|
||||
"对用户可见回复中须有**恰好一个**开放式回忆追问,主动把话头往下递。\n"
|
||||
" 追问可挂住**上文最近具体细节**、**主追问方向**或**挂钩摘录**;"
|
||||
"不要要求从低信息短答里抽词。\n"
|
||||
"- 禁止用日常寒暄替代该追问;仍遵守全篇「最多一个问句」「禁止晚会硬切」。"
|
||||
)
|
||||
else:
|
||||
mode_rules = (
|
||||
"- **回忆推进(memoir_push)**:对用户可见回复中须有**恰好一个**开放式回忆追问,\n"
|
||||
" 且意图明显在补足下面「主追问方向」;问句必须挂住**用户本轮原词**或**挂钩摘录**(二者至少其一,且**优先原词**)。\n"
|
||||
"- **禁止**把用户仍含糊的表达改写成确定事实或关系;禁止用日常寒暄替代该追问;"
|
||||
"仍遵守全篇「最多一个问句」「禁止晚会硬切」。"
|
||||
)
|
||||
|
||||
focus_block = _focus_directive_lines(plan)
|
||||
|
||||
@@ -576,7 +681,7 @@ def format_interview_turn_directive_block(plan: InterviewTurnPlan) -> str:
|
||||
|
||||
return f"""## 本轮编排指令(硬规则,优先于后文一般性建议)
|
||||
{mode_rules}
|
||||
{focus_block}{mem_block}{perspective_block}{shape_block}- **主追问方向(叙述槽)**:{plan.anchor_slot_readable}
|
||||
{focus_block}{ack_block}{mem_block}{perspective_block}{shape_block}- **主追问方向(叙述槽)**:{plan.anchor_slot_readable}
|
||||
- **挂钩线索**{anchor_label}:{snippet_line}
|
||||
"""
|
||||
|
||||
|
||||
@@ -508,3 +508,28 @@ class ChatOrchestrator:
|
||||
profile_era_place=profile_era_place,
|
||||
language=language,
|
||||
)
|
||||
|
||||
async def generate_re_greeting_message(
|
||||
self,
|
||||
conversation_id: str,
|
||||
memoir_state: MemoirStateSchema,
|
||||
idle_hours: float,
|
||||
user_profile_context: str = "",
|
||||
background_voice: str = "default",
|
||||
occupation: str = "",
|
||||
profile_birth_year: Optional[int] = None,
|
||||
profile_era_place: str = "",
|
||||
language: str = "zh",
|
||||
) -> List[str]:
|
||||
"""委托 InterviewAgent 生成老对话回访问候(持久化由调用方负责)。"""
|
||||
return await self.interview_agent.generate_re_greeting_message(
|
||||
conversation_id=conversation_id,
|
||||
memoir_state=memoir_state,
|
||||
idle_hours=idle_hours,
|
||||
user_profile_context=user_profile_context,
|
||||
background_voice=background_voice,
|
||||
occupation=occupation,
|
||||
profile_birth_year=profile_birth_year,
|
||||
profile_era_place=profile_era_place,
|
||||
language=language,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user