games-development-ai/cheap-worker/cheap_service_driver.py
lili 5b34807e73
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
feat(cheap): 面四 Python 三跳 traceparent 打通——便宜档生成串成单条 trace 树(观测阶段四)
Java agent(面一b)已在派发出网自动注 traceparent;本片补 Python 侧三个断点,让一次便宜档生成
在 Tempo 里是 Java→worker(:9501)→cheap-service(:8300)生成 的单条 trace 树而非散落 root:
- 断点①:worker_service.do_POST 抓入站 traceparent 进 job._otelCarrier(self.headers 仅此可得);
  process_job 用 worker_span_scope 在入站 context 下建 worker SERVER span、包住真生成调用(span 建在
  真处理 job 的 worker 线程,非只入队即 202 的 do_POST)。
- 断点②:driver 读当前 context(worker span 经 asyncio.run 透传进协程)inject 成 W3C carrier,随出站
  :8300 各 POST header 带上;同份 carrier 写会话 sidecar。
- 断点③:/chat reply 在 AgentScope 后台任务跑、与 HTTP 请求解耦读不到实时 context——故走既有 C2 sidecar
  桥:cheap_service_app 工厂读 sidecar traceparent → parent_carrier → CheapOtlpSink 每步生成 span 挂入站
  context 当子 span(不再私有 root)。

传播 helper 集中 cheap_otlp_sink(extract/inject/worker_span_scope);全程 best-effort(otel 不可用/carrier
坏/Collector 不可达都退化成少串一段 trace、生成异常原样穿出)、CHEAP_OTLP_ENDPOINT 默认关时零行为变化
(parent=None 退私有 root、sidecar 不写 traceparent 字节不变)、代理旁路红线未破、业务 traceId 并存(§10 决策5)。

test_trace_propagation.py 14 例(含端到端三跳单条 trace 树,InMemorySpanExporter 证父子链+同 trace_id)
+ test_worker_service +2;主控亲跑 40 passed(3 文件)+ 全量 364 passed 1 skipped 无回退。
真跨进程 Tempo trace 树验证 = dev 开 CHEAP_OTLP_ENDPOINT 重跑 follow-up。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 20:30:04 -07:00

360 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""cheap_service_driver.py — 便宜档 · 单 POST 消费方(镜像 tier2 bootstrap + control_plane.single_post,cheap 收口)。
worker_service.py 收 §6.1 job 后,不再进程内 run_studio,而是驱动 cheap Service /chat:
注册 OpenAI 兼容凭据 → 建 agent(system prompt 的 ⟦G⟧=后端 gameId)/session → scaffold(后端 gameId,起点落
amgen-<后端 gameId>)+ 写 session→gameId 映射注册表 sidecar → 设 BYPASS → 发 kick → SSE 等这一次(内部续修多轮)
回合真结束 → 读九门 verdict + Service 收口采集 sidecar 组 run-summary → reply 外非阻塞丰富度评分。续修/门判/软预算
全在 Service 端 middleware(阶段一① 复用),消费方只发一次 POST。**C2**:产物目录/评门/回调 trace 全用后端 gameId,
Service 两工厂经会话注册表把框架分配的 session_id 解析回后端 gameId(工厂拿不到后端 gameId,故靠 sidecar 桥接)。
【惰性 import 红线】顶层零重依赖;httpx/agentscope 牵出的件在函数体内 import。
【代理旁路必保(I2)】**先 client.resolve_base_url()+install_proxy_bypass(base_url) 再 import httpx**(装 NO_PROXY 含
new-api host 与本地 Service 127.0.0.1);回调侧旁路由 worker_service._NO_PROXY_OPENER 兜。
"""
from __future__ import annotations
import os
import time
def _resolve_base() -> str:
"""new-api host 根(已装代理旁路);cheap 凭据在其后补 /v1(OpenAI 兼容路)。"""
from worker import client # noqa: PLC0415
return client.resolve_base_url()
def _resolve_key() -> str:
"""NEWAPI_KEY(_bootstrap 已从凭据档注入 env;client.get_api_key 从 env 读)。"""
from worker import client # noqa: PLC0415
return client.get_api_key()
def _cheap_credential_payload() -> dict:
"""组注册 cheap M3 凭据(POST /credential/)的请求体 —— OpenAI 兼容路,base 末尾补 /v1(决策②)。
与 tier2(anthropic_credential,host 根)不同:cheap 走 OpenAI 兼容 /v1/chat/completions,故
type=openai_credential、base_url 带 /v1(与 config.build_model_openai 的 /v1 补全同口径)。
"""
base = _resolve_base().rstrip("/")
if not base.endswith("/v1"):
base = base + "/v1"
return {"data": {"type": "openai_credential", "api_key": _resolve_key(), "base_url": base}}
def _write_session_cfg(session_id: str, *, external_game_id: str,
write_whitelist=None, scaffold_template=None,
traceparent=None, tracestate=None) -> None:
"""写本 session 的会话注册表 sidecar(C2:Service 两工厂读它把 session_id 解析回后端 gameId + 取 write_whitelist)。
按 session_id 键写 game-runtime/games/_cheap-sessions/<session_id>.json(worker↔Service 同机共享 FS);
**external_game_id = 后端 gameId 恒写**(工厂据它绑六工具/评门/collector 目录,与 driver scaffold 一致)。
restricted = 是否受限写(create 路 False + write_whitelist=None;reskin/modify 路 True + 白名单;I1 fail-closed
依赖 restricted 标记)。traceparent/tracestate = 面四断点③ 桥:worker 这跳的 W3C context,让工厂据它把
cheap-service 生成 span 挂到入站 trace 下(reply 在后台任务跑、读不到出站 header 的实时 context,故走 sidecar 桥)。
best-effort:写失败只告警(Service 侧读缺失 → 回落 session_id、生成响亮失败)。
"""
import json # noqa: PLC0415
import cheap_run # noqa: PLC0415
try:
# I1 fail-closed:restricted 由「是否给定白名单(is not None)」判,不用 bool()——空集白名单 bool 为 False 会误成
# create 的不收窄放开全写;is not None 让空集 → restricted True → T2 读侧收窄成空集禁写(受限会话缺白名单宁禁勿放)。
restricted = write_whitelist is not None
wl = sorted(write_whitelist) if write_whitelist else None
p = cheap_run.session_cfg_path(session_id)
p.parent.mkdir(parents=True, exist_ok=True)
cfg = {
"external_game_id": str(external_game_id),
"write_whitelist": wl,
"scaffold_template": scaffold_template,
"restricted": restricted,
}
# 面四断点③ 桥:仅当有 traceparent 时才写(默认关/无传播时不写、sidecar 字节不变)。
if traceparent:
cfg["traceparent"] = traceparent
if tracestate:
cfg["tracestate"] = tracestate
p.write_text(json.dumps(cfg, ensure_ascii=False), encoding="utf-8")
except Exception as e: # noqa: BLE001 —— sidecar best-effort,写失败 Service 侧回落 session_id
print(f"[cheap-driver] session-cfg 写失败(Service 侧将回落 game_id=session_id、生成响亮失败):"
f"{type(e).__name__}: {e}", flush=True)
def _read_last_cheap_verdict(game_id: str):
"""读服务端最后一次九门 play 已落的 verdict(_wg1-gen/<id>/evidence/verdict.json)。缺失/坏 → None(判未过,不伪造)。"""
import json # noqa: PLC0415
import cheap_run # noqa: PLC0415
vp = cheap_run.wg1_game_dir(game_id) / "evidence" / "verdict.json"
if not vp.exists():
return None
try:
return json.loads(vp.read_text(encoding="utf-8"))
except Exception as e: # noqa: BLE001
print(f"[cheap-driver] verdict 读失败(按未产出):{type(e).__name__}: {e}", flush=True)
return None
def _read_service_run_summary(game_id: str, *, wait_s: float = 5.0) -> dict:
"""读 Service 收口采集 sidecar(game_dir/evidence/service-run-summary.json:costRmb/rmbGate/repairs/…)。
C1 竞态兜底:Service 侧 collector 已在 yield REPLY_END 前**同步 flush**,正常 driver 读 REPLY_END 时盘上必有;
但为防极端时序(SSE 到达早于文件系统可见 / collector 走 finally 兜底路),这里再加**有界轮询**——最多等 wait_s
(每 100ms 探一次「存在且可解析」),仍读不到才诚实降级 {}(driver 落 costRmb=0/degraded,不伪造)。竞态窗口是
ms 级,短轮询即闭合;这是 M3b「result_out 只产 trace.cost → D11 三维恒中性」坑的归并版防线。
"""
import json # noqa: PLC0415
import time as _time # noqa: PLC0415
import cheap_run # noqa: PLC0415
p = cheap_run.game_dir(game_id) / "evidence" / "service-run-summary.json"
deadline = _time.time() + max(0.0, wait_s)
while True:
if p.exists():
try:
obj = json.loads(p.read_text(encoding="utf-8"))
if isinstance(obj, dict):
return obj
except Exception: # noqa: BLE001 —— 半成品/坏 JSON:轮询期内再等,过期后按空降级
pass
if _time.time() >= deadline:
print(f"[cheap-driver] service-run-summary 有界轮询({wait_s:.1f}s)未读到,诚实降级 costRmb=0/degraded。",
flush=True)
return {}
_time.sleep(0.1)
def _read_driver_type(game_id: str):
"""读 ensure_play_spec 产的 driver 类型(_wg1-gen/<id>/play-spec.json.driver.type);缺失 → None(trace.gatespec 省略)。"""
import json # noqa: PLC0415
import cheap_run # noqa: PLC0415
p = cheap_run.wg1_game_dir(game_id) / "play-spec.json"
if not p.exists():
return None
try:
return ((json.loads(p.read_text(encoding="utf-8")) or {}).get("driver") or {}).get("type")
except Exception: # noqa: BLE001
return None
def _build_summary(game_id: str, brief: str, verdict, svc: dict, turn: dict, t0: float) -> dict:
"""据九门 verdict + Service 收口采集 sidecar 组一份与 run_studio 同形的 run-summary(供 result_out.build_result_out)。
复用 cheap_studio 的 _verdict_brief(verdict 简报)+ build_trace_source(9d-trace 源维度 verdictFull/driverType/models/stage);
costRmb/rmbGate/repairs 来自 Service 收口采集(worker 进程拿不到 Service 内存态,靠 sidecar 跨进程)。
attempts = repairs+1(result_out 的 trace.repairs = max(0, attempts-1) 会反推回 repairs)。
"""
import cheap_run # noqa: PLC0415
import cheap_studio # noqa: PLC0415
from worker.gate_judge import judge_cheap_verdict # noqa: PLC0415
# C6 接线:带 game_id/staged 目录判(驱动器族/日志摘要口径与 Service 续修一致;此处只为 summary 归一,不再落 sidecar 也无妨——staged_dir 传入使口径同源)。
j = judge_cheap_verdict(verdict or {}, game_id=game_id,
staged_dir=cheap_run.wg1_game_dir(game_id))
vb = cheap_studio._verdict_brief(verdict) if verdict else None
svc = svc or {}
repairs = svc.get("repairs")
attempts = (repairs + 1) if isinstance(repairs, int) else 1
driver_type = _read_driver_type(game_id)
stage = "play" if verdict is not None else "code" # 简化:有 verdict=跑到 play;无=最远到写码(细分阶段列 follow-up)
model = cheap_studio._bootstrap.SPIKE_MODEL
summary = {
"ok": j.passed,
"gameId": game_id,
"brief": brief,
"model": model,
"finished": j.passed,
"attempts": attempts,
"verdict": vb,
"costRmb": round(float(svc.get("costRmb", 0.0) or 0.0), 4),
"rmbGate": svc.get("rmbGate", "degraded"),
"budgetSoftTripped": bool(svc.get("budgetSoftTripped", False)),
"wallSec": round(time.time() - t0, 1),
"stoppedReason": (turn.get("reason") if not turn.get("ended") else "turn_ended"),
}
# 9d-trace 源维度(verdictFull/driverType/models/stage);对照 None 时各键自然降级(result_out 省略)。
summary.update(cheap_studio.build_trace_source(verdict, driver_type, attempts, stage, model))
return summary
def _failed_summary(game_id: str, reason: str) -> dict:
"""早失败(scaffold 失败等)时的最小 failed summary(verdict None → result_out 走 failed 路)。"""
import cheap_studio # noqa: PLC0415
print(f"[cheap-driver] game={game_id} 早失败:{reason}", flush=True)
return {
"ok": False, "gameId": game_id, "model": cheap_studio._bootstrap.SPIKE_MODEL,
"finished": False, "attempts": 1, "verdict": None,
"costRmb": 0.0, "rmbGate": "degraded", "stage": "scaffold", "stoppedReason": reason,
}
async def drive_cheap_generation(job: dict, *, base_url: str | None = None, user_id: str = "cheap"):
"""驱动 cheap Service /chat 跑一局便宜档生成(单 POST + 洋葱内续修),返回 (run-summary, game_dir)。
与旧 worker_service._default_run_fn 同契约(process_job 零改动消费)。create 路默认:通用 scaffold + 通用系统提示 +
write_whitelist=None(对齐现 _default_run_fn 的 run_studio(game_id, brief) 无 scaffold/whitelist)。
"""
import asyncio # noqa: PLC0415
from worker import client, config, genconfig # noqa: PLC0415 —— client 先导(不牵 httpx),装旁路
base_url = base_url or os.environ.get("CHEAP_SERVICE_URL", "http://127.0.0.1:8300")
# I2 代理旁路必保(**必须先于 import httpx**):resolve_base_url 把 new-api host 并入 NO_PROXY(装凭据/M3 侧);
# driver→Service 打的是 127.0.0.1:8300、resolve_base_url 不含它,故再 install_proxy_bypass(base_url) 把本地
# Service host 也并入 NO_PROXY,避免 trust_env=True 的 httpx 被 clash fake-ip 拦本地环回(Opus/Codex I2)。
client.resolve_base_url()
client.install_proxy_bypass(base_url)
import httpx # noqa: PLC0415 —— 旁路已装,此后 import 才安全
import _bootstrap # noqa: PLC0415
import cheap_otlp_sink # noqa: PLC0415 —— 面四三跳传播:取当前 context 的 W3C carrier(顶层零重依赖)
import cheap_run # noqa: PLC0415
import cheap_verify # noqa: PLC0415
from cheap_roles import build_system_prompt # noqa: PLC0415
from service.control_plane import _wait_for_turn_end # noqa: PLC0415 —— 复用 tier2 SSE 等回合
game_id = job.get("gameId") or job.get("job_id")
# 真后端 gameId 是 Java Long → JSON 数字 → int;cheap_run 的 subprocess/路径要求 str(同 worker_service 边界归一)。
game_id = str(game_id) if game_id is not None else None
brief = job.get("brief") or ""
t0 = time.time()
# create 路参数:brief→genre 确定性关键词路由(T4 最小品类接线,取代硬编码 scaffold_template=None)。
# 命中五类(经营/剧情/TRPG/非遗/解谜)→ 选 per-genre 黄金骨架 + genre 透传丰富度评分(生产 create 路
# 与 S5 bake_off 复验口径同轨);无命中 → (None, None) 走通用 _template(与旧行为一致,路由只加不减)。
# write_whitelist=None(create 不收窄,对齐现 _default_run_fn)。
from cheap_genre_route import route_genre # noqa: PLC0415
scaffold_template, genre = route_genre(brief)
if scaffold_template:
print(f"[cheap-driver] game={game_id} 品类路由命中:genre={genre} template={scaffold_template}", flush=True)
scaffold_desc = None
write_whitelist = None
# 失控兜底轮数(与三闸 150 对齐;成本上界=max_repairs=6 优雅终止,而非轮数闸;放开防单 POST 多续修被 EXCEED_MAX_ITERS 提前切断)。
max_iters = genconfig.get("budget", "cheap_max_iters", 150)
max_tokens = genconfig.get("model", "max_tokens", 16000)
_bootstrap.ensure_api_key_env() # 保证 NEWAPI_KEY(装凭据要用)
# 面四断点②:把「worker 这跳的当前 context」(worker_span_scope 在 worker 线程 attach、经 asyncio.run 透传进本
# 协程)注入成 W3C carrier,随所有出站到 :8300 的 header 带上,让 cheap-service 端能接住入站 context。同一份
# carrier 也写进会话 sidecar(下方 _write_session_cfg)——因 /chat 的 reply 在框架后台任务里跑、生成 span 产在
# 那条与 HTTP 请求解耦的任务里,读不到出站 header 的实时 context,故靠 driver 恒在 /chat 前写的 sidecar 把
# traceparent 跨进程桥给工厂(与 external_game_id 同一 C2 桥)。纯增量 best-effort:无 context / otel 不可用 →
# carrier 为空,header 少带一项、生成 span 退回私有 root,绝不咬生成(设计 §8)。
otel_carrier = cheap_otlp_sink.current_traceparent_carrier()
headers = {"X-User-Id": user_id}
headers.update(otel_carrier) # traceparent(/tracestate,若有);出站 :8300 各 POST 都带
async with httpx.AsyncClient() as http:
# ① 注册 OpenAI 兼容凭据(cheap M3 走 base+/v1)。
cred = (await http.post(f"{base_url}/credential/", json=_cheap_credential_payload(),
headers=headers, timeout=30.0)).json()
credential_id = cred.get("credential_id") or cred.get("id")
# #2 setup fail-fast:Service 未返 id(建凭据/agent/session 任一失败)必须立即诚实失败,不带 id=None 往下走——
# 否则后续 POST/SSE 拿 None 当 id,Service 侧找不到资源、driver 在 _wait_for_turn_end 空等到 SSE 总超时(~600s)才弃单。
if not credential_id:
return (_failed_summary(game_id, f"Service /credential 未返 id(setup 失败):{str(cred)[:200]}"),
cheap_run.game_dir(game_id))
# ② 建 agent(system_prompt=cheap build_system_prompt;context_config 历史压缩;react_config 放开轮数)。
ctx_cfg = config.build_context_config()
agent_body = {
"name": "cheap-writer",
"system_prompt": build_system_prompt(game_id, scaffold_desc),
"context_config": ctx_cfg.model_dump(mode="json"),
"react_config": {"max_iters": max_iters},
}
agent = (await http.post(f"{base_url}/agent/", json=agent_body, headers=headers, timeout=30.0)).json()
agent_id = agent.get("agent_id") or agent.get("id")
if not agent_id: # #2 setup fail-fast:建 agent 失败,不带 None 往下走
return (_failed_summary(game_id, f"Service /agent 未返 id(setup 失败):{str(agent)[:200]}"),
cheap_run.game_dir(game_id))
# ③ 建 session(chat_model_config = openai_credential + MiniMax-M3 + max_tokens,无 thinking 分离)。
# fix400 主防线:parallel_tool_calls=False 源头禁并行 tool call——M3 经 new-api 的并行 call 不区分
# 流式 chunk index,agentscope 2.0.2 聚合会把第二个 call 的 arguments 拼进首桶(非法 JSON)→ 下一请求
# 服务端丢弃该 call → tool result 孤儿 → 400 code 2013 → run 崩、SSE 无终结、driver 慢失败 600s。
# 经 get_model 的 Parameters(**parameters) 透传,_call_api 对 API 带 parallel_tool_calls=false
# (venv agentscope/model/_openai_chat/_model.py:264-265)。纵深兜底见 m3_stream_patch(Service 进程内按 id 分桶)。
session_body = {
"agent_id": agent_id,
"chat_model_config": {
"type": "openai_credential",
"credential_id": credential_id,
"model": _bootstrap.SPIKE_MODEL,
"parameters": {"max_tokens": max_tokens, "parallel_tool_calls": False},
},
}
session = (await http.post(f"{base_url}/sessions/", json=session_body, headers=headers, timeout=30.0)).json()
session_id = session.get("session_id") or session.get("id")
if not session_id: # #2 setup fail-fast:建 session 失败,不带 None 往下走(否则 PATCH/SSE 拿 None 空等超时)
return (_failed_summary(game_id, f"Service /sessions 未返 id(setup 失败):{str(session)[:200]}"),
cheap_run.game_dir(game_id))
# ④ scaffold(clone 模板 + SAA 信封)——必须在 /chat 前,**game_id=后端 gameId**(与 system prompt 的 ⟦G⟧ 一致),
# agent 起点就位在 amgen-<后端 gameId>(subprocess 经 to_thread)。C2:产物目录统一后端 gameId、不用 session_id。
sc = await asyncio.to_thread(cheap_run.scaffold, game_id, scaffold_template)
if not sc["ok"]:
return _failed_summary(game_id, f"scaffold 失败:{sc['output'][:300]}"), cheap_run.game_dir(game_id)
# 红线③:清残留 verdict + service-run-summary,保证回合后 _read_last_cheap_verdict/收口读到的恒是本次 Service 真写。
# 封零门跑路径(setup 静默失败 / SSE 超时未跑一次 check 时,读回上一局绿 verdict → 假 succeeded)。
for _stale in (cheap_run.wg1_game_dir(game_id) / "evidence" / "verdict.json",
cheap_run.game_dir(game_id) / "evidence" / "service-run-summary.json"):
_stale.unlink(missing_ok=True)
# ⑤ 写会话注册表 sidecar(C2:Service 两工厂据 session_id 读它解析回后端 gameId、绑六工具/评门/collector;
# create 路 write_whitelist=None → restricted=False)。external_game_id 恒写、正常路工厂必读到。
_write_session_cfg(session_id, external_game_id=game_id,
write_whitelist=write_whitelist, scaffold_template=scaffold_template,
traceparent=otel_carrier.get("traceparent"),
tracestate=otel_carrier.get("tracestate"))
# ⑥ 设 BYPASS 权限(六工具默认 ASK,服务态无人确认,不设首个工具调用即卡死;PATCH 需 query agent_id,同 tier2)。
await http.patch(f"{base_url}/sessions/{session_id}", json={"permission_mode": "bypass"},
headers=headers, params={"agent_id": agent_id}, timeout=30.0)
# ⑦ 发 kick(引导 read skill → 写 game-logic.js → check/build → finish;文本对齐旧 run_studio 的 create kick)。
kick = (f"请按这个 brief 造一款游戏:「{brief}」。先 read_file 读手册(.agents/skills/littlejs-game-dev.md)"
"和你的起点 game-logic.js 再动手;核心玩法实现完、check 与 build 都绿了就立即 finish。")
await http.post(f"{base_url}/chat/", json={
"agent_id": agent_id, "session_id": session_id,
"input": {"name": "user", "role": "user", "content": [{"type": "text", "text": kick}]},
}, headers=headers, timeout=30.0)
# ⑧ SSE 等这一次回合真结束(内部续修多轮)。C3:idle 从 genconfig 派生(不硬编码 300;慢门 ~390s 静默不误弃单),
# 总超时按 (max_repairs+1)×(门+推理)放宽、且 > breaker 墙钟(让 breaker 先优雅收、driver 别抢先弃单)。
sse_idle_s = genconfig.get("budget", "cheap_repair_step_timeout_s", 600) # = breaker step_timeout,> 单次门跑最坏
sse_timeout = genconfig.get("budget", "cheap_sse_turn_timeout_s",
(genconfig.get("iteration", "max_resumes", 6) + 1)
* (genconfig.get("budget", "cheap_gate_timeout_s", 420) + 180) + 600)
turn = await _wait_for_turn_end(base_url, agent_id, session_id, user_id=user_id,
timeout_s=sse_timeout, idle_timeout_s=sse_idle_s)
if not turn.get("ended"):
# 回合未真结束(SSE 总超时/断流):Service 可能仍在续修写盘,读中间态会误判 → 据现有产物 best-effort 组 summary、诚实标 reason。
print(f"[cheap-driver] game={game_id} 回合未真结束(reason={turn.get('reason')}),据现有产物组 summary。", flush=True)
# ⑨ 读九门 verdict + Service 收口采集 sidecar → 组 run-summary(供 result_out;C2:全用后端 gameId)。
# _read_service_run_summary 带有界轮询(C1:关闭 collector flush 与 driver 读 REPLY_END 的竞态)。
verdict = _read_last_cheap_verdict(game_id)
svc = _read_service_run_summary(game_id)
summary = _build_summary(game_id, brief, verdict, svc, turn, t0)
# ⑩ reply 外收口:非阻塞丰富度 LLM 评分(additive;不进 verdict、不改 ok;token 不污染生成成本台账)。
# genre 透传(T4):品类路由命中时同一次评分 additive 追加品类扩展条目(与 run_studio 的 genre 参数同义)。
try:
summary["richness"] = await cheap_verify.verify_richness(game_id, brief=brief, genre=genre)
rv = summary["richness"]
print(f"[cheap-driver] game={game_id} 丰富度 score={rv.get('score')}/{rv.get('max')} "
f"genre={genre or '-'} groups={rv.get('groups')} degraded={rv.get('degraded')}", flush=True)
except Exception as e: # noqa: BLE001 —— richness 非阻塞铁律:绝不影响回调
summary["richness"] = {"score": None, "degraded": True, "reason": f"richness 接线异常:{type(e).__name__}: {e}"}
print(f"[cheap-driver] game={game_id} 单 POST 结束: ok={summary['ok']} attempts={summary['attempts']} "
f"costRmb={summary['costRmb']} wallSec={summary['wallSec']}", flush=True)
return summary, cheap_run.game_dir(game_id)