数据库 - 新增迁移 0003:timeline_events.memory_source_id 外键 → memory_sources,便于按 ingest 源做时间线幂等 后端 - 记忆 - 新增 ingest 后 LLM 富化(摘要/事实/时间线),可配置开关与最大字符数 - 新增证据包组装:合并 chunk、摘要、事实、时间线、故事等检索结果;支持空 query 时是否仍带 rolling 等开关 - repo/retriever/service/router/schemas/summarizer/timeline/extractor 等扩展;文档 memory-retrieval.md 更新 后端 - 对话 WS - 增加 PING/PONG;分段 ASR 日志与空音频处理;转写失败与「无助手回复」错误提示更明确 - 助手多段回复持久化使用统一分隔符,与分段逻辑一致 后端 - Agent - reply_limits:按 [SPLIT] 与段落拆段,并保证非空 fallback,供 WS 与 TTS 多段下发 后端 - 回忆录任务 - transcript ingest 记录 source_id;任务成功结?
233 lines
9.2 KiB
Python
233 lines
9.2 KiB
Python
"""
|
||
InterviewAgent:正式访谈 Specialist
|
||
负责状态感知回复、开场白,不负责 Redis 持久化(由 Orchestrator 统一处理)
|
||
"""
|
||
|
||
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 app.agents.chat.helpers import format_history_string, get_history_messages
|
||
from app.agents.chat.prompts_conversation import (
|
||
SLOT_NAME_MAP,
|
||
get_guided_conversation_prompt,
|
||
get_opening_prompt,
|
||
)
|
||
from app.agents.state_schema import MemoirStateSchema
|
||
from app.agents.chat.reply_limits import (
|
||
nonempty_segments_or_fallback,
|
||
segments_from_llm_response,
|
||
truncate_chat_segments,
|
||
)
|
||
from app.core.agent_logging import (
|
||
agent_span,
|
||
log_agent_payload,
|
||
log_agent_summary,
|
||
)
|
||
from app.core.config import settings
|
||
|
||
logger = get_logger(__name__)
|
||
|
||
# LLM 不可用或调用失败时对用户展示(不暴露异常细节、不触发 TTS)
|
||
_FALLBACK_REPLY = "刚才网络不太稳,没接上。你可以再说一遍,或稍后再试。"
|
||
|
||
|
||
def _get_langchain_llm():
|
||
try:
|
||
provider = get_llm_provider()
|
||
return getattr(provider, "langchain_llm", None)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
class InterviewAgent:
|
||
"""正式访谈 Specialist Agent"""
|
||
|
||
def __init__(self):
|
||
self.llm = _get_langchain_llm()
|
||
|
||
def _detect_user_stage(self, user_message: str) -> str:
|
||
"""关键词回退:与 stage_detection 一致(多阶段打分)。"""
|
||
return keyword_fallback_primary_stage(user_message)
|
||
|
||
def _estimate_same_topic_turns(
|
||
self, history_messages: List[Any], current_filled_slots: dict
|
||
) -> int:
|
||
"""估算同一话题的连续轮数"""
|
||
if len(history_messages) < 4:
|
||
return len(history_messages) // 2
|
||
recent_messages = history_messages[-6:]
|
||
keywords_per_turn = []
|
||
for i in range(0, len(recent_messages), 2):
|
||
if i + 1 < len(recent_messages):
|
||
human_msg = (
|
||
recent_messages[i].content
|
||
if hasattr(recent_messages[i], "content")
|
||
else str(recent_messages[i])
|
||
)
|
||
ai_msg = (
|
||
recent_messages[i + 1].content
|
||
if hasattr(recent_messages[i + 1], "content")
|
||
else str(recent_messages[i + 1])
|
||
)
|
||
keywords_per_turn.append((human_msg + ai_msg)[:100])
|
||
if len(keywords_per_turn) >= 3:
|
||
return 3
|
||
return len(keywords_per_turn)
|
||
|
||
async def generate_response_with_state(
|
||
self,
|
||
conversation_id: str,
|
||
user_message: str,
|
||
memoir_state: MemoirStateSchema,
|
||
user_profile_context: str = "",
|
||
detected_user_stage: Optional[str] = None,
|
||
) -> AgentChatTurn:
|
||
"""生成状态感知的访谈回复,不持久化(由 Orchestrator 负责)"""
|
||
if not self.llm:
|
||
logger.warning("InterviewAgent: LLM 未配置,返回兜底文案")
|
||
return AgentChatTurn(messages=[_FALLBACK_REPLY], skip_tts=True)
|
||
try:
|
||
empty_slots = memoir_state.empty_slots_for_current_stage()
|
||
filled_slots = {
|
||
key: value.snippet
|
||
for key, value in memoir_state.slots.get(
|
||
memoir_state.current_stage, {}
|
||
).items()
|
||
if value.snippet
|
||
}
|
||
if detected_user_stage is not None:
|
||
du = detected_user_stage
|
||
else:
|
||
du = self._detect_user_stage(user_message)
|
||
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
|
||
)
|
||
all_stages_coverage = memoir_state.all_stages_coverage()
|
||
system_prompt = get_guided_conversation_prompt(
|
||
current_stage=memoir_state.current_stage,
|
||
empty_slots=empty_slots,
|
||
filled_slots=filled_slots,
|
||
user_message=user_message,
|
||
conversation_turn=conversation_turn,
|
||
same_topic_turns=same_topic_turns,
|
||
all_stages_coverage=all_stages_coverage,
|
||
detected_user_stage=du,
|
||
user_profile_context=user_profile_context,
|
||
)
|
||
history_string = format_history_string(history_messages)
|
||
full_prompt = f"{system_prompt}\n\n{history_string}\n\nHuman: {user_message}\n\nAssistant:"
|
||
log_agent_payload(
|
||
logger, "InterviewAgent.generate_response.prompt", full_prompt
|
||
)
|
||
chat_llm = self.llm.bind(max_tokens=settings.chat_interview_max_tokens)
|
||
with agent_span(
|
||
logger,
|
||
"InterviewAgent.generate_response.llm",
|
||
conversation_id=conversation_id,
|
||
stage=memoir_state.current_stage,
|
||
):
|
||
response = await chat_llm.ainvoke(full_prompt)
|
||
response_text = (
|
||
response.content if hasattr(response, "content") else str(response)
|
||
)
|
||
log_agent_payload(
|
||
logger, "InterviewAgent.generate_response.raw_response", response_text
|
||
)
|
||
raw_list = segments_from_llm_response(
|
||
response_text,
|
||
max_segments=settings.chat_interview_max_segments,
|
||
)
|
||
if not raw_list:
|
||
raw_list = [response_text.strip()]
|
||
out = truncate_chat_segments(
|
||
raw_list,
|
||
max_segments=settings.chat_interview_max_segments,
|
||
max_chars_per_segment=settings.chat_interview_max_chars_per_segment,
|
||
)
|
||
if not out:
|
||
out = [
|
||
response_text.strip()[
|
||
: settings.chat_interview_max_chars_per_segment
|
||
]
|
||
]
|
||
out = nonempty_segments_or_fallback(out, fallback=_FALLBACK_REPLY)
|
||
log_agent_summary(
|
||
logger,
|
||
"InterviewAgent.generate_response segments={} conversation_id={}",
|
||
len(out),
|
||
conversation_id,
|
||
)
|
||
return AgentChatTurn(messages=out, skip_tts=False)
|
||
except Exception as e:
|
||
logger.error("生成回应失败: {}", e, exc_info=True)
|
||
return AgentChatTurn(messages=[_FALLBACK_REPLY], skip_tts=True)
|
||
|
||
async def generate_opening_message(
|
||
self,
|
||
conversation_id: str,
|
||
memoir_state: MemoirStateSchema,
|
||
user_profile_context: str = "",
|
||
) -> List[str]:
|
||
"""生成空对话开场白,不持久化(由 Orchestrator 负责)"""
|
||
if not self.llm:
|
||
return ["你好呀~ 又见面了,今天有没有哪段回忆或近况想聊聊?"]
|
||
try:
|
||
empty_slots = memoir_state.empty_slots_for_current_stage()
|
||
empty_slots_readable = [SLOT_NAME_MAP.get(s, s) for s in empty_slots]
|
||
prompt = get_opening_prompt(
|
||
current_stage=memoir_state.current_stage,
|
||
empty_slots_readable=empty_slots_readable,
|
||
user_profile_context=user_profile_context,
|
||
)
|
||
full_prompt = f"{prompt}\n\nAssistant:"
|
||
log_agent_payload(logger, "InterviewAgent.opening.prompt", full_prompt)
|
||
opening_llm = self.llm.bind(max_tokens=settings.chat_opening_max_tokens)
|
||
with agent_span(
|
||
logger,
|
||
"InterviewAgent.opening.llm",
|
||
conversation_id=conversation_id,
|
||
):
|
||
response = await opening_llm.ainvoke(full_prompt)
|
||
response_text = (
|
||
response.content if hasattr(response, "content") else str(response)
|
||
)
|
||
log_agent_payload(
|
||
logger, "InterviewAgent.opening.raw_response", response_text
|
||
)
|
||
raw_list = segments_from_llm_response(response_text, max_segments=2)
|
||
if not raw_list:
|
||
raw_list = [response_text.strip()]
|
||
out = truncate_chat_segments(
|
||
raw_list,
|
||
max_segments=2,
|
||
max_chars_per_segment=settings.chat_interview_max_chars_per_segment,
|
||
)
|
||
log_agent_summary(
|
||
logger,
|
||
"InterviewAgent.opening segments={} conversation_id={}",
|
||
len(out),
|
||
conversation_id,
|
||
)
|
||
segments = (
|
||
out
|
||
if out
|
||
else [
|
||
response_text.strip()[
|
||
: settings.chat_interview_max_chars_per_segment
|
||
]
|
||
]
|
||
)
|
||
return nonempty_segments_or_fallback(
|
||
segments,
|
||
fallback="你好呀~ 又见面了,最近有没有什么事想跟我说说?",
|
||
)
|
||
except Exception as e:
|
||
logger.error("生成开场白失败: {}", e, exc_info=True)
|
||
return ["你好呀~ 又见面了,最近有没有什么事想跟我说说?"]
|