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

@@ -1,16 +1,16 @@
"""
ClassificationAgent将内容分类到 8 个章节类别,或判定无价值返回 None
对应现有逻辑_classify_chapter_category
ClassificationAgent将内容分类到 8 个章节类别之一
返回 None 表示本段不进入回忆录 Story/章节流水线;与 StoryRoute 中「可独立讲述的一段人生经历」
(见 prompts.get_story_route_prompt在标准上对齐零散档案点不进 Story记忆与 slot 抽取仍由上游完成
原「LLM 返回 none / 零散档案启发式」不再跳过 Story统一映射为 ``summary`` 章节,
仍走叙事流水线落库;与 StoryRoute 仍兼容(批次内 new/append 规划不变)
Memory ingest 由 Celery 任务在批次级先行完成,与分类结果独立。
"""
from __future__ import annotations
import json
import re
from typing import Any, Optional
from typing import Any
from app.agents.memoir.prompts import (
CHAPTER_CATEGORIES,
@@ -22,6 +22,9 @@ from app.features.memoir.memoir_images.json_payload import extract_json_payload
logger = get_logger(__name__)
# 模型判定 none 或启发式命中零散档案时,仍写入回忆录正文所用的兜底章节
_SUMMARY_FALLBACK_CATEGORY = "summary"
# 与「仅档案句式」组合使用;过短但明显为叙事句的仍交 LLM 判断
_FRAGMENT_SHORT_MAX_LEN = 48
@@ -67,8 +70,8 @@ def _detect_stage(text: str, fallback_stage: str) -> str:
def _looks_like_fragment_only(text: str) -> bool:
"""
保守启发式:明显为档案点/标签句,不足以作为 Story 叙事单元
与 get_chapter_classification_prompt 中「应返回 none」的情形一致误判风险通过窄正则控制
保守启发式:明显为档案点/标签句。
命中时仍进回忆录正文,章节映射为 ``summary``(与 LLM 返回 none 一致)
"""
s = (text or "").strip()
if not s:
@@ -107,26 +110,30 @@ def _parse_category_from_llm_response(raw: str) -> str:
class ClassificationAgent:
"""将内容分类到 8 个章节类别之一,或判定无价值返回 None"""
"""将内容分类到 8 个章节类别之一none/零散档案映射为 ``summary`` 仍进 Story。"""
def classify(
self,
text: str,
fallback_stage: str,
llm: Any,
) -> Optional[str]:
*,
segment_id: str | None = None,
) -> str:
"""
分类到 8 个章节类别之一。
LLM 判定内容不足以独立成篇(none或启发式判定为零散档案,返回 None
LLM 返回 none 或启发式为零散档案,返回 ``summary``(仍走回忆录流水线)
llm 需支持 .invoke(prompt) 同步调用。
"""
if _looks_like_fragment_only(text):
logger.debug(
"零散档案/极短标签句,跳过回忆录 Story: text_len={} text={}",
logger.info(
"event=chapter_classification_summary_fallback reason=fragment_heuristic "
"segment_id={} text_len={} category={}",
segment_id or "",
len(text or ""),
text or "",
_SUMMARY_FALLBACK_CATEGORY,
)
return None
return _SUMMARY_FALLBACK_CATEGORY
if llm:
try:
@@ -139,12 +146,14 @@ class ClassificationAgent:
)
category = _parse_category_from_llm_response(raw)
if category == "none":
logger.debug(
"LLM 判定内容不足以成篇,跳过: text_len={} text={}",
logger.info(
"event=chapter_classification_summary_fallback reason=llm_none "
"segment_id={} text_len={} category={}",
segment_id or "",
len(text or ""),
text or "",
_SUMMARY_FALLBACK_CATEGORY,
)
return None
return _SUMMARY_FALLBACK_CATEGORY
if category in CHAPTER_CATEGORIES:
return category
except Exception as e: