- 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.
312 lines
13 KiB
Python
312 lines
13 KiB
Python
"""
|
||
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.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 _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
|
||
all_stages_coverage = memoir_state.all_stages_coverage()
|
||
persona = normalize_interview_persona(settings.chat_interview_persona)
|
||
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,
|
||
all_stages_coverage=all_stages_coverage,
|
||
detected_user_stage=du,
|
||
user_profile_context=user_profile_context,
|
||
persona=persona,
|
||
memory_evidence_text=memory_evidence_text,
|
||
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=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=max_segments,
|
||
)
|
||
if not raw_list:
|
||
raw_list = [response_text.strip()]
|
||
out = truncate_chat_segments(
|
||
raw_list,
|
||
max_segments=max_segments,
|
||
max_chars_per_segment=max_chars,
|
||
)
|
||
if not out:
|
||
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={} "
|
||
"max_tokens={}",
|
||
len(out),
|
||
conversation_id,
|
||
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()]
|
||
max_chars = int(settings.chat_interview_max_chars_per_segment)
|
||
out = truncate_chat_segments(
|
||
raw_list,
|
||
max_segments=2,
|
||
max_chars_per_segment=max_chars,
|
||
)
|
||
log_agent_summary(
|
||
logger,
|
||
"InterviewAgent.opening segments={} conversation_id={}",
|
||
len(out),
|
||
conversation_id,
|
||
)
|
||
segments = out if out else [response_text.strip()[:max_chars]]
|
||
return nonempty_segments_or_fallback(
|
||
segments,
|
||
fallback="你好呀~ 又见面了,最近有没有什么事想跟我说说?",
|
||
)
|
||
except Exception as e:
|
||
logger.error("生成开场白失败: {}", e, exc_info=True)
|
||
return ["你好呀~ 又见面了,最近有没有什么事想跟我说说?"]
|