test: fix structured reask evidence and live coverage conclusions

This commit is contained in:
2026-09-09 03:34:48 -04:00
parent d332287b28
commit 3eb22d2a55
7 changed files with 540 additions and 28 deletions
+307
View File
@@ -4,6 +4,7 @@ import asyncio
import inspect
import json
from dataclasses import replace
from typing import Any
import httpx
import pytest
@@ -814,3 +815,309 @@ def test_pytest_report_hook_preserves_report_and_uses_safe_fallback(tmp_path, mo
assert finished.value.value is report
paths = list(tmp_path.rglob("*.md"))
assert len(paths) == 1 and '"status": "FAIL"' in paths[0].read_text()
@pytest.mark.parametrize(
"damage", [None, "prefix", "role", "content", "unpaired", "budget", "wire"]
)
async def test_structured_reask_preserves_message_contract(tmp_path, damage, monkeypatch):
"""真实 StructuredMW 缺字段后重问成功;前缀、反馈结构及 wire 破坏均失败。"""
from pydantic import BaseModel
from polygateway import GatewaySettings
from polygateway.middleware.structured import StructuredMW
from tests.e2e.conftest import captured_chat_round, observed_client
from tests.unit.test_config import _BASE_ENV
class Answer(BaseModel):
"""离线最小结构化契约。"""
answer: int
reason: str
settings = replace(
GatewaySettings.from_env("LLM", env=_BASE_ENV),
sources=(_source(),),
structured_max_retries=1,
)
capture = _capture(messages_prefix_length=1, structured_max_retries=1)
original_feedback = StructuredMW._with_feedback
def feedback(self, *args):
"""只破坏重问产物,不替代生产阶梯或解析。"""
request = original_feedback(self, *args)
messages = [dict(message) for message in request.messages]
if damage == "prefix":
messages[0]["content"] = "changed"
elif damage == "role":
messages[-1]["role"] = "assistant"
elif damage == "content":
messages[-1]["content"] = ["wrong-type"]
elif damage == "unpaired":
messages.pop()
elif damage == "budget":
messages.extend(messages[-2:])
return replace(request, messages=messages)
monkeypatch.setattr(StructuredMW, "_with_feedback", feedback)
requests = []
def handler(request):
requests.append(json.loads(request.content))
body = _response()
body["choices"][0]["message"]["content"] = (
'{"answer":5}' if len(requests) == 1 else '{"answer":5,"reason":"sum"}'
)
return httpx.Response(200, json=body)
original_factory = capture.client_factory
def factory(source):
client = original_factory(source)
client._transport = httpx.MockTransport(handler)
if damage == "wire":
async def corrupt(request):
payload = json.loads(request.content)
if len(payload["messages"]) > 1:
payload["messages"][-1]["content"] = "well-shaped-but-corrupted"
request._content = json.dumps(payload).encode()
client.event_hooks["request"].insert(0, corrupt)
return client
capture.client_factory = factory
async with observed_client(settings, capture) as client:
response, verdict = await captured_chat_round(
client,
capture,
run_id="structured",
matrix_id="reask",
round_index=1,
output_dir=tmp_path,
messages=_MESSAGES,
models={"source": "gpt-5.5"},
aliases={},
stream=False,
structured=Answer,
)
assert response.structured_data.answer == 5
assert len(requests) == 2
assert verdict.status == ("PASS" if damage is None else "FAIL")
text = "".join(path.read_text() for path in tmp_path.rglob("*.md"))
assert _SECRET not in text and _PROMPT not in text
def _thinking_consumer():
"""仅加载 live 消费者定义与字面矩阵,跳过所有环境读取语句。"""
import ast
from pathlib import Path
from types import SimpleNamespace
path = Path(__file__).parents[1] / "e2e/test_thinking_live.py"
tree = ast.parse(path.read_text())
excluded = {
"_ENV",
"_HAS_SOURCE",
"pytestmark",
"_ROUNDS",
"_TIER_ROUNDS",
"_TIER_LONG_ROUNDS",
"_TIER_CONCURRENCY",
}
tree.body = [
node
for node in tree.body
if not (
isinstance(node, ast.Assign)
and any(
isinstance(target, ast.Name) and target.id in excluded for target in node.targets
)
)
]
namespace: dict[str, Any] = {
"_ROUNDS": 3,
"_TIER_ROUNDS": 2,
"_TIER_LONG_ROUNDS": 1,
"_TIER_CONCURRENCY": 1,
}
exec(compile(tree, str(path), "exec"), namespace)
return SimpleNamespace(**namespace), namespace
@pytest.mark.parametrize(
("observation", "qualification", "expected"),
[
(O.ABSENT, "PASS", "UNCOVERED"),
(O.OBSERVED, "PASS", "UNCOVERED"),
(O.UNKNOWN, "PASS", "UNCOVERED"),
(O.ABSENT, "FAIL", "FAIL"),
],
)
async def test_unregistered_candidate_has_no_disable_declaration(
observation, qualification, expected
):
"""执行真实 T10 消费者,未登记不等于不可关闭,资格失败仍红。"""
from types import SimpleNamespace
live, namespace = _thinking_consumer()
conclusions = []
calls = []
async def probe(model, effort, *, rounds, **kwargs):
calls.append(rounds)
return [
{
"verdict": LiveVerdict(qualification, "safe"),
"response": SimpleNamespace(thinking_observation=observation),
}
for _ in range(rounds)
]
namespace["_probe_effort"] = probe
namespace["_conclude"] = lambda matrix, verdict, **kwargs: conclusions.append(verdict)
await live.TestTierProbe().test_t10_none_direction_matches_declaration("claude-haiku-5")
assert conclusions[0].status == expected
assert calls == ([2, 1] if observation is not O.OBSERVED and qualification == "PASS" else [2])
@pytest.mark.parametrize("case", ["none", "tiers", "L8"])
async def test_capability_conclusions_link_models_and_all_subruns(tmp_path, case):
"""两个型号的 PASS/UNCOVERED 结论必须关联原件和完整短长/档位轮数。"""
from contextlib import asynccontextmanager
from types import SimpleNamespace
from polygateway import GatewaySettings
from tests.unit.test_config import _BASE_ENV
live, namespace = _thinking_consumer()
settings = GatewaySettings.from_env("LLM", env=_BASE_ENV)
namespace["_OUT_DIR"] = tmp_path
namespace["_tier_settings"] = lambda model: replace(
settings, sources=(replace(_source(), model=model),)
)
namespace["_settings"] = lambda **kwargs: replace(
settings, sources=(replace(_source(), **kwargs),)
)
namespace["enforce_verdict"] = lambda verdict: None
@asynccontextmanager
async def client(*args, **kwargs):
yield None
async def round_call(client, capture, **kwargs):
model = kwargs["models"]["source"]
observation = (
O.UNKNOWN if model == "gpt-5.4" else O.OBSERVED if case == "tiers" else O.ABSENT
)
write_live_round(
kwargs["output_dir"],
run_id=kwargs["run_id"],
matrix_id=kwargs["matrix_id"],
round_index=kwargs["round_index"],
safe_fields={
"requested_model": model,
"session_id": kwargs["run_id"],
"status": "PASS",
"thinking_observation": observation,
},
)
return SimpleNamespace(thinking_observation=observation), LiveVerdict("PASS", "safe")
namespace["observed_client"] = client
namespace["captured_chat_round"] = round_call
for model in ("gpt-5.5", "gpt-5.4"):
if case == "none":
await live.TestTierProbe().test_t10_none_direction_matches_declaration(model)
elif case == "tiers":
await live.TestTierProbe().test_t10_declared_tiers_actually_reason(model)
else:
await live.TestCapabilityDrift().test_declared_capability_matches_reality(model)
matrix = {"none": "T10-none", "tiers": "T10-tiers", "L8": "L8"}[case]
finals = list(tmp_path.rglob(f"{matrix}-0-*.md"))
assert len(finals) == 2
seen = set()
for path in finals:
row = json.loads(path.read_text().split("```json\n")[1].split("\n```")[0])
assert "requested_model" in row, "结论缺型号,无法关联逐轮原件"
model = row["requested_model"]
seen.add(model)
assert row["session_id"] == path.parent.name
assert row["status"] == ("PASS" if model == "gpt-5.5" else "UNCOVERED")
assert row["proposition"]
subruns = row["subruns"]
expected_groups = (
2
if case == "none"
else len(
[
effort
for effort in live.DEFAULT_CAPABILITIES[model].supported_efforts
if effort is not live.Effort.NONE
]
)
if case == "tiers"
else 1
)
assert len(subruns) == expected_groups
assert row["planned_rounds"] == sum(subrun["planned_rounds"] for subrun in subruns)
assert row["completed_rounds"] == row["planned_rounds"]
for subrun in subruns:
originals = [
p
for p in path.parent.glob(f"{subrun['matrix_id']}-*.md")
if p.name[len(subrun["matrix_id"]) + 1 :].split("-", 1)[0].isdigit()
and not p.name.startswith(subrun["matrix_id"] + "-0-")
]
assert len(originals) == subrun["planned_rounds"] == subrun["completed_rounds"]
for original in originals:
data = json.loads(original.read_text().split("```json\n")[1].split("\n```")[0])
assert data["requested_model"] == model
assert data["session_id"] == row["session_id"]
assert seen == {"gpt-5.5", "gpt-5.4"}
@pytest.mark.parametrize("machine_type", ["model_not_found", _SECRET, "arbitrary-upstream-text"])
def test_safe_machine_type_retains_only_known_enum(tmp_path, machine_type):
"""认可机器枚举可复核;任意机器正文与 sentinel 不得落盘。"""
_, attempts = _failure(body=json.dumps({"error": {"type": machine_type}}).encode())
fields = {"attempts": safe_attempts(attempts)}
event = fields["attempts"][0]["http"][0]
assert event.get("machine_type") == (
"model_not_found" if machine_type == "model_not_found" else "omitted"
)
path = write_live_round(
tmp_path, run_id="safe", matrix_id="machine", round_index=1, safe_fields=fields
)
text = path.read_text()
assert _SECRET not in text and "arbitrary-upstream-text" not in text
if machine_type != "model_not_found":
event["machine_type"] = machine_type
with pytest.raises(ValueError, match="机器"):
write_live_round(
tmp_path, run_id="unsafe", matrix_id="machine", round_index=1, safe_fields=fields
)
async def test_structured_first_attempt_requires_exact_initial_messages():
"""结构化窄规则不能允许首轮凭空带入一对反馈。"""
capture = _capture(messages_prefix_length=1, structured_max_retries=1)
real = _real_transport(capture, lambda request: httpx.Response(200, json=_response()))
try:
with capture.round_context(session_id="first", parent_call_id="parent"):
await ObservedTransport(real, capture).complete(
messages=[
*_MESSAGES,
{"role": "assistant", "content": "old"},
{"role": "user", "content": "retry"},
],
source=_source(),
stream=False,
overlay={},
call_id="first",
reasoning_effort=None,
)
event = capture.attempts(session_id="first", parent_call_id="parent")[0].http[0]
assert not request_is_valid(event)
finally:
await real.aclose()