feat: add P7 OCR soak scenario and scoreboard
This commit is contained in:
+95
-10
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user