"""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-/src/*.js 组 files、算 sourceHash、填 profile;可信 create 路可附 scaffoldTemplate, 供后续 v3 modify 锁定原 proof profile。**profile 三枚举不全 / src 空 / template 非白名单 → 返 None** (省略 sourceProject,后端源落库旁路、不阻断成功,绝不为凑 schema 伪造 profile)。 Args: game_dir: amgen-/ 目录(读其下 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-/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-/ 目录(读 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