"""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 门阈值(人可玩 / 渲染反映) # archetype ← worker/archetypes.py(business-sim driver 参数) # ════════════════════════════════════════════════════════════════════════════ _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) }, # ── archetype:business-sim 品类 driver 参数(worker/archetypes.py 的 _business_sim_gate_spec)── # 注:这些是 driver 真玩规格里的可调数(步数 / 步间隔 / 胜负阈值),改它们影响 driver 怎么玩、 # 富游戏门怎么判;品类的【结构】(三系统 / 数据表 schema)不在配置层,仍由 fixture / 注册表定。 "archetype": { "business_sim_driver_steps": 80, # driver 总步数 "business_sim_driver_step_ms": 280, # driver 步间隔(ms) "business_sim_win_threshold": 100, # 盈利路金币胜利阈值(与 economy/latch 门对齐) "business_sim_bankrupt_steps": 60, # 破产路观察步数 "business_sim_streak_lose": 3, # 连续流失判负次数 }, } # ── 运行时读状态(缓存 + 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) _check("内置默认 budget.rmb_hard_limit", get("budget", "rmb_hard_limit", 3.0), 3.0) _check("内置默认 model.thinking_enable(bool)", get("model", "thinking_enable", True), True) _check("内置默认 iteration.writer_max_iters", get("iteration", "writer_max_iters", 40), 40) # ② 调用方 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), 40) 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), 40) 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 导出自测全部通过。")