test: apply evidence-based live checks without hiding regressions
This commit is contained in:
@@ -1,123 +1,110 @@
|
||||
"""真实网关端到端冒烟(M1 验收第 7 步)。
|
||||
"""真实网关冒烟:逐轮独立取证,行为断言失败也必须留档。"""
|
||||
|
||||
前置: `.env` 配置至少一个 `LLM__{PROVIDER}__1__*` 真实源 + 韧性键。
|
||||
缺配置时 skip(验收前必须真跑)。输出结构化 Markdown 落
|
||||
`tests/outputs/e2e/`(CLAUDE.md §4.6,不提交 git)。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from dotenv import dotenv_values
|
||||
from pydantic import BaseModel
|
||||
|
||||
from polygateway import GatewayClient
|
||||
from polygateway import Effort, GatewaySettings
|
||||
from tests.e2e.conftest import (
|
||||
LiveCapture,
|
||||
captured_chat_round,
|
||||
chat_expectations,
|
||||
enforce_verdict,
|
||||
observed_client,
|
||||
source_controls,
|
||||
)
|
||||
|
||||
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
|
||||
_HAS_SOURCE = any(k.split("__")[0] == "LLM" and k.endswith("__API_KEY") for k in _ENV)
|
||||
|
||||
# 真实网关调用: 与 test_thinking_live.py 同待遇标 slow(pytest addopts 默认排除,
|
||||
# 显式 `pytest -m slow` 运行)。理由见 test_compat_projects.py 同处注释。
|
||||
_HAS_SOURCE = any(k.startswith("LLM__") and k.endswith("__API_KEY") for k in _ENV)
|
||||
pytestmark = [
|
||||
pytest.mark.slow,
|
||||
pytest.mark.skipif(
|
||||
not _HAS_SOURCE,
|
||||
reason="需真实网关凭据: 在 .env 配置 LLM__{PROVIDER}__1__*(M1 验收前必须真跑)",
|
||||
),
|
||||
pytest.mark.skipif(not _HAS_SOURCE, reason="缺少矩阵必需凭据,未覆盖"),
|
||||
]
|
||||
|
||||
_OUT_DIR = Path("tests/outputs/e2e")
|
||||
|
||||
|
||||
class MiniAnswer(BaseModel):
|
||||
"""最小结构化响应契约。"""
|
||||
|
||||
answer: int
|
||||
reason: str
|
||||
|
||||
|
||||
def _report(name: str, sections: list[tuple[str, str]]) -> Path:
|
||||
_OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
path = _OUT_DIR / f"{name}_{ts}.md"
|
||||
body = [f"# e2e 冒烟: {name}", ""]
|
||||
for title, content in sections:
|
||||
body += [f"## {title}", "", "```", content, "```", ""]
|
||||
path.write_text("\n".join(body), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
c = GatewayClient.from_env("LLM", env=_ENV)
|
||||
yield c
|
||||
await c.aclose()
|
||||
async def _smoke(matrix, prompt, validate, *, stream=True, structured=None):
|
||||
"""全量注入仅替换取证装配,仍调用生产结构化策略。"""
|
||||
settings = GatewaySettings.from_env("LLM", env=_ENV)
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
controls = source_controls(settings)
|
||||
capture = LiveCapture(
|
||||
expectations=chat_expectations(
|
||||
settings, messages=messages, stream=stream, controls=controls
|
||||
)
|
||||
)
|
||||
async with observed_client(settings, capture) as client:
|
||||
_, verdict = await captured_chat_round(
|
||||
client,
|
||||
capture,
|
||||
run_id=uuid4().hex,
|
||||
matrix_id=matrix,
|
||||
round_index=1,
|
||||
output_dir=Path("tests/outputs/134/live"),
|
||||
messages=messages,
|
||||
models={s.name: s.model for s in settings.sources},
|
||||
providers={s.name: s.provider for s in settings.sources},
|
||||
source_efforts={
|
||||
s.name: s.reasoning_effort
|
||||
if s.reasoning_effort is not None
|
||||
else (Effort.AUTO if s.enable_thinking else Effort.NONE)
|
||||
if s.enable_thinking is not None
|
||||
else None
|
||||
for s in settings.sources
|
||||
},
|
||||
aliases={},
|
||||
validate=validate,
|
||||
stream=stream,
|
||||
structured=structured,
|
||||
)
|
||||
enforce_verdict(verdict)
|
||||
|
||||
|
||||
class TestRealGatewaySmoke:
|
||||
async def test_stream_chat(self, client):
|
||||
resp = await client.chat(
|
||||
[{"role": "user", "content": "Reply with exactly: pong"}], session_id="e2e-smoke"
|
||||
)
|
||||
path = _report(
|
||||
"stream_chat",
|
||||
[
|
||||
("响应", resp.content),
|
||||
(
|
||||
"元数据",
|
||||
json.dumps(
|
||||
{
|
||||
"model": resp.model,
|
||||
"source": resp.source_name,
|
||||
"usage_source": resp.usage_source,
|
||||
"prompt_tokens": resp.prompt_tokens,
|
||||
"completion_tokens": resp.completion_tokens,
|
||||
"latency_ms": resp.latency_ms,
|
||||
"ttft_ms": resp.ttft_ms,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
assert resp.content.strip()
|
||||
assert resp.ttft_ms is not None and resp.latency_ms > 0
|
||||
print(f"输出: {path}")
|
||||
"""保留流/非流和结构化真实行为断言。"""
|
||||
|
||||
async def test_non_stream_fast_path(self, client):
|
||||
resp = await client.chat(
|
||||
[{"role": "user", "content": "Reply with exactly: pong"}], stream=False
|
||||
)
|
||||
_report("non_stream", [("响应", resp.content)])
|
||||
assert resp.content.strip() and resp.ttft_ms is None
|
||||
async def test_stream_chat(self):
|
||||
def validate(response):
|
||||
assert response.content.strip()
|
||||
assert response.ttft_ms is not None and response.latency_ms > 0
|
||||
|
||||
async def test_structured_json_tier(self, client):
|
||||
resp = await client.chat(
|
||||
[{"role": "user", "content": 'Reply ONLY with JSON: {"ok": true}'}],
|
||||
await _smoke("smoke-stream", "Reply with exactly: pong", validate)
|
||||
|
||||
async def test_non_stream_fast_path(self):
|
||||
def validate(response):
|
||||
assert response.content.strip() and response.ttft_ms is None
|
||||
|
||||
await _smoke("smoke-json", "Reply with exactly: pong", validate, stream=False)
|
||||
|
||||
async def test_structured_json_tier(self):
|
||||
def validate(response):
|
||||
assert isinstance(response.structured_data, dict | list)
|
||||
|
||||
await _smoke(
|
||||
"smoke-structured-json",
|
||||
'Reply ONLY with JSON: {"ok": true}',
|
||||
validate,
|
||||
structured="json",
|
||||
)
|
||||
_report("structured_json", [("解析产物", repr(resp.structured_data))])
|
||||
assert isinstance(resp.structured_data, dict | list)
|
||||
|
||||
async def test_structured_model_ladder(self, client):
|
||||
resp = await client.chat(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What is 2+3? Reply ONLY with JSON matching "
|
||||
'{"answer": <int>, "reason": <short string>}',
|
||||
}
|
||||
],
|
||||
async def test_structured_model_ladder(self):
|
||||
def validate(response):
|
||||
assert isinstance(response.structured_data, MiniAnswer)
|
||||
assert response.structured_data.answer == 5
|
||||
|
||||
await _smoke(
|
||||
"smoke-structured-model",
|
||||
'What is 2+3? Reply ONLY with JSON matching {"answer": <int>, "reason": <short string>}',
|
||||
validate,
|
||||
structured=MiniAnswer,
|
||||
)
|
||||
_report(
|
||||
"structured_model",
|
||||
[
|
||||
("原始响应", resp.content),
|
||||
("校验产物", resp.structured_data.model_dump_json()),
|
||||
],
|
||||
)
|
||||
assert isinstance(resp.structured_data, MiniAnswer)
|
||||
assert resp.structured_data.answer == 5
|
||||
|
||||
Reference in New Issue
Block a user