Files
AgentLab/backend/tests/test_judge_settings.py
T
ThearchyHelios 03c18c4d78 界面文案整改:口语改书面、术语统一、不再露出实现细节;修掉几处说错的提示;新增文案检查防回潮
一、文案(前端约 1600 处,后端传到界面的约 880 处)
- 术语统一:工作流层面用「运行」(继续运行、重新运行、试运行),节点和工具层面用「执行」,不再用「跑」;
  总时长 / 执行时长 / 等待审批;服务端;助手;Agent;token;工作流;固定版本;模型接入;系统提示;批准 / 驳回;受管级别
- 口语改书面:取不到、没取回来、读不懂、连不上、多半、照样、兜底、揉成这类说法改成「无法 / 未能 / 失败 / 请」;去掉拟人写法
- 不再露出实现细节:界面上不写 checkpoint、同一拍、哈希(凭证处除外),不写改版历史(「以前…」「老数据」);
  报错、校验、门禁、自动修复里裸写的配置键名和枚举值换成界面上的中文标签,由新的 backend/app/engine/labels.py 统一提供;
  发布弹窗里结论句的选项不再是 off / require_citation
- 同一件事只留一种说法:生成阶段文案、出具档位说明、画布锁定提示各合成一份
- 去掉界面上的工业场景示例(MES、ERP、「张工」「王工」)和真实产品名,换成通用示例

二、说错的提示(都有测试证明)
- 数据库测连接失败时的专用提示以前从不出现(按报错文字全等比较,永远比不上):改为按错误类型判断,不再拼出重复的前缀
- 「设置 → 供应商」改指「设置 → 模型接入」;「在设置里建数据源」改指「数据」页
- 缺少 API Key、模型接入已停用的报错不再夹英文,写明去哪里填
- 两处原因说错(「这种引用在后续版本支持」「这一版还不支持」)改成真实原因
- 节点说明里不准确的说法改正:代码节点在 microVM 或系统沙箱中执行;报告撰写、口径卡的说明不再说过头
- 导出的证据 CSV 里两列同名,第三列改为「片段编号」

三、前后端对齐
- 前端按后端原文识别日志、报错的地方,新旧两种写法都认得(历史运行里存的是旧文字),逐条补了新旧原文的核对
- 后端交回模型的原指令放进单独的 for_model 字段,前端在解析响应时去掉,不会显示到界面上

四、检查
- 检查脚本里按原文匹配的断言改成新文案;改完会变成永远成立的反向断言逐条修正,并做了变异验证
- 新增 check-copy(静态扫描前端界面字符串和后端上界面的文案):再出现「跑」「后端」「多半」「令牌」、
  界面上的 Copilot、裸写的配置键名、半角标点等就报错;确需保留的写进 scripts/copy-allow.json 并注明理由
2026-09-30 08:05:15 +08:00

266 lines
13 KiB
Python

"""结论句裁判的设置与画布校验。
- 设置里单开一组 judge:证据裁判模型、每份报告的句数 / 金额 / 时长上限、探索运行每次点击的金额上限、
全局每日金额上限。每一项都能存成 null,表示不限——存了 null 就是不限,不会被默认值悄悄盖回去
- limits 组是环境变量定的、只读,裁判设置不放进去;每日计数存在设置表里,但不从设置接口进出
- 裁判模型的优先级:节点上的 judge.model > 设置里的证据裁判模型 > Copilot 模型 > 默认 provider
- 画布校验放开 claims: judge,并校验 judge 子配置的形状:上限可以写 null(不限),rewrite_once 是布尔值,
on_unsupported 取 degrade / withhold
"""
from __future__ import annotations
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import delete
from app.db.base import SessionLocal
from app.db.models import Setting
from app.engine.judge import (
JUDGE_DEFAULTS,
SETTING_GROUP,
SPEND_KEY,
Budget,
click_budget,
judge_model_spec,
judge_settings,
report_budget,
)
from app.engine.schema import CLAIMS_VALUES, JUDGE_KEYS, GraphSpec, claims_problem, validate_graph
from app.main import app
@pytest.fixture(autouse=True)
async def clean():
async with SessionLocal() as session:
await session.execute(delete(Setting).where(Setting.key.in_([SPEND_KEY, SETTING_GROUP, "copilot"])))
await session.commit()
yield
def client() -> AsyncClient:
return AsyncClient(transport=ASGITransport(app=app), base_url="http://test")
# --------------------------------------------------------------------------
# 设置
# --------------------------------------------------------------------------
def test_the_defaults_are_the_agreed_ones():
assert SETTING_GROUP == "judge" and SETTING_GROUP != "limits"
assert JUDGE_DEFAULTS == {
"provider": None, "model": None,
"report_max_claims": 40, "report_max_cost_usd": 0.05, "report_timeout_s": 30,
"click_max_cost_usd": 0.01, "daily_max_usd": 2.0,
}
async def test_settings_expose_the_judge_group_with_defaults():
async with client() as c:
got = (await c.get("/api/settings")).json()
assert got["judge"] == JUDGE_DEFAULTS
assert SPEND_KEY not in got
async def test_null_is_stored_as_unlimited_and_stays_unlimited():
async with client() as c:
r = await c.put("/api/settings", json={"values": {"judge": {
**JUDGE_DEFAULTS, "model": "judge-model", "report_max_cost_usd": None, "daily_max_usd": None,
"report_timeout_s": None, "report_max_claims": None, "click_max_cost_usd": None}}})
assert r.status_code == 200, r.text
got = (await c.get("/api/settings")).json()["judge"]
assert got["model"] == "judge-model"
assert all(got[k] is None for k in ("report_max_cost_usd", "daily_max_usd", "report_timeout_s",
"report_max_claims", "click_max_cost_usd"))
async with SessionLocal() as session:
settings = await judge_settings(session)
assert report_budget(settings, None) == Budget(max_claims=None, max_cost_usd=None, timeout_s=None,
daily_max_usd=None)
assert click_budget(settings).max_cost_usd is None
async def test_a_partial_group_keeps_the_other_defaults():
async with client() as c:
await c.put("/api/settings", json={"values": {"judge": {"daily_max_usd": 5}}})
got = (await c.get("/api/settings")).json()["judge"]
assert got == {**JUDGE_DEFAULTS, "daily_max_usd": 5}
@pytest.mark.parametrize("key,value", [
("daily_max_usd", -1), ("report_max_cost_usd", 0), ("report_max_cost_usd", "0.05"),
("report_max_claims", 2.5), ("report_max_claims", True), ("report_timeout_s", 0),
("click_max_cost_usd", float("nan")), ("model", 3), ("unknown_key", 1),
])
async def test_bad_values_are_refused_with_a_reason(key, value):
async with client() as c:
r = await c.put("/api/settings", json={"values": {"judge": {key: value}}}) if value == value else \
await c.put("/api/settings", content='{"values": {"judge": {"%s": NaN}}}' % key,
headers={"content-type": "application/json"})
assert r.status_code == 422, r.text
# 报错写设置页上的叫法(「每日金额上限(美元)」),不写键名;认不出的键原样点名
from app.engine.judge import SETTING_LABEL
assert SETTING_LABEL.get(key, key) in r.json()["detail"]
assert (await c.get("/api/settings")).json()["judge"] == JUDGE_DEFAULTS, "整组都没写进去"
async def test_the_daily_counter_does_not_travel_through_the_settings_api():
async with SessionLocal() as session:
session.add(Setting(key=SPEND_KEY, value={"date": "2026-09-29", "usd": 1.5, "calls": 3}))
await session.commit()
async with client() as c:
assert SPEND_KEY not in (await c.get("/api/settings")).json()
await c.put("/api/settings", json={"values": {SPEND_KEY: {"date": "2026-09-29", "usd": 0, "calls": 0}}})
async with SessionLocal() as session:
row = await session.get(Setting, SPEND_KEY)
assert row.value["usd"] == 1.5, "设置页整组回写不能把今天的花费清零"
async def test_todays_spend_is_readable_next_to_the_daily_cap(monkeypatch):
from app.engine import judge as judge_mod
monkeypatch.setattr(judge_mod, "_today", lambda: "2026-09-29")
async with SessionLocal() as session:
session.add(Setting(key=SPEND_KEY, value={"date": "2026-09-28", "usd": 1.9, "calls": 7}))
await session.commit()
async with client() as c:
got = (await c.get("/api/settings/judge/spend")).json()
assert got == {"date": "2026-09-29", "usd": 0.0, "calls": 0, "unpriced_calls": 0, "daily_max_usd": 2.0}, \
"昨天的花费不算今天的"
async with SessionLocal() as session:
row = await session.get(Setting, SPEND_KEY)
row.value = {"date": "2026-09-29", "usd": 0.42, "calls": 3, "unpriced_calls": 1}
await session.commit()
await c.put("/api/settings", json={"values": {"judge": {"daily_max_usd": None}}})
got = (await c.get("/api/settings/judge/spend")).json()
assert got == {"date": "2026-09-29", "usd": 0.42, "calls": 3, "unpriced_calls": 1, "daily_max_usd": None}
async def test_bad_rows_already_in_the_database_fall_back_to_defaults():
"""库里存坏了的值(旧版本、手改)读出来按默认,不让裁判拿着一个负数上限跑。"""
async with SessionLocal() as session:
session.add(Setting(key=SETTING_GROUP, value={"daily_max_usd": -3, "report_max_claims": "many",
"report_timeout_s": None}))
await session.commit()
settings = await judge_settings(session)
assert settings["daily_max_usd"] == JUDGE_DEFAULTS["daily_max_usd"]
assert settings["report_max_claims"] == JUDGE_DEFAULTS["report_max_claims"]
assert settings["report_timeout_s"] is None
def test_the_node_overrides_key_by_key():
settings = {**JUDGE_DEFAULTS, "report_max_claims": 10}
assert report_budget(settings, None) == Budget(max_claims=10, max_cost_usd=0.05, timeout_s=30, daily_max_usd=2.0)
assert report_budget(settings, {"max_cost_usd": None, "timeout_s": 5}) == \
Budget(max_claims=10, max_cost_usd=None, timeout_s=5, daily_max_usd=2.0)
# 探索运行每次点击:金额按点击的上限,句数、时长按每份报告的
assert click_budget(settings) == Budget(max_claims=10, max_cost_usd=0.01, timeout_s=30, daily_max_usd=2.0)
# --------------------------------------------------------------------------
# 裁判模型的优先级
# --------------------------------------------------------------------------
async def test_the_judge_model_priority():
async with SessionLocal() as session:
spec = await judge_model_spec(session, None)
assert (spec.provider, spec.model) == (None, None), "什么都没配:默认 provider"
assert spec.thinking == "off" and spec.max_tokens == 4096
session.add(Setting(key="copilot", value={"provider": "", "model": "copilot-model"}))
await session.commit()
assert (await judge_model_spec(session, None)).model == "copilot-model"
session.add(Setting(key=SETTING_GROUP, value={"provider": "house", "model": "judge-model"}))
await session.commit()
spec = await judge_model_spec(session, None)
assert (spec.provider, spec.model) == ("house", "judge-model")
spec = await judge_model_spec(session, {"model": "node-model"})
assert (spec.provider, spec.model) == (None, "node-model"), "节点上的写法整组生效,不和设置里的拼"
spec = await judge_model_spec(session, {"model": None, "provider": None, "max_claims": 3})
assert spec.model == "judge-model"
# --------------------------------------------------------------------------
# 画布校验
# --------------------------------------------------------------------------
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
def graph(report: dict, defaults: dict | None = None) -> dict:
nodes = [node("start", "input"),
node("caliber", "metrics", caliber="周报口径", metrics=[{"id": "gmv", "expression": "1"}]),
node("write", "report", instructions="写周报", **report),
node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}])]
ids = [n["id"] for n in nodes]
g = {"nodes": nodes, "edges": [{"source": a, "target": b} for a, b in zip(ids, ids[1:])]}
if defaults:
g["defaults"] = defaults
return g
def issues(g, code=None):
return [i for i in validate_graph(GraphSpec.model_validate(g)).issues
if i.code in ((code,) if code else ("report.claims_invalid", "report.judge_invalid"))]
def test_judge_is_a_supported_policy_now():
assert CLAIMS_VALUES == ("off", "require_citation", "judge")
assert claims_problem("judge") is None
assert issues(graph({"claims": "judge"})) == []
assert issues(graph({}, defaults={"claims": "judge"})) == []
def test_every_limit_can_be_unlimited():
judge = {"provider": None, "model": None, "max_claims": None, "max_cost_usd": None, "timeout_s": None,
"rewrite_once": True, "on_unsupported": "withhold"}
assert set(judge) == set(JUDGE_KEYS)
assert issues(graph({"claims": "judge", "judge": judge})) == []
assert issues(graph({"claims": "judge", "judge": {"max_claims": 40, "max_cost_usd": 0.05, "timeout_s": 30,
"model": "judge-model", "on_unsupported": "degrade"}})) == []
@pytest.mark.parametrize("judge,field,words", [
("fast", "judge", "对象"),
({"max_cost": 1}, "judge.max_cost", "「金额上限(美元)」"),
({"max_claims": 0}, "judge.max_claims", "正整数"),
({"max_claims": 2.5}, "judge.max_claims", "正整数"),
({"max_claims": True}, "judge.max_claims", "正整数"),
({"max_cost_usd": -1}, "judge.max_cost_usd", "大于 0"),
({"max_cost_usd": "0.05"}, "judge.max_cost_usd", "大于 0"),
({"timeout_s": 0}, "judge.timeout_s", "大于 0"),
({"rewrite_once": "yes"}, "judge.rewrite_once", "开启或关闭"),
({"on_unsupported": "ignore"}, "judge.on_unsupported", "「出具降档」或「不予出具」"),
({"model": 3}, "judge.model", "模型 ID"),
])
def test_a_malformed_judge_block_is_an_error(judge, field, words):
[issue] = issues(graph({"claims": "judge", "judge": judge}), "report.judge_invalid")
assert issue.level == "error" and issue.node_id == "write" and issue.field == field
assert words in issue.message, issue.message
if field in ("judge.max_claims", "judge.max_cost_usd", "judge.timeout_s", "judge.model"):
assert ("留空" if field == "judge.model" else "不限") in issue.message, "上限、模型都说清不填是什么意思"
def test_graph_defaults_are_checked_like_at_run_time():
[issue] = issues(graph({"claims": "judge"}, defaults={"judge": {"max_claims": -1}}), "report.judge_invalid")
assert issue.field == "judge.max_claims"
def test_the_judge_block_only_matters_when_claims_is_judge():
assert issues(graph({"claims": "require_citation", "judge": {"max_claims": -1}})) == []
def test_judging_with_the_writing_model_is_a_warning():
[warn] = [i for i in validate_graph(GraphSpec.model_validate(graph(
{"claims": "judge", "model": "writer-x", "judge": {"model": "writer-x"}}))).issues
if i.code == "report.judge_same_model"]
assert warn.level == "warning" and warn.field == "judge.model" and "裁判模型与写作模型相同" in warn.message
assert not [i for i in validate_graph(GraphSpec.model_validate(graph(
{"claims": "judge", "model": "writer-x", "judge": {"model": "judge-y"}}))).issues
if i.code == "report.judge_same_model"]