# ============================================================ # 滚动摘要 · 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