mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 05:21:57 +00:00
d176ff77ff
出处判定(每个指标的分类靠它): - 合并查询:读合并结果的指标按别名一路追到喂它的每一份上游快照(嵌套的合并也追),上游 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 处变异逐条打红验证,全部被测试抓到。
176 lines
10 KiB
Python
176 lines
10 KiB
Python
"""运行对比:同一个工作流的两次运行按「变化三分法」对照——数据变了、口径变了、还是模板 / 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
|