- 新增 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 文档与多项回归/门控测试
140 lines
5.2 KiB
Python
140 lines
5.2 KiB
Python
"""会话摘要与滚动摘要(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 = (
|
||
"用 2~8 句中文概括下列口述/对话要点,不编造、不评价。只输出 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 = (
|
||
"用 2~8 句中文概括下列口述/对话要点,不编造、不评价。只输出 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()
|