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:
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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 1–2 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 1–2 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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
Reference in New Issue
Block a user