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:
Kevin
2026-04-30 09:17:01 +08:00
parent eddb2c3078
commit ac436b87a2
37 changed files with 1400 additions and 199 deletions

View File

@@ -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: "