mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 08:22:00 +00:00
435 lines
24 KiB
Python
435 lines
24 KiB
Python
"""分析期望走真运行(app/eval/gold.py、score.py、runner.py;docs/design/analysis-round/00-总方案.md §3.8)。库是虚构的
|
||
retail_trends(tests/fixtures/acceptance/retail_trends.py,按今天生成);写作模型、助手都换成剧本,不调任何模型。
|
||
|
||
- 算期望:期望 SQL 的探针运行封存的那份快照上,用平台的算子(真算子、真现查)算出判定;作答前后两次期望照样对得上(运行当天、
|
||
现查时刻、输入快照的工件不比);阈值按交进来的分析设置快照;参数和结果对不上的照实说、整题没有可用的值。
|
||
- 读答案:作答运行台账里的分析结论、报告怎么引用了它(判定语、结果表的格、句末依据)、有判断词的句子;逐题 E1 按分析结论的
|
||
输入查询比。
|
||
- 重评老结果:明细里没有分析结论那一项的照样重评(从封存重新读)。
|
||
- 从头到尾:助手剧本搭「查库 → 分析卡 → 报告」,报告引用判定语;评测给这一题「对且可追溯」,逐题 E1 等价已证明,作答运行和
|
||
算期望用的是同一份分析设置快照(记进指纹)。
|
||
- 分析工具开、关(变体 analysis.tools,「分析工具贡献」预设对照):一次发起两臂;关的那一臂助手建图时工具清单里没有分析工具、
|
||
节点手册里没有分析卡,作答运行里没有分析结论;开的那一臂照常;两臂的指纹、同一变体的指纹都不同。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from datetime import date, datetime, timedelta
|
||
from pathlib import Path
|
||
from zoneinfo import ZoneInfo
|
||
|
||
import pytest
|
||
from langchain_core.messages import AIMessage, AIMessageChunk
|
||
from sqlalchemy import select
|
||
|
||
from app.core import artifact_store
|
||
from app.db.base import SessionLocal
|
||
from app.db.models import Run
|
||
from app.db.models_eval import EvalCase, EvalResult, EvalRun
|
||
from app.engine.runner import run_manager
|
||
from app.eval import cases, gold, judgements, runner, score
|
||
from app.eval.gold import Context
|
||
from tests import eval_samples as es
|
||
from tests.fixtures.acceptance import retail_trends
|
||
|
||
SUITE = json.loads((Path(__file__).parent / "fixtures" / "eval" / "analysis_suite.json").read_text(encoding="utf-8"))
|
||
#: 题集的时区:题目里的期次({{ schedule.* }})按它算,生成库的「今天」也按它取,不看本机时区
|
||
TZ = SUITE["suite"]["timezone"]
|
||
#: 题集里的第 1 题(上周销售额下降主要来自华东)和第 3 题(订单数的常规波动)
|
||
REGION = next(c for c in SUITE["cases"] if c["seq"] == 1)
|
||
ORDERS = next(c for c in SUITE["cases"] if c["seq"] == 3)
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
async def engine_up(monkeypatch):
|
||
from app.eval import snapshots
|
||
|
||
monkeypatch.setattr(snapshots, "MAX_SOURCES", 10_000)
|
||
await run_manager.setup()
|
||
yield
|
||
await runner.manager.shutdown()
|
||
await es.drop_suites()
|
||
await run_manager.shutdown()
|
||
|
||
|
||
@pytest.fixture
|
||
async def source(tmp_path, scratch_source):
|
||
path = retail_trends.build(tmp_path / "trends.db", now().date())
|
||
sid = await scratch_source(retail_trends.SOURCE_NAME, path, schema=True)
|
||
return {"id": sid, "name": retail_trends.SOURCE_NAME, "path": path}
|
||
|
||
|
||
def now() -> datetime:
|
||
return datetime.now(ZoneInfo(TZ))
|
||
|
||
|
||
def ctx(**kw) -> Context:
|
||
return Context(as_of=now(), tz=TZ, actor="评测测试", suite_id="s1", suite_name="虚构分析题集", **kw)
|
||
|
||
|
||
def case_of(raw: dict, source_id: str, *, expectations: list[dict] | None = None, sql: str | None = None) -> dict:
|
||
fields = cases.normalize_case({"question": raw["question"], "datasource_ids": [source_id],
|
||
"gold": {"kind": "sql", "datasource_id": source_id, "sql": sql or raw["gold"]["sql"]},
|
||
"expectations": expectations or raw["expectations"]})
|
||
return {**fields, "id": None, "seq": raw["seq"]}
|
||
|
||
|
||
def rendered(sql: str) -> str:
|
||
return cases.render(sql, cases.variable_values(now(), TZ))
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 算期望
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def test_the_gold_runs_the_platform_analysis_on_the_probe_snapshot(source):
|
||
import sqlite3
|
||
|
||
case = case_of(ORDERS, source["id"])
|
||
before = await gold.compute_gold(case, ctx())
|
||
assert before.ok, before.reason
|
||
band, orders = before.sets[0].values["band"], before.sets[0].values["orders"]
|
||
assert (band["kind"], band["status"], band["judgement"]) == ("verdict", "ok", "normal"), band
|
||
assert band["phrase"].startswith("订单数本期变化") and "常规波动范围内" in band["phrase"]
|
||
result = band["result"]
|
||
assert result["inputs"][0]["ref"] == judgements.GOLD_REF and result["inputs"][0]["artifact"] == before.sets[0].snapshot
|
||
assert result["params"]["today"] == date.today().isoformat()
|
||
assert result["settings"]["k_warn"] == 2.0, "没有设置快照(试算)时现读设置,缺省 2 倍"
|
||
# 本期齐不齐按现查的表范围判(SQL 里按日期算出来的界也在库里算一次),都记进结论
|
||
assert [(p["table"], p["column"], p["basis"]) for p in result["probes"] if "table" in p] == \
|
||
[("orders", "order_date", "live")], result["probes"]
|
||
assert all(p.get("at") for p in result["probes"])
|
||
with sqlite3.connect(source["path"]) as db:
|
||
direct = db.execute(rendered(ORDERS["gold"]["sql"])).fetchall()
|
||
assert orders["value"] == direct[24][1] and len(direct) == 25
|
||
|
||
# 作答后再算一次:快照的工件(记着查询用时)、现查时刻都不同,期望照样对得上
|
||
after = await gold.compute_gold(case, ctx(role="gold_after"))
|
||
assert after.sets[0].snapshot != before.sets[0].snapshot
|
||
assert gold.settle(before, after).status == "ok"
|
||
|
||
|
||
async def test_the_settings_snapshot_decides_the_thresholds(source):
|
||
case = case_of(ORDERS, source["id"])
|
||
outcome = await gold.compute_gold(case, ctx(analysis={"min_history": 30, "max_history": 52}))
|
||
assert outcome.ok, outcome.reason
|
||
band = outcome.sets[0].values["band"]
|
||
assert band["status"] == "refused" and band["reason_code"] == "history_short", band
|
||
assert band["result"]["settings"]["min_history"] == 30
|
||
graded = score.score_case(case, gold.settle(outcome, outcome).to_dict(), score.Answer(run_id="r"), path="ran")
|
||
assert graded.scores[0]["outcome"] == "ungradable" and graded.scores[0]["reason"].startswith("期望算不了:")
|
||
|
||
|
||
async def test_parameters_that_do_not_fit_the_sql_are_said_plainly(source):
|
||
wrong_column = case_of(REGION, source["id"], expectations=[
|
||
{"kind": "contribution", "mode": "additive", "params": {"by": ["门店"], "period": "周", "measure": "销售额"}}])
|
||
outcome = await gold.compute_gold(wrong_column, ctx())
|
||
assert outcome.status == "no_value" and "期望 SQL 里没有「门店」这一列" in outcome.reason, outcome.reason
|
||
three_weeks = REGION["gold"]["sql"].replace("'-7 days'", "'-14 days'")
|
||
outcome = await gold.compute_gold(case_of(REGION, source["id"], sql=three_weeks), ctx())
|
||
assert outcome.status == "no_value" and "有 3 期" in outcome.reason, outcome.reason
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 读答案、重评老结果
|
||
# --------------------------------------------------------------------------
|
||
|
||
REPORT = ("## 上周销售额\n\n上周销售额下降:[[a:A1]]。华东的变化是 [[v:A1.r0.delta]]。\n\n"
|
||
"下降主要来自华东,其余地区变化不大。[[see:A1]]")
|
||
|
||
|
||
def _node(nid: str, ntype: str, **config) -> dict:
|
||
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
|
||
|
||
|
||
def _card_item() -> dict:
|
||
values = cases.variable_values(now(), TZ)
|
||
target = values["last_week_start"]
|
||
base = (date.fromisoformat(target) - timedelta(days=7)).isoformat()
|
||
return {"id": "where", "op": "decompose", "mode": "additive", "from": "q",
|
||
"params": {"by": ["地区"], "period": "周", "base": base, "target": target, "measure": "销售额",
|
||
"label": "销售额"}}
|
||
|
||
|
||
def answer_graph() -> dict:
|
||
nodes = [_node("start", "input"),
|
||
_node("q", "tool", tool=f"db_query__{retail_trends.SOURCE_NAME}", args={"sql": rendered(REGION["gold"]["sql"])}),
|
||
_node("card", "analysis", items=[_card_item()]),
|
||
_node("write", "report", instructions="写上周的周报"),
|
||
_node("out", "output", fields=[{"name": "答案", "value": "{{ nodes.write.text }}"}])]
|
||
ids = [n["id"] for n in nodes]
|
||
return {"nodes": nodes, "edges": [{"source": a, "target": b} for a, b in zip(ids, ids[1:])]}
|
||
|
||
|
||
async def _answer_run(monkeypatch) -> Run:
|
||
from tests.test_analysis_card import wait
|
||
|
||
es.use_report(monkeypatch, REPORT)
|
||
started = await run_manager.start(graph=answer_graph(), input_payload={})
|
||
row = await wait(started.id)
|
||
assert row.status == "succeeded", row.error
|
||
return row
|
||
|
||
|
||
async def test_reading_the_analyses_of_the_answer_run(monkeypatch, source):
|
||
row = await _answer_run(monkeypatch)
|
||
answer = await score.read_answer(row.id)
|
||
assert answer.problem is None and answer.report == "write"
|
||
[record] = answer.analyses
|
||
assert (record["alias"], record["op"], record["mode"], record["judgement"]) == ("A1", "decompose", "additive",
|
||
"source")
|
||
assert record["subjects"][0] == "华东" and record["direction"] == "down"
|
||
assert record["cited"] == ["phrase", "cell", "see"], record
|
||
# 判定语不是答案里的数;结果表的那一格是,维度是那一段(华东)
|
||
[cell] = answer.values
|
||
assert cell["kind"] == "cell" and cell["dims"] == {"地区": "华东"} and cell["artifact"] == record["artifact"]
|
||
# 逐题 E1 拿分析结论的输入查询比
|
||
[query] = answer.queries
|
||
assert answer.cited == [query["artifact"]] and record["inputs"] == [query["artifact"]]
|
||
[sentence] = answer.sentences
|
||
assert sentence["analyses"] == ["A1"] and "主要来自华东" in sentence["text"]
|
||
|
||
case = case_of(REGION, source["id"])
|
||
before = await gold.compute_gold(case, ctx())
|
||
settled = gold.settle(before, before).to_dict()
|
||
graded = score.score_case(case, settled, answer, path="ran")
|
||
assert graded.verdict == "pass" and graded.traced, graded.scores
|
||
assert graded.scores[0]["found"]["alias"] == "A1"
|
||
match = await score.sql_match(case, settled, answer)
|
||
assert match["status"] == "proven" and match["basis"] == "cited", match
|
||
|
||
|
||
async def test_rescoring_old_results_reads_the_analyses_from_the_seal(monkeypatch, source):
|
||
from app.eval import rescore
|
||
from app.eval.cases import case_hash
|
||
|
||
row = await _answer_run(monkeypatch)
|
||
case = case_of(REGION, source["id"])
|
||
settled = gold.settle(*(2 * [await gold.compute_gold(case, ctx())])).to_dict()
|
||
old = score.Answer(run_id=row.id, text="老的答案").to_dict()
|
||
old.pop("analyses")
|
||
old.pop("sentences")
|
||
details = await artifact_store.put_json({"format": 1, "turn": {"reply": None}, "answer": old}, kind="eval_result")
|
||
suite_id = await es.make_suite(source["id"], cases=[{k: case[k] for k in ("question", "datasource_ids", "gold",
|
||
"expectations")}])
|
||
async with SessionLocal() as session:
|
||
stored = (await session.execute(select(EvalCase).where(EvalCase.suite_id == suite_id))).scalar_one()
|
||
ev = EvalRun(suite_id=suite_id, status="succeeded", fingerprint={"scorer_version": 2})
|
||
session.add(ev)
|
||
await session.flush()
|
||
session.add(EvalResult(eval_run_id=ev.id, case_id=stored.id, case_hash=case_hash(case), status="ok",
|
||
path="ran", run_id=row.id, verdict="fail", details_artifact=details, gold=settled,
|
||
scores=[{"id": "source", "kind": "contribution", "required": True, "outcome": "wrong"}]))
|
||
await session.commit()
|
||
ev_id = ev.id
|
||
assert await rescore.rescore(ev_id) == 1
|
||
async with SessionLocal() as session:
|
||
result = (await session.execute(select(EvalResult).where(EvalResult.eval_run_id == ev_id))).scalar_one()
|
||
assert result.verdict == "pass" and result.scores[0]["outcome"] == "traced", result.scores
|
||
assert (await session.get(EvalRun, ev_id)).fingerprint["scorer_version"] == score.SCORER_VERSION == 3
|
||
assert (await session.get(EvalRun, ev_id)).summary["outcomes_by_kind"] == {"contribution": {"traced": 1}}
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 从头到尾
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def _line(op: dict) -> AIMessageChunk:
|
||
return AIMessageChunk(content=json.dumps(op, ensure_ascii=False) + "\n")
|
||
|
||
|
||
def _graph_lines(graph: dict, summary: str):
|
||
yield _line({"op": "plan", "summary": summary})
|
||
for n in graph["nodes"]:
|
||
yield _line({"op": "add_node", "node": {"id": n["id"], "type": n["type"], "label": n["data"]["label"],
|
||
"config": n["data"]["config"]}})
|
||
for e in graph["edges"]:
|
||
yield _line({"op": "add_edge", "edge": e})
|
||
yield _line({"op": "done", "explanation": "拆一下", "run": True})
|
||
|
||
|
||
class AnalysisCopilot:
|
||
"""助手剧本:直接搭「入口 → 查库 → 分析卡 → 报告 → 出口」并运行;上线前自查直接过。"""
|
||
|
||
async def astream(self, messages):
|
||
if "上线前自查" in messages[1][1]:
|
||
yield _line({"op": "done"})
|
||
return
|
||
for line in _graph_lines(answer_graph(), "按地区拆上周销售额的变化"):
|
||
yield line
|
||
|
||
|
||
async def test_an_analysis_case_end_to_end(monkeypatch, source):
|
||
from app.api.settings import analysis_settings
|
||
|
||
es.use_copilot(monkeypatch, AnalysisCopilot())
|
||
es.use_report(monkeypatch, REPORT)
|
||
case = case_of(REGION, source["id"])
|
||
suite_id = await es.make_suite(source["id"], cases=[{k: case[k] for k in ("question", "datasource_ids", "gold",
|
||
"expectations")}])
|
||
from app.db.models_eval import EvalSuite
|
||
from app.eval.variants import normalize_variant
|
||
|
||
async with SessionLocal() as session:
|
||
suite = await session.get(EvalSuite, suite_id)
|
||
[ev] = await runner.start(session, suite, [normalize_variant({})], actor="评测测试")
|
||
expected_settings = await analysis_settings(session)
|
||
await es.wait_eval(ev.id)
|
||
async with SessionLocal() as session:
|
||
ev = await session.get(EvalRun, ev.id)
|
||
[item] = (await session.execute(select(EvalResult).where(EvalResult.eval_run_id == ev.id))).scalars()
|
||
answer_run = await session.get(Run, item.run_id)
|
||
assert ev.status == "succeeded", (ev.error, item.error)
|
||
assert item.path == "ran" and item.verdict == "pass", (item.error, item.scores)
|
||
[scored] = item.scores
|
||
assert scored["outcome"] == "traced" and scored["want"]["subjects"] == ["华东"], scored
|
||
assert item.sql_match["status"] == "proven", item.sql_match
|
||
# 算期望和作答用同一份分析设置快照,记进指纹
|
||
assert ev.fingerprint["analysis"] == expected_settings
|
||
assert answer_run.options["analysis"] == expected_settings
|
||
assert item.gold["before"]["sets"][0]["values"]["source"]["result"]["settings"]["main_share"] == \
|
||
expected_settings["main_share"]
|
||
assert ev.summary["outcomes_by_kind"] == {"contribution": {"traced": 1}}
|
||
assert ev.fingerprint["scorer_version"] == 3
|
||
|
||
|
||
async def test_suites_without_analysis_expectations_take_no_settings_snapshot(monkeypatch, tmp_path):
|
||
from tests.fixtures.sources import drop_source
|
||
|
||
es.use_copilot(monkeypatch, es.Copilot("none"))
|
||
name, sid = await es.make_source(tmp_path)
|
||
try:
|
||
es.use_copilot(monkeypatch, es.Copilot(name))
|
||
es.use_report(monkeypatch)
|
||
suite_id = await es.make_suite(sid)
|
||
from app.db.models_eval import EvalSuite
|
||
from app.eval.variants import normalize_variant
|
||
|
||
async with SessionLocal() as session:
|
||
suite = await session.get(EvalSuite, suite_id)
|
||
[ev] = await runner.start(session, suite, [normalize_variant({})], actor="评测测试")
|
||
await es.wait_eval(ev.id)
|
||
async with SessionLocal() as session:
|
||
ev = await session.get(EvalRun, ev.id)
|
||
[item] = (await session.execute(select(EvalResult).where(EvalResult.eval_run_id == ev.id))).scalars()
|
||
answer_run = await session.get(Run, item.run_id)
|
||
assert item.verdict == "pass", item.error
|
||
assert "analysis" not in ev.fingerprint and "analysis" not in (answer_run.options or {}), \
|
||
"别的题集的指纹、作答运行的选项和以前一样"
|
||
finally:
|
||
await drop_source(sid)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 分析工具开、关(变体 analysis.tools;「分析工具贡献」预设对照)
|
||
# --------------------------------------------------------------------------
|
||
|
||
#: 关的那一臂搭的图里,报告撰写的要求带这个记号:报告撰写的剧本据此不引用分析结论(那一臂没有)
|
||
PLAIN_MARK = "只按查询结果写"
|
||
PLAIN = "## 上周销售额\n\n各地区上周的销售额都在查询结果里。"
|
||
|
||
|
||
def plain_graph() -> dict:
|
||
"""没有分析卡的图:入口 → 查库 → 报告 → 出口。"""
|
||
nodes = [_node("start", "input"),
|
||
_node("q", "tool", tool=f"db_query__{retail_trends.SOURCE_NAME}",
|
||
args={"sql": rendered(REGION["gold"]["sql"])}),
|
||
_node("write", "report", instructions=f"{PLAIN_MARK}上周的周报"),
|
||
_node("out", "output", fields=[{"name": "答案", "value": "{{ nodes.write.text }}"}])]
|
||
ids = [n["id"] for n in nodes]
|
||
return {"nodes": nodes, "edges": [{"source": a, "target": b} for a, b in zip(ids, ids[1:])]}
|
||
|
||
|
||
class SwitchCopilot:
|
||
"""助手剧本:两臂都先照「查库 → 分析卡 → 报告」搭——关掉分析工具的那一臂靠真代码(answer_ops 的自查)挡回去,
|
||
替身不替它判断。自查交回来时(只有关的那一臂会有)去掉分析卡、把查库直接接到报告。systems 记下每次建图收到的 system。"""
|
||
|
||
def __init__(self) -> None:
|
||
self.systems: list[str] = []
|
||
|
||
async def astream(self, messages):
|
||
if "上线前自查" in messages[1][1]:
|
||
yield _line({"op": "remove_node", "id": "card"})
|
||
yield _line({"op": "add_edge", "edge": {"source": "q", "target": "write"}})
|
||
# 这一臂没有分析结论可引:报告只按查询结果写(报告撰写的剧本认这个记号,见 use_writers)
|
||
yield _line({"op": "update_node", "id": "write", "config": {"instructions": f"{PLAIN_MARK}上周的周报"}})
|
||
yield _line({"op": "done", "explanation": "这一轮没有分析工具,去掉分析卡"})
|
||
return
|
||
role, system = messages[0]
|
||
assert role == "system"
|
||
self.systems.append(system)
|
||
for line in _graph_lines(answer_graph(), "看上周销售额"):
|
||
yield line
|
||
|
||
|
||
def use_writers(monkeypatch) -> None:
|
||
"""报告撰写的剧本:要求里带 PLAIN_MARK 的(关掉分析工具那一臂)写 PLAIN,别的写引用了 A1 的 REPORT。"""
|
||
from app.providers import mock_model
|
||
|
||
def decide(self, messages):
|
||
said = "\n".join(str(getattr(m, "content", "")) for m in messages)
|
||
return AIMessage(content=PLAIN if PLAIN_MARK in said else REPORT)
|
||
|
||
monkeypatch.setattr(mock_model.MockChatModel, "_decide", decide)
|
||
|
||
|
||
async def test_the_analysis_tools_preset_runs_with_and_without_them(monkeypatch, source):
|
||
from httpx import ASGITransport, AsyncClient
|
||
|
||
from app.eval import compare
|
||
from app.main import app
|
||
from app.tools.analysis import ANALYSIS_TOOLS
|
||
|
||
copilot = SwitchCopilot()
|
||
es.use_copilot(monkeypatch, copilot)
|
||
use_writers(monkeypatch)
|
||
case = case_of(REGION, source["id"])
|
||
suite_id = await es.make_suite(source["id"], cases=[{k: case[k] for k in ("question", "datasource_ids", "gold",
|
||
"expectations")}])
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
started = await client.post(f"/api/evals/suites/{suite_id}/evaluations",
|
||
json={"preset": "analysis_tools", "variant": {"repeats": 1}})
|
||
assert started.status_code == 202, started.text
|
||
body = started.json()
|
||
on, off = body["evaluations"]
|
||
assert (on["arm"], off["arm"]) == ("分析工具开", "分析工具关") and body["pair_id"] == on["id"] == off["pair_id"]
|
||
assert on["variant"]["analysis"] == {"tools": "on"} and off["variant"]["analysis"] == {"tools": "off"}
|
||
assert on["variant"]["catalog"] == off["variant"]["catalog"] == {"mode": "live", "snapshot": None}
|
||
assert "· 分析工具开 ·" in on["variant_text"] and "· 分析工具关 ·" in off["variant_text"], off["variant_text"]
|
||
await es.wait_eval(on["id"])
|
||
pair = (await client.get(f"/api/evals/pairs/{body['pair_id']}")).json()
|
||
assert [a["arm"] for a in pair["table"]["arms"]] == ["分析工具开", "分析工具关"]
|
||
|
||
async with SessionLocal() as session:
|
||
runs = {r.arm: r for r in (await session.execute(select(EvalRun).where(EvalRun.pair_id == on["id"]))).scalars()}
|
||
items = {arm: (await session.execute(select(EvalResult).where(EvalResult.eval_run_id == r.id))).scalar_one()
|
||
for arm, r in runs.items()}
|
||
answer_runs = {arm: await session.get(Run, item.run_id) for arm, item in items.items()}
|
||
assert all(r.status == "succeeded" for r in runs.values()), [(r.arm, r.error) for r in runs.values()]
|
||
# 建图:两臂各一次,关的那一臂工具清单里没有分析工具、节点手册里没有分析卡;开的那一臂都有
|
||
assert len(copilot.systems) == 2
|
||
with_tools = [s for s in copilot.systems if all(name in s for name in ANALYSIS_TOOLS)]
|
||
without = [s for s in copilot.systems if not any(name in s for name in ANALYSIS_TOOLS)]
|
||
assert len(with_tools) == len(without) == 1
|
||
assert "- analysis:分析卡" in with_tools[0] and "- analysis:分析卡" not in without[0]
|
||
|
||
# 开的那一臂照常:图里有分析卡,答案引用了分析结论,对且可追溯
|
||
lit, dark = items["分析工具开"], items["分析工具关"]
|
||
assert {n["type"] for n in answer_runs["分析工具开"].graph["nodes"]} >= {"analysis", "report"}
|
||
assert lit.verdict == "pass" and lit.scores[0]["outcome"] == "traced", lit.scores
|
||
assert [a["alias"] for a in (await score.read_answer(lit.run_id)).analyses] == ["A1"]
|
||
# 关的那一臂:图里没有分析卡,作答运行里没有分析结论;正文没写这个判断,算错
|
||
assert "analysis" not in {n["type"] for n in answer_runs["分析工具关"].graph["nodes"]}
|
||
assert (await score.read_answer(dark.run_id)).analyses == []
|
||
assert dark.verdict == "fail" and dark.scores[0]["outcome"] == "wrong", dark.scores
|
||
assert "没有做变化拆解" in dark.scores[0]["reason"], dark.scores[0]
|
||
# 两臂共用期望;指纹、同一变体的指纹都不同,对比照实写「分析工具:开 → 关」
|
||
assert lit.gold_run_ids == dark.gold_run_ids
|
||
fp_on, fp_off = runs["分析工具开"].fingerprint, runs["分析工具关"].fingerprint
|
||
assert (fp_on["analysis_tools"], fp_off["analysis_tools"]) == ("on", "off")
|
||
assert fp_on["variant_key"] != fp_off["variant_key"]
|
||
assert [d for d in compare.fingerprint_diff(fp_on, fp_off) if d["key"] == "analysis_tools"] == \
|
||
[{"key": "analysis_tools", "label": "分析工具", "before": "开", "after": "关"}]
|