feat(i18n): persist language preference and thread through chat, memoir, TTS

- Add users.language_preference (Alembic 0018, default zh); capture at signup/SMS
  only; expose on auth and profile APIs
- Lite English prompts for chat and memoir; localized stage labels and agent
  names (Life Echo / 岁月知己)
- Tencent TTS: language-aware synthesis, ModelType=1 for 501004, English chunking
- WebSocket pipeline: emit all AGENT_RESPONSE segments when TTS cancels; INFO logs
  for tts_this_turn and TTS decisions; on-demand TTS logging
- Expo: device language on auth, i18n tiers/agent name, [SPLIT] streaming UX fixes
- Tests for migration, prompts, pipeline, router tts_this_turn, reply segments

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin
2026-05-11 16:16:49 +08:00
parent 5ce29aad64
commit ccdc4e4277
64 changed files with 3233 additions and 208 deletions

View File

@@ -22,6 +22,7 @@ from app.agents.chat.personas import normalize_interview_persona
from app.agents.chat.prompt_context import ChatPromptContext
from app.agents.chat.prompts_conversation import (
SLOT_NAME_MAP,
SLOT_NAME_MAP_EN,
get_opening_prompt,
)
from app.agents.chat.reply_limits import (
@@ -50,6 +51,23 @@ logger = get_logger(__name__)
# LLM 不可用或调用失败时对用户展示(不暴露异常细节、不触发 TTS
_FALLBACK_REPLY = "刚才网络不太稳,没接上。你可以再说一遍,或稍后再试。"
_FALLBACK_REPLY_EN = (
"Network glitch on my end — could you say that again, or give it another try in a moment?"
)
_OPENING_FALLBACK_ZH = "你好呀~ 又见面了。今天想从人生里哪一小段回忆开始聊聊?"
_OPENING_FALLBACK_EN = (
"Hi there — good to see you again. Where in your life would you like to start today?"
)
def _fallback_reply_for(language: str) -> str:
return _FALLBACK_REPLY_EN if language == "en" else _FALLBACK_REPLY
def _opening_fallback_for(language: str) -> str:
return _OPENING_FALLBACK_EN if language == "en" else _OPENING_FALLBACK_ZH
# 仅在「重复问句守卫」把正文削成单句兜底时追加二次 system只多调一次模型。
_DUPLICATE_GUARD_LLM_RETRY_SYSTEM_APPENDIX = """## 二次生成(纠偏)
@@ -60,6 +78,20 @@ _DUPLICATE_GUARD_LLM_RETRY_SYSTEM_APPENDIX = """## 二次生成(纠偏)
- 若要提问,须换**全新角度**,并锚在用户刚说的具体细节里;也可以本轮**完全不提问**,只并肩承接;
- **禁止**整段只有「这一段我记住了」或同类无信息套话。"""
_DUPLICATE_GUARD_LLM_RETRY_SYSTEM_APPENDIX_EN = """## Second pass (correction)
The previous reply was discarded because it repeated questions that already appeared in "recently asked questions" or restated facts already confirmed. Please **write a whole new reply**:
- Still obey every main rule above.
- Open with a half-sentence to a sentence or two that picks up the user's exact words this turn (with a touch of imagery is fine).
- **Do not** re-use the same confirmation question with only different wording.
- If you do ask a question, choose a **new angle** anchored in a specific detail the user just mentioned; you may also ask **no question** this turn and simply walk alongside what they said.
- **Do not** fall back on filler such as "I'll remember this part" or other content-free reassurance."""
def _duplicate_guard_appendix_for(language: str) -> str:
if language == "en":
return _DUPLICATE_GUARD_LLM_RETRY_SYSTEM_APPENDIX_EN
return _DUPLICATE_GUARD_LLM_RETRY_SYSTEM_APPENDIX
def _finalize_chat_segments_after_llm(
response_text: str,
@@ -68,6 +100,7 @@ def _finalize_chat_segments_after_llm(
max_chars: int,
memoir_state: MemoirStateSchema,
recent_questions: list[str],
language: str = "zh",
) -> tuple[list[str], bool]:
raw_list = segments_from_llm_response(
response_text,
@@ -82,7 +115,7 @@ def _finalize_chat_segments_after_llm(
)
if not out:
out = [response_text.strip()[:max_chars]]
out = nonempty_segments_or_fallback(out, fallback=_FALLBACK_REPLY)
out = nonempty_segments_or_fallback(out, fallback=_fallback_reply_for(language))
out, deduped = apply_duplicate_question_guard(
out,
state=memoir_state,
@@ -149,11 +182,12 @@ class InterviewAgent:
profile_era_place: str = "",
stage_switched_this_turn: bool = False,
scene_cues_for_planner: Optional[list[str]] = None,
language: str = "zh",
) -> AgentChatTurn:
"""生成状态感知的访谈回复,不持久化(由 Orchestrator 负责)"""
if not self.llm:
logger.warning("InterviewAgent: LLM 未配置,返回兜底文案")
return AgentChatTurn(messages=[_FALLBACK_REPLY], skip_tts=True)
return AgentChatTurn(messages=[_fallback_reply_for(language)], skip_tts=True)
try:
text_for_model = self._resolve_text_for_model(
user_message, normalized_user_message
@@ -245,6 +279,7 @@ class InterviewAgent:
persona_threads=memoir_state.persona_threads,
recent_questions=recent_questions or memoir_state.recent_questions,
turn_plan=turn_plan,
language=language,
)
system_prompt = ctx.guided_system_prompt()
messages: List[Any] = [SystemMessage(content=system_prompt)]
@@ -305,11 +340,12 @@ class InterviewAgent:
max_chars=max_chars,
memoir_state=memoir_state,
recent_questions=rq_base,
language=language,
)
retry_used = False
if deduped and segments_are_only_duplicate_guard_fallback(out):
retry_system = (
f"{system_prompt}\n\n{_DUPLICATE_GUARD_LLM_RETRY_SYSTEM_APPENDIX}"
f"{system_prompt}\n\n{_duplicate_guard_appendix_for(language)}"
)
retry_messages: List[Any] = [
SystemMessage(content=retry_system),
@@ -359,6 +395,7 @@ class InterviewAgent:
max_chars=max_chars,
memoir_state=memoir_state,
recent_questions=rq_base,
language=language,
)
retry_used = True
out, auto_bio = apply_autobiographical_boundary_guard(out)
@@ -394,7 +431,7 @@ class InterviewAgent:
)
except Exception as e:
logger.error("生成回应失败: {}", e, exc_info=True)
return AgentChatTurn(messages=[_FALLBACK_REPLY], skip_tts=True)
return AgentChatTurn(messages=[_fallback_reply_for(language)], skip_tts=True)
async def generate_opening_message(
self,
@@ -405,17 +442,19 @@ class InterviewAgent:
occupation: str = "",
profile_birth_year: Optional[int] = None,
profile_era_place: str = "",
language: str = "zh",
) -> List[str]:
"""生成空对话开场白,不持久化(由 Orchestrator 负责)"""
if not self.llm:
return ["你好呀~ 又见面了。今天想从人生里哪一小段回忆开始聊聊?"]
return [_opening_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
)
empty_slots_readable = [SLOT_NAME_MAP.get(s, s) for s in empty_slots]
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_opening_prompt(
current_stage=memoir_state.current_stage,
@@ -426,6 +465,7 @@ class InterviewAgent:
occupation=occupation,
profile_birth_year=profile_birth_year,
profile_era_place=profile_era_place,
language=language,
)
hw = await get_history_with_window(
conversation_id,
@@ -434,14 +474,19 @@ class InterviewAgent:
)
messages: List[Any] = [SystemMessage(content=prompt)]
messages.extend(hw.window)
if not hw.window:
messages.append(
HumanMessage(content="(对话刚开始,请自然地说出你的开场白。)")
if language == "en":
kickoff = (
"(The conversation is just starting; please greet naturally.)"
if not hw.window
else "(Continue from the context above and deliver your opening line naturally.)"
)
else:
messages.append(
HumanMessage(content="(请根据上文,自然接续并说出你的开场白。)")
kickoff = (
"(对话刚开始,请自然地说出你的开场白。)"
if not hw.window
else "(请根据上文,自然接续并说出你的开场白。)"
)
messages.append(HumanMessage(content=kickoff))
log_agent_payload(
logger,
"InterviewAgent.opening.prompt",
@@ -498,8 +543,8 @@ class InterviewAgent:
segments = out if out else [response_text.strip()[:max_chars]]
return nonempty_segments_or_fallback(
segments,
fallback="你好呀~ 又见面了。今天想从人生里哪一小段回忆开始聊聊?",
fallback=_opening_fallback_for(language),
)
except Exception as e:
logger.error("生成开场白失败: {}", e, exc_info=True)
return ["你好呀~ 又见面了。今天想从人生里哪一小段回忆开始聊聊?"]
return [_opening_fallback_for(language)]

View File

@@ -51,9 +51,20 @@ if TYPE_CHECKING:
logger = get_logger(__name__)
_UNAUTH_TURN = AgentChatTurn(
_UNAUTH_TURN_ZH = AgentChatTurn(
messages=["暂时没法继续对话,请先登录后再试。"], skip_tts=True
)
_UNAUTH_TURN_EN = AgentChatTurn(
messages=["You'll need to sign in again before we can continue."],
skip_tts=True,
)
def _user_language(user: Optional["User"]) -> str:
if not user:
return "zh"
lang = getattr(user, "language_preference", None) or "zh"
return "en" if str(lang).lower() == "en" else "zh"
async def _fetch_interview_memory_bundle(
@@ -145,6 +156,7 @@ class ChatOrchestrator:
根据 missing_fields 路由到 ProfileAgent 或 InterviewAgent。
"""
t0 = time.perf_counter()
language = _user_language(user)
# --- 资料收集模式 ---
if user:
@@ -179,7 +191,10 @@ class ChatOrchestrator:
# Profile 阶段每轮都抽取:短确认语也可能带可推断资料,跳过抽取会导致槽位长期不更新
extracted = (
await self.profile_agent.extract_profile_from_message(
user_message, missing, conversation_id=conversation_id
user_message,
missing,
conversation_id=conversation_id,
language=language,
)
)
logger.info(
@@ -198,7 +213,7 @@ class ChatOrchestrator:
if not remaining:
st = await get_or_create_state(user.id, db)
interview_stage_hint = life_stage_display_name(
st.current_stage
st.current_stage, language=language
)
responses = await self.profile_agent.generate_profile_followup(
conversation_id=conversation_id,
@@ -207,6 +222,7 @@ class ChatOrchestrator:
filled_fields=filled,
nickname=user.nickname or "",
interview_stage_hint=interview_stage_hint,
language=language,
)
if agent_summary_enabled():
logger.info(
@@ -223,8 +239,13 @@ class ChatOrchestrator:
)
except Exception as e:
logger.exception("资料收集处理失败: {}", e)
fb_msg = (
"Sorry, I missed that. Could you say it again?"
if language == "en"
else "不好意思刚才没接住,你再说一遍好吗?"
)
return AgentChatTurn(
messages=["不好意思刚才没接住,你再说一遍好吗?"],
messages=[fb_msg],
skip_tts=False,
memory_retrieval_trace=None,
)
@@ -239,7 +260,7 @@ class ChatOrchestrator:
(time.perf_counter() - t0) * 1000,
conversation_id,
)
return _UNAUTH_TURN
return _UNAUTH_TURN_EN if language == "en" else _UNAUTH_TURN_ZH
log_agent_detail(
logger,
@@ -284,6 +305,7 @@ class ChatOrchestrator:
birth_place=user.birth_place,
grew_up_place=user.grew_up_place,
occupation=user.occupation,
language=language,
)
background_voice = infer_background_voice(user.occupation)
occupation = user.occupation or ""
@@ -331,6 +353,7 @@ class ChatOrchestrator:
profile_era_place=profile_era_place,
stage_switched_this_turn=stage_switched_this_turn,
scene_cues_for_planner=scene_cues_for_planner,
language=language,
)
recent_questions = prompt_state.recent_questions
if turn.interview_state_meta and isinstance(turn.interview_state_meta, dict):
@@ -387,6 +410,7 @@ class ChatOrchestrator:
voice_session_id: str | None = None,
user_message_timestamp: datetime | None = None,
audio_duration_seconds: int | None = None,
language: str = "zh",
) -> List[str]:
"""委托 ProfileAgent 生成资料追问(持久化由调用方负责)。"""
return await self.profile_agent.generate_profile_followup(
@@ -395,6 +419,7 @@ class ChatOrchestrator:
missing_fields=missing_fields,
filled_fields=filled_fields,
nickname=nickname,
language=language,
)
async def generate_profile_greeting(
@@ -402,12 +427,14 @@ class ChatOrchestrator:
conversation_id: str,
missing_fields: List[str],
nickname: str = "",
language: str = "zh",
) -> List[str]:
"""委托 ProfileAgent 生成资料收集开场白(持久化由调用方负责)。"""
return await self.profile_agent.generate_profile_greeting(
conversation_id=conversation_id,
missing_fields=missing_fields,
nickname=nickname,
language=language,
)
async def generate_response_with_state(
@@ -431,6 +458,7 @@ class ChatOrchestrator:
profile_era_place: str = "",
stage_switched_this_turn: bool = False,
scene_cues_for_planner: Optional[list[str]] = None,
language: str = "zh",
) -> AgentChatTurn:
"""委托 InterviewAgent 生成访谈回复(持久化由调用方负责)。"""
return await self.interview_agent.generate_response_with_state(
@@ -449,6 +477,7 @@ class ChatOrchestrator:
profile_era_place=profile_era_place,
stage_switched_this_turn=stage_switched_this_turn,
scene_cues_for_planner=scene_cues_for_planner,
language=language,
)
def detect_user_stage(self, user_message: str) -> str:
@@ -464,6 +493,7 @@ class ChatOrchestrator:
occupation: str = "",
profile_birth_year: Optional[int] = None,
profile_era_place: str = "",
language: str = "zh",
) -> List[str]:
"""
委托 InterviewAgent 生成访谈开场白(持久化由调用方 ConversationHistoryStore 负责)。
@@ -476,4 +506,5 @@ class ChatOrchestrator:
occupation=occupation,
profile_birth_year=profile_birth_year,
profile_era_place=profile_era_place,
language=language,
)

View File

@@ -1,4 +1,55 @@
"""共用用户可见回复禁令与文风(访谈 / 资料收集 / 所有面向用户的 Agent"""
"""共用用户可见回复禁令与文风(访谈 / 资料收集 / 所有面向用户的 Agent
`*_en` variants are deliberately lighter: they preserve role / fact boundaries
/ format constraints, but drop CJK-specific rhetoric rules (e.g. "嗯。" 起头).
"""
def chat_output_rules_en() -> str:
"""English-lite output guardrails for user-facing replies."""
return (
"**Do not** output Markdown or layout symbols: no headings, bold/italic, "
"code fences, links, lists, or rendering markers; speak in natural, "
"spoken-style prose. You **may** use the literal token `[SPLIT]` to break "
"a reply into at most two short bubbles. "
"**Do not** include parenthetical stage directions, sound effects, or "
"action descriptions (e.g. *(laughs softly)*, *(sighs)*, *(pauses)*); "
"speak as if talking out loud. "
"**Do not** use host/anchor language (\"Now then\", \"Let us\", \"Thank you "
"for sharing\") or hard topic switches (\"Let's move on to...\", \"Changing "
"subjects...\"). When you need to shift focus, lean on the user's own "
"words to bridge. "
"Avoid summarizing tone (\"It sounds like you...\", \"From what you're "
"saying...\") and avoid interview clichés (\"I noticed\", \"I'd like to "
"understand\"). When the user is sharing something heavy or emotional, "
"do not reply with a single neutral particle; respond with at least a "
"short half-sentence that picks up their actual words. "
"Do not invent facts the user has not stated (names, dates, places, "
"events, exact numbers). "
"**Do not** claim personal life experience as the assistant (childhood, "
"schooling, romance, family, career history); do not rewrite the user's "
"experience as \"me too\". If the user asks about your background, redirect "
"by referring back to what *they* shared (\"You mentioned earlier...\"). "
"**Avoid** loaded multi-clause questions or A/B options that smuggle in "
"the answer. **Do not** repeat the same metaphor or imagery across turns. "
"**Length**: prefer short and precise; one acknowledgement plus one "
"question per reply, never an essay."
)
def chat_voice_style_en() -> str:
"""English-lite voice style hint for all user-facing agents."""
return (
"Tone: like a warm, attentive interviewer who is here to help the user "
"tell their life story — friendly, conversational, never clinical. "
"Pick up on the specific detail the user just mentioned and gently push "
"one step deeper, rather than jumping to a new generic question. "
"Use everyday language with concrete imagery; avoid summary clichés "
"(\"It sounds like your childhood was happy\") in favor of conversational "
"follow-ups (\"That feeling you described — does it still come back to "
"you now?\"). When following up, stay close to the detail the user just "
"named instead of broadening the topic."
)
def chat_output_rules() -> str:
@@ -51,4 +102,9 @@ def chat_voice_style() -> str:
)
__all__ = ["chat_output_rules", "chat_voice_style"]
__all__ = [
"chat_output_rules",
"chat_voice_style",
"chat_output_rules_en",
"chat_voice_style_en",
]

View File

@@ -1,11 +1,23 @@
"""
访谈 Agent 可配置性格Persona仅影响语气不替代事实边界与槽位约束。
同时提供品牌名称(中英)的单一来源,便于跨 prompt / UI 文案一致。
"""
from __future__ import annotations
from typing import Final
# Brand / interviewer name — keep aligned with frontend i18n `conversation.agentName`,
# OpenAPI title, README, and project metadata. zh = 「岁月知己」en = Life Echo.
AGENT_NAME_ZH: Final[str] = "岁月知己"
AGENT_NAME_EN: Final[str] = "Life Echo"
def agent_name(language: str = "zh") -> str:
"""Return the interviewer brand name for the requested language."""
return AGENT_NAME_EN if (language or "zh").strip().lower() == "en" else AGENT_NAME_ZH
# 与 settings.chat_interview_persona 及文档保持一致
VALID_INTERVIEW_PERSONAS: Final[frozenset[str]] = frozenset(
{"default", "warm_listener", "curious_guide"}

View File

@@ -29,6 +29,31 @@ from app.ports.llm import LLMProvider
logger = get_logger(__name__)
_FOLLOWUP_FALLBACK_ZH = "谢谢分享!能再告诉我一些吗?"
_FOLLOWUP_FALLBACK_EN = "Thanks for sharing — could you tell me a bit more?"
_GREETING_FALLBACK_ZH = "你好!在开始之前,能告诉我你是哪一年出生的吗?"
_GREETING_FALLBACK_EN = (
"Hi! Before we get started, could you tell me what year you were born?"
)
_GREETING_FALLBACK_FULL_ZH = (
"你好!在我们开始聊人生故事之前,能先简单介绍一下你自己吗?比如你是哪一年出生的?"
)
_GREETING_FALLBACK_FULL_EN = (
"Hi! Before we dive into life stories, could you introduce yourself a little — for example, what year were you born?"
)
def _profile_followup_fallback(language: str) -> str:
return _FOLLOWUP_FALLBACK_EN if language == "en" else _FOLLOWUP_FALLBACK_ZH
def _profile_greeting_fallback(language: str) -> str:
return _GREETING_FALLBACK_EN if language == "en" else _GREETING_FALLBACK_ZH
def _profile_greeting_fallback_full(language: str) -> str:
return _GREETING_FALLBACK_FULL_EN if language == "en" else _GREETING_FALLBACK_FULL_ZH
class _ProviderBackedProfileGateway:
def __init__(self, provider: LLMProvider) -> None:
@@ -173,6 +198,7 @@ class ProfileAgent:
user_message: str,
missing_fields: List[str],
conversation_id: Optional[str] = None,
language: str = "zh",
) -> Dict[str, Any]:
"""从用户消息中提取资料字段,不持久化"""
if not missing_fields:
@@ -186,15 +212,20 @@ class ProfileAgent:
)
recent = hw.window[-4:] if len(hw.window) > 4 else hw.window
parts = []
user_label = "User" if language == "en" else "用户"
asst_label = "Assistant" if language == "en" else "助手"
for msg in recent:
if isinstance(msg, HumanMessage):
parts.append(f"用户: {msg.content}")
parts.append(f"{user_label}: {msg.content}")
elif isinstance(msg, AIMessage):
parts.append(f"助手: {msg.content}")
parts.append(f"{asst_label}: {msg.content}")
recent_dialogue = "\n".join(parts) if parts else ""
try:
prompt = get_profile_extraction_prompt(
user_message, missing_fields, recent_dialogue=recent_dialogue or None
user_message,
missing_fields,
recent_dialogue=recent_dialogue or None,
language=language,
)
parsed = await self._llm_gateway.json_object(
prompt,
@@ -241,6 +272,7 @@ class ProfileAgent:
filled_fields: Dict[str, str],
nickname: str = "",
interview_stage_hint: str = "",
language: str = "zh",
) -> List[str]:
"""生成资料追问回复,不持久化(由 Orchestrator 负责)"""
try:
@@ -249,6 +281,7 @@ class ProfileAgent:
filled_fields,
nickname,
interview_stage_hint=interview_stage_hint,
language=language,
)
hw = await get_history_with_window(
conversation_id,
@@ -284,7 +317,7 @@ class ProfileAgent:
response_text,
max_segments=3,
max_chars_per_segment=settings.chat_interview_max_chars_per_segment,
fallback="谢谢分享!能再告诉我一些吗?",
fallback=_profile_followup_fallback(language),
)
log_agent_summary(
logger,
@@ -295,17 +328,20 @@ class ProfileAgent:
return segments
except Exception as e:
logger.error("生成资料跟进回复失败: {}", e)
return ["谢谢分享!能再告诉我一些吗?"]
return [_profile_followup_fallback(language)]
async def generate_profile_greeting(
self,
conversation_id: str,
missing_fields: List[str],
nickname: str = "",
language: str = "zh",
) -> List[str]:
"""生成资料收集开场白,不持久化(由 Orchestrator 负责)"""
try:
prompt = get_profile_greeting_prompt(missing_fields, nickname)
prompt = get_profile_greeting_prompt(
missing_fields, nickname, language=language
)
hw = await get_history_with_window(
conversation_id,
max_pairs=settings.chat_history_max_pairs,
@@ -313,12 +349,19 @@ class ProfileAgent:
)
messages: List[Any] = [SystemMessage(content=prompt)]
messages.extend(hw.window)
if hw.window:
messages.append(
HumanMessage(content="(请根据上文自然接话,继续资料收集开场。)")
if language == "en":
kickoff = (
"(Continue from the context above and warmly carry on the profile-gathering opener.)"
if hw.window
else "(Please deliver your profile-gathering opener.)"
)
else:
messages.append(HumanMessage(content="(请说出资料收集开场白。)"))
kickoff = (
"(请根据上文自然接话,继续资料收集开场。)"
if hw.window
else "(请说出资料收集开场白。)"
)
messages.append(HumanMessage(content=kickoff))
log_agent_payload(
logger,
"ProfileAgent.greeting.prompt",
@@ -345,7 +388,7 @@ class ProfileAgent:
response_text,
max_segments=2,
max_chars_per_segment=settings.chat_interview_max_chars_per_segment,
fallback="你好!在开始之前,能告诉我你是哪一年出生的吗?",
fallback=_profile_greeting_fallback(language),
)
log_agent_summary(
logger,
@@ -356,6 +399,4 @@ class ProfileAgent:
return segments
except Exception as e:
logger.error("生成资料收集开场白失败: {}", e)
return [
"你好!在我们开始聊人生故事之前,能先简单介绍一下你自己吗?比如你是哪一年出生的?"
]
return [_profile_greeting_fallback_full(language)]

View File

@@ -29,6 +29,7 @@ class ChatPromptContext:
persona_threads: List[PersonaThread] | None = None
recent_questions: List[str] | None = None
turn_plan: InterviewTurnPlan | None = None
language: str = "zh"
def guided_system_prompt(self) -> str:
"""用户原话仅以对话历史 + HumanMessage 注入模型。
@@ -60,4 +61,5 @@ class ChatPromptContext:
persona_threads=self.persona_threads or [],
recent_questions=self.recent_questions or [],
turn_directive_block=directive,
language=self.language,
)

View File

@@ -26,6 +26,7 @@ from app.agents.chat.background_voice import (
)
from app.agents.chat.occupation_context import get_occupation_chat_hint
from app.agents.chat.personas import (
AGENT_NAME_ZH,
get_interview_persona_tone_hint,
normalize_interview_persona,
)
@@ -328,7 +329,7 @@ def assemble_guided_prompt(
)
intro = (
"你是「岁月知己」——**主持式访谈者**:口语、克制、可靠;"
f"你是「{AGENT_NAME_ZH}」——**主持式访谈者**:口语、克制、可靠;"
"**职责是帮用户把人生故事口述清楚**,不代写金句、不把问题写成散文、不替用户选边站队。"
)
if intro_tone_line:

View File

@@ -9,8 +9,13 @@ from app.agents.chat.background_voice import (
normalize_background_voice,
)
from app.agents.chat.occupation_context import get_occupation_chat_hint
from app.agents.chat.output_rules import chat_output_rules
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,
)
@@ -23,7 +28,10 @@ from app.agents.chat.prompt_layers import (
build_reply_strategy_block,
build_style_profile_block,
)
from app.agents.stage_constants import STAGE_DISPLAY_ZH, STAGE_ERA_HINTS
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
@@ -56,6 +64,36 @@ SLOT_NAME_MAP = {
"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,
@@ -106,6 +144,61 @@ def _compact_era_hint(
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],
@@ -115,9 +208,18 @@ def get_opening_prompt(
occupation: str = "",
profile_birth_year: Optional[int] = None,
profile_era_place: str = "",
language: str = "zh",
) -> str:
"""空对话时 AI 先开口的提示词"""
stage_name = STAGE_DISPLAY_ZH.get(current_stage, current_stage)
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)
@@ -178,13 +280,13 @@ def get_opening_prompt(
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"
)
@@ -217,6 +319,92 @@ def get_opening_prompt(
直接输出(仅自然口语,无 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],
@@ -234,8 +422,20 @@ def get_guided_conversation_prompt(
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)
@@ -307,6 +507,8 @@ def get_guided_conversation_prompt(
__all__ = [
"SLOT_NAME_MAP",
"SLOT_NAME_MAP_EN",
"slot_name_map_for",
"get_guided_conversation_prompt",
"get_opening_prompt",
]

View File

@@ -4,7 +4,13 @@
from typing import Dict, List, Optional
from app.agents.chat.output_rules import chat_output_rules, chat_voice_style
from app.agents.chat.output_rules import (
chat_output_rules,
chat_output_rules_en,
chat_voice_style,
chat_voice_style_en,
)
from app.agents.chat.personas import AGENT_NAME_EN, AGENT_NAME_ZH
PROFILE_FIELD_NAMES = {
"birth_year": "出生年份",
@@ -13,16 +19,69 @@ PROFILE_FIELD_NAMES = {
"occupation": "职业",
}
PROFILE_FIELD_NAMES_EN = {
"birth_year": "year of birth",
"birth_place": "birthplace",
"grew_up_place": "where you grew up",
"occupation": "occupation",
}
def get_profile_greeting_prompt(missing_fields: List[str], nickname: str = "") -> str:
def _profile_field_names_for(language: str) -> Dict[str, str]:
return PROFILE_FIELD_NAMES_EN if language == "en" else PROFILE_FIELD_NAMES
def _get_profile_greeting_prompt_en(
missing_fields: List[str], nickname: str = ""
) -> str:
missing_names = [
PROFILE_FIELD_NAMES_EN[f]
for f in missing_fields
if f in PROFILE_FIELD_NAMES_EN
]
missing_str = ", ".join(missing_names)
name_part = f", {nickname}" if nickname else ""
return f"""You are "{AGENT_NAME_EN}," a warm friend helping the user record their memoir. You are meeting the user for the first time{name_part}.
{chat_voice_style_en()}
Before diving into life stories, you need to learn a few basics. Still missing: {missing_str}.
## Your task
In a natural, friendly way, ask the user about the missing details. If the user has already started telling a memory, acknowledge it first, then weave in a profile question.
## Rules
1. Do not ask everything at once — ask 12 things per turn.
2. Do not re-ask facts the user already mentioned.
3. Use casual, warm phrasing; vary your wording instead of fixed templates.
4. Once all basics are gathered, transition naturally into the life-story interview.
## Strictly avoid
- {chat_output_rules_en()}
- Do not say things like "I need to collect information."
- Do not list all the questions at once.
## Format
- Use `[SPLIT]` to break a long reply into at most two short messages.
Output exactly what you would say:"""
def get_profile_greeting_prompt(
missing_fields: List[str],
nickname: str = "",
language: str = "zh",
) -> str:
"""生成初次见面、收集基础资料的引导提示词"""
if language == "en":
return _get_profile_greeting_prompt_en(missing_fields, nickname)
missing_names = [
PROFILE_FIELD_NAMES[f] for f in missing_fields if f in PROFILE_FIELD_NAMES
]
missing_str = "".join(missing_names)
name_part = f"{nickname}" if nickname else ""
return f"""你是「岁月知己」,像最懂我的老朋友。你正在和用户初次见面{name_part}
return f"""你是「{AGENT_NAME_ZH}」,像最懂我的老朋友。你正在和用户初次见面{name_part}
{chat_voice_style()}
@@ -48,12 +107,50 @@ def get_profile_greeting_prompt(missing_fields: List[str], nickname: str = "") -
直接输出你要说的话:"""
def get_profile_extraction_prompt(
def _get_profile_extraction_prompt_en(
user_message: str,
missing_fields: List[str],
recent_dialogue: Optional[str] = None,
) -> str:
missing_names = {
f: PROFILE_FIELD_NAMES_EN[f]
for f in missing_fields
if f in PROFILE_FIELD_NAMES_EN
}
dialogue_section = ""
if recent_dialogue and recent_dialogue.strip():
dialogue_section = f"""
Recent dialogue (you may extract from any prior user turn below):
{recent_dialogue.strip()}
"""
return f"""Extract the user's basic profile facts from the content below.{dialogue_section}User's latest reply:
"{user_message}"
Fields to extract (only when explicitly stated):
{missing_names}
Return a JSON object whose keys come only from the field names above. `birth_year` is a four-digit integer; the others are strings. Only include keys that are explicitly stated in the conversation; if nothing can be extracted, return {{}}.
Rules:
1. `birth_year` must be a four-digit integer (e.g. "born in '65" → 1965).
2. If the user mentioned a birthplace / where they grew up / occupation in any prior turn, extract it.
3. Only extract what is explicitly stated; do not guess.
4. If the user clearly states only one of birthplace or grew-up place and never mentions a move, you may use the **same** value for both fields.
5. If no information can be extracted, return the empty object {{}}."""
def get_profile_extraction_prompt(
user_message: str,
missing_fields: List[str],
recent_dialogue: Optional[str] = None,
language: str = "zh",
) -> str:
"""从用户回答中提取基础资料信息(可包含最近几轮对话,避免漏提)"""
if language == "en":
return _get_profile_extraction_prompt_en(
user_message, missing_fields, recent_dialogue=recent_dialogue
)
missing_names = {
f: PROFILE_FIELD_NAMES[f] for f in missing_fields if f in PROFILE_FIELD_NAMES
}
@@ -81,13 +178,85 @@ def get_profile_extraction_prompt(
5. 如果没有提取到任何信息,返回空对象 {{}}"""
def get_profile_followup_prompt(
def _get_profile_followup_prompt_en(
missing_fields: List[str],
filled_fields: Dict[str, str],
nickname: str = "",
interview_stage_hint: str = "",
) -> str:
missing_names = [
PROFILE_FIELD_NAMES_EN[f]
for f in missing_fields
if f in PROFILE_FIELD_NAMES_EN
]
missing_str = ", ".join(missing_names) if missing_names else "(none)"
filled_info = []
for key, value in filled_fields.items():
name = PROFILE_FIELD_NAMES_EN.get(key, key)
filled_info.append(f"{name}: {value}")
filled_str = "\n".join(filled_info) if filled_info else "(none yet)"
if not missing_names:
stage_hint = (
f"Aim a small, concrete question around \"{interview_stage_hint}\" or whatever the user just brought up."
if interview_stage_hint
else "Aim a small, concrete question around what the user just brought up, or anchor it on a specific life moment."
)
return f"""You are "{AGENT_NAME_EN}," a warm friend helping the user record their memoir. Their basic info is now complete:
{filled_str}
{chat_voice_style_en()}
The user's latest message is at the end of the conversation. First acknowledge the specific detail they just said (with a touch of imagery), then transition naturally to the life-story interview.
Improvise the bridge sentence; do not use canned phrasing. {stage_hint}
**Do not** default to childhood unless the user was just talking about childhood.
Format: separate multiple bubbles with `[SPLIT]`.
Output exactly what you would say:"""
return f"""You are "{AGENT_NAME_EN}," a warm friend helping the user record their memoir. You're chatting with the user while quietly learning a few basic facts.
{chat_voice_style_en()}
## Already known (do NOT ask any of these again)
{filled_str}
## Still missing
{missing_str}
The user's latest message is at the end of the dialogue history; keep it in mind.
## How to reply
1. **Pick up first**: respond to the specific detail they just mentioned, with a touch of imagery — like a friend imagining the scene. Avoid generic "that sounds nice."
2. **Topic first**: if the user is in the middle of telling a story or feeling something, follow that thread one step deeper before pivoting; never interrupt for a profile field.
3. **Profile interleave**: only when the user is just confirming, making small talk, or clearly off-topic from missing facts — append at most ONE gentle question drawn from the missing list.
4. **Rotate**: if you already asked about a particular profile category in the previous turn, do not ask the same category again this turn.
5. At most 12 profile-related questions per reply.
Strictly avoid:
- **Never** re-ask anything in "Already known."
- {chat_output_rules_en()}
Format: separate multiple bubbles with `[SPLIT]`.
Output exactly what you would say:"""
def get_profile_followup_prompt(
missing_fields: List[str],
filled_fields: Dict[str, str],
nickname: str = "",
interview_stage_hint: str = "",
language: str = "zh",
) -> str:
"""在收集资料过程中的跟进提问"""
if language == "en":
return _get_profile_followup_prompt_en(
missing_fields,
filled_fields,
nickname=nickname,
interview_stage_hint=interview_stage_hint,
)
missing_names = [
PROFILE_FIELD_NAMES[f] for f in missing_fields if f in PROFILE_FIELD_NAMES
]
@@ -105,7 +274,7 @@ def get_profile_followup_prompt(
if interview_stage_hint
else "问一个与**用户刚才关注点**或人生故事相关的**具体、好回答**的问题作为开场。"
)
return f"""你是「岁月知己」,像最懂我的老朋友。用户的基本信息已经收集完毕:
return f"""你是「{AGENT_NAME_ZH}」,像最懂我的老朋友。用户的基本信息已经收集完毕:
{filled_str}
{chat_voice_style()}
@@ -117,7 +286,7 @@ def get_profile_followup_prompt(
回复格式:多条消息用 [SPLIT] 分隔。
直接输出你要说的话:"""
return f"""你是「岁月知己」,像最懂我的老朋友。你正在和用户聊天,同时自然地了解一些基本信息。
return f"""你是「{AGENT_NAME_ZH}」,像最懂我的老朋友。你正在和用户聊天,同时自然地了解一些基本信息。
{chat_voice_style()}
@@ -149,9 +318,20 @@ def format_user_profile_context(
birth_place: Optional[str] = None,
grew_up_place: Optional[str] = None,
occupation: Optional[str] = None,
language: str = "zh",
) -> str:
"""将用户基础信息格式化为上下文字符串,供其他 agent 使用"""
parts = []
if language == "en":
if birth_year:
parts.append(f"Year of birth: {birth_year}")
if birth_place:
parts.append(f"Birthplace: {birth_place}")
if grew_up_place:
parts.append(f"Where they grew up: {grew_up_place}")
if occupation:
parts.append(f"Occupation: {occupation}")
return "\n".join(parts) if parts else ""
if birth_year:
parts.append(f"出生年份:{birth_year}")
if birth_place:

View File

@@ -4,11 +4,35 @@ from __future__ import annotations
import re
# 零宽字符LLM 偶尔会在 [SPLIT] 周围注入 ZWSP/ZWNJ/ZWJ/BOM需在拆段前去掉
_ZERO_WIDTH_RE = re.compile(r"[\u200B-\u200D\uFEFF]")
# 与客户端 `message-split.ts` 对齐:宽松正则匹配 [SPLIT] / [ SPLIT ] / [split] 等
# 全角中括号 【】 / 先在 _normalize_split_markers 里折成 ASCII 再走该正则
SPLIT_MARKER_RE = re.compile(r"\[\s*SPLIT\s*\]", re.IGNORECASE)
def _normalize_split_markers(text: str) -> str:
"""归一化 [SPLIT] 周围常见变体,确保后端拆段与前端 `MESSAGE_SPLIT_REGEX` 等价。
覆盖:
- 零宽空格 / ZWNJ / ZWJ / BOM
- 全角方括号 【】 / 折叠为 ASCII []
后续仍用 ``SPLIT_MARKER_RE`` 一次性匹配(含大小写、内部空白)。
"""
if not text:
return text
s = _ZERO_WIDTH_RE.sub("", text)
s = s.replace("\uff3b", "[").replace("\uff3d", "]")
s = s.replace("\u3010", "[").replace("\u3011", "]")
return s
def strip_markdown_for_chat(text: str) -> str:
"""
将模型偶然输出的常见 Markdown 剥成纯文本,供 App 聊天气泡展示。
保留换行与字面量 [SPLIT];不做完整 MD 解析,以简单可预测为主。
保留换行与字面量 [SPLIT](实际拆段由 `segments_from_llm_response` 用宽松正则完成,
支持 `[ SPLIT ]`、`[split]`、`【SPLIT】` 等变体)。不做完整 MD 解析,以简单可预测为主。
"""
if not text:
return text
@@ -82,21 +106,24 @@ def segments_from_llm_response(
min_paragraph_chars: int = 12,
) -> list[str]:
"""
优先按字面 [SPLIT] 拆段;若模型只输出一段、但用空行写了多段,再按段落拆。
解决「两段话 + 换行」却未写 [SPLIT] 时仍要拆气泡 / 多段 TTS 的情况
优先按 [SPLIT] 标记拆段(容错:大小写、内部空白、全角中括号、零宽字符均视作分隔符);
若模型只输出一段、但用空行写了多段,再按段落拆
解决「两段话 + 换行」却未写 [SPLIT] 时仍要拆气泡 / 多段 TTS 的情况,
并避免后端 literal split 与前端容错正则不一致时把字面 `[ SPLIT ]` 留在文本里。
"""
text = strip_markdown_for_chat((response_text or "").strip())
text = strip_parenthetical_asides_for_chat(text)
if not text:
return []
normalized = _normalize_split_markers(text)
primary = [
strip_leading_en_period_ack_for_chat(p)
for p in text.split("[SPLIT]")
for p in SPLIT_MARKER_RE.split(normalized)
if strip_leading_en_period_ack_for_chat(p).strip()
]
if len(primary) > 1:
return primary[:max_segments]
blob = primary[0] if primary else strip_leading_en_period_ack_for_chat(text)
blob = primary[0] if primary else strip_leading_en_period_ack_for_chat(normalized)
blob = strip_leading_en_period_ack_for_chat(blob)
if "\n" not in blob:
return [blob]

View File

@@ -83,8 +83,12 @@ async def detect_primary_life_stage(
return normalize_chat_stage(result.detected_stage, fb)
def life_stage_display_name(stage: str) -> str:
"""供提示词展示的中文"""
def life_stage_display_name(stage: str, language: str = "zh") -> str:
"""供提示词展示的本地化名称(默认中文"""
if language == "en":
from app.agents.stage_constants import stage_display_name
return stage_display_name(stage, language="en")
return life_stage_display_zh(stage)

View File

@@ -2,7 +2,12 @@
访谈「人生阶段」判定专用短提示词(与回忆录五阶段 slots 一致)。
"""
from app.agents.stage_constants import CHAT_STAGES, STAGE_DISPLAY_ZH, VALID_CHAT_STAGES
from app.agents.stage_constants import (
CHAT_STAGES,
STAGE_DISPLAY_ZH,
VALID_CHAT_STAGES,
stage_display_name,
)
VALID_CHAT_LIFE_STAGES = VALID_CHAT_STAGES
@@ -11,6 +16,11 @@ def life_stage_display_zh(stage: str) -> str:
return STAGE_DISPLAY_ZH.get(stage, stage)
def life_stage_display(stage: str, language: str = "zh") -> str:
"""Localized life-stage display name (delegates to stage_constants helper)."""
return stage_display_name(stage, language=language)
def get_chat_stage_detection_prompt(user_message: str, current_stage: str) -> str:
"""
仅判定用户本轮**主要**在谈哪一人生阶段;输出 JSON。