aps-agent/tests/golden/test_scroll_summary_llm.py

269 lines
12 KiB
Python
Raw Normal View History

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