Files
AgentLab/backend/app/api/compare.py
T
ThearchyHelios d176ff77ff 运行对比复核修正(第 1 轮):输入的出处追到合并查询的上游、Agent 引用的列和行段,没出处的数、数据没变数却变了的不再报成数据变化
出处判定(每个指标的分类靠它):
- 合并查询:读合并结果的指标按别名一路追到喂它的每一份上游快照(嵌套的合并也追),上游 SQL 结构变了是 sql_changed,
  不拆、不归因;合并节点多接、少接了输入也算结构变化
- 图里写死的字面量改了('paid' 改成 'refunded'、系数、LIMIT):执行的 SQL 只差字面量,但图里的 SQL 改过,算模板变化;
  图里一字未改(定时运行代入的日期)才是参数。图里的 SQL 只改了空白、大小写的不算改过
- Agent 按出处交来的字段:还要引用同一列、同一行(段),两边都引用整份结果的不算换了;换了列、换了行段的说不清
  (incomparable),不再把没引用的区域算成「数据没了」去归因
- 模型、人直接交出的值(Agent 没按出处交、模型调用、人工审批……):值变了说不清是数据还是模型本身,判 incomparable;
  它自己查的库结构变了、配置变了照常标出
- 代码等确定性节点:上游的查询结果两次一模一样、交出的值却变了的,判 incomparable,不再说「变化来自上游数据」;
  上游只有运行输入变了的算参数
- 图级变化落到节点上:连线有增删的终点节点、默认设置改了且没有自行设置这几项的模型类节点,都算改了配置;
  「模板和取数的 SQL 都没有变化」只在整张图都没变时说
- 运行输入是列表、对象的,按还原出来的完整值比,长度不变、内容变了的是参数变化
- 同一节点按 SQL 配对时先配字面量也相同的:Agent 换了先后查东区、南区,两条各自配上自己,不再配成两条「只有参数不同」
- 一边记不出输入是哪个节点写的,说清是哪一边没记录(不再写出「None」)

口径与数值:
- 口径变了的指标,喂它的取数也变了、说不清的照样写进说明(只在一边才有的输入是改表达式带来的,不重复说);
  分类次序写进 compare() 的说明
- 千分位和不分组互换只影响显示,写进说明、不算口径变化;比率与否不同才算
- 升级前的口径卡没记小数位、显示格式:按那次运行图里的指标定义核对,定义也没有就不比,不再误报「小数位由 未设置 改为 1」
- 百分比:基准极小、这次很大时十进制量化放不下,退回浮点算,不再整个对比报 500
- 没设小数位的指标拆分:三个数按同一精度量化(最多 15 位有效数字),转成浮点交出去后两效应之和仍逐位等于总变化
- 维度归因标出用的是哪个输入、两边各自哪份快照(path、base_artifact、target_artifact)

接口:已经结束、只是没有封存清单的运行,409 说「没有封存清单,不能用于对比」,不再说「运行结束后才能对比」。

测试:补上复核指出的必测项(只一边对得上不给占比、只截断基准一侧、换了数据源、按旧口径小数位取整、写它的节点多查一次、
口径和 SQL 一起变不归因、小数位和比率变化、日期类型和常量列不当维度、合计行浮点误差、只改名称、结束过又接着运行的不当
基准),以及上面每一处修正的用例。32 处变异逐条打红验证,全部被测试抓到。
2026-10-05 10:20:29 +08:00

176 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""运行对比:同一个工作流的两次运行按「变化三分法」对照——数据变了、口径变了、还是模板 / SQL 变了(engine/compare.py)。
GET /api/runs/{run_id}/compare/base:默认拿哪一次来比,{base_run_id, reason, base}。这次运行是定时任务发起的,取同一
定时任务、结束得比它早的最近一次终态运行(reason: same_schedule);不是、或者同一任务还没有更早的,取同一工作流、
同一运行类别、结束得比它早的最近一次终态运行(reason: same_workflow)。只挑封存过的:没封存的拿来也比不了。
找不到 404。
GET /api/runs/{run_id}/compare?base={base_id}:{target, base, summary, template, metrics, queries, notes},每个字段
的形状见 engine/compare.py。两次运行都必须已结束、封存且封存核对一致,封存判定照搬证据接口(api/evidence._sealed):
没封存的运行事件还会变,封存核对不一致的事件被改过,拿它们比出来的结论都不作数,回 409(还没结束的和已经结束却没有
封存清单的分开说:后者再等也等不来封存)。不是同一个工作流、或者是同一次运行,回 422。
只读:两次运行都不追加事件、不落工件、不改运行记录;结果只交回给发请求的人。只认各自封存范围内的工件(证据接口的
白名单,加上封存的节点产出和指标集里写着的完整值,同推演),遮罩列(查询当时记下的加数据源现在设的)的值不出现在
结果里。读工件、比 SQL、重算都在线程里做:快照大的时候不挡住同一进程里别的请求。
路径挂在 /api/runs/{run_id}/… 下面;这个路由登记在 runs.router 之前,以后加 /api/runs/compare 这类固定路径也不会
被 /{run_id} 吃掉。定时任务结束后自动对比(期 4)直接调 default_base、compare_runs。
"""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from typing import Any
from fastapi import APIRouter
from sqlalchemy import select
from app.api.coded import CodedHTTPException
from app.api.evidence import _TERMINAL, RUN_NOT_FOUND, _Masks, _Sealed, _sealed, _sealed_cards
from app.api.whatif import _finished_before, _loader, _mask_of, _outputs_before, _returned
from app.db.base import SessionLocal
from app.db.models import DataSource, Run
from app.engine import compare as engine
from app.engine import recompute
router = APIRouter(prefix="/api/runs", tags=["compare"])
BAD_REQUEST = "compare_bad_request"
MISMATCH = "compare_mismatch"
UNSEALED = "compare_unsealed"
SEAL_BROKEN = "compare_seal_broken"
NO_BASE = "compare_no_base"
BAD_REQUEST_TEXT = "请求格式有误:base 应为作为对比基准的运行 ID"
BASE_NOT_FOUND = "作为对比基准的运行记录不存在"
SAME_RUN = "不能和同一次运行自己对比,请选择另一次运行作为基准"
UNSAVED = "{who}不属于任何已保存的工作流,无法确认两次运行是同一个工作流,不能对比"
OTHER_WORKFLOW = "两次运行不属于同一个工作流(「{target}」与「{base}」),不能对比"
UNSEALED_TEXT = "{who}尚未结束并封存:对比只用封存的运行记录,运行结束后才能对比"
#: 已经结束、只是没有封存清单(封存上线前结束的、结束时没封上的):再等也等不来封存,不能说「运行结束后才能对比」
NO_MANIFEST_TEXT = "{who}没有封存清单,不能用于对比"
SEAL_BROKEN_TEXT = "{who}的封存核对不一致:封存后有事件被修改、删除或插入,不能用于对比。可在记录页点「核对」查看核对结果"
NO_BASE_TEXT = ("没有找到可以对比的上一次运行:需要同一工作流、同一运行类别(定时任务发起的优先找同一定时任务)、"
"更早结束并已封存的运行")
TARGET = "这次运行"
BASE = "作为基准的运行"
@router.get("/{run_id}/compare/base")
async def compare_base(run_id: str) -> dict[str, Any]:
"""默认拿来对比的那一次运行:{base_run_id, reason: same_schedule | same_workflow, base}。"""
found = await default_base(run_id)
if found is None:
raise CodedHTTPException(404, NO_BASE_TEXT, NO_BASE)
run, reason = found
return {"base_run_id": run.id, "reason": reason, "base": run_ref(run)}
@router.get("/{run_id}/compare")
async def compare_with(run_id: str, base: str | None = None) -> dict[str, Any]:
"""这次运行和 base 那一次的对比,形状见 engine/compare.py。"""
if base is None or not base.strip():
raise CodedHTTPException(422, BAD_REQUEST_TEXT, BAD_REQUEST)
return await compare_runs(run_id, base.strip())
async def default_base(run_id: str) -> tuple[Run, str] | None:
"""(默认的基准运行, 怎么选的);找不到返回 None,这次运行本身不存在 404。
「早」按结束时间比:这次运行还没结束(还在跑、停在审批)的按开始时间。只挑终态、封存过的运行——没封存的拿来
对比只会得到 409。定时任务发起的先找同一定时任务(同一个工作流)的;它是这个任务的第一次时,退回同一工作流、
同一运行类别。没挂在已保存的工作流上的运行(画布上没保存就运行的图)没有可比的。"""
async with SessionLocal() as session:
run = await session.get(Run, run_id)
if run is None:
raise CodedHTTPException(404, "运行记录不存在", RUN_NOT_FOUND)
if not run.workflow_id:
return None
before = run.finished_at or run.started_at or run.created_at
query = (select(Run)
.where(Run.id != run.id, Run.workflow_id == run.workflow_id, Run.status.in_(_TERMINAL),
Run.manifest_hash.is_not(None), Run.finished_at.is_not(None), Run.finished_at < before)
.order_by(Run.finished_at.desc(), Run.created_at.desc()).limit(1))
if run.schedule_id:
found = (await session.execute(query.where(Run.schedule_id == run.schedule_id))).scalars().first()
if found is not None:
return found, "same_schedule"
found = (await session.execute(query.where(Run.run_class == run.run_class))).scalars().first()
return (found, "same_workflow") if found is not None else None
async def compare_runs(run_id: str, base_id: str) -> dict[str, Any]:
"""对比两次运行(run_id 是这次,base_id 是基准)。拒绝时抛 CodedHTTPException:运行不存在 404,同一次运行、
不是同一个工作流 422,没结束封存、封存核对不一致 409。"""
async with SessionLocal() as session:
target = await session.get(Run, run_id)
base = await session.get(Run, base_id)
kinds = {str(name): kind for name, kind in (await session.execute(select(DataSource.name, DataSource.kind)))}
if target is None:
raise CodedHTTPException(404, "运行记录不存在", RUN_NOT_FOUND)
if base is None:
raise CodedHTTPException(404, BASE_NOT_FOUND, RUN_NOT_FOUND)
if base.id == target.id:
raise CodedHTTPException(422, SAME_RUN, MISMATCH)
for run, who in ((target, TARGET), (base, BASE)):
if not run.workflow_id:
raise CodedHTTPException(422, UNSAVED.format(who=who), MISMATCH)
if target.workflow_id != base.workflow_id:
raise CodedHTTPException(422, OTHER_WORKFLOW.format(target=target.workflow_name, base=base.workflow_name),
MISMATCH)
sealed = []
for run, who in ((target, TARGET), (base, BASE)):
found = await _sealed(run.id)
if found.status not in _TERMINAL:
raise CodedHTTPException(409, UNSEALED_TEXT.format(who=who), UNSEALED)
if not found.sealed:
raise CodedHTTPException(409, NO_MANIFEST_TEXT.format(who=who), UNSEALED)
if not found.trusted:
raise CodedHTTPException(409, SEAL_BROKEN_TEXT.format(who=who), SEAL_BROKEN)
sealed.append(found)
masks = _Masks()
target_side = await _side(target, sealed[0], masks, kinds)
base_side = await _side(base, sealed[1], masks, kinds)
return await asyncio.to_thread(engine.compare, base_side, target_side)
def run_ref(run: Run) -> dict[str, Any]:
"""一次运行的概要(RunRef):对比结果和默认基准里都用这一个形状。时间带时区(UTC)。"""
return {"id": run.id, "workflow_name": run.workflow_name, "version": run.version, "run_class": run.run_class,
"started_at": run.started_at.isoformat() if run.started_at else None,
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
"schedule_id": run.schedule_id}
async def _side(run: Run, sealed: _Sealed, masks: _Masks, kinds: dict[str, Any]) -> engine.Side:
"""一次运行交给对比的东西(engine.compare.Side):封存范围内的事件、复验过哈希的口径卡、只认封存范围内工件的
读法、每份查询快照要遮的列,以及按口径卡那次执行还原输入用的 Sources。"""
cards = await asyncio.to_thread(_sealed_cards, sealed)
loader = _loader(sealed, cards)
# 遮罩要查库(数据源现在设的),在这里每份快照算一遍交进去,对比本身在线程里不碰库。快照先在线程里读好,
# 同一个 loader 记着读过的,对比时不再读盘
snaps = await asyncio.to_thread(_read_all, loader, list(sealed.queries))
masked = {artifact: frozenset(await _mask_of(snaps.get(artifact), info, masks))
for artifact, info in sealed.queries.items()}
returned = _returned(sealed)
def sources(started: int) -> recompute.Sources:
return recompute.Sources(load=loader, queries=tuple(sealed.queries), outputs=_outputs_before(sealed, started),
masked=masked, returned=returned, finished=_finished_before(sealed, started))
return engine.Side(run=run_ref(run), graph=sealed.graph, events=sealed.events, cards=cards, load=loader,
masked=masked, sources=sources, dialects=kinds, inputs=dict(run.input or {}))
def _read_all(loader: Callable[[str], Any], artifacts: list[str]) -> dict[str, Any]:
"""读出这几份查询快照:读不出来、被改过的记 None(遮罩照样按台账记的数据源算)。"""
out: dict[str, Any] = {}
for artifact in artifacts:
try:
content = loader(artifact)
except ValueError:
content = None
out[artifact] = content if isinstance(content, dict) else None
return out