- 新增 utterance_substance:短时/应答/元话语可跳过记忆检索、阶段 LLM 与资料抽取 LLM;可配置 - 输入归一化:LLM 模式默认仅语音/ASR;配置项写入 .env.example - Memoir Phase1:可选 batch LLM 一次性抽取+分类(失败回退逐段);Extraction 空槽位时阶段与 current_stage 对齐,prompt 约束收紧 - 叙事与忠实度:narrative_safety、证据重叠/场合锚点、标题 slots 与履历短语 grounded;fidelity 解析失败 fail-open 可配置 - 章节管线:锁 TTL 上调、锁竞争 Celery 重试、Phase2 immediate singleflight 等;story_pipeline_sync / chapter_compose / memoir_tasks 联动 - Memory:compaction / repo / summarizer / evidence 小修;事实 FTS 未命中是否回退最近事实可配置 - 新增 memoir_pipeline_trace;补充 memoir_reliability 文档与多项回归/门控测试
364 lines
14 KiB
Python
364 lines
14 KiB
Python
"""
|
||
ChatOrchestrator:AI 回复用户模块的编排层
|
||
负责路由(Profile vs Interview)、调用 Specialist Agent;持久化由 feature 层 ConversationHistoryStore 完成。
|
||
"""
|
||
|
||
import time
|
||
from datetime import datetime
|
||
from typing import TYPE_CHECKING, List, Optional
|
||
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.agents.chat.agent_turn import AgentChatTurn
|
||
from app.agents.chat.interview_agent import InterviewAgent
|
||
from app.agents.chat.profile_agent import ProfileAgent
|
||
from app.agents.state_schema import MemoirStateSchema
|
||
from app.core.agent_logging import agent_summary_enabled, log_agent_detail
|
||
from app.core.logging import get_logger
|
||
from app.agents.chat.stage_detection import (
|
||
detect_primary_life_stage,
|
||
life_stage_display_name,
|
||
)
|
||
from app.agents.chat.utterance_substance import should_run_chat_stage_memory_heavy_work
|
||
from app.core.config import settings
|
||
from app.core.dependencies import get_llm_provider
|
||
from app.features.conversation.input_normalize import (
|
||
apply_conversation_input_rules,
|
||
normalize_chat_input_for_agent,
|
||
)
|
||
from app.features.memoir.state_service import get_or_create_state, switch_stage
|
||
|
||
|
||
def _llm_for_chat_input_normalize():
|
||
try:
|
||
p = get_llm_provider()
|
||
return getattr(p, "langchain_llm", None)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
if TYPE_CHECKING:
|
||
from app.features.user.models import User
|
||
|
||
logger = get_logger(__name__)
|
||
|
||
_UNAUTH_TURN = AgentChatTurn(
|
||
messages=["暂时没法继续对话,请先登录后再试。"], skip_tts=True
|
||
)
|
||
|
||
|
||
async def _fetch_interview_memory_evidence(
|
||
db: AsyncSession,
|
||
user_id: str,
|
||
user_message: str,
|
||
) -> str:
|
||
"""按本轮用户话检索记忆,格式化为短文本;失败或未启用时返回空串。"""
|
||
from app.core.dependencies import get_embedding_provider
|
||
from app.features.memory.evidence_format import format_evidence_chunks_for_prompt
|
||
from app.features.memory.service import MemoryService
|
||
|
||
if not settings.chat_memory_retrieval_enabled:
|
||
return ""
|
||
msg = (user_message or "").strip()
|
||
if not msg:
|
||
return ""
|
||
if (
|
||
settings.chat_memory_retrieval_require_substantive
|
||
and not should_run_chat_stage_memory_heavy_work(msg)
|
||
):
|
||
return ""
|
||
try:
|
||
ms = MemoryService(db, embedding_provider=get_embedding_provider())
|
||
bundle = await ms.retrieve(user_id, msg, top_k=settings.chat_memory_top_k)
|
||
text = format_evidence_chunks_for_prompt(bundle.model_dump())
|
||
t = (text or "").strip()
|
||
if not t:
|
||
return ""
|
||
max_c = settings.chat_memory_evidence_max_chars
|
||
if len(t) > max_c:
|
||
return t[: max_c - 3] + "..."
|
||
return t
|
||
except Exception as e:
|
||
try:
|
||
await db.rollback()
|
||
except Exception as rollback_error:
|
||
logger.warning("访谈记忆检索失败后回滚也失败: {}", rollback_error)
|
||
logger.warning("访谈记忆检索失败: {}", e)
|
||
return ""
|
||
|
||
|
||
class ChatOrchestrator:
|
||
"""
|
||
聊天编排器:根据用户资料完成度路由到 ProfileAgent 或 InterviewAgent。
|
||
不直接写入 Redis/DB;由 WS pipeline / ConversationHistoryStore 落库并同步缓存。
|
||
"""
|
||
|
||
def __init__(self):
|
||
self.profile_agent = ProfileAgent()
|
||
self.interview_agent = InterviewAgent()
|
||
|
||
async def process_user_message(
|
||
self,
|
||
conversation_id: str,
|
||
user_message: str,
|
||
user: Optional["User"],
|
||
conversation, # 用于更新 conversation_stage
|
||
is_from_voice: bool,
|
||
voice_session_id: Optional[str],
|
||
db: AsyncSession,
|
||
apply_extracted_profile_fn,
|
||
get_missing_profile_fields_fn,
|
||
get_filled_profile_fields_fn,
|
||
user_message_timestamp: Optional[datetime] = None,
|
||
audio_duration_seconds: Optional[int] = None,
|
||
) -> AgentChatTurn:
|
||
"""
|
||
处理用户消息,返回 AI 回复(分段 + 是否跳过 TTS)。
|
||
根据 missing_fields 路由到 ProfileAgent 或 InterviewAgent。
|
||
"""
|
||
t0 = time.perf_counter()
|
||
|
||
# --- 资料收集模式 ---
|
||
if user:
|
||
missing = get_missing_profile_fields_fn(user)
|
||
if missing:
|
||
try:
|
||
log_agent_detail(
|
||
logger,
|
||
"ChatOrchestrator route=profile conversation_id={} "
|
||
"missing_fields={} user_msg_len={}",
|
||
conversation_id,
|
||
missing,
|
||
len(user_message or ""),
|
||
)
|
||
run_extract = True
|
||
if settings.chat_profile_extract_require_substantive:
|
||
rules_only = apply_conversation_input_rules(user_message or "")
|
||
run_extract = should_run_chat_stage_memory_heavy_work(
|
||
rules_only
|
||
)
|
||
extracted = None
|
||
if run_extract:
|
||
extracted = (
|
||
await self.profile_agent.extract_profile_from_message(
|
||
user_message, missing, conversation_id=conversation_id
|
||
)
|
||
)
|
||
if extracted:
|
||
await apply_extracted_profile_fn(user, extracted, db)
|
||
|
||
remaining = get_missing_profile_fields_fn(user)
|
||
filled = get_filled_profile_fields_fn(user)
|
||
interview_stage_hint = ""
|
||
if not remaining:
|
||
st = await get_or_create_state(user.id, db)
|
||
interview_stage_hint = life_stage_display_name(st.current_stage)
|
||
responses = await self.profile_agent.generate_profile_followup(
|
||
conversation_id=conversation_id,
|
||
user_message=user_message,
|
||
missing_fields=remaining,
|
||
filled_fields=filled,
|
||
nickname=user.nickname or "",
|
||
interview_stage_hint=interview_stage_hint,
|
||
)
|
||
if agent_summary_enabled():
|
||
logger.info(
|
||
"ChatOrchestrator.process_user_message route=profile "
|
||
"duration_ms={:.2f} conversation_id={} response_segments={}",
|
||
(time.perf_counter() - t0) * 1000,
|
||
conversation_id,
|
||
len(responses),
|
||
)
|
||
return AgentChatTurn(messages=responses, skip_tts=False)
|
||
except Exception as e:
|
||
logger.error(f"资料收集处理失败: {e}", exc_info=True)
|
||
return AgentChatTurn(
|
||
messages=["不好意思刚才没接住,你再说一遍好吗?"],
|
||
skip_tts=False,
|
||
)
|
||
|
||
# --- 正式访谈模式 ---
|
||
user_id = user.id if user else None
|
||
if not user_id:
|
||
if agent_summary_enabled():
|
||
logger.info(
|
||
"ChatOrchestrator.process_user_message route=unauth "
|
||
"duration_ms={:.2f} conversation_id={}",
|
||
(time.perf_counter() - t0) * 1000,
|
||
conversation_id,
|
||
)
|
||
return _UNAUTH_TURN
|
||
|
||
log_agent_detail(
|
||
logger,
|
||
"ChatOrchestrator route=interview conversation_id={} user_msg_len={}",
|
||
conversation_id,
|
||
len(user_message or ""),
|
||
)
|
||
llm_n = None
|
||
if settings.chat_input_normalize_enabled and (
|
||
(settings.chat_input_normalize_mode or "").strip().lower() == "llm"
|
||
):
|
||
llm_n = _llm_for_chat_input_normalize()
|
||
normalized_user_message = normalize_chat_input_for_agent(
|
||
user_message or "",
|
||
llm=llm_n,
|
||
is_from_voice=is_from_voice,
|
||
)
|
||
state = await get_or_create_state(user_id, db)
|
||
substantive_turn = should_run_chat_stage_memory_heavy_work(
|
||
normalized_user_message
|
||
)
|
||
detected = await detect_primary_life_stage(
|
||
normalized_user_message,
|
||
state.current_stage,
|
||
self.interview_agent.llm,
|
||
skip_llm=not substantive_turn,
|
||
)
|
||
if detected != state.current_stage:
|
||
state = await switch_stage(user_id, detected, db)
|
||
|
||
if conversation and conversation.conversation_stage != state.current_stage:
|
||
conversation.conversation_stage = state.current_stage
|
||
await db.commit()
|
||
|
||
from app.agents.chat.background_voice import infer_background_voice
|
||
from app.agents.chat.prompts_profile import format_user_profile_context
|
||
|
||
user_profile_context = ""
|
||
background_voice = "default"
|
||
occupation = ""
|
||
if user:
|
||
user_profile_context = format_user_profile_context(
|
||
birth_year=user.birth_year,
|
||
birth_place=user.birth_place,
|
||
grew_up_place=user.grew_up_place,
|
||
occupation=user.occupation,
|
||
)
|
||
background_voice = infer_background_voice(user.occupation)
|
||
occupation = user.occupation or ""
|
||
|
||
memory_evidence_text = await _fetch_interview_memory_evidence(
|
||
db, user_id, normalized_user_message
|
||
)
|
||
|
||
turn = await self.interview_agent.generate_response_with_state(
|
||
conversation_id=conversation_id,
|
||
user_message=user_message,
|
||
memoir_state=state,
|
||
user_profile_context=user_profile_context,
|
||
detected_user_stage=detected,
|
||
memory_evidence_text=memory_evidence_text,
|
||
background_voice=background_voice,
|
||
normalized_user_message=normalized_user_message,
|
||
occupation=occupation,
|
||
)
|
||
if agent_summary_enabled():
|
||
logger.info(
|
||
"ChatOrchestrator.process_user_message route=interview "
|
||
"duration_ms={:.2f} conversation_id={} stage={} response_segments={} skip_tts={}",
|
||
(time.perf_counter() - t0) * 1000,
|
||
conversation_id,
|
||
state.current_stage,
|
||
len(turn.messages),
|
||
turn.skip_tts,
|
||
)
|
||
return turn
|
||
|
||
async def extract_profile_from_message(
|
||
self,
|
||
user_message: str,
|
||
missing_fields: List[str],
|
||
conversation_id: Optional[str] = None,
|
||
):
|
||
"""委托 ProfileAgent 提取资料"""
|
||
return await self.profile_agent.extract_profile_from_message(
|
||
user_message, missing_fields, conversation_id=conversation_id
|
||
)
|
||
|
||
async def generate_profile_followup(
|
||
self,
|
||
conversation_id: str,
|
||
user_message: str,
|
||
missing_fields: List[str],
|
||
filled_fields: dict,
|
||
nickname: str = "",
|
||
is_from_voice: bool = False,
|
||
voice_session_id: str | None = None,
|
||
user_message_timestamp: datetime | None = None,
|
||
audio_duration_seconds: int | None = None,
|
||
) -> List[str]:
|
||
"""委托 ProfileAgent 生成资料追问(持久化由调用方负责)。"""
|
||
return await self.profile_agent.generate_profile_followup(
|
||
conversation_id=conversation_id,
|
||
user_message=user_message,
|
||
missing_fields=missing_fields,
|
||
filled_fields=filled_fields,
|
||
nickname=nickname,
|
||
)
|
||
|
||
async def generate_profile_greeting(
|
||
self,
|
||
conversation_id: str,
|
||
missing_fields: List[str],
|
||
nickname: str = "",
|
||
) -> List[str]:
|
||
"""委托 ProfileAgent 生成资料收集开场白(持久化由调用方负责)。"""
|
||
return await self.profile_agent.generate_profile_greeting(
|
||
conversation_id=conversation_id,
|
||
missing_fields=missing_fields,
|
||
nickname=nickname,
|
||
)
|
||
|
||
async def generate_response_with_state(
|
||
self,
|
||
conversation_id: str,
|
||
user_message: str,
|
||
memoir_state: MemoirStateSchema,
|
||
user_profile_context: str = "",
|
||
is_from_voice: bool = False,
|
||
voice_session_id: str | None = None,
|
||
user_message_timestamp: datetime | None = None,
|
||
audio_duration_seconds: int | None = None,
|
||
detected_user_stage: str | None = None,
|
||
memory_evidence_text: str = "",
|
||
background_voice: str = "default",
|
||
normalized_user_message: str | None = None,
|
||
occupation: str = "",
|
||
) -> AgentChatTurn:
|
||
"""委托 InterviewAgent 生成访谈回复(持久化由调用方负责)。"""
|
||
return await self.interview_agent.generate_response_with_state(
|
||
conversation_id=conversation_id,
|
||
user_message=user_message,
|
||
memoir_state=memoir_state,
|
||
user_profile_context=user_profile_context,
|
||
detected_user_stage=detected_user_stage,
|
||
memory_evidence_text=memory_evidence_text,
|
||
background_voice=background_voice,
|
||
normalized_user_message=normalized_user_message,
|
||
occupation=occupation,
|
||
)
|
||
|
||
def detect_user_stage(self, user_message: str) -> str:
|
||
"""委托 InterviewAgent 检测用户阶段"""
|
||
return self.interview_agent._detect_user_stage(user_message)
|
||
|
||
async def generate_opening_message(
|
||
self,
|
||
conversation_id: str,
|
||
memoir_state: MemoirStateSchema,
|
||
user_profile_context: str = "",
|
||
background_voice: str = "default",
|
||
occupation: str = "",
|
||
) -> List[str]:
|
||
"""
|
||
委托 InterviewAgent 生成访谈开场白(持久化由调用方 ConversationHistoryStore 负责)。
|
||
"""
|
||
return await self.interview_agent.generate_opening_message(
|
||
conversation_id=conversation_id,
|
||
memoir_state=memoir_state,
|
||
user_profile_context=user_profile_context,
|
||
background_voice=background_voice,
|
||
occupation=occupation,
|
||
)
|