Files
AgentLab/backend/tests/test_verify_deadline.py

513 lines
32 KiB
Python

"""核查节点在运行时长上限内收尾(engine/deadline.py):反方审稿逐句、双盲复算逐项开始之前,看这一段执行还剩多少时间。
- 反方审稿:5 句、截止时刻只够审 2 句 → 审 2 句,其余 3 句记为未能反查(理由、时间线写明运行时长上限将至),运行
成功、不被中止,日志和产出的 unreviewed 对得上;剩下的时间不够整段时这句的时长收紧,到点按查到的结果收尾,说的是
运行时长上限将至,不说「每句时长上限 90 秒」;接着运行时取 checkpoint 里的审稿,不重审
- 双盲复算:同理,没来得及开始的盲算按无法复算记(照现有的路径记缺口);收紧过的时长到点也按无法复算记,不发提醒
调高上限的 dual_check_limit;审批恢复后重放,盲算不再执行、哪几项没来得及照旧
- 因运行时长上限将至没核成的,时间线另起一条、带 cause: run_deadline 和这一段的总时长(run_seconds):界面据此说清
原因、给对的下一步(调高核查自己的上限只会更快撞线,检查模型、数据源也无济于事),不和别的原因混在一条里、被「等」
盖掉;收紧过的核查 agent 自己发的 verify_limit 也改说运行时长上限将至
- 下游还有写报告这类要调模型的节点时,核查给它们多留一份余量:双盲复算后面接着写报告,运行不在写报告时被中止
- 时间充裕时(默认 600 秒的一段)时长上限照传原值:和没有这道检查时一样
两种钟:精确的计数用假的钟(每派出一项就把 allot() 看到的钟往前拨若干秒,真实只过去几毫秒,runner 的 asyncio.timeout
远远不到);「运行不被中止」用真的钟、真的短时长上限和真的慢 agent,只断言不随机器快慢变的事。模型都是 MockChatModel
剧本,数据源是现造的 SQLite 文件,区域、金额都是虚构的。
"""
from __future__ import annotations
import asyncio
from collections.abc import Callable
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.core import artifact_store
from app.core.config import settings
from app.db.base import SessionLocal
from app.db.models import Approval, Run
from app.engine import compiler, deadline, verify_agent
from app.engine.nodes import challenge as challenge_mod
from app.engine.nodes import dual_check as dual_check_mod
from app.engine.runner import run_manager, verify_manifest
from app.engine.schema import NodeType
from app.main import app
from app.providers.mock_model import MockChatModel
from tests.fixtures.dual_check_shop import WEEK, Blind, add_source, build
from tests.fixtures.sources import drop_source
from tests.test_challenge_node import ( # noqa: F401 - engine_up、shop 是夹具,按名字引入
NET,
REFUNDS,
Models,
Play,
engine_up,
finish,
graph,
logs,
output_of,
shop,
wait,
)
#: 五句都挂了依据的结论,按文档先后反查:华东居首(被削弱)、华南超过华东(预埋的错误,被推翻)、其余三句经受住
FIVE = ("华东销售额 [[v:Q1.r0.销售额]],居各区域之首。[[see:Q1]]华南销售额超过华东。[[see:Q1]]"
"华北销售额垫底。[[see:Q1]]华东领先华北。[[see:Q1]]华南高于华北。[[see:Q1]]")
FIVE_PLAYS = {
"居各区域之首": Play([REFUNDS], "weakened", "华东含 [[v:Q1.r0.退款笔数]] 笔退款,扣除后仍居首,但销售额被高估。"),
"华南销售额超过华东": Play([NET], "refuted",
"扣除退款后华南为 [[v:Q1.r1.净销售额]],仍低于华东的 [[v:Q1.r0.净销售额]]。[[see:Q1]]"),
"华北销售额垫底": Play([NET], "stands", "华北净销售额为 [[v:Q1.r2.净销售额]],确实最低。"),
"华东领先华北": Play([NET], "stands", "没有找到反例。"),
"华南高于华北": Play([NET], "stands", "没有找到反例。"),
}
ORDER = list(FIVE_PLAYS)
#: 截止时刻将至、不再开始的句子:每句的理由,时间线上的原因(默认 600 秒的一段,剩下 3 句)
LATE_CLAIM = "本次运行即将达到 600 秒的时长上限,为保证运行按时完成,这句未反查"
LATE_REST = "本次运行即将达到 600 秒的时长上限,为保证运行按时完成,剩下 3 句未审"
class Ticker:
"""假的钟:allot() 看到的「现在」= 真的事件循环时间 + offset。包住节点调用的核查 agent:每派出一项先把钟往前拨
step 秒(像这一项真花了这么久),再照常执行,并记下这一项拿到的时长上限。hit:拿到的时长上限满足它时,把结果改成
触了时长上限(像收紧过的时长真到了点),用来看收紧过的触顶怎么说。"""
def __init__(self, monkeypatch, module, *, step: float, start: float = 0.0,
hit: Callable[[float], bool] | None = None) -> None:
self.offset = start
self.step = step
self.timeouts: list[float | None] = []
real = module.run_verify_agent
monkeypatch.setattr(deadline, "_now", lambda: asyncio.get_running_loop().time() + self.offset)
async def spy(**kwargs):
self.timeouts.append(kwargs.get("timeout_s"))
self.offset += self.step
outcome = await real(**kwargs)
if hit is not None and kwargs.get("timeout_s") is not None and hit(kwargs["timeout_s"]):
outcome.limit = "timeout_s"
return outcome
monkeypatch.setattr(module, "run_verify_agent", spy)
# --------------------------------------------------------------------------
# 反方审稿
# --------------------------------------------------------------------------
async def test_five_claims_with_time_for_two_review_two(monkeypatch, shop):
"""截止时刻只够审 2 句(每句像花了 250 秒):审 2 句,其余 3 句不再派 agent、记为未能反查;运行成功,被推翻的
那句照样让出具降档;时间线和产出的 unreviewed 都是 3。"""
models = Models(monkeypatch, shop, writer=(FIVE,), plays=FIVE_PLAYS)
ticker = Ticker(monkeypatch, challenge_mod, step=250)
row = await finish(graph(shop))
assert row.status == "succeeded", row.error
assert models.reviewed() == ORDER[:2], "第 3 句开始前只剩约 100 秒:留出 60 秒余量和 30 秒收尾宽限,不够再审一句"
assert ticker.timeouts == [90, 90], "时间充裕的两句照传每句时长上限,不收紧"
out = await output_of(row.id, "chal")
assert out["counts"] == {"stands": 0, "weakened": 1, "refuted": 1, "inconclusive": 3}
assert out["unreviewed"] == 3, "没来得及审的 3 句是未能反查,不是查过了判断不了"
assert row.output["_issuance"]["tier"] == "degraded", "审到的那句被推翻:照样降档"
record = artifact_store.load(out["challenge_artifact"])
assert [c["verdict"] for c in record["claims"]] == ["weakened", "refuted", "inconclusive", "inconclusive",
"inconclusive"]
for claim in record["claims"][2:]:
assert claim["rationale"] == LATE_CLAIM and claim["queries"] == [] and claim["rationale_doc"] is None
assert claim["usage"]["calls"] == 0, "没派 agent:一分钱都没花"
[warn] = await logs(row.id, "challenge_unreviewed")
assert warn["claims_skipped"] == 3 and warn["reason"] == LATE_REST
assert warn["cause"] == deadline.RUN_DEADLINE and warn["run_seconds"] == 600, "界面按 cause 说原因、给下一步"
assert warn["message"] == f"反方审稿有 3 句结论未能反查,已记为无法判断:{LATE_REST}"
assert await logs(row.id, "challenge_limit") == [], "没有哪一句触了自己的上限"
[done] = await logs(row.id, "challenge_done")
assert done["counts"] == out["counts"]
assert "其中 3 句未能反查" in done["message"]
assert (await verify_manifest(row.id))["ok"] is True
async def test_a_claim_squeezed_by_the_deadline_says_so(monkeypatch, shop):
"""第 2 句开始前只剩约 160 秒:留出余量和收尾宽限,这句的时长收紧到 70 秒以内;它到点按查到的结果收尾时,时间线
说的是运行时长上限将至、时长已收紧,不说「每句时长上限 90 秒」(那会叫人去调大每句的上限,调大了只会更快撞线)。"""
models = Models(monkeypatch, shop, writer=(FIVE,), plays=FIVE_PLAYS)
ticker = Ticker(monkeypatch, challenge_mod, step=440, hit=lambda timeout: timeout < 90)
row = await finish(graph(shop))
assert row.status == "succeeded", row.error
assert models.reviewed() == ORDER[:2]
first, second = ticker.timeouts
assert first == 90 and 60 < second < 70, ticker.timeouts
[limit] = await logs(row.id, "challenge_limit")
assert limit["limit"] == "timeout_s" and limit["claims_skipped"] == 0
assert limit["cause"] == deadline.RUN_DEADLINE and limit["run_seconds"] == 600 and limit["claims"] == 1, \
"界面认 cause:不叫人调高每句的上限"
assert "本次运行即将达到 600 秒的时长上限" in limit["message"] and "收紧" in limit["message"]
assert "每句时长上限" not in limit["message"]
out = await output_of(row.id, "chal")
assert out["counts"]["refuted"] == 1, "收紧后按查到的结果收尾:反方给的结论照常采信"
assert out["unreviewed"] == 3
[warn] = await logs(row.id, "challenge_unreviewed")
assert warn["claims_skipped"] == 3 and warn["cause"] == deadline.RUN_DEADLINE
async def test_the_run_deadline_gets_its_own_line(monkeypatch, shop):
"""第 1 句反查一条查询都没执行成功(另一个原因),第 3 句起来不及审:时间线两条 challenge_unreviewed,各说各的原因。
运行时长上限将至的那条带 cause,不被前一句的原因盖掉、藏进「等」里——界面也就不会为了它叫人去查模型和数据源。"""
plays = dict(FIVE_PLAYS)
plays["居各区域之首"] = Play([], "weakened", "没有查询。")
models = Models(monkeypatch, shop, writer=(FIVE,), plays=plays)
Ticker(monkeypatch, challenge_mod, step=250)
row = await finish(graph(shop))
assert row.status == "succeeded", row.error
assert models.reviewed() == ORDER[:2]
out = await output_of(row.id, "chal")
assert out["unreviewed"] == 4 and out["counts"]["inconclusive"] == 4
other, late = await logs(row.id, "challenge_unreviewed")
assert other["claims_skipped"] == 1 and other["reason"] == "反查没有执行成功任何查询" and "cause" not in other
assert other["message"] == "反方审稿有 1 句结论未能反查,已记为无法判断:反查没有执行成功任何查询"
assert late["claims_skipped"] == 3 and late["reason"] == LATE_REST
assert late["cause"] == deadline.RUN_DEADLINE and late["run_seconds"] == 600
assert late["message"] == f"反方审稿有 3 句结论未能反查,已记为无法判断:{LATE_REST}"
async def test_its_own_limit_and_the_run_deadline_are_told_apart(monkeypatch, shop):
"""第 1 句触了自己的每句时长上限(90 秒,没收紧),第 2 句的时长按剩余时间收紧后也到了点:两条 challenge_limit 都是
timeout_s,只有收紧的那条带 cause——时间线上不是两行一样的「反方审稿已达时长上限」,叫人调高每句上限的只有第 1 条。"""
Models(monkeypatch, shop, writer=(FIVE,), plays=FIVE_PLAYS)
ticker = Ticker(monkeypatch, challenge_mod, step=440, hit=lambda timeout: True)
row = await finish(graph(shop))
assert row.status == "succeeded", row.error
first, second = ticker.timeouts
assert first == 90 and second < 90, ticker.timeouts
own, squeezed = await logs(row.id, "challenge_limit")
assert own["limit"] == squeezed["limit"] == "timeout_s"
assert "cause" not in own and "每句时长上限 90 秒" in own["message"]
assert squeezed["cause"] == deadline.RUN_DEADLINE and squeezed["run_seconds"] == 600 and squeezed["claims"] == 1
assert "每句时长上限" not in squeezed["message"]
async def test_no_time_at_all_reviews_nothing_but_finishes(monkeypatch, shop):
"""一开始就只剩 50 秒:一句都不派,5 句都记为未能反查,节点照常收尾、运行成功;没有审到的句子,不降档。"""
models = Models(monkeypatch, shop, writer=(FIVE,), plays=FIVE_PLAYS)
ticker = Ticker(monkeypatch, challenge_mod, step=0, start=550)
row = await finish(graph(shop))
assert row.status == "succeeded", row.error
assert models.calls == [] and ticker.timeouts == []
out = await output_of(row.id, "chal")
assert out["counts"] == {"stands": 0, "weakened": 0, "refuted": 0, "inconclusive": 5} and out["unreviewed"] == 5
[warn] = await logs(row.id, "challenge_unreviewed")
assert warn["claims_skipped"] == 5 and "剩下 5 句未审" in warn["reason"] and warn["cause"] == deadline.RUN_DEADLINE
assert row.output["_issuance"]["tier"] == "formal"
async def test_continuing_takes_the_review_from_the_checkpoint(monkeypatch, shop):
"""审了 2 句、3 句没来得及之后,节点落盘时出错;接着运行时这一段有了新的截止时刻,审稿照样取 checkpoint 里的:
不再派 agent,没来得及的 3 句照旧记为未能反查。重放确定,不看接着运行时还剩多少时间。"""
models = Models(monkeypatch, shop, writer=(FIVE,), plays=FIVE_PLAYS)
ticker = Ticker(monkeypatch, challenge_mod, step=250)
real = challenge_mod._finish
broken = {"on": True}
def flaky(*args, **kwargs):
if broken["on"]:
raise RuntimeError("落盘时断了")
return real(*args, **kwargs)
monkeypatch.setattr(challenge_mod, "_finish", flaky)
run = await run_manager.start(graph=graph(shop), input_payload={}, run_class="formal")
row = await wait(run.id)
assert row.status == "failed" and "落盘时断了" in (row.error or ""), row.error
calls, timeouts = len(models.calls), list(ticker.timeouts)
assert models.reviewed() == ORDER[:2]
broken["on"] = False
ticker.offset = 0 # 新的一段:时间又充裕了,重审的话 5 句都会派出去
await run_manager.continue_failed(run.id)
row = await wait(run.id)
assert row.status == "succeeded", row.error
assert len(models.calls) == calls and ticker.timeouts == timeouts, "接着运行时又派了 agent:审稿没取 checkpoint"
out = await output_of(row.id, "chal")
assert out["unreviewed"] == 3 and out["counts"]["inconclusive"] == 3
assert len(await logs(row.id, "challenge_done")) == 1 and len(await logs(row.id, "challenge_unreviewed")) == 1
async def test_a_slow_review_no_longer_aborts_the_run(monkeypatch, shop):
"""真的钟:这一段只有 7 秒,每句反查都要查满 1.5 秒的时长上限(模型每次调用 0.1 秒,查不完的查询、不限次数),5 句串行
要 8 秒多——以前整次运行在审到第 5 句时被中止。现在审到时间不够就停下,剩下的记为未能反查,运行成功。
几句来得及审随机器快慢变,只断言不变的:至少审了 1 句、至少 1 句没来得及,没来得及的都写明了原因、计数对得上。"""
monkeypatch.setattr(settings, "max_run_seconds", 7)
monkeypatch.setattr(deadline, "RESERVE_S", 2.0)
monkeypatch.setattr(verify_agent, "SETTLE_GRACE_S", 0.5)
endless = [f"SELECT COUNT(*) AS n FROM sales WHERE id > {i}" for i in range(60)]
plays = {key: Play(endless, "stands", "查到的 [[v:Q1.r0.n]] 行里没有反例。") for key in ORDER}
models = Models(monkeypatch, shop, writer=(FIVE,), plays=plays, latency=0.1)
row = await finish(graph(shop, timeout_s=1.5, max_queries=100))
assert row.status == "succeeded", row.error
assert "时长上限,已中止" not in (row.error or "")
out = await output_of(row.id, "chal")
record = artifact_store.load(out["challenge_artifact"])
late = [c for c in record["claims"] if "即将达到" in c["rationale"] and not c["queries"]]
reviewed = [c for c in record["claims"] if c["queries"]]
assert reviewed and late, [c["rationale"] for c in record["claims"]]
assert len(reviewed) + len(late) == 5
assert models.reviewed() == ORDER[:len(reviewed)], "按顺序审,停下之后一句都不再派"
assert out["unreviewed"] == len(late)
[warn] = await logs(row.id, "challenge_unreviewed")
assert warn["claims_skipped"] == len(late) and f"剩下 {len(late)} 句未审" in warn["reason"]
assert "本次运行即将达到 7 秒的时长上限" in warn["reason"] and warn["cause"] == deadline.RUN_DEADLINE
# 核查 agent 到点自己也发 verify_limit:时长收紧过的那几句说运行时长上限将至,写的秒数不会是一个和设置对不上的数
for said in await logs(row.id, "verify_limit"):
if said.get("cause") == deadline.RUN_DEADLINE:
assert "本次运行即将达到 7 秒的时长上限,时长已按剩余时间收紧到" in said["message"] and said["run_seconds"] == 7
else:
assert "时长上限 1.5 秒" in said["message"] or said["limit"] != "timeout_s", said
# --------------------------------------------------------------------------
# 双盲复算
# --------------------------------------------------------------------------
#: 一条查询算出五个指标(按支付日、排除退款):销售额 330.0、订单数 3、付款客户数 2、最大单笔 200.0、最小单笔 30.0
FIVE_SQL = (f"SELECT SUM(amount) AS gmv, COUNT(*) AS orders, COUNT(DISTINCT customer_id) AS buyers, "
f"MAX(amount) AS top, MIN(amount) AS low FROM orders WHERE status <> 'refunded' AND paid_date {WEEK}")
NAMES = {"gmv": "销售额", "orders": "订单数", "buyers": "付款客户数", "top": "最大单笔", "low": "最小单笔"}
AGGS = {"gmv": "SUM(amount)", "orders": "COUNT(*)", "buyers": "COUNT(DISTINCT customer_id)", "top": "MAX(amount)",
"low": "MIN(amount)"}
METRICS = [{"id": mid, "name": name, "expression": f"cell(nodes.fetch, 0, '{mid}')"} for mid, name in NAMES.items()]
#: 盲算各自写一条只算这一个数的 SQL:和原流程的结果一致
PLANS = {name: (f"SELECT {AGGS[mid]} AS blind_v FROM orders WHERE status <> 'refunded' AND paid_date {WEEK}", "blind_v")
for mid, name in NAMES.items()}
#: 没来得及开始的盲算(默认 600 秒的一段):指标的原因、出具缺口
LATE_BLIND = "本次运行即将达到 600 秒的时长上限,为保证运行按时完成,未进行盲算"
def node(nid, ntype, label=None, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}}
def dual_graph(source: str, **dual) -> dict:
"""取数 → 口径卡(五个指标)→ 双盲复算 → 成果。成果的叙述里没有数字:没有缺口时是完整出具。"""
nodes = [
node("start", "input"),
node("fetch", "tool", tool=f"db_query__{source}", args={"sql": FIVE_SQL}),
node("card", "metrics", caliber="销售口径", caliber_version="v1", metrics=METRICS),
node("dual", "dual_check", "复核五个指标", **dual),
node("out", "output", fields=[{"name": "结论", "value": "本周指标见口径卡"}],
contract={"metrics_from": "card", "narrative": "本周指标见口径卡"}),
]
return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
@pytest.fixture
async def orders(tmp_path):
name, source_id = await add_source(build(tmp_path / "orders.db"))
yield name
await drop_source(source_id)
async def dual_run(spec: dict, **kwargs) -> Run:
run = await run_manager.start(graph=spec, input_payload={}, run_class="formal", **kwargs)
return await wait(run.id, ("succeeded", "failed", "interrupted"))
def unchecked_reasons(out: dict) -> dict[str, str]:
return {m["metric_id"]: m["reason"] for m in out["metrics"] if m["status"] == "unchecked"}
async def test_five_metrics_with_time_for_two_check_two(monkeypatch, orders):
"""截止时刻只够盲算 2 项(每项像花了 260 秒):前 2 项照常盲算、一致,后 3 项不再开始,按无法复算记缺口;运行成功、
出具降档。时间线上 dual_check_unchecked 点名这 3 项和原因,不发提醒调高上限的 dual_check_limit。"""
blind = Blind(monkeypatch, orders, PLANS)
ticker = Ticker(monkeypatch, verify_agent, step=260)
row = await dual_run(dual_graph(orders))
assert row.status == "succeeded", row.error
assert blind.runs == 2 and ticker.timeouts == [120, 120], "时间充裕的两项照传每项时长上限,不收紧"
out = await output_of(row.id, "dual")
assert out["status"] == "partial" and out["counts"] == {"agreed": 2, "disagreed": 0, "unchecked": 3}
assert [m["status"] for m in out["metrics"]] == ["agreed", "agreed", "unchecked", "unchecked", "unchecked"]
assert unchecked_reasons(out) == {"buyers": LATE_BLIND, "top": LATE_BLIND, "low": LATE_BLIND}
gaps = [f"指标「{NAMES[m]}」无法复算:{LATE_BLIND}" for m in ("buyers", "top", "low")]
assert out["issuance_gaps"] == gaps
assert row.output["_issuance"]["tier"] == "degraded"
assert row.output["_issuance"]["gaps"] == [f"双盲复算「复核五个指标」:{gap}" for gap in gaps]
[unchecked] = await logs(row.id, "dual_check_unchecked")
assert unchecked["metrics"] == [{"id": m, "name": NAMES[m], "reason": LATE_BLIND} for m in ("buyers", "top", "low")]
assert unchecked["cause"] == deadline.RUN_DEADLINE and unchecked["run_seconds"] == 600, "界面按 cause 说原因、给下一步"
assert unchecked["message"] == ("本次运行即将达到 600 秒的时长上限,为保证运行按时完成,双盲复算有 3 个指标未能复算:"
"「付款客户数」「最大单笔」「最小单笔」,按无法复算计入缺口")
assert await logs(row.id, "dual_check_limit") == []
assert (await verify_manifest(row.id))["ok"] is True
async def test_blind_checks_cut_by_the_deadline_get_their_own_line(monkeypatch, orders):
"""「订单数」的盲算没查到这个数(另一个原因),后 3 项来不及开始:时间线两条 dual_check_unchecked。运行时长上限将至的
那条带 cause、只列那 3 项,界面据此说原因、给下一步;另一条照旧,不带 cause。"""
Blind(monkeypatch, orders, {name: plan for name, plan in PLANS.items() if name != "订单数"})
Ticker(monkeypatch, verify_agent, step=260)
row = await dual_run(dual_graph(orders))
assert row.status == "succeeded", row.error
out = await output_of(row.id, "dual")
assert unchecked_reasons(out) == {"orders": "盲算没有查到这个数", "buyers": LATE_BLIND, "top": LATE_BLIND,
"low": LATE_BLIND}
other, late = await logs(row.id, "dual_check_unchecked")
assert other["metrics"] == [{"id": "orders", "name": "订单数", "reason": "盲算没有查到这个数"}] and "cause" not in other
assert other["message"] == "双盲复算未能核对 1 个指标:「订单数」(盲算没有查到这个数),按无法复算计入缺口"
assert late["cause"] == deadline.RUN_DEADLINE and late["run_seconds"] == 600
assert [m["id"] for m in late["metrics"]] == ["buyers", "top", "low"]
assert all(m["reason"] == LATE_BLIND for m in late["metrics"])
async def test_no_time_for_even_the_first_blind_check(monkeypatch, orders):
"""一开始就只剩 40 秒:第一项都不开始(后面的也就不再一个个去试),五项都按无法复算记,节点状态是一个都没核成。"""
blind = Blind(monkeypatch, orders, PLANS)
ticker = Ticker(monkeypatch, verify_agent, step=0, start=560)
row = await dual_run(dual_graph(orders))
assert row.status == "succeeded", row.error
assert blind.calls == [] and ticker.timeouts == []
out = await output_of(row.id, "dual")
assert out["status"] == "unchecked" and out["counts"] == {"agreed": 0, "disagreed": 0, "unchecked": 5}
assert set(unchecked_reasons(out).values()) == {LATE_BLIND}
assert len(out["issuance_gaps"]) == 5
async def test_a_blind_check_squeezed_by_the_deadline_says_so(monkeypatch, orders):
"""第 2 项开始前只剩约 150 秒:时长收紧到 60 秒以内;它到点时按无法复算记,原因说运行时长上限将至、时长已收紧,
不说「已达每项时长上限(120 秒)」,也不发叫人调大上限的 dual_check_limit。"""
blind = Blind(monkeypatch, orders, PLANS)
ticker = Ticker(monkeypatch, verify_agent, step=450, hit=lambda timeout: timeout < 120)
row = await dual_run(dual_graph(orders))
assert row.status == "succeeded", row.error
assert blind.runs == 2
first, second = ticker.timeouts
assert first == 120 and 50 < second < 60, ticker.timeouts
out = await output_of(row.id, "dual")
reasons = unchecked_reasons(out)
assert set(reasons) == {"orders", "buyers", "top", "low"}
assert "本次运行即将达到 600 秒的时长上限" in reasons["orders"] and "收紧" in reasons["orders"]
assert "每项时长上限" not in reasons["orders"]
assert {reasons[m] for m in ("buyers", "top", "low")} == {LATE_BLIND}
assert await logs(row.id, "dual_check_limit") == []
[unchecked] = await logs(row.id, "dual_check_unchecked")
assert unchecked["cause"] == deadline.RUN_DEADLINE and [m["id"] for m in unchecked["metrics"]] == \
["orders", "buyers", "top", "low"], "收紧后到点的和没来得及开始的是同一个原因:一条说完"
def dual_report_graph(source: str) -> dict:
"""取数 → 口径卡 → 双盲复算 → 写报告 → 成果:Copilot 指南里的惯常接法,双盲复算下游还有一次写报告的模型调用。"""
nodes = dual_graph(source)["nodes"]
nodes.insert(4, node("write", "report", "写周报", instructions="写周报"))
return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
@pytest.mark.parametrize("build, low, high", [(dual_graph, 100, 110), (dual_report_graph, 40, 50)])
async def test_a_report_downstream_gets_its_share(monkeypatch, orders, build, low, high):
"""第 3 项开始时只剩约 200 秒:下游只有成果节点时留 60 秒余量,这一项还能查 110 秒左右;下游还要写报告时再给它留
60 秒,这一项只查 50 秒左右——双盲复算用到最后一刻的话,写报告那次模型调用一慢,整次运行照样被中止。"""
Blind(monkeypatch, orders, PLANS)
ticker = Ticker(monkeypatch, verify_agent, step=200)
row = await dual_run(build(orders))
assert row.status == "succeeded", row.error
first, second, third = ticker.timeouts
assert first == second == 120 and low < third < high, ticker.timeouts
async def test_resuming_after_the_review_replays_the_blind_checks(monkeypatch, orders):
"""销售额两路不一致、挂起等审核人裁决;后 3 项没来得及。审批恢复后节点从头重放:盲算取 checkpoint 里的,不再执行,
没来得及的 3 项照旧——不看恢复后这一段还剩多少时间。"""
plans = dict(PLANS)
plans["销售额"] = (f"SELECT SUM(amount) AS blind_v FROM orders WHERE paid_date {WEEK}", "blind_v") # 没排除退款:380.0
blind = Blind(monkeypatch, orders, plans)
ticker = Ticker(monkeypatch, verify_agent, step=260)
row = await dual_run(dual_graph(orders))
assert row.status == "interrupted", row.error
calls, timeouts = list(blind.calls), list(ticker.timeouts)
assert blind.runs == 2
ticker.offset = 0 # 恢复后的一段:时间又充裕了,重做的话五项都会盲算
async with SessionLocal() as session:
approval = (await session.execute(select(Approval).where(
Approval.run_id == row.id, Approval.status == "pending"))).scalar_one()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
res = await client.post(f"/api/approvals/{approval.id}/decide",
json={"approved": True, "value": {"choices": {"gmv": "a"}, "note": ""}})
assert res.status_code == 200, res.text
row = await wait(row.id)
assert row.status == "succeeded", row.error
assert blind.calls == calls and ticker.timeouts == timeouts, "恢复后盲算又执行了:没取 checkpoint"
out = await output_of(row.id, "dual")
assert out["status"] == "resolved" and out["choices"] == {"gmv": "a"}
assert [m["status"] for m in out["metrics"]] == ["disagreed", "agreed", "unchecked", "unchecked", "unchecked"]
assert set(unchecked_reasons(out).values()) == {LATE_BLIND}
assert len(await logs(row.id, "dual_check_unchecked")) == 1, "盲算那一步的日志只发一次"
async def test_slow_blind_checks_no_longer_abort_the_run(monkeypatch, orders):
"""真的钟:这一段只有 5 秒,五项盲算一项接一项(并行数 1),每项约 1 秒(模型每次调用 0.3 秒,每项 3 次),合起来
超过 5 秒——以前整次运行在盲算到一半时被中止。现在时间不够就不再开始、开始的按剩余时间收紧,没来得及的按无法复算
记,运行成功。每项时长上限 3 秒,一项自己用不完:无法复算的只会是因为运行时长上限将至。几项来得及随机器快慢变,
只断言不变的。"""
monkeypatch.setattr(settings, "max_run_seconds", 5)
monkeypatch.setattr(deadline, "RESERVE_S", 1.2)
monkeypatch.setattr(verify_agent, "SETTLE_GRACE_S", 0.4)
monkeypatch.setattr(dual_check_mod, "BLIND_CONCURRENCY", 1)
async def slow_model(session, spec):
return MockChatModel(model_name="slow-verify", latency=0.3), "slow-verify"
monkeypatch.setattr(verify_agent, "get_chat_model", slow_model)
Blind(monkeypatch, orders, PLANS)
row = await dual_run(dual_graph(orders), options={"verify": {"dual_check": {"timeout_s": 3}}})
assert row.status == "succeeded", row.error
assert "时长上限,已中止" not in (row.error or "")
out = await output_of(row.id, "dual")
reasons = unchecked_reasons(out)
assert out["counts"]["agreed"] >= 1 and reasons, out["metrics"]
assert out["counts"]["agreed"] + len(reasons) == 5 and out["counts"]["disagreed"] == 0
assert all("本次运行即将达到 5 秒的时长上限" in why for why in reasons.values()), reasons
assert [m["status"] for m in out["metrics"]][:out["counts"]["agreed"]] == ["agreed"] * out["counts"]["agreed"], \
"一项接一项:来得及的在前"
[unchecked] = await logs(row.id, "dual_check_unchecked")
assert {m["id"] for m in unchecked["metrics"]} == set(reasons)
async def test_a_slow_report_after_the_blind_checks_still_finishes(monkeypatch, orders):
"""真的钟:双盲复算 → 写报告 → 成果,这一段只有 10 秒。盲算一项接一项,每项都查到时长用完(查不完的查询);写报告的
执行器换成一个要 3 秒的替身(像写作模型慢)。双盲复算给下游的写报告多留了一份余量(4 秒),收尾之后报告写得完,运行
照常完成——只留一份(2 秒)的话,盲算用到最后,报告写到一半整次运行就被中止。几项来得及随机器快慢变,只断言不变的。"""
monkeypatch.setattr(settings, "max_run_seconds", 10)
monkeypatch.setattr(deadline, "RESERVE_S", 2.0)
monkeypatch.setattr(verify_agent, "SETTLE_GRACE_S", 0.4)
monkeypatch.setattr(dual_check_mod, "BLIND_CONCURRENCY", 1)
async def slow_model(session, spec):
return MockChatModel(model_name="slow-verify", latency=0.1), "slow-verify"
monkeypatch.setattr(verify_agent, "get_chat_model", slow_model)
endless = [f"SELECT COUNT(*) AS blind_v FROM orders WHERE id > {i}" for i in range(300)]
Blind(monkeypatch, orders, {name: (endless, "blind_v") for name in NAMES.values()})
wrote: list[float] = []
async def slow_report(state, ctx):
await asyncio.sleep(3.0)
wrote.append(asyncio.get_running_loop().time())
return {}
monkeypatch.setitem(compiler.RUNNERS, NodeType.REPORT, slow_report)
row = await dual_run(dual_report_graph(orders),
options={"verify": {"dual_check": {"timeout_s": 3, "max_queries": None}}})
assert row.status == "succeeded", row.error
assert "时长上限,已中止" not in (row.error or "")
assert len(wrote) == 1, "报告写完了"
out = await output_of(row.id, "dual")
reasons = unchecked_reasons(out)
assert any("本次运行即将达到 10 秒的时长上限" in why for why in reasons.values()), reasons