- llm_client: usage 累加器(prompt/completion/total/calls_counted)+成本估算(单价假设PRICE_PER_MTOKEN_YUAN,环境变量可覆盖) - run_batch: meta 注入 tokenUsage; report: 成本段渲染总token/均次/估算成本/单游戏成本 - 单测 +3 绿(累加/缺usage不臆造/成本估算); 真实数据待下次批跑自动采 - 模型§5/§7 标埋点就位; ③④广告/创作者埋点仍待后端(B2扩展,未动) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1270 lines
75 KiB
Python
1270 lines
75 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
编排器主入口(D3 件)—— spec:HJ-AGENT-LOOP-EXEC-001 §7(+§10 裁决接线、§11 失败路径)
|
||
|
||
职责:批次驱动 / 阶段流水 / 预算闸(§7.3)/ 熔断(§7.4)/ 发布段(§7.6)/
|
||
换模型抽检与对抗稳定性重测 / eval 回流(§7.7)/ 批报告(§7.5)。
|
||
编排器不做任何内容判断;发布函数仅接受裁决引擎铸章的 accept Verdict(代码层强制,§7.1)。
|
||
|
||
每创意阶段顺序(账本 stage 受控枚举的执行序):
|
||
submit → design → schema_gate → dedup_gate → adversary → [fix → 回炉重走] →
|
||
callback → verify_package → play → judge → (批末统一)publish → postcheck
|
||
依据:§3 mermaid「IDEAS → ST(draft+generate)」与设计路径并行起步(submit 先行),
|
||
保证任何文本面 kill(D2/D3/D4/D6)都有 taskId 可回调 failed(§16-1:任务必须有诚实终态),
|
||
且 Verdict.taskId(§6.2 必填)在 fix 中间 Verdict 上也可填。
|
||
|
||
发布段置于批末(换模型抽检之后):使 §7.4-4「抽检分歧 >10% 冻结本批发布开关」
|
||
成为真实可执行的闸(若边跑边发布,冻结阀对本批将形同虚设;F12 的「已发布金丝雀保留」
|
||
仍覆盖跨 resume 场景)。
|
||
|
||
designId 口径(§16 D2-a 裁决):designId=sha256(idea+templateId+round) **含 round 因子**——
|
||
round=0(root)与 round=1(回炉轮)两轮 designId 必不同;账本阶段/judge/verdict 行落当前轮
|
||
designId、幂等重放以各轮 designId 为键;rootDesignId(=round0 designId)由阶段行 data 携带,
|
||
作 fix 前后对照与报告/eval 回流的创意级关联键。预算闸 ①LLM/②实玩 为「每创意」口径,一律以
|
||
root_id 计数(防回炉轮换 designId 后预算翻倍)。
|
||
|
||
环境铁律:staging app(100.64.0.7:48080)在跑勿动;NEWAPI_KEY 只走环境变量,缺失即退出;
|
||
试玩环境探活失败 → 整批暂停(§7.4-3,禁降级为「跳过试玩」)。
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import random
|
||
import sys
|
||
import time
|
||
import urllib.request
|
||
from pathlib import Path
|
||
|
||
# 同目录模块(脚本直跑时保证可导入)
|
||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||
if _HERE not in sys.path:
|
||
sys.path.insert(0, _HERE)
|
||
|
||
import backend_gw
|
||
import evalflow
|
||
import judge as judge_mod
|
||
import ledger as ledger_mod
|
||
import llm_client
|
||
import prompts as prompts_mod
|
||
import report as report_mod
|
||
|
||
# 仓库根(本文件位于 docs/agent-specs/2026-06-09-agent-loop-v1/orchestrator/)
|
||
_REPO = os.path.abspath(os.path.join(_HERE, "..", "..", "..", ".."))
|
||
_LOOP_ROOT = os.path.abspath(os.path.join(_HERE, ".."))
|
||
|
||
# v1 仅 clicker 走全闭环(§1.1)
|
||
TEMPLATE_ID = "clicker"
|
||
# 三条 prompt 的 Registry id(D1 交付,§5.1/§5.3)
|
||
PROMPT_DESIGNER = "config.clicker-designer"
|
||
PROMPT_ADVERSARY = "quality.adversary-review"
|
||
PROMPT_FIX = "fix.design-revise"
|
||
|
||
# F6:succeeded 回调后任务轮询预算(>60s 仍未达 succeeded → infra_task_poll)
|
||
TASK_POLL_TIMEOUT_SECONDS = 60
|
||
TASK_POLL_INTERVAL_SECONDS = 2
|
||
# 对抗 agent 输出形状异常的应用层重试上限(通道层重试在 llm_client 内)
|
||
ADVERSARY_SHAPE_RETRIES = 2
|
||
|
||
# 退出码约定(§7.4 熔断触发即退出码非 0)
|
||
EXIT_OK = 0
|
||
EXIT_CONFIG = 2 # 配置/密钥/契约件缺失
|
||
EXIT_ENV_HALT = 3 # §7.4-3 试玩环境不可用整批暂停
|
||
EXIT_INFRA_TRIP = 4 # §7.4-1 infra 占比熔断
|
||
EXIT_KILL_STREAK = 5 # §7.4-2 连续同因 kill 熔断
|
||
|
||
|
||
def _log(msg):
|
||
"""统一中文日志(可追溯:时间戳+消息)。"""
|
||
print("[%s] %s" % (time.strftime("%H:%M:%S"), msg), flush=True)
|
||
|
||
|
||
class OrchestratorError(Exception):
|
||
"""编排器自身缺陷或环境硬失败(非单创意级),需要立即暴露。"""
|
||
|
||
|
||
class BatchHalt(Exception):
|
||
"""整批中止信号(携退出码与中文原因)。"""
|
||
|
||
def __init__(self, exit_code, reason):
|
||
super(BatchHalt, self).__init__(reason)
|
||
self.exit_code = exit_code
|
||
self.reason = reason
|
||
|
||
|
||
def _norm_text(text):
|
||
"""查重归一化:NFKC + casefold + 去空白与常见中英文标点(§7.1 批内 title/theme 禁重)。"""
|
||
import re
|
||
import unicodedata
|
||
s = unicodedata.normalize("NFKC", text or "").casefold()
|
||
return re.sub(r"[\s,.;:!?'\"()\[\]{}<>~`@#$%^&*+=/\\|,。·、!?:;…“”‘’()《》【】—-]+", "", s)
|
||
|
||
|
||
class Orchestrator(object):
|
||
"""批次编排器:持有网关/账本/预算闸/熔断器与批内状态,按 §7 流水驱动。"""
|
||
|
||
def __init__(self, args):
|
||
self.args = args
|
||
self.batch_id = args.resume or args.batch_id
|
||
self.run_dir = os.path.join(args.runs_dir, self.batch_id)
|
||
self.ledger_path = os.path.join(self.run_dir, "ledger.jsonl")
|
||
|
||
# 新批拒绝复用已有目录(防误覆盖);续跑必须显式 --resume(§7.2)
|
||
if args.resume:
|
||
if not os.path.exists(self.ledger_path):
|
||
raise BatchHalt(EXIT_CONFIG, "--resume 指定批 %s 无账本:%s" % (self.batch_id, self.ledger_path))
|
||
elif os.path.exists(self.ledger_path):
|
||
raise BatchHalt(EXIT_CONFIG, "批 %s 账本已存在,续跑请用 --resume %s" % (self.batch_id, self.batch_id))
|
||
|
||
self.ledger = ledger_mod.Ledger(self.ledger_path, self.batch_id)
|
||
self.resume_state, bad = ledger_mod.Ledger.resume_state(self.ledger_path)
|
||
if bad:
|
||
_log("账本存在 %d 条坏行(崩溃残行),已跳过" % bad)
|
||
|
||
self.budget = ledger_mod.BudgetGuard()
|
||
self.breaker = ledger_mod.CircuitBreaker()
|
||
self.browser_pool = None # probe_environment 中按 --cdp 构建(D5 BrowserPool,≤2 实例)
|
||
self.backend = backend_gw.BackendGateway(base=args.backend, log=_log)
|
||
self.llm = llm_client.LlmClient() # 缺 NEWAPI_KEY 在 main 已拦,这里再设防
|
||
self.prompts = prompts_mod.PromptStore(os.path.join(_REPO, "contracts", "prompts"))
|
||
self.prompts.require([PROMPT_DESIGNER, PROMPT_ADVERSARY, PROMPT_FIX])
|
||
|
||
# 模板 schema(D2 契约件):缺失即停(深度校验权威在编排器侧,§8.4-3)
|
||
schema_path = os.path.join(_REPO, "contracts", "templates", "%s.schema.json" % TEMPLATE_ID)
|
||
if not os.path.exists(schema_path):
|
||
raise BatchHalt(EXIT_CONFIG, "模板 schema 缺失:%s(D2 契约件未就位)" % schema_path)
|
||
with open(schema_path, "r", encoding="utf-8") as fh:
|
||
self.template_schema_text = fh.read()
|
||
self.template_schema = json.loads(self.template_schema_text)
|
||
|
||
# 批内查重登记表:rootDesignId(创意级)→ {titleNorm, themeNorm, title, theme}
|
||
#(resume 时从账本回灌;行 data.rootDesignId 缺位时回落该行 designId——兼容 round0 行)
|
||
self.dedup_registry = {}
|
||
for design_id, info in self.resume_state.items():
|
||
for (rnd, stage), data in info["ok_stages"].items():
|
||
if stage == "dedup_gate" and data.get("titleNorm"):
|
||
self.dedup_registry[data.get("rootDesignId") or design_id] = {
|
||
"titleNorm": data["titleNorm"], "themeNorm": data.get("themeNorm", ""),
|
||
"title": data.get("title", ""), "theme": data.get("theme", ""),
|
||
}
|
||
|
||
# 本批已发布数(金丝雀跨 resume 累计,§7.3-⑥)
|
||
self.published_count = sum(1 for i in self.resume_state.values() if i["published"])
|
||
|
||
# 本次运行的运行期记录(报告/抽检/稳定性重测用)
|
||
self.accept_records = [] # [{designId, verdict(Sealed), playReport, round, gameId, taskId, playAttempt}]
|
||
self.adversary_records = [] # [{designId, gameDesign, findings, round}](稳定性重测样本池)
|
||
self.processed_ideas = set() # 本次运行已处理(terminal/infra/skip)的创意原文(超时收口豁免名单)
|
||
self.timed_out = False
|
||
self.prompt_versions_seen = {}
|
||
|
||
# ================================================================ 工具
|
||
|
||
def _stage(self, design_id, round_, stage, status, data=None, **kw):
|
||
"""账本阶段行 + 同步中文日志(外部交互/错误路径全可溯)。"""
|
||
_log("stage[%s] %s round=%d → %s %s"
|
||
% (design_id[:8], stage, round_, status, (data or {}).get("reason", "")))
|
||
return self.ledger.append_stage(design_id, round_, stage, status, data=data, **kw)
|
||
|
||
def _judge_row(self, design_id, round_, outcome, **kw):
|
||
"""落 stage=judge 行(每次裁决调用都记账:行号/动作/理由全可溯)。"""
|
||
return self._stage(design_id, round_, "judge", "ok", data=outcome.as_dict(), **kw)
|
||
|
||
def _llm_call(self, design_id, system, user, model=None, count_budget=True):
|
||
"""带预算闸的 LLM 调用;返回 (content, attempts)。超预算抛 BudgetExceeded。"""
|
||
cb = (lambda: self.budget.note_llm_call(design_id)) if count_budget else None
|
||
return self.llm.chat_json(system, user, model=model, budget_cb=cb, log=_log)
|
||
|
||
def _check_batch_clock(self):
|
||
"""③ 批时长闸:超 30 分钟抛 BatchTimeout 信号(用 BudgetExceeded 类别区分)。"""
|
||
if self.budget.batch_timed_out():
|
||
self.timed_out = True
|
||
raise ledger_mod.BudgetExceeded("批 30 分钟强制收口(§7.3-③)", category="batch_timeout")
|
||
|
||
# ================================================================ 环境探活(F9/§7.4-3)
|
||
|
||
def probe_environment(self):
|
||
"""
|
||
批前探活(F9/§7.4-3):staging / 前端 serve / 玩家 agent 模块 / CDP 端点(≤2,§7.3-④)。
|
||
任一失败 → 整批暂停(禁降级为「跳过试玩」——runnable 证据不可豁免)。
|
||
"""
|
||
try:
|
||
self.backend.probe_staging()
|
||
except Exception as ex:
|
||
self.breaker.halt_env("staging 探活失败:%s" % ex)
|
||
raise BatchHalt(EXIT_ENV_HALT, "staging 探活失败(F9 整批暂停):%s" % ex)
|
||
try:
|
||
req = urllib.request.Request(self.args.frontend, method="GET")
|
||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||
resp.read(64)
|
||
_log("前端 demo serve 探活通过:%s" % self.args.frontend)
|
||
except Exception as ex:
|
||
self.breaker.halt_env("前端 serve 探活失败:%s" % ex)
|
||
raise BatchHalt(EXIT_ENV_HALT, "前端 serve %s 探活失败(F9 整批暂停):%s" % (self.args.frontend, ex))
|
||
# 玩家 agent(D5 件)+ CDP 端点池(≤2 实例,README §2 启动配方)
|
||
player = self._load_player(required=True)
|
||
endpoints = [e.strip() for e in (self.args.cdp or "").split(",") if e.strip()]
|
||
endpoints = endpoints[: self.budget.BROWSER_POOL_MAX]
|
||
if not endpoints:
|
||
self.breaker.halt_env("未提供 CDP 端点(--cdp)")
|
||
raise BatchHalt(EXIT_ENV_HALT, "未提供 CDP 端点(--cdp,默认 http://127.0.0.1:9222)")
|
||
for endpoint in endpoints:
|
||
if player.probe_cdp(endpoint) is None:
|
||
self.breaker.halt_env("CDP 端点不可达:%s" % endpoint)
|
||
raise BatchHalt(EXIT_ENV_HALT,
|
||
"CDP 端点 %s 不可达(F9 整批暂停;启动配方见 orchestrator/README.md §2)" % endpoint)
|
||
self.browser_pool = player.BrowserPool(endpoints)
|
||
_log("浏览器池就绪:%s(≤%d 实例)" % (", ".join(endpoints), self.budget.BROWSER_POOL_MAX))
|
||
|
||
def _load_player(self, required=False):
|
||
"""延迟加载 D5 交付件 player_cdp.py(D3 只留位不实现)。缺位时按 F9 处理。"""
|
||
try:
|
||
import player_cdp # noqa: PLC0415 —— 延迟导入:D5 件可后置就位
|
||
return player_cdp
|
||
except ImportError as ex:
|
||
if required:
|
||
self.breaker.halt_env("player_cdp.py(D5)未就位:%s" % ex)
|
||
raise BatchHalt(EXIT_ENV_HALT,
|
||
"玩家 agent player_cdp.py 未就位(D5 交付件),按 §7.4-3 整批暂停:%s" % ex)
|
||
return None
|
||
|
||
# ================================================================ 单创意流水
|
||
|
||
def process_idea(self, idea):
|
||
"""
|
||
跑完一个创意的全流水(直至终判/中间态落账);返回 ('accept'|'kill'|'infra_fail'|'skip', reasons首项)。
|
||
|
||
designId 口径(§16 D2-a):designId=sha256(idea+templateId+round) **含 round 因子**——
|
||
round=0(root)与 round=1(回炉轮)designId 必不同;幂等重放以各轮 designId 为键;
|
||
rootDesignId(=round0 designId)作 fix 前后对照关联键(阶段行 data 携带,eval 回流落 labels)。
|
||
"""
|
||
root_id = ledger_mod.mint_design_id(idea, TEMPLATE_ID, 0)
|
||
r1_id = ledger_mod.mint_design_id(idea, TEMPLATE_ID, 1)
|
||
root_info = self.resume_state.get(root_id) or {}
|
||
r1_info = self.resume_state.get(r1_id) or {}
|
||
|
||
# —— 幂等重放(§7.2):任一轮 designId 已有终判 verdict(accept/kill)→ 创意整体跳过
|
||
if root_info.get("terminal") or r1_info.get("terminal"):
|
||
_log("rootDesignId=%s 已有终判 verdict,整体跳过(幂等重放)" % root_id[:8])
|
||
return "skip", None
|
||
|
||
# —— 断点档案合并:键 (round, stage) 天然分轮不冲突;infra 重放语义见下
|
||
recovered = {}
|
||
recovered.update(root_info.get("ok_stages") or {})
|
||
recovered.update(r1_info.get("ok_stages") or {})
|
||
if root_info.get("replay_from_submit"):
|
||
# round0 出过 infra 且其后无新产物:创意从头全重走(新 taskId;round0 文本面无 verdict 固化)
|
||
recovered = {}
|
||
_log("rootDesignId=%s round0 含 infra_fail,从 submit 重走(新 taskId,旧 task 留档)" % root_id[:8])
|
||
elif r1_info.get("replay_from_submit"):
|
||
# 回炉轮出过 infra:丢弃旧任务(强制新 taskId,§7.2「旧 task 留档不复用」),
|
||
# round0 文本面产物保留(其结论已由 fix verdict 固化,不重跑避免重复 verdict)
|
||
recovered.pop((0, "submit"), None)
|
||
recovered.pop((1, "submit"), None)
|
||
_log("rootDesignId=%s 回炉轮含 infra_fail,从 submit 重走(新 taskId)" % root_id[:8])
|
||
|
||
# —— fix 已签发(root 上有 decision=fix 的中间 Verdict):直接从回炉轮续跑
|
||
start_round = 1 if root_info.get("fix_issued") else 0
|
||
# 当前活动轮跟踪(_pipeline/_text_face 推进 round 时更新):预算闸兜底 infra 记账的归属轮。
|
||
# 必须精确到轮——若回炉轮超预算却记到 root 下,root 档案被 infra 重放语义清空,
|
||
# 而 root 已固化 fix verdict,resume 将无法恢复 fix 输入(死循环),故以此实时跟踪。
|
||
self._active = {"designId": root_id if start_round == 0 else r1_id,
|
||
"round": start_round, "rootDesignId": root_id}
|
||
|
||
try:
|
||
return self._pipeline(idea, root_id, r1_id, start_round, recovered)
|
||
except ledger_mod.BudgetExceeded as ex:
|
||
# 预算闸触发(①②③):按 D7 infra 处置;阶段标签按类别归位(账本可读归因);
|
||
# 记账轮归属=触发时的当前活动轮(见上 self._active 注释)
|
||
act_id, act_round = self._active["designId"], self._active["round"]
|
||
stage_by_category = {"llm_budget": "design", "play_budget": "play", "batch_timeout": "submit"}
|
||
outcome = judge_mod.judge(True, False, [], None, 0, True, act_round, infra_category=ex.category)
|
||
self._stage(act_id, act_round, "judge", "ok", data=outcome.as_dict())
|
||
self._stage(act_id, act_round, stage_by_category.get(ex.category, "design"),
|
||
"infra_fail", data={"reason": outcome.reasons[0], "note": str(ex),
|
||
"rootDesignId": root_id})
|
||
if ex.category == "batch_timeout":
|
||
raise # 批时钟超时上抛由主循环统一收口剩余创意
|
||
return "infra_fail", outcome.reasons[0]
|
||
|
||
def _pipeline(self, idea, root_id, r1_id, start_round, recovered):
|
||
"""
|
||
创意级阶段流水主体(submit → 文本面 → 回调 → 核验 → 实玩 → 终判)。
|
||
designId 轮级口径(§16 D2-a):阶段/judge/verdict 行落「当前轮 designId」;
|
||
预算闸(§7.3 ①②为每创意口径)一律以 root_id 计数。
|
||
"""
|
||
self._check_batch_clock()
|
||
|
||
# ---------------- submit:建草稿 + 提交生成(每创意一次;文本面 kill 也要有任务可终态化) ----------------
|
||
# 恢复优先级:回炉轮重建的新任务 (1,"submit") > 首轮任务 (0,"submit") > 新建
|
||
sub = recovered.get((1, "submit")) or recovered.get((0, "submit"))
|
||
submit_owner_id = r1_id if start_round == 1 else root_id # 新建 submit 行的归属轮
|
||
if sub and sub.get("taskId"):
|
||
self._stage(submit_owner_id, start_round, "submit", "skip",
|
||
data={"resumedFrom": "ledger", "rootDesignId": root_id, **sub})
|
||
task_id, trace_id, game_id = sub["taskId"], sub["traceId"], sub["gameId"]
|
||
else:
|
||
try:
|
||
draft = self.backend.create_draft(title=idea[:60], template_id=TEMPLATE_ID, prompt=idea)
|
||
chain = self.backend.studio_generate(draft["id"], idea)
|
||
task = self.backend.get_aigc_task(chain["aigcTaskId"])
|
||
task_id, trace_id, game_id = task["id"], task["traceId"], chain.get("gameId") or draft.get("gameId")
|
||
if not trace_id:
|
||
raise OrchestratorError("生成任务无 traceId,回调无法定位(taskId=%s)" % task_id)
|
||
self._stage(submit_owner_id, start_round, "submit", "ok",
|
||
data={"taskId": task_id, "traceId": trace_id, "gameId": game_id,
|
||
"sessionId": draft["id"], "taskChainId": chain["id"],
|
||
"rootDesignId": root_id},
|
||
task_id=task_id, trace_id=trace_id)
|
||
except Exception as ex: # F3:studio 链失败(网关内已重试 ×2)
|
||
self._stage(submit_owner_id, start_round, "submit", "infra_fail",
|
||
data={"reason": "infra_backend", "note": str(ex), "rootDesignId": root_id})
|
||
outcome = judge_mod.judge(True, False, [], None, 0, True, start_round, infra_category="backend")
|
||
self._judge_row(submit_owner_id, start_round, outcome)
|
||
return "infra_fail", outcome.reasons[0]
|
||
|
||
ctx = {"taskId": task_id, "traceId": trace_id, "gameId": game_id}
|
||
|
||
# ---------------- 文本面(design/schema/dedup/adversary,含 D1/D5 回炉) ----------------
|
||
text = self._text_face(idea, root_id, r1_id, start_round, ctx, recovered)
|
||
design_id = text["designId"] # 文本面收口时的当前轮 designId(后续阶段行的归属)
|
||
if text["action"] in (judge_mod.ACTION_KILL,):
|
||
# 文本面 kill(D2/D3/D4/D6):先回调 failed 给任务诚实终态,再落终判 verdict
|
||
self._terminate_task_for_kill(design_id, text, ctx)
|
||
verdict = judge_mod.build_verdict(
|
||
text["outcome"], design_id, task_id, None, None,
|
||
text["findings"], text["round"], trace_id, text["promptVersions"])
|
||
self.ledger.append_verdict(verdict)
|
||
return "kill", text["outcome"].reasons[0]
|
||
if text["action"] == judge_mod.ACTION_INFRA_FAIL:
|
||
return "infra_fail", text["outcome"].reasons[0]
|
||
|
||
design, findings, round_ = text["design"], text["findings"], text["round"]
|
||
prompt_versions = text["promptVersions"]
|
||
|
||
# ---------------- callback:伪装 Dify succeeded(写入面唯一交叉点,§3) ----------------
|
||
self._check_batch_clock()
|
||
cb = recovered.get((round_, "callback"))
|
||
if cb and cb.get("versionId"):
|
||
version_id = cb["versionId"]
|
||
self._stage(design_id, round_, "callback", "skip", data={"resumedFrom": "ledger", **cb}, **_ids(ctx))
|
||
else:
|
||
version_id = self._callback_succeeded(design_id, round_, design, ctx)
|
||
if version_id is None:
|
||
return "infra_fail", "infra_callback"
|
||
|
||
# ---------------- verify_package:实玩前编排器侧核验(§9.5) ----------------
|
||
expected_checksum = self._verify_package(design_id, round_, design, version_id, ctx)
|
||
if expected_checksum is None:
|
||
outcome = judge_mod.judge(True, False, findings, None, 0, True, round_,
|
||
infra_category="package_verify")
|
||
self._judge_row(design_id, round_, outcome, version_id=version_id, **_ids(ctx))
|
||
return "infra_fail", outcome.reasons[0]
|
||
|
||
# ---------------- play + 终判(D7/D8/D9/D10) ----------------
|
||
outcome, play_report, play_attempt = self._play_and_judge(
|
||
design_id, root_id, round_, design, findings, version_id, expected_checksum, ctx)
|
||
if outcome.action == judge_mod.ACTION_INFRA_FAIL:
|
||
return "infra_fail", outcome.reasons[0]
|
||
|
||
verdict = judge_mod.build_verdict(
|
||
outcome, design_id, task_id, version_id, play_report,
|
||
findings, round_, trace_id, prompt_versions)
|
||
self.ledger.append_verdict(verdict)
|
||
|
||
if outcome.action == judge_mod.ACTION_ACCEPT:
|
||
self.accept_records.append({
|
||
"designId": design_id, "rootDesignId": root_id, "verdict": verdict, "design": design,
|
||
"playReport": play_report, "round": round_, "gameId": ctx["gameId"],
|
||
"taskId": task_id, "playAttempt": play_attempt, "findings": findings,
|
||
})
|
||
return "accept", "accept"
|
||
# D9 kill-runnable:不再回调(任务保持 succeeded,§10.2)
|
||
return "kill", outcome.reasons[0]
|
||
|
||
# ---------------------------------------------------------------- 文本面
|
||
|
||
def _text_face(self, idea, root_id, r1_id, start_round, ctx, recovered):
|
||
"""
|
||
文本面流水(E1-E4 的编排实现):design → schema_gate → dedup_gate → adversary,
|
||
含 D1 schema 本地重出与 D5 P1-fix 回炉(共享 1 轮额度,由 round 推进体现)。
|
||
designId 轮级口径(§16 D2-a):round=0 的行落 root_id、round=1 的行落 r1_id(两 id 必不同);
|
||
D5 的中间 fix Verdict 落 round=0 的 designId(root_id)。
|
||
返回 dict{action, outcome, design, findings, round, promptVersions, designId(当前轮)}。
|
||
"""
|
||
round_ = start_round
|
||
design = None
|
||
schema_feedback = "" # D1 重出时回灌的 schema 错误文本(findings 变量)
|
||
fix_findings = None # D5 回炉时回灌的 P1 findings
|
||
prompt_versions = {}
|
||
|
||
# —— resume 从回炉轮起跑(root 已落 fix verdict):从账本恢复 round0 的 P1 findings 与原稿
|
||
if start_round == 1:
|
||
adv0 = recovered.get((0, "adversary")) or {}
|
||
des0 = recovered.get((0, "design")) or {}
|
||
restored = [f for f in (adv0.get("findings") or []) if f.get("severity") == "P1"]
|
||
if restored and des0.get("design"):
|
||
fix_findings = restored
|
||
design = des0["design"]
|
||
# round0 已用 prompt 版本并入(终判 verdict 的 evidence.promptVersions 跨 resume
|
||
# 与正常链一致——eval 回流的 fix 前后对照在 designer labels 上不缺 round1 行)
|
||
for cached0 in (des0, adv0):
|
||
if cached0.get("promptId") and cached0.get("promptVersion"):
|
||
prompt_versions[cached0["promptId"]] = cached0["promptVersion"]
|
||
_log("rootDesignId=%s resume:fix 已签发,从回炉轮续跑(恢复 P1 findings %d 条)"
|
||
% (root_id[:8], len(restored)))
|
||
else:
|
||
# 防御:fix verdict 在而 round0 产物恢复不到(账本缺损)——无法重建 fix 输入,
|
||
# 按 infra 收口可再 resume(中文告警,人工核账本)
|
||
self._stage(r1_id, 1, "design", "infra_fail",
|
||
data={"reason": "infra_resume_gap", "rootDesignId": root_id,
|
||
"note": "fix 已签发但 round0 design/adversary 产物无法从账本恢复"})
|
||
outcome = judge_mod.judge(True, False, [], None, 0, True, 1, infra_category="resume_gap")
|
||
self._judge_row(r1_id, 1, outcome, **_ids(ctx))
|
||
return {"action": outcome.action, "outcome": outcome, "design": None,
|
||
"findings": [], "round": 1, "promptVersions": prompt_versions,
|
||
"designId": r1_id}
|
||
|
||
while True:
|
||
self._check_batch_clock()
|
||
design_id = r1_id if round_ == 1 else root_id # 当前轮 designId(账本行归属)
|
||
# 同步当前活动轮(预算闸兜底记账的轮归属,见 process_idea 注释)
|
||
self._active = {"designId": design_id, "round": round_, "rootDesignId": root_id}
|
||
# ---- design(round0/schema 重出走策划 prompt;P1-fix 走 fix prompt) ----
|
||
key_design = (round_, "design") if fix_findings is None else (round_, "fix")
|
||
cached = recovered.get(key_design)
|
||
if cached and cached.get("design"):
|
||
design = cached["design"]
|
||
self._stage(design_id, round_, key_design[1], "skip",
|
||
data={"resumedFrom": "ledger", "rootDesignId": root_id}, **_ids(ctx))
|
||
# resume 恢复该次调用的 prompt 版本(verdict.evidence.promptVersions 跨 resume 完整)
|
||
if cached.get("promptId") and cached.get("promptVersion"):
|
||
prompt_versions[cached["promptId"]] = cached["promptVersion"]
|
||
else:
|
||
try:
|
||
design, pv = self._call_designer(idea, design_id, root_id, round_, schema_feedback,
|
||
fix_findings, design, ctx)
|
||
prompt_versions.update(pv)
|
||
except llm_client.LlmError as ex: # F1:通道重试耗尽 → infra_llm
|
||
self._stage(design_id, round_, "design", "infra_fail",
|
||
data={"reason": "infra_llm", "note": str(ex),
|
||
"rootDesignId": root_id}, **_ids(ctx))
|
||
outcome = judge_mod.judge(True, False, [], None, 0, True, round_, infra_category="llm")
|
||
self._judge_row(design_id, round_, outcome, **_ids(ctx))
|
||
return {"action": outcome.action, "outcome": outcome, "design": None,
|
||
"findings": [], "round": round_, "promptVersions": prompt_versions,
|
||
"designId": design_id}
|
||
|
||
# ---- schema_gate(E1;F2:非 JSON 已在 _call_designer 折算为校验失败) ----
|
||
schema_ok, errors = self._schema_gate(design_id, round_, design, ctx)
|
||
if not schema_ok:
|
||
outcome = judge_mod.judge(False, False, [], None, 0, False, round_)
|
||
self._judge_row(design_id, round_, outcome, **_ids(ctx))
|
||
if outcome.action == judge_mod.ACTION_SCHEMA_RETRY: # D1:本地重出(额度→1,designId 换轮)
|
||
round_ = 1
|
||
schema_feedback = "schema 校验未过,请修正以下问题后重出:\n- " + "\n- ".join(errors)
|
||
fix_findings = None
|
||
continue
|
||
return {"action": outcome.action, "outcome": outcome, "design": design,
|
||
"findings": [], "round": round_, "promptVersions": prompt_versions,
|
||
"designId": design_id} # D2 kill
|
||
|
||
# ---- dedup_gate(E2;批内登记以 rootDesignId 为键——创意级,排除自身两轮互撞) ----
|
||
dup_hit = self._dedup_gate(design_id, root_id, round_, design, ctx)
|
||
if dup_hit:
|
||
outcome = judge_mod.judge(True, True, [], None, 0, False, round_)
|
||
self._judge_row(design_id, round_, outcome, **_ids(ctx))
|
||
return {"action": outcome.action, "outcome": outcome, "design": design,
|
||
"findings": [], "round": round_, "promptVersions": prompt_versions,
|
||
"designId": design_id} # D3 kill
|
||
|
||
# ---- adversary(E3/E4) ----
|
||
adv = recovered.get((round_, "adversary"))
|
||
if adv and "findings" in adv:
|
||
findings = adv["findings"]
|
||
self._stage(design_id, round_, "adversary", "skip",
|
||
data={"resumedFrom": "ledger", "rootDesignId": root_id}, **_ids(ctx))
|
||
if adv.get("promptId") and adv.get("promptVersion"):
|
||
prompt_versions[adv["promptId"]] = adv["promptVersion"]
|
||
else:
|
||
try:
|
||
findings, pv = self._call_adversary(idea, design_id, root_id, round_, design, ctx)
|
||
prompt_versions.update(pv)
|
||
except llm_client.LlmError as ex:
|
||
self._stage(design_id, round_, "adversary", "infra_fail",
|
||
data={"reason": "infra_llm", "note": str(ex),
|
||
"rootDesignId": root_id}, **_ids(ctx))
|
||
outcome = judge_mod.judge(True, False, [], None, 0, True, round_, infra_category="llm")
|
||
self._judge_row(design_id, round_, outcome, **_ids(ctx))
|
||
return {"action": outcome.action, "outcome": outcome, "design": design,
|
||
"findings": [], "round": round_, "promptVersions": prompt_versions,
|
||
"designId": design_id}
|
||
self.adversary_records.append({"designId": design_id, "rootDesignId": root_id,
|
||
"design": design, "findings": findings,
|
||
"round": round_, "idea": idea})
|
||
|
||
has_p0 = any(f.get("severity") == "P0" for f in findings)
|
||
has_p1 = any(f.get("severity") == "P1" for f in findings)
|
||
if has_p0 or has_p1:
|
||
outcome = judge_mod.judge(True, False, findings, None, 0, False, round_)
|
||
self._judge_row(design_id, round_, outcome, **_ids(ctx))
|
||
if outcome.action == judge_mod.ACTION_FIX: # D5:fix 回炉(中间 Verdict 落 round0 designId)
|
||
fix_verdict = judge_mod.build_verdict(
|
||
outcome, design_id, ctx["taskId"], None, None,
|
||
findings, round_, ctx["traceId"], prompt_versions)
|
||
self.ledger.append_verdict(fix_verdict)
|
||
round_ = 1
|
||
fix_findings = [f for f in findings if f.get("severity") == "P1"]
|
||
schema_feedback = ""
|
||
continue
|
||
return {"action": outcome.action, "outcome": outcome, "design": design,
|
||
"findings": findings, "round": round_, "promptVersions": prompt_versions,
|
||
"designId": design_id} # D4/D6 kill
|
||
|
||
# 文本面全过(P2-only 随行留档,不挡 accept——§10.2)
|
||
return {"action": "text_pass", "outcome": None, "design": design,
|
||
"findings": findings, "round": round_, "promptVersions": prompt_versions,
|
||
"designId": design_id}
|
||
|
||
def _call_designer(self, idea, design_id, root_id, round_, schema_feedback, fix_findings, prev_design, ctx):
|
||
"""
|
||
调用策划/fix prompt 产 GameDesign;非 JSON / 形状缺损按 F2 折算为 schema 待裁状态。
|
||
:param design_id: 当前轮 designId(账本行归属;GameDesign.designId 取此值——§16 D2-a 轮级口径)
|
||
:param root_id: round0 designId(预算闸创意级计数键 + 阶段行 rootDesignId 关联键)
|
||
:param prev_design: fix 轮的 round=0 原稿(D5 行「findings 回灌策划重出」的 game_design 变量源)
|
||
"""
|
||
banned = [v["title"] for k, v in self.dedup_registry.items() if k != root_id] \
|
||
+ [v["theme"] for k, v in self.dedup_registry.items() if k != root_id]
|
||
if fix_findings is not None:
|
||
prompt_id, stage_label = PROMPT_FIX, "fix"
|
||
public_prev = {k: v for k, v in (prev_design or {}).items() if not str(k).startswith("_")}
|
||
variables = {"idea": idea, "template_schema": self.template_schema_text,
|
||
"game_design": public_prev,
|
||
"findings": fix_findings}
|
||
else:
|
||
prompt_id, stage_label = PROMPT_DESIGNER, "design"
|
||
variables = {"idea": idea, "template_schema": self.template_schema_text,
|
||
"banned_list": banned, "findings": schema_feedback}
|
||
text, version = self.prompts.render(prompt_id, variables)
|
||
self.prompt_versions_seen[prompt_id] = version
|
||
before = self.budget.llm_calls_used(root_id)
|
||
content, attempts = self._llm_call(root_id, "", text) # 预算键=root(①每创意 ≤8 次)
|
||
parsed, parse_err = _parse_json_object(content)
|
||
design = {
|
||
"designId": design_id, "idea": idea, "templateId": TEMPLATE_ID,
|
||
"designIntent": (parsed or {}).get("designIntent"),
|
||
"expectedPlaySeconds": (parsed or {}).get("expectedPlaySeconds"),
|
||
"config": (parsed or {}).get("config"),
|
||
"round": round_,
|
||
# 非 JSON(F2)时保留原文片段供账本归因;schema_gate 将判 fail
|
||
"_parseError": parse_err,
|
||
}
|
||
# 输入变量快照入账(§7.7 eval 回流数据源);template_schema 以摘要替代全文防账本膨胀
|
||
snapshot = dict(variables)
|
||
snapshot["template_schema"] = "sha256:%s" % backend_gw.sha256_hex(self.template_schema_text)[:16]
|
||
if fix_findings is not None:
|
||
snapshot["game_design"] = "(回炉原稿见 round=0 design 行)"
|
||
self._stage(design_id, round_, stage_label, "ok",
|
||
data={"design": design, "promptId": prompt_id, "promptVersion": version,
|
||
"inputs": snapshot, "llmAttempts": attempts, "rootDesignId": root_id},
|
||
llm_calls=self.budget.llm_calls_used(root_id) - before, **_ids(ctx))
|
||
return design, {prompt_id: version}
|
||
|
||
def _schema_gate(self, design_id, round_, design, ctx):
|
||
"""E1 结构门:设计级必填 + config 过模板 schema(深度校验权威在编排器,§8.4-3)。"""
|
||
errors = []
|
||
if design.get("_parseError"):
|
||
errors.append("LLM 输出非合法 JSON:%s" % design["_parseError"]) # F2 等价 schemaOk=false
|
||
intent = design.get("designIntent")
|
||
if not isinstance(intent, str) or not intent.strip():
|
||
errors.append("designIntent 缺失或为空")
|
||
elif len(intent) > 100:
|
||
errors.append("designIntent 超 100 字(实际 %d)" % len(intent))
|
||
eps = design.get("expectedPlaySeconds")
|
||
if not isinstance(eps, int) or isinstance(eps, bool) or eps <= 0:
|
||
errors.append("expectedPlaySeconds 须为正整数(实际 %r)" % (eps,))
|
||
ok_cfg, cfg_errors = (False, ["config 缺失"]) if design.get("config") is None \
|
||
else judge_mod.validate_config_against_schema(design["config"], self.template_schema)
|
||
errors.extend(cfg_errors if not ok_cfg else [])
|
||
ok = not errors
|
||
self._stage(design_id, round_, "schema_gate", "ok" if ok else "fail",
|
||
data={"errors": errors}, **_ids(ctx))
|
||
return ok, errors
|
||
|
||
def _dedup_gate(self, design_id, root_id, round_, design, ctx):
|
||
"""
|
||
E2 查重门:批内 title/theme 归一化撞重。
|
||
登记表键=rootDesignId(创意级):排除自身按 root 比较——fix 回炉轮(designId 换轮)
|
||
不会与自己 round0 的登记互撞;过门后登记/刷新自身创意的最新文案。
|
||
"""
|
||
cfg = design.get("config") or {}
|
||
title_n, theme_n = _norm_text(cfg.get("title")), _norm_text(cfg.get("theme"))
|
||
hit = None
|
||
for other_root, reg in self.dedup_registry.items():
|
||
if other_root == root_id:
|
||
continue # 排除自身创意(含回炉轮换 designId 的场景)
|
||
if title_n and title_n == reg["titleNorm"]:
|
||
hit = ("title", other_root)
|
||
break
|
||
if theme_n and theme_n == reg["themeNorm"]:
|
||
hit = ("theme", other_root)
|
||
break
|
||
if hit:
|
||
self._stage(design_id, round_, "dedup_gate", "fail",
|
||
data={"dupField": hit[0], "dupWith": hit[1], "rootDesignId": root_id,
|
||
"title": cfg.get("title"), "theme": cfg.get("theme")}, **_ids(ctx))
|
||
return True
|
||
self.dedup_registry[root_id] = {"titleNorm": title_n, "themeNorm": theme_n,
|
||
"title": cfg.get("title"), "theme": cfg.get("theme")}
|
||
self._stage(design_id, round_, "dedup_gate", "ok",
|
||
data={"titleNorm": title_n, "themeNorm": theme_n, "rootDesignId": root_id,
|
||
"title": cfg.get("title"), "theme": cfg.get("theme")}, **_ids(ctx))
|
||
return False
|
||
|
||
def _call_adversary(self, idea, design_id, root_id, round_, design, ctx, model=None, record=True):
|
||
"""
|
||
调用对抗评审 prompt 产 findings;形状异常应用层重试 ×2 后按 F1 通道类处置。
|
||
:param design_id: 当前轮 designId(账本行归属);:param root_id: 预算键(①每创意口径)。
|
||
"""
|
||
public_design = {k: v for k, v in design.items() if not k.startswith("_")}
|
||
text, version = self.prompts.render(PROMPT_ADVERSARY,
|
||
{"idea": idea, "game_design": public_design})
|
||
self.prompt_versions_seen[PROMPT_ADVERSARY] = version
|
||
before = self.budget.llm_calls_used(root_id)
|
||
last_err = None
|
||
for shape_try in range(1, ADVERSARY_SHAPE_RETRIES + 2):
|
||
content, _att = self._llm_call(root_id, "", text, model=model, count_budget=record)
|
||
parsed, parse_err = _parse_json_object(content)
|
||
findings, shape_err = _validate_findings_shape(parsed)
|
||
if shape_err is None:
|
||
if record:
|
||
self._stage(design_id, round_, "adversary", "ok",
|
||
data={"findings": findings, "promptId": PROMPT_ADVERSARY,
|
||
"promptVersion": version,
|
||
"inputs": {"idea": idea, "game_design_designId": design_id},
|
||
"model": model or self.llm.model, "rootDesignId": root_id},
|
||
llm_calls=self.budget.llm_calls_used(root_id) - before, **_ids(ctx))
|
||
return findings, {PROMPT_ADVERSARY: version}
|
||
last_err = parse_err or shape_err
|
||
_log("对抗输出形状异常(第 %d 次):%s" % (shape_try, last_err))
|
||
raise llm_client.LlmError("对抗评审输出形状异常重试耗尽:%s" % last_err)
|
||
|
||
# ---------------------------------------------------------------- 回调与核验
|
||
|
||
def _terminate_task_for_kill(self, design_id, text, ctx):
|
||
"""
|
||
文本面 kill 的任务终态化(§16-1 决断):回调 failed + 归桶 failureReason,
|
||
避免任务永久 queued 残留。回调失败仅记账告警(裁决已由文本面证据成立,不翻案)。
|
||
"""
|
||
outcome = text["outcome"]
|
||
if not outcome.callback:
|
||
return
|
||
status, failure_reason = outcome.callback
|
||
try:
|
||
self.backend.dify_callback({
|
||
"traceId": ctx["traceId"], "status": status,
|
||
"templateId": TEMPLATE_ID, "failureReason": failure_reason,
|
||
})
|
||
self._stage(design_id, text["round"], "callback", "ok",
|
||
data={"mode": "text_kill", "failureReason": failure_reason}, **_ids(ctx))
|
||
except Exception as ex:
|
||
self._stage(design_id, text["round"], "callback", "fail",
|
||
data={"mode": "text_kill", "failureReason": failure_reason,
|
||
"note": "终态化回调失败(任务可能残留 queued,人工跟进):%s" % ex}, **_ids(ctx))
|
||
|
||
def _callback_succeeded(self, design_id, round_, design, ctx):
|
||
"""
|
||
succeeded 回调(三表同事务,D4 后端件)+ F6 任务轮询拿 versionId。
|
||
F4 transport 网关内重试 ×2;F5 business(写链失败补偿后 ServiceException)不重试同 traceId。
|
||
返回 versionId 或 None(infra)。
|
||
"""
|
||
payload = {
|
||
"traceId": ctx["traceId"], "status": "succeeded", "templateId": TEMPLATE_ID,
|
||
"gameConfig": design["config"], "assets": [],
|
||
}
|
||
try:
|
||
self.backend.dify_callback(payload)
|
||
except backend_gw.BackendError as ex:
|
||
reason = "infra_callback_tx" if ex.kind == "business" else "infra_callback"
|
||
self._stage(design_id, round_, "callback", "infra_fail",
|
||
data={"reason": reason, "bizCode": ex.biz_code, "note": str(ex)}, **_ids(ctx))
|
||
outcome = judge_mod.judge(True, False, [], None, 0, True, round_,
|
||
infra_category="callback_tx" if ex.kind == "business" else "callback")
|
||
self._judge_row(design_id, round_, outcome, **_ids(ctx))
|
||
return None
|
||
# F6:轮询任务至 succeeded(2) + versionId 回填;>60s → infra_task_poll
|
||
deadline = time.monotonic() + TASK_POLL_TIMEOUT_SECONDS
|
||
while time.monotonic() < deadline:
|
||
try:
|
||
task = self.backend.get_aigc_task(ctx["taskId"])
|
||
except Exception as ex: # 轮询读失败也归 F6(回调已成功,读侧异常按 infra 告警)
|
||
_log("任务轮询读失败:%s" % ex)
|
||
break
|
||
if task.get("status") == 2 and task.get("versionId"):
|
||
self._stage(design_id, round_, "callback", "ok",
|
||
data={"versionId": task["versionId"]},
|
||
version_id=task["versionId"], **_ids(ctx))
|
||
return task["versionId"]
|
||
if task.get("status") in (3, 4, 5):
|
||
break # 任务被置失败/超时/取消:回填异常,按 F6 告警
|
||
time.sleep(TASK_POLL_INTERVAL_SECONDS)
|
||
self._stage(design_id, round_, "callback", "infra_fail",
|
||
data={"reason": "infra_task_poll",
|
||
"note": "回调成功但 %ds 内未达 succeeded+versionId(后端缺陷,告警)"
|
||
% TASK_POLL_TIMEOUT_SECONDS}, **_ids(ctx))
|
||
outcome = judge_mod.judge(True, False, [], None, 0, True, round_, infra_category="task_poll")
|
||
self._judge_row(design_id, round_, outcome, **_ids(ctx))
|
||
return None
|
||
|
||
def _verify_package(self, design_id, round_, design, version_id, ctx):
|
||
"""
|
||
§9.5 实玩前编排器侧核验(不经浏览器):复用 D5 落位的 verify_package_backend——
|
||
RespVO.checksum == sha256(manifest 原文),且包内 target/title 与设计一致。
|
||
任一不等 → F7 infra_package_verify(Z1 病灶信号,推高熔断分子——设计意图)。
|
||
返回期望 checksum(=RespVO.checksum,实玩浏览器侧核验基准)或 None。
|
||
"""
|
||
player = self._load_player(required=True)
|
||
try:
|
||
result = player.verify_package_backend(
|
||
backend_base=self.args.backend, version_id=str(version_id),
|
||
token=self.backend.token, tenant_id=self.backend.tenant_id,
|
||
# target 是 clicker 专属字段(merge 等模板无此键,直取 KeyError → 整批熔断,
|
||
# merge-cal-10 首跑实测逮获):.get 取不到给 None,verify_package_backend 对
|
||
# None 期望自动跳过该比对(clicker 行为零变化);title 各模板 schema 均必填。
|
||
expected_target=design["config"].get("target"),
|
||
expected_title=design["config"]["title"])
|
||
except Exception as ex:
|
||
self._stage(design_id, round_, "verify_package", "infra_fail",
|
||
data={"reason": "infra_package_verify", "note": "核验执行异常:%s" % ex},
|
||
version_id=version_id, **_ids(ctx))
|
||
return None
|
||
if not result.get("ok"):
|
||
self._stage(design_id, round_, "verify_package", "infra_fail",
|
||
data={"reason": "infra_package_verify", "problems": result.get("mismatches")},
|
||
version_id=version_id, **_ids(ctx))
|
||
return None
|
||
expected = result.get("checksum")
|
||
self._stage(design_id, round_, "verify_package", "ok",
|
||
data={"checksum": expected}, version_id=version_id, **_ids(ctx))
|
||
return expected
|
||
|
||
# ---------------------------------------------------------------- 实玩与终判
|
||
|
||
def _play_and_judge(self, design_id, root_id, round_, design, findings, version_id, expected_checksum, ctx):
|
||
"""
|
||
实玩段(D5 取证 + §10 E5/E6 终判)。接 D5 实际调用面(README §「D5」分区):
|
||
BrowserPool 租约(≤2)→ play(cdp_http=..., continue_on_demo=False) → PlayReport。
|
||
预算 ②:实玩 ≤3 次/创意(键=root_id)= 首玩 + runnable 重试 1(D8)+ infra 重试 1(demo/浏览器挂)。
|
||
CdpError(端点不可达,无证据可产)→ F9 整批暂停(D5 play() 文档约定)。
|
||
返回 (outcome, play_report|None, play_attempt)。
|
||
"""
|
||
player = self._load_player(required=True)
|
||
evidence_dir = Path(self.run_dir) / "evidence" / design_id
|
||
runnable_attempt = 0
|
||
infra_retry_used = False
|
||
while True:
|
||
self._check_batch_clock()
|
||
self.budget.note_play(root_id) # 超 ② 闸抛 BudgetExceeded(外层折算 infra;②为每创意口径)
|
||
try:
|
||
with self.browser_pool.acquire() as lease:
|
||
play_report = player.play(
|
||
cdp_http=lease.endpoint,
|
||
frontend_base=self.args.frontend,
|
||
version_id=str(version_id), game_id=str(ctx["gameId"]),
|
||
# §7.1 取参分流:传 (templateId, config),player 内部按模板取字段
|
||
#(clicker→config.target;merge→chainLength/targetLevel/boardSize),
|
||
# 避免每加模板改 play 形参;clicker 路径行为零变化。
|
||
template_id=design["templateId"],
|
||
config=design["config"],
|
||
expected_checksum=expected_checksum,
|
||
evidence_dir=evidence_dir,
|
||
continue_on_demo=False, # 批跑严格 False(demo=infra,§9.5)
|
||
# §16 D5-a 三角合成判定第三要件:进入实玩段的前提即 _verify_package(F7 闸)
|
||
# 已对同 URL 原文比对通过(expected_checksum 非 None),故恒 True 传入
|
||
backend_verified=True)
|
||
except player.CdpError as ex:
|
||
# F9:CDP 端点级失败(建页即败、无证据可产)→ 整批暂停(禁降级跳过试玩)
|
||
self.breaker.halt_env("CDP 端点失败:%s" % ex)
|
||
raise BatchHalt(EXIT_ENV_HALT, "试玩环境不可用(F9 整批暂停):%s" % ex)
|
||
except Exception as ex:
|
||
if not infra_retry_used:
|
||
infra_retry_used = True
|
||
_log("实玩 infra 异常,重试一次(§7.3-②):%s" % ex)
|
||
continue
|
||
self._stage(design_id, round_, "play", "infra_fail",
|
||
data={"reason": "infra_browser", "note": str(ex)},
|
||
version_id=version_id, **_ids(ctx))
|
||
outcome = judge_mod.judge(True, False, findings, None, 0, True, round_,
|
||
infra_category="browser")
|
||
self._judge_row(design_id, round_, outcome, version_id=version_id, **_ids(ctx))
|
||
return outcome, None, runnable_attempt
|
||
# infra 信号(D5 报告归集:demo 兜底/前置断言失败/loaded 超时等):
|
||
# 不算通过也不算 kill(§9.5/F8),消耗一次 infra 重试后按 D7 收口
|
||
if play_report.get("infraSignal") or play_report.get("demoFallback") is True:
|
||
infra_reasons = play_report.get("infraReasons") or ["infra_unknown"]
|
||
category = infra_reasons[0]
|
||
category = category[len("infra_"):] if category.startswith("infra_") else category
|
||
if not infra_retry_used:
|
||
infra_retry_used = True
|
||
_log("实玩 infra 信号(%s),重试一次(§7.3-②/§9.5)" % "、".join(infra_reasons))
|
||
continue
|
||
self._stage(design_id, round_, "play", "infra_fail",
|
||
data={"reason": "infra_%s" % category, "infraReasons": infra_reasons,
|
||
"playReport": _slim_report(play_report)},
|
||
version_id=version_id, **_ids(ctx))
|
||
outcome = judge_mod.judge(True, False, findings, None, 0, True, round_,
|
||
infra_category=category)
|
||
self._judge_row(design_id, round_, outcome, version_id=version_id, **_ids(ctx))
|
||
return outcome, None, runnable_attempt
|
||
# runnable 语义实玩完成一次(judge 对捕获原始数据复核五条 AND,不信任 agent 自述)
|
||
runnable_attempt += 1
|
||
self._stage(design_id, round_, "play", "ok",
|
||
data={"playReport": _slim_report(play_report), "playAttempt": runnable_attempt},
|
||
version_id=version_id, **_ids(ctx))
|
||
outcome = judge_mod.judge(True, False, findings, play_report,
|
||
runnable_attempt, False, round_)
|
||
self._judge_row(design_id, round_, outcome, version_id=version_id, **_ids(ctx))
|
||
if outcome.action == judge_mod.ACTION_PLAY_RETRY: # D8:重试实玩(不耗回炉额度)
|
||
continue
|
||
return outcome, play_report, runnable_attempt
|
||
|
||
# ---------------------------------------------------------------- 发布段(§7.6,批末统一)
|
||
|
||
def publish_segment(self):
|
||
"""
|
||
发布段:仅 accept 走,全部既有真实链路(publish→review→postcheck 五步编排结果校验)。
|
||
候选 = 本批账本中所有 accept 终判且尚无 publish 阶段行的 designId(覆盖 resume 场景)。
|
||
冻结(§7.4-4 / F10 / F12)时写 skip(frozen) 行;金丝雀 ≤10(§7.3-⑥)。
|
||
"""
|
||
candidates = list(self.accept_records)
|
||
# resume 场景:既往运行的 accept(有终判 verdict、无 publish 行)重新验章后入列
|
||
rows, _bad = ledger_mod.Ledger.load_rows(self.ledger_path)
|
||
seen = {c["designId"] for c in candidates}
|
||
staged_publish = {r.get("designId") for r in rows
|
||
if r.get("type") == "stage" and r.get("stage") == "publish"}
|
||
for row in rows:
|
||
if row.get("type") != "verdict" or row.get("decision") != "accept":
|
||
continue
|
||
did = row.get("designId")
|
||
if did in seen or did in staged_publish:
|
||
continue
|
||
resealed = self._reseal_accept_from_row(row)
|
||
if resealed is not None:
|
||
candidates.append(resealed)
|
||
seen.add(did)
|
||
|
||
for rec in candidates:
|
||
design_id, verdict = rec["designId"], rec["verdict"]
|
||
round_ = rec["round"]
|
||
if self.breaker.publish_frozen():
|
||
self._stage(design_id, round_, "publish", "skip",
|
||
data={"frozen": True, "reason": self.breaker.publish_frozen_reason})
|
||
continue
|
||
if not self.budget.canary_slot_available(self.published_count):
|
||
# 金丝雀额度用尽:accept 记 accept_unpublished,不发布(§7.3-⑥)
|
||
self._stage(design_id, round_, "publish", "skip",
|
||
data={"accept_unpublished": True, "reason": "canary_quota"})
|
||
continue
|
||
ok = self._publish_one(rec)
|
||
self.breaker.note_publish_result(ok)
|
||
if ok:
|
||
self.published_count += 1
|
||
|
||
def _reseal_accept_from_row(self, verdict_row):
|
||
"""
|
||
resume 重发布前的再裁决验章:用账本 verdict 行中的证据(findings+playReport)重跑裁决引擎,
|
||
仍判 accept 才重新铸章入发布段——保证「发布仅凭 judge 铸章」在跨进程后依然成立。
|
||
"""
|
||
try:
|
||
outcome = judge_mod.judge(True, False, verdict_row.get("findings") or [],
|
||
verdict_row.get("playReport"), 1, False,
|
||
verdict_row.get("round") or 0)
|
||
if outcome.action != judge_mod.ACTION_ACCEPT:
|
||
_log("designId=%s 账本证据再裁决非 accept(%s),不入发布段"
|
||
% (verdict_row.get("designId", "")[:8], outcome.row))
|
||
return None
|
||
verdict = judge_mod.build_verdict(
|
||
outcome, verdict_row["designId"], verdict_row.get("taskId"),
|
||
verdict_row.get("versionId"), verdict_row.get("playReport"),
|
||
verdict_row.get("findings") or [], verdict_row.get("round") or 0,
|
||
(verdict_row.get("evidence") or {}).get("traceId"),
|
||
(verdict_row.get("evidence") or {}).get("promptVersions") or {})
|
||
# gameId 召回:①本 designId 档案的任意轮 submit 行;②兜底按 taskId 全账本关联
|
||
#(§16 D2-a 轮级口径下,round1 designId 的 accept 其 submit 行可能落在 root designId 下,
|
||
# taskId 为创意级唯一关联键)
|
||
info = self.resume_state.get(verdict_row["designId"]) or {}
|
||
ok_stages = info.get("ok_stages") or {}
|
||
sub = ok_stages.get((1, "submit")) or ok_stages.get((0, "submit")) or {}
|
||
game_id = sub.get("gameId")
|
||
if not game_id and verdict_row.get("taskId") is not None:
|
||
rows, _b = ledger_mod.Ledger.load_rows(self.ledger_path)
|
||
for row in rows:
|
||
if row.get("type") == "stage" and row.get("stage") == "submit" \
|
||
and row.get("status") == "ok" \
|
||
and (row.get("data") or {}).get("taskId") == verdict_row.get("taskId"):
|
||
game_id = row["data"].get("gameId")
|
||
break
|
||
return {"designId": verdict_row["designId"], "verdict": verdict,
|
||
"round": verdict_row.get("round") or 0,
|
||
"gameId": game_id, "versionId": verdict_row.get("versionId")}
|
||
except judge_mod.JudgeError as ex:
|
||
_log("designId=%s 账本证据再裁决异常(%s),不入发布段" % (verdict_row.get("designId", "")[:8], ex))
|
||
return None
|
||
|
||
def _publish_one(self, rec):
|
||
"""单条 accept 的发布三步(§7.6);任一步失败 → accept_publish_fail 告警(F10),不影响裁决统计。"""
|
||
design_id, verdict, round_ = rec["designId"], rec["verdict"], rec["round"]
|
||
game_id = rec.get("gameId")
|
||
version_id = verdict.get("versionId") or rec.get("versionId")
|
||
# —— 代码层强制(§7.1):发布函数仅接受裁决引擎铸章的 accept Verdict
|
||
if not judge_mod.is_judge_accept(verdict):
|
||
raise OrchestratorError("发布闸拦截:非裁决引擎铸章的 accept Verdict(designId=%s)" % design_id)
|
||
if not game_id or not version_id:
|
||
self._stage(design_id, round_, "publish", "fail",
|
||
data={"reason": "accept_publish_fail", "note": "缺 gameId/versionId 无法发布"})
|
||
return False
|
||
try:
|
||
gate = self.backend.submit_publish(game_id, version_id, self.args.launch_zone_id)
|
||
if isinstance(gate, dict) and gate.get("admitted") is False:
|
||
raise backend_gw.BackendError("发布门禁未过:%s" % json.dumps(gate, ensure_ascii=False)[:300],
|
||
kind="business")
|
||
reason = "agent-loop 自动批(裁决即审核员,batchId=%s,designId=%s)" % (self.batch_id, design_id)
|
||
self.backend.review_approve(game_id, version_id, reason)
|
||
self._stage(design_id, round_, "publish", "ok",
|
||
data={"gameId": game_id, "versionId": version_id}, version_id=version_id)
|
||
except Exception as ex:
|
||
self._stage(design_id, round_, "publish", "fail",
|
||
data={"reason": "accept_publish_fail", "note": str(ex)})
|
||
return False
|
||
# —— ③ 校验五步编排结果(HTTP 可观测面:project=4 / 包 play 态可取 / feed 行可见;
|
||
# 全量 SQL 断言(§12.3)由主 agent 冒烟执行)
|
||
try:
|
||
project = self.backend.get_project(game_id)
|
||
pkg_play_ok = True
|
||
try:
|
||
self.backend.get_package(version_id, scene="play") # play 态仅放行已发布包
|
||
except Exception:
|
||
pkg_play_ok = False
|
||
# cursor 逐页查找(修 batch-001 P1:size=50 超后端单页上限 30 致 400 误熔断;详见 backend_gw.feed_find_game)
|
||
feed_visible = self.backend.feed_find_game(game_id)
|
||
ok = (project.get("status") == 4) and pkg_play_ok and feed_visible
|
||
self._stage(design_id, round_, "postcheck", "ok" if ok else "fail",
|
||
data={"gameId": game_id, "projectStatus": project.get("status"),
|
||
"packagePlayOk": pkg_play_ok, "feedVisible": feed_visible},
|
||
version_id=version_id)
|
||
if not ok:
|
||
return False
|
||
except Exception as ex:
|
||
self._stage(design_id, round_, "postcheck", "fail",
|
||
data={"reason": "accept_publish_fail", "note": str(ex)}, version_id=version_id)
|
||
return False
|
||
return True
|
||
|
||
# ---------------------------------------------------------------- 批级 QA(§7.3-⑦⑧)
|
||
|
||
def model_swap_audit(self):
|
||
"""
|
||
⑦ 换模型抽检:accept 的 20%(向上取整)用独立模型重裁;分歧率 >10% → 冻结本批发布开关。
|
||
审计模型走 NEWAPI_AUDIT_MODEL 环境变量;未配置时如实降级并在报告显著标注(不假装抽检过)。
|
||
批级 QA 调用不占用每创意 8 次预算(流水线内闸),成本计入批报告。
|
||
"""
|
||
result = {"sampled": 0, "divergent": 0, "divergenceRate": None, "frozen": False, "note": ""}
|
||
n = self.budget.audit_sample_size(len(self.accept_records))
|
||
if n == 0:
|
||
result["note"] = "本批运行无新 accept,抽检空转"
|
||
return result
|
||
if not llm_client.AUDIT_MODEL:
|
||
result["note"] = "NEWAPI_AUDIT_MODEL 未配置,换模型抽检未执行(降级如实披露)"
|
||
_log("换模型抽检跳过:%s" % result["note"])
|
||
return result
|
||
rng = random.Random(self.batch_id) # 批 id 种子化:抽样可复现
|
||
samples = rng.sample(self.accept_records, n)
|
||
divergent = 0
|
||
effective = 0
|
||
for rec in samples:
|
||
try:
|
||
findings, _pv = self._call_adversary(
|
||
rec["design"]["idea"], rec["designId"],
|
||
rec.get("rootDesignId") or rec["designId"], rec["round"], rec["design"],
|
||
{"taskId": rec["taskId"], "traceId": None, "gameId": rec["gameId"]},
|
||
model=llm_client.AUDIT_MODEL, record=False)
|
||
outcome = judge_mod.judge(True, False, findings, rec["playReport"],
|
||
rec["playAttempt"], False, rec["round"])
|
||
effective += 1
|
||
if outcome.action != judge_mod.ACTION_ACCEPT:
|
||
divergent += 1
|
||
self._stage(rec["designId"], rec["round"], "adversary", "ok",
|
||
data={"audit": True, "model": llm_client.AUDIT_MODEL,
|
||
"auditRow": outcome.row, "findings": findings}, llm_calls=1)
|
||
except Exception as ex:
|
||
_log("换模型抽检单条失败(不计分歧分母):%s" % ex)
|
||
result.update({"sampled": effective, "divergent": divergent})
|
||
if effective:
|
||
rate = divergent / float(effective)
|
||
result["divergenceRate"] = rate
|
||
if rate > self.budget.AUDIT_DIVERGENCE_FREEZE_RATIO:
|
||
# §7.4-4:仅冻结发布段;告警行落账本(§7.3 行格式范例)
|
||
self.breaker.freeze_publish("换模型抽检分歧率 %.0f%% > 10%%" % (rate * 100))
|
||
self._stage("batch", 0, "publish", "skip",
|
||
data={"frozen": True, "reason": self.breaker.publish_frozen_reason,
|
||
"sampled": effective, "divergent": divergent})
|
||
result["frozen"] = True
|
||
return result
|
||
|
||
def adversary_stability_retest(self):
|
||
"""⑧ 对抗稳定性重测(Z4):随机 ≥3 条 GameDesign 同 prompt 同模型重测,P0/P1 判定翻转即不一致。"""
|
||
result = {"retested": 0, "consistent": 0, "consistentRate": None, "note": ""}
|
||
pool = [r for r in self.adversary_records]
|
||
if not pool:
|
||
result["note"] = "本批无对抗评审样本,重测空转"
|
||
return result
|
||
rng = random.Random("stability:" + self.batch_id)
|
||
n = min(len(pool), max(self.budget.ADVERSARY_RETEST_MIN, 3))
|
||
samples = rng.sample(pool, n)
|
||
consistent = 0
|
||
effective = 0
|
||
for rec in samples:
|
||
try:
|
||
findings, _pv = self._call_adversary(
|
||
rec["idea"], rec["designId"],
|
||
rec.get("rootDesignId") or rec["designId"], rec["round"], rec["design"],
|
||
{"taskId": None, "traceId": None, "gameId": None}, record=False)
|
||
effective += 1
|
||
orig = (any(f.get("severity") == "P0" for f in rec["findings"]),
|
||
any(f.get("severity") == "P1" for f in rec["findings"]))
|
||
new = (any(f.get("severity") == "P0" for f in findings),
|
||
any(f.get("severity") == "P1" for f in findings))
|
||
if orig == new:
|
||
consistent += 1
|
||
self._stage(rec["designId"], rec["round"], "adversary", "ok",
|
||
data={"retest": True, "original": list(orig), "retested": list(new),
|
||
"findings": findings}, llm_calls=1)
|
||
except Exception as ex:
|
||
_log("稳定性重测单条失败(不计一致率分母):%s" % ex)
|
||
result.update({"retested": effective, "consistent": consistent})
|
||
if effective:
|
||
result["consistentRate"] = consistent / float(effective)
|
||
if len(pool) < self.budget.ADVERSARY_RETEST_MIN:
|
||
result["note"] = "样本池仅 %d 条(<3),已全量重测如实披露" % len(pool)
|
||
return result
|
||
|
||
# ---------------------------------------------------------------- 主循环
|
||
|
||
def run(self, ideas):
|
||
"""批主循环:逐创意流水 + 熔断巡检 + 批末 QA/发布/回流/报告。返回退出码。"""
|
||
started_at = ledger_mod.now_iso()
|
||
wall_start = time.time()
|
||
self.budget.start_batch()
|
||
exit_code = EXIT_OK
|
||
halt_reason = None
|
||
try:
|
||
for idx, idea in enumerate(ideas):
|
||
if self.budget.batch_timed_out():
|
||
# ③ 批时长闸:剩余创意全记 infra_batch_timeout(F11,不计分母)
|
||
self.timed_out = True
|
||
self._mark_timeout_leftovers(ideas[idx:])
|
||
break
|
||
kind, first_reason = self.process_idea(idea)
|
||
self.processed_ideas.add(idea)
|
||
if kind == "skip":
|
||
continue
|
||
self.breaker.note_outcome(kind, first_reason)
|
||
# §7.4-1/2 熔断巡检(触发即停批、退出码非 0)
|
||
if self.breaker.infra_ratio_tripped():
|
||
halt_reason, exit_code = "infra 占比熔断", EXIT_INFRA_TRIP
|
||
self._mark_timeout_leftovers(ideas[idx + 1:], reason="halted_infra_ratio")
|
||
break
|
||
if self.breaker.kill_streak_tripped():
|
||
halt_reason, exit_code = "连续同因 kill 熔断", EXIT_KILL_STREAK
|
||
self._mark_timeout_leftovers(ideas[idx + 1:], reason="halted_kill_streak")
|
||
break
|
||
except ledger_mod.BudgetExceeded:
|
||
# 批时钟在创意中途打点触发:当前创意已按 infra 记账,剩余未处理创意统一收口
|
||
self.timed_out = True
|
||
self._mark_timeout_leftovers(ideas, reason="infra_batch_timeout")
|
||
except BatchHalt as halt:
|
||
halt_reason, exit_code = halt.reason, halt.exit_code
|
||
|
||
# —— 批级 QA → 发布段 → eval 回流 → 报告(环境暂停时跳过发布段,QA 与报告照常出数)
|
||
audit = {"sampled": 0, "divergent": 0, "divergenceRate": None, "frozen": False,
|
||
"note": "批中止未执行" if halt_reason else ""}
|
||
stability = {"retested": 0, "consistent": 0, "consistentRate": None,
|
||
"note": "批中止未执行" if halt_reason else ""}
|
||
if not halt_reason:
|
||
audit = self.model_swap_audit()
|
||
stability = self.adversary_stability_retest()
|
||
self.publish_segment()
|
||
try:
|
||
evalflow.flow_batch(self.ledger_path, self.args.eval_root, log=_log) # §7.7 每批强制
|
||
except Exception as ex:
|
||
_log("eval 回流失败(不阻断报告):%s" % ex)
|
||
|
||
meta = {
|
||
"batchId": self.batch_id, "startedAt": started_at, "endedAt": ledger_mod.now_iso(),
|
||
"channelBase": self.llm.base, "model": self.llm.model,
|
||
"auditModel": llm_client.AUDIT_MODEL or None,
|
||
"promptVersions": self.prompt_versions_seen or self._registry_versions(),
|
||
"submittedIdeas": len(ideas), "wallSeconds": int(time.time() - wall_start),
|
||
"adversaryStability": stability, "audit": audit,
|
||
"breakerSummary": self.breaker.tripped_summary() + ([halt_reason] if halt_reason else []),
|
||
"timedOut": self.timed_out,
|
||
# W4 成本侧①:本批 LLM token 真实计量 + 成本估算(单价为假设,待网关账单)
|
||
"tokenUsage": self.llm.usage_cost_estimate(),
|
||
}
|
||
report_mod.write_reports(self.run_dir, self.ledger_path, meta, log=_log)
|
||
if halt_reason:
|
||
_log("批以熔断/暂停收口:%s(退出码 %d)" % (halt_reason, exit_code))
|
||
return exit_code
|
||
|
||
def _registry_versions(self):
|
||
"""报告兜底:本批未发生 LLM 调用时,从 Registry 读三条 prompt 注册版本。"""
|
||
out = {}
|
||
for pid in (PROMPT_DESIGNER, PROMPT_ADVERSARY, PROMPT_FIX):
|
||
entry = self.prompts.registry.get(pid) or {}
|
||
out[pid] = entry.get("version")
|
||
return out
|
||
|
||
def _mark_timeout_leftovers(self, leftover_ideas, reason="infra_batch_timeout"):
|
||
"""
|
||
批收口(F11/停批):未处理创意全记 infra_fail,resume 可重放。
|
||
豁免:本次运行已处理的创意(self.processed_ideas)与既往已终判的创意(任一轮 designId 终判)——
|
||
防止把已完成的创意误记 infra。不计入本次熔断分母(它们未被「处理」)。
|
||
记账轮归属:root 已签发 fix(卡在回炉轮)→ 落 round1 designId,否则落 root——
|
||
保证 resume 重放语义只作用于当前活动轮,不破坏已固化的 round0 fix verdict 链。
|
||
"""
|
||
for idea in leftover_ideas:
|
||
if idea in self.processed_ideas:
|
||
continue
|
||
root_id = ledger_mod.mint_design_id(idea, TEMPLATE_ID, 0)
|
||
r1_id = ledger_mod.mint_design_id(idea, TEMPLATE_ID, 1)
|
||
root_info = self.resume_state.get(root_id) or {}
|
||
r1_info = self.resume_state.get(r1_id) or {}
|
||
if root_info.get("terminal") or r1_info.get("terminal"):
|
||
continue
|
||
if root_info.get("fix_issued"):
|
||
self._stage(r1_id, 1, "submit", "infra_fail",
|
||
data={"reason": reason, "rootDesignId": root_id,
|
||
"note": "批收口未处理(回炉轮),resume 可重放"})
|
||
else:
|
||
self._stage(root_id, 0, "submit", "infra_fail",
|
||
data={"reason": reason, "rootDesignId": root_id,
|
||
"note": "批收口未处理,resume 可重放"})
|
||
|
||
|
||
# ==================================================================== 工具函数
|
||
|
||
def _ids(ctx):
|
||
"""阶段行可选关联键展开(taskId/traceId 透传)。"""
|
||
return {"task_id": ctx.get("taskId"), "trace_id": ctx.get("traceId")}
|
||
|
||
|
||
def _slim_report(play_report):
|
||
"""账本瘦身:剥掉 PlayReport 中下划线开头的暂存键(如 _manifestBody,证据已由 D5 落盘)。"""
|
||
return {k: v for k, v in (play_report or {}).items() if not str(k).startswith("_")}
|
||
|
||
|
||
def _parse_json_object(content):
|
||
"""解析 LLM 输出为 JSON 对象;失败返回 (None, 错误说明)(F2 等价 schemaOk=false 的归因源)。"""
|
||
try:
|
||
obj = json.loads(content)
|
||
except ValueError as ex:
|
||
return None, "json_parse:%s" % ex
|
||
if not isinstance(obj, dict):
|
||
return None, "not_object:%s" % type(obj).__name__
|
||
return obj, None
|
||
|
||
|
||
def _validate_findings_shape(parsed):
|
||
"""校验对抗输出形状:{findings:[{severity∈P0/P1/P2, issue, suggestion}]};空数组=无问题。"""
|
||
if parsed is None:
|
||
return None, "输出非 JSON 对象"
|
||
findings = parsed.get("findings")
|
||
if not isinstance(findings, list):
|
||
return None, "缺 findings 数组"
|
||
for idx, item in enumerate(findings):
|
||
if not isinstance(item, dict) or item.get("severity") not in ("P0", "P1", "P2") \
|
||
or not isinstance(item.get("issue"), str):
|
||
return None, "findings[%d] 形状非法:%r" % (idx, item)
|
||
item.setdefault("suggestion", "")
|
||
return findings, None
|
||
|
||
|
||
def load_ideas(path):
|
||
"""读取创意批次文件:一行一条;# 开头与空行跳过。"""
|
||
if not os.path.exists(path):
|
||
raise BatchHalt(EXIT_CONFIG, "创意文件不存在:%s" % path)
|
||
ideas = []
|
||
with open(path, "r", encoding="utf-8") as fh:
|
||
for line in fh:
|
||
line = line.strip()
|
||
if line and not line.startswith("#"):
|
||
ideas.append(line)
|
||
if not ideas:
|
||
raise BatchHalt(EXIT_CONFIG, "创意文件为空:%s" % path)
|
||
return ideas
|
||
|
||
|
||
def main(argv=None):
|
||
"""CLI 入口:参数解析 → 密钥/环境前置校验 → 批驱动。"""
|
||
parser = argparse.ArgumentParser(description="agent-loop v1 批跑编排器(HJ-AGENT-LOOP-EXEC-001 §7)")
|
||
parser.add_argument("--ideas", default=os.path.join(_LOOP_ROOT, "ideas", "batch-001.txt"),
|
||
help="创意批次文件(默认 ideas/batch-001.txt)")
|
||
parser.add_argument("--batch-id", default=None, help="批 ID(默认=创意文件名去扩展名)")
|
||
parser.add_argument("--resume", default=None, metavar="BATCHID",
|
||
help="断点续跑指定批(§7.2 幂等重放)")
|
||
parser.add_argument("--runs-dir", default=os.path.join(_LOOP_ROOT, "runs"),
|
||
help="批产物根目录(默认 runs/)")
|
||
parser.add_argument("--backend", default=backend_gw.DEFAULT_BACKEND_BASE,
|
||
help="staging 后端地址(在跑勿动,仅 HTTP 调用)")
|
||
# 默认 localhost 而非 100.64.0.7:宿主 manifest 校验用 crypto.subtle,仅安全上下文
|
||
# (https/localhost)可用——经 IP 访问会永走 demo 兜底(D5 self-test 实测,README 踩坑1)。
|
||
# Chrome 与前端 serve 同在 mini-desktop,浏览器内 localhost 即解。
|
||
parser.add_argument("--frontend", default="http://localhost:4173",
|
||
help="前端 demo serve 地址(批跑必须 localhost,理由见 README D5 踩坑1)")
|
||
parser.add_argument("--cdp", default="http://127.0.0.1:9222",
|
||
help="CDP 端点(逗号分隔,≤2 实例取前两个;启动配方见 README §2)")
|
||
parser.add_argument("--launch-zone-id", type=int, default=backend_gw.DEFAULT_LAUNCH_ZONE_ID,
|
||
help="发布去向专区(默认 0,对齐 9001/9002 夹具基线)")
|
||
parser.add_argument("--eval-root", default=os.path.join(_REPO, "contracts", "prompts", "eval"),
|
||
help="eval 回流根目录(§7.7)")
|
||
# HJ-MC-TPL-EXEC-001 §7.1:模板参数化(default=clicker 保既有 clicker 批跑零改动)
|
||
parser.add_argument("--template", default="clicker",
|
||
help="玩法模板 ID(默认 clicker;本波 §6 支持集 clicker+merge)")
|
||
parser.add_argument("--prompt-designer", default=None,
|
||
help="策划 prompt 的 Registry id(默认按 --template 推导 config.<template>-designer)")
|
||
args = parser.parse_args(argv)
|
||
|
||
# 密钥纪律(§15-3,沿 gen_spike.py:143-145 写法):缺 NEWAPI_KEY 立即退出
|
||
if not os.environ.get("NEWAPI_KEY"):
|
||
print("ERROR: 需设环境变量 NEWAPI_KEY(密钥只走环境变量,严禁入 repo)", file=sys.stderr)
|
||
return EXIT_CONFIG
|
||
|
||
if args.batch_id is None:
|
||
args.batch_id = os.path.splitext(os.path.basename(args.ideas))[0]
|
||
|
||
# HJ-MC-TPL-EXEC-001 §7.1:由 CLI 覆盖模块级模板常量——schema 路径(:134)与全文
|
||
# TEMPLATE_ID/PROMPT_DESIGNER 引用自动联动(已是变量);--template 缺省时恒为 clicker,
|
||
# 既有 clicker 批跑行为零变化(红线:default=clicker 调用与改前等价)。
|
||
global TEMPLATE_ID, PROMPT_DESIGNER
|
||
TEMPLATE_ID = args.template
|
||
PROMPT_DESIGNER = args.prompt_designer or ("config.%s-designer" % args.template)
|
||
|
||
try:
|
||
ideas = load_ideas(args.ideas)
|
||
orch = Orchestrator(args)
|
||
orch.probe_environment() # F9:探活失败整批暂停(禁降级跳过试玩)
|
||
return orch.run(ideas)
|
||
except BatchHalt as halt:
|
||
print("ERROR: %s" % halt.reason, file=sys.stderr)
|
||
return halt.exit_code
|
||
except (prompts_mod.PromptError, llm_client.LlmError, ValueError) as ex:
|
||
# 配置类失败(D1 prompt/registry 缺位、模板 schema 非法 JSON、密钥缺失等):
|
||
# 干净退出码 2,不抛裸 traceback(真实代码缺陷仍会原样上抛)
|
||
print("ERROR: 配置/契约件检查失败:%s" % ex, file=sys.stderr)
|
||
return EXIT_CONFIG
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|