feat(memory,conversation): 记忆富化/证据包、时间线幂等字段与对话分段全链路

数据库
- 新增迁移 0003:timeline_events.memory_source_id 外键 → memory_sources,便于按 ingest 源做时间线幂等

后端 - 记忆
- 新增 ingest 后 LLM 富化(摘要/事实/时间线),可配置开关与最大字符数
- 新增证据包组装:合并 chunk、摘要、事实、时间线、故事等检索结果;支持空 query 时是否仍带 rolling 等开关
- repo/retriever/service/router/schemas/summarizer/timeline/extractor 等扩展;文档 memory-retrieval.md 更新

后端 - 对话 WS
- 增加 PING/PONG;分段 ASR 日志与空音频处理;转写失败与「无助手回复」错误提示更明确
- 助手多段回复持久化使用统一分隔符,与分段逻辑一致

后端 - Agent
- reply_limits:按 [SPLIT] 与段落拆段,并保证非空 fallback,供 WS 与 TTS 多段下发

后端 - 回忆录任务
- transcript ingest 记录 source_id;任务成功结?
This commit is contained in:
Kevin
2026-03-27 16:01:28 +08:00
parent 1374f6e8f5
commit e4bf0710c7
70 changed files with 3404 additions and 557 deletions

View File

@@ -22,7 +22,10 @@ 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.history_store import ConversationHistoryStore
from app.features.conversation.history_store import (
AI_RESPONSE_SEGMENT_JOIN,
ConversationHistoryStore,
)
from app.features.conversation.models import Conversation, Segment
from app.features.conversation.ws.connection_manager import manager
from app.features.conversation.ws.message_types import MessageType
@@ -369,6 +372,16 @@ async def process_audio_segment(
) -> None:
"""分段语音的异步处理:并行 ASR + 幂等落库 + 有序聚合触发 Agent。"""
state = get_or_create_segment_state(conversation_id, voice_session_id)
logger.info(
"process_audio_segment 开始: conversation_id={} voice_session_id={} "
"segment_index={} is_last={} duration_s={} audio_b64_len={}",
conversation_id,
voice_session_id,
segment_index,
is_last,
audio_duration,
len(audio_base64 or ""),
)
try:
async with AsyncSessionLocal() as db:
@@ -420,6 +433,12 @@ async def process_audio_segment(
audio_bytes = base64.b64decode(audio_base64)
except Exception:
audio_bytes = b""
if not audio_bytes:
logger.warning(
"process_audio_segment: 解码后音频为空 conversation_id={} segment_index={}",
conversation_id,
segment_index,
)
transcript_text = await get_asr_provider().transcribe(
audio_bytes, format="m4a"
)
@@ -440,12 +459,19 @@ async def process_audio_segment(
)
if _is_transcribe_failure(transcript_text):
detail = (transcript_text or "").strip()
if detail.startswith("转写失败"):
user_msg = f"分段 {segment_index} {detail}"
elif not detail:
user_msg = f"分段 {segment_index} 转写失败:未识别到内容(请检查后端 ASR 配置)"
else:
user_msg = f"分段 {segment_index} 转写失败:{detail[:400]}"
await manager.send_message(
conversation_id,
{
"type": MessageType.ERROR,
"data": {
"message": f"分段 {segment_index} 转写失败,请重试该片段",
"message": user_msg,
"segment_index": segment_index,
},
"timestamp": datetime.now(timezone.utc).isoformat(),
@@ -553,6 +579,12 @@ async def process_user_message(
store = ConversationHistoryStore(db)
tts_urls: list[str] = []
try:
logger.info(
"process_user_message 开始: conversation_id={} segment_id={} user_chars={}",
conversation_id,
segment.id,
len(user_message or ""),
)
is_from_voice = bool(segment.audio_url)
voice_session_id = _voice_session_id_from_audio_url(segment.audio_url)
audio_dur = getattr(segment, "audio_duration_seconds", None)
@@ -586,7 +618,7 @@ async def process_user_message(
responses = turn.messages
skip_tts = turn.skip_tts
segment.agent_response = "\n\n".join(responses)
segment.agent_response = AI_RESPONSE_SEGMENT_JOIN.join(responses)
_mark_conversation_active(conversation)
ai_msg_id = await store.record_human_ai_turn(
conversation_id=conversation_id,
@@ -600,6 +632,22 @@ async def process_user_message(
segment_id=segment.id,
)
if not ai_msg_id:
logger.warning(
"process_user_message: 无有效助手段落responses 为空conversation_id={} segment_id={}",
conversation_id,
segment.id,
)
if conversation_id in manager.active_connections:
await manager.send_message(
conversation_id,
{
"type": MessageType.ERROR,
"data": {
"message": "未生成回复,请重试或稍后再试",
},
"timestamp": datetime.now(timezone.utc).isoformat(),
},
)
return
tts_epoch_start = _tts_epoch_value(conversation_id)
@@ -614,6 +662,7 @@ async def process_user_message(
"text": response_text,
"index": i,
"total": n,
"assistant_message_id": ai_msg_id,
},
"timestamp": datetime.now(timezone.utc).isoformat(),
},