Files
AgentLab/backend/app/apps/backfill.py
T
ThearchyHelios b3dcddb52e 召回复核修正(后端):人工节点不再挡召回、读不出召回卡记失败、旧版不再接着算、团队渠道一次召回只推一条、目录核对按数据源合并排队;账本接口带上召回标记,对账结果可以反馈
- W4R-01:召回只查新版图里会不会写外部系统(apps/backfill 抽出 graph_external_write,graph_side_effect 复用它)。召回图只有两版
  的取数和口径卡,图里别处的人工节点、按流程使用的问题碰不到它;口径卡上游的人工节点 derive 本来就认作数据部分不能复用。
  「按新版重新出具」走完整的正式运行,仍按 graph_side_effect 全套拒绝。原来带审批的口径升版后一组都不召回,L6 对它们失效。
- W4R-09:收尾时召回卡读不出、或缺了某个比较的旧值 / 新值,照实记重算失败(读不出召回卡),原答案一个不动。原来两边都是空,
  按渲染比被当成「没变」,给每个原答案永久盖上「已核对,不影响」,收过尾之后再也不核对。
- W4R-12:这一版已经不是当前发布版(之后又发布了更新的版本)时不再发起召回、不给接着算,批次里写 continue_reason,接着算的
  接口 409 带同一句。同启动时接着做只认当前发布版:新版本的召回覆盖更早的所有版本,再按旧版开「口径升到 vN」的待办只是添乱。
- W4R-13:开登录时一组答案几个主人就几条待办,全局渠道却按这一批(recall.batch_of_key:同一次召回、同一个来由)只在最早开的
  那条上推一次,答案、待办按这一批汇总,几条的链到待办箱;别的几条连 task.opened 也不再推全局渠道。个人渠道照旧每人一条。
- W4R-11:目录审阅之后的核对按数据源排队、批量审阅的几张表合成一遍(catalog_pass,同一数据源一把锁);查询快照按数据源在库里
  筛、按运行结束时间分页、每张表凑够 BASIS_MAX_RUNS 次运行就停;冻结的表结构快照这一遍里按工件 id 只读一次。原来批量审阅
  几十张表就同时起几十个后台任务,每个都全表扫查询快照、反复读同一份几 MB 的表结构快照。
- W4R-03(本包那一半):GET /api/runs/{id}/ledger 加 recall:checked(已按 vN 核对,不影响)、basis(依据有变:哪一种、几项、
  新出现几条错误级问题)、tasks(看的这个人手上说的是这个答案、还没处理的 recall 待办)。不放数、不放表名。几种状态都带。
- W4R-10(本包那一半):新事件 ledger.feedback(对账运行上的封存后事件),POST /api/ledger/answers/{run}/reconcile/{rid}/feedback
  {verdict: useful | unclear} 记反馈,同一个人改主意只算最后一次(app/ledger/feedback.py);账本接口的 reconciled 每一项带
  feedback {mine, useful, unclear};统计 E2 的 feedback 数到它,去掉「第 4 波接上」的占位说法。离线核对页、封存后事件摘要
  都认得这个事件。

测试:test_ledger_recall 新增七个用例、改了人工节点那一个(召回照算、重新出具照实拒绝);新增 test_ledger_feedback;
test_ledger_api 的 E2 断言跟上。每个修复都先写失败的测试、去掉修复回跑确认抓得住。后端全量 14237 通过。
2026-10-16 22:16:58 +08:00

283 lines
16 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.
"""按新口径补算(D-f;设计文档 §3.4 趋势那一行、§2.10 X3 的 app_backfill)。
钉住项升版时选了 recompute(按新口径重算),趋势只画新版本的数:升版之后才有的那一两期孤零零的,历史全空着。补算就是
把旧版本在同一来源上算过的那几期,按新版本重新发起一遍正式运行:
- 只对读定时任务或正式运行的视图(latest_sealed):同一来源才说得清「那几期」是哪几期;
- 只在这个视图的钉住项最近一次升版选了 recompute 时:选 dual、incomparable 的本来就照旧画旧版本的数;
- 只补没有外部副作用的工作流(_side_effect):钉作流程的、图里(含子工作流)有人工节点的、调用工具节点绑了写外部系统
能力的、按流程使用会出事(process.*:Agent 中途等审批、联网的代码节点)的一律拒。补算一次最多发起 SERIES_MAX 次正式
运行,对着历史输入逐个给人开待办、往外部系统重发写入,这些都收不回来,生效记录还会挡住以后删运行、删工作流;
- 取旧版本在同一来源(同一个定时任务,或 formal)上最近 N 期已封存的正式运行的输入(N 不超过视图上趋势的 last,
最多 SERIES_MAX)。旧版本自己补算过的那几期也算(trend_runs):连着升过两次版的,历史一样接得上;
- 新版本已经算过同一个输入的跳过:补算过的(没失败的),以及来源本身已经按新版本跑过的那一期;
- 用新版本逐个发起正式运行:来源 app_backfill、按钉住的 graph_hash 核对(expect_hash)、带 app_id / app_version,
origin 里记补的是哪个来源、哪一次、那一期的时间(period_at),趋势按它排(resolve.period_of)。
补算的运行和别的正式运行一样封存、一样能点开证据;它不是「最近一次」(resolve.sealed_runs 不取它),只是趋势上的点。
同一个视图的补算在进程内串行(_lock):锁里重新算一遍计划再发起,并发点两次、确认框弹着时别人先补过的,后来的那次
只会跳过。部署是单进程(调度器、运行管理都在进程里),进程内的锁就够。
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.apps.pins import load_pins
from app.apps.resolve import (BACKFILL_ORIGIN, SERIES_MAX, input_key, period_of, pin_upgrade, sealed_runs,
trend_runs)
from app.apps.tree import components
from app.db.models import Run
from app.db.models_apps import AppVersion
#: 补算被拒的机器码
BACKFILL_REFUSED = "app_backfill_refused"
#: 趋势没写 last 时取几期(同 resolve._series)
SERIES_DEFAULT = 12
_LIVE = ("queued", "running", "interrupted", "succeeded")
class BackfillRefused(Exception):
def __init__(self, status_code: int, message: str, code: str | None = BACKFILL_REFUSED) -> None:
super().__init__(message)
self.status_code = status_code
self.message = message
self.code = code
@dataclass
class Plan:
view: str
alias: str
source: str
from_version: int
to_version: int
#: 这个视图上的趋势最多画几期
periods: int
#: 要发起的:旧版本那几期的运行,按输入期从早到晚
todo: list[Run] = field(default_factory=list)
skipped: int = 0
def out(self) -> dict[str, Any]:
return {"view": self.view, "alias": self.alias, "source": self.source, "from_version": self.from_version,
"to_version": self.to_version, "periods": self.periods, "will_launch": len(self.todo),
"will_skip": self.skipped}
def series_periods(spec: dict[str, Any], view_id: str) -> int:
"""这个视图上的趋势最多画几期:各趋势组件 last 的最大值(没写按 SERIES_DEFAULT),不超过 SERIES_MAX。没有趋势组件
返回 0。"""
wanted = 0
for comp, _, _ in components(spec):
bind = comp.get("bind") if isinstance(comp.get("bind"), dict) else None
if not bind or bind.get("view") != view_id or "series" not in bind:
continue
last = bind.get("last")
n = last if isinstance(last, int) and not isinstance(last, bool) and last > 0 else SERIES_DEFAULT
wanted = max(wanted, n)
return min(wanted, SERIES_MAX)
async def _upgrade(session: AsyncSession, app: Any, version: AppVersion, view_id: str
) -> tuple[dict[str, Any], Any, dict[str, Any], str] | None:
"""(视图来源, 新版本的钉住状态, 升版前的钉住项, 来源);这个视图不需要补算时返回 None。拒绝的情形抛 BackfillRefused。"""
spec = version.spec or {}
view = (spec.get("views") or {}).get(view_id)
src = (view or {}).get("source") if isinstance(view, dict) else None
if not isinstance(src, dict):
raise BackfillRefused(404, f"应用里没有视图「{view_id}」", "app_view_not_found")
if src.get("kind") != "latest_sealed":
raise BackfillRefused(409, "只有读定时任务或正式运行的视图能按新口径补算:按需运行、实例视图没有「历次」可补")
alias = str(src.get("pin") or "")
previous, disposition = await pin_upgrade(session, app.id, version, alias)
if previous is None or disposition != "recompute":
return None
state = (await load_pins(session, spec)).get(alias)
if state is None or not state.usable:
raise BackfillRefused(409, "视图固定的工作流版本已退役或不可用,应用需要升级后才能补算", "app_needs_upgrade")
if reason := await _side_effect(session, state):
raise BackfillRefused(409, f"「{state.label}」{reason},补算会对历史输入逐个重做这些事、无法撤回,这类工作流不能"
"按新口径补算")
return src, state, previous, str(src.get("from") or "formal")
async def _side_effect(session: AsyncSession, state: Any) -> str | None:
"""新版本的图补算时会不会碰到人或外部系统:会的交回一句原因(接在「「周报 v3」」后面),不会的返回 None。
作为流程固定的一律算;别的按图判(graph_side_effect)。"""
if state.pin.get("use") == "process":
return "是作为流程固定的"
return await graph_side_effect(session, state.graph, level=state.level)
async def graph_side_effect(session: AsyncSession, graph_json: Any, *, level: str | None = None) -> str | None:
"""一张图替人重跑一次会不会碰到人或外部系统:会的交回一句原因(接在「「周报 v3」」后面),不会的返回 None。按新口径
补算(这里)和按口径正式出具(ledger/issue.py:自动出具、一键出具都不代人做这些事)共用这一个判断。
判断沿用应用检查对流程钉住项的那一套(apps/lint._lint_process):人工节点、写外部系统的能力
(capabilities.registry.writes_externally,和运行时、发布检查同一个判断)、process.* 问题(子工作流先读好,
process.human_in_subgraph 才查得到)。level 是那一版发布时的级别(门禁按它查),没有按 published。"""
from app.engine.governance import publish_issues
from app.engine.lint_process import load_subgraphs, with_subgraphs
from app.engine.schema import GraphSpec, NodeType
try:
graph = GraphSpec.model_validate(graph_json)
except ValueError:
return "的图无法解析"
if humans := [n.title for n in graph.nodes if n.type == NodeType.HUMAN]:
return f"里有人工节点「{'、'.join(humans[:3])}」"
if reason := await _external_write(session, graph):
return reason
with with_subgraphs(await load_subgraphs(session, graph)):
issues = publish_issues(graph, level=level or "published")
if process := [i for i in issues if (i.code or "").startswith("process.")]:
return f"按流程使用不安全:{process[0].message.rstrip('。')}"
return None
async def graph_external_write(session: AsyncSession, graph_json: Any) -> str | None:
"""一张图里有没有调用工具节点会写外部系统(graph_side_effect 里的那一条):有的交回一句原因,没有返回 None。
口径召回(ledger/recall)只用这一条:召回图是从口径卡往上派生的取数和口径卡,图里别处的人工节点、按流程使用的问题
碰不到它(细案做法 7 不重算的只有缺入口字段、数据部分不能复用、会写外部系统三种);替人整张图重跑的补算、按口径出具
仍然走 graph_side_effect 全套。"""
from app.engine.schema import GraphSpec
try:
graph = GraphSpec.model_validate(graph_json)
except ValueError:
return "的图无法解析"
return await _external_write(session, graph)
async def _external_write(session: AsyncSession, graph: Any) -> str | None:
from app.capabilities.registry import effective_effect, get_capability, writes_externally
from app.engine.schema import NodeType
for node in graph.nodes:
name = node.config.get("tool") if node.type == NodeType.TOOL else None
if not isinstance(name, str) or not name:
continue
cap = await get_capability(session, name)
if cap is not None and writes_externally(cap.name, effective_effect(cap), cap.endpoint_id):
return f"的「{node.title}」会写外部系统({name})"
return None
async def plan(session: AsyncSession, app: Any, version: AppVersion, view_id: str) -> Plan:
"""这个视图补算会发起几次、跳过几次(不发起任何运行)。"""
found = await _upgrade(session, app, version, view_id)
if found is None:
raise BackfillRefused(409, "这个视图固定的版本最近一次升版没有选「按新口径重算」,不需要补算")
src, state, previous, source = found
periods = series_periods(version.spec or {}, view_id)
if periods <= 0:
raise BackfillRefused(409, "这个视图上没有趋势组件,补算出来的历史没有地方展示")
out = Plan(view=view_id, alias=state.alias, source=source, from_version=int(previous.get("version")),
to_version=int(state.pin["version"]), periods=periods)
origin = src.get("from")
old_runs = await trend_runs(session, app.id, previous.get("workflow_id"), previous.get("version"),
previous.get("graph_hash"), origin, periods)
done = await _computed(session, app.id, state, origin, source)
for run in old_runs:
key = input_key(run.input)
if key in done:
out.skipped += 1
continue
done.add(key)
out.todo.append(run)
return out
async def _computed(session: AsyncSession, app_id: str, state: Any, origin: str | None, source: str) -> set[str]:
"""新版本已经算过(或正在算)的输入:这个应用为同一来源补算过、没失败的;来源本身按新版本跑过的。"""
pin = state.pin
keys = {input_key(r.input) for r in await sealed_runs(session, pin["workflow_id"], pin["version"],
pin.get("graph_hash"), origin, SERIES_MAX * 2)}
rows = (await session.execute(select(Run.input, Run.origin).where(
Run.app_id == app_id, Run.origin_kind == BACKFILL_ORIGIN, Run.workflow_id == pin["workflow_id"],
Run.version == pin["version"], Run.version_hash == pin.get("graph_hash"), Run.status.in_(_LIVE)))).all()
keys |= {input_key(inputs) for inputs, meta in rows if (meta or {}).get("source") == source}
return keys
async def run_backfill(session: AsyncSession, app: Any, version: AppVersion, view_id: str, *, principal: Any,
actor: str | None) -> dict[str, Any]:
"""按计划逐个发起正式运行。返回 {launched, skipped, run_ids, ...plan}。
一次也没发起成(发起被拒,比如口径升版没处置)时原样抛 LaunchRefused;发起了几次之后被拒的,已经发起的照常算,
原因写进 refused 交回。"""
async with _lock(app.id, view_id):
return await _run_backfill(session, app, version, view_id, principal=principal, actor=actor)
#: (应用 id, 视图) → 补算锁。只增不减:一个应用的视图就那么几个
_LOCKS: dict[tuple[str, str], asyncio.Lock] = {}
def _lock(app_id: str, view_id: str) -> asyncio.Lock:
return _LOCKS.setdefault((app_id, view_id), asyncio.Lock())
async def _run_backfill(session: AsyncSession, app: Any, version: AppVersion, view_id: str, *, principal: Any,
actor: str | None) -> dict[str, Any]:
from app.engine.launch import LaunchRefused, launch_run
# 计划在锁里算:前一次补算发起的运行已经提交,这里读得到、按「已经补算过」跳过
todo = await plan(session, app, version, view_id)
pin = (await load_pins(session, version.spec or {}))[todo.alias].pin
run_ids: list[str] = []
refused: str | None = None
for old in todo.todo:
period = period_of(old)
try:
run = await launch_run(
session, workflow_id=pin["workflow_id"], run_class="formal", version=pin["version"],
expect_hash=pin.get("graph_hash"), input_payload=dict(old.input or {}), actor=actor,
origin_kind=BACKFILL_ORIGIN, app_id=app.id, app_version=version.version,
origin={"view": view_id, "spec_hash": version.spec_hash, "source": todo.source,
"backfill_of": old.id, "from_version": todo.from_version,
"period_at": period.isoformat() if period else None,
"actor_verified": bool(getattr(principal, "verified", False)),
"auth_mode": getattr(principal, "mode", "off")})
except LaunchRefused as e:
if not run_ids:
raise
refused = e.message
break
run_ids.append(run.id)
from app.apps.audit import record
await record(session, action="app.backfill", subject_kind="app", subject_id=app.id, actor=actor,
data={**todo.out(), "launched": len(run_ids), "run_ids": run_ids, "app_version": version.version})
await session.commit()
return {**todo.out(), "launched": len(run_ids), "skipped": todo.skipped, "run_ids": run_ids,
**({"refused": refused} if refused else {})}
async def backfillable(session: AsyncSession, app: Any) -> list[dict[str, Any]]:
"""当前发布版里能按新口径补算的视图,各自会发起几次(编辑器、应用页上的「按新口径补算历史」按钮用)。只读。"""
if not app.published_version:
return []
version = (await session.execute(select(AppVersion).where(
AppVersion.app_id == app.id, AppVersion.version == app.published_version))).scalar_one_or_none()
if version is None:
return []
out: list[dict[str, Any]] = []
for view_id, view in ((version.spec or {}).get("views") or {}).items():
source = (view or {}).get("source") if isinstance(view, dict) else None
if not isinstance(source, dict) or source.get("kind") != "latest_sealed":
continue
try:
found = await _upgrade(session, app, version, view_id)
if found is None or series_periods(version.spec or {}, view_id) <= 0:
continue
out.append((await plan(session, app, version, view_id)).out())
except BackfillRefused:
continue
return out