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:
@@ -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:
|
||||
"""一次读取 Redis:turn_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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
47
api/app/agents/chat/prompt_context.py
Normal file
47
api/app/agents/chat/prompt_context.py
Normal 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,
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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" # 人生总结
|
||||
|
||||
|
||||
# 访谈问题库(每阶段 2~3 条短问句,供参考;主流程仍由模型与槽位驱动)
|
||||
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
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user