Files
AgentLab/backend/app/api/compare.py
T
ThearchyHelios 86e52192a9 运行对比后端(变化三分法):两次运行按指标拆清是数据变了、口径变了还是模板 / SQL 变了,数据变化按维度归因
GET /api/runs/{id}/compare/base 找默认基准:定时任务发起的先找同一定时任务更早结束的一次,否则同一工作流、
同一运行类别;只挑封存过的终态运行,找不到 404。GET /api/runs/{id}/compare?base= 对比两次运行:两次都要
已结束、封存且核对一致(照搬证据接口的封存判定),否则 409;不是同一个工作流、同一次运行 422。只读,不追加
事件、不落工件。

engine/compare.py 是纯函数:
- 查询按「节点 + 第几次 + 工具」配对,配不上再按规范化 SQL 配;normalize_sql 判 same / params_only /
  changed,结构变了的用 diff_facets 写出差在哪(时间范围排在最后);核查类查询不参与
- 口径卡同一节点按指标 id 配对,表达式、口径版本、小数位、单位、显示格式不同算口径变了
- 每个指标按 added/removed → 口径 → SQL 或上游配置 → 说不清 → 只参数 → 数据 的次序分类;输入落在快照上
  的直接比两边快照的 SQL,不落在快照上的看写它的节点和画布上游
- 口径变了时严格拆分:旧表达式配这次的输入用推演重算的还原和求值算 f_old(x_new),十进制精确相减,两效应之和
  逐位等于总变化;SQL 也变了、用到遮罩列、单位不同、旧叶子没记录的不拆并说明
- 维度归因按维度键配对两边的行、取变化最大的 10 个;度量之和等于输入的值(含合计行那一格)才给占比;截断、
  度量列遮罩照实写;遮罩列、日期列、每期都不同的列不当维度
2026-10-05 08:49:52 +08:00

172 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}尚未结束并封存:对比只用封存的运行记录,运行结束后才能对比"
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 not found.sealed or found.status not in _TERMINAL:
raise CodedHTTPException(409, UNSEALED_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