2026-03-27 16:01:28 +08:00
|
|
|
|
"""会话摘要与滚动摘要(LLM + JSON)。"""
|
2026-03-18 17:18:23 +08:00
|
|
|
|
|
2026-03-27 16:01:28 +08:00
|
|
|
|
from __future__ import annotations
|
2026-03-18 17:18:23 +08:00
|
|
|
|
|
2026-03-27 16:01:28 +08:00
|
|
|
|
from typing import Any
|
2026-03-18 17:18:23 +08:00
|
|
|
|
|
2026-03-27 16:01:28 +08:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
|
2026-03-27 16:01:28 +08:00
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
|
2026-04-03 10:12:59 +08:00
|
|
|
|
_ROLLING_SUMMARY_MERGE_RULES_ZH = (
|
|
|
|
|
|
"若新材料与已有摘要在同一人物或事件上存在明显事实冲突(如阵亡与在世、牺牲与退休、军衔或驻地去向矛盾),"
|
|
|
|
|
|
"以新材料为准,删除或改写旧摘要中的矛盾句;不得把两处矛盾信息拼接成一句。"
|
|
|
|
|
|
"不得将两则无因果关联的信息强行合成因果关系。"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-27 16:01:28 +08:00
|
|
|
|
|
|
|
|
|
|
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"
|
2026-04-03 10:12:59 +08:00
|
|
|
|
f"{_ROLLING_SUMMARY_MERGE_RULES_ZH}\n"
|
2026-03-27 16:01:28 +08:00
|
|
|
|
'只输出 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"
|
2026-04-03 10:12:59 +08:00
|
|
|
|
f"{_ROLLING_SUMMARY_MERGE_RULES_ZH}\n"
|
2026-03-27 16:01:28 +08:00
|
|
|
|
'只输出 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()
|