feat: add P7 OCR soak scenario and scoreboard

This commit is contained in:
2026-07-21 23:22:12 -04:00
parent d2138e535f
commit bb1600c0f1
6 changed files with 211 additions and 20 deletions
+95 -10
View File
@@ -122,14 +122,18 @@ async def _worker_async(args: argparse.Namespace, worker_idx: int) -> None:
"""单 worker: 消费场景生成器,信号量限并发,双预算任一命中即停。"""
from polygateway import GatewayClient
from polygateway.telemetry.sqlite import SQLiteRecorder
from tools.soak.scenarios import SCENARIOS, SoakCorpus
env = _merged_env()
run_id = args.run_id
telemetry_path = _ROOT / f"data/soak/telemetry_{run_id}_{worker_idx}.db"
recorder = SQLiteRecorder(telemetry_path)
client = GatewayClient.from_env(args.scope, telemetry=recorder, env=env)
if args.scenario == "P7":
from polygateway.ocr import OcrClient
client = OcrClient.from_env(args.scope, telemetry=recorder, env=env)
else:
client = GatewayClient.from_env(args.scope, telemetry=recorder, env=env)
corpus = SoakCorpus(
harness_db=_ROOT / "data/soak/harness.db",
telemetry_db=_ROOT / "data/soak/generate_questions_telemetry.db",
@@ -148,11 +152,16 @@ async def _worker_async(args: argparse.Namespace, worker_idx: int) -> None:
async def _one(kind: str, kwargs: dict) -> None:
try:
# run 级双层隔离: FLUSHDB(入口)+ 每 run 独立 namespace(此处)
kwargs.setdefault("cache_namespace", run_id)
resp = await client.chat(**kwargs)
if kind == "ocr_text":
await client.recognize_text(**kwargs)
elif kind == "ocr_layout":
await client.parse_layout(**kwargs)
else:
# run 级双层隔离: FLUSHDB(入口)+ 每 run 独立 namespace(此处)
kwargs.setdefault("cache_namespace", run_id)
resp = await client.chat(**kwargs)
stats["tokens"] += resp.prompt_tokens + resp.completion_tokens
stats["ok"] += 1
stats["tokens"] += resp.prompt_tokens + resp.completion_tokens
except asyncio.CancelledError:
stats["cancelled"] += 1
raise
@@ -263,8 +272,11 @@ def _scoreboard(args: argparse.Namespace, env: dict[str, str]) -> None:
# 交报告"错误分布"人工核对(findings §4 条 2 的比例校准留待 P5 常态化)
if args.scenario in ("P5", "P6"):
_check("故障混编生效(P5/P6)", sb.inv_any_errors, rows)
rate = sb.structured_success_rate(rows, session_suffix="-p3")
verdicts.append(("P3 结构化成功率", f"{rate:.3f}(基线首跑建立)"))
if args.scenario == "P7":
_p7_checks(args, env, results, rows, calls, verdicts, _check)
else:
rate = sb.structured_success_rate(rows, session_suffix="-p3")
verdicts.append(("P3 结构化成功率", f"{rate:.3f}(基线首跑建立)"))
report = sb.render_report(args.run_id, rows, verdicts)
path = sb.write_report(args.run_id, report)
print(report)
@@ -273,6 +285,78 @@ def _scoreboard(args: argparse.Namespace, env: dict[str, str]) -> None:
raise SystemExit("硬不变量被击穿,见报告")
def _p7_checks(args, env, results, rows, calls, verdicts, check) -> None:
"""P7 专属裁决(M3 计划 T8): 成功率/坏源压制/故障生效/错误分类/取消泄漏/一致性。"""
from tools.soak import scoreboard as sb
ok_total = sum(r["stats"]["ok"] for r in results)
check("成功率 ≥98%(P7)", sb.inv_success_rate, ok_total, calls, min_rate=0.98)
fault_names = [
s.strip() for s in env.get(f"{args.scope}_FAULT_SOURCES", "").split(",") if s.strip()
]
check("坏源尝试占比 ≤15%(P7)", sb.inv_fault_share, rows, fault_names, max_share=0.15)
check("故障混编生效(P7)", sb.inv_fault_errors_present, rows, fault_source_names=fault_names)
check("错误全部可分类(P7)", sb.inv_errors_classified, rows, _KNOWN_ERROR_PREFIXES)
cancelled = sum(r["stats"]["cancelled"] for r in results)
verdicts.append(
("零取消泄漏(P7)", "PASS" if cancelled == 0 else f"FAIL — cancelled={cancelled}")
)
asyncio.run(_ocr_consistency_check(env, args.scope, verdicts))
# P7 已知错误前缀(metric ocr-call-success 口径: 异常类名 + 取消哨兵)
_KNOWN_ERROR_PREFIXES = (
"TransientError",
"SourceDeadError",
"RequestRejectedError",
"ResultInvalidError",
"AllSourcesExhausted",
"CircuitOpenError",
"GatewayUnavailableError",
"cancelled",
)
async def _ocr_consistency_check(
env: dict[str, str], scope: str, verdicts: list[tuple[str, str]], n: int = 20
) -> None:
"""P7 不变量⑥: tables/para_blocks 一致性抽查(跑后活检查,设计 §1.2 护栏)。"""
import io
import random
import zipfile
import httpx
from polygateway.transports.monkey_ocr import _parse_middle_json
base = env[f"{scope}__MONKEY__1__BASE_URL"]
images = sorted((_ROOT / "data/soak/chs_images").glob("chs_*.jpg"))
sample = random.sample(images, min(n, len(images)))
mismatches: list[str] = []
async with httpx.AsyncClient(base_url=base, trust_env=False, timeout=300) as client:
for path in sample:
resp = await client.post(
"/parse", files={"file": ("image.jpg", path.read_bytes(), "image/jpeg")}
)
resp.raise_for_status()
zip_resp = await client.get(resp.json()["download_url"])
zip_resp.raise_for_status()
elements, _ = _parse_middle_json(zip_resp.content)
lib = sorted(e.bbox for e in elements if e.type == "table")
with zipfile.ZipFile(io.BytesIO(zip_resp.content)) as archive:
member = next(m for m in archive.namelist() if m.endswith("_middle.json"))
payload = json.load(archive.open(member))
raw = sorted(
tuple(float(v) for v in t["bbox"])
for page in payload["pdf_info"]
for t in page.get("tables", [])
)
if lib != raw:
mismatches.append(path.name)
verdict = "PASS" if not mismatches else f"FAIL — 不一致: {mismatches[:5]}"
verdicts.append((f"tables/para_blocks 一致性抽查 n={len(sample)}", verdict))
async def _redis_clock_offset_s(redis_url: str) -> float:
"""Redis 服务器钟相对本机钟的偏移(秒);限流窗口 id 以服务器钟为准。"""
import redis.asyncio as aioredis
@@ -292,7 +376,6 @@ async def _live_checks(
from polygateway.backends.redis.breaker import RedisGate
from polygateway.backends.redis.limiter import RedisLimiter
from polygateway.config import GatewaySettings
from tools.soak import scoreboard as sb
settings = GatewaySettings.from_env(args.scope, env=env)
@@ -322,7 +405,9 @@ async def _live_checks(
def main() -> None:
parser = argparse.ArgumentParser(description="PolyGateway 真实数据压测")
parser.add_argument("--scenario", required=True, choices=["P1", "P2", "P3", "P4", "P5", "P6"])
parser.add_argument(
"--scenario", required=True, choices=["P1", "P2", "P3", "P4", "P5", "P6", "P7"]
)
parser.add_argument("--budget-calls", type=int)
parser.add_argument("--budget-tokens", type=int)
parser.add_argument("--workers", type=int, default=1)