lili 882ca3f357 feat(cheap-gen): W-AXIS 波2 判定语义重置——独立模型玩法判定阻断+拆残笼+判定档切 M3
裁定三落地:accepted = 机械预筛 ∧ 独立模型玩法判定,九门 pass 降为「未见明显死」预筛。

- 波2a 玩法判定器(cheap_verify.py):读 brief+run-summary+全程截图(含新增局中连拍
  midplay-1..N,play.cdp.cjs 只读并发拍、不进任何门判据),布尔裁 broken/hollow/off_brief,
  fail-closed;判定真相层落 evidence/judge.json + verdict.json 写回 accepted/judge 段;
  金标夹具 fixtures/judge-golden/ + judge_golden.py 校准跑批
- 波2b 拆残笼:check 词法 tokenizer 替换 stripCode 正则(字符串内 // 假阴根修,292 在册
  源零差异);prompt v1.6.3 外科清洗(两层奖励→纯设计语/删盲驱动器围设计/删熔断恐吓),
  registry 热取==内置逐字节一致(cheap_roles 内置回退同步);edit_file 空白归一+近似片段提示
- 判定器补修:空输出有界重试一次(glm 思考吃光 max_tokens 时 content 空)+重试放大公式
  反缩 bug 修(旧 min(×2,6000) 大 base 反缩)+finishReason 落盘;imagesSeen 模型自报+
  盲检微扰重掷、两掷均盲=仪器故障 degraded(闲鱼中转静默丢图实锤,不再错杀 hollow)
- 判定档切 MiniMax-M3(创始人 2026-07-10 拍板,裁定三①边界随 v2 plan 修订):
  generation.yaml judge.model+max_tokens=202752(512K 网关实探 400 拒,取实探上限);
  3 例盲案 M3 重判全 accept、¥0.026/局
- 三路消费对齐:CLI 预筛语义修(cheap_studio 喂 verdict.pass 而非 finished,违裁定三的
  放行已纠)/Service 路 apply_gameplay_judge 显式 prefilter 参数/result_out 权威改读
  accepted(无键回落预筛,旧产物兼容)+trace.gameplayJudge additive 透传;
  tier2 gate_judge/genconfig 同步判定配置与消费

测试:test_gameplay_judge 33 项新增,全仓 pytest 480 绿;真判定闭环 accept ¥0.061。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 04:27:18 -07:00

484 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""genconfig.py —— tier2 生成配置层加载器(把散在 Python 里的生成器旋钮收敛成一份外部 YAML,运行时读)。
【这份解决什么问题】
"调生成、优化过门率"是 tier2 当前最高频的迭代循环:改一个 max_iters、抬一档 thinking_budget、调一条
退路树阈值,跑一批看过门率有没有涨 20%。在此之前这些旋钮散在 6+ 个 Python 文件里写死,改一个值要
改码、再同步到 mini-desktop 重跑。本模块把它们集中成 tier2/config/generation.yaml,**运行时读**:
调生成 = 改 YAML 重跑,不改码、不重同步。这正是图说 B 族「配置注册表(唯一事实源 / 版本化 / 运行时读)」
在生成器旋钮这一面的最小落地——配置是真相,代码只是按 id 取值的消费方。
【取值优先级(运行时读,失败绝不中断主链)】
get(area, key, default) 每次调用按以下顺序取值,任一级取到即用:
⓪ Nacos 热源(配置控制面阶段二接入,默认不启用):worker 启动期若由工厂 genconfig_nacos
.build_and_attach_hot_source() attach 了一个 NacosHotConfig(env TIER2_GENCONFIG_NACOS 启用),
则最先查它——配置中心激活的预算/门阈值经 Nacos 长轮询推送热读进来,改一次配置中心、下一次生成
即读到新值,不重启。未启用(默认)时本级不存在,取值链 = 原三级,行为与阶段二前逐字节一致。
热源以复合键 "area.key" 存值(genconfig 是 (area,key) 二维,复合键无歧义扁平化);缺该键则继续
往下回落,绝不引入第二默认值(热源只给「有就用」的值,缺失全交下面 env/YAML/内置默认)。
① env 单点覆盖:环境变量 TIER2_GEN__<AREA>__<KEY>(全大写、双下划线分隔)——临时压一个值不必改文件,
适合「这一批我想试 max_iters=60」这种一次性实验(改完 unset 即恢复)。
② 外部 YAML:tier2/config/generation.yaml(路径可被 env TIER2_GENCONFIG 指向别处)的 area.key。
③ 内置默认 _BUILTIN_DEFAULTS[area][key]:本模块自带的一份与现行硬编码值字节一致的副本——YAML 缺失 /
读不出 / 脏值时的兜底,保证「配置文件没了也能按现行行为跑」。
④ 调用方传入的 default:连内置默认都没登记这个 key 时的最后防线(调用方按惯例传现行硬编码值)。
【默认行为字节不变(本相红线)】
外置后,只要没人去改 YAML / 没设 env,每个旋钮取到的值必须 == 改造前的硬编码值。为此:
- generation.yaml 里每个值 = 当前硬编码值(逐项对齐);
- _BUILTIN_DEFAULTS 里每个值 = 当前硬编码值(逐项对齐,作 YAML 失效兜底);
- 各调用点 get(..., default=现值):default 也填现值。
三处同值 ⇒ 无论哪一级取到,结果都是现值。改 YAML 一个值即可让它流通到取值点(运行时读)。
【best-effort 铁律(对齐项目 client.py / newapi_pricing.py 同款纪律)】
读文件 / 解析 YAML / env 转型的任何异常都不抛、不中断生成主链:读不到或脏 → 落内置默认 + 一次性告警。
YAML 只在「首次访问 + 文件 mtime 变化」时重读(运行时读但不必每次 get 都打开文件;改了文件下一次 get 自动生效)。
"""
from __future__ import annotations
import os
import threading
from pathlib import Path
from typing import Any
# YAML 解析:pyyaml 是声明依赖(requirements.txt),但仍 best-effort 兜底——import 失败则整层降级为
# 「只用内置默认 + env 覆盖」,绝不让一个缺失的解析库阻断生成(读不到 = 用默认,与脏文件同处置)。
try:
import yaml as _yaml # type: ignore
except Exception: # pragma: no cover —— 极端环境缺 pyyaml:降级为内置默认 + env(不抛)
_yaml = None
# ── 配置文件默认路径(可被 env TIER2_GENCONFIG 指向别处)─────────────────────────────
# 本文件在 tier2/gen-worker/worker/genconfig.py → 上溯 2 级到 gen-worker/,再上 1 级到 tier2/,
# 配置在 tier2/config/generation.yaml。与 archetypes.py / run.py 同源的「从 __file__ 推 tier2/ 根」口径。
_TIER2_DIR = Path(__file__).resolve().parents[2]
_DEFAULT_CONFIG_PATH = _TIER2_DIR / "config" / "generation.yaml"
# env 覆盖前缀:TIER2_GEN__<AREA>__<KEY>(双下划线分隔 area / key;全大写)。
# 例:压一档单写轮数 → export TIER2_GEN__ITERATION__WRITER_MAX_ITERS=60。
_ENV_PREFIX = "TIER2_GEN__"
# ════════════════════════════════════════════════════════════════════════════
# 内置默认(_BUILTIN_DEFAULTS)—— 与现行硬编码值【字节一致】的兜底副本
# 作用:generation.yaml 读不到 / 脏 / 缺某 key 时,按这里的值兜底,保证默认行为不漂移。
# ⚠️ 维护纪律:这里每个值必须与对应源文件里改造前的硬编码值严格相等;改默认值优先改 YAML,
# 这份兜底只在 YAML 失效时生效,不应与 YAML 的「现值」语义分叉。
# 分区(area)与各源文件一一对应,便于查改:
# model ← worker/config.py(模型名 / max_tokens / thinking / 历史压缩 / 取价估算)
# budget ← worker/middleware.py(¥ 硬闸 / 四道熔断 / 软刹 / 折价倍率 / 首调估算 token)
# iteration ← worker/agent_loop/studio.py(单写轮数 / 外层 resume 上限)
# design_team ← worker/agent_loop/design_team.py(leader 轮数 / 单专家上限 / 团队超时)
# gates ← worker/fallback_tree.py(退路树四阈值)+ harness 门阈值(人可玩 / 渲染反映)
# ════════════════════════════════════════════════════════════════════════════
_BUILTIN_DEFAULTS: dict[str, dict[str, Any]] = {
# ── model:模型客户端构建旋钮(worker/config.py)──
"model": {
"default_model_name": "MiniMax-M3", # model_name_from_env 默认(M3 路)
"deepseek_flash": "deepseek-v4-flash", # DEEPSEEK_FLASH 便宜主力档
"deepseek_pro": "deepseek-v4-pro", # DEEPSEEK_PRO 强便宜救场档
# ── 强基线档(C3;A 门「M3 证路(软)」用强模型跑同一条 studio/控制面线,把「路不通」与「便宜模型太弱」分开)──
# Opus = Anthropic 原生经 new-api(与 M3 同路);Fable 同协议但 **不假设网关一定可用**
# (build_baseline_model 取不到即诚实报错,不静默)。退路树 fallback_tree.STRONG_BASELINE_KEYS 读 "Opus"/"Fable" 当 key,
# 这里两键存的是 new-api 上的真实模型名(消费方:worker/config.py build_baseline_model 的 baseline_name → 真名映射)。
"baseline_opus": "claude-opus-4-8", # 强基线 Opus(Anthropic 原生路;A 门软证路主用)
"baseline_fable": "claude-fable-5", # 强基线 Fable(列出但不假设网关可用;取不到诚实报错)
"max_tokens": 16000, # build_model / build_model_openai 默认输出上限
"thinking_enable": True, # build_model 默认开 thinking 分离
"thinking_budget": 8000, # build_model 默认 thinking 预算(须 < max_tokens)
"max_retries": 2, # 网关偶发 502 突发兜底重试次数
"ctx_trigger_ratio": 0.8, # build_context_config 触发压缩比例
"ctx_reserve_ratio": 0.1, # build_context_config 保留最近上下文比例
},
# ── budget:¥ 累进硬闸 + 四道熔断 + 软刹(worker/middleware.py)──
"budget": {
# 富档单局 ¥ 软停线(soft 档语义;hard 档=单段硬线)。创始人 2026-07-03 一次性裁决「预算两段式」:
# 软停线维持 ¥50(富档)/¥10(便宜档,见 cheap_rmb_hard_limit),越线只许收尾类动作;
# 整局 RMB 硬地板 = 软停线 × rmb_hard_floor_ratio(1.5 → 富档 ¥75 / 便宜档 ¥15),middleware 强制。
# 单位经济或模型单价大变即复审(裁决记录 = 2026-07-03 重推演档 §5)。
"rmb_hard_limit": 50.0,
# 两段式硬地板倍率:硬地板 = 软停线 × 此值(soft 档 fail-closed 封上界;hard 档不用它)。
"rmb_hard_floor_ratio": 1.5,
# 便宜档单局 ¥ 软停线(创始人裁决 ¥10;硬地板 = ×1.5 = ¥15)。
# 生产 Service(cheap_service_app)与 CLI(cheap_studio)经 cheap_budget.build_cheap_breaker 同源读此键。
"cheap_rmb_hard_limit": 10.0,
"max_tool_calls": 100, # step_cap 步数硬顶(2026-07-04 创始人令 60→100,详注见 generation.yaml)
"max_model_calls": 120, # budget 预算闸(随步顶等比 80→120)
"wall_timeout_s": 2700.0, # timeout 整 reply 墙钟超时(随步顶 1800→2700)
"step_timeout_s": 420.0, # timeout 单步静默超时(秒)
"stuck_repeat_threshold": 4, # stuck 连续同一失败签名达此次数判死圈
"soft_ratio": 0.8, # 软刹触发比例(达 soft_ratio×硬顶注入收敛提醒)
"group_ratio": 1.0, # new-api 分组倍率(成本折算用)
"est_prompt_tokens": 12000, # _estimate_call_rmb 首调保守估:prompt token 量级
"est_completion_tokens": 2000, # _estimate_call_rmb 首调保守估:补全 token 量级
},
# ── iteration:单写 ReAct 轮数 + 外层有界 resume(worker/agent_loop/studio.py)──
"iteration": {
"writer_max_iters": 60, # run_studio 单写 ReAct 最大轮数(2026-07-04 创始人令 40→60,详注见 generation.yaml)
"max_resumes": 6, # 外层有界自纠 resume 上限(agent 过早停下再踹回去续修)
},
# ── design_team:阶段 1 工作室星形多 agent 设计预算(worker/agent_loop/design_team.py)──
"design_team": {
"leader_max_iters": 12, # leader ReAct 总轮数上限(调专家 + 汇总天花板)
"per_expert_cap": 2, # 单专家被 leader 调用次数上限(防反复刷同一专家)
"timeout_s": 420.0, # 整团队墙钟硬超时(秒;2026-07-04 F-2 R1 实证 240→420,详注见 generation.yaml)
},
# ── gates:退路树四阈值(fallback_tree.py)+ harness 门阈值(play-phaser.cdp.cjs)──
# 注:harness 是 Node,这些值经导出 JSON / env 注入到 .cjs(见下方 export_for_harness 与 .cjs 注释);
# Python 侧 fallback_tree 直接 import genconfig 读。
"gates": {
"qpass_go_min": 0.40, # 退路树 Q1:v4-pro 过门率 ≥ 此值 → GO(QPASS_GO_MIN)
"qpass_floor_max": 0.20, # 退路树 Q4:便宜档全线 < 此值 → 全线崩(QPASS_FLOOR_MAX)
"qpass_strong_baseline_min": 0.0, # 退路树 Q4:强基线过门率 > 此值 → 能过(QPASS_STRONG_BASELINE_MIN)
"concentration_ratio": 0.50, # 退路树 Q2/Q3:某桶失败占比 ≥ 此值 → 集中(CONCENTRATION_RATIO)
"human_min_patience_ms": 12000, # harness 人可玩 advisory:订单耐心人类最小窗口(HUMAN_MIN_PATIENCE_MS)
"color_dist_min": 18, # harness render-reflects-state:色差判反映状态阈值(COLOR_DIST_MIN)
"var_delta_min": 120, # harness render-reflects-state:方差增量判反映状态阈值(VAR_DELTA_MIN)
},
# ── judge:独立模型玩法地板判定(cheap-worker/cheap_verify.apply_gameplay_judge;W-AXIS 波2 裁定三)──
# 机械九门降位为预筛,玩法地板由独立多模态档看真玩证据裁 broken/hollow/off-brief,阻断放行(ok=预筛∧判定)。
"judge": {
"blocking": True, # 判定是否阻断放行(整体回退位:false=只观测、ok 仍=预筛)
"model": "glm-5.2", # 判定模型(new-api 唯一实测多模态档;2026-07-09 probe 坐实 M3/MiniMax/deepseek 不吃图)
"max_tokens": 1500, # 判定输出上限
"timeout_s": 120.0, # 判定 LLM 超时(秒);超时 → fail-closed
"max_frames": 6, # 最多喂几帧证据截图(首帧+局中连拍+局末)
"cost_target_rmb": 0.3, # 单局判定成本目标(¥;仅记账对照,不阻断)
},
# 注:business-sim driver 参数(steps/stepMs/winThreshold/bankruptSteps/streakLose)不在本配置层——
# 曾登记过一个 "archetype" 区声明这五个键,但生成路(archetypes._business_sim_gate_spec /
# run.DEFAULT_BUSINESS_SIM_PLAY_SPEC)从不读它、真值一直硬编码在那两处 = 死旋钮(改配置无效、误导)。
# 2026-07-05 清账删除该假声明,两处硬编码为单一权威源。若放量期确需热调这几个 driver 数,再按 D11 的
# 「读 genconfig、硬编码值作回落」范式补接线(那时这里重新加区、生成路加 genconfig.get 读取)。
}
# ── 运行时读状态(缓存 + mtime 守门;线程安全)──────────────────────────────────────
# _cache 存解析后的 YAML dict;_cache_mtime / _cache_path 记上次读的文件特征,用于判定是否需重读
# (运行时读 = 改了文件下次 get 自动重读,但不必每次 get 都开文件)。_warned 防重复告警刷屏。
_lock = threading.Lock()
_cache: dict[str, dict[str, Any]] | None = None
_cache_mtime: float | None = None
_cache_path: str | None = None
_warned: set[str] = set()
# ── Nacos 热源(配置控制面阶段二步骤4:genconfig 读取后端提升到 Nacos)────────────────────
# 工厂 genconfig_nacos.build_and_attach_hot_source() 在 worker 启动期把一个 NacosHotConfig 实例
# attach 进这个进程级单例槽;get() 取值链最前端先查它(见 get() 的 ⓪ 层)。未 attach(默认)→ get()
# 完全走原链(env→YAML→内置默认→调用方 default),行为与阶段二前逐字节一致。
# 为什么是进程级单例、且 genconfig 不认档:cheap Service 与 tier2 Service 是两个独立进程,却都
# `from worker import genconfig` 共用本模块。两档隔离不靠 genconfig 区分,而靠【各档进程 attach 各档
# dataId 的热源】——cheap 进程 attach cheap dataId 的 NacosHotConfig(store 里是 budget.cheap_* 键)、
# tier2 进程 attach tier2 dataId 的(store 里是 budget.rmb_hard_limit 等)。genconfig 只查「当前进程
# attach 的那个热源」,故同一份 get() 代码在两档进程里各取各的 dataId,天然不串档。
_hot_source: Any = None
# sentinel:区分「热源里没有这个键」与「热源里键值恰好是 None」。热源缺键必须继续回落到 env/YAML/
# 内置默认,不能被当成一个 None 值截断(否则会吞掉下面所有回落级)。
_MISSING: Any = object()
def attach_hot_source(source: Any) -> None:
"""worker 启动期由工厂注入 Nacos 热源(NacosHotConfig 实例);此后 get() 最先查它。
幂等可重入:重复 attach 覆盖为最新源。source 只需满足 get(key, default)->value 契约
(NacosHotConfig 即是),genconfig 不依赖其内部实现——故单测可注入任意 fake 源。
"""
global _hot_source
_hot_source = source
def detach_hot_source() -> None:
"""卸载热源,get() 回落纯本地链(env→YAML→内置默认→调用方 default)。
这是设计 §3.6/§6「回退开关」在进程内的一处落点:热源出问题时切回纯本地,调用面一行不改。
"""
global _hot_source
_hot_source = None
def _warn_once(tag: str, msg: str) -> None:
"""同一 tag 只告警一次(防 best-effort 路径在循环里刷屏);可追溯日志,带 [tier2-genconfig] 前缀。"""
if tag in _warned:
return
_warned.add(tag)
print(f"[tier2-genconfig] {msg}", flush=True)
def config_path() -> Path:
"""当前生效的配置文件路径:env TIER2_GENCONFIG 指定 > 默认 tier2/config/generation.yaml。"""
p = os.environ.get("TIER2_GENCONFIG")
return Path(p) if p else _DEFAULT_CONFIG_PATH
def _load_yaml() -> dict[str, dict[str, Any]]:
"""运行时读 generation.yaml(带 mtime 缓存 + best-effort)。返回解析后的 dict(读不到/脏 → 空 dict)。
线程安全:首次读 / 文件 mtime 变化时重读并刷新缓存;否则返回缓存(避免每次 get 都开文件)。
任何异常(文件缺失 / YAML 语法错 / 顶层非 dict / 缺 pyyaml)都不抛——返回空 dict(由上层落内置默认)。
"""
global _cache, _cache_mtime, _cache_path
path = config_path()
path_str = str(path)
# pyyaml 缺失:整层降级(只用内置默认 + env)。一次性告警后返回空 dict。
if _yaml is None:
_warn_once("no-yaml", "pyyaml 不可用 → 配置层降级为「内置默认 + env 覆盖」(行为仍 = 现值)。")
return {}
with _lock:
# 取文件 mtime;文件不存在 → mtime=None。
try:
mtime = path.stat().st_mtime if path.exists() else None
except OSError:
mtime = None
# 命中缓存:路径与 mtime 都没变 → 直接返回上次解析结果(运行时读但不重复开文件)。
if _cache is not None and _cache_path == path_str and _cache_mtime == mtime:
return _cache
# 文件不存在:缓存空 dict(按内置默认跑);一次性告警「用内置默认」(非错误,可能就是没建文件)。
if mtime is None:
_warn_once(
f"missing:{path_str}",
f"配置文件不存在({path_str})→ 全部用内置默认(行为 = 现值);"
"如需调生成,建该文件或 export TIER2_GENCONFIG 指向它。",
)
_cache, _cache_mtime, _cache_path = {}, None, path_str
return _cache
# 读 + 解析(best-effort:任何异常都落空 dict + 告警,绝不抛)。
try:
raw = path.read_text(encoding="utf-8")
data = _yaml.safe_load(raw)
if data is None:
data = {} # 空文件 → 空配置(按内置默认跑)
if not isinstance(data, dict):
# 顶层不是映射(写错了)→ 视为脏文件,落空 dict 用内置默认。
_warn_once(
f"nonmap:{path_str}",
f"配置文件顶层不是 YAML 映射({path_str})→ 视为脏,全部用内置默认(行为 = 现值)。",
)
data = {}
except Exception as exc: # noqa: BLE001 —— 脏 YAML / IO 异常都降级,绝不中断生成
_warn_once(
f"parse:{path_str}",
f"配置文件解析失败({path_str}: {type(exc).__name__}: {exc})→ 全部用内置默认(行为 = 现值)。",
)
data = {}
_cache, _cache_mtime, _cache_path = data, mtime, path_str
return _cache
def _coerce(value: Any, like: Any) -> Any:
"""把从 env / YAML 取到的原始值按「内置默认 like 的类型」做最小转型(best-effort,失败回 None)。
为什么要转型:env 取出来全是字符串,YAML 也可能把 0.8 读成字符串;转型按现值的类型对齐,避免
"60" 当字符串塞进期望 int 的旋钮。bool 特殊处理(避免 bool('False') == True 的经典坑)。
转型失败(如把 'abc' 转 int)→ 返回 None,由上层视为「这一级没取到」继续往下回落。
"""
if value is None:
return None
try:
if isinstance(like, bool):
# bool 优先:像 like 是 True/False 时,把常见真假字面量与数值都正确映射。
if isinstance(value, bool):
return value
s = str(value).strip().lower()
if s in ("1", "true", "yes", "on"):
return True
if s in ("0", "false", "no", "off"):
return False
return None # 不认识的 bool 字面量 → 视为没取到
if isinstance(like, int) and not isinstance(like, bool):
return int(value)
if isinstance(like, float):
return float(value)
if isinstance(like, str):
return str(value)
except (TypeError, ValueError):
return None # 转型失败 → 当作没取到,回落下一级
# like 是 None / 其它类型:原样返回(不强转)。
return value
def get(area: str, key: str, default: Any = None) -> Any:
"""取一个生成器旋钮的当前值:env 覆盖 > 外部 YAML > 内置默认 > 调用方 default。
Args:
area: 分区名(model / budget / iteration / design_team / gates / archetype)。
key: 分区内的旋钮名(见 _BUILTIN_DEFAULTS 与 generation.yaml)。
default: 调用方传入的兜底值——**按惯例传该旋钮当前硬编码值**;它是连内置默认都没登记该 key
时的最后防线,保证取值结果在三处同值时 == 现值(默认行为字节不变)。
Returns:
旋钮值(类型按内置默认对齐转型;全级都取不到 → 返回 default)。绝不抛(best-effort)。
取值口径(任一级取到即用):
① env TIER2_GEN__<AREA>__<KEY>(全大写、双下划线分隔);
② YAML 的 area.key;
③ _BUILTIN_DEFAULTS[area][key];
④ 调用方 default。
转型基准 `like`:优先用内置默认的类型,缺则用 default 的类型(让 env/YAML 的字符串值按现值类型落位)。
"""
builtin = _BUILTIN_DEFAULTS.get(area, {})
# 转型基准:内置默认有该 key 用它的类型,否则用 default 的类型(都为 None 则不强转)。
like = builtin.get(key, default)
# ⓪ Nacos 热源(配置控制面阶段二:最高优先级)。未 attach(默认)/ 热源无此键 / 取值异常 → 静默
# 继续回落,绝不引入第二默认值(热源只给「有就用」的值,缺失一律交下面 env/YAML/内置默认)。
# 复合键 area.key:把 genconfig 的 (area,key) 二维无歧义扁平化成热源 store 的一个键;激活编排 /
# 演示脚本按同一 "area.key" 组 dataId JSON。热源命中即返回,根本不读 YAML —— mtime 缓存无从盖住热值。
src = _hot_source
if src is not None:
try:
hot_val = src.get(f"{area}.{key}", _MISSING)
except Exception as exc: # noqa: BLE001 —— 热源取值绝不连累主链:异常即视为「没取到」回落
hot_val = _MISSING
_warn_once(
f"hot-err:{area}.{key}",
f"Nacos 热源取 {area}.{key} 异常({type(exc).__name__}: {exc})→ 回落 env/YAML/默认。",
)
if hot_val is not _MISSING:
coerced = _coerce(hot_val, like)
if coerced is not None:
return coerced
# 热源有该键但值转不动(类型脏)→ 告警回落(与 env/YAML 脏值同处置,不让脏热值穿透)。
_warn_once(
f"hot-bad:{area}.{key}",
f"Nacos 热源 {area}.{key}={hot_val!r} 无法转成 {type(like).__name__} → 忽略,回落 env/YAML/默认。",
)
# ① env 单点覆盖(Nacos 热源之下、YAML 之上;一次性实验逃生舱,不必改文件)。
env_name = f"{_ENV_PREFIX}{area.upper()}__{key.upper()}"
env_raw = os.environ.get(env_name)
if env_raw is not None:
coerced = _coerce(env_raw, like)
if coerced is not None:
return coerced
# env 设了但转型失败(写了非法值)→ 告警并继续回落(不让一个手滑的 env 把旋钮变 None)。
_warn_once(
f"env-bad:{env_name}",
f"env {env_name}={env_raw!r} 无法转成 {type(like).__name__} → 忽略,回落 YAML/默认。",
)
# ② 外部 YAML 的 area.key。
data = _load_yaml()
section = data.get(area) if isinstance(data, dict) else None
if isinstance(section, dict) and key in section:
coerced = _coerce(section.get(key), like)
if coerced is not None:
return coerced
# YAML 写了该 key 但值脏(类型转不动)→ 告警并回落内置默认(不让脏值穿透)。
_warn_once(
f"yaml-bad:{area}.{key}",
f"配置 {area}.{key}={section.get(key)!r} 无法转成 {type(like).__name__} → 忽略,回落内置默认。",
)
# ③ 内置默认。
if key in builtin:
return builtin[key]
# ④ 调用方 default(连内置默认都没登记该 key 的最后防线)。
return default
def export_for_harness() -> dict[str, Any]:
"""导出 harness(Node)需要的门阈值为一个扁平 dict(供 .cjs 读一份 JSON,或编排器写临时 JSON)。
harness 是 .cjs,不便直接读本 Python 加载器;最小改动法是让它读一份导出的 JSON 或经 env 注入。
本函数把 gates 区里 harness 关心的几个阈值按 .cjs 端约定的 env 名导出成 dict,值仍走 get() 三级回落
(故默认 = 现值)。.cjs 端按「process.env.<同名> > 内置默认」消费(见 play-phaser.cdp.cjs 顶部注释)。
返回的键 = .cjs 端读取的 env 变量名,值 = 当前生效值(已转型)。
"""
return {
# 键名 = play-phaser.cdp.cjs 读的 env 名(TIER2_GEN__GATES__* 同 get() 口径,.cjs 也认这个前缀)。
"TIER2_GEN__GATES__HUMAN_MIN_PATIENCE_MS": get("gates", "human_min_patience_ms", 12000),
"TIER2_GEN__GATES__COLOR_DIST_MIN": get("gates", "color_dist_min", 18),
"TIER2_GEN__GATES__VAR_DELTA_MIN": get("gates", "var_delta_min", 120),
}
def reload() -> None:
"""强制丢弃缓存,下次 get 重新读文件(测试 / 手动热更用;运行时 mtime 已自动重读,一般不必显式调)。"""
global _cache, _cache_mtime, _cache_path
with _lock:
_cache, _cache_mtime, _cache_path = None, None, None
# ── __main__ 自测块:不依赖 agentscope / 网络,只校验三级回落与转型(6c6g 可直接跑)────────────
# 校验:python3 -c 'from worker import genconfig'(import 干净)+ python3 worker/genconfig.py(自测)。
if __name__ == "__main__": # pragma: no cover —— 本地自测
import tempfile
def _check(label: str, got: Any, expect: Any) -> None:
ok = "OK" if got == expect and type(got) is type(expect) else "FAIL"
print(f"[{ok}] {label}: got={got!r}(期望 {expect!r})")
assert got == expect and type(got) is type(expect), f"{label}: 期望 {expect!r},实得 {got!r}"
# ① 无 YAML、无 env:取内置默认(= 现值),类型正确。
reload()
os.environ.pop("TIER2_GENCONFIG", None)
_check("内置默认 model.max_tokens", get("model", "max_tokens", 16000), 16000)
# 期望值锚当前单源现值(YAML/内置):rmb_hard_limit 裁决 ¥50(0号 spike 占位 3.0 已废)、
# writer_max_iters 60(2026-07-04 创始人阈值批 40→60)。改单源必须同步这里,否则自测块先失真。
_check("内置默认 budget.rmb_hard_limit", get("budget", "rmb_hard_limit", 3.0), 50.0)
_check("内置默认 model.thinking_enable(bool)", get("model", "thinking_enable", True), True)
_check("内置默认 iteration.writer_max_iters", get("iteration", "writer_max_iters", 40), 60)
# ② 调用方 default 兜底:内置默认未登记的 key → 返回 default。
_check("未登记 key 回落 default", get("model", "不存在的key", 999), 999)
# ③ env 覆盖 + 转型(字符串 → int / float / bool)。
os.environ["TIER2_GEN__ITERATION__WRITER_MAX_ITERS"] = "60"
os.environ["TIER2_GEN__BUDGET__RMB_HARD_LIMIT"] = "5.5"
os.environ["TIER2_GEN__MODEL__THINKING_ENABLE"] = "false"
_check("env 覆盖 int", get("iteration", "writer_max_iters", 40), 60)
_check("env 覆盖 float", get("budget", "rmb_hard_limit", 3.0), 5.5)
_check("env 覆盖 bool=false", get("model", "thinking_enable", True), False)
# env 非法值 → 忽略、回落内置默认(不变 None)。
os.environ["TIER2_GEN__ITERATION__WRITER_MAX_ITERS"] = "abc"
_check("env 非法值回落默认", get("iteration", "writer_max_iters", 40), 60)
for k in ("TIER2_GEN__ITERATION__WRITER_MAX_ITERS", "TIER2_GEN__BUDGET__RMB_HARD_LIMIT",
"TIER2_GEN__MODEL__THINKING_ENABLE"):
os.environ.pop(k, None)
# ④ 外部 YAML 流通:写一个临时 YAML 改一个值 → get 取到改后值(运行时读)。
if _yaml is not None:
with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False, encoding="utf-8") as f:
f.write("model:\n max_tokens: 24000\nbudget:\n rmb_hard_limit: 2.0\n")
tmp_path = f.name
os.environ["TIER2_GENCONFIG"] = tmp_path
reload()
_check("YAML 改值流通 model.max_tokens", get("model", "max_tokens", 16000), 24000)
_check("YAML 改值流通 budget.rmb_hard_limit", get("budget", "rmb_hard_limit", 3.0), 2.0)
# YAML 没写的 key 仍回落内置默认(= 现值)。
_check("YAML 未写 key 回落内置默认", get("iteration", "writer_max_iters", 40), 60)
os.environ.pop("TIER2_GENCONFIG", None)
os.unlink(tmp_path)
reload()
# ⑤ 脏 YAML(顶层非映射 / 语法错):降级内置默认,不抛。
if _yaml is not None:
with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False, encoding="utf-8") as f:
f.write("- 这是个列表不是映射\n- 顶层写错了\n")
bad_path = f.name
os.environ["TIER2_GENCONFIG"] = bad_path
reload()
_check("脏 YAML 降级内置默认", get("model", "max_tokens", 16000), 16000)
os.environ.pop("TIER2_GENCONFIG", None)
os.unlink(bad_path)
reload()
# ⑥ export_for_harness:默认导出 = 现值。
h = export_for_harness()
_check("harness 导出 HUMAN_MIN_PATIENCE_MS", h["TIER2_GEN__GATES__HUMAN_MIN_PATIENCE_MS"], 12000)
_check("harness 导出 COLOR_DIST_MIN", h["TIER2_GEN__GATES__COLOR_DIST_MIN"], 18)
print("\n[genconfig] 三级回落 + 转型 + YAML 流通 + 脏值降级 + harness 导出自测全部通过。")