refactor(chat): AI-native prompts, remove interview heuristics

- Drop interview_reply_length and utterance_substance; always run stage LLM
  and memory retrieval when enabled; trim Settings fields and .env.example.
- Replace guided/opening prompts with compact fact blocks plus unified
  behavior guidance; slim background_voice and persona to tone hints.
- InterviewAgent uses fixed chat_interview max_tokens/chars/segments.

Also includes stacked work: profile followup/extract path, evaluation rubric
and judge schema updates, transcript SPLIT handling in execution service,
user export markdown split tests, and golden case fixture.
This commit is contained in:
Kevin
2026-04-06 22:22:50 +08:00
parent ca8bcc8489
commit 2fded6fbd9
27 changed files with 426 additions and 1349 deletions

View File

@@ -84,26 +84,19 @@ def normalize_background_voice(voice: str | None) -> BackgroundVoice:
return infer_background_voice(s)
def get_background_voice_chat_block(voice: str | None) -> str:
"""注入访谈 guided/opening 的「背景语气」段落default 返回空串。"""
def get_background_voice_tone_hint(voice: str | None) -> str:
"""一句背景语气提示,融入主 system promptdefault 返回空串。"""
v = normalize_background_voice(voice)
if v == "default":
return ""
if v == "military":
return (
"## 背景语气:军队语境(仅语气,不编造事实)\n"
"称呼得体、句子简洁利落、条理清楚;避免网络梗与油滑套话。\n"
"先简短接住对方,再**最多一个**具体问题;不写命令式、不做思想政治表态。\n"
"涉及纪律、集体、任务等措辞,**仅当用户口述已出现相关事实时**自然呼应,禁止堆砌军事化辞藻或虚构经历。\n"
"用户已退役/转业,以回忆军旅岁月为基调,不要预设其仍在服役。"
"语气简洁利落、得体;称呼自然;不写命令式、不堆砌军事辞藻;"
"仅当用户口述已出现相关事实时才呼应军旅语境,不编造经历。"
)
# cadre
return (
"## 背景语气:干部/机关语境(仅语气,不编造事实)\n"
"稳重、有分寸,敬语适度;句子可略完整,但仍控制总字数,避免官样文章与排比空话。\n"
"先回应对方内容,再**最多一个**具体问题;不写公文套话、不做政治评价。\n"
"涉及职务与组织时,**不得编造**用户未提及的职级、单位与荣誉。\n"
"用户已退休,以回顾和怀念工作岁月为基调,不要预设其仍在岗。"
"语气稳重有分寸、敬语适度;避免官样排比与公文套话;"
"不得编造用户未提及的职级、单位与荣誉。"
)

View File

@@ -13,7 +13,6 @@ from app.agents.chat.helpers import format_history_string, get_history_with_wind
from app.agents.chat.personas import normalize_interview_persona
from app.agents.chat.prompt_context import ChatPromptContext
from app.agents.chat.stage_detection import keyword_fallback_primary_stage
from app.agents.chat.interview_reply_length import compute_reply_plan
from app.agents.chat.prompts_conversation import (
SLOT_NAME_MAP,
get_opening_prompt,
@@ -67,23 +66,6 @@ class InterviewAgent:
"""关键词回退:与 stage_detection 一致(多阶段打分)。"""
return keyword_fallback_primary_stage(user_message)
def _estimate_same_topic_turns(
self, history_messages: List[Any], current_filled_slots: dict
) -> int:
"""估算同一话题的连续轮数(保守:宁可多陪聊几轮再换)。"""
n_pairs = len(history_messages) // 2
if n_pairs <= 1:
return n_pairs
recent_window = min(n_pairs, 5)
recent = history_messages[-(recent_window * 2) :]
nonempty_user_turns = 0
for i in range(0, len(recent), 2):
msg = recent[i]
text = msg.content if hasattr(msg, "content") else str(msg)
if len(text.strip()) > 5:
nonempty_user_turns += 1
return nonempty_user_turns
def _resolve_text_for_model(
self,
user_message: str,
@@ -137,27 +119,21 @@ class InterviewAgent:
max_chars=settings.chat_history_max_chars,
)
conversation_turn_total = hw.turn_total
same_topic_turns = self._estimate_same_topic_turns(hw.window, filled_slots)
all_stages_coverage = memoir_state.all_stages_coverage()
persona = normalize_interview_persona(settings.chat_interview_persona)
reply_plan = compute_reply_plan(
text_for_model,
background_voice=background_voice,
settings=settings,
)
max_segments = int(settings.chat_interview_max_segments)
max_tokens = int(settings.chat_interview_max_tokens)
max_chars = int(settings.chat_interview_max_chars_per_segment)
ctx = ChatPromptContext(
current_stage=memoir_state.current_stage,
empty_slots=empty_slots,
filled_slots=filled_slots,
user_message=text_for_model,
conversation_turn_total=conversation_turn_total,
same_topic_turns=same_topic_turns,
all_stages_coverage=all_stages_coverage,
detected_user_stage=du,
user_profile_context=user_profile_context,
persona=persona,
memory_evidence_text=memory_evidence_text,
reply_length_mode=reply_plan.mode.value,
background_voice=background_voice,
occupation=occupation,
)
@@ -181,7 +157,7 @@ class InterviewAgent:
omit_system_body=settings.agent_log_omit_system_message_body,
),
)
chat_llm = self.llm.bind(max_tokens=reply_plan.max_tokens)
chat_llm = self.llm.bind(max_tokens=max_tokens)
prompt_chars = _message_contents_char_count(messages)
llm_t0 = time.perf_counter()
with agent_span(
@@ -212,26 +188,25 @@ class InterviewAgent:
)
raw_list = segments_from_llm_response(
response_text,
max_segments=reply_plan.max_segments,
max_segments=max_segments,
)
if not raw_list:
raw_list = [response_text.strip()]
out = truncate_chat_segments(
raw_list,
max_segments=reply_plan.max_segments,
max_chars_per_segment=reply_plan.max_chars_per_segment,
max_segments=max_segments,
max_chars_per_segment=max_chars,
)
if not out:
out = [response_text.strip()[: reply_plan.max_chars_per_segment]]
out = [response_text.strip()[:max_chars]]
out = nonempty_segments_or_fallback(out, fallback=_FALLBACK_REPLY)
log_agent_summary(
logger,
"InterviewAgent.generate_response segments={} conversation_id={} "
"reply_length_mode={} max_tokens={}",
"max_tokens={}",
len(out),
conversation_id,
reply_plan.mode.value,
reply_plan.max_tokens,
max_tokens,
)
return AgentChatTurn(messages=out, skip_tts=False)
except Exception as e:
@@ -314,15 +289,11 @@ class InterviewAgent:
raw_list = segments_from_llm_response(response_text, max_segments=2)
if not raw_list:
raw_list = [response_text.strip()]
open_plan = compute_reply_plan(
"x" * 50,
background_voice=background_voice,
settings=settings,
)
max_chars = int(settings.chat_interview_max_chars_per_segment)
out = truncate_chat_segments(
raw_list,
max_segments=2,
max_chars_per_segment=open_plan.max_chars_per_segment,
max_chars_per_segment=max_chars,
)
log_agent_summary(
logger,
@@ -330,11 +301,7 @@ class InterviewAgent:
len(out),
conversation_id,
)
segments = (
out
if out
else [response_text.strip()[: open_plan.max_chars_per_segment]]
)
segments = out if out else [response_text.strip()[:max_chars]]
return nonempty_segments_or_fallback(
segments,
fallback="你好呀~ 又见面了,最近有没有什么事想跟我说说?",

View File

@@ -1,311 +0,0 @@
"""
访谈回复长度:由用户本轮文本 + 启发式(新细节 / 闲聊 / 信息密度)决定档位,
与 max_tokens、max_chars_per_segment 联动;单一 ReplyPlan 供 prompt 与截断共用。
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING
from app.agents.chat.background_voice import normalize_background_voice
if TYPE_CHECKING:
from app.core.config import Settings
class ReplyLengthMode(str, Enum):
"""brief极短standard默认expanded值得展开承接时稍长。"""
brief = "brief"
standard = "standard"
expanded = "expanded"
# 用户本轮字符数分桶strip 后按 len中文友好
_LEN_BRIEF_MAX = 20
_LEN_MID_EXPAND_MIN = 40
_LEN_LONG_MIN = 80
def heuristic_likely_new_detail(user_message: str) -> bool:
"""
轻量启发:本轮是否很可能补充了新人名、新关系或新情节(追问触发与长度共用)。
"""
m = (user_message or "").strip()
if len(m) < 2:
return False
needles = (
"",
"名字",
"名叫",
"同桌",
"初恋",
"现实里",
"戏里",
"饰演",
"演我",
"第一次",
"认识",
"没想到",
"猜猜",
)
return any(n in m for n in needles)
def heuristic_information_rich(user_message: str) -> bool:
"""
轻量启发:短句也可能信息密度高(新转折、重大事件、时间锚点),用于避免误压成 brief。
"""
m = (user_message or "").strip()
if len(m) < 2:
return False
needles = (
"突然",
"那年",
"后来",
"记得",
"第一次",
"没想到",
"离开",
"去世",
"走了",
"结婚",
"离婚",
"生病",
"辍学",
"退学",
"下岗",
"破产",
"我爸",
"我妈",
"爷爷",
"奶奶",
)
return any(n in m for n in needles)
def heuristic_likely_emotional(user_message: str) -> bool:
"""
轻量启发:用户本轮是否在表达较强情绪(需要更多承接空间、不应被压成 brief
"""
m = (user_message or "").strip()
if len(m) < 4:
return False
needles = (
"想哭",
"哭了",
"难过",
"伤心",
"心酸",
"感动",
"激动",
"害怕",
"委屈",
"后悔",
"对不起",
"愧疚",
"感激",
"谢谢你",
"想念",
"想他",
"想她",
"舍不得",
"不容易",
"太难了",
"崩溃",
"绝望",
"幸福",
"骄傲",
"自豪",
)
return any(n in m for n in needles)
def heuristic_likely_chit_chat(user_message: str) -> bool:
"""
轻量启发:本轮是否偏闲聊(放宽长句里纯寒暄/天气类)。
"""
m = (user_message or "").strip()
if len(m) > 200:
return False
needles_short = (
"天气",
"谢谢",
"哈哈",
"呵呵",
"在吗",
"吃了吗",
"早上好",
"晚安",
"闲聊",
"逗你",
)
if len(m) > 48:
head = m[:100]
if any(n in head for n in needles_short):
if not heuristic_information_rich(m) and not heuristic_likely_new_detail(m):
return True
return False
if any(n in m for n in needles_short):
return True
if len(m) <= 8 and m in ("", "", "行的", "谢谢", "哈哈", "可以", "没事"):
return True
return False
@dataclass(frozen=True)
class ReplyPlan:
"""单一计划prompt 展示档位与数值上限一致(含背景语气微调)。"""
mode: ReplyLengthMode
max_tokens: int
max_chars_per_segment: int
max_segments: int
likely_new_detail: bool
likely_chit_chat: bool
information_rich: bool
def compute_reply_plan(
user_message: str,
*,
background_voice: str | None,
settings: "Settings",
) -> ReplyPlan:
"""
信息量与情绪优先,字数次之:
- 短输入且无新信息、无情绪 → brief
- 短输入但有新细节/高密度/强情绪 → standard
- 中段40-79有实质/情绪 → expanded给足承接空间
- 中段无实质 → standard
- 长输入:闲聊为主 → standard有展开价值 → expanded
"""
norm = (user_message or "").strip()
n = max(0, len(norm))
max_segments = int(settings.chat_interview_max_segments)
likely_new = heuristic_likely_new_detail(norm)
likely_chit = heuristic_likely_chit_chat(norm)
info_rich = heuristic_information_rich(norm)
emotional = heuristic_likely_emotional(norm)
substantive = likely_new or info_rich or emotional
def _mk(m: ReplyLengthMode) -> ReplyPlan:
return _plan_from_mode(
m,
max_segments=max_segments,
settings=settings,
background_voice=background_voice,
likely_new=likely_new,
likely_chit=likely_chit,
info_rich=info_rich,
)
if likely_chit and not substantive:
return _mk(
ReplyLengthMode.brief if n <= _LEN_BRIEF_MAX else ReplyLengthMode.standard
)
if n <= _LEN_BRIEF_MAX:
return _mk(ReplyLengthMode.standard if substantive else ReplyLengthMode.brief)
if n < _LEN_MID_EXPAND_MIN:
return _mk(ReplyLengthMode.standard)
if n < _LEN_LONG_MIN:
return _mk(
ReplyLengthMode.expanded if substantive else ReplyLengthMode.standard
)
return _mk(ReplyLengthMode.expanded if substantive else ReplyLengthMode.standard)
def _plan_from_mode(
mode: ReplyLengthMode,
*,
max_segments: int,
settings: "Settings",
background_voice: str | None,
likely_new: bool,
likely_chit: bool,
info_rich: bool,
) -> ReplyPlan:
if mode == ReplyLengthMode.brief:
base = ReplyPlan(
mode=mode,
max_tokens=int(settings.chat_interview_brief_max_tokens),
max_chars_per_segment=int(
settings.chat_interview_brief_max_chars_per_segment
),
max_segments=max_segments,
likely_new_detail=likely_new,
likely_chit_chat=likely_chit,
information_rich=info_rich,
)
elif mode == ReplyLengthMode.expanded:
base = ReplyPlan(
mode=mode,
max_tokens=int(settings.chat_interview_expanded_max_tokens),
max_chars_per_segment=int(
settings.chat_interview_expanded_max_chars_per_segment
),
max_segments=max_segments,
likely_new_detail=likely_new,
likely_chit_chat=likely_chit,
information_rich=info_rich,
)
else:
base = ReplyPlan(
mode=ReplyLengthMode.standard,
max_tokens=int(settings.chat_interview_max_tokens),
max_chars_per_segment=int(settings.chat_interview_max_chars_per_segment),
max_segments=max_segments,
likely_new_detail=likely_new,
likely_chit_chat=likely_chit,
information_rich=info_rich,
)
return bump_reply_plan_for_background_voice(
base, background_voice=background_voice, settings=settings
)
def bump_reply_plan_for_background_voice(
plan: ReplyPlan,
*,
background_voice: str | None,
settings: "Settings",
) -> ReplyPlan:
"""
干部/军队背景时,仅对 standard 档小幅提高 token 与单段字数;**展示档位不变**(仍为 standard
"""
if normalize_background_voice(background_voice) == "default":
return plan
if plan.mode != ReplyLengthMode.standard:
return plan
extra_t = int(
getattr(
settings,
"chat_interview_cadre_military_standard_extra_tokens",
0,
)
)
extra_c = int(
getattr(
settings,
"chat_interview_cadre_military_standard_extra_chars",
0,
)
)
return ReplyPlan(
mode=plan.mode,
max_tokens=plan.max_tokens + extra_t,
max_chars_per_segment=plan.max_chars_per_segment + extra_c,
max_segments=plan.max_segments,
likely_new_detail=plan.likely_new_detail,
likely_chit_chat=plan.likely_chit_chat,
information_rich=plan.information_rich,
)

View File

@@ -6,17 +6,13 @@ from app.agents.chat.background_voice import normalize_background_voice
def get_occupation_chat_hint(occupation: str | None, background_voice: str) -> str:
"""default 路径的通用职业上下文cadre/military 已有专属块,返回空串"""
"""一句职业事实(仅 default 路径cadre/military 语气由 background_voice 覆盖"""
if normalize_background_voice(background_voice) != "default":
return ""
occ = (occupation or "").strip()
if not occ:
return ""
return (
f"## 用户职业背景\n"
f"用户从事过「{occ}」相关工作。聊天时自然贴合这一背景,"
f"在用语和追问方向上适度靠近用户的职业经历与知识面,但不要刻意。"
)
return f"从事过「{occ}」相关工作,聊天可自然贴近其经历,不要刻意。"
def get_occupation_narrative_hint(occupation: str | None, background_voice: str) -> str:

View File

@@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.chat.agent_turn import AgentChatTurn
from app.agents.chat.helpers import get_history_with_window
from app.agents.chat.interview_agent import InterviewAgent
from app.agents.chat.profile_agent import ProfileAgent
from app.agents.state_schema import MemoirStateSchema
@@ -19,13 +20,9 @@ from app.agents.chat.stage_detection import (
detect_primary_life_stage,
life_stage_display_name,
)
from app.agents.chat.utterance_substance import should_run_chat_stage_memory_heavy_work
from app.core.config import settings
from app.core.dependencies import get_llm_provider
from app.features.conversation.input_normalize import (
apply_conversation_input_rules,
normalize_chat_input_for_agent,
)
from app.features.conversation.input_normalize import normalize_chat_input_for_agent
from app.features.memoir.state_service import get_or_create_state, switch_stage
@@ -68,15 +65,6 @@ async def _fetch_interview_memory_evidence(
"event=chat_memory_retrieval_skip reason=empty user_id={}", user_id
)
return ""
if (
settings.chat_memory_retrieval_require_substantive
and not should_run_chat_stage_memory_heavy_work(msg)
):
logger.debug(
"event=chat_memory_retrieval_skip reason=not_substantive user_id={}",
user_id,
)
return ""
try:
emb = get_embedding_provider()
ms = MemoryService(db, embedding_provider=emb)
@@ -143,60 +131,79 @@ class ChatOrchestrator:
if user:
missing = get_missing_profile_fields_fn(user)
if missing:
try:
log_agent_detail(
logger,
"ChatOrchestrator route=profile conversation_id={} "
"missing_fields={} user_msg_len={}",
hw_profile = await get_history_with_window(
conversation_id,
max_pairs=settings.chat_history_max_pairs,
max_chars=settings.chat_history_max_chars,
)
profile_turn_total = hw_profile.turn_total
if profile_turn_total >= settings.chat_profile_max_turns:
logger.info(
"event=chat_profile_cap_skip conversation_id={} "
"turn_total={} cap={} missing_fields={}",
conversation_id,
profile_turn_total,
settings.chat_profile_max_turns,
missing,
len(user_message or ""),
)
run_extract = True
if settings.chat_profile_extract_require_substantive:
rules_only = apply_conversation_input_rules(user_message or "")
run_extract = should_run_chat_stage_memory_heavy_work(
rules_only
else:
try:
log_agent_detail(
logger,
"ChatOrchestrator route=profile conversation_id={} "
"missing_fields={} user_msg_len={} profile_turn_total={}",
conversation_id,
missing,
len(user_message or ""),
profile_turn_total,
)
extracted = None
if run_extract:
# Profile 阶段每轮都抽取:短确认语也可能带可推断资料,跳过抽取会导致槽位长期不更新
extracted = (
await self.profile_agent.extract_profile_from_message(
user_message, missing, conversation_id=conversation_id
)
)
if extracted:
await apply_extracted_profile_fn(user, extracted, db)
remaining = get_missing_profile_fields_fn(user)
filled = get_filled_profile_fields_fn(user)
interview_stage_hint = ""
if not remaining:
st = await get_or_create_state(user.id, db)
interview_stage_hint = life_stage_display_name(st.current_stage)
responses = await self.profile_agent.generate_profile_followup(
conversation_id=conversation_id,
user_message=user_message,
missing_fields=remaining,
filled_fields=filled,
nickname=user.nickname or "",
interview_stage_hint=interview_stage_hint,
)
if agent_summary_enabled():
logger.info(
"ChatOrchestrator.process_user_message route=profile "
"duration_ms={:.2f} conversation_id={} response_segments={}",
(time.perf_counter() - t0) * 1000,
"event=chat_profile_extract conversation_id={} "
"extracted_keys={} missing_before={}",
conversation_id,
len(responses),
list(extracted.keys()) if extracted else [],
missing,
)
if extracted:
await apply_extracted_profile_fn(user, extracted, db)
remaining = get_missing_profile_fields_fn(user)
filled = get_filled_profile_fields_fn(user)
interview_stage_hint = ""
if not remaining:
st = await get_or_create_state(user.id, db)
interview_stage_hint = life_stage_display_name(
st.current_stage
)
responses = await self.profile_agent.generate_profile_followup(
conversation_id=conversation_id,
user_message=user_message,
missing_fields=remaining,
filled_fields=filled,
nickname=user.nickname or "",
interview_stage_hint=interview_stage_hint,
)
if agent_summary_enabled():
logger.info(
"ChatOrchestrator.process_user_message route=profile "
"duration_ms={:.2f} conversation_id={} response_segments={}",
(time.perf_counter() - t0) * 1000,
conversation_id,
len(responses),
)
return AgentChatTurn(messages=responses, skip_tts=False)
except Exception as e:
logger.error(f"资料收集处理失败: {e}", exc_info=True)
return AgentChatTurn(
messages=["不好意思刚才没接住,你再说一遍好吗?"],
skip_tts=False,
)
return AgentChatTurn(messages=responses, skip_tts=False)
except Exception as e:
logger.error(f"资料收集处理失败: {e}", exc_info=True)
return AgentChatTurn(
messages=["不好意思刚才没接住,你再说一遍好吗?"],
skip_tts=False,
)
# --- 正式访谈模式 ---
user_id = user.id if user else None
@@ -227,14 +234,10 @@ class ChatOrchestrator:
is_from_voice=is_from_voice,
)
state = await get_or_create_state(user_id, db)
substantive_turn = should_run_chat_stage_memory_heavy_work(
normalized_user_message
)
detected = await detect_primary_life_stage(
normalized_user_message,
state.current_stage,
self.interview_agent.llm,
skip_llm=not substantive_turn,
)
if detected != state.current_stage:
state = await switch_stage(user_id, detected, db)

View File

@@ -8,7 +8,9 @@ def chat_output_rules() -> str:
"反引号代码、`[]()` 链接、列表符号或渲染用符号;只输出连贯口语,**可以**在需要分两气泡时使用字面量 "
"`[SPLIT]`(仅此一处方括号用法);**禁止**输出括号、括号内的策略/舞台说明(例如「(先接住情绪)」「(共情)」)、"
"思考过程或任何元注释——这些只存在于系统指令里,**绝不可**出现在你对用户说的话中;"
"采访腔(「我注意到」「我想了解」);重复确认对方已经说过或能推断出的信息;编造对方没说的细节。"
"采访腔(「我注意到」「我想了解」);重复确认对方已经说过或能推断出的信息;"
"编造对方没说的**具体**事实(人名、时间、地点、事件经过等若用户未提及则不说)。"
"**允许**用「我能想象……」「那时候大概……」等泛泛接话,但不要把这些写成就等于用户亲身经历的事实。"
)

View File

@@ -1,5 +1,5 @@
"""
访谈 Agent 可配置性格Persona仅影响语气与追问倾向,不替代事实边界与槽位约束。
访谈 Agent 可配置性格Persona仅影响语气不替代事实边界与槽位约束。
"""
from __future__ import annotations
@@ -20,41 +20,21 @@ def normalize_interview_persona(raw: str | None) -> str:
return "default"
def get_interview_persona_block(persona: str) -> str:
"""
返回注入到访谈 prompt 的「访谈性格」段落(不含 default由调用方跳过
"""
def get_interview_persona_tone_hint(persona: str) -> str:
"""一句访谈性格提示,融入主 system promptdefault 返回空串。"""
key = normalize_interview_persona(persona)
if key == "default":
return ""
if key == "warm_listener":
return "偏倾听与承接,语气柔和、少打断;不审问感,一次最多一个具体问题。"
return "爱把人往一个具体细节里带;短句像微信,一次最多一个具体问题,不重复上文已清楚的事。"
blocks = {
"warm_listener": (
"## 访谈性格:温柔倾听\n"
"在遵守「回忆录导向与闲聊」的前提下,优先把对话引向可写进回忆录的素材;明显闲聊时先陪聊。\n"
"你更偏倾听与承接,语气柔和、少打断;"
"但一旦用户说出**新的人名、新的关系、或新的情节线**(上文未展开),"
"仍必须按本提示中的「追问触发」规则,在承接后带**一个**具体问题,不能用纯感慨代替。\n"
"禁止审问感、禁止一次抛多个问题。"
),
"curious_guide": (
"## 访谈性格:好奇引导\n"
"在遵守「回忆录导向与闲聊」的前提下,追问尽量落在人生故事与未覆盖方向上;明显闲聊时先陪聊。\n"
"你更愿意把人往**一个具体细节**里带:时间、场景、对方反应、你心里一闪而过的念头;"
"每轮**最多一个**具体问题,短句、像微信。\n"
"若本轮触发「追问触发」,优先追问用户刚抛出的新信息,不要为了凑问题去重复上文已清楚的事。"
),
}
return blocks.get(key, "")
def get_interview_persona_block(persona: str) -> str:
"""兼容旧名:返回空串,请改用 get_interview_persona_tone_hint。"""
return ""
def get_opening_persona_line(persona: str) -> str:
"""开场白用的一行性格提示(短,避免喧宾夺主)"""
key = normalize_interview_persona(persona)
if key == "default":
return ""
lines = {
"warm_listener": "语气偏倾听、少打断;但仍须完成「问候 + 一个具体问题」。",
"curious_guide": "语气偏好奇、爱往细节里带一个具体问题;不要一次问很多。",
}
return lines.get(key, "")
"""兼容旧名:与访谈轮次共用一句性格提示"""
return get_interview_persona_tone_hint(persona)

View File

@@ -152,6 +152,12 @@ class ProfileAgent:
result["grew_up_place"] = str(parsed.grew_up_place)
if parsed.occupation:
result["occupation"] = str(parsed.occupation)
bp = result.get("birth_place")
gp = result.get("grew_up_place")
if bp and not gp:
result["grew_up_place"] = bp
elif gp and not bp:
result["birth_place"] = gp
return result
except Exception as e:
logger.error("提取资料信息失败: {}", e)
@@ -173,7 +179,6 @@ class ProfileAgent:
prompt = get_profile_followup_prompt(
missing_fields,
filled_fields,
user_message,
nickname,
interview_stage_hint=interview_stage_hint,
)

View File

@@ -13,35 +13,27 @@ class ChatPromptContext:
current_stage: str
empty_slots: List[str]
filled_slots: Dict[str, str]
user_message: str
conversation_turn_total: int = 0
same_topic_turns: int = 0
all_stages_coverage: Optional[Dict[str, Dict]] = None
detected_user_stage: str = ""
user_profile_context: str = ""
persona: str = "default"
memory_evidence_text: str = ""
reply_length_mode: str = "standard"
background_voice: str = "default"
occupation: str = ""
def guided_system_prompt(self) -> str:
"""`user_message` 仅参与启发式,不出现在返回的系统提示文本中"""
"""用户原话仅以对话历史 + HumanMessage 注入模型"""
from app.agents.chat.prompts_conversation import get_guided_conversation_prompt
return get_guided_conversation_prompt(
current_stage=self.current_stage,
empty_slots=self.empty_slots,
filled_slots=self.filled_slots,
user_message=self.user_message,
conversation_turn_total=self.conversation_turn_total,
same_topic_turns=self.same_topic_turns,
all_stages_coverage=self.all_stages_coverage,
detected_user_stage=self.detected_user_stage,
user_profile_context=self.user_profile_context,
persona=self.persona,
memory_evidence_text=self.memory_evidence_text,
reply_length_mode=self.reply_length_mode,
background_voice=self.background_voice,
occupation=self.occupation,
)

View File

@@ -1,22 +1,16 @@
"""
对话 Agent 提示词模板
对话 Agent 提示词模板(精简:事实块 + 行为指引,由模型自行判断追问/长度/闲聊)。
"""
from typing import Dict, List, Optional
from app.agents.chat.background_voice import (
get_background_voice_chat_block,
get_background_voice_tone_hint,
normalize_background_voice,
)
from app.agents.chat.occupation_context import get_occupation_chat_hint
from app.agents.chat.interview_reply_length import (
heuristic_likely_chit_chat,
heuristic_likely_emotional,
heuristic_likely_new_detail,
)
from app.agents.chat.personas import (
get_interview_persona_block,
get_opening_persona_line,
get_interview_persona_tone_hint,
normalize_interview_persona,
)
from app.agents.chat.output_rules import chat_output_rules
@@ -49,20 +43,55 @@ SLOT_NAME_MAP = {
"lesson": "人生经验",
}
STAGE_RELATED_TOPICS = {
"childhood": ["family", "education"],
"education": ["childhood", "career"],
"career": ["education", "family", "belief"],
"family": ["childhood", "career", "belief"],
"belief": ["career", "family"],
}
def _compact_era_hint(current_stage: str, user_profile_context: str) -> str:
"""单行时代联想,可选附在进度后。"""
if not user_profile_context:
return ""
def _guided_voice_intro_line(background_voice: str) -> str:
"""顶部角色描述(具体「接住」写法集中在 ## 你要做的)。"""
birth_year = None
birth_place = ""
for line in user_profile_context.split("\n"):
if "出生年份" in line:
try:
birth_year = int(line.split("")[1].strip().replace("", ""))
except (ValueError, IndexError):
pass
if "出生地" in line or "成长地" in line:
birth_place = line.split("")[1].strip() if "" in line else ""
if not birth_year:
return ""
age_range = STAGE_ERA_HINTS.get(current_stage, (0, 30))
era_start = birth_year + age_range[0]
era_end = birth_year + age_range[1]
era_events = []
decade_events = {
1950: "新中国成立初期、土地改革、抗美援朝",
1960: "大跃进、三年自然灾害、中苏关系变化",
1970: "文化大革命、知青上山下乡、中美建交",
1980: "改革开放、恢复高考、个体经济兴起、电视普及",
1990: "社会主义市场经济、下海潮、香港回归、互联网初期",
2000: "加入WTO、房地产兴起、手机普及、北京奥运",
2010: "移动互联网爆发、微信时代、共享经济、双创浪潮",
2020: "新冠疫情、直播经济、人工智能崛起",
}
for decade, events in decade_events.items():
if era_start <= decade + 9 and era_end >= decade:
era_events.append(f"{decade}年代:{events}")
if not era_events:
return ""
place_hint = f" {birth_place}" if birth_place else ""
return (
"你是「岁月知己」,像老朋友陪用户聊人生。"
"短句为主,遵守下方「本轮回复长度」档位。"
f"时代联想(口述里一两句带过即可):约 {era_start}-{era_end}{place_hint}"
f"可提及 {era_events[0]}"
+ (f"{era_events[1]}" if len(era_events) > 1 else "")
+ ""
)
@@ -82,7 +111,7 @@ def get_opening_prompt(
f"## 当前建议话题({stage_name}\n可以从中选一个来问:{topics_str}"
)
task_question = (
"2. **必须问一个问题**接着问一个**具体、好回答**的问题,引导用户开始分享;"
"2. 接着问一个**具体、好回答**的问题,引导用户开始分享;"
"优先落在上述还未聊透的方向上。不要问太宽泛的「有什么想聊的」。"
)
_opening_examples = {
@@ -132,44 +161,50 @@ def get_opening_prompt(
else:
topics_heading = (
f"## 当前阶段({stage_name}\n"
"访谈结构化槽位里,这一阶段的主要题在素材侧**已有覆盖**。"
"开场要像老朋友重逢:接近况、接续上次聊过的事、或任何用户可能提起的新片段;"
"**禁止**为了凑问题而默认再从「童年在哪长大」等已覆盖模板重头盘问。"
"这一阶段的主要题在素材侧**已有覆盖**。"
"开场要像老朋友重逢:接近况、接续上次聊过的事、或新片段;"
"**禁止**为了凑问题而从「童年在哪长大」等已覆盖模板重头盘问。"
)
task_question = (
"2. **问候 + 轻巧引子**用一句温暖的话接上对话;若自然,可以问一个与近况"
"想续上的回忆、或新冒出来的小事有关的问题。若不适合追问问候 + 一句开放式引子即可。"
"2. **问候 + 轻巧引子**温暖接话;若自然问一个与近况或回忆有关的问题,"
"不适合追问问候 + 开放式引子即可。"
)
style_examples = (
"示例(仅供参考风格):\n"
'"嘿,又见面啦~ 今天有没有哪件事突然从脑子里冒出来,想跟我说说?"\n\n'
'"在的!上次聊到那儿我还记着,你后来还有想起什么细节吗?"'
)
profile_section = (
f"\n## 用户基本信息\n{user_profile_context}\n" if user_profile_context else ""
)
profile_lines: List[str] = []
if user_profile_context.strip():
profile_lines.append(user_profile_context.strip())
occ = get_occupation_chat_hint(occupation, background_voice)
if occ:
profile_lines.append(occ)
profile_section = ""
if profile_lines:
profile_section = "## 用户信息\n" + "\n".join(profile_lines) + "\n"
persona_key = normalize_interview_persona(persona)
opening_persona = get_opening_persona_line(persona_key)
persona_extra = f"\n## 访谈性格\n{opening_persona}\n" if opening_persona else ""
voice_block = get_background_voice_chat_block(background_voice)
voice_section = f"\n{voice_block}\n" if voice_block else ""
occ_hint = get_occupation_chat_hint(occupation, background_voice)
occ_section = f"\n{occ_hint}\n" if occ_hint else ""
persona_tone = get_interview_persona_tone_hint(persona_key)
voice_tone = get_background_voice_tone_hint(background_voice)
tone_bits = [t for t in (persona_tone, voice_tone) if t]
tone_paragraph = ""
if tone_bits:
tone_paragraph = " " + " ".join(tone_bits) + "\n\n"
bv = normalize_background_voice(background_voice)
if bv == "default":
opening_head = (
"你是「岁月知己」。用户刚进对话,**还没说话**,请你先开口。"
"**短、像微信**,一两句问候 + 一个具体问题即可,不要排比、不要文学描写。\n\n"
)
if bv != "default":
opening_head = (
"你是「岁月知己」。用户刚进对话,**还没说话**,请你先开口。"
"**短、像微信**,一两句问候 + 一具体问题即可,不要排比、不要文学描写。"
"**短**;两三句内问候 + 一具体问题不要排比、不要文学描写。\n\n"
)
else:
opening_head = (
"你是「岁月知己」。用户刚进对话,**还没说话**,请你先开口。"
"**短;两三句内完成问候 + 一个具体问题**;不要排比、不要文学描写。"
)
return f"""{opening_head}
{profile_section}
{topics_heading}
{persona_extra}{voice_section}{occ_section}
return f"""{opening_head}{tone_paragraph}{profile_section}{topics_heading}
## 任务
1. 简短问候。
{task_question}
@@ -184,102 +219,26 @@ def get_opening_prompt(
直接输出(仅自然口语,无 Markdown"""
def _build_era_context(current_stage: str, user_profile_context: str) -> str:
"""根据用户的人生阶段和出生年份,生成对应时代的历史/政治/文化背景提示"""
if not user_profile_context:
return ""
birth_year = None
birth_place = ""
for line in user_profile_context.split("\n"):
if "出生年份" in line:
try:
birth_year = int(line.split("")[1].strip().replace("", ""))
except (ValueError, IndexError):
pass
if "出生地" in line or "成长地" in line:
birth_place = line.split("")[1].strip() if "" in line else ""
if not birth_year:
return ""
age_range = STAGE_ERA_HINTS.get(current_stage, (0, 30))
era_start = birth_year + age_range[0]
era_end = birth_year + age_range[1]
era_events = []
decade_events = {
1950: "新中国成立初期、土地改革、抗美援朝",
1960: "大跃进、三年自然灾害、中苏关系变化",
1970: "文化大革命、知青上山下乡、中美建交",
1980: "改革开放、恢复高考、个体经济兴起、电视普及",
1990: "社会主义市场经济、下海潮、香港回归、互联网初期",
2000: "加入WTO、房地产兴起、手机普及、北京奥运",
2010: "移动互联网爆发、微信时代、共享经济、双创浪潮",
2020: "新冠疫情、直播经济、人工智能崛起",
}
for decade, events in decade_events.items():
if era_start <= decade + 9 and era_end >= decade:
era_events.append(f"{decade}年代:{events}")
if not era_events:
return ""
place_hint = f" {birth_place}" if birth_place else ""
return (
f"\n## 时代参考(一两句带过即可,勿长篇)\n"
f"{era_start}-{era_end}{place_hint};可联想:{era_events[0]}"
+ (f"{era_events[1]}" if len(era_events) > 1 else "")
+ "\n"
)
def _format_reply_length_section(current_mode: str) -> str:
"""仅输出当前档位说明,减少重复 tokens。"""
safe = (
current_mode
if current_mode in ("brief", "standard", "expanded")
else "standard"
)
mode_desc = {
"brief": "一两句话,简短温暖;可带一个小问题也可以不带。",
"standard": "承接对方 + 最多一个具体问题;像朋友聊天,不写长段。",
"expanded": "用户本轮内容或情绪较浓——可多一两句承接核心点,再自然追问;仍控制在两段以内。",
}
desc = mode_desc[safe]
return f"""## 本轮回复长度
**当前档位:{safe}**
{desc}
"""
def get_guided_conversation_prompt(
current_stage: str,
empty_slots: List[str],
filled_slots: Dict[str, str],
user_message: str,
conversation_turn_total: int = 0,
same_topic_turns: int = 0,
all_stages_coverage: Optional[Dict[str, Dict]] = None,
detected_user_stage: str = "",
user_profile_context: str = "",
persona: str = "default",
memory_evidence_text: str = "",
reply_length_mode: str = "standard",
background_voice: str = "default",
occupation: str = "",
) -> str:
"""生成状态感知的对话提示词
``user_message`` 仅用于启发式(新细节/闲聊/情绪),其原文**不会**写入本提示,用户话仅以最终 HumanMessage 传入模型。
``conversation_turn_total`` 为 Redis 全量历史的轮次数,不受窗口截断影响。
"""
"""生成状态感知的对话提示词;用户原话仅以 HumanMessage 传入,不写入本 system 文本。"""
persona_key = normalize_interview_persona(persona)
persona_block = get_interview_persona_block(persona_key)
likely_new = heuristic_likely_new_detail(user_message)
likely_chit = heuristic_likely_chit_chat(user_message)
reply_length_section = _format_reply_length_section(reply_length_mode)
persona_tone = get_interview_persona_tone_hint(persona_key)
voice_tone = get_background_voice_tone_hint(background_voice)
tone_bits = [t for t in (persona_tone, voice_tone) if t]
tone_line = ""
if tone_bits:
tone_line = " " + " ".join(tone_bits)
current_stage_name = STAGE_DISPLAY_ZH.get(current_stage, current_stage)
user_stage_name = (
@@ -305,7 +264,6 @@ def get_guided_conversation_prompt(
filled_slots_str = "\n".join(filled_info) if filled_info else "刚开始聊"
progress_lines: List[str] = []
uncovered_stages: List[str] = []
if all_stages_coverage:
cur_cn = STAGE_DISPLAY_ZH.get(current_stage, current_stage)
progress_lines.append(f"当前阶段:{cur_cn}")
@@ -318,55 +276,34 @@ def get_guided_conversation_prompt(
continue
if filled_n == 0:
progress_lines.append(f" {sname}:未聊")
uncovered_stages.append(sname)
elif filled_n < total_n:
progress_lines.append(f" {sname}{filled_n}/{total_n}")
progress_str = "\n".join(progress_lines) if progress_lines else ""
filled_count = len(filled_slots)
should_switch_topic = same_topic_turns >= 5 or (
filled_count >= 3 and same_topic_turns >= 4
active_stage = (
detected_user_stage if user_jumped and detected_user_stage else current_stage
)
should_lighten_mood = (
conversation_turn_total > 0 and conversation_turn_total % 7 == 0
)
should_try_new_stage = filled_count >= 4 and len(empty_slots) <= 1
era_line = ""
if settings.chat_era_context_enabled:
era_line = _compact_era_hint(active_stage, user_profile_context)
related_stages = STAGE_RELATED_TOPICS.get(current_stage, [])
related_stages_str = "".join([STAGE_DISPLAY_ZH.get(s, s) for s in related_stages])
emotional = heuristic_likely_emotional(user_message)
tone_section = f"{persona_block}\n" if persona_block else ""
followup_trigger_block = "## 本轮追问判定\n"
followup_trigger_block += (
"总体原则见「对话方向」与「你要做的」;以下为仅本轮生效的判定:\n"
)
if likely_new:
followup_trigger_block += (
"**【本轮判定】用户补充了新细节 → 承接后须追问 1 句。**\n"
)
elif emotional:
followup_trigger_block += (
"**【本轮判定】用户情绪较浓 → 先好好共情承接,不必急着追问。**\n"
if user_jumped:
topic_desc = (
f"你们原本在聊「{current_stage_name}」,"
f"用户自然地聊到了「{user_stage_name}」——跟着他/她的节奏,别硬拉回。"
)
else:
followup_trigger_block += (
"(无特殊判定时按惯例:新线头追问一句,否则可只承接。)\n"
)
topic_desc = f"你们在聊「{current_stage_name}」这阶段的话题。"
memoir_orientation_lines = [
"## 对话方向",
"追问与承接**优先服务于人生故事与回忆录素材**,但不要让对方觉得你在走流程。",
"若用户**明显在闲聊**,以陪聊为主,**不要**用回忆录式问题打断。",
"若用户一边回忆一边开玩笑,先接情绪,再轻轻带回一个与经历相关的小问题。",
]
if likely_chit:
memoir_orientation_lines.append(
"**【本轮偏闲聊】** → 以承接与陪聊为主;若用户自然带回经历,再追问。"
)
memoir_orientation_block = "\n".join(memoir_orientation_lines) + "\n"
user_info_parts: List[str] = []
if user_profile_context.strip():
user_info_parts.append(user_profile_context.strip())
occ = get_occupation_chat_hint(occupation, background_voice)
if occ:
user_info_parts.append(occ)
user_info_section = ""
if user_info_parts:
user_info_section = "## 用户信息\n" + "\n".join(user_info_parts) + "\n\n"
memory_section = ""
mem_trim = (memory_evidence_text or "").strip()
@@ -374,87 +311,40 @@ def get_guided_conversation_prompt(
memory_section = (
"## 相关记忆摘录(仅供衔接,禁止编造)\n"
"以下为系统从用户**过往口述**中检索到的摘录,**不是**用户本轮亲口新说的内容。\n"
"承接时可自然用「你之前提过……」「上次你说到……」等口语,不要把摘录里的细节写成本轮用户新告诉你的事实;禁止编造摘录未出现的内容。\n\n"
"承接时可自然用「你之前提过……」等口语,不要把摘录里的细节写成本轮用户新说的;"
"禁止编造摘录未出现的内容。\n\n"
f"{mem_trim}\n\n"
)
dynamic_guidance = ""
if user_jumped:
dynamic_guidance += f"""
- **用户正在聊「{user_stage_name}」的话题,跟着他/她的节奏走,不要试图拉回「{current_stage_name}」**
- 顺着用户的思路,帮他/她把这个话题聊深聊透
- 这是很自然的事情,人回忆往事经常会跳跃,你要做的是陪伴和倾听"""
else:
if should_lighten_mood:
dynamic_guidance += "\n- 聊了一会儿了,可以适当轻松一下,聊点有趣的"
if should_switch_topic and empty_slots_readable:
if likely_new:
dynamic_guidance += f"\n- 若用户本轮**刚补充**新细节,请先就这一点追问一句,再自然转到未聊方向:{empty_slots_str}"
else:
dynamic_guidance += (
f"\n- 这个话题聊得差不多了,可以自然转到:{empty_slots_str}"
)
if should_try_new_stage and related_stages:
dynamic_guidance += (
f"\n- 如果自然的话,可以尝试聊聊相关的话题,比如{related_stages_str}"
)
progress_block = f"## 进度\n{progress_str}\n" if progress_str else ""
era_block = f"{era_line}\n" if era_line else ""
uncovered_hint = ""
if not user_jumped and uncovered_stages and should_try_new_stage:
uncovered_hint = f"\n- 还没聊到的人生阶段有:{''.join(uncovered_stages)},如果聊天中有自然的契机,可以轻轻带一句,但不要刻意"
return f"""你是「岁月知己」,像老朋友陪用户聊人生。短句为主,像微信聊天。{tone_line}
if user_jumped:
topic_desc = f"你们原本在聊「{current_stage_name}」,但用户自然地聊到了「{user_stage_name}」的内容"
else:
topic_desc = f"你们聊到了「{current_stage_name}」这个话题"
profile_section = ""
if user_profile_context:
profile_section = f"\n## 用户基本信息\n{user_profile_context}\n"
active_stage = (
detected_user_stage if user_jumped and detected_user_stage else current_stage
)
era_context = (
_build_era_context(active_stage, user_profile_context)
if settings.chat_era_context_enabled
else ""
)
voice_block = get_background_voice_chat_block(background_voice)
voice_section = f"\n{voice_block}\n" if voice_block else ""
occ_hint = get_occupation_chat_hint(occupation, background_voice)
occ_section = f"\n{occ_hint}\n" if occ_hint else ""
intro_line = _guided_voice_intro_line(background_voice)
prompt = f"""{intro_line}
{topic_desc}
{reply_length_section}
{profile_section}
{voice_section}{occ_section}
## 本阶段已聊
{user_info_section}## 当前对话状态
已聊:
{filled_slots_str}
## 还可聊的方向
{empty_slots_str}
还可聊的方向{empty_slots_str}
## 进度
{progress_str}
{era_context}
{memoir_orientation_block}{memory_section}{followup_trigger_block}
{tone_section}
## 你要做的
1. **先接住对方**——一句真诚回应,不要写成总结或讲评。
2. 用户跳到别的人生阶段,跟着聊,别硬拉回。
3. **最多追问一个**具体、好答的问题(参照上方「本轮追问判定」);无需追问时,只承接就好。
4. 用户回「嗯」「对」之类,结合上文理解,承接或换个新角度,不要重复上一轮问过的事。
5. 可用 [SPLIT] 分成**最多 2 条**消息。
{dynamic_guidance}{uncovered_hint}
{progress_block}{era_block}{memory_section}## 你要做的
- **先接住对方**——一句真诚回应,不要写成总结或讲评。
- 你自己判断该追问还是只承接:有新线头就顺着问一个具体的事;情绪浓就好好接住、不必急着追问;明显闲聊就陪聊;用户只说「嗯」「对」则结合上文承接或换个角度。
- 可以用「我能想象……」「那时候大概……」轻轻接话,但不可编造具体人名、时间、事件等你不知道的细节。
- 不要重复上一轮问过的事;用户跳到别的人生阶段,跟着聊,别硬拉回。
- 追问与承接服务于人生故事素材,但不要让对方觉得在走审问式流程;**最多**抛一个具体问题,也可以不追问。
- 可用 [SPLIT] 分成**最多 2 条**消息。
## 不要做的
{chat_output_rules()}
直接输出(仅自然口语,无 Markdown无任何括号前缀或旁白"""
return prompt
__all__ = [
"SLOT_NAME_MAP",
"get_guided_conversation_prompt",
"get_opening_prompt",
]

View File

@@ -88,13 +88,13 @@ def get_profile_extraction_prompt(
1. birth_year 填整数(四位数),如"65年出生"转为 1965
2. 如果用户在任一轮说过出生地/成长地/职业等,都要提取
3. 只提取明确提到的信息,不要猜测
4. 如果没有提取到任何信息,返回空对象 {{}}"""
4. 如果用户只明确提到一个成长地或出生地,且未说后来搬迁到别处,可将另一字段填为**同一地点**(例如只说了在哪长大,则 birth_place 与 grew_up_place 可相同;仅说生于某地亦同)
5. 如果没有提取到任何信息,返回空对象 {{}}"""
def get_profile_followup_prompt(
missing_fields: List[str],
filled_fields: Dict[str, str],
user_message: str,
nickname: str = "",
interview_stage_hint: str = "",
) -> str:
@@ -119,9 +119,7 @@ def get_profile_followup_prompt(
return f"""你是「岁月知己」。用户的基本信息已经收集完毕:
{filled_str}
用户刚才说:"{user_message}"
请对用户的回答做出温暖的回应,然后自然地过渡到人生故事的访谈。
用户本轮消息在对话末尾。请对用户的回答做出温暖的回应,然后自然地过渡到人生故事的访谈。
可以说类似「了解了!那我们现在开始聊聊你的人生故事吧」这样的话;{stage_hint}
**不要**默认只问童年,除非用户刚才聊的正是童年。
@@ -136,13 +134,17 @@ def get_profile_followup_prompt(
## 还需要了解
{missing_str}
用户刚才说:"{user_message}"
用户本轮原话在历史里(末尾 HumanMessage勿在脑中丢开。
请先对用户说的内容做出自然回应,然后**只**询问「还需要了解」中的信息(每次问 1-2 个)。
语气要像朋友聊天一样自然亲切
## 你怎么说
1. **先接住**:对用户说的内容做自然回应,像朋友在听
2. **话题优先**:若用户正在讲一段故事、回忆或情绪,**优先**顺着问一个与**当前话题**相关的具体小问题;不要为凑字段打断叙事。
3. **资料穿插**:仅当用户本轮主要在确认、闲聊或话题与缺失资料完全无关时,再在末尾**温和插入 01 个**「还需要了解」里的问题。
4. **轮换**:若上一轮你已就某一类资料追问过(见历史里助手发言),本轮**不要再问同一类**;改问其他缺失项,或本轮只承接、不提资料。
5. 每次最多 **12 个**资料相关问点;能用推断就不要重复确认已知地/年。
严格禁止:
- **严禁再次询问「已知信息」中已列出的内容**(例如已知出生年份就绝不要再问哪年出生)
- **严禁再次询问「已知信息」中已列出的内容**
- {chat_output_rules()}
回复格式:多条消息用 [SPLIT] 分隔。

View File

@@ -59,20 +59,15 @@ async def detect_primary_life_stage(
user_message: str,
current_stage: str,
llm: Any,
*,
skip_llm: bool = False,
) -> str:
"""
返回合法的人生阶段 key失败时回退为 current_stage。
skip_llm=True 时仅用关键词(短时/元话语等路径,不调阶段 LLM)。
每轮在启用时调用阶段检测 LLM短句亦由模型判断不用关键词替代)。
"""
fb = normalize_chat_stage(current_stage, "childhood")
if not settings.chat_stage_detection_enabled:
return _keyword_fallback_stage(user_message, fb)
if skip_llm and settings.chat_stage_detection_skip_llm_on_insufficient_signal:
return _keyword_fallback_stage(user_message, fb)
if not llm:
return _keyword_fallback_stage(user_message, fb)

View File

@@ -1,73 +0,0 @@
"""
启发式判断访谈「本轮」是否值得跑阶段 LLM / 记忆检索等高成本步骤。
短答、应答词、元话语(谈整理回忆本身而非人生经历)为 False长文本或中等长度非常用词为 True。
与配置 `chat_substantive_*` 配合;关闭启发式时恒为 True。
"""
from __future__ import annotations
import re
from typing import Final
from app.core.config import settings
# 极短应答(整句精确匹配)
_SHORT_ACK_EXACT: Final[frozenset[str]] = frozenset(
{
"",
"",
"",
"",
"行的",
"是的",
"没有",
"",
"",
"",
"好吧",
"嗯嗯",
"对对",
"好嘞",
"对的",
"没了",
"可以",
"就这样",
"还行",
"还好",
}
)
# 元话语:谈回忆过程/访谈本身,不足以切换人生阶段或拉记忆证据
_META_PROCESS: Final[re.Pattern[str]] = re.compile(
r"(回忆|想起).{0,20}(细节|收获|快忘|忘的|很多东西)"
r"|(整理|聊聊|谈到).{0,8}(回忆|访谈|记录)"
r"|最大的收获",
re.UNICODE,
)
def should_run_chat_stage_memory_heavy_work(text: str) -> bool:
"""
True值得调用阶段检测 LLM、记忆检索向量等
False仅用关键词阶段回退、跳过记忆检索。
"""
if not settings.chat_substantive_heuristic_enabled:
return True
s = (text or "").strip()
if not s:
return False
# 元话语可略长,须在「达到 min_chars」分支之前判断
if _META_PROCESS.search(s):
return False
min_chars = int(settings.chat_substantive_min_chars)
if len(s) >= min_chars:
return True
if s in _SHORT_ACK_EXACT:
return False
if len(s) <= 4:
# 极短:多为语气/应答
if all(ch in "嗯哦噢对对好好的没行是的不没一下的了呗嘛呀啊" for ch in s):
return False
# 偏短但未命中噪音规则默认走完整路径5 字常见为有信息短句(旧逻辑用 >=6 会误杀)
return len(s) >= 5