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:
@@ -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 prompt;default 返回空串。"""
|
||||
v = normalize_background_voice(voice)
|
||||
if v == "default":
|
||||
return ""
|
||||
if v == "military":
|
||||
return (
|
||||
"## 背景语气:军队语境(仅语气,不编造事实)\n"
|
||||
"称呼得体、句子简洁利落、条理清楚;避免网络梗与油滑套话。\n"
|
||||
"先简短接住对方,再**最多一个**具体问题;不写命令式、不做思想政治表态。\n"
|
||||
"涉及纪律、集体、任务等措辞,**仅当用户口述已出现相关事实时**自然呼应,禁止堆砌军事化辞藻或虚构经历。\n"
|
||||
"用户已退役/转业,以回忆军旅岁月为基调,不要预设其仍在服役。"
|
||||
"语气简洁利落、得体;称呼自然;不写命令式、不堆砌军事辞藻;"
|
||||
"仅当用户口述已出现相关事实时才呼应军旅语境,不编造经历。"
|
||||
)
|
||||
# cadre
|
||||
return (
|
||||
"## 背景语气:干部/机关语境(仅语气,不编造事实)\n"
|
||||
"稳重、有分寸,敬语适度;句子可略完整,但仍控制总字数,避免官样文章与排比空话。\n"
|
||||
"先回应对方内容,再**最多一个**具体问题;不写公文套话、不做政治评价。\n"
|
||||
"涉及职务与组织时,**不得编造**用户未提及的职级、单位与荣誉。\n"
|
||||
"用户已退休,以回顾和怀念工作岁月为基调,不要预设其仍在岗。"
|
||||
"语气稳重有分寸、敬语适度;避免官样排比与公文套话;"
|
||||
"不得编造用户未提及的职级、单位与荣誉。"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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="你好呀~ 又见面了,最近有没有什么事想跟我说说?",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -8,7 +8,9 @@ def chat_output_rules() -> str:
|
||||
"反引号代码、`[]()` 链接、列表符号或渲染用符号;只输出连贯口语,**可以**在需要分两气泡时使用字面量 "
|
||||
"`[SPLIT]`(仅此一处方括号用法);**禁止**输出括号、括号内的策略/舞台说明(例如「(先接住情绪)」「(共情)」)、"
|
||||
"思考过程或任何元注释——这些只存在于系统指令里,**绝不可**出现在你对用户说的话中;"
|
||||
"采访腔(「我注意到」「我想了解」);重复确认对方已经说过或能推断出的信息;编造对方没说的细节。"
|
||||
"采访腔(「我注意到」「我想了解」);重复确认对方已经说过或能推断出的信息;"
|
||||
"编造对方没说的**具体**事实(人名、时间、地点、事件经过等若用户未提及则不说)。"
|
||||
"**允许**用「我能想象……」「那时候大概……」等泛泛接话,但不要把这些写成就等于用户亲身经历的事实。"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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 prompt;default 返回空串。"""
|
||||
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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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. **资料穿插**:仅当用户本轮主要在确认、闲聊或话题与缺失资料完全无关时,再在末尾**温和插入 0~1 个**「还需要了解」里的问题。
|
||||
4. **轮换**:若上一轮你已就某一类资料追问过(见历史里助手发言),本轮**不要再问同一类**;改问其他缺失项,或本轮只承接、不提资料。
|
||||
5. 每次最多 **1~2 个**资料相关问点;能用推断就不要重复确认已知地/年。
|
||||
|
||||
严格禁止:
|
||||
- **严禁再次询问「已知信息」中已列出的内容**(例如已知出生年份就绝不要再问哪年出生)
|
||||
- **严禁再次询问「已知信息」中已列出的内容**
|
||||
- {chat_output_rules()}
|
||||
|
||||
回复格式:多条消息用 [SPLIT] 分隔。
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user