mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 01:51:57 +00:00
4e3623885b
# Conflicts: # backend/app/api/schedules.py # backend/app/main.py
1811 lines
100 KiB
Python
1811 lines
100 KiB
Python
"""口径召回:口径升版、目录事实被驳回或修改、表结构变动之后,追回已经交付的答案(docs/design/next-round/02-口径账本.md
|
||
做法 7,拍板 L6、L7;总方案 X1、X3、X6)。
|
||
|
||
**一、口径升版。** 工作流 W 发布了新版本 N(api/workflows.commit_publish 提交之后在后台排队,失败不影响发布):
|
||
|
||
- 反查(ledger/uses):W 的旧版本 V < N 被哪些问数回答、正式运行、固定了它的工作流、定时任务、应用、导出用过。旧版本里
|
||
没有源头口径卡(metrics 节点、不是转抄别处的 caliber_from)的不是口径,不召回。
|
||
- 分组:按(旧版本、新版本、input_key(参数))去重成组。参数先按旧版本入口字段整理(只留入口有的字段、空的补旧版的缺省值),
|
||
这样问数回答绑定出的 {start, end} 和定时任务的 {start, end, store: all} 是同一组。组按最近一次用到的先后排。
|
||
- 每组一次召回运行(来源 recall,探索运行,系统派生的运行不进使用统计):派生图经 ledger/derive.recall_graph(全站唯一的
|
||
派生图生成器)——入口(这一组的参数)→ 旧版取数 + 旧版口径卡、新版取数 + 新版口径卡(caliber_from.nodes 改名,不撞 id)
|
||
→ 召回卡(recall_card:每个两版都有的指标三个数:旧版、新版、两版之差;差值在卡里算,叙述层无算术)→ 出口(契约
|
||
metrics_from 指向召回卡;不加报告、不调模型)。两版在同一次运行里查同一份数据,差值只反映口径;数据随时间的变化另外拿
|
||
原答案当时的值对照着说(task_payload 的 original)。
|
||
- 不能重算的照实记「无法重算:原因」,不发起:口径的数据部分不能复用(derive.NotDerivable)、新版里没有这张口径卡、两版
|
||
没有同名的指标、新版入口要的参数这一组里没有或不合规(compose.check_inputs 同一套校验)、新版的图里会写外部系统
|
||
(apps/backfill.graph_external_write,和按新口径补算、按口径出具同一个判断)。新版图里别处有人工节点、按流程使用不安全的
|
||
照样重算:召回图只有两版的取数和口径卡,碰不到它们(口径卡上游的人工节点 derive 已经认作数据部分不能复用);「按新版
|
||
重新出具」走完整的正式运行,仍按 graph_side_effect 全套拒绝(ledger/issue.check_target)。
|
||
- 上限:每次发布最多自动重算 ledger.recall_limit 组(设置,缺省 50;0 表示不自动重算,只列出受影响的答案)。超出的写
|
||
「另有 M 组未重算」,在版本历史里点「接着算」手动接着发起下一批(每批同样的组数,上限是 0 时按缺省值)。
|
||
- **计划从已有的召回运行重算**(照 apps/backfill 的写法,不建任务表):一组算没算过,看有没有 origin 指向(W、V、N、
|
||
参数指纹)的召回运行;不能重算的每次按同样的规则重新判出来。幂等:重复排队、服务重启之后重算一遍计划,已经算过的跳过。
|
||
启动时(resume)把最近 RESUME_DAYS 天发布、自动那一批没发完的接着发完,结束了还没收尾的召回运行补上收尾。同一个(W、N)
|
||
的计划和发起在进程内串行(单进程部署,同 apps/backfill 的锁)。
|
||
- **收尾**(settle,召回运行结束时 runner 调):读召回卡(封存范围内的指标集),每个指标按它的单位、小数位渲染的旧值和新值
|
||
不同就是数变了。数变了:给这一组答案的主人开 recall 待办(挂在召回运行上;开登录时每个主人一条,只列他的答案;关登录时
|
||
一条派给任何人),推 ledger.recall 通知。数没变:只记账——在每个原答案的运行上追加 ledger.recalled(「已按 vN 核对,
|
||
不影响」)。召回运行自己也追加一条 ledger.recalled 记结论(只记变没变、几个答案、几条待办,不记数),它同时是「收过尾」的
|
||
记号。召回运行没成功的、成功了却读不出召回卡(或缺了某个比较的旧值、新值)的照实记重算失败,原答案一个不动。
|
||
|
||
**二、目录事实被驳回或修改**(只管影响数字的那一组:有效记录条件、去重、业务日期、关联、业务主键、度量类型,同
|
||
data/catalog_review.FIELD_GROUP 的 facts):审阅写入之后(data/catalog 的审阅、人工编辑、保存提案,catalog_review 的批量审阅)
|
||
在后台找出查过这张表、而且查询快照冻结的目录里看得到这一项的探索运行,追加 ledger.basis_changed(kind=catalog),界面标
|
||
「依据有变」;对这些运行执行过的 SQL 按新目录跑一遍 SQL 检查(data/sqlcheck,纯计算),比冻结目录下多出错误级问题的才开
|
||
一条 recall 待办(reason=catalog,只有「知道了」)通知答案的主人(拍板 L7:不自动重问)。同一个数据源排着做、批量审阅的几张表
|
||
合成一遍(catalog_pass):查询快照按数据源在库里筛、凑够就停,冻结的表结构快照(大库的能有几 MB)同一份只读一次。
|
||
|
||
**三、表结构变动**:消费 data/source_changes(list_changes(since_seq) 按 seq 游标拉,游标记在设置表里;探查、剖析任务结束、
|
||
处理变化改了目录时触发)。只看 triggers_recall 认的:表或列被删、列的类型变了标「依据的表结构已变」(kind=structure);
|
||
触及有效记录条件的新码值(values)、处理时改了目录的(catalog_edit)标依据有变,同二按新目录跑 SQL 检查。表结构变了的,
|
||
受影响的口径(最新的已发布版本里查这张表的口径)按最近一次正式运行的输入派生数据部分试算一次(召回运行,origin.probe;
|
||
这一版还没有正式运行的退回最近一次等价于它的问数回答的参数,再没有用入口的缺省值;参数凑不齐的不试算),算不出来才开
|
||
recall 待办(reason=structure)通知发布人。
|
||
|
||
**通知**:ledger.recall 只推口径升版、数真变了的那一种待办(消息照 notify/service 的规矩:标题、状态、条数、链接,不放
|
||
新旧值和差值);目录和表结构的两种待办照别的待办推 task.opened。全局渠道按一批待办(一次召回开给几个主人的那几条,
|
||
batch_of_key)只推一次,个人渠道每个主人各一条(notify/service.notify_recall)。
|
||
|
||
**答案上的标记**(answer_marks):账本接口 GET /api/runs/{id}/ledger 的 recall——已按 vN 核对、依据有变、看的这个人手上说的
|
||
是这个答案的 recall 待办,给问数据的轮次和证据面板「口径」一步。
|
||
|
||
**自动的那几样**(发布后自动那一批、探查剖析之后拉表结构变化、启动时接着做)和调度循环同一个开关(automatic,
|
||
AGENTLAB_SCHEDULER):它们是在后台替人查业务库,测试、拷来的数据目录(检查脚本)不做;关着时版本历史里照样列出受影响的
|
||
答案、能手动接着算,审阅之后标依据有变照做(只读应用库)。
|
||
|
||
只读:召回运行带 READ_ONLY,只查库、不写;不调模型。召回是辅助步骤:出错只记日志,不影响发布、审阅和探查。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
import weakref
|
||
from collections.abc import Iterable, Mapping, Sequence
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime, timedelta, timezone
|
||
from typing import Any
|
||
|
||
from sqlalchemy import func, not_, select, update
|
||
|
||
from app.core.events import EventType
|
||
from app.db.base import SessionLocal, utcnow
|
||
from app.db.models import Run, RunEvent, Workflow
|
||
from app.db.portable import json_eq, json_flag, json_text
|
||
from app.ledger import uses as ledger_uses
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
ORIGIN = "recall"
|
||
#: 召回待办的种类(process/tasks.RECALL_KIND)和三种来由
|
||
TASK_KIND = "recall"
|
||
REASONS = ("caliber", "catalog", "structure")
|
||
#: 发起的批次:发布后自动的、版本历史里手动接着算的
|
||
BATCHES = ("auto", "manual")
|
||
#: 一组的结论
|
||
OUTCOMES = ("changed", "unchanged", "row_order", "failed", "running", "pending", "not_recomputable")
|
||
#: 启动时接着做最近几天发布的
|
||
RESUME_DAYS = 7
|
||
#: 目录、表结构变了时最多看多少次探索运行(新的在前)
|
||
BASIS_MAX_RUNS = 200
|
||
#: 设置表里记表结构变化游标的键(通知自己记的账同 notify_state:* 的写法,设置接口不列)
|
||
CURSOR_KEY = "ledger_state:recall_changes"
|
||
#: 被拒的机器码
|
||
REFUSED = "ledger_recall_refused"
|
||
|
||
#: 不能重算的原因
|
||
NOT_CALIBER = "这一版里没有源头口径卡,不是口径,不召回"
|
||
NOT_REUSABLE = "口径的数据部分不能复用:{reason}"
|
||
CARD_GONE = "新版 v{version} 里没有口径卡「{card}」"
|
||
NO_COMMON = "新旧两版的口径卡没有同名的指标,比不了"
|
||
SIDE_EFFECT = "「{name}」v{version}{reason},召回不代人做这些事"
|
||
NEW_INPUTS = "新版 v{version} 的入口要的参数这一组里没有或不合规:{problem}"
|
||
NEW_GONE = "新版本不存在,可能已被删除"
|
||
LAUNCH_REFUSED = "没能发起召回运行:{reason}"
|
||
RUN_FAILED = "重算失败:{reason}"
|
||
#: 召回运行成功了,召回卡(或其中某个比较的旧值、新值)却读不出来:照实算重算失败,不当成数没变
|
||
CARD_UNREADABLE = "读不出召回卡"
|
||
#: 这一版已经不是当前发布版:不再接着算(新版本的召回本来就覆盖更早的所有版本,同 resume 只接着做当前发布版)
|
||
SUPERSEDED = "已有更新的发布版 v{latest},召回在 v{latest} 那一版接着看"
|
||
NOT_CURRENT = "v{version} 已不是当前发布版,召回不再接着算"
|
||
#: 收尾
|
||
CHANGED_TITLE = "口径「{name}」升到 v{version},答案的数变了"
|
||
#: 全部指标行序不定、核对不了时的待办标题(settle 的 row_order)
|
||
ROW_ORDER_TITLE = "口径「{name}」升到 v{version},答案里的数行序不定、没能核对,请人工核对"
|
||
CATALOG_TITLE = "答案依据的数据目录有变:按新目录检查出了新的问题"
|
||
STRUCTURE_TITLE = "口径「{name}」v{version} 依据的表结构变了,试算没有算出来"
|
||
#: 依据有变的四种(ledger.basis_changed 的 kind)
|
||
BASIS_KINDS = ("catalog", "structure", "values", "catalog_edit")
|
||
#: 目录里影响数字的那一组(同 data/catalog_review.FIELD_GROUP 的 facts)
|
||
FACT_SLOTS = (("t", "valid_filter"), ("t", "dedup"), ("t", "business_date"), ("t", "keys"), ("c", "measure"))
|
||
|
||
|
||
class RecallRefused(Exception):
|
||
def __init__(self, status_code: int, message: str, code: str = REFUSED) -> None:
|
||
super().__init__(message)
|
||
self.status_code = status_code
|
||
self.message = message
|
||
self.code = code
|
||
|
||
|
||
# ==========================================================================
|
||
# 计划
|
||
# ==========================================================================
|
||
|
||
|
||
@dataclass
|
||
class Pair:
|
||
"""一对口径卡:旧版那一张、新版同 id 的那一张。"""
|
||
|
||
old: Any
|
||
new: Any
|
||
|
||
|
||
@dataclass
|
||
class Group:
|
||
"""(旧版本、新版本、参数)一组:这一组的用法、能不能重算、算过没有。"""
|
||
|
||
old_version: int
|
||
new_version: int
|
||
key: str
|
||
binding: dict[str, Any]
|
||
uses: list[ledger_uses.Use] = field(default_factory=list)
|
||
latest: datetime | None = None
|
||
problem: str | None = None
|
||
#: 发起召回运行用的输入(按新版入口校验过、补了缺省值;旧版独有的字段接在后面)
|
||
inputs: dict[str, Any] = field(default_factory=dict)
|
||
run: Run | None = None
|
||
refused: str | None = None
|
||
|
||
@property
|
||
def id(self) -> str:
|
||
return f"{self.old_version}-{self.key[:16]}"
|
||
|
||
|
||
@dataclass
|
||
class Plan:
|
||
workflow_id: str
|
||
workflow_name: str
|
||
new_version: int
|
||
#: 工作流现在的发布版(不是 new_version 的不再接着算,见 stale)
|
||
published_version: int | None = None
|
||
groups: list[Group] = field(default_factory=list)
|
||
#: 每个旧版本的口径卡对、旧版本自己的问题(不是口径、数据部分不能复用……)
|
||
pairs: dict[int, list[Pair]] = field(default_factory=dict)
|
||
version_problem: dict[int, str] = field(default_factory=dict)
|
||
found: ledger_uses.Found | None = None
|
||
problem: str | None = None
|
||
#: 自动那一批已经发起了几组
|
||
auto_launched: int = 0
|
||
|
||
def todo(self) -> list[Group]:
|
||
"""还没算、能算的组(按最近一次用到的先后)。"""
|
||
return [g for g in self.groups if g.run is None and g.problem is None]
|
||
|
||
def stale(self) -> str | None:
|
||
"""这一版已经不是当前发布版时不再发起(手动接着算也不)的原因:之后又发布了更新的版本,新版本的召回覆盖更早的所有
|
||
版本(同 resume 只接着做当前发布版),再按这一版开「口径升到 vN」的待办只是添乱。是当前发布版为 None。"""
|
||
if self.published_version == self.new_version:
|
||
return None
|
||
if self.published_version is not None and self.published_version > self.new_version:
|
||
return SUPERSEDED.format(latest=self.published_version)
|
||
return NOT_CURRENT.format(version=self.new_version)
|
||
|
||
|
||
def _aware(moment: datetime | None) -> datetime | None:
|
||
if moment is None:
|
||
return None
|
||
return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc)
|
||
|
||
|
||
def normalize(binding: Mapping[str, Any], fields: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
|
||
"""参数按旧版入口整理:只留入口有的字段,没给(或给了空)的补旧版的缺省值。分组、发起都按它。"""
|
||
out: dict[str, Any] = {}
|
||
for f in fields:
|
||
name = str(f.get("name") or "")
|
||
if not name:
|
||
continue
|
||
value = binding.get(name)
|
||
if value in (None, ""):
|
||
value = f.get("default")
|
||
if value not in (None, ""):
|
||
out[name] = value
|
||
return out
|
||
|
||
|
||
def source_cards(graph: Any) -> list[str]:
|
||
"""一版图里的源头口径卡(metrics 节点、不是转抄别处的 caliber_from),按图里的先后。"""
|
||
out: list[str] = []
|
||
for node in (graph or {}).get("nodes") or [] if isinstance(graph, dict) else []:
|
||
if not isinstance(node, dict) or node.get("type") != "metrics":
|
||
continue
|
||
cfg = (node.get("data") or {}).get("config") if isinstance(node.get("data"), dict) else None
|
||
if isinstance(cfg, dict) and cfg.get("caliber_from") in (None, "", {}):
|
||
out.append(str(node.get("id")))
|
||
return out
|
||
|
||
|
||
async def recall_runs(session: Any, workflow_id: str, new_version: int | None = None) -> list[Run]:
|
||
"""以 W(新版本 N)发起过的召回运行(试算的不算),新的在前。origin_kind 有索引,先按它筛再看 origin。"""
|
||
stmt = select(Run).where(Run.origin_kind == ORIGIN,
|
||
json_eq(Run.origin, "workflow_id", workflow_id), not_(json_flag(Run.origin, "probe")))
|
||
if new_version is not None:
|
||
stmt = stmt.where(json_eq(Run.origin, "to_version", int(new_version)))
|
||
return list((await session.execute(stmt.order_by(Run.created_at.desc(), Run.id.desc()))).scalars())
|
||
|
||
|
||
async def build_plan(session: Any, workflow_id: str, new_version: int) -> Plan:
|
||
"""W 升到 N 的召回计划(见模块说明)。只读,不发起。"""
|
||
from app.apps import versions as app_versions
|
||
from app.apps.backfill import graph_external_write
|
||
from app.ledger import compose, derive
|
||
|
||
workflow = await session.get(Workflow, workflow_id)
|
||
plan = Plan(workflow_id=workflow_id, workflow_name=workflow.name if workflow is not None else "",
|
||
new_version=int(new_version),
|
||
published_version=workflow.published_version if workflow is not None else None)
|
||
new_row = await app_versions.version_row(session, workflow_id, int(new_version)) if workflow else None
|
||
if new_row is None:
|
||
plan.problem = NEW_GONE
|
||
return plan
|
||
found = await ledger_uses.find_uses(session, workflow_id, below=int(new_version))
|
||
plan.found = found
|
||
side = await graph_external_write(session, new_row.graph)
|
||
for version in found.versions():
|
||
old_row = await app_versions.version_row(session, workflow_id, version)
|
||
cards = source_cards(old_row.graph if old_row is not None else None)
|
||
if not cards:
|
||
plan.version_problem[version] = NOT_CALIBER
|
||
continue
|
||
pairs: list[Pair] = []
|
||
problems: list[str] = []
|
||
for card in cards:
|
||
try:
|
||
old = await derive.load_pinned(session, workflow_id, version, card)
|
||
except derive.NotDerivable as e:
|
||
problems.append(NOT_REUSABLE.format(reason=e.message))
|
||
continue
|
||
try:
|
||
new = await derive.load_pinned(session, workflow_id, int(new_version), card)
|
||
except derive.NotDerivable as e:
|
||
problems.append(CARD_GONE.format(version=new_version, card=old.card_title)
|
||
if e.code == derive.GONE else NOT_REUSABLE.format(reason=e.message))
|
||
continue
|
||
if not {m["id"] for m in old.metrics} & {m["id"] for m in new.metrics}:
|
||
problems.append(NO_COMMON)
|
||
continue
|
||
pairs.append(Pair(old, new))
|
||
if side:
|
||
plan.version_problem[version] = SIDE_EFFECT.format(name=plan.workflow_name, version=new_version,
|
||
reason=side)
|
||
elif not pairs:
|
||
plan.version_problem[version] = problems[0] if problems else NOT_CALIBER
|
||
plan.pairs[version] = pairs
|
||
groups: dict[tuple[int, str], Group] = {}
|
||
for use in found.uses:
|
||
if plan.version_problem.get(use.version) == NOT_CALIBER:
|
||
continue
|
||
pairs = plan.pairs.get(use.version) or []
|
||
fields = pairs[0].old.entry_fields if pairs else ()
|
||
binding = normalize(use.binding, fields) if pairs else dict(use.binding)
|
||
key = ledger_uses._key(binding)
|
||
group = groups.get((use.version, key))
|
||
if group is None:
|
||
group = groups[(use.version, key)] = Group(old_version=use.version, new_version=int(new_version), key=key,
|
||
binding=binding, problem=plan.version_problem.get(use.version))
|
||
group.uses.append(use)
|
||
at = _aware(use.at)
|
||
if at is not None and (group.latest is None or at > group.latest):
|
||
group.latest = at
|
||
for group in groups.values():
|
||
pairs = plan.pairs.get(group.old_version) or []
|
||
if group.problem is not None or not pairs:
|
||
continue
|
||
new = pairs[0].new
|
||
new_names = {str(f.get("name")) for f in new.entry_fields}
|
||
try:
|
||
checked = compose.check_inputs(new, {k: v for k, v in group.binding.items() if k in new_names})
|
||
except compose.ComposeRefused as e:
|
||
group.problem = NEW_INPUTS.format(version=new_version, problem=e.message)
|
||
continue
|
||
group.inputs = {**checked, **{k: v for k, v in group.binding.items() if k not in new_names}}
|
||
existing = await recall_runs(session, workflow_id, int(new_version))
|
||
by_key: dict[tuple[int, str], Run] = {}
|
||
for run in existing:
|
||
origin = run.origin if isinstance(run.origin, dict) else {}
|
||
k = (int(origin.get("from_version") or 0), str(origin.get("input_key") or ""))
|
||
by_key.setdefault(k, run)
|
||
if origin.get("batch") == "auto":
|
||
plan.auto_launched += 1
|
||
for k, group in groups.items():
|
||
group.run = by_key.get(k)
|
||
group.refused = _refusals.get((workflow_id, int(new_version), k[0], k[1]))
|
||
far = datetime.min.replace(tzinfo=timezone.utc)
|
||
plan.groups = sorted(groups.values(), key=lambda g: (-(g.latest or far).timestamp(), g.old_version, g.key))
|
||
return plan
|
||
|
||
|
||
#: 发起时被拒的组(launch_run 拒绝了:工具不在、数据源停用……):(W, N, V, 参数指纹) → 原因。只在进程里记(下次「接着算」
|
||
#: 重试;重启后忘掉、照样算待算)
|
||
_refusals: dict[tuple[str, int, int, str], str] = {}
|
||
|
||
|
||
# ==========================================================================
|
||
# 召回卡与发起
|
||
# ==========================================================================
|
||
|
||
|
||
def _metric(mid: str, name: str, expression: str, src: Mapping[str, Any]) -> dict[str, Any]:
|
||
out: dict[str, Any] = {"id": mid, "name": name, "unit": str(src.get("unit") or ""), "expression": expression}
|
||
if src.get("decimals") is not None:
|
||
out["decimals"] = src["decimals"]
|
||
if src.get("format"):
|
||
out["format"] = src["format"]
|
||
return out
|
||
|
||
|
||
def recall_card(pairs: Sequence[Pair]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||
"""召回卡(derive.recall_graph 的 tail)和每个比较的说明。每个两版都有(按指标 id)的指标三个数:old_k 旧版、new_k
|
||
新版、diff_k 两版之差(新版 − 旧版),单位、小数位、显示格式照新版的定义。缺输入记空值(on_missing=null)。纯计算。"""
|
||
from app.ledger import derive
|
||
|
||
metrics: list[dict[str, Any]] = []
|
||
meta: list[dict[str, Any]] = []
|
||
k = 0
|
||
for i, pair in enumerate(pairs):
|
||
old_prefix, new_prefix = derive.recall_prefixes(i)
|
||
new_by = {m["id"]: (j, m) for j, m in enumerate(pair.new.metrics)}
|
||
for idx, m in enumerate(pair.old.metrics):
|
||
if m["id"] not in new_by:
|
||
continue
|
||
j, nm = new_by[m["id"]]
|
||
old_expr = f"nodes.{old_prefix}{pair.old.node_id}.metrics[{idx}].value"
|
||
new_expr = f"nodes.{new_prefix}{pair.new.node_id}.metrics[{j}].value"
|
||
name = str(nm.get("name") or m["id"])
|
||
metrics += [_metric(f"old_{k}", f"{name} · 旧版 v{pair.old.version}", old_expr, nm),
|
||
_metric(f"new_{k}", f"{name} · 新版 v{pair.new.version}", new_expr, nm),
|
||
_metric(f"diff_{k}", f"{name} · 两版之差", f"({new_expr}) - ({old_expr})", nm)]
|
||
# 旧版的式子(定义,不是数)一并记下:问数回答当时的值要按它找到答案里对应的那一格(original_values)
|
||
item = {"k": k, "card": pair.old.node_id, "metric": m["id"], "name": name,
|
||
"unit": str(nm.get("unit") or ""), "decimals": nm.get("decimals"), "format": nm.get("format"),
|
||
"expression": str(m.get("expression") or "")}
|
||
if not (_rows_stable(pair.old, m) and _rows_stable(pair.new, nm)):
|
||
# 两版都按行号取多行结果里的一格、查询又没有 ORDER BY:两次查询交回的行序可能不同,第几行不一定是同一行,
|
||
# 按位置比出来的「变了 / 没变」都不可信(compare 照实记行序不定)
|
||
item["row_order"] = "unstable"
|
||
meta.append(item)
|
||
k += 1
|
||
node = derive._node(derive.RECALL_CARD, "metrics", "召回卡", caliber="口径召回", caliber_version="v1",
|
||
on_missing="null", metrics=metrics)
|
||
return node, meta
|
||
|
||
|
||
def _rows_stable(pinned: Any, metric: Mapping[str, Any]) -> bool:
|
||
"""这个指标按行号取的每一格(cell(nodes.<取数>, 行, '列')),在那一版的取数 SQL 上重跑时还是同一行(sql_semantics.row_stable)。
|
||
行号、SQL 认不出来的照旧当认得住(以前就是按位置比的,不凭猜测改结论)。"""
|
||
import ast
|
||
|
||
from app.data.sql_semantics import row_stable
|
||
from app.engine.expressions import ExpressionError, cell_parts, leaf_refs, parse_expression
|
||
|
||
sql_of = {str(n.get("id")): ((n.get("data") or {}).get("config") or {}).get("args", {}).get("sql")
|
||
for n in pinned.fetches if isinstance(n, dict)}
|
||
try:
|
||
leaves = leaf_refs(parse_expression(str(metric.get("expression") or ""))[0])
|
||
except ExpressionError:
|
||
return True
|
||
for leaf in leaves:
|
||
parts = cell_parts(str(leaf))
|
||
if parts is None or not parts[0].startswith("nodes."):
|
||
continue
|
||
sql = sql_of.get(parts[0].removeprefix("nodes."))
|
||
try:
|
||
row = ast.literal_eval(parts[1].strip())
|
||
except (ValueError, SyntaxError):
|
||
continue
|
||
if isinstance(sql, str) and isinstance(row, int) and not row_stable(sql, row):
|
||
return False
|
||
return True
|
||
|
||
|
||
def build_graph(group: Group, pairs: Sequence[Pair]) -> tuple[Any, list[dict[str, Any]]]:
|
||
"""一组的召回图(derive.recall_graph)和比较说明。同样的输入永远得到同一张图。"""
|
||
from app.ledger import derive
|
||
|
||
card, meta = recall_card(pairs)
|
||
graph = derive.recall_graph([(p.old, p.new) for p in pairs], [card], inputs=group.inputs)
|
||
return graph, meta
|
||
|
||
|
||
async def _launch_group(plan: Plan, group: Group, *, batch: str, actor: str | None) -> Run | None:
|
||
from app.engine.launch import LaunchRefused, launch_run
|
||
from app.tools.datasource import READ_ONLY_OPTION
|
||
|
||
pairs = plan.pairs.get(group.old_version) or []
|
||
graph, meta = build_graph(group, pairs)
|
||
origin = {"workflow_id": plan.workflow_id, "from_version": group.old_version, "to_version": group.new_version,
|
||
"input_key": group.key, "binding": dict(group.binding), "batch": batch,
|
||
"cards": [p.old.node_id for p in pairs], "metrics": meta,
|
||
"uses": [u.describe() for u in group.uses], "derive": graph.origin}
|
||
key = (plan.workflow_id, plan.new_version, group.old_version, group.key)
|
||
async with SessionLocal() as session:
|
||
try:
|
||
run = await launch_run(session, graph=graph.graph, input_payload=dict(group.inputs),
|
||
run_class="exploratory", actor=actor, origin_kind=ORIGIN, origin=origin,
|
||
options={READ_ONLY_OPTION: True},
|
||
workflow_name=f"口径召回:{plan.workflow_name} v{group.old_version} → v{group.new_version}")
|
||
except LaunchRefused as e:
|
||
_refusals[key] = LAUNCH_REFUSED.format(reason=e.message)
|
||
logger.info("口径召回没能发起(%s v%s → v%s):%s", plan.workflow_id, group.old_version, group.new_version,
|
||
e.message)
|
||
return None
|
||
_refusals.pop(key, None)
|
||
return run
|
||
|
||
|
||
#: 同一(W、N)的计划和发起串行:{事件循环: {键: 锁}}
|
||
_locks: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, dict[tuple[str, int], asyncio.Lock]]" = \
|
||
weakref.WeakKeyDictionary()
|
||
|
||
|
||
def _lock(key: tuple[str, int]) -> Any:
|
||
"""跨进程的锁(app/cluster/mutex):同一(W、N)的计划和发起多进程时也串行,「已经自动发过几次」读得准。"""
|
||
from app.cluster import mutex
|
||
|
||
return mutex.lock(f"ledger-recall:{key[0]}:{key[1]}")
|
||
|
||
|
||
async def launch(workflow_id: str, new_version: int, *, batch: str = "auto", actor: str | None = None) -> list[str]:
|
||
"""按计划发起一批召回运行,交回发起了的运行 id。auto:发布后自动的那一批,一次发布最多 recall_limit 组(已经自动发起
|
||
过的扣掉,重复调幂等);manual:「接着算」,再发起一批(上限是 0 时按缺省的组数)。"""
|
||
from app.api.settings import LEDGER_DEFAULTS, ledger_settings
|
||
|
||
if batch not in BATCHES:
|
||
raise ValueError(f"不认识的召回批次:{batch}")
|
||
async with _lock((workflow_id, int(new_version))):
|
||
async with SessionLocal() as session:
|
||
plan = await build_plan(session, workflow_id, int(new_version))
|
||
limit = int((await ledger_settings(session))["recall_limit"])
|
||
if plan.problem is not None or plan.stale() is not None:
|
||
return []
|
||
if batch == "auto":
|
||
size = max(0, limit - plan.auto_launched)
|
||
else:
|
||
size = limit or int(LEDGER_DEFAULTS["recall_limit"])
|
||
launched: list[str] = []
|
||
for group in plan.todo()[:size]:
|
||
run = await _launch_group(plan, group, batch=batch, actor=actor)
|
||
if run is not None:
|
||
launched.append(run.id)
|
||
if launched:
|
||
logger.info("口径召回:%s 升到 v%s,%s 发起了 %d 组", workflow_id, new_version, batch, len(launched))
|
||
return launched
|
||
|
||
|
||
# ==========================================================================
|
||
# 预告、批次、版本用量
|
||
# ==========================================================================
|
||
|
||
|
||
def _counts(plan: Plan) -> dict[str, int]:
|
||
out = dict.fromkeys(OUTCOMES, 0)
|
||
for group in plan.groups:
|
||
out[_group_state(group)[0]] += 1
|
||
return out
|
||
|
||
|
||
async def preview(session: Any, workflow_id: str, version: int) -> dict[str, Any]:
|
||
"""发布弹窗的召回预告:把 version 发布成新版本之后,旧版本被多少问数回答、导出、定时任务、应用用过,预计重算几组,超出
|
||
上限的写明。只读、不发起(和发布之后真的召回走同一份计划,规模一致)。"""
|
||
from app.api.settings import ledger_settings
|
||
|
||
plan = await build_plan(session, workflow_id, int(version))
|
||
limit = int((await ledger_settings(session))["recall_limit"])
|
||
found = plan.found or ledger_uses.Found(uses=[])
|
||
per_version = []
|
||
for v in sorted({*found.versions(), *plan.version_problem}):
|
||
if plan.version_problem.get(v) == NOT_CALIBER:
|
||
continue
|
||
sub = ledger_uses.Found(uses=[u for u in found.uses if u.version == v], apps={v: found.apps.get(v, [])})
|
||
counts = ledger_uses.summarize(sub)
|
||
counts["exports"] = await ledger_uses.exports_count(session, [u.run_id for u in sub.uses])
|
||
per_version.append({"version": v, **counts, **({"problem": plan.version_problem[v]}
|
||
if v in plan.version_problem else {})})
|
||
todo = len(plan.todo())
|
||
done = sum(1 for g in plan.groups if g.run is not None)
|
||
auto_left = max(0, limit - plan.auto_launched)
|
||
will = min(todo, auto_left)
|
||
return {
|
||
"workflow_id": workflow_id, "version": int(version), "limit": limit, "caliber": bool(plan.pairs),
|
||
"problem": plan.problem, "versions": per_version, "groups": len(plan.groups),
|
||
"recomputable": todo + done, "not_recomputable": sum(1 for g in plan.groups if g.problem is not None),
|
||
"already": done, "will_recompute": will, "over_limit": max(0, todo - will),
|
||
"truncated": found.truncated,
|
||
"reasons": _reasons(plan),
|
||
}
|
||
|
||
|
||
def _reasons(plan: Plan) -> list[dict[str, Any]]:
|
||
"""不能重算的原因各几组(同一句话合在一起)。"""
|
||
by: dict[str, int] = {}
|
||
for group in plan.groups:
|
||
if group.problem:
|
||
by[group.problem] = by.get(group.problem, 0) + 1
|
||
return [{"reason": r, "groups": n} for r, n in sorted(by.items(), key=lambda x: (-x[1], x[0]))]
|
||
|
||
|
||
async def outcome_of(session: Any, run: Run) -> tuple[str, dict[str, Any]]:
|
||
"""一次召回运行的结论:(changed / unchanged / row_order(全部指标行序不定、核对不了)/ failed / running, 收尾时记下的那一条
|
||
ledger.recalled 的内容)。"""
|
||
data = await _settled(session, run.id)
|
||
if data is not None:
|
||
if data.get("outcome") in ("failed", "row_order"):
|
||
return str(data["outcome"]), data
|
||
return ("changed" if data.get("changed") else "unchanged"), data
|
||
if run.status in ("failed", "cancelled"):
|
||
return "failed", {"error": run.error}
|
||
return "running", {}
|
||
|
||
|
||
def _group_state(group: Group) -> tuple[str, str | None]:
|
||
"""没读收尾记录之前的状态:不能重算、待算、发起被拒(照样算待算,原因另写)、算过(具体结论看 outcome_of)。"""
|
||
if group.run is not None:
|
||
return "running", None
|
||
if group.problem is not None:
|
||
return "not_recomputable", group.problem
|
||
return "pending", group.refused
|
||
|
||
|
||
async def batches(session: Any, workflow_id: str, version: int) -> dict[str, Any]:
|
||
"""版本历史里「这一版的召回」:每组的结论(数变了 / 没变 / 无法重算:原因 / 重算失败 / 还在算 / 未重算),「另有 M 组
|
||
未重算」和能不能接着算(还有没算的组却不能接着算时 continue_reason 写为什么:之后又发布了更新的版本……)。
|
||
接口只给搭建者(api/ledger 的 router):参数原样给,同他在召回运行的输入里读到的一样;使用者
|
||
看的是 recall 待办(task_payload,按遮罩换过)。"""
|
||
from app.api.settings import LEDGER_DEFAULTS, ledger_settings
|
||
|
||
plan = await build_plan(session, workflow_id, int(version))
|
||
limit = int((await ledger_settings(session))["recall_limit"])
|
||
counts = dict.fromkeys(OUTCOMES, 0)
|
||
items: list[dict[str, Any]] = []
|
||
for group in plan.groups:
|
||
state, reason = _group_state(group)
|
||
extra: dict[str, Any] = {}
|
||
if group.run is not None:
|
||
state, data = await outcome_of(session, group.run)
|
||
if state == "failed":
|
||
reason = RUN_FAILED.format(reason=str(data.get("error") or group.run.error or "运行没有成功结束")[:200])
|
||
extra = {"run_id": group.run.id, "batch": (group.run.origin or {}).get("batch"),
|
||
"tasks": len(data.get("tasks") or []) if data else 0,
|
||
"changed_metrics": [m.get("name") for m in data.get("metrics") or [] if m.get("changed")]
|
||
if data else []}
|
||
counts[state] += 1
|
||
kinds: dict[str, int] = {}
|
||
for use in group.uses:
|
||
kinds[use.kind] = kinds.get(use.kind, 0) + 1
|
||
items.append({"id": group.id, "from_version": group.old_version, "to_version": group.new_version,
|
||
"binding": [{"field": k, "value": v} for k, v in sorted(group.binding.items())],
|
||
"uses": [{"kind": k, "kind_text": ledger_uses.KIND_TEXT.get(k, k), "count": n}
|
||
for k, n in sorted(kinds.items())],
|
||
"latest": group.latest.isoformat() if group.latest else None,
|
||
"state": state, **({"reason": reason} if reason else {}), **extra})
|
||
pending = counts["pending"]
|
||
why_not = (plan.problem or plan.stale()) if pending > 0 else None
|
||
return {"workflow_id": workflow_id, "workflow_name": plan.workflow_name, "version": int(version), "limit": limit,
|
||
"batch_size": limit or int(LEDGER_DEFAULTS["recall_limit"]), "problem": plan.problem,
|
||
"caliber": bool(plan.pairs) or bool(plan.groups), "counts": counts, "groups": items,
|
||
"remaining": pending, "can_continue": pending > 0 and why_not is None, "continue_reason": why_not,
|
||
"truncated": bool(plan.found and plan.found.truncated)}
|
||
|
||
|
||
async def version_recall_counts(session: Any, workflow_id: str, version: int) -> dict[str, Any]:
|
||
"""版本用量里的召回:以这一版为新版本召回过几组(其中数变了几组),这一版作为旧版本被召回过几组。"""
|
||
as_new = await recall_runs(session, workflow_id, version)
|
||
changed = 0
|
||
for run in as_new:
|
||
state, _ = await outcome_of(session, run)
|
||
changed += state == "changed"
|
||
as_old = int(await session.scalar(select(func.count()).select_from(Run).where(
|
||
Run.origin_kind == ORIGIN, json_eq(Run.origin, "workflow_id", workflow_id),
|
||
json_eq(Run.origin, "from_version", int(version)), not_(json_flag(Run.origin, "probe")))) or 0)
|
||
return {"groups": len(as_new), "changed": changed, "recalled_from": as_old}
|
||
|
||
|
||
# ==========================================================================
|
||
# 收尾:数变没变、开待办或记账
|
||
# ==========================================================================
|
||
|
||
|
||
async def _settled(session: Any, run_id: str) -> dict[str, Any] | None:
|
||
"""召回运行上收尾时记的 ledger.recalled(role=recall);没收过尾为 None。"""
|
||
rows = (await session.execute(select(RunEvent.data).where(
|
||
RunEvent.run_id == run_id, RunEvent.type == EventType.LEDGER_RECALLED).order_by(RunEvent.seq.desc()))).scalars()
|
||
for data in rows:
|
||
if isinstance(data, dict) and data.get("role") == "recall":
|
||
return data
|
||
return None
|
||
|
||
|
||
async def card_values(run_id: str) -> dict[str, dict[str, Any]]:
|
||
"""召回运行封存范围内的召回卡:{指标 id: 指标}(value、rendered……)。读不出为空。"""
|
||
from app.api.evidence import _sealed, _sealed_cards
|
||
from app.ledger import derive
|
||
|
||
sealed = await _sealed(run_id)
|
||
for node, _, card in _sealed_cards(sealed):
|
||
if node == derive.RECALL_CARD:
|
||
return {str(m.get("id")): m for m in card.get("metrics") or [] if isinstance(m, dict)}
|
||
return {}
|
||
|
||
|
||
def compare(meta: Sequence[Mapping[str, Any]], values: Mapping[str, Mapping[str, Any]]) -> list[dict[str, Any]]:
|
||
"""每个比较的旧值、新值、两版之差,和数变没变:按指标的单位、小数位渲染的旧值和新值不同就是变了(召回卡上的渲染,
|
||
同报告里显示的那个字)。只读召回卡,不在这里算差值。"""
|
||
out = []
|
||
for item in meta:
|
||
k = item.get("k")
|
||
old, new, diff = (values.get(f"{p}_{k}") or {} for p in ("old", "new", "diff"))
|
||
unstable = item.get("row_order") == "unstable"
|
||
# 行序不定的(recall_card 记的):按位置比不出变没变,changed 为空,照实记 row_order,不假装对上
|
||
changed = None if unstable else str(old.get("rendered")) != str(new.get("rendered"))
|
||
out.append({"k": k, "card": item.get("card"), "metric": item.get("metric"), "name": item.get("name"),
|
||
"unit": item.get("unit"), "old": old.get("value"), "new": new.get("value"),
|
||
"diff": diff.get("value"), "old_text": old.get("rendered"), "new_text": new.get("rendered"),
|
||
"diff_text": diff.get("rendered"), "changed": changed,
|
||
**({"row_order": "unstable"} if unstable else {})})
|
||
return out
|
||
|
||
|
||
_settling: set[str] = set()
|
||
_background: set[asyncio.Task[Any]] = set()
|
||
|
||
|
||
def spawn(coro: Any) -> None:
|
||
"""在后台做(召回是辅助步骤:出错只记日志)。"""
|
||
task = asyncio.create_task(_guarded(coro))
|
||
_background.add(task)
|
||
task.add_done_callback(_background.discard)
|
||
|
||
|
||
async def _guarded(coro: Any) -> None:
|
||
try:
|
||
await coro
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception: # noqa: BLE001 - 辅助步骤
|
||
logger.exception("口径召回的后台任务出错")
|
||
|
||
|
||
def schedule_settle(run_id: str) -> None:
|
||
"""召回运行结束了(runner 收尾时调):在后台收尾。"""
|
||
spawn(settle(run_id))
|
||
|
||
|
||
async def _mark(run_id: str, data: dict[str, Any]) -> bool:
|
||
from app.engine.run_append import append_events
|
||
|
||
return await append_events(run_id, [(EventType.LEDGER_RECALLED, None, data)], require="sealed") is not None
|
||
|
||
|
||
async def settle(run_id: str) -> str | None:
|
||
"""一次召回运行结束之后:数变了开 recall 待办、数没变在原答案上记 ledger.recalled,最后在召回运行上记结论。返回结论
|
||
(changed / unchanged / failed),不是召回运行、还没结束、收过尾的为 None。幂等:待办按 correlation_key 认、原答案上的
|
||
记账按召回运行认,中途断了再收一次不会多开。"""
|
||
if run_id in _settling:
|
||
return None
|
||
_settling.add(run_id)
|
||
try:
|
||
# 多进程:收尾的进程(runner 收尾时)和当班进程启动时的「补上收尾」可能同时来,跨进程的锁里先看收过没有
|
||
from app.cluster import mutex
|
||
|
||
async with mutex.lock(f"ledger-settle:{run_id}"):
|
||
return await _settle(run_id)
|
||
finally:
|
||
_settling.discard(run_id)
|
||
|
||
|
||
async def _settle(run_id: str) -> str | None:
|
||
async with SessionLocal() as session:
|
||
run = await session.get(Run, run_id)
|
||
if run is None or run.origin_kind != ORIGIN or run.status not in ("succeeded", "failed", "cancelled") \
|
||
or not run.manifest_hash:
|
||
return None
|
||
if await _settled(session, run_id) is not None:
|
||
return None
|
||
origin = run.origin if isinstance(run.origin, dict) else {}
|
||
if origin.get("probe"):
|
||
return await _settle_probe(run)
|
||
base = {"role": "recall", "workflow_id": origin.get("workflow_id"), "from_version": origin.get("from_version"),
|
||
"to_version": origin.get("to_version")}
|
||
if run.status != "succeeded":
|
||
await _mark(run_id, {**base, "outcome": "failed", "changed": None,
|
||
"error": str(run.error or "运行没有成功结束")[:300]})
|
||
return "failed"
|
||
meta = origin.get("metrics") or []
|
||
values = await card_values(run_id)
|
||
# 读不出召回卡、少了哪个比较的旧值或新值:两边都是空,按渲染比会被当成「没变」,给每个原答案盖上「已核对,不影响」
|
||
# ——照实记重算失败,原答案一个不动(收过尾就不再核对,盖错了收不回来)
|
||
if not meta or any(f"{side}_{m.get('k')}" not in values for m in meta for side in ("old", "new")):
|
||
await _mark(run_id, {**base, "outcome": "failed", "changed": None, "error": CARD_UNREADABLE})
|
||
return "failed"
|
||
results = compare(meta, values)
|
||
# 行序不定的(recall_card 记的 row_order)比不出变没变:不算「没变」。全部都行序不定的结论写核对不了、开待办请人核对;
|
||
# 部分行序不定的,已判定的照常判,行序不定的那几项在结论、待办、原答案的记账里照实列出
|
||
unstable = [r["name"] for r in results if r.get("row_order") == "unstable"]
|
||
decided = [r for r in results if r.get("row_order") != "unstable"]
|
||
changed = any(r["changed"] for r in decided)
|
||
outcome = "row_order" if not decided else "changed" if changed else "unchanged"
|
||
answers = list(dict.fromkeys(str(u.get("run_id")) for u in origin.get("uses") or [] if u.get("run_id")))
|
||
tasks: list[str] = []
|
||
if outcome in ("changed", "row_order"):
|
||
tasks = await open_tasks(run, reason="caliber", row_order=outcome == "row_order")
|
||
else:
|
||
for answer in answers:
|
||
await _mark_answer(answer, run, row_order=unstable)
|
||
await _mark(run_id, {**base, "outcome": outcome, "changed": changed if decided else None,
|
||
"answers": len(answers), "tasks": tasks,
|
||
**({"row_order_metrics": unstable} if unstable else {}),
|
||
"metrics": [{"k": r["k"], "name": r["name"], "changed": r["changed"],
|
||
**({"row_order": "unstable"} if r.get("row_order") else {})}
|
||
for r in results]})
|
||
return outcome
|
||
|
||
|
||
async def _mark_answer(answer_id: str, recall_run: Run, *, row_order: Sequence[str] = ()) -> None:
|
||
"""数没变:在原答案的运行上记一条 ledger.recalled(已按 vN 核对,不影响)。这次召回记过的不再记。row_order 是行序不定、
|
||
没能核对的那几项(照实列出,不算进「不影响」)。"""
|
||
origin = recall_run.origin if isinstance(recall_run.origin, dict) else {}
|
||
async with SessionLocal() as session:
|
||
rows = (await session.execute(select(RunEvent.data).where(
|
||
RunEvent.run_id == answer_id, RunEvent.type == EventType.LEDGER_RECALLED))).scalars()
|
||
if any(isinstance(d, dict) and d.get("recall_run") == recall_run.id for d in rows):
|
||
return
|
||
await _mark(answer_id, {"role": "answer", "changed": False, "recall_run": recall_run.id,
|
||
"workflow_id": origin.get("workflow_id"), "from_version": origin.get("from_version"),
|
||
"to_version": origin.get("to_version"),
|
||
**({"row_order_metrics": list(row_order)} if row_order else {})})
|
||
|
||
|
||
# ==========================================================================
|
||
# 待办
|
||
# ==========================================================================
|
||
|
||
|
||
async def _owner_targets(session: Any, owners: Iterable[tuple[str, str]]) -> list[tuple[str, str]]:
|
||
"""答案的主人 → 待办派给谁:开登录时每个主人一份(组照留,开待办时展开;服务账号不派),关登录时派给任何人。"""
|
||
from app.auth import current_mode
|
||
from app.process.tasks import _service_accounts
|
||
|
||
if current_mode() == "off":
|
||
return [("anyone", "")]
|
||
owners = list(dict.fromkeys((k, n) for k, n in owners if k in ("user", "group") and n))
|
||
service = await _service_accounts(session, [n for k, n in owners if k == "user"])
|
||
out = [(k, n) for k, n in owners if not (k == "user" and n in service)]
|
||
return out or [("anyone", "")]
|
||
|
||
|
||
def _uses_of(origin: Mapping[str, Any], owner: tuple[str, str]) -> list[dict[str, Any]]:
|
||
"""这条待办列哪几个答案:派给任何人的列全部,派给某人(组)的只列他是主人的。"""
|
||
items = [u for u in origin.get("uses") or [] if isinstance(u, dict)]
|
||
if owner[0] == "anyone":
|
||
return items
|
||
return [u for u in items if [owner[0], owner[1]] in (u.get("owners") or [])]
|
||
|
||
|
||
def correlation(run_id: str, owner: tuple[str, str], reason: str = "caliber", extra: str = "") -> str:
|
||
"""recall 待办的 correlation_key:来由:运行[:附加]:派给的种类:名字。同一条按它认,最后两段是派给谁(_owner_of_key)。"""
|
||
return ":".join([reason, run_id, *([extra] if extra else []), owner[0], owner[1]])
|
||
|
||
|
||
async def open_tasks(run: Run, *, reason: str, owners: Iterable[tuple[str, str]] | None = None,
|
||
title: str | None = None, extra: str = "", row_order: bool = False) -> list[str]:
|
||
"""给答案的主人开 recall 待办(挂在 run 上)。owners 不给就按召回运行 origin 里各答案的主人。按 correlation_key 认同一条,
|
||
开过的不再开。提交之后推通知。返回这次新开的待办 id。"""
|
||
from app.process import tasks as process_tasks
|
||
|
||
origin = run.origin if isinstance(run.origin, dict) else {}
|
||
async with SessionLocal() as session:
|
||
if owners is None:
|
||
owners = [tuple(o) for u in origin.get("uses") or [] for o in u.get("owners") or [] if len(o) == 2]
|
||
targets = await _owner_targets(session, owners)
|
||
name = await ledger_uses.workflow_name(session, str(origin.get("workflow_id") or ""))
|
||
if title is None:
|
||
title = (ROW_ORDER_TITLE if row_order else CHANGED_TITLE).format(name=name or "口径",
|
||
version=origin.get("to_version"))
|
||
opened: list[str] = []
|
||
for owner in targets:
|
||
key = correlation(run.id, owner, reason, extra)
|
||
if await session.scalar(select(process_tasks.Task.id).where(process_tasks.Task.correlation_key == key)):
|
||
continue
|
||
task = await process_tasks.open_recall(session, run=run, title=title, owner=owner, correlation_key=key)
|
||
opened.append(task.id)
|
||
await session.commit()
|
||
for task_id in opened:
|
||
await process_tasks.announce(task_id)
|
||
return opened
|
||
|
||
|
||
def _owner_of_key(key: str) -> tuple[str, str]:
|
||
"""correlation_key 的最后两段是派给谁(见 correlation)。"""
|
||
parts = (key or "").split(":")
|
||
return (parts[-2], parts[-1]) if len(parts) >= 4 else ("anyone", "")
|
||
|
||
|
||
def batch_of_key(key: str) -> str:
|
||
"""correlation_key 去掉派给谁的那两段:同一次召回(同一个来由、同一次运行、同一条依据)给各个主人开的那一批待办是同一个
|
||
值。全局渠道按它只推一次(notify/service.notify_recall)。"""
|
||
parts = (key or "").split(":")
|
||
return ":".join(parts[:-2]) if len(parts) >= 4 else key or ""
|
||
|
||
|
||
async def task_payload(session: Any, task: Any, principal: Any) -> dict[str, Any]:
|
||
"""recall 待办给处理人看的内容(待办箱、待办详情的 payload,process/tasks._describe 调):旧口径的值、新口径的值、两版之差
|
||
和原答案当时的值(每个都带出处:哪次运行、哪张卡、哪个指标,点得开),这一组的参数,能做的动作。使用者看到的值照样
|
||
遮罩(按那次运行查到过的遮罩值,同账本接口)。"""
|
||
from app.api.evidence import _sealed
|
||
from app.ledger.view import _hider
|
||
|
||
run = await session.get(Run, task.run_id)
|
||
origin = run.origin if run is not None and isinstance(run.origin, dict) else {}
|
||
key = task.correlation_key or ""
|
||
reason = key.split(":", 1)[0] if key.split(":", 1)[0] in REASONS else "caliber"
|
||
base: dict[str, Any] = {"kind": TASK_KIND, "reason": reason, "recall_run": task.run_id,
|
||
"workflow_id": origin.get("workflow_id"), "from_version": origin.get("from_version"),
|
||
"to_version": origin.get("to_version")}
|
||
if origin.get("workflow_id"):
|
||
base["workflow_name"] = await ledger_uses.workflow_name(session, str(origin["workflow_id"]))
|
||
pending = task.status == "pending"
|
||
if reason != "caliber":
|
||
return {**base, "basis": await _basis_of(session, task, run, reason),
|
||
"actions": [{"decision": "ack", "available": pending}]}
|
||
builder = getattr(principal, "mode", "off") == "off" or principal.at_least("builder")
|
||
hide = (await _hider(await _sealed(task.run_id))).hide if not builder and run is not None else (lambda _v: False)
|
||
values = await card_values(task.run_id) if run is not None else {}
|
||
metrics = []
|
||
for item in compare(origin.get("metrics") or [], values):
|
||
cells = {}
|
||
for side in ("old", "new", "diff"):
|
||
value, text = item[side], item[f"{side}_text"]
|
||
masked = value is not None and hide(value)
|
||
cells[side] = {"value": None if masked else value, "text": "已遮罩" if masked else text,
|
||
"metric": f"{side}_{item['k']}", "masked": masked}
|
||
metrics.append({"k": item["k"], "name": item["name"], "unit": item["unit"], "changed": item["changed"],
|
||
**({"row_order": item["row_order"]} if item.get("row_order") else {}),
|
||
"card": item["card"], "metric": item["metric"], **cells})
|
||
answers = []
|
||
for use in _uses_of(origin, _owner_of_key(key)):
|
||
answers.append({**{k: use.get(k) for k in ("kind", "run_id", "version", "conversation_id", "turn_id",
|
||
"schedule_id", "app_id", "at", "via_workflow", "via_version")
|
||
if use.get(k) is not None},
|
||
"kind_text": ledger_uses.KIND_TEXT.get(str(use.get("kind")), str(use.get("kind"))),
|
||
"original": await original_values(use, origin.get("metrics") or [], hide_for=not builder)})
|
||
binding = [{"field": k, "value": None if hide(v) else v, "masked": bool(hide(v))}
|
||
for k, v in sorted((origin.get("binding") or {}).items())]
|
||
actions = [await reissue_action(session, task, principal, origin), {"decision": "ack", "available": pending}]
|
||
return {**base, "binding": binding, "metrics": metrics, "answers": answers, "actions": actions,
|
||
"outcome": task.outcome}
|
||
|
||
|
||
async def _basis_of(session: Any, task: Any, run: Run | None, reason: str) -> dict[str, Any]:
|
||
"""目录、表结构两种 recall 待办说的是什么:目录的读答案运行上那一条 ledger.basis_changed(correlation_key 里记着它的序号),
|
||
表结构的读试算运行的 origin 和它为什么没算出来。"""
|
||
if reason == "structure":
|
||
origin = run.origin if run is not None and isinstance(run.origin, dict) else {}
|
||
return {"workflow_id": origin.get("workflow_id"), "version": origin.get("version"), "table": origin.get("table"),
|
||
"column": origin.get("column"), "change_kind": origin.get("change_kind"), "change": origin.get("change"),
|
||
"source_id": origin.get("source_id"), "error": (run.error if run is not None else None),
|
||
"probe_run": task.run_id}
|
||
parts = (task.correlation_key or "").split(":")
|
||
seq = int(parts[2]) if len(parts) >= 5 and parts[2].isdigit() else None
|
||
data = await session.scalar(select(RunEvent.data).where(
|
||
RunEvent.run_id == task.run_id, RunEvent.type == EventType.LEDGER_BASIS_CHANGED, RunEvent.seq == seq)) \
|
||
if seq is not None else None
|
||
data = data if isinstance(data, dict) else {}
|
||
return {k: data.get(k) for k in ("kind", "source_id", "source", "table", "items", "new_errors", "checks",
|
||
"catalog_version") if data.get(k) is not None}
|
||
|
||
|
||
async def original_values(use: Mapping[str, Any], meta: Sequence[Mapping[str, Any]], *,
|
||
hide_for: bool) -> list[dict[str, Any]]:
|
||
"""原答案当时的值(每个比较一项):正式运行读它封存的口径卡(固定了这一版的读转抄来的那张);问数回答读等价的那条查询
|
||
快照里那一格(口径卡的指标就是取数第一行某一列时,按列映射找这次的列)。读不出、原答案里没有单独这个数的为空,写为什么。
|
||
hide_for:给使用者看,按那次运行查到过的遮罩值遮。"""
|
||
from app.api.evidence import _sealed, _sealed_cards
|
||
from app.ledger.view import _hider
|
||
|
||
run_id = str(use.get("run_id") or "")
|
||
sealed = await _sealed(run_id)
|
||
hide = (await _hider(sealed)).hide if hide_for else (lambda _v: False)
|
||
out: list[dict[str, Any]] = []
|
||
if use.get("kind") != "answer":
|
||
cards = {node: card for node, _, card in _sealed_cards(sealed)}
|
||
for item in meta:
|
||
card = _card_for(sealed, cards, str(item.get("card")))
|
||
metric = next((m for m in (card or {}).get("metrics") or [] if m.get("id") == item.get("metric")), None)
|
||
out.append(_cell(item, metric.get("value") if metric else None, metric.get("rendered") if metric else None,
|
||
hide, node=_card_node(sealed, cards, str(item.get("card"))), metric=item.get("metric"),
|
||
missing=None if metric else "原答案里没有这个指标"))
|
||
return out
|
||
import ast
|
||
|
||
from app.core.artifact_store import load
|
||
from app.engine.evidence import render_number
|
||
from app.engine.expressions import cell_parts
|
||
from app.ledger import store
|
||
from app.ledger.labels import candidates_of
|
||
|
||
recorded = await store.latest(run_id)
|
||
content = recorded.content if recorded is not None else None
|
||
query = next((q for q in (content or {}).get("queries") or [] if q.get("artifact") == use.get("query")), None)
|
||
# 整条证明了的、逐格证明了的都算:口径的哪一列对这次的哪一列,按各自的列映射找(逐格的只映射那一列)
|
||
proven = [c for c in candidates_of(query or {})
|
||
if c.get("status") == "proven" and c.get("node_id") == use.get("node_id")]
|
||
try:
|
||
snap = load(str(use.get("query") or "")) if use.get("query") else None
|
||
except ValueError:
|
||
snap = None
|
||
columns = [str(c) for c in (snap or {}).get("columns") or []] if isinstance(snap, dict) else []
|
||
rows = (snap or {}).get("rows") if isinstance(snap, dict) else None
|
||
for item in meta:
|
||
value = text = missing = None
|
||
expression = str(item.get("expression") or "")
|
||
parts = cell_parts(expression.strip()) if expression else None
|
||
column = None
|
||
if parts is not None and parts[0] == f"nodes.{use.get('node_id')}" and parts[1].strip() == "0":
|
||
try:
|
||
column = ast.literal_eval(parts[2])
|
||
except (ValueError, SyntaxError):
|
||
column = None
|
||
ours = next((m.get("ours") for c in proven for m in c.get("columns") or [] if m.get("caliber") == column),
|
||
None) if isinstance(column, str) else None
|
||
if ours in columns and isinstance(rows, list) and rows and isinstance(rows[0], (list, tuple)):
|
||
value = rows[0][columns.index(ours)]
|
||
try:
|
||
text = render_number(value, unit=str(item.get("unit") or ""), decimals=item.get("decimals"),
|
||
fmt=item.get("format"))
|
||
except Exception: # noqa: BLE001 - 渲染不出就写原值
|
||
text = str(value)
|
||
else:
|
||
missing = "原答案里没有单独的这个数"
|
||
out.append(_cell(item, value, text, hide, query=use.get("query"), column=ours, missing=missing))
|
||
return out
|
||
|
||
|
||
def _card_for(sealed: Any, cards: Mapping[str, Any], card_id: str) -> Mapping[str, Any] | None:
|
||
node = _card_node(sealed, cards, card_id)
|
||
return cards.get(node) if node else None
|
||
|
||
|
||
def _card_node(sealed: Any, cards: Mapping[str, Any], card_id: str) -> str | None:
|
||
"""正式运行里这张口径卡的节点:同 id 的(W 自己的运行),或者 caliber_from 固定到它的那张(固定了这一版的工作流)。"""
|
||
if card_id in cards:
|
||
return card_id
|
||
for node in (sealed.graph or {}).get("nodes") or []:
|
||
cfg = (node.get("data") or {}).get("config") if isinstance(node, dict) and isinstance(node.get("data"), dict) \
|
||
else None
|
||
ref = cfg.get("caliber_from") if isinstance(cfg, dict) else None
|
||
if isinstance(ref, dict) and ref.get("node_id") == card_id and node.get("id") in cards:
|
||
return str(node["id"])
|
||
return None
|
||
|
||
|
||
def _cell(item: Mapping[str, Any], value: Any, text: Any, hide: Any, *, missing: str | None = None,
|
||
**where: Any) -> dict[str, Any]:
|
||
masked = value is not None and bool(hide(value))
|
||
out = {"k": item.get("k"), "value": None if masked else value, "text": "已遮罩" if masked else text,
|
||
"masked": masked, **{k: v for k, v in where.items() if v is not None}}
|
||
if missing:
|
||
out["missing"] = missing
|
||
return out
|
||
|
||
|
||
# ==========================================================================
|
||
# 答案上的召回标记(账本接口 GET /api/runs/{id}/ledger 的 recall,ledger/view.run_view)
|
||
# ==========================================================================
|
||
|
||
|
||
async def answer_marks(session: Any, run_id: str, principal: Any) -> dict[str, Any]:
|
||
"""一个答案(探索运行、正式运行)身上的召回标记,给问数据的轮次和证据面板「口径」一步(细案做法 7:界面标「依据有变」、
|
||
「已按 vN 核对,不影响」):
|
||
|
||
- checked:数没变、追加在这次运行上的 ledger.recalled(role=answer)——按哪份口径、从哪一版核对到哪一版、哪次召回、什么时候;
|
||
- basis:ledger.basis_changed——哪一种依据(目录事实、表结构、取值、处理时改了目录)、几项、按新目录新出现几条错误级问题、
|
||
什么时候;
|
||
- tasks:看的这个人手上还没处理的、说的是这个答案的 recall 待办(数变了的那种挂在召回运行上,列着这个答案;目录有变的
|
||
挂在这次运行上),界面链到待办箱。
|
||
|
||
不放数(新旧值、差值只在待办里给,按遮罩换过),不放表名、取值。"""
|
||
from app.process.tasks import Task, _assignments, handles
|
||
|
||
checked: list[dict[str, Any]] = []
|
||
basis: list[dict[str, Any]] = []
|
||
rows = (await session.execute(select(RunEvent.type, RunEvent.data, RunEvent.ts).where(
|
||
RunEvent.run_id == run_id, RunEvent.type.in_((EventType.LEDGER_RECALLED, EventType.LEDGER_BASIS_CHANGED)))
|
||
.order_by(RunEvent.seq))).all()
|
||
def at(ts: Any) -> str | None:
|
||
return datetime.fromtimestamp(ts, timezone.utc).isoformat(timespec="seconds") \
|
||
if isinstance(ts, (int, float)) else None
|
||
|
||
workflows: set[str] = set()
|
||
for etype, data, ts in rows:
|
||
data = data if isinstance(data, dict) else {}
|
||
if etype == EventType.LEDGER_RECALLED and data.get("role") == "answer":
|
||
checked.append({k: data.get(k) for k in ("workflow_id", "from_version", "to_version", "recall_run")}
|
||
| {"changed": bool(data.get("changed")), "at": at(ts)}
|
||
# 行序不定、没能核对的那几项(只列名字):不算进「不影响」
|
||
| ({"row_order_metrics": [str(x) for x in data["row_order_metrics"]]}
|
||
if isinstance(data.get("row_order_metrics"), list) and data["row_order_metrics"] else {}))
|
||
workflows.add(str(data.get("workflow_id") or ""))
|
||
elif etype == EventType.LEDGER_BASIS_CHANGED:
|
||
items = data.get("items")
|
||
basis.append({"kind": data.get("kind"), "items": len(items) if isinstance(items, list) else 0,
|
||
"new_errors": int(data.get("new_errors") or 0), "at": at(ts)})
|
||
names = dict((await session.execute(select(Workflow.id, Workflow.name).where(
|
||
Workflow.id.in_(sorted(w for w in workflows if w))))).all()) if workflows - {""} else {}
|
||
for item in checked:
|
||
item["workflow_name"] = names.get(str(item.get("workflow_id") or ""))
|
||
# 说的是这个答案、还没处理的 recall 待办:挂在这次运行上的(目录有变),和召回运行 origin.uses 里列着它的(数变了)。
|
||
# 运行 id 是 32 位十六进制,在 uses 的 JSON 文字里找子串不会误中,找到的再逐条核对
|
||
candidates = (await session.execute(
|
||
select(Task, Run.origin).join(Run, Run.id == Task.run_id)
|
||
.where(Task.kind == TASK_KIND, Task.status == "pending",
|
||
(Task.run_id == run_id) | ((Run.origin_kind == ORIGIN)
|
||
& json_text(Run.origin, "uses").contains(run_id, autoescape=True)))
|
||
.order_by(Task.created_at, Task.id))).all()
|
||
mine = await _assignments(session, [t.id for t, _ in candidates])
|
||
tasks: list[dict[str, Any]] = []
|
||
for task, origin in candidates:
|
||
key = task.correlation_key or ""
|
||
reason = key.split(":", 1)[0] if key.split(":", 1)[0] in REASONS else "caliber"
|
||
if task.run_id != run_id:
|
||
origin = origin if isinstance(origin, dict) else {}
|
||
if not any(u.get("run_id") == run_id for u in _uses_of(origin, _owner_of_key(key))):
|
||
continue
|
||
if not handles(mine.get(task.id, []), principal):
|
||
continue
|
||
tasks.append({"id": task.id, "reason": reason,
|
||
**({"to_version": (origin or {}).get("to_version")} if reason == "caliber" else {})})
|
||
return {"checked": checked, "basis": basis, "tasks": tasks}
|
||
|
||
|
||
# ==========================================================================
|
||
# 「按新版重新出具」「知道了」
|
||
# ==========================================================================
|
||
|
||
|
||
async def _reissue_plan(session: Any, task: Any, principal: Any, origin: Mapping[str, Any]) -> Any:
|
||
"""按新版重新出具的打算(走按口径出具同一套判法,ledger/issue.check_target:已发布未退役、没有人工节点和写入、参数过
|
||
校验、使用者只限有权使用的应用固定着的那一版、每日上限)。来源记成这条待办列的第一个答案。"""
|
||
from app.ledger import issue as ledger_issue
|
||
from app.ledger.labels import caliber_key
|
||
|
||
workflow_id, version = str(origin.get("workflow_id") or ""), int(origin.get("to_version") or 0)
|
||
plan = ledger_issue.Plan(run_id=task.run_id, workflow_id=workflow_id, version=version,
|
||
caliber=caliber_key(workflow_id, version), how="recall",
|
||
binding=dict(origin.get("binding") or {}))
|
||
workflow = await session.get(Workflow, workflow_id)
|
||
plan.workflow_name = workflow.name if workflow is not None else ""
|
||
answers = _uses_of(origin, _owner_of_key(task.correlation_key or ""))
|
||
source = next((u["run_id"] for u in answers if u.get("kind") == "answer"), None) \
|
||
or next((u["run_id"] for u in answers), None) or task.run_id
|
||
await ledger_issue.check_target(session, plan, workflow, principal=principal, source_run=str(source))
|
||
return plan, str(source)
|
||
|
||
|
||
async def reissue_action(session: Any, task: Any, principal: Any, origin: Mapping[str, Any]) -> dict[str, Any]:
|
||
"""待办里「按新版重新出具」这一项:能不能、不能的原因、已经出具过的那一次。"""
|
||
out: dict[str, Any] = {"decision": "reissue", "available": False}
|
||
if task.status != "pending":
|
||
issued = (task.outcome or {}).get("issued_run")
|
||
return {**out, **({"issued_run": issued} if issued else {})}
|
||
plan, _ = await _reissue_plan(session, task, principal, origin)
|
||
out["available"] = plan.reason is None
|
||
if plan.reason:
|
||
out["reason"] = plan.reason
|
||
if plan.existing:
|
||
out["issued_run"] = plan.existing
|
||
return out
|
||
|
||
|
||
async def reissue(task_id: str, principal: Any) -> Run:
|
||
"""按新版重新出具:经 launch_run 发起一次正式运行(来源 ledger_issue,origin 记来源答案、召回运行、这条待办)。同一个来源、
|
||
同一版、同一组参数出具过的交回那一次。不能出具抛 RecallRefused。"""
|
||
from app.engine.launch import LaunchRefused, launch_run
|
||
from app.ledger import issue as ledger_issue
|
||
from app.process.tasks import Task
|
||
|
||
async with SessionLocal() as session:
|
||
task = await session.get(Task, task_id)
|
||
if task is None:
|
||
raise RecallRefused(404, "待办不存在")
|
||
run = await session.get(Run, task.run_id)
|
||
origin = run.origin if run is not None and isinstance(run.origin, dict) else {}
|
||
plan, source = await _reissue_plan(session, task, principal, origin)
|
||
if plan.reason is not None:
|
||
raise RecallRefused(plan.status, plan.reason, plan.code)
|
||
if plan.existing is not None:
|
||
return await session.get(Run, plan.existing)
|
||
from app.apps.resolve import input_key
|
||
|
||
try:
|
||
return await launch_run(
|
||
session, workflow_id=plan.workflow_id, run_class="formal", version=plan.version,
|
||
input_payload=plan.inputs, actor=principal.username, origin_kind=ledger_issue.ORIGIN,
|
||
origin={"source_run": source, "recall_run": task.run_id, "task_id": task_id, "caliber": plan.caliber,
|
||
"how": "recall", "binding_hash": input_key(plan.binding), "auto": False,
|
||
**({"granted_by_app": plan.granted_by_app} if plan.granted_by_app else {})},
|
||
expect_hash=plan.expect_hash)
|
||
except LaunchRefused as e:
|
||
raise RecallRefused(e.status_code, e.message, e.code or REFUSED) from e
|
||
|
||
|
||
# ==========================================================================
|
||
# 二、目录事实被驳回或修改
|
||
# ==========================================================================
|
||
|
||
|
||
def fact_changes(before: Mapping[str, Any] | None, after: Mapping[str, Any] | None) -> list[dict[str, Any]]:
|
||
"""这次写入改动了的影响数字的项:写入前看得到(没驳回)、写入后被驳回、删掉或改了值的。{path, label, change}。纯函数。"""
|
||
from app.data import catalog
|
||
|
||
old, new = catalog._slots(before), catalog._slots(after)
|
||
out: list[dict[str, Any]] = []
|
||
for key, item in old.items():
|
||
if item.get("status") == "rejected":
|
||
continue
|
||
fact = key[0] == "r" or (key[0], key[-1]) in FACT_SLOTS
|
||
if not fact:
|
||
continue
|
||
now = new.get(key)
|
||
if now is None:
|
||
change = "removed"
|
||
elif now.get("status") == "rejected":
|
||
change = "rejected"
|
||
elif catalog._value_of(key, now) != catalog._value_of(key, item):
|
||
change = "changed"
|
||
else:
|
||
continue
|
||
from app.data.catalog_review import slot_path
|
||
|
||
out.append({"path": slot_path(key), "label": catalog.describe_slot(key), "change": change})
|
||
return out
|
||
|
||
|
||
#: 查询快照一页取几行(按运行结束时间从新到旧翻,每张表凑够 BASIS_MAX_RUNS 次运行就不再往下翻)
|
||
SNAPSHOT_PAGE = 500
|
||
|
||
|
||
async def _explore_snapshots(session: Any, source: Any, tables: Iterable[str], *, before: datetime | None = None
|
||
) -> dict[str, list[tuple[str, str]]]:
|
||
"""查过 source 这几张表的探索运行(计入统计的、成功封存的)和那几条查询快照:{表: [(运行, 快照)]},新的在前,每张表最多
|
||
BASIS_MAX_RUNS 次运行。表名按 name_key 比,表已经不在表结构里也认(表被删了正要找用过它的)。
|
||
|
||
数据源在库里就筛掉(引用的 meta 里记着源 id 和源名,query_snapshot_meta):部署久了查询快照有几万条,别的源的不该读回来;
|
||
按运行结束时间、运行 id 排序分页翻(同一次运行的几条挨在一起),每张表都凑够了就停。表名不在库里筛:meta 里的表名是
|
||
SQL 里写的样子(带 schema 前缀、大小写不一),按 name_key 比只能在这里做。"""
|
||
from sqlalchemy import or_
|
||
|
||
from app.data.catalog import QUERY_SNAPSHOT_KIND
|
||
from app.data.names import name_key
|
||
from app.db.models import Artifact
|
||
from app.engine.origin import counted_runs
|
||
|
||
want: dict[str, list[str]] = {}
|
||
for t in dict.fromkeys(tables):
|
||
want.setdefault(name_key(t), []).append(t)
|
||
out: dict[str, list[tuple[str, str]]] = {t: [] for names in want.values() for t in names}
|
||
runs: dict[str, set[str]] = {t: set() for t in out}
|
||
stmt = (select(Artifact.id, Artifact.meta, Run.id, Run.finished_at).join(Run, Run.id == Artifact.run_id)
|
||
.where(Artifact.kind == QUERY_SNAPSHOT_KIND, Run.run_class == "exploratory", Run.status == "succeeded",
|
||
Run.manifest_hash.is_not(None), counted_runs(),
|
||
or_(json_eq(Artifact.meta, "source_id", source.id),
|
||
json_eq(Artifact.meta, "source", source.name)))
|
||
.order_by(Run.finished_at.desc(), Run.id, Artifact.id))
|
||
cut = _aware(before)
|
||
offset = 0
|
||
last_run: str | None = None
|
||
while want:
|
||
page = (await session.execute(stmt.limit(SNAPSHOT_PAGE).offset(offset))).all()
|
||
offset += len(page)
|
||
for artifact, meta, run_id, finished in page:
|
||
if run_id != last_run and all(len(seen) >= BASIS_MAX_RUNS for seen in runs.values()):
|
||
return out
|
||
last_run = run_id
|
||
if cut is not None and (_aware(finished) is None or _aware(finished) > cut):
|
||
continue
|
||
meta = meta if isinstance(meta, dict) else {}
|
||
if meta.get("source_id") != source.id and meta.get("source") != source.name:
|
||
continue
|
||
hit = {name_key(str(t).rsplit(".", 1)[-1]) for t in meta.get("tables") or []}
|
||
for table in (t for key in hit & want.keys() for t in want[key]):
|
||
if run_id not in runs[table] and len(runs[table]) >= BASIS_MAX_RUNS:
|
||
continue
|
||
runs[table].add(run_id)
|
||
# 翻页之间进来了新的运行,偏移会错开一两行:同一条不收两次
|
||
if (run_id, artifact) not in out[table]:
|
||
out[table].append((run_id, artifact))
|
||
if len(page) < SNAPSHOT_PAGE:
|
||
break
|
||
return out
|
||
|
||
|
||
def _frozen_of(snapshot_id: str, cache: dict[str, Any] | None = None
|
||
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
||
"""查询快照 → (快照内容, 它当时冻结的表结构快照)。读不出为 None。cache 是这一遍里读过的工件({工件 id: 内容}):工件按内容
|
||
寻址、不会变,几十次运行冻结的常常是同一份表结构快照(大库的能有几 MB),同一遍里只读一次。"""
|
||
from app.core.artifact_store import load
|
||
|
||
def read(artifact_id: str) -> Any:
|
||
if cache is not None and artifact_id in cache:
|
||
return cache[artifact_id]
|
||
try:
|
||
content = load(artifact_id)
|
||
except ValueError:
|
||
content = None
|
||
if cache is not None:
|
||
cache[artifact_id] = content
|
||
return content
|
||
|
||
snap = read(snapshot_id)
|
||
if not isinstance(snap, dict):
|
||
return None, None
|
||
schema = read(str(snap["schema_artifact"])) if snap.get("schema_artifact") else None
|
||
return snap, schema if isinstance(schema, dict) else None
|
||
|
||
|
||
def _visible(frozen: Mapping[str, Any] | None, table: str, paths: Iterable[str]) -> list[str]:
|
||
"""冻结的目录里这张表看得到这几项里的哪几项。"""
|
||
from app.data import catalog
|
||
from app.data.names import name_key
|
||
|
||
entries = (frozen or {}).get("catalog") if isinstance((frozen or {}).get("catalog"), dict) else {}
|
||
notes = next((e.get("notes") for t, e in entries.items() if name_key(t) == name_key(table)
|
||
and isinstance(e, dict)), None)
|
||
slots = catalog._slots(notes if isinstance(notes, dict) else {})
|
||
out = []
|
||
for path in paths:
|
||
try:
|
||
key = catalog.parse_path(path)
|
||
except catalog.CatalogPathError:
|
||
continue
|
||
if key in slots and slots[key].get("status") != "rejected":
|
||
out.append(path)
|
||
return out
|
||
|
||
|
||
def new_errors(sql: str, frozen: Mapping[str, Any], current: Mapping[str, Mapping[str, Any]],
|
||
kind: str | None) -> list[dict[str, Any]]:
|
||
"""这条 SQL 按新目录(current:现在的目录)跑 SQL 检查,比按冻结的目录多出来的错误级问题。纯计算。"""
|
||
from app.data.sqlcheck import SqlChecker, frozen_checker
|
||
|
||
def key(c: Any) -> tuple[Any, ...]:
|
||
return (c.code, (c.table or "").lower(), (c.column or "").lower(), c.relation_id)
|
||
|
||
old = frozen_checker(frozen, kind=kind)
|
||
before = {key(c) for c in (old.check(sql) if old is not None else []) if c.level == "error"}
|
||
cache = {"tables": frozen.get("tables") or {}, **({"schema": frozen["schema"]} if frozen.get("schema") else {})}
|
||
after = SqlChecker(kind=kind, schema_cache=cache, notes=dict(current), source_name=frozen.get("source")).check(sql)
|
||
return [{"code": c.code, "message": c.message, "table": c.table, **({"column": c.column} if c.column else {})}
|
||
for c in after if c.level == "error" and key(c) not in before]
|
||
|
||
|
||
async def _basis_marked(session: Any, run_id: str, match: Mapping[str, Any]) -> bool:
|
||
rows = (await session.execute(select(RunEvent.data).where(
|
||
RunEvent.run_id == run_id, RunEvent.type == EventType.LEDGER_BASIS_CHANGED))).scalars()
|
||
return any(isinstance(d, dict) and all(d.get(k) == v for k, v in match.items()) for d in rows)
|
||
|
||
|
||
async def _answer_owner(session: Any, run_id: str) -> list[tuple[str, str]]:
|
||
"""一次探索运行(答案)的主人:对话的 created_by,没有的是发起人。"""
|
||
use = ledger_uses.Use(kind="answer", run_id=run_id, version=0, binding={})
|
||
await ledger_uses._chat_owners(session, [use])
|
||
return use.owners
|
||
|
||
|
||
@dataclass
|
||
class CatalogEdit:
|
||
"""一张表这一次影响数字的改动(目录审阅写入、新码值、处理变化时改了目录):catalog_changed 一遍里可以有好几张表。"""
|
||
|
||
table: str
|
||
changes: Sequence[Mapping[str, Any]]
|
||
catalog_version: int | None = None
|
||
kind: str = "catalog"
|
||
change_id: str | None = None
|
||
|
||
|
||
async def catalog_changed(source_id: str, table: str, changes: Sequence[Mapping[str, Any]], *,
|
||
catalog_version: int | None, kind: str = "catalog", change_id: str | None = None) -> int:
|
||
"""目录里这张表影响数字的几项被驳回或改了(见模块说明二):冻结目录里看得到其中某项的探索运行追加 ledger.basis_changed;
|
||
按新目录多出错误级问题的开 recall 待办(reason=catalog)。返回标了几次运行。同一个数据源的排着做(_catalog_lock)。"""
|
||
return await catalog_pass(source_id, [CatalogEdit(table=table, changes=changes, catalog_version=catalog_version,
|
||
kind=kind, change_id=change_id)])
|
||
|
||
|
||
#: 同一个数据源的目录核对排着做:{事件循环: {数据源: 锁}}。批量审阅几十张表、几个人同时审阅,每一遍都要翻查询快照、读冻结
|
||
#: 的表结构快照,同时跑几十遍会把单进程的服务拖住
|
||
_catalog_locks: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, dict[str, asyncio.Lock]]" = \
|
||
weakref.WeakKeyDictionary()
|
||
|
||
|
||
def _catalog_lock(source_id: str) -> Any:
|
||
from app.cluster import mutex
|
||
|
||
return mutex.lock(f"ledger-catalog:{source_id}")
|
||
|
||
|
||
async def catalog_pass(source_id: str, edits: Sequence[CatalogEdit]) -> int:
|
||
"""一遍核对同一个数据源的几张表(catalog_changed、审阅之后排着的那一批):查询快照只翻一次,冻结的表结构快照同一份只读
|
||
一次。返回标了几次运行。"""
|
||
async with _catalog_lock(source_id):
|
||
return await _catalog_pass(source_id, edits)
|
||
|
||
|
||
async def _catalog_pass(source_id: str, edits: Sequence[CatalogEdit]) -> int:
|
||
from app.data import catalog
|
||
from app.db.models import DataSource
|
||
|
||
# 目录审阅那一种得有改了的项(路径)才核对;新码值、处理时改了目录的没有逐项路径,照样核对
|
||
edits = [e for e in edits if e.kind != "catalog" or any(c.get("path") for c in e.changes)]
|
||
if not edits:
|
||
return 0
|
||
async with SessionLocal() as session:
|
||
source = await session.get(DataSource, source_id)
|
||
if source is None:
|
||
return 0
|
||
found = await _explore_snapshots(session, source, list(dict.fromkeys(e.table for e in edits)))
|
||
current = {t: e.notes for t, e in (await catalog.read_catalog(session, source_id)).items()}
|
||
cache: dict[str, Any] = {}
|
||
marked = 0
|
||
for edit in edits:
|
||
by_run: dict[str, list[tuple[dict[str, Any], dict[str, Any]]]] = {}
|
||
for run_id, snapshot in found.get(edit.table) or []:
|
||
snap, frozen = await asyncio.to_thread(_frozen_of, snapshot, cache)
|
||
if snap is None or frozen is None:
|
||
continue
|
||
by_run.setdefault(run_id, []).append((snap, frozen))
|
||
marked += await _mark_catalog(source, edit, by_run, current)
|
||
return marked
|
||
|
||
|
||
async def _mark_catalog(source: Any, edit: CatalogEdit,
|
||
by_run: Mapping[str, list[tuple[dict[str, Any], dict[str, Any]]]],
|
||
current: Mapping[str, Mapping[str, Any]]) -> int:
|
||
"""一张表的这次改动:冻结目录里看得到改动项的运行追加 ledger.basis_changed,按新目录多出错误级问题的开待办。"""
|
||
from app.engine.run_append import append_events
|
||
|
||
table, changes = edit.table, edit.changes
|
||
paths = [str(c.get("path")) for c in changes if c.get("path")]
|
||
marked = 0
|
||
labels = {str(c.get("path")): c for c in changes}
|
||
for run_id, items in by_run.items():
|
||
seen = sorted({p for _, frozen in items for p in _visible(frozen, table, paths)}) if paths else []
|
||
if paths and not seen:
|
||
continue
|
||
errors: list[dict[str, Any]] = []
|
||
for snap, frozen in items:
|
||
if isinstance(snap.get("sql"), str):
|
||
errors += new_errors(snap["sql"], frozen, current, source.kind)
|
||
match = {"kind": edit.kind, "source_id": source.id, "table": table,
|
||
**({"catalog_version": edit.catalog_version} if edit.catalog_version is not None else {}),
|
||
**({"change": edit.change_id} if edit.change_id else {})}
|
||
async with SessionLocal() as session:
|
||
if await _basis_marked(session, run_id, match):
|
||
continue
|
||
data = {**match, "source": source.name,
|
||
"items": [{"path": p, "label": labels.get(p, {}).get("label"), "change": labels.get(p, {}).get("change")}
|
||
for p in seen], "new_errors": len(errors), "checks": errors[:10]}
|
||
written = await append_events(run_id, [(EventType.LEDGER_BASIS_CHANGED, None, data)], require="sealed")
|
||
if not written:
|
||
continue
|
||
marked += 1
|
||
if errors:
|
||
await _open_basis_task(run_id, written[0].seq)
|
||
return marked
|
||
|
||
|
||
async def _open_basis_task(run_id: str, seq: int) -> list[str]:
|
||
"""答案依据的目录有变、按新目录检查出了新的错误级问题:给答案的主人开一条 recall 待办(reason=catalog,挂在答案的运行
|
||
上,只有「知道了」)。说的是哪一条依据有变记在 correlation_key 里(那条 ledger.basis_changed 的序号,_basis_of 按它读)。"""
|
||
async with SessionLocal() as session:
|
||
run = await session.get(Run, run_id)
|
||
if run is None:
|
||
return []
|
||
owners = await _answer_owner(session, run_id)
|
||
return await open_tasks(run, reason="catalog", owners=owners, title=CATALOG_TITLE, extra=str(seq))
|
||
|
||
|
||
#: 审阅之后排着的目录改动:{事件循环: {数据源: [CatalogEdit]}}。一个数据源一个后台任务(_drain_catalog),批量审阅逐表写完
|
||
#: 逐表调 after_review,这期间(不让出事件循环)进来的几张表合成一遍做;做的时候又进来的,做完这一遍接着做
|
||
_catalog_queues: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, dict[str, list[CatalogEdit]]]" = \
|
||
weakref.WeakKeyDictionary()
|
||
|
||
|
||
def after_review(source_id: str, table: str, before: Mapping[str, Any] | None, after: Mapping[str, Any] | None,
|
||
version: int | None) -> int:
|
||
"""审阅写入之后(data/catalog、catalog_review 调):有影响数字的项被驳回或改了,排进这个数据源的后台任务标依据有变(同一个
|
||
数据源的几张表合成一遍)。返回改了几项。出错只记日志:审阅已经写进去了。"""
|
||
try:
|
||
changes = fact_changes(before, after)
|
||
if changes:
|
||
queues = _catalog_queues.setdefault(asyncio.get_running_loop(), {})
|
||
edit = CatalogEdit(table=table, changes=changes, catalog_version=version)
|
||
if source_id in queues:
|
||
queues[source_id].append(edit)
|
||
else:
|
||
queues[source_id] = [edit]
|
||
spawn(_drain_catalog(source_id))
|
||
return len(changes)
|
||
except Exception: # noqa: BLE001 - 辅助步骤
|
||
logger.exception("审阅写入后排队标依据有变时出错(%s · %s)", source_id, table)
|
||
return 0
|
||
|
||
|
||
async def _drain_catalog(source_id: str) -> None:
|
||
"""一个数据源排着的目录改动一遍一遍做完。一遍出错只记日志,接着做后面排进来的。"""
|
||
queues = _catalog_queues.setdefault(asyncio.get_running_loop(), {})
|
||
try:
|
||
while edits := queues.get(source_id):
|
||
queues[source_id] = []
|
||
try:
|
||
await catalog_pass(source_id, edits)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception: # noqa: BLE001 - 辅助步骤
|
||
logger.exception("审阅之后标依据有变时出错(%s)", source_id)
|
||
finally:
|
||
queues.pop(source_id, None)
|
||
|
||
|
||
# ==========================================================================
|
||
# 三、表结构变动
|
||
# ==========================================================================
|
||
|
||
#: 「依据的表结构已变」认哪几种结构变化(表或列被删、列的类型变了)
|
||
STRUCTURE_KINDS = ("table_removed", "column_removed", "column_type")
|
||
|
||
|
||
def basis_kind(change: Mapping[str, Any]) -> str | None:
|
||
"""一条变化记录算哪一种依据有变(triggers_recall 认的里面):structure / values / catalog_edit;别的结构变化(新加了列、
|
||
注释变了……)不动答案,为 None。"""
|
||
from app.data import source_changes
|
||
|
||
if not source_changes.triggers_recall(change):
|
||
return None
|
||
if (change.get("resolution") or {}).get("catalog_version"):
|
||
return "catalog_edit"
|
||
if change.get("kind") in STRUCTURE_KINDS:
|
||
return "structure"
|
||
if change.get("kind") == source_changes.CODES_NEW:
|
||
return "values"
|
||
return None
|
||
|
||
|
||
async def process_change(change: Mapping[str, Any]) -> int:
|
||
"""处理一条变化记录:用过这张表的探索运行标依据有变;目录有改动的(新码值、处理时改了目录)按新目录查 SQL;表结构变了的
|
||
让受影响的口径试算一次。返回标了几次运行。"""
|
||
kind = basis_kind(change)
|
||
if kind is None or not change.get("table"):
|
||
return 0
|
||
if kind in ("values", "catalog_edit"):
|
||
return await catalog_changed(str(change["source_id"]), str(change["table"]), [],
|
||
catalog_version=(change.get("resolution") or {}).get("catalog_version"),
|
||
kind=kind, change_id=str(change.get("id")))
|
||
marked = await _mark_structure(change)
|
||
await _probe_calibers(change)
|
||
return marked
|
||
|
||
|
||
async def _mark_structure(change: Mapping[str, Any]) -> int:
|
||
from app.db.models import DataSource
|
||
from app.engine.run_append import append_events
|
||
|
||
async with SessionLocal() as session:
|
||
source = await session.get(DataSource, str(change.get("source_id")))
|
||
if source is None:
|
||
return 0
|
||
detected = change.get("detected_at")
|
||
at = datetime.fromisoformat(detected) if isinstance(detected, str) else None
|
||
table = str(change["table"])
|
||
found = (await _explore_snapshots(session, source, [table], before=at)).get(table) or []
|
||
marked = 0
|
||
for run_id in dict.fromkeys(r for r, _ in found):
|
||
match = {"kind": "structure", "change": change.get("id")}
|
||
async with SessionLocal() as session:
|
||
if await _basis_marked(session, run_id, match):
|
||
continue
|
||
data = {**match, "source_id": source.id, "source": source.name, "table": change.get("table"),
|
||
"column": change.get("column"), "change_kind": change.get("kind"), "seq": change.get("seq"),
|
||
"items": [{"label": str(change.get("kind")), "column": change.get("column")}]}
|
||
if await append_events(run_id, [(EventType.LEDGER_BASIS_CHANGED, None, data)], require="sealed") is not None:
|
||
marked += 1
|
||
return marked
|
||
|
||
|
||
async def _probe_calibers(change: Mapping[str, Any]) -> list[str]:
|
||
"""表结构变了:最新的已发布版本里查这张表的口径,按最近一次正式运行的输入(没有就按入口的缺省值)派生数据部分试算一次
|
||
(召回运行,origin.probe)。同一条变化、同一版口径只试算一次。返回发起了的运行。"""
|
||
from app.data.names import name_key
|
||
from app.engine.launch import LaunchRefused, launch_run
|
||
from app.ledger import compose, derive
|
||
from app.ledger import match as ledger_match
|
||
from app.tools.datasource import READ_ONLY_OPTION
|
||
|
||
table = name_key(str(change.get("table") or "").rsplit(".", 1)[-1])
|
||
launched: list[str] = []
|
||
async with SessionLocal() as session:
|
||
calibers = await ledger_match.load_calibers(session)
|
||
for caliber in calibers:
|
||
if not any(f.source == change.get("source_name") and table in f.tables for f in caliber.fetches):
|
||
continue
|
||
async with SessionLocal() as session:
|
||
done = await session.scalar(select(func.count()).select_from(Run).where(
|
||
Run.origin_kind == ORIGIN, json_flag(Run.origin, "probe"),
|
||
json_eq(Run.origin, "workflow_id", caliber.workflow_id),
|
||
json_eq(Run.origin, "version", caliber.version),
|
||
json_eq(Run.origin, "change", change.get("id"))))
|
||
if done:
|
||
continue
|
||
latest = (await session.execute(select(Run.input).where(
|
||
Run.workflow_id == caliber.workflow_id, Run.version == caliber.version, Run.run_class == "formal",
|
||
Run.status == "succeeded").order_by(Run.finished_at.desc(), Run.id.desc()).limit(1))).scalar_one_or_none()
|
||
inputs_from = "formal" if latest else "defaults"
|
||
if not latest:
|
||
# 这一版还没有正式运行:退回最近一次等价于它的问数回答绑定出的参数,再没有才只用入口的缺省值
|
||
found = await ledger_uses.find_uses(session, caliber.workflow_id, only=caliber.version)
|
||
answer = next((u for u in found.uses if u.kind == "answer"), None)
|
||
if answer is not None:
|
||
latest, inputs_from = dict(answer.binding), "answer"
|
||
for card in caliber.cards[:1]:
|
||
pinned = None
|
||
try:
|
||
pinned = await derive.load_pinned(session, caliber.workflow_id, caliber.version, card)
|
||
names = {str(f.get("name")) for f in pinned.entry_fields}
|
||
given = {k: v for k, v in (latest or {}).items() if k in names}
|
||
inputs = compose.check_inputs(pinned, given)
|
||
except (derive.NotDerivable, compose.ComposeRefused) as e:
|
||
# 凑不齐参数(这一版没有正式运行、也没有等价的问数回答,必填的入口字段又没有缺省值)或者派生不了:不试算,
|
||
# 照实记一条缺哪几个参数——日志里一条,变化记录的影响里一条(变化页照它写),不再一声不响地跳过
|
||
await _probe_skipped(change, caliber, pinned, latest, inputs_from, e.message)
|
||
continue
|
||
probe = derive.caliber_probe(pinned)
|
||
origin = {"probe": 1, "workflow_id": caliber.workflow_id, "version": caliber.version,
|
||
"change": change.get("id"), "source_id": change.get("source_id"), "table": change.get("table"),
|
||
"column": change.get("column"), "change_kind": change.get("kind"),
|
||
"inputs_from": inputs_from, "derive": probe.origin}
|
||
try:
|
||
run = await launch_run(session, graph=probe.graph, input_payload=inputs, run_class="exploratory",
|
||
actor=None, origin_kind=ORIGIN, origin=origin,
|
||
options={READ_ONLY_OPTION: True},
|
||
workflow_name=f"口径试算:{pinned.workflow_name} v{caliber.version}")
|
||
except LaunchRefused as e:
|
||
logger.info("表结构变了之后口径试算没能发起(%s v%s):%s", caliber.workflow_id, caliber.version,
|
||
e.message)
|
||
continue
|
||
launched.append(run.id)
|
||
return launched
|
||
|
||
|
||
#: 变化记录 affects 里试算情况的键(data/source_changes 再次探查到同一条变化时保留它)
|
||
PROBES_KEY = "probes"
|
||
|
||
|
||
def missing_inputs(pinned: Any, given: Mapping[str, Any] | None) -> list[str]:
|
||
"""口径入口里必填、又没给值也没有缺省值的字段(按入口字段的先后)。"""
|
||
out = []
|
||
for f in pinned.entry_fields if pinned is not None else ():
|
||
name = str(f.get("name") or "").strip()
|
||
if not name or not f.get("required") or f.get("default") not in (None, ""):
|
||
continue
|
||
if (given or {}).get(name) in (None, ""):
|
||
out.append(name)
|
||
return out
|
||
|
||
|
||
async def _probe_skipped(change: Mapping[str, Any], caliber: Any, pinned: Any, given: Mapping[str, Any] | None,
|
||
inputs_from: str, reason: str) -> None:
|
||
"""表结构变了、这一版口径没能试算:记一条日志,写进变化记录的 affects.probes(同一版只记一条,换成最新的说法)。"""
|
||
from app.db.models_sources import SourceChange
|
||
|
||
missing = missing_inputs(pinned, given)
|
||
name = getattr(pinned, "workflow_name", None) or caliber.workflow_id
|
||
said = f"缺少参数 {'、'.join(missing)}" if missing else reason
|
||
logger.info("表结构变了之后口径「%s」v%s 没有试算:%s(参数来自%s)", name, caliber.version, said,
|
||
{"formal": "最近一次正式运行", "answer": "等价的问数回答", "defaults": "入口的缺省值"}.get(inputs_from, inputs_from))
|
||
note = {"workflow_id": caliber.workflow_id, "version": caliber.version, "name": str(name), "outcome": "skipped",
|
||
"missing": missing, "reason": reason, "inputs_from": inputs_from}
|
||
async with SessionLocal() as session:
|
||
# 先占住那一行再读改写(SQLite 上拿到写锁、PostgreSQL 上锁住这一行):几版口径同时记、探查同时刷新影响
|
||
# (source_changes.record)时一个接一个来,谁也不盖掉谁
|
||
await session.execute(update(SourceChange).where(SourceChange.id == change.get("id"))
|
||
.values(seen_count=SourceChange.seen_count).execution_options(synchronize_session=False))
|
||
row = (await session.execute(select(SourceChange).where(SourceChange.id == change.get("id"))
|
||
.execution_options(populate_existing=True))).scalar_one_or_none()
|
||
if row is None:
|
||
await session.rollback()
|
||
return
|
||
affects = dict(row.affects or {})
|
||
kept = [p for p in affects.get(PROBES_KEY) or [] if isinstance(p, dict)
|
||
and (p.get("workflow_id"), p.get("version")) != (caliber.workflow_id, caliber.version)]
|
||
affects[PROBES_KEY] = [*kept, note]
|
||
row.affects = affects
|
||
await session.commit()
|
||
|
||
|
||
async def _settle_probe(run: Run) -> str:
|
||
"""试算运行结束:算出来了只记结论;没算出来给那一版口径的发布人开 recall 待办(reason=structure)。"""
|
||
from app.apps import versions as app_versions
|
||
|
||
origin = run.origin if isinstance(run.origin, dict) else {}
|
||
ok = run.status == "succeeded"
|
||
tasks: list[str] = []
|
||
if not ok:
|
||
async with SessionLocal() as session:
|
||
row = await app_versions.version_row(session, str(origin.get("workflow_id")), int(origin.get("version") or 0))
|
||
name = await ledger_uses.workflow_name(session, str(origin.get("workflow_id") or ""))
|
||
publisher = getattr(row, "published_by", None) if row is not None else None
|
||
tasks = await open_tasks(run, reason="structure", owners=[("user", publisher)] if publisher else [],
|
||
title=STRUCTURE_TITLE.format(name=name or "口径", version=origin.get("version")))
|
||
await _mark(run.id, {"role": "recall", "probe": True, "outcome": "unchanged" if ok else "failed",
|
||
"changed": None, "workflow_id": origin.get("workflow_id"), "version": origin.get("version"),
|
||
"tasks": tasks, **({} if ok else {"error": str(run.error or "")[:300]})})
|
||
return "unchanged" if ok else "failed"
|
||
|
||
|
||
async def _cursor(session: Any) -> int:
|
||
from app.db.models import Setting
|
||
|
||
row = await session.get(Setting, CURSOR_KEY)
|
||
value = (row.value or {}).get("seq") if row is not None and isinstance(row.value, dict) else None
|
||
return int(value) if isinstance(value, int) else 0
|
||
|
||
|
||
async def _set_cursor(session: Any, seq: int) -> None:
|
||
from app.db.models import Setting
|
||
|
||
row = await session.get(Setting, CURSOR_KEY)
|
||
if row is None:
|
||
session.add(Setting(key=CURSOR_KEY, value={"seq": int(seq)}))
|
||
else:
|
||
row.value = {"seq": int(seq)}
|
||
await session.commit()
|
||
|
||
|
||
_consuming: set[asyncio.AbstractEventLoop] = set()
|
||
|
||
|
||
async def consume_source_changes() -> int:
|
||
"""按 seq 游标拉变化记录(data/source_changes.list_changes),逐条处理(process_change),游标前移。探查、剖析任务结束时
|
||
调。同一时刻只拉一份。返回处理了几条。"""
|
||
from app.data import source_changes
|
||
|
||
if not automatic():
|
||
return 0
|
||
loop = asyncio.get_running_loop()
|
||
if loop in _consuming:
|
||
return 0
|
||
_consuming.add(loop)
|
||
try:
|
||
# 多进程:几个进程的探查、剖析结束时都会来拉,游标是设置表里先读后写的一行——跨进程的锁里一份接一份,后来的接着游标往下拉
|
||
from app.cluster import mutex
|
||
|
||
async with mutex.lock("ledger-consume-changes"):
|
||
return await _consume(source_changes)
|
||
finally:
|
||
_consuming.discard(loop)
|
||
|
||
|
||
async def _consume(source_changes: Any) -> int:
|
||
count = 0
|
||
while True:
|
||
async with SessionLocal() as session:
|
||
since = await _cursor(session)
|
||
batch = await source_changes.list_changes(session, since_seq=since, limit=200)
|
||
if not batch:
|
||
return count
|
||
for change in batch:
|
||
try:
|
||
if basis_kind(change) is not None:
|
||
await process_change(change)
|
||
count += 1
|
||
except Exception: # noqa: BLE001 - 一条出错不挡后面的
|
||
logger.exception("召回处理变化记录 #%s 出错", change.get("seq"))
|
||
async with SessionLocal() as session:
|
||
await _set_cursor(session, int(batch[-1]["seq"]))
|
||
|
||
|
||
# ==========================================================================
|
||
# 发布钩子、启动时接着做、测试用的 drain
|
||
# ==========================================================================
|
||
|
||
|
||
def automatic() -> bool:
|
||
"""自动召回(发布后自动那一批、探查剖析之后拉表结构变化、启动时接着做)开没开:和调度循环同一个开关
|
||
(AGENTLAB_SCHEDULER)。这几样都是在后台替人查业务库(召回运行、口径试算),测试、拷来的数据目录(检查脚本)不做——
|
||
同 ledger/match 的启动补比、定时任务。手动「接着算」、审阅之后标依据有变(只读应用库)不受它管。"""
|
||
from app.core.config import settings
|
||
|
||
return bool(settings.scheduler)
|
||
|
||
|
||
def after_publish(workflow_id: str, version: int, actor: str | None) -> bool:
|
||
"""工作流发布了第 version 版(api/workflows.commit_publish 提交之后):在后台按计划自动召回一批(automatic 关着时不做,
|
||
版本历史里照样列出受影响的答案、能手动接着算)。不挡发布。返回排没排上。"""
|
||
if not automatic():
|
||
return False
|
||
try:
|
||
spawn(launch(workflow_id, int(version), batch="auto", actor=actor))
|
||
return True
|
||
except Exception: # noqa: BLE001 - 辅助步骤
|
||
logger.exception("工作流 %s 发布 v%s 后排队召回出错", workflow_id, version)
|
||
return False
|
||
|
||
|
||
async def resume(*, days: int = RESUME_DAYS) -> dict[str, int]:
|
||
"""启动时:最近 days 天发布过、现在仍是当前发布版的版本,自动那一批没发完的接着发(之后又发布了更新的版本的不再接着做:
|
||
新版本的召回本来就覆盖更早的所有版本);结束了还没收尾的召回运行补上收尾;表结构变化的游标没有时从现在开始(升级之前的
|
||
变化不追)。"""
|
||
from app.db.models import WorkflowVersion
|
||
|
||
since = utcnow() - timedelta(days=days)
|
||
async with SessionLocal() as session:
|
||
rows = (await session.execute(select(WorkflowVersion.workflow_id, WorkflowVersion.version, WorkflowVersion.published_by)
|
||
.join(Workflow, Workflow.id == WorkflowVersion.workflow_id)
|
||
.where(WorkflowVersion.published_at >= since,
|
||
WorkflowVersion.level.is_not(None),
|
||
Workflow.published_version == WorkflowVersion.version))).all()
|
||
unsettled = [r for (r,) in (await session.execute(select(Run.id).where(
|
||
Run.origin_kind == ORIGIN, Run.status.in_(("succeeded", "failed", "cancelled")),
|
||
Run.manifest_hash.is_not(None)))).all()]
|
||
from app.db.models import Setting
|
||
|
||
if await session.get(Setting, CURSOR_KEY) is None:
|
||
from app.db.models_sources import SourceChange
|
||
|
||
top = int(await session.scalar(select(func.max(SourceChange.seq))) or 0)
|
||
await _set_cursor(session, top)
|
||
launched = 0
|
||
for workflow_id, version, actor in rows:
|
||
launched += len(await launch(workflow_id, int(version), batch="auto", actor=actor))
|
||
settled = 0
|
||
for run_id in unsettled:
|
||
settled += await settle(run_id) is not None
|
||
return {"launched": launched, "settled": settled}
|
||
|
||
|
||
def start_resume() -> bool:
|
||
"""启动时在后台接着做(engine/runner 的 setup 调)。和调度循环同一个开关(automatic):拷来的数据目录、测试不替原库补做
|
||
后台的事。"""
|
||
if not automatic():
|
||
return False
|
||
spawn(resume())
|
||
return True
|
||
|
||
|
||
def _mine() -> list[asyncio.Task[Any]]:
|
||
loop = asyncio.get_running_loop()
|
||
return [t for t in _background if t.get_loop() is loop]
|
||
|
||
|
||
async def drain() -> None:
|
||
"""等这个事件循环里召回的后台任务都做完(测试用)。"""
|
||
while True:
|
||
live = [t for t in _mine() if not t.done()]
|
||
if not live:
|
||
return
|
||
await asyncio.gather(*live, return_exceptions=True)
|
||
|
||
|
||
async def shutdown() -> None:
|
||
tasks = _mine()
|
||
for task in tasks:
|
||
task.cancel()
|
||
await asyncio.gather(*tasks, return_exceptions=True)
|
||
|
||
|
||
__all__ = ["BASIS_KINDS", "BATCHES", "CURSOR_KEY", "OUTCOMES", "ORIGIN", "REASONS", "TASK_KIND", "CatalogEdit",
|
||
"Group", "Pair", "Plan", "RecallRefused", "after_publish", "after_review", "answer_marks", "automatic",
|
||
"basis_kind", "batch_of_key", "batches", "build_graph", "build_plan", "card_values", "catalog_changed",
|
||
"catalog_pass", "compare", "consume_source_changes", "drain", "fact_changes", "launch", "new_errors",
|
||
"normalize", "open_tasks", "original_values", "outcome_of", "preview", "process_change", "recall_card",
|
||
"recall_runs", "reissue", "reissue_action", "resume", "schedule_settle", "settle", "shutdown",
|
||
"source_cards", "spawn", "start_resume", "task_payload", "version_recall_counts"]
|