mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 08:51:58 +00:00
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 并注明理由
480 lines
22 KiB
Python
480 lines
22 KiB
Python
"""报告直接引用查询单元格 [[v:Qn.r<行>.<列>]]:受管级别的正式运行要在契约里显式声明 cells。
|
||
|
||
用户拍板:探索运行和已发布级别允许直接引用单元格;受管级别的正式运行,契约里没写
|
||
"cells": true 的,单元格引用按 unresolved 计,原因写「受管出具要在契约里声明 cells」。
|
||
|
||
- 出口复核(io.py)按这条规则独立判,不信报告节点的自查
|
||
- 报告节点写作时也照这条规则建目录:不允许时提示里只给 [[see:Qn]],写了单元格当场就被打回,
|
||
不必等到出口才发现
|
||
- 旧 narrative 契约的 matched 里,单元格引用的数字记成 cell: "Q1.r0.gmv",不是 input: None
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import sqlite3
|
||
|
||
import pytest
|
||
from httpx import ASGITransport, AsyncClient
|
||
from langchain_core.messages import AIMessage
|
||
from sqlalchemy import select
|
||
|
||
from app.data.engine import engines
|
||
from app.db.base import SessionLocal
|
||
from app.db.models import DataSource, Run, RunEvent, Workflow, WorkflowVersion
|
||
from app.engine.evidence import CELLS_REASON
|
||
from app.engine.runner import run_manager
|
||
from app.main import app
|
||
|
||
CELL_TEXT = "本周销售额 [[v:Q1.r0.gmv]],按口径卡是 [[m:gmv]]。"
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
async def engine_up():
|
||
await run_manager.setup()
|
||
yield
|
||
await run_manager.shutdown()
|
||
|
||
|
||
@pytest.fixture
|
||
async def client():
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||
yield c
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
async def shop(tmp_path):
|
||
path = tmp_path / "shop.db"
|
||
db = sqlite3.connect(path)
|
||
db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, week TEXT, amount REAL);")
|
||
db.executemany("INSERT INTO orders VALUES (?, ?, ?)", [(1, "2026-W37", 100.5), (2, "2026-W37", 200.0)])
|
||
db.commit()
|
||
db.close()
|
||
async with SessionLocal() as session:
|
||
row = (await session.execute(select(DataSource).where(DataSource.name == "shop"))).scalar_one_or_none()
|
||
if row is None:
|
||
row = DataSource(name="shop", kind="sqlite", database=str(path), readonly=True, enabled=True)
|
||
session.add(row)
|
||
else:
|
||
row.database, row.kind, row.enabled, row.readonly, row.options = str(path), "sqlite", True, True, {}
|
||
await session.commit()
|
||
source_id = row.id
|
||
await engines.invalidate(source_id)
|
||
yield
|
||
await engines.invalidate(source_id)
|
||
|
||
|
||
def node(nid, ntype, **config):
|
||
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
|
||
|
||
|
||
def weekly(*, cells=None, on_violation="flag"):
|
||
contract = {"report_from": "write", "metrics_from": ["card"], "required": ["gmv"], "strict": True}
|
||
if cells is not None:
|
||
contract["cells"] = cells
|
||
nodes = [
|
||
node("start", "input"),
|
||
node("fetch", "tool", tool="db_query__shop",
|
||
args={"sql": "SELECT SUM(amount) AS gmv, COUNT(*) AS orders FROM orders"}),
|
||
node("card", "metrics", caliber="周报口径", caliber_version="v2", metrics=[
|
||
{"id": "gmv", "name": "销售额", "unit": "元", "decimals": 1, "format": "thousands",
|
||
"expression": "cell(nodes.fetch, 0, 'gmv')"}]),
|
||
node("write", "report", instructions="写周报", on_violation=on_violation),
|
||
node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}], contract=contract),
|
||
]
|
||
return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
|
||
|
||
|
||
class Writer:
|
||
"""报告写作者:每次都照 text 写;记下每次看到的提示。"""
|
||
|
||
def __init__(self, monkeypatch, text=CELL_TEXT):
|
||
from app.providers import mock_model
|
||
|
||
self.prompts: list[str] = []
|
||
|
||
def decide(model, messages):
|
||
self.prompts.append("\n".join(str(m.content) for m in messages))
|
||
return AIMessage(content=text)
|
||
|
||
monkeypatch.setattr(mock_model.MockChatModel, "_decide", decide)
|
||
|
||
|
||
async def wait(run_id: str) -> Run:
|
||
for _ in range(400):
|
||
await asyncio.sleep(0.05)
|
||
async with SessionLocal() as session:
|
||
row = await session.get(Run, run_id)
|
||
if row.status in ("succeeded", "failed") and row.manifest_seq is not None:
|
||
return row
|
||
raise AssertionError(f"run {run_id} 没跑完:{row.status}")
|
||
|
||
|
||
async def formal(client, graph, *, status: str) -> Run:
|
||
"""把这张图发布成 status 级别,再从发布版本发起正式运行。"""
|
||
async with SessionLocal() as session:
|
||
wf = Workflow(name=f"周报-{status}", graph=graph, status=status, published_version=1)
|
||
session.add(wf)
|
||
await session.flush()
|
||
session.add(WorkflowVersion(workflow_id=wf.id, version=1, graph=graph))
|
||
await session.commit()
|
||
wf_id = wf.id
|
||
res = await client.post("/api/runs", json={"workflow_id": wf_id, "run_class": "formal"})
|
||
assert res.status_code == 201, res.text
|
||
return await wait(res.json()["id"])
|
||
|
||
|
||
async def checked(run_id: str) -> dict:
|
||
async with SessionLocal() as session:
|
||
[event] = (await session.execute(select(RunEvent).where(
|
||
RunEvent.run_id == run_id, RunEvent.type == "report.checked"))).scalars()
|
||
return event.data
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 出口复核
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def test_governed_formal_run_without_cells_leaves_cell_refs_unresolved(client, monkeypatch):
|
||
Writer(monkeypatch)
|
||
row = await formal(client, weekly(), status="governed")
|
||
assert row.status == "succeeded", row.error
|
||
issuance = row.output["_issuance"]
|
||
assert issuance["mode"] == "citations" and issuance["tier"] == "withheld", issuance
|
||
[unresolved] = issuance["unresolved"]
|
||
assert unresolved["ref"] == "v:Q1.r0.gmv" and CELLS_REASON in unresolved["message"], unresolved
|
||
# 指标照常核对过;单元格那个数不算「有出处」
|
||
assert [m.get("metric") for m in issuance["matched"]] == ["gmv"]
|
||
|
||
|
||
async def test_governed_formal_run_with_cells_declared_passes(client, monkeypatch):
|
||
Writer(monkeypatch)
|
||
row = await formal(client, weekly(cells=True), status="governed")
|
||
assert row.status == "succeeded", row.error
|
||
issuance = row.output["_issuance"]
|
||
assert issuance["tier"] == "formal" and issuance["unresolved"] == [], issuance
|
||
cell = next(m for m in issuance["matched"] if m.get("cell"))
|
||
assert cell["cell"] == "Q1.r0.gmv" and cell["token"] == "300.5" and cell["metric"] is None
|
||
assert "input" not in cell and cell["eid"].startswith("ev:cell:")
|
||
|
||
|
||
@pytest.mark.parametrize("status", ["published"])
|
||
async def test_published_formal_runs_allow_cells(client, monkeypatch, status):
|
||
Writer(monkeypatch)
|
||
row = await formal(client, weekly(), status=status)
|
||
assert row.output["_issuance"]["tier"] == "formal", row.output["_issuance"]
|
||
|
||
|
||
async def test_exploratory_runs_are_not_affected(monkeypatch):
|
||
Writer(monkeypatch)
|
||
run = await run_manager.start(graph=weekly(), input_payload={})
|
||
row = await wait(run.id)
|
||
issuance = row.output["_issuance"]
|
||
assert issuance["tier"] == "formal" and issuance["unresolved"] == [], issuance
|
||
assert any(m.get("cell") == "Q1.r0.gmv" for m in issuance["matched"])
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 报告节点写作时就照这条规则
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def test_report_node_refuses_cells_while_writing_for_governed_formal_runs(client, monkeypatch):
|
||
writer = Writer(monkeypatch)
|
||
row = await formal(client, weekly(), status="governed")
|
||
data = await checked(row.id)
|
||
assert data["ok"] is False and data["repairs"] == 1
|
||
assert any(CELLS_REASON in v["message"] for v in data["violations"]), data["violations"]
|
||
# 提示里不再教它写单元格,只给依据写法
|
||
assert "[[v:Q1.r0." not in writer.prompts[0] and "[[see:Q1]]" in writer.prompts[0]
|
||
|
||
|
||
async def test_report_node_with_on_violation_fail_stops_before_the_exit(client, monkeypatch):
|
||
Writer(monkeypatch)
|
||
row = await formal(client, weekly(on_violation="fail"), status="governed")
|
||
assert row.status == "failed" and CELLS_REASON in (row.error or ""), row.error
|
||
|
||
|
||
async def test_report_node_teaches_cells_when_they_are_allowed(client, monkeypatch):
|
||
writer = Writer(monkeypatch)
|
||
row = await formal(client, weekly(cells=True), status="governed")
|
||
data = await checked(row.id)
|
||
assert data["ok"] is True and data["repairs"] == 0, data
|
||
assert "[[v:Q1.r0." in writer.prompts[0]
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# report_no_evidence:有任何一种可引用的来源就不报,报的时候说清缺的是哪种
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def no_evidence_logs(run_id: str) -> list[dict]:
|
||
async with SessionLocal() as session:
|
||
rows = (await session.execute(select(RunEvent).where(
|
||
RunEvent.run_id == run_id, RunEvent.type == "log"))).scalars()
|
||
return [e.data for e in rows if e.data.get("code") == "report_no_evidence"]
|
||
|
||
|
||
def chain(*nodes):
|
||
return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
|
||
|
||
|
||
FETCH = node("fetch", "tool", tool="db_query__shop", args={"sql": "SELECT SUM(amount) AS gmv FROM orders"})
|
||
WRITE = node("write", "report", instructions="回答问题", on_violation="flag")
|
||
OUT = node("out", "output", fields=[{"name": "答案", "value": "{{ nodes.write.text }}"}])
|
||
|
||
|
||
async def test_a_report_over_queries_only_is_not_warned(monkeypatch):
|
||
"""问数据的图(input → 取数 → report)只有查询条目、没有口径卡:单元格就是它的出处,不该报没有证据。"""
|
||
Writer(monkeypatch, "销售额 [[v:Q1.r0.gmv]]。")
|
||
row = await wait((await run_manager.start(graph=chain(node("start", "input"), FETCH, WRITE, OUT),
|
||
input_payload={})).id)
|
||
assert row.status == "succeeded", row.error
|
||
assert await no_evidence_logs(row.id) == []
|
||
|
||
|
||
async def test_a_report_with_nothing_upstream_names_every_missing_kind(monkeypatch):
|
||
Writer(monkeypatch, "什么都没有。")
|
||
row = await wait((await run_manager.start(graph=chain(node("start", "input"), WRITE, OUT),
|
||
input_payload={})).id)
|
||
[log] = await no_evidence_logs(row.id)
|
||
assert all(kind in log["message"] for kind in ("口径卡指标", "查询结果", "知识库检索", "运行输入")), log
|
||
|
||
|
||
async def test_queries_without_cells_in_a_governed_formal_run_are_named(client, monkeypatch):
|
||
Writer(monkeypatch, "销售额 [[v:Q1.r0.gmv]]。")
|
||
card = node("card", "metrics", caliber="周报口径", metrics=[
|
||
{"id": "gmv", "name": "销售额", "expression": "cell(nodes.fetch, 0, 'gmv')"}])
|
||
out = node("out", "output", fields=[{"name": "答案", "value": "{{ nodes.write.text }}"}],
|
||
contract={"report_from": "write", "metrics_from": ["card"], "required": ["gmv"]})
|
||
row = await formal(client, chain(node("start", "input"), FETCH, WRITE, card, out), status="governed")
|
||
[log] = await no_evidence_logs(row.id)
|
||
assert "只有查询结果" in log["message"] and CELLS_REASON in log["message"], log
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 受管与否在发起时定下来:之后改图、改级别,这次运行都按发起时的算
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def governance_notes(run_id: str) -> list[RunEvent]:
|
||
async with SessionLocal() as session:
|
||
rows = (await session.execute(select(RunEvent).where(
|
||
RunEvent.run_id == run_id, RunEvent.type == "log").order_by(RunEvent.seq))).scalars()
|
||
return [e for e in rows if e.data.get("code") == "run_governance"]
|
||
|
||
|
||
async def set_status(run_id: str, status: str) -> None:
|
||
async with SessionLocal() as session:
|
||
run = await session.get(Run, run_id)
|
||
wf = await session.get(Workflow, run.workflow_id)
|
||
wf.status = status
|
||
await session.commit()
|
||
|
||
|
||
@pytest.mark.parametrize("status, governed, later", [("governed", True, "draft"), ("published", False, "governed")])
|
||
async def test_the_governed_decision_is_recorded_at_start_and_does_not_follow_later_edits(
|
||
client, monkeypatch, status, governed, later):
|
||
from app.engine.governance import governed_formal
|
||
|
||
Writer(monkeypatch)
|
||
row = await formal(client, weekly(cells=True), status=status)
|
||
[note] = await governance_notes(row.id)
|
||
assert note.data["governed"] is governed and note.data["level"] == "info" and note.node_id is None
|
||
assert note.seq <= row.manifest_seq # 在封存范围里
|
||
assert await governed_formal(row.id) is governed
|
||
await set_status(row.id, later) # 改图退回草稿 / 事后升成受管
|
||
assert await governed_formal(row.id) is governed
|
||
|
||
|
||
async def test_exploratory_runs_record_nothing(monkeypatch):
|
||
Writer(monkeypatch)
|
||
row = await wait((await run_manager.start(graph=weekly(), input_payload={})).id)
|
||
assert await governance_notes(row.id) == []
|
||
|
||
|
||
async def test_runs_started_before_the_record_existed_follow_the_current_status(client, monkeypatch):
|
||
from app.engine.governance import governed_formal
|
||
|
||
Writer(monkeypatch)
|
||
row = await formal(client, weekly(cells=True), status="governed")
|
||
async with SessionLocal() as session:
|
||
for note in await governance_notes(row.id):
|
||
await session.delete(await session.get(RunEvent, note.id))
|
||
await session.commit()
|
||
assert await governed_formal(row.id) is True
|
||
await set_status(row.id, "draft")
|
||
assert await governed_formal(row.id) is False
|
||
|
||
|
||
async def test_the_report_node_and_the_exit_check_agree_when_the_status_changes_mid_run(client, monkeypatch):
|
||
"""报告节点写作时按受管拒了单元格,跑到出口前工作流被改回草稿:出口仍按受管判,两边说法一致。"""
|
||
from app.engine import governance
|
||
|
||
Writer(monkeypatch)
|
||
real, seen = governance.governed_formal, []
|
||
|
||
async def edit_after_first_look(run_id):
|
||
result = await real(run_id)
|
||
seen.append(result)
|
||
if len(seen) == 1:
|
||
await set_status(run_id, "draft")
|
||
return result
|
||
|
||
monkeypatch.setattr(governance, "governed_formal", edit_after_first_look)
|
||
row = await formal(client, weekly(), status="governed")
|
||
assert seen == [True, True], seen
|
||
issuance = row.output["_issuance"]
|
||
assert issuance["tier"] == "withheld" and CELLS_REASON in issuance["unresolved"][0]["message"], issuance
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 只引单元格、没有口径卡:契约不用写 metrics_from
|
||
#
|
||
# 问数据的典型图是 input → 取数 → 报告 → 出口,报告直接引用查过的单元格。受管级别在契约里
|
||
# 写 cells: true 就够了(用户拍板的第 5 条),以前校验和门禁无条件要 metrics_from,
|
||
# 这张图怎么都发不成受管,Copilot 补上契约也会被判「冒出新的问题」作废
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def cells_only(**contract):
|
||
contract = {"report_from": "write", "strict": True, "cells": True, **contract}
|
||
nodes = [
|
||
node("start", "input"),
|
||
node("fetch", "tool", tool="db_query__shop",
|
||
args={"sql": "SELECT SUM(amount) AS gmv, COUNT(*) AS orders FROM orders"}),
|
||
node("write", "report", instructions="写周报", numbers="strict", on_violation="fail",
|
||
claims="require_citation"),
|
||
node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}], contract=contract),
|
||
]
|
||
return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
|
||
|
||
|
||
def contract_codes(graph: dict, level: str = "governed") -> tuple[list[str], list[str]]:
|
||
from app.engine.governance import lint_for_publish
|
||
from app.engine.schema import GraphSpec, validate_graph
|
||
|
||
spec = GraphSpec.model_validate(graph)
|
||
return ([i.code for i in validate_graph(spec).issues if i.level == "error"],
|
||
[i.code for i in lint_for_publish(spec, level=level).issues if i.level == "error"])
|
||
|
||
|
||
def test_a_cells_only_contract_needs_no_metrics_from():
|
||
validate, gate = contract_codes(cells_only())
|
||
assert validate == [], validate
|
||
assert gate == [], gate # 受管门禁整张图一条 error 都没有
|
||
|
||
|
||
def test_a_cited_contract_that_lists_required_metrics_still_needs_metrics_from():
|
||
validate, gate = contract_codes(cells_only(required=["gmv"]))
|
||
assert "contract.metrics_from_missing" in validate and "contract.metrics_from_missing" in gate
|
||
validate, gate = contract_codes(cells_only(expected=["gmv"]))
|
||
assert "contract.metrics_from_missing" in validate and "contract.metrics_from_missing" in gate
|
||
|
||
|
||
def test_a_narrative_contract_still_needs_metrics_from():
|
||
graph = cells_only(report_from=None, narrative="{{ nodes.write.text }}")
|
||
graph["nodes"][-1]["data"]["config"]["contract"].pop("report_from")
|
||
validate, _ = contract_codes(graph, level="published") # 受管级别不收 narrative,只看校验
|
||
assert "contract.metrics_from_missing" in validate
|
||
|
||
|
||
async def test_a_governed_formal_run_over_cells_only_issues_formally(client, monkeypatch):
|
||
Writer(monkeypatch, text="本周销售额 [[v:Q1.r0.gmv]]。")
|
||
row = await formal(client, cells_only(), status="governed")
|
||
assert row.status == "succeeded", row.error
|
||
issuance = row.output["_issuance"]
|
||
assert issuance["mode"] == "citations" and issuance["tier"] == "formal", issuance
|
||
assert issuance["unresolved"] == [] and issuance["gaps"] == [], issuance
|
||
[cell] = [m for m in issuance["matched"] if m.get("cell")]
|
||
assert cell["cell"] == "Q1.r0.gmv" and cell["token"] == "300.5"
|
||
|
||
|
||
def test_a_graph_with_a_caliber_card_still_needs_required_even_without_metrics_from():
|
||
graph = weekly(cells=True)
|
||
contract = graph["nodes"][-1]["data"]["config"]["contract"]
|
||
contract.pop("metrics_from"), contract.pop("required")
|
||
validate, gate = contract_codes(graph)
|
||
assert "contract.required_missing" in gate, gate # 不写 metrics_from 绕不过必需指标
|
||
assert validate == [], validate
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 没有口径卡、又没写 cells: true:受管门禁要拦下,发布弹窗里一键选
|
||
#
|
||
# 放开 metrics_from / required 之后留下的口子:这种契约以前被 metrics_from 挡着,现在能过门禁,
|
||
# 可受管正式运行里单元格引用一律不认,报告撰写节点(numbers strict、on_violation fail)每次都失败。
|
||
# 写不写 cells 要作者定(模型不许替人打开),所以修复是 choice:写 cells: true,或交给 Copilot 改成口径卡
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def _plan(graph: dict, level: str = "governed") -> dict[str, dict]:
|
||
from app.engine.autofix import plan_fixes
|
||
from app.engine.governance import publish_issues
|
||
from app.engine.schema import GraphSpec
|
||
|
||
spec = GraphSpec.model_validate(graph)
|
||
return {f["id"]: f for f in plan_fixes(spec, publish_issues(spec, level=level), level=level)}
|
||
|
||
|
||
def test_a_governed_graph_without_cards_must_declare_cells():
|
||
graph = cells_only()
|
||
graph["nodes"][-1]["data"]["config"]["contract"].pop("cells")
|
||
_, gate = contract_codes(graph)
|
||
assert gate == ["contract.cells_undeclared"], gate
|
||
_, gate = contract_codes(cells_only(cells=False))
|
||
assert gate == ["contract.cells_undeclared"], gate
|
||
_, gate = contract_codes(graph, level="published") # 已发布级别照样允许直接引用单元格
|
||
assert "contract.cells_undeclared" not in gate
|
||
|
||
|
||
def test_a_graph_with_a_caliber_card_does_not_need_cells():
|
||
_, gate = contract_codes(weekly()) # 有口径卡:数可以走 [[m:]],cells 是可选的
|
||
assert "contract.cells_undeclared" not in gate, gate
|
||
|
||
|
||
def test_the_fix_is_a_choice_and_picking_cells_is_not_loosening():
|
||
from app.engine.autofix import HANDOFF, apply_fixes
|
||
|
||
graph = cells_only()
|
||
graph["nodes"][-1]["data"]["config"]["contract"].pop("cells")
|
||
fid = "contract.cells_undeclared:out"
|
||
fix = _plan(graph)[fid]
|
||
assert fix["kind"] == "choice" and fix["field"] == "contract.cells"
|
||
assert [o["value"] for o in fix["options"]] == [True, HANDOFF]
|
||
assert [o.get("handoff") is True for o in fix["options"]] == [False, True]
|
||
assert apply_fixes(graph, [fid], level="governed")["applied"] == [] # 不替人选
|
||
out = apply_fixes(graph, [fid], {fid: True}, level="governed")
|
||
assert out["applied"] == [fid] and out["rejected"] == [], out["rejected"]
|
||
assert out["graph"]["nodes"][-1]["data"]["config"]["contract"]["cells"] is True and out["ok"] is True
|
||
out = apply_fixes(graph, [fid], {fid: HANDOFF}, level="governed")
|
||
assert out["handoff"] == [fid] and out["applied"] == []
|
||
|
||
|
||
def test_turning_cells_on_unasked_is_still_loosening():
|
||
from app.engine.autofix import forbidden_changes
|
||
|
||
before = cells_only()
|
||
before["nodes"][-1]["data"]["config"]["contract"].pop("cells")
|
||
after = cells_only()
|
||
[why] = forbidden_changes(before, after)
|
||
assert "单元格引用" in why
|
||
assert forbidden_changes(before, after, chosen={("out", "contract.cells")}) == []
|
||
|
||
|
||
def test_only_a_newly_raised_human_choice_error_is_let_through():
|
||
from app.engine.autofix import judge, worse
|
||
|
||
no_contract = {"level": "error", "code": "governed.no_contract", "node_id": "out", "message": "没有契约"}
|
||
cells = {"level": "error", "code": "contract.cells_undeclared", "node_id": "out", "field": "contract.cells",
|
||
"message": "要写 cells"}
|
||
other = {"level": "error", "code": "contract.strict_off", "node_id": "out", "message": "strict 关了"}
|
||
assert judge([no_contract], [cells]) is None # 补上契约、cells 留给人:算更好
|
||
assert worse([], [cells]) is None
|
||
assert "出现了新的问题" in (judge([no_contract], [other]) or "") # 别的新 error 照样作废
|
||
assert judge([no_contract], [cells, other]) is not None
|
||
# 原来就有的 cells 照常计数:修它的那一步,改完还在就是没修好
|
||
assert "仍然存在" in (judge([cells], [cells], target=("contract.cells_undeclared", "out")) or "")
|
||
assert judge([cells], [], target=("contract.cells_undeclared", "out")) is None
|
||
assert judge([cells], [cells]) == "修改后错误没有减少" # 原来就有、改完还在:Copilot 兜底不算修好
|