Files
life-echo/api/app/features/memory/summarizer.py

140 lines
5.2 KiB
Python
Raw Normal View History

"""会话摘要与滚动摘要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()