# ============================================================ # 黄金测试:滚动摘要 LLM 压缩生成方(矩阵 64 行剩余项 · 方向 L) # stub provider:压缩成功 / 确定性回退 / 溯源链 / 预算门控(fail closed) # ============================================================ from __future__ import annotations import pytest from server.agent_core.context_policy import ( ContextBudget, ContextBudgetExceeded, assemble_context, estimate_tokens, get_policy, pin_message, scroll_summary, ) from server.agent_core.summarizer import ( SYSTEM_PROMPT, SummaryCompression, build_compression_prompt, compress_scroll_summary, deterministic_compress, maybe_roll_session_summary, ) class StubProvider: """可注入的 stub provider:记录调用;按配置返回/抛错/禁用。""" def __init__(self, result: str | None = None, *, error: Exception | None = None, disabled: bool = False): self.result = result self.error = error self.disabled = disabled self.calls: list[dict] = [] async def chat_text(self, system: str, user: str, timeout: float = 45.0) -> str | None: self.calls.append({"system": system, "user": user, "timeout": timeout}) if self.error is not None: raise self.error if self.disabled: return None return self.result def _messages(n: int = 30, repeat: int = 15) -> list[dict]: """构造长会话消息:每条约 4 + 9*repeat 个 CJK token。""" unit = "插单交期优先确认内容" # 9 个 CJK 字符 ≈ 9 token return [ {"id": f"m{i}", "role": "user" if i % 2 == 0 else "agent", "text": f"第{i}条" + unit * repeat} for i in range(n) ] def _store_data(messages: list[dict] | None = None) -> dict: return { "systemPrompt": "你是 APS 排产智能体:提议权,写操作必须经门禁。", "projects": [{ "id": "p1", "name": "青岛Q3排产", "scopeLabel": "青岛工厂/总装车间", "sharedContext": ["V3 参数版本"], "archived": False, }], "sessions": [{"id": "s1", "projectId": "p1", "title": "本周主排产", "status": "running"}], "messages": {"s1": messages if messages is not None else _messages()}, } # ---------------- compress_scroll_summary:stub provider ---------------- async def test_llm_compress_success(): msgs = _messages(n=5, repeat=2) stub = StubProvider(result="插单按交期优先确认;产能足够。") out = await compress_scroll_summary(msgs, provider=stub, budget_tokens=1000) assert isinstance(out, SummaryCompression) assert out.mode == "llm" assert out.text == "插单按交期优先确认;产能足够。" assert out.compressed_message_ids == ["m0", "m1", "m2", "m3", "m4"] assert out.reason is None # 固定中文提示词:只基于给定消息、禁止编造 assert stub.calls and "禁止编造" in stub.calls[0]["system"] assert SYSTEM_PROMPT == stub.calls[0]["system"] user = stub.calls[0]["user"] assert "预算:摘要估算 token 不得超过 1000" in user assert "插单交期优先确认内容" in user # 消息原样进入提示词 assert "只输出压缩后的摘要正文" in user async def test_llm_unavailable_falls_back_deterministic(): msgs = _messages(n=6, repeat=3) out = await compress_scroll_summary(msgs, provider=StubProvider(result=None), budget_tokens=400) assert out.mode == "deterministic" assert out.reason == "provider-unavailable" assert estimate_tokens(out.text) <= 400 assert out.text and "插单交期优先确认内容" in out.text assert out.compressed_message_ids == [f"m{i}" for i in range(6)] async def test_llm_error_timeout_falls_back_deterministic(): msgs = _messages(n=6, repeat=3) out = await compress_scroll_summary( msgs, provider=StubProvider(error=RuntimeError("timeout")), budget_tokens=400) assert out.mode == "deterministic" assert out.reason == "provider-error:RuntimeError" assert estimate_tokens(out.text) <= 400 async def test_llm_over_budget_falls_back_deterministic(): msgs = _messages(n=6, repeat=3) out = await compress_scroll_summary(msgs, provider=StubProvider(result="长" * 5000), budget_tokens=200) assert out.mode == "deterministic" assert out.reason == "llm-over-budget" assert estimate_tokens(out.text) <= 200 async def test_llm_empty_output_falls_back_deterministic(): msgs = _messages(n=6, repeat=3) out = await compress_scroll_summary(msgs, provider=StubProvider(result=" \n "), budget_tokens=400) assert out.mode == "deterministic" assert out.reason == "llm-empty-output" assert out.text async def test_llm_fence_wrapped_output_stripped(): msgs = _messages(n=3, repeat=1) out = await compress_scroll_summary( msgs, provider=StubProvider(result="```text\n压缩摘要正文\n```"), budget_tokens=400) assert out.mode == "llm" assert out.text == "压缩摘要正文" async def test_compress_rejects_no_messages(): with pytest.raises(ValueError): await compress_scroll_summary([], provider=StubProvider(result="x"), budget_tokens=100) with pytest.raises(ValueError): await compress_scroll_summary(_messages(), provider=StubProvider(result="x"), budget_tokens=0) def test_deterministic_compress_budget_and_order(): msgs = [{"id": f"m{i}", "role": "user", "text": "消息内容" * 5} for i in range(20)] out = deterministic_compress(msgs, budget_tokens=100) assert out.mode == "deterministic" assert out.reason == "budget-truncation" assert estimate_tokens(out.text) <= 100 assert out.text.startswith("用户:消息内容") # 保序:从最旧开始保留 assert out.compressed_message_ids == [f"m{i}" for i in range(20)] def test_prompt_template_is_fixed_and_bounded(): msgs = _messages(n=3, repeat=1) prompt = build_compression_prompt(msgs, 123) assert "预算:摘要估算 token 不得超过 123" in prompt assert "需要压缩的对话(按时间顺序):" in prompt assert prompt.count("[消息") == 3 # ---------------- scroll_summary:summarizer 溯源标记 ---------------- def test_scroll_summary_records_compression_provenance_and_chain(): sd = _store_data(messages=_messages(n=4, repeat=1)) s1 = scroll_summary(sd, "s1", "验收标准:插单必须满足交期优先", source_message_ids=["m0", "m1"], summarizer="llm") assert s1["compressedBy"] == "llm" assert s1["sourceMessageIds"] == ["m0", "m1"] s2 = scroll_summary(sd, "s1", "插单已评估完毕,等待确认", source_message_ids=["m2", "m3"], summarizer="deterministic") assert s2["compressedBy"] == "deterministic" assert s2["supersedes"] == s1["summaryId"] # supersedes 溯源链 assert s2["generation"] == 2 # 不传 summarizer:保持纯确定性行为,不写入新字段(向后兼容) legacy = scroll_summary(sd, "s1", "第三版") assert "compressedBy" not in legacy # ---------------- maybe_roll_session_summary:预算门控接线 ---------------- async def test_maybe_roll_budget_gated_and_config_switch(): sd = _store_data() # 长会话(30 条) stub = StubProvider(result="压缩摘要:插单按交期优先。") # 未超预算:不触发,不写策略 out = await maybe_roll_session_summary(sd, "s1", provider=stub, budget=ContextBudget(session=10 ** 5)) assert out is None assert get_policy(sd, "s1") == {} # 配置开关:autoSummarize=False 时即使超预算也不滚动 out = await maybe_roll_session_summary( sd, "s1", provider=stub, budget=ContextBudget(session=3000), auto_summarize=False) assert out is None assert get_policy(sd, "s1") == {} # 超预算:滚动成功(LLM 压缩入库),会话层重新拟合预算 out = await maybe_roll_session_summary(sd, "s1", provider=stub, budget=ContextBudget(session=3000)) assert out is not None assert out["text"] == "压缩摘要:插单按交期优先。" assert out["compressedBy"] == "llm" assert set(out["sourceMessageIds"]) <= {f"m{i}" for i in range(30)} assert len(out["sourceMessageIds"]) >= 1 policy = get_policy(sd, "s1") assert policy["summary"]["summaryId"] == out["summaryId"] assembly = assemble_context(sd, "s1", None, ContextBudget(session=3000)) assert assembly.usage["session"] <= 3000 assert "压缩摘要" in assembly.layer("session") # 已覆盖的消息不再重复压缩(幂等):无新消息滑出窗口 -> 不触发 noop = await maybe_roll_session_summary(sd, "s1", provider=stub, budget=ContextBudget(session=3000)) assert noop is None assert get_policy(sd, "s1")["summary"]["summaryId"] == out["summaryId"] # 新一轮对话:新消息把 m22/m23 推出窗口 -> 滚动溯源链续接(supersedes) sd["messages"]["s1"].extend([ {"id": "m30", "role": "user", "text": "第30条" + "插单交期优先确认内容" * 15}, {"id": "m31", "role": "agent", "text": "第31条" + "插单交期优先确认内容" * 15}, ]) out2 = await maybe_roll_session_summary(sd, "s1", provider=stub, budget=ContextBudget(session=3000)) assert out2 is not None assert out2["supersedes"] == out["summaryId"] assert out2["generation"] == 2 assert set(out2["sourceMessageIds"]) == {"m22", "m23"} async def test_maybe_roll_deterministic_fallback_when_provider_unavailable(): sd = _store_data() out = await maybe_roll_session_summary( sd, "s1", provider=StubProvider(result=None), budget=ContextBudget(session=3000)) assert out is not None assert out["compressedBy"] == "deterministic" assert estimate_tokens(out["text"]) <= 3000 // 2 policy = get_policy(sd, "s1") assert policy["summary"]["compressedBy"] == "deterministic" # 确定性回退仍可溯源到被压缩消息 id assert policy["summary"]["sourceMessageIds"] async def test_maybe_roll_fail_closed_when_still_over_budget(): """滚动后会话层仍超预算(钉住超大消息)-> ContextBudgetExceeded 可预测拒绝。""" sd = _store_data() sd["messages"]["s1"].append({"id": "m_pin", "role": "user", "text": "验收标准" * 3000}) pin_message(sd, "s1", "m_pin", note="不可淘汰") async def _attempt(): return await maybe_roll_session_summary( sd, "s1", provider=StubProvider(result="压缩摘要"), budget=ContextBudget(session=3000)) with pytest.raises(ContextBudgetExceeded) as exc_info: await _attempt() exc = exc_info.value assert exc.layer == "session" assert exc.budget == 3000 assert exc.required > 3000 # 可预测:同一输入重复触发,层/用量/限额完全一致 with pytest.raises(ContextBudgetExceeded) as exc_info2: await _attempt() second = exc_info2.value assert (second.layer, second.required, second.budget) == (exc.layer, exc.required, exc.budget) async def test_maybe_roll_ignores_foreign_session(): """越权会话(不在快照/项目不可达)-> 显式 ValueError,绝不滚动。""" sd = _store_data() sd["sessions"].append({"id": "s2", "projectId": "p2", "title": "别厂会话", "status": "running"}) sd["messages"]["s2"] = [{"id": "x0", "role": "user", "text": "机密:别厂排产方案"}] stub = StubProvider(result="压缩摘要") with pytest.raises(ValueError) as exc_info: await maybe_roll_session_summary(sd, "s2", provider=stub, budget=ContextBudget()) assert "project not accessible" in str(exc_info.value) # 授权会话 s1 的策略未被污染 assert get_policy(sd, "s1") == {}