统一 rubric 判分器
凡是靠 tests/rubric.json 让大模型逐条打分的 instance,判分代码都用这一份
tests/judge.py,在这个目录改,改完拷到各个 instance 里去。
1.2.0 起 rubric 分三类(judge_method 字段),由 tests/router.py 分派:
judge_method |
谁来判 | 例子 |
|---|---|---|
code |
直接执行 utils.py 里的函数,零 LLM 调用 |
locate_deck、check_deck_openable |
workflow |
workflow/<name>/ 下代码写死流程的多步判分器 |
ppt_judge(视觉 + 内容两轴) |
agentic |
分块交给 coding agent(codex/claude)逐条判 | 1.9.0 及以前的全部 rubric |
judge_method 缺失 = agentic。 存量 4312 个包的 rubric.json 一个字不改、
行为逐字节等价,1.2.0 对它们是能力上线而不是行为变更。
这个目录本身就摆成一个 harbor instance 的样子:把 tests/ 下那几个文件拷进目标
instance 的 tests/,再把 task.toml 里 [verifier.env] 那段照抄过去,就算装好了。
这些文件各干什么:
| 文件 | 干什么 | 要不要拷 |
|---|---|---|
test.sh |
harbor 调的入口。收集产物、归档、探测环境能力、然后调 judge.py |
✓ |
judge.py |
rubric 规范化、工作区、算分、三件套输出。唯一决定分数的地方 | ✓ |
router.py |
按 judge_method 分派 + 串行编排 + 收分 + per-method 留痕 |
✓ |
utils.py |
code 型 rubric 的函数库 |
✓ |
workflow/{__init__,_contract}.py |
workflow 的加载器与契约(router.py 模块级 import) |
✓ |
workflow/<name>/ |
某一类任务的 workflow 判分器 | 按需(用到才拷) |
lint_rubric.py |
打包前的引用完整性检查 | ✗(工具) |
reward_exit_message.py |
判分器自己崩了的时候,从日志里反推一个错误码 | ✓ |
装法 A:pip 装包(薄壳 instance,1.2.0+ 推荐新 export 用)
1.2.0 起判分代码发布到 PyPI(包名 cowork-judge)。新 export 的 instance 不再物理
拷贝判分代码:tests/test.sh 换成薄壳 templates/test.sh,运行时装包打分。
存量装机源(物理拷贝)的装法见下文「怎么装」,两条路共用同一份判分代码
(src/cowork_judge/ 与 tests/ 同步,CI 有防漂移门禁)。
装包
pip install cowork-judge # 核心包(纯 agentic 判分,零第三方依赖)
pip install "cowork-judge==1.2.0" # 指定版本
pip install "cowork-judge[pptx]" # + python-pptx/lxml(离线 ppt 取证 pptx_evidence)
pip install "cowork-judge[workflow]" # + pymupdf/playwright/pillow/openai(ppt_judge)
[workflow] 只对 rubric 里有 judge_method: "workflow" 的 instance 有意义;装完后还要
playwright install chromium,判分镜像要装 LibreOffice(soffice)——这些是环境能力,
判分器靠 env_capabilities.json 探测,缺了只把相关 rubric 记「未判到」,不会误罚 deck。
薄壳 instance 集成
-
拷入口:
cp templates/test.sh <instance>/tests/test.sh(不拷 judge.py / router.py / utils.py / workflow/)。 -
在
<instance>/task.toml的[verifier.env]里声明版本 placeholder:COWORK_JUDGE_VERSION = "${COWORK_JUDGE_VERSION:-1.2.0}" # 可选: COWORK_WHEEL_URL = "${COWORK_WHEEL_URL:-}" # 内网 OSS PEP 503 index;空 = 公网 PyPI COWORK_JUDGE_EXTRAS = "${COWORK_JUDGE_EXTRAS:-}" # pptx / workflow / pptx,workflow CUSTOM_JUDGE_ENTRY = "${CUSTOM_JUDGE_ENTRY:-}" # 自定义判分脚本(相对 tests/ 或绝对路径)
-
提交任务时按需注入
COWORK_JUDGE_VERSION(rollout 侧覆盖),薄壳据此 pip install, 并把版本来源写进 result.json 的requested_version/version_source。
版本选择优先级:注入 > placeholder > 内置兜底。 提交侧注入
COWORK_JUDGE_VERSION → version_source=injection;只靠 task.toml 的 placeholder →
version_source=placeholder;都没给 → 装 1.2.0(包 __version__ 兜底)。
薄壳环境变量
| 变量 | 默认 | 说明 |
|---|---|---|
COWORK_JUDGE_VERSION |
1.2.0 |
要装的包版本。keep in sync with src/cowork_judge/__init__.py |
COWORK_JUDGE_VERSION_SOURCE |
package |
写进 result.json:injection(提交侧注入)/ placeholder(task.toml 默认)/ package(内置兜底) |
COWORK_WHEEL_URL |
空 | 内网 OSS PEP 503 simple index;空 = 公网 PyPI |
COWORK_JUDGE_EXTRAS |
空 | 追加的 pip extra:pptx / workflow / pptx,workflow |
CUSTOM_JUDGE_ENTRY |
空 | 自定义判分脚本路径(相对 tests/ 或绝对路径);失败自动写 0 分三件套 |
判分本身的 JUDGE_* 环境变量不变,见下文「环境变量」。
发布流程
tag v*(如 v1.2.0)→ Aone CI(.aoneci/release.yaml):
- tag 版本必须等于
src/cowork_judge/__init__.py的__version__; - 防漂移门禁:
tests/judge.py的VERSION==__version__,且tests/judge_instruction.md与src/cowork_judge/judge_instruction.md逐字节一致; - 构建 sdist + wheel,泄漏红线扫描(禁止内网地址/凭据进包);
- twine 上传公网 PyPI;
release-ossjob 同步推到内网 OSS PEP 503 index。
目录里有什么
judger/
├── task.toml instance 示例,只有 [verifier.env] 那段要照抄
├── README.md 本文件
├── CHANGELOG.md 每一版改了什么、为什么改(带实测数据)
├── src/cowork_judge/ PyPI 打包源(与 tests/ 同步,CI 防漂移门禁)
├── templates/test.sh 薄壳 verifier 入口(pip 装法用,见「装法 A」)
├── tests/ ← 要拷进 instance 的就是这里的东西(物理拷贝装法)
│ ├── judge.py ★ 唯一决定分数的地方
│ ├── router.py ★ 三类 rubric 的分派与编排
│ ├── utils.py ★ code 型函数库(白名单 CODE_METHODS)
│ ├── workflow/ ★ workflow 型判分器
│ │ ├── README.md 契约、三条规则、故障归属、ppt_judge 说明
│ │ ├── _contract.py verdict row 构造器 / 幂平均 / 退避 / na_policy
│ │ ├── stub/ 最小示例
│ │ └── ppt_judge/ 视觉 + 内容两轴
│ ├── lint_rubric.py 打包前校验(不通过别打包)
│ ├── reward_exit_message.py 错误码兜底(照抄 FIN-0071 的原件,一个字没改)
│ ├── test.sh harbor 入口
│ ├── pptx_evidence.py 旧的 ppt 取证库,**已废弃**(ppt_judge 自带渲染链路)
│ ├── rubric.json 示例文件,各 instance 用自己的
│ ├── judge_instruction.md 示例文件,各 instance 用自己的
│ ├── task_prompt.md 示例文件,各 instance 用自己的
│ └── workflow_assets/ ← per-instance 的 workflow 资产(如 ppt 的 task_check_dir)
└── selftests/ 自测,跑法见文末
注意三类文件的维护方式完全不同(判据是「改了会不会让别的包的分数变」, 不是「是不是代码」):
- 算分引擎,改了要全量重装:
judge.py/router.py/workflow/_contract.py。 它们每个包都有、且决定分数口径,受VERSION约束。 - 能力库,改了只重装用它的包:
utils.py/workflow/<name>/。 不受VERSION约束,纯新增(加一个函数、加一个 workflow 目录)时零重装。 - per-instance 资产,改了只重装那一批:
rubric.json/judge_instruction.md/task_prompt.md/workflow_assets/。不用动VERSION。
各 workflow/<name>/ 目录是按需拷的 —— 一个 word 任务不该带 1500 行
ppt_judge 代码和 playwright / LibreOffice 依赖。只有 workflow/__init__.py 和
workflow/_contract.py 是每个包必须带的(router.py 在模块级 import 它们)。
怎么装
单个 instance:
# 算分引擎(每个包都要)
cp judger/tests/{judge.py,router.py,reward_exit_message.py,test.sh} <instance>/tests/
mkdir -p <instance>/tests/workflow
cp judger/tests/workflow/{__init__.py,_contract.py} <instance>/tests/workflow/
# 能力库(按这个 instance 的 rubric.json 实际用到的拷)
cp judger/tests/utils.py <instance>/tests/ # code 型用得到
cp -r judger/tests/workflow/ppt_judge <instance>/tests/workflow/ # 只有 ppt 任务要
chmod 755 <instance>/tests/{judge.py,test.sh}
python3 <instance>/tests/lint_rubric.py <instance>/tests # 不通过别打包
# 再照 judger/task.toml 的 [verifier.env] 把目标 instance 的对齐,然后重新打包传 OSS
几件容易踩的事:
- 这些文件是打进每个 instance 的
content.tgz的。改完不重新打包上传 OSS, 线上跑的还是旧版本——本地改了等于没改。 workflow/<name>/按需拷,workflow/{__init__.py,_contract.py}必拷。 后两个 是router.py在模块级 import 的;前者惰性加载(load()只 import rubric.json 点名的那一个),所以一个 word 任务带着ppt_judge/只是白占体积和依赖。lint_rubric.py会同时报「引用了但没拷」(ERROR)和「拷了但没用到」(WARN)。router.py/utils.py/workflow/是 1.2.0 新增的,装机脚本要一起拷。 漏了router.py的话:全 agentic 的包会退回 1.9.0 的直判路径(行为等价,只在 stderr 告警);但只要有一条code/workflow型 rubric,就会以judge:scorer_error硬失败 —— 不能让 agent 去判一条本该由代码执行的 rubric, 那会悄悄产出一个看起来正常的错分。rubric.json/judge_instruction.md/task_prompt.md/expected_output//workflow_assets/不要动。judger/tests/下那几个只是示例,别拷过去把 instance 自己的覆盖了。- 判分说明一个字都不用改。 1.2.0 也没改它:
_judge_block本来就给每个块写 子集rubric.json,router 分派后 agentic 段拿到的也是子集,语义不变。 - 原来的判分脚本备份成
<名字>.pre-unify.<md5>.bak留在包里。OSS 那个桶只能写, 下载会 403,所以回滚点只能放在包内。
改一版要重新装哪些包
判据只有一条:改一个组件,要重装的是「带有这个组件、且行为真会变」的包。
这不是一个共享库,是 4312 份互不通信的独立拷贝(各自独立进程、独立文件系统)。
包 A 的 utils.py 比包 B 新,两者不会互相影响 —— 真正的不变量是每个包自己内部
自洽(rubric.json 引用的函数/workflow 在它自己的 tests/ 里存在且能跑),
而不是 fleet-wide 一致。
| 改了什么 | 哪些包有它 | 哪些包行为会变 | 要重装 |
|---|---|---|---|
judge.py(算分 / 留痕 / 输出) |
全部 | 全部 | 4312 + 204 |
router.py(分派 / gate / 留痕聚合) |
全部 | 全部 | 4312 + 204 |
workflow/_contract.py(verdict row 形状、power_mean、snap_to_grid) |
带 workflow/ 的包 |
有 workflow 型 rubric 的包 | 那一批 |
utils.py 纯新增一个函数 |
全部 | 一个都没有(存量 rubric.json 没人引用它) | 0 个 |
utils.py 改已有函数 |
全部 | 声明了那个函数的包 | 那一批 |
workflow/ppt_judge/ 的实现 |
拷了这个目录的包 | workflow_name: "ppt_judge" 的包 |
那一批 |
新增 workflow/word_judge/ |
只有新任务 | 只有新任务 | 0 个存量 |
某个 instance 的 rubric.json / workflow_assets/ |
那一个 | 那一个 | 那一批 |
只改 [verifier.env] 里已声明的变量的值 |
— | — | 不用重装,rollout 侧覆盖 |
只有前三行会 bump VERSION。utils.py 和 workflow/<name>/ 不受 VERSION 约束。
但一致性的作用域缩到了「批」,不是没有
放宽之后会出现新风险:包 A 的 locate_deck 是旧版(有 bug)、包 B 是新版。如果这两个
包在同一次 RL 训练里,那就是静默的口径不一致 —— 正是 1.0.0 的「各批比较符不一致」、
1.9.0 的「clawgym 被按二值判」那类问题。所以:
同一个任务族(同一份 jsonl / 同一次 RL 训练)里的包,能力库组件必须一致。 不同任务族之间不要求一致 —— 它们的 reward 本来就不可比(不同 rubric、不同 weight)。
也就是把校验粒度从 --all 改成 --batch <jsonl>。这是放宽的代价,不是免费的:
装机流程要开始记录「哪批用了哪个组件版本」,而不是只记一个全局 VERSION。
ABI:防「新 workflow + 旧 judge.py」
judge.py 里除了 VERSION 还有 ABI / ABI_COMPAT,两者是不同的东西:
| 常量 | 含义 | 什么时候动 |
|---|---|---|
VERSION |
算分引擎版本 | 每次引擎改动 |
ABI |
verdict row / ctx / WorkflowResult 的契约形状 |
只在契约真的换形状时 |
每个 workflow/<name>/__init__.py 声明 REQUIRES_ABI = N,router 加载时对着
ABI_COMPAT 校验,对不上就把该批 rubric 记未判到(workflow_missing)。
这道闸防的是:包里的 workflow/ 和 judge.py/router.py 不是同一次拷来的。不校验
的话,按新契约写的 workflow 会返回一个字段名对不上的 WorkflowResult,router 只当
它漏返回、补哨兵 —— 事后看起来像判分抖动,而不是装机错误。那是最难查的一类失败。
lint_rubric.py 在打包时也查这一条(以及「声明了 REQUIRES_ABI 没有」)。
long_horizon 那 204 个(long_horizon_0707_fix)不走批量装机那条链:每个包自带
一份 judge.py,没有单一来源,装机和校验脚本都不认它们。涉及算分引擎的改动两边
都要打,否则线上一半新一半旧。
1.2.0 对 judge.py 改了什么
只有 5 处,_compute / _judge_all / _judge_block / _build_schema /
_write_outputs / _die / _result_document / _extract_verdict_json / _polarity
一个字没动:
| # | 位置 | 改动 |
|---|---|---|
| 1 | 头部常量 | VERSION 1.9.0→1.2.0;新增 ABI / ABI_COMPAT;sys.path 加 TASK_DIR |
| 2 | _normalize_rubrics |
透传 + 校验 7 个新字段;judge_method 缺失→agentic;认不出→退回 agentic + WARN |
| 3 | main() |
_judge_all(...) → router.route(criteria, ctx);router 缺失时全 agentic 退回直判、有非 agentic 则硬失败 |
| 4 | main() |
纯 code 任务跳过 _configure_backend(零 LLM 调用不该因缺凭据判死) |
| 5 | extra_deliv |
update(method_stats),合并 router 的 per-method 留痕 |
存量等价性由 selftests/test_score.py::new_routing_fields_do_not_touch_compute 钉住:
7 个路由字段对 _compute 的输出逐字节没有影响。建议全量铺开前先在小批量上核对
task_score 与 1.9.0 逐位一致。
打分怎么算
记第 i 条 rubric 的权重 w_i、判词命中 hit_i(没判到的按 hit=false),
gate_i = 它 depends_on 的行是否全部命中(没写 depends_on 时恒为真),则
分母 W = Σ w_i w_i > 0 的行(含 hurdle 行)
分子 E = Σ w_i · lvl_i · [gate_i] w_i > 0 且非 hurdle
+ Σ w_i · [hit_i] w_i < 0 且非 hurdle(w_i 是负数,这项在扣分)
fatal = 存在 hurdle 行且 hit_i = true
lvl_i = 判分模型给的档位(分级项,`"type": "gradient"`,取值只能是 levels 的键)
= 1 if hit_i else 0 二值项(也是分级项漏回 level 时的兜底)
score_normalized = 0 若 fatal
= clamp(E / W, 0, 1) 否则
reward = score_normalized JUDGE_REWARD_MODE=continuous(默认)
= 1.0 if 判分成功 and 过线 else 0.0 JUDGE_REWARD_MODE=binary(迁移期才用)
task_score 就是 reward,原样透给 harbor。分子分母都在 judge.py:_compute(),
对应自测在 selftests/test_score.py。
逐条说明:
w > 0加分项:命中且过 gate 才加w;不管判没判到都记进分母。分级项加w × level。w < 0扣分项:命中就从分子里减|w|,不进分母。所以扣分只压已得分、不改天花板, 扣成负数会被 clamp 抬回 0。扣分项不看 gate,依赖没满足也照扣。hurdle: true致命门槛项:无论权重多少,本行永不产生分子,命中一条整题直接 0 分。 但权重 > 0 的门槛项仍然留在分母里(代码没排除),等于白占天花板 —— 门槛项应当写weight: 0,线上 3124 条门槛项都是这个形态。depends_on是 DAG gate:依赖没全命中 → 该行不计分,但分母保留,否则模型可以靠 不做前置步骤来抬分。依赖 id 悬空(指向不存在的行)按未满足处理(fail-closed)。- 一条正权重都没有、且没有 hurdle 行时,权重为 0 的行按等权
1.0兜底(对齐 GDPval 原实现的if not primary: primary = criteria);有 hurdle 就不兜底,否则命中门槛反而变成加分。 兜底之后依然没有正权重 →judge:scorer_error,整题不出分。
阈值(JUDGE_PASS_THRESHOLD)默认不参与算分,只写进 extra_fields.passed 供统计过线率。
JUDGE_REWARD_MODE=binary 是迁移期才用的开关,见下文。
门槛项的 hit 该怎么读(1.7.0)
门槛项的描述几乎都是负向的("最终交付缺失或为空"),所以 hit=true 的意思是
"确实发现了这个问题",不是"这条要求达到了"。判分模型很容易读反。
原来指望判分说明来讲清楚,但说明里只写了"weight > 0 加分、< 0 扣分",而线上 3124 条门槛项
全都是 weight == 0 且 hurdle == true,两条规则一条都不覆盖它,模型只能按正向猜——
交付明明合规也判 hit=true,整题 0 分。实测这种假 0 占了命中门槛项的 job 的 28%。
1.7.0 的做法:judge.py 发给模型的那份 block rubric.json 里,除了原有字段,再补两个
派生字段(不是标注字段,标注文件里没有):
polarity:negative/positive,由weight符号 +hurdle推出来。hit_means:一句话写清这条的hit=true到底是什么意思。negative 那档明确写 "要求已满足、没发现这个违规 → 必须hit=false"。
算分代码一个字没改,纯粹是给模型的输入里多说一句。改前后的实测对比见 CHANGELOG 1.7.0。
分级项 "type": "gradient"(1.9.0)
标注侧新增的第二种 rubric 形态:不是「做到/没做到」,而是「做到几成」。
{"id": "R06", "description": "DCF 用的 WACC 应落在 [10%,12%],越接近得分越高",
"tag": "内容质量-数值与计算准确性", "criterion_type": "Objective",
"criterion_necessity": "Explicit", "type": "gradient", "weight": 7.0,
"levels": {"1": "落在 [10%,12%] 内", "0.75": "偏离 ≤±1%", "0.5": "偏离 ±1%~±3%",
"0.25": "偏离 ±3%~±5%", "0": "未给出或偏离 >±5%"}}
levels的键就是该档的得分比例,必须是 0~1 的数、至少两档。得分 =weight × level, 所以分级项和二值项可以在同一份 rubric 里混用,分母口径完全不变(还是Σ w)。- 档位表原样发给判分模型(block
rubric.json里带levels+level_choices+ 分级版hit_means),并且level字段是结构化输出 schema 里 required 的—— 这是关键的接线点:4186 个已铺包各自带着一份只讲二值的judge_instruction.md, 靠指令文件讲不通,靠 schema 才强制得住。 - 模型回了表外的数(0.8、0.9)会吸附到最近的合法档位,并列时取低的一档(宁严不宽);
漏回
level则退回按hit算(满档/零分)并照常出分——分级项判不了也不能让整题没分。 hurdle行和w < 0行不吃levels:门槛是「有没有踩」、扣分是「有没有犯」, 没有「做到几成」这回事。写了也按二值判。- 结果里分级项多一个
results[].level(和criteria[].level_raw,模型回的原值), 二值项的输出形状一个字不变。 - 顺带把 clawgym 那批的
scores档位表({"0":…,"0.25":…,…},无weight)也接上了: 它没有权重 → 走「全零权重按等权 1.0 兜底」 → 分数恰好等于原clawgym_grader的 「各条分级得分算术平均」(等价性用例t_clawgym_scores_alias_equals_original_mean)。 1.8.x 之前这批被按二值判,0.75 档当 0,分数系统性偏低。
rubric 的三类判分手段(1.2.0)
rubric.json 里每条用 judge_method 标类型,tests/router.py 按它分派。
缺失 = agentic,所以存量 4312 个包的 rubric.json 一个字不改。
{
"workflow_config": {
"ppt_judge": {"mode": "grouped", "group_size": 1, "page_mean_p": 1.0,
"render_resolution": "1920x1080"}
},
"rubrics": [
{"id": "C-LOCATE-DECK", "judge_method": "code",
"code_method_name": "locate_deck",
"code_method_params": {"prefer": ["pptx", "html", "pdf"], "allow_multiple": false},
"description": "交付目录下存在且仅存在一个可识别的 deck 产物", "weight": 2.0},
{"id": "V-SL-OVERLAP", "judge_method": "workflow", "workflow_name": "ppt_judge",
"workflow_group": "containment", "na_policy": "unjudged",
"description": "所有元素之间没有遮挡对方可读内容的有害重叠……", "weight": 6.0,
"type": "gradient", "levels": {"1": "[grid]", "0.95": "[grid]", "…": "…"},
"depends_on": ["C-LOCATE-DECK", "C-DECK-OPENABLE"]},
{"id": "A-NARRATIVE-FLOW", "judge_method": "agentic",
"description": "整套 deck 的叙事有清晰主线……", "weight": 5.0}
]
}
新增的字段:
| 字段 | 用于 | 说明 |
|---|---|---|
judge_method |
全部 | code / workflow / agentic。缺失 = agentic;认不出的值退回 agentic 并 WARN |
code_method_name |
code |
utils.py 的 CODE_METHODS 白名单里的函数名。缺了就退回 agentic |
code_method_params |
code |
传给那个函数的参数对象 |
workflow_name |
workflow |
workflow/<name>/ 的目录名。router 按它聚合后整批交过去 |
workflow_group |
workflow |
mode=grouped 时的调用分组。缺失 → grouped 退化成 combined |
na_policy |
workflow |
全部页都判「不适用」时怎么记:pass / fail / unjudged(默认) |
page_mean_p |
workflow |
单条覆盖跨页聚合的幂平均指数(缺省用 workflow_config 的值) |
顶层的 workflow_config 放 workflow 的质量/口径参数(per-instance 属性,
不随 fleet 变,所以不占 harbor 变量)。放顶层是安全的:_rubric_rows 先按
rubrics/criteria/… 找 list 并提前返回,只有这些键都没有时才会走 id→行映射分支。
细节(契约、怎么写新 workflow、ppt_judge 的跨页聚合和 na_policy、极性陷阱)
见 tests/workflow/README.md。
示例 rubric.json 的构成
本仓 tests/rubric.json 是一个完整的三类混合示例(50 条,正权重合计 191),
配套资产是真实的 Abbott FY2022 10-K 那份 task_check_dir:
| 类型 | 条数 | 来源 | 形态 |
|---|---|---|---|
code |
2 | 手写 | locate_deck(点名 slides.pptx)/ check_pptx_openable |
workflow 视觉 |
14 | 源 visual_judge/rubrics.json 逐条搬 |
分级项 + 21 档量化网格,5 个 cluster |
workflow 内容 |
33 | 源 task_check_dir 的 19 type1 + 14 type2 |
二值项,type2 depends_on 配对 type1 |
agentic |
1 | 手写 | 正文语言与源文档一致 |
权重分配(视觉 53 : 内容 100 ≈ 1:2):
| 侧 | 档位 | 条数 | 小计 | 依据 |
|---|---|---|---|---|
| 视觉 | critical 6.0 | 4 | 24 | 源 CRITICAL_RUBRICS:结构性缺陷(裁切/遮挡/乱码/空白页) |
| 视觉 | 普通 3.0 | 9 | 27 | |
| 视觉 | richness 2.0 | 1 | 2 | 唯一的正向项,「可以更好」而非「有缺陷」 |
| 内容 | type2 4.0 | 14 | 56 | 讲得对不对 —— 数字错比漏章节严重 |
| 内容 | type1 必需 3.0 | 6 | 18 | 源 mandatory(原 VERIFY_MANDATORY_PENALTY 默认关,实际只表示「更重要」) |
| 内容 | type1 普通 2.0 | 13 | 26 | 讲没讲 |
| code | 2.0 / 3.0 | 2 | 5 | 纯门禁,失败时下游全被 gate,自身权重不重要 |
| agentic | 5.0 | 1 | 5 |
gen_ppt_rubrics.py --balance 1.0 可以把视觉侧等比缩放到 1:1,但那会让档位变成
11.3 / 5.7 这种非整数。当前保持整数档位,两轴的实际投入靠
JUDGE_WORKFLOW_DEADLINE_SHARE 和 mode 调。自测
weights_are_balanced_between_axes 卡住比例落在 0.4~1.6 区间、critical 重于普通项、
type2 重于 type1。
hurdle 字段所有条目都显式写了(当前全 false)。机制本身完好、有自测钉着
(命中一条 → 整题 0 分、不吃 levels、_polarity 推成 negative),随时可以启用 ——
但记得 hurdle + weight: 0 的 description 必须写成违规的描述,见下文极性那节。
这个示例是面向 pptx 任务配的:locate_deck 只扫 .pptx(源材料常是 pdf,不能
误当交付物)、点名 slides.pptx 且 require_expect_name: true(题面一般会指定产物
文件名)。html 相关的比例闸和「产物必须放在指定路径」那两条先去掉了 —— 前者是 html
任务才用,后者不是所有任务都硬性规定。
视觉和内容两侧都是从源文件程序化生成的,不是手写。 第一版示例只手写了 7 条视觉
rubric、漏了一半(还漏掉 CRITICAL_RUBRICS 里的 visual_eq_blank_page),而 lint
查不出来 —— 它只查引用完整性,不知道「应该有几条」。所以补了两条自测钉住:
example_rubric_json_covers_both_axes_completely(条数、cluster 覆盖、critical 权重、
分级/二值形态、na_policy)和 example_rubric_json_visual_questions_are_verbatim
(description 必须是源文件 question 的原文,改写就会改判分口径)。
几处映射规则(源文件的信息不能丢):
| 源字段 | 映射成 | 为什么 |
|---|---|---|
visual cluster |
workflow_group |
mode=grouped 的调用分组 |
visual question |
description |
逐字搬,严重度分级和豁免条件都写在里面 |
CRITICAL_RUBRICS(4 条) |
weight 8.0(普通 5.0、richness 4.0) |
原来只用于 n_critical_severe 上报,那套聚合删掉后改用权重表达 |
| 「NA if there are no X」(4 条) | na_policy: "pass" |
全 NA 说明 deck 里根本没这类元素,不该扣分 |
type1 mandatory(6 条) |
weight 3.0(非必需 2.0) |
原 VERIFY_MANDATORY_PENALTY 默认 0、门本来就关着,它事实上只表示「更重要」 |
type2 paired_type1_id |
depends_on |
这就是 leak_fix 的等价表达 |
执行顺序:code → workflow → agentic,严格串行
三段相对独立,串行有三个理由:
- workflow 和 agentic 都要调 LLM,并行会叠加瞬时 RPM。 1.8.0 那场 62% 判分失败 的根因就是全 fleet 峰值 RPM 顶穿配额。
- code 段在最前面,于是
locate_deck定下的 deck 路径能给后两段复用(走ctx["artifacts"])。这消掉了 PPT-Judger 的一个真 bug:那边 content 轴优先index.html、visual 轴优先.pptx,同一个目录能判出两个不同的文件,而最终分 是这两者的加权和。 - workflow 段在 agentic 之前,于是它渲出的页图落在
evidence/pages/—— 而_judge_block现成的硬链清单里已经有evidence,所以每个 agentic 块的工作目录 会自动拿到这批页图,不用再各自渲一遍(这正是 1.6.0 离线取证要解决的问题)。
JUDGE_WORKFLOW_DEADLINE_SHARE(默认 0.5)给 workflow 段划一段预算,剩下的留给
agentic。不留余量就是把「块预算漏」原地换成「撞总墙漏」,而且会让降级偏斜到
某一类 rubric 上(覆盖率掉了,但掉的是特定一类的分,reward 被系统性压低)。
code 段的定位结果怎么传给后两段
locate_deck 把 deck 路径写进 artifacts,router 收进 ctx["artifacts"]。两条传递
路径不同:
- workflow 段:直接从
ctx["artifacts"]读(run_workflows里sub = dict(ctx)是浅拷贝,artifacts是同一个 dict 对象)。 - agentic 段:
_judge_all的签名是(criteria, instr_text, env, codex_bin, model_arg),塞不进 artifacts —— 改签名就要动 judge.py。所以 router 把它写进WORK/evidence/artifacts.json,而_judge_block的硬链清单里已经有evidence,于是每个 agentic 块的工作目录都会自动拿到,零 judge.py 改动。
// evidence/artifacts.json(agentic 型 rubric 的 description 里可以直接引它)
{"deck_path": "/tmp/rubric_judge/output/slides.pptx", "deck_kind": "pptx",
"deck_rel": "slides.pptx", "deck_in_workspace": "output/slides.pptx"}
deck_in_workspace 是相对路径 —— agent 的工作目录里是 output/ 的硬链,不是宿主机
绝对路径。不这么传的后果:agentic 型 rubric 只能自己 glob 一遍 output/,可能判到一个
和 code/workflow 段不同的文件,那正是 PPT-Judger 里「content 轴优先 index.html、
visual 轴优先 .pptx,同一个目录判出两个文件」那个 bug 从 agentic 这侧漏回来。
depends_on 的三层消费
同一份声明被三处消费,各管一件事:
| 层 | 干什么 | 性质 |
|---|---|---|
| router(跨段) | 外部 dep 未命中 → 整条 rubric 不执行 | 优化:省钱 |
| workflow(段内) | 内部 dep 未命中 → 跳过这条的 LLM 调用(= 原 leak_fix) |
优化:省钱 |
_compute |
gate 未满足 → 不计分、分母保留 | 语义:唯一决定分数的一层 |
只有第三层决定分数,前两层有 bug 也只是白花钱。三条规则:
- 规则 1:段内短路只看内部 dep。外部 dep(code 段的
C-LOCATE-DECK) workflow 查不到判定,误当成「前置未命中」会把整批错杀。 - 规则 2:确定性短路产出
hit=false+[gate]reason,不是未判到哨兵。 记成未判到会让judge_coverage从「评估了多少」变成「deck 做得多好」,下游那条 按judge_coverage < 1mask 假 0 的口径就失效了。 - 规则 3:dep 未判到 ≠ dep 未命中。前者沿 DAG 传播成未判到,后者是真 0。 分数上都是 0,但留痕上必须分得开 —— 否则「code 函数崩了」和「deck 真没交产物」 在下游长得一模一样。
depends_on 必须写传递闭包。 _compute 的 hit_by_id 用的是原始 hit,
gate 不沿依赖链传递。反例:C-LOCATE-DECK 因为有两个 pptx 而歧义未命中,但
C-DECK-OPENABLE 随便挑一个能打开、hit=true,于是只写了
depends_on: ["C-DECK-OPENABLE"] 的视觉项 gate 照样打开 —— 定位失败了却还在打分,
判的还是一个来源不明的文件。lint_rubric.py 会算闭包并在不闭合时报错。
新任务怎么生成 rubric.json:gen_ppt_rubrics.py
别手写。 两侧来源不同,但都是生成的:
python3 tests/gen_ppt_rubrics.py <instance>/tests # 视觉 + 内容都刷
python3 tests/gen_ppt_rubrics.py <instance>/tests --content # 只刷内容侧
python3 tests/gen_ppt_rubrics.py <instance>/tests --balance 1.0 # 视觉:内容 = 1:1
python3 tests/gen_ppt_rubrics.py <instance>/tests --dry-run # 只看不写
| 侧 | 来源 | 性质 |
|---|---|---|
| 视觉 14 条 | workflow/ppt_judge/visual_rubrics.json |
所有 ppt 任务共享的模板,属于 workflow 包 |
| 内容 N 条 | workflow_assets/ppt_judge/task_check_dir/ |
每个任务都不一样,per-instance 资产 |
它保留 code / agentic / 别的 workflow 的 rubric,只重刷 ppt_judge 那部分,所以
可以反复跑(幂等,有自测钉着)。
visual_rubrics.json 是模板而不是运行时配置 —— workflow 运行时不读它(criteria 由
router 传进来)。它不带 depends_on,门禁由生成器按 --gate 注入(因为门禁的 id 是
per-instance 的)。
task_check_dir 的信息去哪了
两边靠 id 对应,各存各的一半,lint_rubric.py 校验不漂。没有信息丢失,
只是按「谁需要」分了两处存:
| 字段 | 去向 | 用途 |
|---|---|---|
id |
两边都有 | 连接键 |
question |
→ description |
逐字搬,判定标准就写在里面 |
mandatory |
→ weight(3.0 vs 2.0) |
字段本身留在资产里作溯源 |
paired_type1_id |
→ depends_on |
这就是 leak_fix 的等价表达 |
section / check_kind |
→ tag |
下游分类统计 |
search_hints |
留在资产 | type1 Pass A/B 的弱定位符 |
scope |
留在资产 | deck_wide → type2 看代表页而不是配对页 |
anchor_refs |
留在资产 | ground truth,整份内联进 prompt |
manifest.anchor_index |
留在资产 | anchor_refs 缺失时的兜底(运行时唯一会读的 manifest 字段) |
manifest.pairs / type1_only / counts |
留在资产 | 溯源;运行时不读,lint 校验与 depends_on 一致 |
verify_input / compare_against |
留在资产 | 纯文档,代码从不读(写错也不报错) |
generation-log.json |
留在资产 | 生成过程审计,判分时完全不读 |
判分口径进 rubric.json(标注可改、可加权),取证手段留在资产里(跟着 checklist 生成 流程走)。这样改 anchor 文字不用动 rubric.json,调权重不用动资产。
打包前必须跑 lint
python3 tests/lint_rubric.py <instance>/tests
查:
- rubric.json 内部:
code_method_name/workflow_name有没有写、depends_on悬空/成环/不闭合、量化网格是否合法、workflow型有没有既是 hurdle 又是分级项、 hurdle +weight: 0的 description 是不是负向的 - per-package 自洽(放宽「全量一致」之后最要紧的一条):
code_method_name在 本包utils.py的CODE_METHODS里注册了没、workflow_name在本包workflow/下存在没、workflow/{__init__.py,_contract.py}在不在、各 workflow 的REQUIRES_ABI和本包judge.py的ABI_COMPAT对不对得上、有没有拷了用不到的 workflow - ppt 资产:anchor 是否都在包里、
task_check_dir与 rubric.json 的 id 集合是否 一致、manifest.pairs与depends_on是否一致
退出码非 0 就别打包。
为什么是 lint 而不是 checksum:资产是 per-instance 的,随时会被优化、重标注,
按 md5 卡死会挡住一切正当改动;真正要防的不是「变了」而是「变得不自洽」——
那些在运行时只会表现为静默的错分或降级。能力库(utils.py / workflow/<name>/)
不受 VERSION 约束之后,也不能再靠 fleet-wide 的 md5 一致来保证「包能跑」,只能
逐包查引用完整性。
环境变量
"线上可调" 那一列很关键:harbor 只会注入 [verifier.env] 里声明过的变量,
没声明的等于线上不存在,改代码默认值也只对新装的包生效。
标 ✗ 的那几个只在本地调试有用。
本仓 task.toml 只声明了凭据和常动的那几个旋钮,标「模板未声明」的默认值等价、
但新装的包线上调不了,要调先把那行加回 [verifier.env]。已铺的 4186 个包里
这些变量当初都声明着,对它们仍然线上可调。
| 变量 | 默认 | 线上可调 | 说明 |
|---|---|---|---|
JUDGE_API_KEY / JUDGE_BASE_URL |
无 | ✓ | 判分模型的凭据,必须显式给。少任一个就直接以 judge:no_credential 退出,不会拿不到凭据还接着跑 |
OPENAI_API_KEY / OPENAI_BASE_URL |
无 | ✓ | 老配置兜底,JUDGE_* 优先。解析结果会回写进两套变量名,codex 的 config.toml 用哪个 env_key 都取得到 |
JUDGE_MODEL |
gpt-5.5 |
✓ | 判分模型名 |
JUDGE_BATCH |
4 |
✓ | 一个会话判几条。块数 = 会话数 = 请求量,代价是一个块超时就丢这么多条。1.5.1 从 8 减到 4、1.8.1 回到 8、1.8.2 又回到 4(8 条一块把长 rubric 实例判崩,覆盖率 0.980 → 0.903),三条 CHANGELOG 都要看 |
JUDGE_CONCURRENCY |
2 |
✓ | 同时跑几个块。这是全 fleet 峰值 RPM 的直接倍数,1.8.0 从 4 降到 2(ppt 那 126 个还是 8,没跟着改) |
JUDGE_PER_TIMEOUT |
150 |
✓ | 单次调用超时(秒)。单次耗时 ≈ 固定开销(起会话 + 读完所有产物,实测 ~100s)+ 条数 × ~3s。ppt 那族一条 rubric 的边际成本是几十秒,所以单独装 240 |
JUDGE_MAX_TRIES |
6 |
✓ | 一个块最多试几次。别调小:ppt 上试过 1,覆盖率反而从 0.825 掉到 0.722,见 CHANGELOG 1.6.0-ppt.2 |
JUDGE_RESPLIT |
1(= 开) |
✓ | 块失败后拆成单条重判。1.8.0 关掉(怕放大限流)、1.8.3 重新打开:块失败的实因是单次调用越过 PER_TIMEOUT,不是限流。必须和 DEADLINE=1800 一起开,只开这个是把块预算漏换成撞总墙漏 |
JUDGE_DEADLINE |
1800 |
✓ | 判分总时长上限(秒,从 judge.py 启动算)。超了就停,已判到的照常算分。1.8.0 从 780 提到 1200(配合 K=2 和 180s 抖动),1.8.3 提到 1800(给 resplit 留时间) |
JUDGE_START_JITTER |
180 |
模板未声明 | 第一波每个块各自随机等 0~N 秒再发请求,摊平瞬时速率。1.8.0 从 30 提到 180 |
JUDGE_RETRY_BACKOFF_MAX |
20 |
模板未声明 | 被限流时的最长等待秒数,uniform(1, min(20, 2·3^(n-1))) = 2→6→18→20 |
JUDGE_REWARD_MODE |
continuous |
✓ | continuous = reward 就是连续分(默认);binary = 按阈值压成 0/1。认不出的值按 continuous 处理并告警 |
JUDGE_PASS_THRESHOLD |
0.75 |
✓ | continuous 下只写统计字段;binary 下它决定分数 |
JUDGE_PASS_COMPARE |
gte |
模板未声明 | gt / gte。开 binary 迁移时要连比较符一起对齐,见下文 |
JUDGE_LENIENT_DELIVERY |
空(= 严格) | 模板未声明 | 设真值 → 把写在 /app 根和 /output 的产物也搬进来判分。默认严格:只认 /app/output |
JUDGE_CHAT_FALLBACK |
1(= 开) |
模板未声明 | output/ 空但对话里有答案 → 照常判分。0 恢复 1.2.0 的硬 0 |
JUDGE_EVIDENCE |
180 |
模板未声明 | 交付物是 .pptx 时,先离线抽一份结构 + 页图给判分模型的预算秒数(0 = 关)。抽一次所有块共用,见下文 |
JUDGE_METRIC_ID |
rubric_llm_judge |
模板未声明 | 写进 result.json,下游按它归类 |
JUDGE_AUX_FILES |
无 | ppt 那 126 个声明了 | 判分说明要 import 的辅助模块(逗号或空格分隔)。ppt 的 pptx_evidence.py 就靠它。不声明就不拷,模型 import 必失败 |
JUDGE_BACKEND |
codex |
✗ | codex / claude |
JUDGE_INSTRUCTION_FILE |
无 | ✗ | 判分说明的路径。不给就按 judge_instruction.md → judge_instructions.md → rubric_instruction.md → judge_prompt.md 找,都没有就用内置的默认说明并告警 |
JUDGE_SYSTEMIC_FRACTION |
0.4 |
✗ | 未判比例超过它才算系统性失败 |
JUDGE_SYSTEMIC_MIN |
2 |
✗ | 且未判条数至少这么多 |
JUDGE_SKIP |
无 | ✗ | 设真值 → 只归档产物不判分,reward.txt 写 SKIPPED |
1.2.0 新增的三个(都是 fleet 级成本旋钮)
新变量只加了三个,而且都是成本/并发/时限类。判断标准:
- 质量/口径参数 →
rubric.json的workflow_config(per-instance,不随 fleet 变):mode/group_size/page_mean_p/render_resolution - 成本/并发/时限参数 →
[verifier.env](fleet 级,限流事故时要能不重打 4312 个包 就一键拧低):下面这三个
| 变量 | 默认 | 线上可调 | 说明 |
|---|---|---|---|
JUDGE_WORKFLOW_CONCURRENCY |
4 |
✓ | workflow 段全局 VLM 在飞上限(信号量)。不是线程池大小 —— workflow 有嵌套并行(页 × rubric 组、type1/type2 各起一批),两个池各 8 worker 就是 16 个在飞,只有信号量封得住 |
JUDGE_WORKFLOW_DEADLINE_SHARE |
空(= 自适应) | ✓ | 留空 = 按需预留(见下)。显式给 0~1 的数就退回固定比例,事故时的逃生阀 |
JUDGE_WORKFLOW_START_JITTER |
30 |
✓ | workflow 段起判抖动(秒)。agentic 段有 JUDGE_START_JITTER=180,这是它的对应物 —— 单次调用短得多,所以默认小一档 |
JUDGE_CODE_TIMEOUT |
60 |
✓ | 单个 code 型函数的超时(守护线程 + join,超时记未判到 + code_timeout) |
JUDGE_PER_TIMEOUT / JUDGE_MAX_TRIES / JUDGE_RESPLIT / JUDGE_DEADLINE /
JUDGE_START_JITTER / JUDGE_RETRY_BACKOFF_MAX / JUDGE_MODEL / JUDGE_API_KEY /
JUDGE_BASE_URL 全部复用现有的,不新增同义变量。PPT-Judger 原来的
VJ_PRIMARY_MODEL / VJ_FALLBACK_MODEL / VLM_MODEL / TIMEOUT / WORKERS /
API_MAX_ATTEMPTS / API_RETRY_INTERVAL / PARSE_MAX_ATTEMPTS /
VERIFY_RENDER_RESOLUTION 以及 JUDGE_*→OPENAI_* 凭据桥都删掉了。
两段的预算怎么分:按需预留,不是固定比例
code → workflow → agentic 串行,预算得分开,否则前面的段会吃光后面的。但固定比例
对谁都不对 —— 两段的 rubric 比例在任务之间波动极大(ppt 这批是 47:1,有的任务没有
workflow,有的以 agentic 为主)。实测过一次事故:
固定 0.5:workflow 47 条 → 898s(不够,28 条 type2 全部未判到)
agentic 1 条 → 898s(只用 191s,浪费 707s)
↑ 浪费的这半,正好是被饿死的那部分需要的
关键不对称:agentic 的成本可预测,workflow 的不可预测。
- agentic:CHANGELOG 1.5.0 实测的成本模型 —— 单次 ≈ 固定开销 ~100s(起 codex 会话 +
读完所有产物)+ 条数 × ~3s,固定开销占九成。块数 = ⌈条数/
BATCH⌉,波数 = ⌈块数/K⌉。 - workflow:取决于页数,而页数要渲完图才知道。
所以给可预测的那一侧按需预留,剩下全给不可预测的那一侧:
agentic 预留 = 波数 × (100s + BATCH×3s) × 2 安全系数 2 同 BLOCK_BUDGET 的推导
workflow 可用 = max(剩余 − 预留, 0.5 × 剩余) 下限防 agentic 很多时把 workflow 挤掉
同样那两个任务:预留 224s(实测 agentic 只用 191/211s,够),workflow 拿到 1571s(原来的 1.75×)。
agentic 一条都没有时预留是 0,workflow 拿全部。显式设 JUDGE_WORKFLOW_DEADLINE_SHARE
可以退回固定比例当逃生阀。
workflow 段内的执行顺序:content 先,visual 后
这不增加预算,只改变预算不足时谁被饿死 —— 而两侧的降级质量差一个数量级:
| 预算耗尽时 | |
|---|---|
| visual | job 是页优先排的,所以是「所有 rubric 都少看几页」:用判到的页算跨页平均、reason 记「N 页未判到」,rubric 不丢 |
| content type2 | 一条 rubric 一次调用,没了就是整条丢;而且它有依赖链(tagging → type1 → type2)天然排最后 |
所以让预算不足砸在能优雅降级的那一侧。
workflow 的成本账(铺开前必须重算峰值 RPM)
JUDGE_DEADLINE 的三层结构(单次 ⊂ 单块 2×PER_TIMEOUT ⊂ 全局)只适用于
agentic 段。那个推导的前提是「超时失败一次就烧掉一个 PER_TIMEOUT,所以块预算
天然只够试 2 次」,而 workflow 的单次调用便宜得多、重试很划算。所以 workflow 段
不复用 BLOCK_BUDGET,用 DEADLINE_SHARE 划总预算、段内按 PER_TIMEOUT 控单次。
两段的成本形状完全不同:
| agentic | workflow(20 页 deck,grouped) | |
|---|---|---|
| 单次成本 | 固定开销 ~100s(起 codex 会话 + 读全部产物)+ 条数 × ~3s | 一次 VLM chat,几秒~几十秒 |
| 每 job 调用数 | 块数 = ⌈条数/BATCH⌉,典型 ~6 | O(页数 × rubric 数 / group_size),典型 ~100 |
| 默认并发 | JUDGE_CONCURRENCY=2 |
JUDGE_WORKFLOW_CONCURRENCY=4 |
| 每 job RPM 量级 | 2/100 × 60 ≈ 1.2 | 4/10 × 60 ≈ 24 |
mode 的选择直接乘在峰值 RPM 上:combined 1× / grouped 5× / split 7×。
1.8.0 实测过 CONCURRENCY 是全 fleet 峰值 RPM 的直接倍数,正常态就已吃掉 key 额度
的 65%,一抖动就正反馈;而且限流在 HTTP 层看不见(在 SSE 流里返回,proxy log
里 737 个请求全是 200,网关 metrics 一个错误都没有,只能下 artifact 查)。
所以:在小批量 A/B 之前不要往 4312 个包上铺 workflow 型 rubric。 先按目标
instance 的真实页数 × rubric 数 × 预计同时判分的 job 数算一遍峰值,再定
JUDGE_WORKFLOW_CONCURRENCY 和 mode。
几个关键行为
产物写错地方 = 没交付;但"没写文件" ≠ "什么都没做"
题面对交付路径是硬要求,三批 yuanrang 数据一字不差(/app/output/result.md、
把唯一的最终产物写入 /app/output/),gdpval0701 是同一句的英文版。所以默认口径是
只认 /app/output 下的非空文件。写到 /app 根或 /output 的不算分,
但会归档到 /logs/artifacts/output/_stray_not_scored/,不然"模型到底写哪了"查不出来。
但这批数据里有一批题题面本身就要求答在对话里,和 harbor 的样板要求直接冲突。数过:
| 分类 | 实例数 | rubric 里一条文件性要求都没有 | 文件性权重占比中位数 |
|---|---|---|---|
| 纯对话题(题面明确不让建文件) | 61(WLE 48 + GDP 13) | 31 个(51%) | 0.0% |
| 混合题(要交文件 + "洞察请在文本直接回复") | 49(GDP 45 + WLE 4) | 11 个(22%) | 29.3% |
那 31 个纯对话题,一刀切给 0 分丢掉的是全部能判的权重:rubric 一条文件性要求都没提, 每条都能从对话回复里判出来,结果一条没判就给 0。所以 1.3.0 改成让 rubric 条目自己决定 看哪儿的证据:
- 判分说明保持原样。原文本来就写着"纯文本题(
output/为空)以model_response_text.txt为判据",1.2.0 把它改坏了,1.3.0 逐字节退回去。 - 不需要造假文件。
judge.py无条件把 agent 最后那句回复写进判分工作区的model_response_text.txt,判分模型本来就看得到。反过来,把回复写成output/下一个文件 会让"output/为空"这个前提不成立,和刚退回去的原文打架。 - 所以
JUDGE_CHAT_FALLBACK=1(默认开)放宽的只是这道判断本身:没交文件、但对话里有答案 → 照常判分,并记一笔。 - 文件性条目该扣的分不用另加机制。判分说明第 1 条就是"有
output/交付文件时优先以其为判据", 真·文件题只答对话,点名要文件的那些条目自然判不中。于是罚分是按权重成比例的 (混合题中位数 29.3%),比一刀切 0 分准得多,也不用给每个实例配开关。 details.delivery.from_chat/extra_fields.delivery_from_chat会记下来, 下游能把"真交付"和"只答对话"分开统计。
两个渠道都空(文件没写、对话里也没答案)才是真的什么都没交:
- 直接给 0,一次判分 API 都不发。没有产物就没有证据可命中,逐条判一遍结果一样,
白花一轮钱。
extra_fields.judge_called = false标记这件事。 status仍是success,不给exit_code。没交付是任务没做完,不是判分坏了。 给了错误码,下游会按"基础设施故障"把这条过滤掉,等于没交付还免罚。这个 0 必须原样进训练集。- 原因写进
exit_reason,extra_fields.delivery_missing = true,details.delivery记下files/missing/from_chat/stray。
想回到老口径:JUDGE_CHAT_FALLBACK=0 恢复 1.2.0 的"没写文件就 0 分";
JUDGE_LENIENT_DELIVERY=1 恢复原来的宽松路径(并且会连带打开纯文本题那条)。
已部署的实例有 2957 个,所以这两处都做成可配置而不是写死。
少判了几条也照常出分,但要留下痕迹
口径是:没判到的 rubric 按未命中算、照常出分,只有系统性失败才标 exit_code。
好处是判分端抖一下不会丢掉整条 rollout,代价是局部降级原来一点痕迹都没有——实测
group-0a04ac64(GDP 全量)3582 个真判过的 job 里,有 1862 个(52%)少判但没到门槛,
平均丢 20.3% 的 rubric(reward 均值 0.400,满覆盖的是 0.448),分被静默压低,
下游按 exit_code 也筛不出来。所以 1.4.0 起 extra_fields 里恒定写这几个字段:
| 字段 | 含义 |
|---|---|
judge_coverage |
判到的比例。想只用满覆盖的样本就按 == 1.0 筛 |
judge_unjudged |
漏判几条 |
judge_degraded |
有没有降级 |
judge_error_kind |
漏判的真因(限流是 rate_limit、撞总时长墙是 deadline) |
judge_seconds |
判分花了多少秒(1.6.0 加) |
judge_evidence_pages |
离线取证渲出几页(0 = 镜像里没有渲图链路,1.6.0 加) |
judge_methods |
各 method 的 {n, judged, coverage}(1.2.0 加) |
judge_coverage_by_method |
{code: 1.0, workflow: 0.82, agentic: 1.0}(1.2.0 加) |
judge_unjudged_weight_frac |
未判到的正权重占比(1.2.0 加) |
最后三个是 1.2.0 新增的。加 judge_unjudged_weight_frac 的动机:三类混合之后
systemic 门禁按「条数」算会失真 —— 漏掉 3 条 weight=8 的内容项,比漏掉 10 条
weight=1 的视觉项严重得多。但门禁本身这一版不改,先只留痕。 1.4.0 先加覆盖率
字段、1.8.x 才拿实测数据改并发默认值,这一条走同一个节奏:攒到线上数据再决定要不要
把 systemic 改成按权重。
judge_error_kind 在 1.2.0 多了几个更细的值(vlm_error / render_env /
asset_broken / code_timeout / code_error / workflow_missing / dep_unjudged /
parse_error),由 router 提供并覆盖 judge.py 算出的粗分类。撞全局时间墙的情况
router 不覆盖,仍由 judge.py 判成 deadline(它认 DEADLINE_MARK)。
exit_code 的取值集合没有变,不给下游造新码。
这几个字段在所有出分路径上都写,下游不用处理缺字段。局部降级也写 exit_reason,
但仍然不给 exit_code——分是真的,只是覆盖率不满;给了错误码下游会当故障整条丢掉。
超时和限流
三层时间上限:单次调用 PER_TIMEOUT ⊂ 单块 2 × PER_TIMEOUT ⊂ 全局 DEADLINE。
中间那层是推出来的,不给单独的参数——并发和重试的参数堆多了没人看得懂。
能这么推,是因为两种失败的代价本来就不一样:超时失败一次就烧掉一个 PER_TIMEOUT
(所以块预算天然只够试 2 次),限流是秒级失败、不吃预算(所以 MAX_TRIES=6 才用得上)。
- 只有限流才退避,等待时间是
uniform(1, min(JUDGE_RETRY_BACKOFF_MAX, 2·3^(n-1)))= 2→6→18→20 秒。超时类失败不走这个,按 1s 定值重试。 - 限流关键词要包含 dashscope 的原文
Request rate increased too quickly(它不带 429 字样)。 - 判分端限的是增速,尖峰来自"几千个 job 同时进判分、每个又一瞬打出好几个请求"。
所以真正对症的是
JUDGE_START_JITTER(第一波各自随机延迟再发),而不是等更久—— 窄区间的抖动会让全 fleet 近乎同步重试,自己造出第二个尖峰。 - 配额分两种:
quota exceeded(配额窗口打满)退避重试;insufficient_quota(账号没额度) 立刻放弃,不白烧重试。 exit_code的取值集合没有变(不给下游造新码),限流归judge:api_error, 细分看judge_error_kind。- 撞
DEADLINE时:墙前判到的照常计分,剩下的按未命中算,judge_error_kind=deadline, 和端点故障区分开。没这道墙的时候,实测有 job 烧掉 4320s(= 9 × 480s)还在重试。 - 单次调用的成本主要是固定开销(起会话 + 把长文档和所有产物读一遍,实测 ~100s),
每条 rubric 的边际成本只有 ~3s。所以块要尽量大(调小
BATCH只会多付几倍固定开销), 拆出来的单条也不比整块便宜,照样给满PER_TIMEOUT。
峰值 RPM 是怎么算出来的(1.8.0 改默认值的依据)
限流是全 fleet 一起打同一个 workspace 造成的,单看一个 job 永远看不出问题。
把 pod 里的 logs/litellm-proxy.log 一行行数出来,两个 group 的对比:
group-55acd93e GDPval(炸) |
group-2d2fe36f AQ(正常) |
|
|---|---|---|
| 判分方式 | 本判分器,codex agent 判分 | 直接调 API,/v1/responses 用了 0 次 |
| 单 job 判分请求数(中位) | 正常 98,失败 564~638 | 34 |
| job 时长中位 | 736s | 2653s |
| 一个 step 内完成时间跨度(p10~p90) | 13 分钟 | 72 分钟 |
| 峰值同时判分的 job | 576(step 0 实测 1350) | 77 |
| 估算峰值 RPM | 正常 ~19.6k,失败态 ~52k | ~460 |
三条结论直接落到默认值上:
CONCURRENCY就是峰值倍数。 job 数和请求数都不由判分器决定,K 是唯一能直接除的因子,4→2 峰值减半。RESPLIT是失败态的放大器。 正常 job 起 10 个 codex 会话,失败 job 起 68~78 个(最高 137)—— 31 条拆 31 个会话、每条又最多 6 次。而块失败本身多半就是限流造成的,重判只会更堵。- 抖动窗口要盖住整个爆发。 一个 step 的几百个 job 在 13 分钟内集体进判分,30s 的窗口 摊不平,等于没抖;放到 180s 才有意义。
限流在 HTTP 层看不见:失败 job 的 proxy log 里 737 个请求全是 200,
限流是在 SSE 流里返回的(stream disconnected before completion,codex 侧 168 次 Reconnecting)。
所以网关 metrics 上一个错误都没有,只能下 artifact 查。
ppt 交付物先离线抽一次证据
块是独立进程,几 MB 的 .pptx 每个块都从零解析一遍,块块撞满超时(ppt split 的
judge:timeout 一度 60.3%)。所以 JUDGE_EVIDENCE 打开时,judge.py 先对每个交付的
.pptx 确定性地抽一份 evidence/<文件名>/{digest.json, pages/slide-NNN.json, slides/slide-NNN.png},再硬链进每个块——抽一次,所有块共用。纯 python-pptx + lxml +
LibreOffice,没有 LLM 调用。
预算剩不到 45s 就只抽结构不渲图;缺 LibreOffice 或 PyMuPDF 只少抽一层、不报错, 日志里写清抽了几份、渲了几页。
取证绝不整份放弃:被判的 deck 是模型生成的,经常有 python-pptx 解析不了的结构,
所以做了字段级 → 页级 → 整份级三层隔离,连 Presentation() 都打不开时照样渲图
(渲图不经过 python-pptx)。失败原因写进 digest.json 的 structure_ok /
extract_errors / structure_note,判分说明里也明确写了抽不到 ≠ 原件里没有,
让模型自己去解 zip,别据此判未命中。
codex 自定义端点必须走 config.toml
codex(Rust) 不认 OPENAI_BASE_URL / JUDGE_BASE_URL 这两个环境变量。所以只要
JUDGE_BASE_URL 不是 api.openai.com,判分器就往 $CODEX_HOME/config.toml 写一段
[model_providers.judge_endpoint](env_key = "JUDGE_API_KEY"、wire_api = "responses"),
而不是走 codex login --with-api-key。routify 的 compatible-mode 端点实测
/chat/completions 和 /responses 都返 200,用 responses 没问题。
binary 模式是迁移期才用的
JUDGE_REWARD_MODE=binary 只在"要和历史 reward 分布逐批对齐"时才开,开了会在 stderr 大声告警。
代价是同一批 rollout 之间的梯度全丢:GDPval1107 那 8 条 rollout 的原始分是
53/53、46/53、38/53(×3)、37/53、30/53(×2)(= 1.0 / 0.868 / 0.717 / 0.698 / 0.566),
在 binary 0.75 gte 下只有 2 条给 1,另外 6 条全被压成同一个 0。
要开的话比较符也得一起对齐:原来各批口径不一致(DR/GDP >=0.75、WLE >0.70、
GDP other_file 和 gdpval_v3 multi_files >=1.0)。恰好等于阈值的样本在 gt 和 gte
两种写法下 reward 是相反的。
自测
cd Cowork-Judger
for t in test_parse test_score test_router test_workflow_ppt test_degrade test_harbor; do
python3 selftests/$t.py
done
python3 tests/lint_rubric.py # 示例 rubric.json 的引用完整性
当前 220 例全过(1.2.0):
| 文件 | 例数 | 层 | 测什么 |
|---|---|---|---|
test_parse.py |
15 | 纯函数 | 判词解析的五级还原(整体 JSON → 剥代码块 → 配平扫描 → 逐条抢救 → 兜底) |
test_score.py |
47 | 算分引擎 | 算分规则、reward 语义、systemic 门槛、限流退避、极性字段、gradient(含与 clawgym / gdpval 原口径的等价性)、1.2.0 的存量等价性 |
test_router.py |
41 | 分派层 | 三类分派、gate 三条规则、跨 method depends_on、分段预算、某类全崩不影响其他类、code 型异常/超时归未判到、ABI 不匹配归未判到 |
test_workflow_ppt.py |
51 | workflow | 视觉:跨页幂平均、量化网格吸附、na_policy 三态、cell 级 resplit。内容:两趟 type1(caption → 真图复核、单向翻正)、type1→type2 页码路由与短路、真实 Abbott 资产端到端 8 例。故障:P0(VLM→未判到)、P1(环境缺→未判到 / deck 坏→真 0) |
test_degrade.py |
46 | main() |
判分坏成各种样子还能不能出分(假 codex 跑真 main()),外加交付判断、覆盖率字段、gradient 端到端 |
test_harbor.py |
20 | bash 接线 | 真 bash tests/test.sh,只把 codex 换成假的。含三类混合端到端、纯 code 零调用零凭据、router 缺失的两条退路、环境能力探测 |
test_pptx_evidence.py |
5 | 纯函数 | 旧 pptx_evidence.py 的三层容错(该库已废弃,用例保留) |
test_real_rubrics.py |
— | 语料 | 线上真实 rubric.json 的规范化 + 算分普查(只读,不调模型) |
1.2.0 新增的三条存量等价性用例最要紧:
new_routing_fields_do_not_touch_compute—— 7 个路由字段对_compute的输出逐字节 没有影响。这是全量重装 4312 个包的前提。以后谁想在_compute里读路由字段,会先撞到它。no_judge_method_defaults_to_agentic—— 缺judge_method必须是 agentic。workflow_grid_level_survives_the_round_trip—— workflow 吸附过的 level 再经_level_frac是恒等变换(这是「不动_level_frac、用量化网格」这个决定的正确性依据)。
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file cowork_judge-1.2.3.tar.gz.
File metadata
- Download URL: cowork_judge-1.2.3.tar.gz
- Upload date:
- Size: 175.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.10.19
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e65176038f01e81f0a9d25de303f6412b2d0b41070a5d2882aad5eca19b4616d
|
|
| MD5 |
07d68825dc2268c64f9ef3cbc66c8dfd
|
|
| BLAKE2b-256 |
c04b4970941f5d07e07575e3f2ad8745c7cb448b144fc52eeea44f229def3489
|
File details
Details for the file cowork_judge-1.2.3-py3-none-any.whl.
File metadata
- Download URL: cowork_judge-1.2.3-py3-none-any.whl
- Upload date:
- Size: 137.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.10.19
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b91491a2d34404e665fc32ca186031071cea9ab8b9c390320743d219cb08956e
|
|
| MD5 |
1c56c8e697c434d236817aba359a7193
|
|
| BLAKE2b-256 |
ba60e21c5d7e72e64f8afe1df41ec0a1baa4e0ceff3c1d9ea2f3c3d99f72c956
|