feat: agent proactively re-engages users on returning sessions
Two complementary changes to reduce conversation cold-start friction: A. Returning-user re-greeting (backend) - When WS reconnects to a non-empty conversation and last_message_at is older than chat_re_greeting_idle_hours (default 6h), the agent emits a warm continuation message that references prior history instead of staying silent. - Self-debouncing: the AI message updates last_message_at, so reconnects within the window will not re-trigger. - Skipped while profile collection is still pending. D. Topic suggestion chips (backend + Expo) - New WS message type topic_suggestions carries 3-4 quick-start chips derived from the current memoir stage's empty slots (deterministic, no extra LLM cost). Sent alongside opening / re-greeting / resume. - Expo chat screen renders a horizontally-scrollable chip row above the input bar; tapping a chip sends the chip's text as a user message and clears the row. Sending any text/voice also clears the chips.
This commit is contained in:
@@ -23,6 +23,7 @@ from app.agents.chat.prompt_context import ChatPromptContext
|
||||
from app.agents.chat.prompts_conversation import (
|
||||
SLOT_NAME_MAP,
|
||||
get_opening_prompt,
|
||||
get_re_greeting_prompt,
|
||||
)
|
||||
from app.agents.chat.reply_limits import (
|
||||
nonempty_segments_or_fallback,
|
||||
@@ -503,3 +504,118 @@ class InterviewAgent:
|
||||
except Exception as e:
|
||||
logger.error("生成开场白失败: {}", e, exc_info=True)
|
||||
return ["你好呀~ 又见面了。今天想从人生里哪一小段回忆开始聊聊?"]
|
||||
|
||||
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 = "",
|
||||
) -> List[str]:
|
||||
"""老对话回访问候:用户带着已有历史回到对话时,AI 主动做承接式开场。
|
||||
|
||||
与 generate_opening_message 的差异:prompt 明确告知有历史 + 距上次的时间感受,
|
||||
要求轻轻引用历史里的具体细节,不能用首次见面式硬开场。
|
||||
"""
|
||||
if not self.llm:
|
||||
return ["上次聊到的事我还记着,今天想继续往下讲讲吗?"]
|
||||
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
|
||||
)
|
||||
empty_slots_readable = [SLOT_NAME_MAP.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,
|
||||
)
|
||||
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)
|
||||
messages.append(
|
||||
HumanMessage(
|
||||
content=(
|
||||
"(用户回到这个已有历史的对话,还没说话。"
|
||||
"请基于上文做温和的承接式回访问候。)"
|
||||
)
|
||||
)
|
||||
)
|
||||
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="上次聊到的事我还记着,今天想继续往下讲讲吗?",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("生成回访问候失败: {}", e, exc_info=True)
|
||||
return ["上次聊到的事我还记着,今天想继续往下讲讲吗?"]
|
||||
|
||||
@@ -477,3 +477,26 @@ class ChatOrchestrator:
|
||||
profile_birth_year=profile_birth_year,
|
||||
profile_era_place=profile_era_place,
|
||||
)
|
||||
|
||||
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 = "",
|
||||
) -> 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,
|
||||
)
|
||||
|
||||
@@ -305,8 +305,176 @@ def get_guided_conversation_prompt(
|
||||
# 旧的超大 system prompt 已拆入 BehaviorPolicy / Context / StyleProfile 三层,此处不再保留快照。
|
||||
|
||||
|
||||
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,
|
||||
) -> str:
|
||||
"""老对话回访问候提示词:用户带着已有历史回到对话,AI 先开口做承接式问候。"""
|
||||
stage_name = STAGE_DISPLAY_ZH.get(current_stage, current_stage)
|
||||
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",
|
||||
"build_topic_chips",
|
||||
"get_guided_conversation_prompt",
|
||||
"get_opening_prompt",
|
||||
"get_re_greeting_prompt",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user