"""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____(全大写、双下划线分隔)——临时压一个值不必改文件,
适合「这一批我想试 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;全大写)。
# 例:压一档单写轮数 → 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____(全大写、双下划线分隔);
② 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 导出自测全部通过。")