Files
life-echo/api/app/features/memory/summarizer.py
Kevin 07c6478742 feat(api): 访谈路径轻量门控、Memoir Phase1 批处理与叙事/记忆管线加固
- 新增 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 文档与多项回归/门控测试
2026-04-03 10:12:59 +08:00

140 lines
5.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""会话摘要与滚动摘要LLM + JSON"""
from __future__ import annotations
from typing import Any
from app.core.langchain_llm import ainvoke_json_object, invoke_json_object
from app.core.logging import get_logger
from app.features.memory.llm_schemas import (
RollingSummaryPayload,
SessionSummaryPayload,
parse_json_payload,
)
logger = get_logger(__name__)
_ROLLING_SUMMARY_MERGE_RULES_ZH = (
"若新材料与已有摘要在同一人物或事件上存在明显事实冲突(如阵亡与在世、牺牲与退休、军衔或驻地去向矛盾),"
"以新材料为准,删除或改写旧摘要中的矛盾句;不得把两处矛盾信息拼接成一句。"
"不得将两则无因果关联的信息强行合成因果关系。"
)
def _max_input_chars() -> int:
from app.core.config import settings
return settings.memory_enrichment_max_chars
def generate_session_summary_sync(llm: Any, chunk_texts: list[str]) -> str:
"""为本批块生成 session 级短摘要。"""
if not llm:
return ""
lim = _max_input_chars()
combined = "\n\n".join(t for t in chunk_texts if t).strip()[:lim]
if not combined:
return ""
prompt = (
"用 28 句中文概括下列口述/对话要点,不编造、不评价。只输出 JSON"
'{"summary":"..."}\n\n文本:\n'
f"{combined}"
)
try:
raw = invoke_json_object(
llm, prompt, max_tokens=2048, agent="memory.session_summary_sync"
)
parsed = parse_json_payload(raw, SessionSummaryPayload)
if parsed is None:
return ""
return str(parsed.summary or "").strip()
except (TypeError, ValueError) as e:
logger.warning("generate_session_summary_sync 失败: {}", e)
return ""
async def generate_session_summary_async(llm: Any, chunk_texts: list[str]) -> str:
if not llm:
return ""
lim = _max_input_chars()
combined = "\n\n".join(t for t in chunk_texts if t).strip()[:lim]
if not combined:
return ""
prompt = (
"用 28 句中文概括下列口述/对话要点,不编造、不评价。只输出 JSON"
'{"summary":"..."}\n\n文本:\n'
f"{combined}"
)
try:
raw = await ainvoke_json_object(
llm, prompt, max_tokens=2048, agent="memory.session_summary_async"
)
parsed = parse_json_payload(raw, SessionSummaryPayload)
if parsed is None:
return ""
return str(parsed.summary or "").strip()
except (TypeError, ValueError) as e:
logger.warning("generate_session_summary_async 失败: {}", e)
return ""
def generate_rolling_summary_sync(
llm: Any, existing_summary: str | None, new_chunk_texts: list[str]
) -> str:
"""合并已有滚动摘要与新材料。"""
if not llm:
return (existing_summary or "").strip()
lim = _max_input_chars()
new_t = "\n\n".join(t for t in new_chunk_texts if t).strip()[:lim]
if not new_t and not (existing_summary or "").strip():
return ""
ex = (existing_summary or "").strip()[:lim]
prompt = (
"将「已有滚动摘要」与「新材料」合并为更新后的滚动摘要(中文,段落)。"
"保留人物与时间线索;不编造;可省略无关细节。\n"
f"{_ROLLING_SUMMARY_MERGE_RULES_ZH}\n"
'只输出 JSON{"rolling_summary":"..."}\n\n'
f"【已有摘要】\n{ex}\n\n【新材料】\n{new_t}"
)
try:
raw = invoke_json_object(
llm, prompt, max_tokens=3072, agent="memory.rolling_summary_sync"
)
parsed = parse_json_payload(raw, RollingSummaryPayload)
if parsed is None:
return (existing_summary or "").strip()
return str(parsed.rolling_summary or "").strip()
except (TypeError, ValueError) as e:
logger.warning("generate_rolling_summary_sync 失败: {}", e)
return (existing_summary or "").strip()
async def generate_rolling_summary_async(
llm: Any, existing_summary: str | None, new_chunk_texts: list[str]
) -> str:
if not llm:
return (existing_summary or "").strip()
lim = _max_input_chars()
new_t = "\n\n".join(t for t in new_chunk_texts if t).strip()[:lim]
if not new_t and not (existing_summary or "").strip():
return ""
ex = (existing_summary or "").strip()[:lim]
prompt = (
"将「已有滚动摘要」与「新材料」合并为更新后的滚动摘要(中文,段落)。"
"保留人物与时间线索;不编造。\n"
f"{_ROLLING_SUMMARY_MERGE_RULES_ZH}\n"
'只输出 JSON{"rolling_summary":"..."}\n\n'
f"【已有摘要】\n{ex}\n\n【新材料】\n{new_t}"
)
try:
raw = await ainvoke_json_object(
llm, prompt, max_tokens=3072, agent="memory.rolling_summary_async"
)
parsed = parse_json_payload(raw, RollingSummaryPayload)
if parsed is None:
return (existing_summary or "").strip()
return str(parsed.rolling_summary or "").strip()
except (TypeError, ValueError) as e:
logger.warning("generate_rolling_summary_async 失败: {}", e)
return (existing_summary or "").strip()