aps-agent/tests/golden/test_scroll_summary_llm.py

269 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 黄金测试:滚动摘要 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") == {}