feat(api): 收敛对话与记忆流程边界,引入 LLM 网关与专用服务
- MemoryService 异步路径委托 MemoryIngestService / MemoryRetrievalService;富化派发经 MemoryEnrichmentScheduler - WebSocket pipeline 经 ChatTurnService 与显式 DTO 编排单轮对话;回忆录片段入队由 MemoirIngestScheduler 封装 - 新增 LlmGateway(LlmUseCase),各 agent、任务与适配器对齐 ports - 补充 memory 提示适配、runtime 类型、memory-retrieval 文档、ai-touchpoints 说明与扫描脚本及配套测试 Made-with: Cursor
This commit is contained in:
@@ -23,6 +23,11 @@ from app.core.config import settings
|
||||
from app.core.cos_url_keys import TTS_PRESIGNED_EXPIRES_SEC
|
||||
from app.core.db import AsyncSessionLocal
|
||||
from app.core.dependencies import get_asr_provider, get_object_storage, get_tts_provider
|
||||
from app.features.conversation.chat_turn import (
|
||||
ChatTurnContext,
|
||||
ChatTurnInput,
|
||||
ChatTurnService,
|
||||
)
|
||||
from app.features.conversation.history_store import (
|
||||
AI_RESPONSE_SEGMENT_JOIN,
|
||||
ConversationHistoryStore,
|
||||
@@ -37,6 +42,7 @@ from app.features.conversation.ws.profile_collector import (
|
||||
get_missing_profile_fields,
|
||||
)
|
||||
from app.features.memoir.background_runner import BackgroundTaskRunner
|
||||
from app.features.memoir.ingest_scheduler import MemoirIngestScheduler
|
||||
from app.features.user.models import User
|
||||
from app.ports.asr import ASRTranscriptionError
|
||||
|
||||
@@ -134,7 +140,9 @@ async def _send_tts_audio(
|
||||
|
||||
# ── Agent 实例(从 ConnectionManager 移出) ─────────────────────
|
||||
chat_orchestrator = ChatOrchestrator()
|
||||
background_runner = BackgroundTaskRunner()
|
||||
chat_turn_service = ChatTurnService(chat_orchestrator)
|
||||
_background_runner = BackgroundTaskRunner()
|
||||
memoir_ingest_scheduler = MemoirIngestScheduler(_background_runner)
|
||||
|
||||
|
||||
# ── 分段流状态 ──────────────────────────────────────────────────
|
||||
@@ -573,7 +581,7 @@ async def process_audio_segment(
|
||||
user_message_timestamp = _mark_conversation_active(conversation)
|
||||
await db.commit()
|
||||
await db.refresh(segment)
|
||||
await background_runner.queue_message(
|
||||
await memoir_ingest_scheduler.queue_segment(
|
||||
conversation.user_id,
|
||||
segment.id,
|
||||
text_char_count=len((transcript_text or "").strip()),
|
||||
@@ -655,19 +663,24 @@ async def process_user_message(
|
||||
voice_session_id = _voice_session_id_from_audio_url(segment.audio_url)
|
||||
audio_dur = getattr(segment, "audio_duration_seconds", None)
|
||||
t_pipeline = time.perf_counter()
|
||||
turn = await chat_orchestrator.process_user_message(
|
||||
conversation_id=conversation_id,
|
||||
user_message=user_message,
|
||||
user=user,
|
||||
conversation=conversation,
|
||||
is_from_voice=is_from_voice,
|
||||
voice_session_id=voice_session_id,
|
||||
db=db,
|
||||
apply_extracted_profile_fn=apply_extracted_profile,
|
||||
get_missing_profile_fields_fn=get_missing_profile_fields,
|
||||
get_filled_profile_fields_fn=get_filled_profile_fields,
|
||||
user_message_timestamp=user_message_timestamp,
|
||||
audio_duration_seconds=audio_dur,
|
||||
turn = await chat_turn_service.process_turn(
|
||||
ChatTurnInput(
|
||||
conversation_id=conversation_id,
|
||||
user_message=user_message,
|
||||
is_from_voice=is_from_voice,
|
||||
voice_session_id=voice_session_id,
|
||||
user_message_timestamp=user_message_timestamp,
|
||||
audio_duration_seconds=audio_dur,
|
||||
force_skip_tts=force_skip_tts,
|
||||
),
|
||||
ChatTurnContext(
|
||||
db=db,
|
||||
user=user,
|
||||
conversation=conversation,
|
||||
apply_extracted_profile_fn=apply_extracted_profile,
|
||||
get_missing_profile_fields_fn=get_missing_profile_fields,
|
||||
get_filled_profile_fields_fn=get_filled_profile_fields,
|
||||
),
|
||||
)
|
||||
if agent_summary_enabled():
|
||||
logger.info(
|
||||
@@ -682,7 +695,7 @@ async def process_user_message(
|
||||
turn.skip_tts,
|
||||
)
|
||||
responses = turn.messages
|
||||
skip_tts = bool(turn.skip_tts or force_skip_tts)
|
||||
skip_tts = bool(turn.skip_tts)
|
||||
|
||||
segment.agent_response = AI_RESPONSE_SEGMENT_JOIN.join(responses)
|
||||
_mark_conversation_active(conversation)
|
||||
@@ -696,7 +709,7 @@ async def process_user_message(
|
||||
audio_duration_seconds=audio_dur,
|
||||
tts_audio_urls=None,
|
||||
segment_id=segment.id,
|
||||
memory_retrieval_trace=getattr(turn, "memory_retrieval_trace", None),
|
||||
memory_retrieval_trace=turn.memory_retrieval_trace,
|
||||
)
|
||||
if not turn_ids:
|
||||
logger.warning(
|
||||
@@ -823,7 +836,7 @@ async def process_conversation_segments(
|
||||
"""
|
||||
对话结束时:把本对话仍待 Phase1 的段落交给回忆录管线。
|
||||
|
||||
经 `BackgroundTaskRunner.flush_pending` 将内存防抖 batch 与当前查询到的
|
||||
经 `MemoirIngestScheduler.flush_pending` 将内存防抖 batch 与当前查询到的
|
||||
`topic_category IS NULL` 段 ID 合并、去重后**单次**提交 `process_memoir_phase1`,
|
||||
并在 flush 末尾触发待叙事 Phase2 派发;避免会话结束路径与 debounce flush 双发 Phase1。
|
||||
|
||||
@@ -842,7 +855,10 @@ async def process_conversation_segments(
|
||||
segments = result.scalars().all()
|
||||
|
||||
if not segments:
|
||||
await background_runner.flush_pending(conversation.user_id)
|
||||
await memoir_ingest_scheduler.flush_pending(
|
||||
conversation.user_id,
|
||||
trigger="conversation_end",
|
||||
)
|
||||
return
|
||||
|
||||
user = await db.get(User, conversation.user_id)
|
||||
@@ -854,13 +870,18 @@ async def process_conversation_segments(
|
||||
logger.info(
|
||||
f"用户 {user.id} 章节配额已用尽,跳过提交整理任务: conversation_id={conversation_id}"
|
||||
)
|
||||
await background_runner.flush_pending(conversation.user_id)
|
||||
await memoir_ingest_scheduler.flush_pending(
|
||||
conversation.user_id,
|
||||
trigger="conversation_end",
|
||||
)
|
||||
return
|
||||
|
||||
segment_ids = [seg.id for seg in segments]
|
||||
try:
|
||||
await background_runner.flush_pending(
|
||||
conversation.user_id, extra_segment_ids=segment_ids
|
||||
await memoir_ingest_scheduler.flush_pending(
|
||||
conversation.user_id,
|
||||
extra_segment_ids=segment_ids,
|
||||
trigger="conversation_end",
|
||||
)
|
||||
logger.info(
|
||||
"对话结束,合并批内 segment 与 DB 待分类段,单次提交 Phase1: "
|
||||
|
||||
Reference in New Issue
Block a user