""" InterviewAgent:正式访谈 Specialist 负责状态感知回复、开场白,不负责 Redis 持久化(由 Orchestrator 统一处理) """ import time from typing import Any, List, Optional from langchain_core.messages import HumanMessage, SystemMessage 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_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 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__) # LLM 不可用或调用失败时对用户展示(不暴露异常细节、不触发 TTS) _FALLBACK_REPLY = "刚才网络不太稳,没接上。你可以再说一遍,或稍后再试。" def _get_langchain_llm(): try: provider = get_llm_provider() return getattr(provider, "langchain_llm", None) except Exception: 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""" 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: """估算同一话题的连续轮数(保守:宁可多陪聊几轮再换)。""" 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, normalized_user_message: Optional[str], ) -> str: """模型侧净稿:编排层已归一则直接用;否则在本层补一次(含可选 LLM)。""" if normalized_user_message is not None: return (normalized_user_message or "").strip() llm_n = None if settings.chat_input_normalize_enabled and ( (settings.chat_input_normalize_mode or "").strip().lower() == "llm" ): llm_n = self.llm return normalize_chat_input_for_agent(user_message or "", llm=llm_n) 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, memory_evidence_text: str = "", background_voice: str = "default", normalized_user_message: Optional[str] = None, occupation: str = "", ) -> AgentChatTurn: """生成状态感知的访谈回复,不持久化(由 Orchestrator 负责)""" if not self.llm: logger.warning("InterviewAgent: LLM 未配置,返回兜底文案") return AgentChatTurn(messages=[_FALLBACK_REPLY], skip_tts=True) try: text_for_model = self._resolve_text_for_model( user_message, normalized_user_message ) 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(text_for_model) 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( text_for_model, background_voice=background_voice, settings=settings, ) 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, ) 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", format_history_string( messages, omit_system_body=settings.agent_log_omit_system_message_body, ), ) 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, ): 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) ) log_agent_payload( logger, "InterviewAgent.generate_response.raw_response", response_text ) raw_list = segments_from_llm_response( response_text, max_segments=reply_plan.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, ) if not out: out = [response_text.strip()[: reply_plan.max_chars_per_segment]] out = nonempty_segments_or_fallback(out, fallback=_FALLBACK_REPLY) log_agent_summary( logger, "InterviewAgent.generate_response segments={} conversation_id={} " "reply_length_mode={} max_tokens={}", len(out), conversation_id, reply_plan.mode.value, reply_plan.max_tokens, ) 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 = "", background_voice: str = "default", occupation: 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] persona = normalize_interview_persona(settings.chat_interview_persona) prompt = get_opening_prompt( current_stage=memoir_state.current_stage, empty_slots_readable=empty_slots_readable, user_profile_context=user_profile_context, persona=persona, background_voice=background_voice, occupation=occupation, ) 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, omit_system_body=settings.agent_log_omit_system_message_body, ), ) 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, ): 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) ) 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()] open_plan = compute_reply_plan( "x" * 50, background_voice=background_voice, settings=settings, ) out = truncate_chat_segments( raw_list, max_segments=2, max_chars_per_segment=open_plan.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()[: open_plan.max_chars_per_segment]] ) return nonempty_segments_or_fallback( segments, fallback="你好呀~ 又见面了,最近有没有什么事想跟我说说?", ) except Exception as e: logger.error("生成开场白失败: {}", e, exc_info=True) return ["你好呀~ 又见面了,最近有没有什么事想跟我说说?"]