lili cbfd4d871b
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
feat(acceptance): 闭合 playtest v3 与 A+ 可信消费链
固化 Match-3 生产者、视觉、音频与双 Judge 证据闭包。

将《山海行纪》r1.1 绑定新的不可变 release,并以生产预检现场核验 bundle、Registry/2 和 25 项 Writer 快照。

同步地图1平衡锁值、跨游戏回归修复、验收契约与 SoT 证据。
2026-07-28 20:16:13 -07:00

672 lines
37 KiB
Python
Raw Permalink 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.

"""result_out.py — 把 run_studio 产物组成 §6.1 result-out(DifyCallbackReqVO 形态)。
便宜档 M3a U1。严格对齐仓内权威 `SaaGraphDispatcher.buildCallbackReqVO`(game-cloud)
与 Python 参考 `wg1/gen-worker/worker/service.py:build_callback_payload`——三字段语义分离:
· gameConfig = 最小占位 Map {templateId,title,theme:"generic",engineDriven:true}
(engine 路游戏逻辑全在 engineBundle,gameConfig 仍不可空:GamePackage required);
· engineBundle = bundle.iife.js 全文(含 __GameBundle)= 运行时承重产物
(后端 resolveEngineBundleText 取值落 GamePackage.engineBundle 入 feed);
· sourceProject = 源工程 2.0 JSON 字符串 = best-effort 可选源血缘(additive,缺则后端源落库旁路)。
成功门:playtest/3 active v3 的可信 accept未冻结且硬证完整或存量验收通过且 engineBundle 含
__GameBundle否则 status=failed + 契约枚举内 failureReason。
纯函数(吃 job + run-summary + 产物目录,出 dict),无网络无 LLM、同输入恒同输出——可单测。
"""
import hashlib
import json
from pathlib import Path
import artifact_snapshot
import cheap_verify # W-AXIS-V2 波1:四门投影(project_floor)= 便宜档预筛权威的唯一产地
# §6.1 result-out / 契约#6 output.failureReason 枚举(对齐后端 FailureReasonEnum,真验改正:
# 原稿用了 content_violation/budget_exceeded/generation_failed 三个不在枚举内的值 → 真 e2e 被后端
# DifyCallbackTxService L165 拒「failureReason 不在枚举内」、回调事务回滚。真枚举见 FailureReasonEnum)。
# WU2 §3.9 起新增 quota_exhausted(new-api per-user 额度耗尽/池空,网关 402/403;跨契约共享枚举同批落
# aigc.yaml/dify-workflow-io.json/FailureReasonEnum 三处)。
_FAILURE_REASONS = {
"unsafe_prompt", "intent_unclear", "no_template_match", "config_invalid",
"llm_error", "timeout", "asset_gen_failed", "quota_exhausted",
}
# v3/v3_shadow 是生产 playtest/3 闭环的声明值historical_replay 与未知值也必须识别为新协议,
# 但永远不可发布。只有明确的 v1/v2 才能进入存量兼容回落。
_V3_MODES = {"v3", "v3_shadow"}
_LEGACY_MODES = {"v1", "v2"}
def _has_v3_projection_marker(value) -> bool:
"""轻量投影是否已经声明 playtest/3只用于识别协议代际不参与通过判定。"""
return isinstance(value, dict) and any(key in value for key in (
"schemaVersion", "outcome", "acceptanceMode", "publishFrozen", "proofComplete", "rollMerge",
))
def _declared_v3_mode(summary: dict) -> str | None:
"""识别 run-summary 是否声明了 v3声明后丢失 acceptanceV3 必须 fail-closed。
这里仅判断“是否进入 v3 协议”,不判断结果真假。完整真假仍只交给
``cheap_verify.validate_acceptance_v3_payload`` / ``is_v3_publishable``。
"""
for key in ("acceptanceVersion", "acceptanceMode"):
if key not in summary:
continue
mode = summary.get(key)
if not isinstance(mode, str) or mode not in _LEGACY_MODES:
return mode if isinstance(mode, str) and mode else "unknown"
if "publishFrozen" in summary:
return "v3"
playtest = summary.get("playtest")
if _has_v3_projection_marker(playtest):
mode = playtest.get("acceptanceMode")
return mode if mode in _V3_MODES else "v3"
trace = summary.get("trace")
if isinstance(trace, dict):
if "acceptanceVersion" in trace:
mode = trace.get("acceptanceVersion")
if not isinstance(mode, str) or mode not in _LEGACY_MODES:
return mode if isinstance(mode, str) and mode else "unknown"
if "publishFrozen" in trace or _has_v3_projection_marker(trace.get("playtest")):
playtest = trace.get("playtest") if isinstance(trace.get("playtest"), dict) else {}
mode = playtest.get("acceptanceMode")
return mode if mode in _V3_MODES else "v3"
return None
def _acceptance_v3_payload(summary: dict) -> dict | None:
"""取 run-summary 内完整封存的 playtest/2 或 playtest/3 结果;坏形态触发 fail-closed。
生产三路统一把完整结果放在 ``summary['acceptanceV3']``。允许 summary 本身就是封存对象,
仅用于纯函数测试和离线回放;不再接受其他别名,避免多入口逐渐漂移成多份事实源。
"""
if "acceptanceV3" in summary:
value = summary.get("acceptanceV3")
return value if isinstance(value, dict) else {}
if (summary.get("schemaVersion") in ("playtest/2", "playtest/3")
and isinstance(summary.get("decision"), dict)):
return summary
# v3 声明已出现却没有完整封存对象,说明入口发生降级或字段丢失;返回空对象触发统一 fail-closed。
if _declared_v3_mode(summary) is not None:
return {}
return None
def _v3_playtest_projection(v3: dict, *, engine_bundle_hash: str | None) -> dict:
"""把封存决策压成后端 trace.playtest事实直接取顶层兼容层只做镜像。"""
decision = v3.get("decision") if isinstance(v3.get("decision"), dict) else {}
compatibility = v3.get("compatibility") if isinstance(v3.get("compatibility"), dict) else {}
merge = v3.get("merge") if isinstance(v3.get("merge"), dict) else {}
floor = v3.get("floor") if isinstance(v3.get("floor"), dict) else {}
final_postguard = v3.get("finalPostguard") if isinstance(v3.get("finalPostguard"), dict) else {}
first_play = v3.get("firstPlay") if isinstance(v3.get("firstPlay"), dict) else {}
obligations = [row for row in v3.get("proofObligations") or [] if isinstance(row, dict)]
required_obligations = [row for row in obligations if row.get("required") is True]
evidence_refs = {
"actions": list(dict.fromkeys(ref for row in obligations for ref in row.get("actionRefs") or [])),
"frames": list(dict.fromkeys(ref for row in obligations for ref in row.get("postFrameRefs") or [])),
"events": [str(ref) for ref in dict.fromkeys(
ref for row in obligations for ref in row.get("eventRefs") or [])],
}
outcome = decision.get("outcome")
rescued_by_roll = decision.get("rescuedByRoll")
return {
"schemaVersion": v3.get("schemaVersion"),
"acceptanceMode": v3.get("acceptanceMode"),
# 六字段是 Java 发布闸读取的最小防重放身份。hash 只来自当前已验证暂存产物;
# 暂存产物不可复核时保留键但置空,让可信消费侧明确 fail-closed。
"runId": v3.get("runId"),
"gameId": v3.get("gameId"),
"briefHash": v3.get("briefHash"),
"artifactHash": v3.get("artifactHash"),
"taskBindingHash": v3.get("taskBindingHash"),
"acceptanceRequestHash": v3.get("acceptanceRequestHash"),
"engineBundleHash": engine_bundle_hash,
"outcome": outcome,
# accepted 不复制兼容字段,始终由权威 outcome 单向派生,保证 accepted == (outcome == accept)。
"accepted": outcome == "accept",
"publishFrozen": decision.get("publishFrozen"),
"compatibilityAccepted": compatibility.get("accepted"),
"compatibilityOk": compatibility.get("ok"),
"proofComplete": outcome == "accept" and bool(required_obligations) and all(
row.get("status") == "satisfied" for row in required_obligations),
"rollMerge": merge.get("mergeCandidate"),
"mergeConflicts": merge.get("conflicts"),
"rescued": rescued_by_roll in (1, 2),
"floorPass": floor.get("pass"),
"finalPostguardPass": final_postguard.get("pass"),
"writeAuthority": final_postguard.get("writeAuthority"),
"finalChecks": final_postguard.get("checks"),
"blockingProblems": final_postguard.get("blockingProblems"),
"contradictions": final_postguard.get("contradictions"),
"evidenceRefs": {
"actions": evidence_refs.get("actions"),
"frames": evidence_refs.get("frames"),
"events": evidence_refs.get("events"),
},
"firstPlay": {
"playableAtMs": first_play.get("interactiveAtVirtualMs"),
"firstFeedbackMs": first_play.get("firstFeedbackAtVirtualMs"),
"loopClosed": first_play.get("loopClosed"),
"proofRefs": first_play.get("proofObligationRefs"),
},
}
def _v3_publishable(summary: dict, v3: dict, *, release_binding_ok: bool) -> bool:
"""复用 canonical 发布谓词,并要求本次任务、摘要和产物均绑定到同一封存决策。"""
if not release_binding_ok:
return False
if not cheap_verify.is_v3_publishable(v3):
return False
compatibility = v3.get("compatibility") if isinstance(v3.get("compatibility"), dict) else {}
# 直接离线回放时 summary 就是封存对象,没有平铺层;生产 run-summary 则要求已有字段逐字镜像。
if summary is not v3:
for key in ("accepted", "ok", "acceptanceVersion", "publishFrozen", "schemaVersion", "sourceRollId"):
if key in summary and summary.get(key) != compatibility.get(key):
return False
return True
def _v3_observation_state(v3: dict, publishable: bool, canonical_valid: bool) -> str:
"""把不可发布原因压成稳定观测态;不参与发布判定。"""
if not v3:
return "downgrade_detected"
mode = v3.get("acceptanceMode")
decision = v3.get("decision") if isinstance(v3.get("decision"), dict) else {}
# historical_replay 与未知模式即使对象本身 contract 合法,也只供离线校准,不能伪装成 active/frozen。
if not isinstance(mode, str) or mode not in _V3_MODES:
return "downgrade_detected"
if mode == "v3_shadow":
return "shadow"
outcome = decision.get("outcome")
if outcome == "accept" and decision.get("publishFrozen") is True:
return "frozen"
if not canonical_valid:
return "downgrade_detected"
if outcome in ("reject", "inconclusive", "tester_error"):
return outcome
if publishable:
return "active"
# 完整对象存在但 canonical 校验未过,仍按协议降级单列,不能伪装成普通玩法失败。
return "downgrade_detected"
def newapi_error_status_text(exc) -> tuple:
"""从一个 new-api 调用异常里尽力抽出 (http_status, 错误体文本),供 classify_newapi_failure_reason 分辨(§3.9)。
兼容 openai SDK 的 APIStatusError(.status_code / .response.status_code / .body / .message)与被上层框架
(AgentScope)包装后的普通异常(只剩 str)。抽不到 status 返 None,文本恒为 str(exc) 兜底关键词匹配。
"""
status = getattr(exc, "status_code", None)
if status is None:
status = getattr(exc, "code", None)
if status is None:
resp = getattr(exc, "response", None)
status = getattr(resp, "status_code", None) if resp is not None else None
parts = []
for attr in ("message", "body"):
v = getattr(exc, attr, None)
if v:
parts.append(str(v))
parts.append(str(exc))
return status, " ".join(parts)
def classify_newapi_failure_reason(status=None, text: str = "") -> str:
"""据 new-api 真实响应把网关非 2xx 分辨成失败因(WU2 §3.9)。
返回 'quota_exhausted'(额度耗尽,干净失败、不可重试)或 'llm_error'(凭据失效/其他,可重试、不塌成额度耗尽)。
判据(2026-07-08 真机耗尽探测坐实,取代旧的纯状态码假设):new-api 对**额度耗尽返 HTTP 401**、
message 含「该令牌额度已用尽 !token.UnlimitedQuota && token.RemainQuota = 0」——**401 与凭据失效状态码重叠**,
故必须按错误体 message 分辨、不能只看状态码(旧代码 401→llm_error 会把耗尽误判成可重试 → 反复重试到
watchdog 超时,而非干净"额度用尽")。所以:① message 命中额度关键词 → quota_exhausted(优先,不看状态码);
② 否则 HTTP 402/403(one-api 惯例额度码,不与凭据失效重叠)→ quota_exhausted;③ 其余(401 无额度关键词
= 真凭据失效 / 其他)→ llm_error 可重试。
"""
try:
s = int(status) if status is not None else None
except (TypeError, ValueError):
s = None
t = (text or "").lower()
# 额度耗尽关键词(实测 new-api 中文 "该令牌额度已用尽" + 错误体内联判定条件 "RemainQuota = 0";兼容 one-api 英文/通用)。
quota_markers = (
"额度已用尽", "额度已用完", "额度不足", "令牌额度", "余额不足", # new-api/one-api 中文
"remainquota = 0", "remainquota=0", # new-api 错误体内联条件文本
"insufficient user quota", "insufficient quota", "quota not enough", # one-api 英文
)
if any(m in t for m in quota_markers):
return "quota_exhausted" # ① message 优先:catch new-api 真实的 401+额度耗尽
if s in (402, 403):
return "quota_exhausted" # ② one-api 惯例额度码(与凭据失效 401 不重叠,保守判耗尽)
return "llm_error" # ③ 401 无额度关键词=真凭据失效 / 其他 → 可重试
# 源工程 2.0 profile 必填三枚举(contracts/agent-loop/source-project.schema.json:39)。
_REQUIRED_PROFILE_KEYS = ("tickModel", "inputModel", "progressModel")
_SOURCE_PROJECT_SCAFFOLDS = {
"_template-story", "_template-trpg", "_template-feiyi", "_template-puzzle", "_template-shop",
}
def _valid_profile(profile) -> bool:
"""profile 是否含齐三枚举且非空——派生不出即视为无效(省略 sourceProject、不伪造)。"""
return isinstance(profile, dict) and all(profile.get(k) for k in _REQUIRED_PROFILE_KEYS)
def _source_hash(files: dict) -> str:
"""规范化 files(按路径排序的紧凑 JSON)的 sha256——源可寻址/幂等键(schema 2.0 sourceHash)。"""
canon = json.dumps(files, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canon.encode("utf-8")).hexdigest()
def build_source_project(game_dir, profile, entry: str = "entry.js", *, scaffold_template=None,
origin_brief=None, origin_brief_hash=None) -> str | None:
"""best-effort 组源工程 2.0 JSON 字符串(additive 可选源血缘)。
读 amgen-<id>/src/*.js 组 files、算 sourceHash、填 profile可信 create 路可附 scaffoldTemplate
供后续 v3 modify 锁定原 proof profile。**profile 三枚举不全 / src 空 / template 非白名单 → 返 None**
(省略 sourceProject,后端源落库旁路、不阻断成功,绝不为凑 schema 伪造 profile)。
Args:
game_dir: amgen-<id>/ 目录(读其下 src/)。
profile: {tickModel,inputModel,progressModel};上游从 play-spec/品类派生,派生不出传 None。
entry: esbuild 入口相对路径(默认 entry.js)。
scaffold_template: 已封存 acceptanceV3.templateRoute历史记录可不传禁止传任意模板名。
origin_brief: create 时冻结的原始题面active v3 必传,历史记录可不传。
origin_brief_hash: origin_brief 的 UTF-8 sha256与题面不自洽时拒绝组源。
Returns:
合法 2.0 源工程 JSON 字符串,或 None(无法组出合法 2.0 时)。
"""
src_dir = Path(game_dir) / "src"
if not src_dir.is_dir():
return None
files = {}
for p in sorted(src_dir.glob("*.js")):
files[f"src/{p.name}"] = p.read_text(encoding="utf-8")
if not files or not _valid_profile(profile):
return None
if scaffold_template is not None and scaffold_template not in _SOURCE_PROJECT_SCAFFOLDS:
return None
if origin_brief is not None or origin_brief_hash is not None:
if (not isinstance(origin_brief, str) or not origin_brief.strip()
or not isinstance(origin_brief_hash, str)
or hashlib.sha256(origin_brief.encode("utf-8")).hexdigest() != origin_brief_hash):
return None
source_project = {
"schemaVersion": "2.0",
"sourceHash": _source_hash(files),
"profile": profile,
"files": files,
"entry": entry,
"globalName": "__GameBundle",
}
# sourceHash 的冻结契约明确只覆盖 files不暗改既有可寻址语义scaffoldTemplate 随完整
# sourceProject JSON 由可信回调和数据库 canonical hash 封存,跨请求消费时再与 acceptance identity 对账。
if scaffold_template is not None:
source_project["scaffoldTemplate"] = scaffold_template
if origin_brief is not None:
source_project["originBrief"] = origin_brief
source_project["originBriefHash"] = origin_brief_hash
return json.dumps(source_project, ensure_ascii=False)
def _derive_title(brief: str) -> str:
"""从一句话创意派生标题(供后端 meta 段派生 cover/summary);截断防超 META_TITLE_MAX。"""
brief = (brief or "").strip()
return brief[:20] if brief else "便宜档小游戏"
def _read_bundle(game_dir: Path) -> str | None:
"""读 amgen-<id>/bundle.iife.js 全文(engineBundle 真值);缺失返 None。"""
p = Path(game_dir) / "bundle.iife.js"
try:
return p.read_text(encoding="utf-8") if p.is_file() else None
except (OSError, UnicodeError):
return None
def _capture_artifact_snapshot(root: Path) -> artifact_snapshot.ArtifactSnapshot:
"""发布入口复用验收入口的唯一可信快照实现。"""
return artifact_snapshot.capture_artifact_snapshot(root)
def _source_files_from_snapshot(snapshot: artifact_snapshot.ArtifactSnapshot) -> dict | None:
"""只从已封存快照解码 SourceProject 的直接 ``src/*.js`` 文件。"""
files = {}
for relative, data in snapshot.files.items():
if not relative.startswith("src/") or not relative.endswith(".js"):
continue
if "/" in relative[len("src/"):]:
continue
files[relative] = data.decode("utf-8")
return files or None
def _v3_source_project_matches_staged(source_project, v3: dict, staged_files) -> bool:
"""复核 v3 SourceProject 确实描述本次验收的 staged 源,而不是发布目录的后验漂移。"""
try:
value = json.loads(source_project) if isinstance(source_project, str) else source_project
except (TypeError, ValueError):
return False
if not isinstance(value, dict) or not isinstance(staged_files, dict) or not staged_files:
return False
if (value.get("schemaVersion") != "2.0" or value.get("globalName") != "__GameBundle"
or value.get("entry") != "entry.js" or not _valid_profile(value.get("profile"))
or value.get("scaffoldTemplate") != v3.get("templateRoute")
or not isinstance(value.get("originBrief"), str) or not value["originBrief"].strip()
or value.get("originBriefHash") != v3.get("briefHash")
or hashlib.sha256(value["originBrief"].encode("utf-8")).hexdigest() != value.get("originBriefHash")
or value.get("files") != staged_files
or value.get("sourceHash") != _source_hash(staged_files)):
return False
return True
def _v3_release_binding(job: dict, summary: dict, v3: dict, game_dir) -> dict:
"""把 active v3 的 accept 绑定到当前 job、当前 run-summary 与当前暂存/发布 bundle。
``decision.json`` 只证明某次验收接受了某个 staged artifact它本身不能授权另一任务、另一题面或
后续被替换的 bundle。这里在最终回调边界重新计算 staged artifactHash并要求发布目录 bundle 与
staged bundle 原始字节完全相同。任何字段缺失、路径异常或字节漂移都只返回不可发布,不抛出回调线程。
"""
result = {"ok": False, "bundleText": None, "engineBundleHash": None, "stagedSourceFiles": None}
try:
game_id_value = job.get("gameId")
if game_id_value is None:
return result
game_id = str(game_id_value)
if not cheap_verify._is_safe_game_id_v3(game_id):
return result
if v3.get("gameId") != game_id or str(summary.get("gameId")) != game_id:
return result
trace_id = job.get("traceId") or job.get("job_id")
if v3.get("taskBindingHash") != cheap_verify.task_binding_hash_v3(trace_id):
return result
# create 的 job.brief 就是验收题面modify/regen 的 brief 是修改指令,原题面由 SourceProject/本地
# brief 文件继承,因此只在 create 路执行此等值,和 Java callback gate 保持同一口径。
is_modify = bool(job.get("modifyMode")) or job.get("baseVersionId") not in (None, "")
current_brief_hash = hashlib.sha256(str(job.get("brief") or "").encode("utf-8")).hexdigest()
if not is_modify and v3.get("briefHash") != current_brief_hash:
return result
# run-summary 必须显式镜像本次刚完成的封存身份。直接拿旧 decision.json 作为 summary或把旧
# acceptanceV3 塞进新摘要,都无法满足这组逐字段绑定。
summary_identity = {
"acceptanceRunId": v3.get("runId"),
"acceptanceRequestHash": v3.get("acceptanceRequestHash"),
"acceptanceTaskBindingHash": v3.get("taskBindingHash"),
"acceptanceArtifactHash": v3.get("artifactHash"),
"acceptanceBriefHash": v3.get("briefHash"),
}
if any(summary.get(key) != expected for key, expected in summary_identity.items()):
return result
release_dir = Path(game_dir)
if release_dir.is_symlink() or release_dir.name != f"amgen-{game_id}":
return result
staged_dir = release_dir.parent / "_wg1-gen" / game_id
if not staged_dir.is_dir() or staged_dir.is_symlink():
return result
# 一次捕获后立即在内存快照上复算验收 hash之后 bundle/source 均只读 snapshot
# 不再用“前后两次目录 hash”推断中间读取没有遭遇改写→恢复。
snapshot = _capture_artifact_snapshot(staged_dir)
if snapshot.artifact_hash != v3.get("artifactHash"):
return result
release_bundle = release_dir / "bundle.iife.js"
staged_bytes = snapshot.files.get("bundle.iife.js")
if staged_bytes is None or not release_bundle.is_file() or release_bundle.is_symlink():
return result
if release_bundle.read_bytes() != staged_bytes:
return result
bundle_text = staged_bytes.decode("utf-8")
if "__GameBundle" not in bundle_text:
return result
engine_bundle_hash = hashlib.sha256(staged_bytes).hexdigest()
# 最终 callback 发送的是该文本;显式复算,防未来 decode/变换改动让 trace hash 与真实文本分叉。
if hashlib.sha256(bundle_text.encode("utf-8")).hexdigest() != engine_bundle_hash:
return result
staged_source_files = _source_files_from_snapshot(snapshot)
return {"ok": True, "bundleText": bundle_text, "engineBundleHash": engine_bundle_hash,
"stagedSourceFiles": staged_source_files}
except (OSError, UnicodeError, ValueError, TypeError):
return result
def _map_failure_reason(summary: dict, explicit: str | None, *, trust_summary: bool = True) -> str:
"""把 run-summary 的失败信号映射到契约#6 failureReason 枚举。
优先级:显式入参(在枚举内)> summary.failureReason(在枚举内)> catch-all `llm_error`。
summary.failureReason 是 WU2 §3.9 的桥:Service 侧在模型调用抛错时按 one-api 惯例分辨额度耗尽,
经收口 sidecar → driver._build_summary 透传到 summary,这里据它落成 quota_exhausted(而非含糊 llm_error)。
其余图内部真因[九门不过 / 未收敛 / 预算熔断 / engineBundle 缺]不在枚举内 → 归「生成执行链异常」最近桶
(SaaGraphDispatcher 同口径,真因已落 summary/日志);枚举无 budget/generation 专项值,不强造、统一 llm_error。
"""
if isinstance(explicit, str) and explicit in _FAILURE_REASONS:
return explicit
if trust_summary:
svc_reason = (summary or {}).get("failureReason")
if isinstance(svc_reason, str) and svc_reason in _FAILURE_REASONS:
return svc_reason
return "llm_error"
def _build_trace(summary: dict, *, v3: dict | None, v3_publishable: bool,
v3_canonical_valid: bool, engine_bundle_hash: str | None) -> dict | None:
"""从(U1 富化的)run-summary 组 9d trace(camelCase,镜像 wg1 service._extract_trace;后端 D11
ReadinessScorer 据此算就绪分)。成功/失败路都抽(失败路 repairs/attempts/stage/wallS 仍有排障价值);
summary 空(兜底路)→ None(不塞 trace)。
⚠️ trace 是后端开放 Map(DifyCallbackReqVO.trace),Jackson **不拒 trace 内层未知键**——键名拼错
(如 seven_gate_verdict / driverType)会被静默接收、ReadinessScorer 读不到 → 悄悄落中性(假绿)。
故内层键名必须逐字镜像 ReadinessScorer 读取口径。
必填七项:pass/repairs/wallS/models/attempts/gameId/stage
· pass = summary.verdict.pass(brief 既有键);repairs = max(0, attempts-1)(首轮 attempts=1→0,
对齐 wg1 len(attempts)-1);wallS = summary.wallSec;models/attempts/gameId/stage = U1 富化字段直透。
D11 首局维(None 容错:verdictFull/driverType 为 None 的对照路 → 省略键,不塞 None):
· sevenGateVerdict 只取 {pass,guards}(从 verdictFull 去 url/ts 噪声;guards.H_progress 保嵌套含 .pass);
· gatespec 键名必须是 `driver`(值=driverType;落成 `driverType` 会让后端 hasDriver=false、firstPlay 永中性)。
similarity:U3 D9 查重产,有则带、缺则省。
"""
if not summary:
return None
attempts = summary.get("attempts")
# repairs = 自修复轮数 = max(0, attempts-1)(首轮 attempts=1 → repairs=0);attempts 非 int 时降 None。
repairs = max(0, attempts - 1) if isinstance(attempts, int) else None
verdict_brief = summary.get("verdict") or {}
trace = {
"pass": verdict_brief.get("pass"),
"repairs": repairs,
"wallS": summary.get("wallSec"),
"models": summary.get("models"),
"attempts": attempts,
"gameId": summary.get("gameId"),
"stage": summary.get("stage"),
}
# sevenGateVerdict:只 {pass,guards}(去 url/ts 噪声,对齐 _extract_trace:148-154);verdictFull=None → 省略。
vf = summary.get("verdictFull")
if isinstance(vf, dict):
trace["sevenGateVerdict"] = {"pass": vf.get("pass"), "guards": vf.get("guards")}
# gatespec:键名必须 driver(driverType=None 的对照路 → 省略,不塞 None)。
driver_type = summary.get("driverType")
if driver_type is not None:
trace["gatespec"] = {"driver": driver_type}
# cost.totalRmb(维持现状,additive 成本台账;顶层不可有 costRmb,会被后端 ObjectMapper 拒)。
# v3 三路接线若未把成本平铺回 summary则从封存结果取同一笔验收成本。
cost_rmb = summary.get("costRmb")
if cost_rmb is None and v3 is not None:
cost_rmb = v3.get("costRmb")
if cost_rmb is not None:
trace.setdefault("cost", {})["totalRmb"] = cost_rmb
# similarity(U3 D9 查重产;additive,有则带)。
similarity = summary.get("similarity")
if similarity is not None:
trace["similarity"] = similarity
# gameplayJudge(W-AXIS 波2):additive 透传玩法地板判定裁决,给观测线区分「玩法判定失败」类目
# (后端 GenMetrics/OTLP 消费前 Jackson 静默接收、ReadinessScorer 不读 → 无害;消费后据它补 gate 归因)。
# 只带轻量裁决字段(不塞完整 checks/截图):accepted 是最终验收权威,rejectClasses 供 gate 桶归因。
judge = None
if v3:
judge = v3.get("judge")
elif v3 is None:
judge = summary.get("judge")
if isinstance(judge, dict):
trace["gameplayJudge"] = {
# v3 下这是独立 Judge 摘要,不能误用 shadow 的 compatibility.accepted=false 覆盖 Judge 自身裁决。
"accepted": judge.get("decision") == "accept",
"verdict": judge.get("decision"),
"rejectClasses": [judge.get("failureSignature")] if judge.get("failureSignature") else [],
"degraded": judge.get("degraded"),
"blocking": judge.get("conflicts"),
"ran": True,
}
# playtest(W-AXIS-V2 波1):additive 透传测试员真玩裁决 + firstPlay 三字段(§5 首局门语义并入,后端
# ReadinessScorer.scoreFirstPlay 改读 trace.playtest.firstPlay)。trace.gameplayJudge 双写窗口保留(旧消费面)。
playtest = summary.get("playtest")
if v3:
# v3 真相层优先于所有平铺兼容字段;后端按 outcome/readiness/冻结位消费,旧 accepted 只保留派生值。
trace["playtest"] = _v3_playtest_projection(v3, engine_bundle_hash=engine_bundle_hash)
elif v3 is None and isinstance(playtest, dict):
fp = playtest.get("firstPlay") if isinstance(playtest.get("firstPlay"), dict) else {}
trace["playtest"] = {
"accepted": playtest.get("accepted"),
"verdict": playtest.get("verdict"),
"degraded": playtest.get("degraded"),
"rollCount": playtest.get("rollCount"),
# firstPlay 键名逐字镜像 ReadinessScorer 读取口径(playableAtMs/firstFeedbackMs/loopClosed)。
"firstPlay": {"playableAtMs": fp.get("playableAtMs"), "firstFeedbackMs": fp.get("firstFeedbackMs"),
"loopClosed": fp.get("loopClosed")},
}
# floor 四门投影(预筛权威)与 acceptanceVersion:additive,后端可据它区分预筛与最终验收、对账 shadow/v2。
floor = summary.get("floor")
if not isinstance(floor, dict) and v3:
floor = v3.get("floor")
if isinstance(floor, dict):
trace["floor"] = {"pass": floor.get("pass"), "gates": floor.get("gates")}
av = summary.get("acceptanceVersion")
if v3 is not None:
av = (v3.get("acceptanceMode") if v3 else None) or _declared_v3_mode(summary) or av
if av is not None:
trace["acceptanceVersion"] = av
if v3 is not None:
decision = v3.get("decision") if v3 and isinstance(v3.get("decision"), dict) else {}
# 根级状态让 Java/指标在 playtest 投影丢失时仍能识别 v3 降级,不能回落 legacy pass=true。
trace["acceptanceState"] = _v3_observation_state(v3, v3_publishable, v3_canonical_valid)
trace["publishFrozen"] = decision.get("publishFrozen") if v3 else True
return trace
def build_result_out(job: dict, summary: dict, game_dir, *,
failure_reason: str | None = None, source_project: str | None = None,
expected_acceptance_mode: str | None = None) -> dict:
"""据 §6.1 job-in + run-summary + 产物目录组 result-out(DifyCallbackReqVO 形态)。
Args:
job: §6.1 job-in(取 traceId/job_id、templateId、brief)。
summary: run_studio 返回的 run-summary dict(取 verdict.pass 判成败、costRmb 透传)。
game_dir: 本 run 的 amgen-<id>/ 目录(读 bundle.iife.js)。
failure_reason: 失败路显式失败原因(可空,缺则从 summary 推)。
source_project: best-effort 源工程 2.0 JSON 字符串(可空=省略;由 build_source_project 预组)。
expected_acceptance_mode: 可信 worker 在入队前冻结的验收代际;值为 v3/v3_shadow 时summary 即使
被全量剥掉 marker 也不得回落 legacy。
Returns:
result-out dict(traceId/status/templateId/gameConfig/assets[+engineBundle|+failureReason]
[+trace.cost][+sourceProject];字段严格限 DifyCallbackReqVO 已知集,后端拒未知字段)。
"""
trace_id = job.get("traceId") or job.get("job_id")
template_id = job.get("templateId") or "generic"
brief = job.get("brief") or ""
bundle_text = _read_bundle(game_dir)
callback_bundle_text = bundle_text
verdict = summary.get("verdict") or {}
vf = summary.get("verdictFull") if isinstance(summary.get("verdictFull"), dict) else verdict
# W-AXIS-V2 波1:预筛权威 = 四门投影(floor)。三级取值(注意 summary.verdict 是 brief 版、无 guards):
# ① summary.floor(run_acceptance 落,生产路)→ 直接用;
# ② verdictFull 带 guards(老产物 / W-AXIS 波2 路)→ 现算 project_floor(四门投影,**不复用**九门 verdict.pass);
# ③ 完全无 guards(极老产物 / 契约桩只给 verdict.pass)→ 回落 verdict.pass 作预筛(向后兼容、不误拒;无 guards
# 的假绿在上游 gate_judge/run_acceptance 已拦,build_result_out 是最终契约组装、非验收门)。
if isinstance(summary.get("floor"), dict):
floor = summary["floor"]
elif vf.get("guards"):
floor = cheap_verify.project_floor(vf)
else:
floor = {"pass": bool(verdict.get("pass")), "gates": {}}
prefilter_pass = bool(floor.get("pass"))
# v3 以封存的 decision 为唯一权威active v3 + accept + 未冻结 + 完整硬证才允许 succeeded。
# 旧 v2/存量结果没有 acceptanceV3继续按 accepted → 四门投影回落,保持兼容窗口不受影响。
v3 = _acceptance_v3_payload(summary)
expected_v3 = expected_acceptance_mode in _V3_MODES
if expected_v3 and v3 is None:
# 显式预期是 v3但 summary 的 acceptanceV3/marker 全丢失:以空 v3 进入统一降级路径,
# 关闭“剥掉全部新协议字段后被误当真 legacy”的最后一个窗口。
v3 = {}
v3_publishable = False
v3_canonical_valid = False
engine_bundle_hash = None
if v3 is not None:
release_binding = _v3_release_binding(job, summary, v3, game_dir)
callback_bundle_text = release_binding["bundleText"]
engine_bundle_hash = release_binding["engineBundleHash"]
# active accept 主路只跑一次 helper仅不可发布时再取错误列表用于区分正常四态与协议降级观测。
expected_mode_matches = not expected_v3 or v3.get("acceptanceMode") == expected_acceptance_mode
source_binding_ok = _v3_source_project_matches_staged(
source_project, v3, release_binding.get("stagedSourceFiles"))
v3_publishable = _v3_publishable(
summary, v3, release_binding_ok=(release_binding["ok"] and expected_mode_matches
and source_binding_ok))
v3_canonical_valid = v3_publishable or not cheap_verify.validate_acceptance_v3_payload(v3)
gates_pass = v3_publishable
else:
accepted = summary.get("accepted")
gates_pass = (accepted is True) if accepted is not None else prefilter_pass
engine_ok = bool(callback_bundle_text) and "__GameBundle" in callback_bundle_text
status = "succeeded" if (gates_pass and engine_ok) else "failed"
payload = {
"traceId": trace_id,
"status": status,
"templateId": template_id,
# gameConfig 最小合法占位(engine 路逻辑全在 engineBundle,gameConfig 仍不可空)。
"gameConfig": {
"templateId": template_id,
"title": _derive_title(brief),
"theme": "generic",
"engineDriven": True,
},
"assets": [],
}
if status == "succeeded":
# 成功路携 bundle 全文 → 后端落 GamePackage.engineBundle 入 feed。
payload["engineBundle"] = callback_bundle_text
else:
# 失败路:后端据 status=failed 把任务置 FAILED;failureReason 供排障(七值枚举)。
# playtest/3 已有权威 outcome/failure 投影;顶层 failureReason 可能是旧 worker 遗留值,
# 不能把玩法拒绝误归为 unsafe_prompt 等旧桶。v3 精确真因只从 trace 读取,旧枚举统一落兼容 catch-all。
payload["failureReason"] = _map_failure_reason(
summary, failure_reason if v3 is None else None, trust_summary=v3 is None)
# ── M3b U2:组 9d trace(必填七项 + D11 首局维 sevenGateVerdict/gatespec + cost + similarity)──
# camelCase 逐字镜像 wg1 _extract_trace + 后端 ReadinessScorer 读取口径;成功/失败路都抽,summary 空则省略。
trace = _build_trace(summary, v3=v3, v3_publishable=v3_publishable,
v3_canonical_valid=v3_canonical_valid,
engine_bundle_hash=engine_bundle_hash)
if trace:
payload["trace"] = trace
# legacy 维持原 best-effort 行为v3 只允许成功回调携带与 acceptance artifactHash 同源的 staged 源。
# v3 失败、release/src 后验漂移、sourceHash/templateRoute 不一致时均省略,避免落成可被 A11 继承的错误血缘。
if source_project is not None:
if v3 is None or (status == "succeeded"
and _v3_source_project_matches_staged(
source_project, v3, release_binding.get("stagedSourceFiles"))):
payload["sourceProject"] = source_project
return payload