285 lines
12 KiB
Python
285 lines
12 KiB
Python
# ============================================================
|
||
# 滚动摘要 · LLM 压缩生成方(moduleId: core-summarizer, 可重生 ✅)
|
||
# 矩阵 64 行剩余项:滚动摘要的 LLM 压缩生成方接线 + 越权上下文测试。
|
||
# - compress_scroll_summary:固定中文提示词 -> ModelProvider.chat_text 压缩;
|
||
# provider 不可用/超时/异常/输出为空/超预算 -> 确定性回退
|
||
# (deterministic_compress,复用 estimate_tokens 预算口径截断)。
|
||
# - maybe_roll_session_summary:每轮对话后的预算门控接线(fail closed)。
|
||
# 绝不编造:提示词限定只基于给定消息;任何异常输出都回退确定性截断;
|
||
# 压缩结果经 scroll_summary 入库,sourceMessageIds/supersedes 可溯源。
|
||
# ============================================================
|
||
from __future__ import annotations
|
||
|
||
import inspect
|
||
import re
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime
|
||
from typing import Any
|
||
|
||
from server.agent_core.context_policy import (
|
||
DEFAULT_WINDOW,
|
||
ContextBudget,
|
||
ContextBudgetExceeded,
|
||
assemble_context,
|
||
estimate_tokens,
|
||
get_policy,
|
||
scroll_summary,
|
||
)
|
||
|
||
# 固定中文提示词:只允许压缩给定消息,禁止编造。
|
||
SYSTEM_PROMPT = (
|
||
"你是一名 APS 排产助手的长会话压缩器。你的唯一任务:把给定的对话消息压缩成"
|
||
"一段结构化中文摘要。\n"
|
||
"铁律:\n"
|
||
"1. 只能使用给定对话里出现过的内容——事实、数字、决策、未决事项、验收标准;\n"
|
||
"2. 禁止编造、补全或推断给定对话中不存在的信息;\n"
|
||
"3. 输出纯文本摘要正文,不要输出 JSON、markdown 代码块、消息编号或任何解释;\n"
|
||
"4. 必须保留:用户的验收标准与硬性要求、已做决策及理由、未决事项、关键数字、"
|
||
"当前进度;\n"
|
||
"5. 长度不得超过给定预算(中文 1 字 ≈ 1 token 估算),宁可保守删减细节,不可超长。"
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class SummaryCompression:
|
||
"""压缩结果:摘要文本 + 被压缩消息 id 列表 + 生成方标记与回退原因。"""
|
||
|
||
text: str
|
||
compressed_message_ids: list[str] = field(default_factory=list)
|
||
mode: str = "deterministic" # "llm" | "deterministic"
|
||
reason: str | None = None # 仅回退时记录原因(审计/测试可检查)
|
||
|
||
|
||
def _normalize_message(message: Any, idx: int) -> dict[str, Any] | None:
|
||
"""与 context_policy._normalize_message 同口径:只保留 user/agent 且有文本的消息。"""
|
||
if not isinstance(message, dict):
|
||
return None
|
||
role = str(message.get("role") or "")
|
||
if role in ("assistant", "bot", "ai"):
|
||
role = "agent"
|
||
if role not in ("user", "agent"):
|
||
return None
|
||
text = str(message.get("text") or message.get("content") or "").strip()
|
||
if not text:
|
||
return None
|
||
return {
|
||
"messageId": str(message.get("messageId") or message.get("id") or f"m{idx}"),
|
||
"role": role,
|
||
"text": text,
|
||
}
|
||
|
||
|
||
def _normalize_messages(messages: list[Any]) -> list[dict[str, Any]]:
|
||
return [
|
||
item for item in (
|
||
_normalize_message(message, idx)
|
||
for idx, message in enumerate(messages)
|
||
)
|
||
if item is not None
|
||
]
|
||
|
||
|
||
def build_compression_prompt(messages: list[dict[str, Any]], budget_tokens: int) -> str:
|
||
"""固定中文提示词模板:把给定消息原样呈现给 LLM,禁止引用外部信息。"""
|
||
lines = [
|
||
f"预算:摘要估算 token 不得超过 {budget_tokens}。",
|
||
"",
|
||
"需要压缩的对话(按时间顺序):",
|
||
"",
|
||
]
|
||
for idx, message in enumerate(messages, start=1):
|
||
who = "用户" if message.get("role") == "user" else "助手"
|
||
lines.append(f"[消息{idx}] {who}:{message.get('text')}")
|
||
lines.extend(["", "请只输出压缩后的摘要正文:"])
|
||
return "\n".join(lines)
|
||
|
||
|
||
def deterministic_compress(messages: list[Any], budget_tokens: int) -> SummaryCompression:
|
||
"""确定性回退:复用 estimate_tokens 预算口径,从最旧开始保留直到超预算。
|
||
|
||
只使用给定消息内容(绝不引入外部信息);输出可溯源到消息 id。
|
||
"""
|
||
if budget_tokens <= 0:
|
||
raise ValueError("budget_tokens must be positive")
|
||
normalized = _normalize_messages(messages)
|
||
if not normalized:
|
||
raise ValueError("no compressible messages")
|
||
message_ids = [m["messageId"] for m in normalized]
|
||
lines = [f"{'用户' if m['role'] == 'user' else '助手'}:{m['text']}" for m in normalized]
|
||
kept: list[str] = []
|
||
for line in lines:
|
||
candidate = "\n".join([*kept, line])
|
||
if estimate_tokens(candidate) > budget_tokens:
|
||
break
|
||
kept.append(line)
|
||
text = "\n".join(kept).strip()
|
||
if not text:
|
||
# 单条都放不下:对最新一条做预算内强制截断,保证非空回退。
|
||
text = lines[-1]
|
||
while estimate_tokens(text) > budget_tokens and len(text) > 8:
|
||
text = text[:-8].rstrip()
|
||
return SummaryCompression(
|
||
text=text,
|
||
compressed_message_ids=message_ids,
|
||
mode="deterministic",
|
||
reason="budget-truncation",
|
||
)
|
||
|
||
|
||
_FENCE_RE = re.compile(r"```(?:json|text)?\s*([\s\S]*?)```", re.IGNORECASE)
|
||
|
||
|
||
def _strip_fence(text: str) -> str:
|
||
"""容错:剥离 markdown 代码块围栏(部分国产端点会在文本外套 ```)。"""
|
||
match = _FENCE_RE.search(text)
|
||
return (match.group(1) if match else text).strip()
|
||
|
||
|
||
def _chat_accepts_timeout(provider: Any) -> bool:
|
||
try:
|
||
return "timeout" in inspect.signature(provider.chat_text).parameters
|
||
except (TypeError, ValueError):
|
||
return True # 拿不到签名时按完整签名处理
|
||
|
||
|
||
async def _provider_chat(provider: Any, system: str, user: str, timeout: float) -> str | None:
|
||
if _chat_accepts_timeout(provider):
|
||
return await provider.chat_text(system, user, timeout=timeout)
|
||
return await provider.chat_text(system, user)
|
||
|
||
|
||
async def compress_scroll_summary(
|
||
messages: list[dict[str, Any]], *,
|
||
provider: Any, budget_tokens: int = 600, timeout: float = 45.0,
|
||
) -> SummaryCompression:
|
||
"""LLM 压缩生成方:固定中文提示词 + provider.chat_text(ModelProvider 接口)。
|
||
|
||
- 只允许压缩给定消息内容:提示词限定 + 输出校验 + 异常回退,绝不编造。
|
||
- provider 不可用(未启用/返回 None)/ 超时 / 异常 / 输出为空 / 输出超预算
|
||
-> 确定性回退 deterministic_compress(复用 estimate_tokens 预算截断)。
|
||
Returns:
|
||
SummaryCompression{text, compressedMessageIds, mode, reason}
|
||
Raises:
|
||
ValueError: 无可压缩消息或 budget_tokens <= 0(fail closed)
|
||
"""
|
||
normalized = _normalize_messages(messages)
|
||
if not normalized:
|
||
raise ValueError("compress_scroll_summary: no compressible messages")
|
||
if budget_tokens <= 0:
|
||
raise ValueError("compress_scroll_summary: budget_tokens must be positive")
|
||
message_ids = [m["messageId"] for m in normalized]
|
||
|
||
def fallback(reason: str) -> SummaryCompression:
|
||
compressed = deterministic_compress(normalized, budget_tokens)
|
||
return SummaryCompression(
|
||
text=compressed.text,
|
||
compressed_message_ids=message_ids,
|
||
mode="deterministic",
|
||
reason=reason,
|
||
)
|
||
|
||
raw: str | None = None
|
||
reason: str | None = None
|
||
try:
|
||
raw = await _provider_chat(
|
||
provider,
|
||
SYSTEM_PROMPT,
|
||
build_compression_prompt(normalized, budget_tokens),
|
||
timeout,
|
||
)
|
||
except Exception as exc: # noqa: BLE001 - provider 异常一律回退,绝不阻断
|
||
reason = f"provider-error:{type(exc).__name__}"
|
||
if raw is None:
|
||
return fallback(reason or "provider-unavailable")
|
||
text = _strip_fence(str(raw))
|
||
if not text:
|
||
return fallback("llm-empty-output")
|
||
if estimate_tokens(text) > budget_tokens:
|
||
return fallback("llm-over-budget")
|
||
return SummaryCompression(
|
||
text=text,
|
||
compressed_message_ids=message_ids,
|
||
mode="llm",
|
||
reason=None,
|
||
)
|
||
|
||
|
||
async def maybe_roll_session_summary(
|
||
store_data: dict[str, Any], session_id: str, *,
|
||
provider: Any | None = None,
|
||
budget: ContextBudget | None = None,
|
||
window: int = DEFAULT_WINDOW,
|
||
auto_summarize: bool | None = None,
|
||
by: str | None = None,
|
||
now: datetime | None = None,
|
||
) -> dict[str, Any] | None:
|
||
"""每轮对话后的滚动摘要钩子:预算门控 + LLM 压缩(fail closed)。
|
||
|
||
触发条件(预算门控):
|
||
- 会话原始消息(未钉住)估算 token 超过会话层预算(长会话),或
|
||
- 当前会话层组装已超预算(layer == "session")。
|
||
满足时,把「滑出窗口且未被既有摘要覆盖」的消息交给 compress_scroll_summary
|
||
(LLM 压缩;异常/超时/输出不合格 -> 确定性回退),再经 scroll_summary 入库
|
||
(sourceMessageIds + supersedes 溯源链,compressedBy 标记生成方)。
|
||
滚动后会话层仍超预算 -> ContextBudgetExceeded(可预测拒绝,fail closed)。
|
||
|
||
auto_summarize: None -> 读会话策略 contextPolicies[session].autoSummarize,
|
||
缺省 True(长会话默认开启);False -> 永不滚动。
|
||
Returns:
|
||
新摘要 dict(发生滚动)或 None(未触发/被禁用)。
|
||
Raises:
|
||
ContextBudgetExceeded: 滚动后仍超预算(可预测拒绝)
|
||
ValueError: 会话/项目不可访问(越权 fail closed)
|
||
"""
|
||
budget = budget or ContextBudget()
|
||
session_limit = budget.layer_limit("session")
|
||
policy = get_policy(store_data, session_id)
|
||
enabled = policy.get("autoSummarize", True) if auto_summarize is None else auto_summarize
|
||
if not enabled:
|
||
return None
|
||
|
||
normalized = _normalize_messages((store_data.get("messages") or {}).get(session_id) or [])
|
||
pinned_ids = {str(item.get("messageId")) for item in policy.get("pinned") or []}
|
||
raw_history = [m for m in normalized if m["messageId"] not in pinned_ids]
|
||
if not raw_history:
|
||
return None
|
||
raw_tokens = estimate_tokens("\n".join(m["text"] for m in raw_history))
|
||
|
||
layer_over = False
|
||
try:
|
||
assemble_context(store_data, session_id, None, budget, window=window)
|
||
except ContextBudgetExceeded as exc:
|
||
if exc.layer != "session":
|
||
raise
|
||
layer_over = True
|
||
if raw_tokens <= session_limit and not layer_over:
|
||
return None
|
||
|
||
covered: set[str] = set()
|
||
for entry in [policy.get("summary"), *(policy.get("summaries") or [])]:
|
||
if isinstance(entry, dict):
|
||
covered.update(str(sid) for sid in (entry.get("sourceMessageIds") or []))
|
||
recent_ids = {m["messageId"] for m in normalized[-window:]}
|
||
eligible = [
|
||
m for m in raw_history
|
||
if m["messageId"] not in recent_ids and m["messageId"] not in covered
|
||
]
|
||
if not eligible:
|
||
if layer_over:
|
||
# 无可压缩消息但会话层仍超预算:可预测拒绝(fail closed)。
|
||
assemble_context(store_data, session_id, None, budget, window=window)
|
||
return None
|
||
|
||
summary_budget = max(1, session_limit // 2)
|
||
compression = await compress_scroll_summary(
|
||
eligible, provider=provider, budget_tokens=summary_budget,
|
||
)
|
||
new_summary = scroll_summary(
|
||
store_data, session_id, compression.text,
|
||
source_message_ids=compression.compressed_message_ids,
|
||
by=by or "planner", now=now, summarizer=compression.mode,
|
||
)
|
||
# 滚动后验证:会话层仍超预算 -> ContextBudgetExceeded(可预测拒绝)。
|
||
assemble_context(store_data, session_id, None, budget, window=window)
|
||
return new_summary
|