Files
life-echo/api/app/agents/chat/interview_agent.py
Kevin 69a673e6c6 feat(api): 访谈人格/回复长度策略、口述归一、背景语气与输入净稿全链路
Chat 访谈
- 新增 persona 系统(default / warm_listener / curious_guide)与 background_voice 语气层
- 回复长度由 compute_reply_plan 统一决策(brief / standard / expanded),融合信息密度启发式
- 输入净稿(input_normalize):编排层可选 rules/llm 归一用户口语后再喂模型与记忆检索
- 记忆证据注入:按用户话检索 memory evidence 并注入 prompt

Memoir 回忆录
- 口述归一(oral_normalize):segment 原文保留,story 管线取派生净稿作叙事输入
- segment 入队批次门闸:累计字数 + 最长等待秒数,减少零碎提交
- fidelity_check / prompts / narrative_agent 微调
- Alembic 0005:清理跨章节 story 外键

Infra
- Dockerfile 加入 ffmpeg
- pyproject.toml 新增依赖并同步 uv.lock
- .env.example / .env.production 补全新配置项

Tests
- 新增 test_background_voice、test_chat_input_normalize、test_experience_regressions
- 扩展 test_interview_prompts、test_interview_reply_length、test_story_route_oral_invariant

Made-with: Cursor
2026-03-31 23:55:26 +08:00

263 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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.personas import normalize_interview_persona
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
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.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
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,
) -> 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)
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()
persona = normalize_interview_persona(settings.chat_interview_persona)
reply_plan = compute_reply_plan(
text_for_model,
background_voice=background_voice,
settings=settings,
)
system_prompt = get_guided_conversation_prompt(
current_stage=memoir_state.current_stage,
empty_slots=empty_slots,
filled_slots=filled_slots,
user_message=text_for_model,
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,
persona=persona,
memory_evidence_text=memory_evidence_text,
reply_length_mode=reply_plan.mode.value,
background_voice=background_voice,
)
history_string = format_history_string(history_messages)
full_prompt = f"{system_prompt}\n\n{history_string}\n\nHuman: {text_for_model}\n\nAssistant:"
log_agent_payload(
logger, "InterviewAgent.generate_response.prompt", full_prompt
)
chat_llm = self.llm.bind(max_tokens=reply_plan.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=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",
) -> 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,
)
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()]
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 ["你好呀~ 又见面了,最近有没有什么事想跟我说说?"]