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:
Claude
2026-05-07 15:39:33 +00:00
parent 7617ea902c
commit 55cfbc7f80
14 changed files with 688 additions and 52 deletions

View File

@@ -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 ["上次聊到的事我还记着,今天想继续往下讲讲吗?"]