refactor(agents): 抽取阶段常量与对话上下文;快档 LLM;图片 prompt 可禁止回退

访谈与阶段
- 新增 app/agents/stage_constants.py:集中 CHAT_STAGES、章节分类/顺序、阶段到默认 memoir 类别等,与 MemoirState 默认槽位顺序对齐;减少散落在 prompts 内的重复常量。
- 新增 app/agents/chat/prompt_context.py:以 ChatPromptContext 汇总 guided 系统提示所需字段(阶段、槽位、轮次、人设、记忆证据、回复长度模式、背景声线、职业等),统一走 get_guided_conversation_prompt。
- 大幅收敛 app/agents/chat/prompts_conversation.py;调整 prompts.py、stage_prompts.py、stage_detection.py;同步 interview_agent、profile_agent、helpers 与 state_schema,使对话侧构造提示的方式一致、可测。

回忆录流水线
- memoir/prompts.py 删除已迁至 stage_constants / 独立模板的大段常量与图片占位相关逻辑;classification / extraction / fidelity / narrative agents 与 orchest(全量历史仍可用于计数,注入模型时按轮次与字符上限截断)、image_prompt_fallback_disabled。
- dependencies 增加 get_llm_provider_fast(LRU 缓存,可与默认共用密钥与 base_url)。

任务与编排
- memoir_tasks:prepare_batches 注入 llm_fast;开启独立快档模型时打结构化日志。
- chapter_cover_tasks、story_image_tasks:与图片 prompt / JSON 工具路径或策略变更对齐(import 与行为一致)。
- story_pipeline_sync 等小处同步。

其它核心
- langchain_llm、text_normalize 随上述调用链微调。

开发者体验
- .cursor/settings.json:启用 redis-development、postman 插件。

测试
- 新增 test_image_prompt_policy:覆盖「禁止回退」等图片 prompt 策略。
- 更新 test_interview_prompts、test_interview_reply_length、test_experience_regressions、test_json_and_memory_utils,匹配新常量位置、json_utils 与对话/长度行为。
This commit is contained in:
Kevin
2026-04-02 12:00:00 +08:00
parent 43ef260ae2
commit bb16d3a5c9
42 changed files with 894 additions and 580 deletions

View File

@@ -1,33 +1,86 @@
"""聊天 Agent 共享工具:历史获取、格式化、存储"""
from dataclasses import dataclass
from datetime import datetime
from typing import Any, List
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from app.core.redis import redis_service
def _human_ai_rows(history: list[dict]) -> list[dict]:
"""与 get_history_messages 一致:仅保留 human/ai顺序与 Redis 列表一致。"""
return [m for m in history if m.get("role") in ("human", "ai")]
def _lc_messages_from_rows(rows: list[dict]) -> list[HumanMessage | AIMessage]:
out: list[HumanMessage | AIMessage] = []
for msg in rows:
role = msg.get("role")
if role == "human":
out.append(HumanMessage(content=msg["content"]))
elif role == "ai":
out.append(AIMessage(content=msg["content"]))
return out
@dataclass(frozen=True)
class HistoryWithWindow:
"""单次 Redis 读取后的全量轮次计数 + 截断后注入 LLM 的消息列表。"""
turn_total: int
window: list[HumanMessage | AIMessage]
async def get_history_with_window(
conversation_id: str,
*,
max_pairs: int,
max_chars: int,
) -> HistoryWithWindow:
"""一次读取 Redisturn_total 由全量 human/ai 条数得到;仅对窗口切片构造 LangChain 消息。"""
history = await redis_service.get_conversation_history(conversation_id)
human_ai = _human_ai_rows(history)
turn_total = len(human_ai) // 2
window_raw = human_ai[-(max_pairs * 2) :] if max_pairs > 0 else human_ai[:]
window = _lc_messages_from_rows(window_raw)
total_chars = 0
start = len(window)
for i in range(len(window) - 1, -1, -1):
msg = window[i]
content = getattr(msg, "content", "") or ""
total_chars += len(content)
if total_chars > max_chars:
start = i + 1
break
else:
start = 0
if start < len(window) and isinstance(window[start], AIMessage):
start += 1
trimmed = window[start:]
return HistoryWithWindow(turn_total=turn_total, window=trimmed)
async def get_history_messages(conversation_id: str) -> List[Any]:
"""从 Redis 获取对话历史"""
history = await redis_service.get_conversation_history(conversation_id)
messages = []
for msg in history:
if msg["role"] == "human":
messages.append(HumanMessage(content=msg["content"]))
elif msg["role"] == "ai":
messages.append(AIMessage(content=msg["content"]))
return messages
return _lc_messages_from_rows(_human_ai_rows(history))
def format_history_string(messages: List[Any]) -> str:
"""消息列表格式化为 Human/Assistant 字符串"""
history_parts = []
""" LangChain 消息列表格式化为调试日志用多段文本(含 System不静默跳过"""
history_parts: list[str] = []
for msg in messages:
if isinstance(msg, HumanMessage):
if isinstance(msg, SystemMessage):
history_parts.append(f"System: {msg.content}")
elif isinstance(msg, HumanMessage):
history_parts.append(f"Human: {msg.content}")
elif isinstance(msg, AIMessage):
history_parts.append(f"Assistant: {msg.content}")
else:
content = getattr(msg, "content", None)
history_parts.append(f"{type(msg).__name__}: {content}")
return "\n\n".join(history_parts)

View File

@@ -3,19 +3,19 @@ InterviewAgent正式访谈 Specialist
负责状态感知回复、开场白,不负责 Redis 持久化(由 Orchestrator 统一处理)
"""
import time
from typing import Any, List, Optional
from app.agents.chat.agent_turn import AgentChatTurn
from app.agents.chat.stage_detection import keyword_fallback_primary_stage
from app.core.dependencies import get_llm_provider
from app.core.logging import get_logger
from langchain_core.messages import HumanMessage, SystemMessage
from app.agents.chat.helpers import format_history_string, get_history_messages
from app.agents.chat.agent_turn import AgentChatTurn
from app.agents.chat.helpers import format_history_string, get_history_with_window
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_guided_conversation_prompt,
get_opening_prompt,
)
from app.agents.state_schema import MemoirStateSchema
@@ -30,6 +30,8 @@ from app.core.agent_logging import (
log_agent_summary,
)
from app.core.config import settings
from app.core.dependencies import get_llm_provider
from app.core.logging import get_logger
from app.features.conversation.input_normalize import normalize_chat_input_for_agent
logger = get_logger(__name__)
@@ -46,6 +48,15 @@ def _get_langchain_llm():
return None
def _message_contents_char_count(messages: List[Any]) -> int:
n = 0
for m in messages:
c = getattr(m, "content", None)
if isinstance(c, str):
n += len(c)
return n
class InterviewAgent:
"""正式访谈 Specialist Agent"""
@@ -120,11 +131,13 @@ class InterviewAgent:
du = detected_user_stage
else:
du = self._detect_user_stage(text_for_model)
history_messages = await get_history_messages(conversation_id)
conversation_turn = len(history_messages) // 2
same_topic_turns = self._estimate_same_topic_turns(
history_messages, filled_slots
hw = await get_history_with_window(
conversation_id,
max_pairs=settings.chat_history_max_pairs,
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(
@@ -132,12 +145,12 @@ class InterviewAgent:
background_voice=background_voice,
settings=settings,
)
system_prompt = get_guided_conversation_prompt(
ctx = ChatPromptContext(
current_stage=memoir_state.current_stage,
empty_slots=empty_slots,
filled_slots=filled_slots,
user_message=text_for_model,
conversation_turn=conversation_turn,
conversation_turn_total=conversation_turn_total,
same_topic_turns=same_topic_turns,
all_stages_coverage=all_stages_coverage,
detected_user_stage=du,
@@ -148,19 +161,46 @@ class InterviewAgent:
background_voice=background_voice,
occupation=occupation,
)
history_string = format_history_string(history_messages)
full_prompt = f"{system_prompt}\n\n{history_string}\n\nHuman: {text_for_model}\n\nAssistant:"
system_prompt = ctx.guided_system_prompt()
messages: List[Any] = [SystemMessage(content=system_prompt)]
messages.extend(hw.window)
messages.append(HumanMessage(content=text_for_model))
history_pairs_windowed = len(hw.window) // 2
window_chars = sum(len(getattr(m, "content", "") or "") for m in hw.window)
logger.info(
"event=history_window_applied total={} windowed={} chars={}",
conversation_turn_total,
history_pairs_windowed,
window_chars,
)
log_agent_payload(
logger, "InterviewAgent.generate_response.prompt", full_prompt
logger,
"InterviewAgent.generate_response.prompt",
format_history_string(messages),
)
chat_llm = self.llm.bind(max_tokens=reply_plan.max_tokens)
prompt_chars = _message_contents_char_count(messages)
llm_t0 = time.perf_counter()
with agent_span(
logger,
"InterviewAgent.generate_response.llm",
conversation_id=conversation_id,
stage=memoir_state.current_stage,
):
response = await chat_llm.ainvoke(full_prompt)
logger.info(
"event=chat_prompt_built agent=InterviewAgent.generate_response_with_state "
"prompt_chars={} history_pairs_total={} history_pairs_windowed={}",
prompt_chars,
conversation_turn_total,
history_pairs_windowed,
)
response = await chat_llm.ainvoke(messages)
response_ms = (time.perf_counter() - llm_t0) * 1000
logger.info(
"event=chat_llm_done agent=InterviewAgent.generate_response_with_state "
"response_latency_ms={:.2f}",
response_ms,
)
response_text = (
response.content if hasattr(response, "content") else str(response)
)
@@ -218,15 +258,47 @@ class InterviewAgent:
background_voice=background_voice,
occupation=occupation,
)
full_prompt = f"{prompt}\n\nAssistant:"
log_agent_payload(logger, "InterviewAgent.opening.prompt", full_prompt)
hw = await get_history_with_window(
conversation_id,
max_pairs=settings.chat_history_max_pairs,
max_chars=settings.chat_history_max_chars,
)
messages: List[Any] = [SystemMessage(content=prompt)]
messages.extend(hw.window)
if not hw.window:
messages.append(
HumanMessage(content="(对话刚开始,请自然地说出你的开场白。)")
)
else:
messages.append(
HumanMessage(content="(请根据上文,自然接续并说出你的开场白。)")
)
log_agent_payload(
logger,
"InterviewAgent.opening.prompt",
format_history_string(messages),
)
opening_llm = self.llm.bind(max_tokens=settings.chat_opening_max_tokens)
prompt_chars = _message_contents_char_count(messages)
llm_t0 = time.perf_counter()
with agent_span(
logger,
"InterviewAgent.opening.llm",
conversation_id=conversation_id,
):
response = await opening_llm.ainvoke(full_prompt)
logger.info(
"event=chat_prompt_built agent=InterviewAgent.generate_opening_message "
"prompt_chars={} history_pairs_total={} history_pairs_windowed={}",
prompt_chars,
hw.turn_total,
len(hw.window) // 2,
)
response = await opening_llm.ainvoke(messages)
logger.info(
"event=chat_llm_done agent=InterviewAgent.generate_opening_message "
"response_latency_ms={:.2f}",
(time.perf_counter() - llm_t0) * 1000,
)
response_text = (
response.content if hasattr(response, "content") else str(response)
)

View File

@@ -4,27 +4,28 @@ ProfileAgent用户资料收集 Specialist
"""
import json
import time
from typing import Any, Dict, List, Optional
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from app.agents.chat.helpers import format_history_string, get_history_messages
from app.agents.chat.helpers import format_history_string, get_history_with_window
from app.agents.chat.prompts_profile import (
get_profile_extraction_prompt,
get_profile_followup_prompt,
get_profile_greeting_prompt,
)
from app.core.dependencies import get_llm_provider
from app.core.langchain_llm import ainvoke_json_object
from app.core.agent_logging import agent_span, log_agent_payload, log_agent_summary
from app.core.config import settings
from app.core.dependencies import get_llm_provider
from app.core.json_utils import extract_json_payload
from app.core.langchain_llm import ainvoke_json_object
from app.core.logging import get_logger
from app.agents.chat.reply_limits import (
nonempty_segments_or_fallback,
segments_from_llm_response,
truncate_chat_segments,
)
from app.features.memoir.memoir_images.json_payload import extract_json_payload
logger = get_logger(__name__)
@@ -37,6 +38,15 @@ def _get_langchain_llm():
return None
def _message_contents_char_count(messages: List[Any]) -> int:
n = 0
for m in messages:
c = getattr(m, "content", None)
if isinstance(c, str):
n += len(c)
return n
class ProfileAgent:
"""用户资料收集 Specialist Agent"""
@@ -54,10 +64,12 @@ class ProfileAgent:
return {}
recent_dialogue = ""
if conversation_id:
history_messages = await get_history_messages(conversation_id)
recent = (
history_messages[-4:] if len(history_messages) > 4 else history_messages
hw = await get_history_with_window(
conversation_id,
max_pairs=settings.chat_history_max_pairs,
max_chars=settings.chat_history_max_chars,
)
recent = hw.window[-4:] if len(hw.window) > 4 else hw.window
parts = []
for msg in recent:
if isinstance(msg, HumanMessage):
@@ -118,21 +130,41 @@ class ProfileAgent:
nickname,
interview_stage_hint=interview_stage_hint,
)
history_messages = await get_history_messages(conversation_id)
history_string = format_history_string(history_messages)
full_prompt = (
f"{prompt}\n\n{history_string}\n\nHuman: {user_message}\n\nAssistant:"
hw = await get_history_with_window(
conversation_id,
max_pairs=settings.chat_history_max_pairs,
max_chars=settings.chat_history_max_chars,
)
messages: List[Any] = [SystemMessage(content=prompt)]
messages.extend(hw.window)
messages.append(HumanMessage(content=user_message))
log_agent_payload(
logger,
"ProfileAgent.followup.prompt",
format_history_string(messages),
)
log_agent_payload(logger, "ProfileAgent.followup.prompt", full_prompt)
chat_llm = self.llm.bind(
max_tokens=settings.chat_profile_followup_max_tokens
)
llm_t0 = time.perf_counter()
with agent_span(
logger,
"ProfileAgent.followup.llm",
conversation_id=conversation_id,
):
response = await chat_llm.ainvoke(full_prompt)
logger.info(
"event=chat_prompt_built agent=ProfileAgent.generate_profile_followup "
"prompt_chars={} history_pairs_total={} history_pairs_windowed={}",
_message_contents_char_count(messages),
hw.turn_total,
len(hw.window) // 2,
)
response = await chat_llm.ainvoke(messages)
logger.info(
"event=chat_llm_done agent=ProfileAgent.generate_profile_followup "
"response_latency_ms={:.2f}",
(time.perf_counter() - llm_t0) * 1000,
)
response_text = (
response.content if hasattr(response, "content") else str(response)
)
@@ -181,19 +213,44 @@ class ProfileAgent:
return ["你好!在开始之前,能告诉我你是哪一年出生的吗?"]
try:
prompt = get_profile_greeting_prompt(missing_fields, nickname)
history_messages = await get_history_messages(conversation_id)
history_string = format_history_string(history_messages)
full_prompt = f"{prompt}\n\n{history_string}" if history_string else prompt
log_agent_payload(logger, "ProfileAgent.greeting.prompt", full_prompt)
hw = await get_history_with_window(
conversation_id,
max_pairs=settings.chat_history_max_pairs,
max_chars=settings.chat_history_max_chars,
)
messages: List[Any] = [SystemMessage(content=prompt)]
messages.extend(hw.window)
if hw.window:
messages.append(
HumanMessage(content="(请根据上文自然接话,继续资料收集开场。)")
)
else:
messages.append(HumanMessage(content="(请说出资料收集开场白。)"))
log_agent_payload(
logger, "ProfileAgent.greeting.prompt", format_history_string(messages)
)
chat_llm = self.llm.bind(
max_tokens=settings.chat_profile_followup_max_tokens
)
llm_t0 = time.perf_counter()
with agent_span(
logger,
"ProfileAgent.greeting.llm",
conversation_id=conversation_id,
):
response = await chat_llm.ainvoke(full_prompt)
logger.info(
"event=chat_prompt_built agent=ProfileAgent.generate_profile_greeting "
"prompt_chars={} history_pairs_total={} history_pairs_windowed={}",
_message_contents_char_count(messages),
hw.turn_total,
len(hw.window) // 2,
)
response = await chat_llm.ainvoke(messages)
logger.info(
"event=chat_llm_done agent=ProfileAgent.generate_profile_greeting "
"response_latency_ms={:.2f}",
(time.perf_counter() - llm_t0) * 1000,
)
response_text = (
response.content if hasattr(response, "content") else str(response)
)

View File

@@ -0,0 +1,47 @@
"""Bundled parameters for chat system prompts (InterviewAgent)."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List, Optional
@dataclass
class ChatPromptContext:
"""访谈轮次构建 `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 = ""
def guided_system_prompt(self) -> str:
"""`user_message` 仅参与启发式,不出现在返回的系统提示文本中。"""
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

@@ -14,14 +14,9 @@ from app.agents.chat.prompts_profile import (
# Conversation prompts对话访谈
from app.agents.chat.prompts_conversation import (
ConversationStage,
INTERVIEW_QUESTIONS,
SLOT_NAME_MAP,
get_guided_conversation_prompt,
get_interview_system_prompt,
get_opening_prompt,
get_questions_for_stage,
get_system_prompt,
)
__all__ = [
@@ -31,12 +26,7 @@ __all__ = [
"get_profile_extraction_prompt",
"get_profile_followup_prompt",
"get_profile_greeting_prompt",
"ConversationStage",
"INTERVIEW_QUESTIONS",
"SLOT_NAME_MAP",
"get_guided_conversation_prompt",
"get_opening_prompt",
"get_questions_for_stage",
"get_system_prompt",
"get_interview_system_prompt",
]

View File

@@ -1,8 +1,7 @@
"""
对话 Agent 提示词模板和访谈问题库
对话 Agent 提示词模板
"""
from enum import Enum
from typing import Dict, List, Optional
from app.agents.chat.background_voice import (
@@ -20,106 +19,9 @@ from app.agents.chat.personas import (
get_opening_persona_line,
normalize_interview_persona,
)
from app.agents.stage_constants import CHAT_STAGES, STAGE_DISPLAY_ZH
from app.core.config import settings
class ConversationStage(str, Enum):
"""对话阶段枚举"""
CHILDHOOD = "childhood" # 童年
EDUCATION = "education" # 教育
CAREER = "career" # 事业
FAMILY = "family" # 家庭
BELIEFS = "beliefs" # 信念
SUMMARY = "summary" # 人生总结
# 访谈问题库(每阶段 23 条短问句,供参考;主流程仍由模型与槽位驱动)
INTERVIEW_QUESTIONS: Dict[ConversationStage, List[str]] = {
ConversationStage.CHILDHOOD: [
"你是在哪儿长大的?小时候印象最深的一件事是什么?",
"小时候家里是怎样的?父母对你影响大吗?",
],
ConversationStage.EDUCATION: [
"求学阶段印象最深的是哪段经历?",
"有没有哪位老师或同学对你影响特别大?毕业后最初想做什么?",
],
ConversationStage.CAREER: [
"第一次工作还记得吗?当时心情怎样?",
"事业里遇到过最大的挑战是什么?怎么挺过来的?有没有特别自豪的时刻?",
],
ConversationStage.FAMILY: [
"和伴侣是怎么认识的?做父母时最难忘或最骄傲的瞬间?",
"家庭在你的人生里扮演什么角色?",
],
ConversationStage.BELIEFS: [
"有没有一直坚守的信念或座右铭?哪些价值观对你最重要?",
"你怎样理解成功与幸福?低谷时是什么支撑你?",
],
ConversationStage.SUMMARY: [
"回顾一生,最重要的经验或教训是什么?最感激的人与事有哪些?",
"若能对年轻时的自己说一句话,你会说什么?",
],
}
def _guided_voice_intro_line(background_voice: str) -> str:
"""顶部角色描述:温暖陪聊,但仍控制篇幅。"""
if normalize_background_voice(background_voice) == "default":
return (
"你是「岁月知己」,像老朋友陪用户聊人生。"
"**先真诚接住对方的话**,再决定要不要追问;短句为主,但**接住情绪比控制字数更重要**。"
)
return (
"你是「岁月知己」,像老朋友陪用户聊人生。"
"**先真诚承接对方一句,再自然推进**;短句为主,遵守下方长度档位。"
)
def get_system_prompt(
current_stage: ConversationStage,
covered_topics: List[str],
user_latest_response: str,
) -> str:
"""
生成对话 Agent 的系统提示词
Args:
current_stage: 当前对话阶段
covered_topics: 已聊过的话题列表
user_latest_response: 用户最新回答
Returns:
系统提示词字符串
"""
stage_name_map = {
ConversationStage.CHILDHOOD: "童年",
ConversationStage.EDUCATION: "教育",
ConversationStage.CAREER: "事业",
ConversationStage.FAMILY: "家庭",
ConversationStage.BELIEFS: "信念",
ConversationStage.SUMMARY: "人生总结",
}
covered_topics_str = "".join(covered_topics) if covered_topics else "暂无"
prompt = f"""你是「岁月知己」,像老朋友一样陪用户聊人生。**回复要短**,像微信聊天,不要长篇、不要文学腔。
规则:先简短接住对方一句,**最多再问一个具体问题**;禁止括号与思考过程;禁止采访腔(如「我注意到」「我想了解」);**不要重复确认**对方刚说过或上文已能推断的信息。
当前阶段:{stage_name_map.get(current_stage, current_stage.value)}
已聊话题:{covered_topics_str}
直接输出对用户说的话。"""
return prompt
def get_questions_for_stage(stage: ConversationStage) -> List[str]:
"""获取指定阶段的所有问题"""
return INTERVIEW_QUESTIONS.get(stage, [])
SLOT_NAME_MAP = {
"place": "成长的地方",
"people": "重要的人",
@@ -154,20 +56,13 @@ STAGE_RELATED_TOPICS = {
"belief": ["career", "family"],
}
LIGHT_TOPICS = [
"有什么爱好或者特别喜欢的消遣方式吗?",
"最近有什么让你开心的事吗?",
"有没有什么趣事想分享?",
"平时喜欢看什么书或者电影吗?",
]
RESPONSE_STYLES = [
"empathy",
"curious",
"reflection",
"lighthearted",
"connection",
]
def _guided_voice_intro_line(background_voice: str) -> str:
"""顶部角色描述(具体「接住」写法集中在 ## 你要做的)。"""
return (
"你是「岁月知己」,像老朋友陪用户聊人生。"
"短句为主,遵守下方「本轮回复长度」档位。"
)
def get_opening_prompt(
@@ -179,14 +74,7 @@ def get_opening_prompt(
occupation: str = "",
) -> str:
"""空对话时 AI 先开口的提示词"""
stage_name_map = {
"childhood": "童年时光",
"education": "求学经历",
"career": "职业生涯",
"family": "家庭生活",
"belief": "人生信念",
}
stage_name = stage_name_map.get(current_stage, current_stage)
stage_name = STAGE_DISPLAY_ZH.get(current_stage, current_stage)
if empty_slots_readable:
topics_str = "".join(empty_slots_readable)
topics_heading = (
@@ -244,7 +132,7 @@ def get_opening_prompt(
topics_heading = (
f"## 当前阶段({stage_name}\n"
"访谈结构化槽位里,这一阶段的主要问题在素材侧**已有覆盖**。"
"开场要像老朋友重逢:接近况、接续上次聊过的事、或任何用户可能提起的新片段;"
"开场要像老朋友重逢:接近况、接续上次聊过的事、或任何用户可能提起的新片段;"
"**禁止**为了凑问题而默认再从「童年在哪长大」等已覆盖模板重头盘问。"
)
task_question = (
@@ -346,7 +234,6 @@ def _build_era_context(current_stage: str, user_profile_context: str) -> str:
return ""
place_hint = f" {birth_place}" if birth_place else ""
# 短提示即可,避免模型写长段「时代散文」
return (
f"\n## 时代参考(一两句带过即可,勿长篇)\n"
f"{era_start}-{era_end}{place_hint};可联想:{era_events[0]}"
@@ -356,17 +243,21 @@ def _build_era_context(current_stage: str, user_profile_context: str) -> str:
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}**
- **brief**:一两句话,简短温暖地接住对方,可以带一个小问题也可以不带。
- **standard**:承接 + 最多一个具体问题;像朋友聊天,不写长段。
- **expanded**:用户本轮分享了较多内容或情绪较浓——可以多说一两句承接对方话里的核心点,表达你听到了、你在意,再自然追问;**仍控制在两段以内**。
{desc}
"""
@@ -375,7 +266,7 @@ def get_guided_conversation_prompt(
empty_slots: List[str],
filled_slots: Dict[str, str],
user_message: str,
conversation_turn: int = 0,
conversation_turn_total: int = 0,
same_topic_turns: int = 0,
all_stages_coverage: Optional[Dict[str, Dict]] = None,
detected_user_stage: str = "",
@@ -386,29 +277,28 @@ def get_guided_conversation_prompt(
background_voice: str = "default",
occupation: str = "",
) -> str:
"""生成状态感知的对话提示词(档位由 Agent 计算的 ReplyPlan 传入,不在此重复推导)。"""
"""生成状态感知的对话提示词
``user_message`` 仅用于启发式(新细节/闲聊/情绪),其原文**不会**写入本提示,用户话仅以最终 HumanMessage 传入模型。
``conversation_turn_total`` 为 Redis 全量历史的轮次数,不受窗口截断影响。
"""
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)
stage_name_map = {
"childhood": "童年时光",
"education": "求学经历",
"career": "职业生涯",
"family": "家庭生活",
"belief": "人生信念",
}
current_stage_name = stage_name_map.get(current_stage, current_stage)
current_stage_name = STAGE_DISPLAY_ZH.get(current_stage, current_stage)
user_stage_name = (
stage_name_map.get(detected_user_stage, "") if detected_user_stage else ""
STAGE_DISPLAY_ZH.get(detected_user_stage, "") if detected_user_stage else ""
)
user_jumped = detected_user_stage and detected_user_stage != current_stage
user_jumped = bool(detected_user_stage and detected_user_stage != current_stage)
empty_slots_readable = [SLOT_NAME_MAP.get(s, s) for s in empty_slots]
empty_slots_str = (
"".join(empty_slots_readable) if empty_slots_readable else "已聊得很充分"
"".join(empty_slots_readable)
if empty_slots_readable
else "本阶段暂无明显缺口"
)
filled_info = []
@@ -421,60 +311,56 @@ def get_guided_conversation_prompt(
)
filled_slots_str = "\n".join(filled_info) if filled_info else "刚开始聊"
progress_lines = []
uncovered_stages = []
progress_lines: List[str] = []
uncovered_stages: List[str] = []
if all_stages_coverage:
for stage in ["childhood", "education", "career", "family", "belief"]:
cur_cn = STAGE_DISPLAY_ZH.get(current_stage, current_stage)
progress_lines.append(f"当前阶段:{cur_cn}")
for stage in CHAT_STAGES:
cov = all_stages_coverage.get(stage, {})
filled_n = cov.get("filled", 0)
total_n = cov.get("total", 0)
sname = stage_name_map.get(stage, stage)
sname = STAGE_DISPLAY_ZH.get(stage, stage)
if total_n <= 0:
continue
if filled_n == 0:
progress_lines.append(f" {sname}还没聊到")
progress_lines.append(f" {sname}未聊")
uncovered_stages.append(sname)
elif filled_n < total_n:
progress_lines.append(f" {sname}聊了一些({filled_n}/{total_n}")
else:
progress_lines.append(f" {sname}:已聊得很充分 ✓")
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
)
should_lighten_mood = conversation_turn > 0 and conversation_turn % 7 == 0
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
related_stages = STAGE_RELATED_TOPICS.get(current_stage, [])
related_stages_str = "".join([stage_name_map.get(s, s) for s in related_stages])
related_stages_str = "".join([STAGE_DISPLAY_ZH.get(s, s) for s in related_stages])
emotional = heuristic_likely_emotional(user_message)
if persona_block:
tone_section = f"{persona_block}\n"
else:
tone_section = ""
tone_section = f"{persona_block}\n" if persona_block else ""
followup_trigger_block = """## 什么时候追问、什么时候只承接
**该追问**(承接后带 1 个具体问题):
- 出现**新的人名、新关系、新情节**,上文还没展开过;
- 用户邀你接话(如「你猜猜」);
- 本阶段仍有未聊方向,且对方话里露出可深挖的线头。
**可以只承接、不追问**
- 本轮几乎无新信息(「嗯」「对」「行」);
- 用户明确要结束或换话题;
- 再问会重复上文已说清的事。
**用户在表达情绪时**:先好好接住情绪,让对方感觉被听到、被理解;不急着追问,等情绪有着落后再自然引回。
"""
followup_trigger_block = "## 本轮追问判定\n"
followup_trigger_block += (
"总体原则见「对话方向」与「你要做的」;以下为仅本轮生效的判定:\n"
)
if likely_new:
followup_trigger_block += (
"\n**【本轮判定】用户补充了新细节 → 承接后须追问 1 句。**\n"
"**【本轮判定】用户补充了新细节 → 承接后须追问 1 句。**\n"
)
if emotional and not likely_new:
elif emotional:
followup_trigger_block += (
"\n**【本轮判定】用户情绪较浓 → 先好好共情承接,不必急着追问。**\n"
"**【本轮判定】用户情绪较浓 → 先好好共情承接,不必急着追问。**\n"
)
else:
followup_trigger_block += (
"(无特殊判定时按惯例:新线头追问一句,否则可只承接。)\n"
)
memoir_orientation_lines = [
@@ -562,16 +448,13 @@ def get_guided_conversation_prompt(
## 进度
{progress_str}
{era_context}
## 用户刚才说
"{user_message}"
{memoir_orientation_block}{memory_section}{followup_trigger_block}
{tone_section}
## 你要做的
1. **先接住对方**——一句真诚回应,不要写成总结或讲评。
2. 用户跳到别的人生阶段,跟着聊,别硬拉回。
3. **最多追问一个**具体、好答的问题(参照上方「什么时候追问」);无需追问时,只承接就好。
3. **最多追问一个**具体、好答的问题(参照上方「本轮追问判定」);无需追问时,只承接就好。
4. 用户回「嗯」「对」之类,结合上文理解,承接或换个新角度,不要重复上一轮问过的事。
5. 可用 [SPLIT] 分成**最多 2 条**消息。
{dynamic_guidance}{uncovered_hint}
@@ -582,7 +465,3 @@ def get_guided_conversation_prompt(
直接输出(仅自然口语,无任何括号前缀或旁白):"""
return prompt
# 别名:访谈对话专用;勿与回忆录 `app.agents.memoir.prompts.get_memoir_editor_system_prompt` 混淆
get_interview_system_prompt = get_system_prompt

View File

@@ -15,7 +15,7 @@ from app.agents.chat.stage_prompts import (
from app.core.config import settings
from app.core.langchain_llm import ainvoke_json_object
from app.core.logging import get_logger
from app.features.memoir.memoir_images.json_payload import extract_json_payload
from app.core.json_utils import extract_json_payload
logger = get_logger(__name__)

View File

@@ -2,21 +2,13 @@
访谈「人生阶段」判定专用短提示词(与回忆录五阶段 slots 一致)。
"""
from app.agents.state_schema import DEFAULT_STAGE_ORDER
from app.agents.stage_constants import CHAT_STAGES, STAGE_DISPLAY_ZH
VALID_CHAT_LIFE_STAGES = frozenset(DEFAULT_STAGE_ORDER)
LIFE_STAGE_DISPLAY_ZH = {
"childhood": "童年时光",
"education": "求学经历",
"career": "职业生涯",
"family": "家庭生活",
"belief": "人生信念",
}
VALID_CHAT_LIFE_STAGES = frozenset(CHAT_STAGES)
def life_stage_display_zh(stage: str) -> str:
return LIFE_STAGE_DISPLAY_ZH.get(stage, stage)
return STAGE_DISPLAY_ZH.get(stage, stage)
def get_chat_stage_detection_prompt(user_message: str, current_stage: str) -> str:
@@ -24,7 +16,7 @@ def get_chat_stage_detection_prompt(user_message: str, current_stage: str) -> st
仅判定用户本轮**主要**在谈哪一人生阶段;输出 JSON。
无人生经历实质(纯寒暄等)时返回当前系统阶段。
"""
allowed = "".join(DEFAULT_STAGE_ORDER)
allowed = "".join(CHAT_STAGES)
return f"""你是访谈助手。根据用户**本轮**话语,判断其**主要**在谈论哪一段人生经历。
系统当前跟踪的阶段(仅供参考,不要默认沿用;以用户实质内容为准):{current_stage}