Files
AgentLab/backend/app/ledger/reconcile.py
T

658 lines
37 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.
"""口径对账:路径二b,差在哪、差多少(docs/design/next-round/02-口径账本.md 做法 6,总方案 X3)。
**差在哪**在比对时就有(ledger/match:explain_sql_diff 的条目,这次 / 口径两边说),不跑查询。**差多少**要点开才跑:
按那一版口径确定性派生一张只读小图(ledger/derive.reconcile_graph,全站唯一的派生图生成器),跑一次探索运行
(来源 reconcile,服务端专用、系统派生、不进使用统计):
入口(口径的入口字段,缺省值是这次的参数)→「这次的取数」ours_q1(执行过的 SQL 原文)→ 口径的取数(模板里保留
{{ input.x }})→ 口径卡(caliber_from 固定那一版)→(多行时)按维度对齐的合并查询 align → 对账卡(这次、口径、差值、
相对差)→ 出口(契约 metrics_from 指向对账卡;不加报告、不调模型)
两边在同一次运行里查,差值在对账卡里算(叙述层无算术,也不在 Python 里算:compare.py 是同一工作流两次运行配对用的)。
面板上每个数都点得开 /evidence/metrics 的出处链。
**参数。** 默认用比对时绑定出来的参数(缺的补缺省值)。参数是启发得来的(绑定在 heuristic 一级配上)、有没绑上的、补完
缺省值表单还不齐、或者里面有遮罩值时,要人先核对:动作里写 needs_check 和原因,发起时要带 confirm 和 inputs(按口径入口
的字段校验,ledger/compose.check_inputs 同一套),界面写「参数按这次的 SQL 推断,请核对」。
**列怎么配。** 只有一行结果时:口径卡上的指标式就是取数第一行某一列(cell(nodes.取数, 0, '列'))的,按那一列在口径取数
结果里的位置配这次结果同一位置的列(等价时已实测列的先后计;不等价的两条 SQL 选择列表一样时位置照样对得上)。比对时
按引用的列投影过的(候选的 projection,ledger/match 的 _projected),按投影之后两边留下的列逐个配(这次多选的列不算)。差异
有兜底说明(fallback:两条 SQL 对不上结构)、列数不同、一个都配不上时不猜,要人选(pairs:{ours: 这次的列, metric: 口径卡
上的指标 id}),这个选择记进对账运行的 origin。
**多行结果**(这次的结果不止一行):被报告引用了的那几行(最多 MAX_ROWS 行),按维度列(不是数的列)经合并查询和口径
取数的结果按键对齐(LEFT JOIN,按引用的先后排),每个被引用的行、每个被引用的列出一组对账指标。两边的列按位置配(列数
不同时不猜,照实拒绝)。维度列是遮罩列、或者这次的结果里合并键不唯一时照实拒绝:前者对齐要用到遮住的值,后者对不准
是哪一行。被引用的列是遮罩列的同样不对账。
只读:派生图只查库、不写;使用者发起时带上 MASK_RESULTS / READ_ONLY(同问数据),搭建者也带 READ_ONLY。对账运行记在
使用者名下,但它的原始记录、事件、运行时的图不给使用者(api/deps.run_events_readable 只给问数据的运行):图里原样是口径
取数节点的 SQL 模板,他经账本和证据接口看结果(展示的 SQL 按遮罩换过)。
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import Run, Workflow
ORIGIN = "reconcile"
#: 被拒的机器码
REFUSED = "ledger_reconcile_refused"
CHECK_INPUTS = "ledger_reconcile_check_inputs"
NEEDS_PAIRS = "ledger_reconcile_needs_pairs"
FORBIDDEN = "ledger_reconcile_forbidden"
#: 多行对账最多几行、几列(每行每列出四个对账指标)
MAX_ROWS = 10
MAX_COLUMNS = 5
#: 给人看的说法
NOT_FOUND = "运行记录不存在"
NO_ACCESS = "没有权限查看这次运行"
NOT_MATCHED = "这次运行还没有比对口径,比对完才能对账"
NO_QUERY = "这次运行的比对记录里没有这条查询"
NOT_RELATED = "这条查询没有和「{name}」v{version} 比出差异(只有相关口径才对账)"
NO_CARD = "「{name}」v{version} 里没有读这个取数节点的口径卡,对不了账"
SNAPSHOT = "这条查询的快照读不出来(文件缺失或与哈希不一致),对不了账"
CHECK_REASON = "参数按这次的 SQL 推断,请核对"
CHECK_MASKED = "参数里有遮罩值,请核对参数"
CHECK_INCOMPLETE = "按这次的 SQL 没能推断出全部参数,请补全并核对"
CONFIRM_FIRST = "参数按这次的 SQL 推断,发起前请先核对参数"
PAIRS_FALLBACK = "两条 SQL 的结构对不上,认不出这次的哪一列对应口径的哪个指标,请选择要对照的指标"
PAIRS_NONE = "认不出这次的哪一列对应口径的哪个指标,请选择要对照的指标"
PAIR_COLUMN = "这次的结果里没有列「{column}」"
PAIR_MASKED = "列「{column}」设置了遮罩,不能对账"
PAIR_METRIC = "口径卡「{card}」上没有指标「{metric}」"
PAIRS_SHAPE = "要对照的指标需要写成一组「这次的列 → 口径卡上的指标」的对照"
ROWS_NONE = "报告里没有引用这条查询的格,不知道要对哪几行"
ROWS_NO_KEY = "这次的结果有多行,但没有可以用来对齐的维度列(不是数的列)"
ROWS_MASKED_KEY = "用来对齐的维度列「{column}」设置了遮罩,不能对账"
ROWS_DUPLICATE = "这次的结果里按维度列({columns})有重复的行,对不准是哪一行"
ROWS_COLUMNS = "这次的结果和口径的取数列数不同({ours} 列对 {theirs} 列),按位置对不上"
ROWS_CALIBER_COLUMNS = "认不出口径取数的结果列,按位置对不上"
class ReconcileRefused(Exception):
"""不能对账。extra 是给界面的几样(并进 409 的返回体):认不出列怎么配时的 options(可选的列、口径卡上的指标),界面
照它画「选要对照的指标」那一步,不自己去猜有哪些列。"""
def __init__(self, status_code: int, message: str, code: str = REFUSED, *,
extra: dict[str, Any] | None = None) -> None:
super().__init__(message)
self.status_code = status_code
self.message = message
self.code = code
self.extra = dict(extra or {})
# ==========================================================================
# 动作:GET 账本里能对的账
# ==========================================================================
def _needs_check(cand: dict[str, Any], fields: list[dict[str, Any]], hide: Any) -> str | None:
"""参数要不要人先核对:要的回原因(见模块说明),不要的为 None。fields 是比对工件里记的入口字段(name、required、
has_default)。"""
binding = cand.get("binding") or {}
if hide is not None and any(hide(v) for v in binding.values()):
return CHECK_MASKED
if cand.get("unbound") or not binding and any(f.get("name") for f in fields):
return CHECK_INCOMPLETE
if any(f.get("required") and not f.get("has_default") and f.get("name") not in binding for f in fields):
return CHECK_INCOMPLETE
if cand.get("via") in (None, "heuristic"):
return CHECK_REASON
return None
def actions_of(content: dict[str, Any], names: dict[str, str], hide: Any = None) -> list[dict[str, Any]]:
"""比对工件里每条有相关口径的查询、每份相关口径一项「看差多少」:{kind: reconcile, query, workflow_id, version, node_id,
needs_check, check_reason?, binding(遮过的), fields}。界面据此摆按钮、要不要先核对参数;fields 是那一版口径的入口字段
(名字、必填、有没有缺省值,比对工件里记的那一份),核对参数的表单照它画(缺的参数也写得出是哪一项)。"""
from app.ledger.labels import LEVEL_TEXT, binding_view
hide = hide or (lambda _value: False)
fields = {c.get("caliber"): c.get("fields") or [] for c in content.get("calibers") or []}
out: list[dict[str, Any]] = []
for query in content.get("queries") or []:
if query.get("status") not in ("related",):
continue
for cand in query.get("candidates") or []:
if cand.get("status") != "related" or cand.get("comparable") is False:
continue
why = _needs_check(cand, list(fields.get(cand.get("caliber")) or []), hide)
item = {"kind": "reconcile", "query": query.get("artifact"), "caliber": cand.get("caliber"),
"workflow_id": cand.get("workflow_id"), "version": cand.get("version"),
"node_id": cand.get("node_id"), "workflow_name": names.get(str(cand.get("workflow_id") or "")),
"level_text": LEVEL_TEXT.get(str(cand.get("level") or ""), ""),
"needs_check": why is not None, "binding": binding_view(cand.get("binding"), hide),
"fallback": bool((cand.get("diff") or {}).get("fallback")),
"fields": [{"name": str(f.get("name")), "required": bool(f.get("required")),
"has_default": bool(f.get("has_default"))}
for f in fields.get(cand.get("caliber")) or [] if f.get("name")]}
if why is not None:
item["check_reason"] = why
out.append(item)
return out
async def reconciled_runs(session: Any, run_id: str) -> list[Run]:
"""对这次运行对过的账(origin.source_run 是它),新的在前。"""
from app.db.portable import json_eq
rows = (await session.execute(select(Run).where(
Run.origin_kind == ORIGIN, json_eq(Run.origin, "source_run", run_id))
.order_by(Run.created_at.desc(), Run.id.desc()))).scalars()
return list(rows)
#: 对账卡上一组对照的四个指标:id 的前缀(tail_nodes 的 _four)和名字的后缀
_FOUR = (("ours", "这次"), ("caliber", "口径"), ("diff", "差值"), ("rel", "相对差"))
_PREFIX = {"ours": "ours_", "caliber": "cal_", "diff": "diff_", "rel": "rel_"}
def reconcile_groups(graph: Any) -> list[dict[str, str]]:
"""对账运行的图里,对账卡上的每一组对照:{label, ours, caliber, diff, rel}(后四个是指标 id)。按卡上的先后;名字
是「<这一组的说法> · 这次」那样写的,label 去掉后缀。认不出的(不是派生的对账图)为空。"""
from app.ledger.derive import RECONCILE_CARD
nodes = (graph or {}).get("nodes") if isinstance(graph, dict) else None
card = next((n for n in nodes or [] if isinstance(n, dict) and n.get("id") == RECONCILE_CARD
and n.get("type") == "metrics"), None)
metrics = (((card or {}).get("data") or {}).get("config") or {}).get("metrics") or []
by_id = {str(m.get("id")): m for m in metrics if isinstance(m, dict) and m.get("id")}
out: list[dict[str, str]] = []
for mid, metric in by_id.items():
if not mid.startswith(_PREFIX["ours"]):
continue
n = mid[len(_PREFIX["ours"]):]
ids = {role: f"{_PREFIX[role]}{n}" for role, _ in _FOUR}
if not all(i in by_id for i in ids.values()):
continue
name = str(metric.get("name") or "")
label = name[:-len(" · 这次")] if name.endswith(" · 这次") else name
out.append({"label": label, **ids})
return out
def reconciled_view(run: Run) -> dict[str, Any]:
"""GET 账本 reconciled 里的一项:哪一次对账运行、状态、对的哪条查询和哪份口径、参数从哪来;card 和 groups 是对账卡和
每一组对照的四个指标(界面按 /evidence/metrics/{card}/{指标} 取值、点开出处,不按命名规则自己拼)。"""
from app.ledger.derive import RECONCILE_CARD
origin = run.origin if isinstance(run.origin, dict) else {}
return {"run_id": run.id, "status": run.status, "query": origin.get("query"), "caliber": origin.get("caliber"),
"workflow_id": origin.get("workflow_id"), "version": origin.get("version"),
"inputs_from": origin.get("inputs_from"), "rows": origin.get("rows"),
"card": RECONCILE_CARD, "groups": reconcile_groups(run.graph),
"created_at": run.created_at.isoformat() if run.created_at else None}
# ==========================================================================
# 派生图
# ==========================================================================
@dataclass
class Pair:
"""一组对照:这次结果的哪一列,对口径卡上的哪个指标(metric)或口径取数结果的哪一列(column)。"""
ours: str
metric: str | None = None
column: str | None = None
#: 口径卡上那个指标的位置(metric 配对时)
index: int | None = None
name: str = ""
unit: str = ""
decimals: Any = None
def describe(self) -> dict[str, Any]:
return {"ours": self.ours, **({"metric": self.metric} if self.metric else {}),
**({"caliber": self.column} if self.column else {})}
@dataclass
class Plan:
"""一次对账要派生的东西(纯计算的结果,测试锁住确定性)。"""
pairs: list[Pair]
rows: list[int] | None = None
keys: list[tuple[str, str]] = field(default_factory=list)
key_values: list[tuple[Any, ...]] = field(default_factory=list)
def _lit(value: Any) -> str:
"""一个值写进合并查询(SQLite)的字面量。"""
if value is None:
return "NULL"
if isinstance(value, bool):
return "1" if value else "0"
if isinstance(value, (int, float)):
return repr(value)
return "'" + str(value).replace("'", "''") + "'"
def _ident(name: str) -> str:
return '"' + str(name).replace('"', '""') + '"'
def _equals(alias: str, column: str, value: Any) -> str:
return f"{alias}.{_ident(column)} IS NULL" if value is None else f"{alias}.{_ident(column)} = {_lit(value)}"
def align_sql(keys: list[tuple[str, str]], pairs: list[Pair], values: list[tuple[Any, ...]]) -> str:
"""多行对账的合并查询:这次(o)LEFT JOIN 口径(c)按维度列对齐,只留被引用的那几行,按引用的先后排。"""
select_ = [f"o.{_ident(ours)} AS {_ident(ours)}" for ours, _ in keys]
select_ += [f"o.{_ident(p.ours)} AS {_ident(f'ours_{j}')}, c.{_ident(p.column or '')} AS {_ident(f'cal_{j}')}"
for j, p in enumerate(pairs)]
on = " AND ".join(f"o.{_ident(ours)} = c.{_ident(theirs)}" for ours, theirs in keys)
rows = ["(" + " AND ".join(_equals("o", ours, v) for (ours, _), v in zip(keys, row)) + ")" for row in values]
order = " ".join(f"WHEN {cond} THEN {i}" for i, cond in enumerate(rows))
return (f"SELECT {', '.join(select_)} FROM o LEFT JOIN c ON {on} WHERE {' OR '.join(rows)} "
f"ORDER BY CASE {order} END")
def _metric(mid: str, name: str, expression: str, *, unit: str = "", decimals: Any = None,
fmt: str | None = None) -> dict[str, Any]:
out: dict[str, Any] = {"id": mid, "name": name, "unit": unit, "expression": expression}
if decimals is not None:
out["decimals"] = decimals
if fmt:
out["format"] = fmt
return out
def _four(n: str, label: str, ours: str, theirs: str, pair: Pair) -> list[dict[str, Any]]:
"""一组对照的四个对账指标:这次、口径、差值(这次 − 口径)、相对差(差值 ÷ 口径,口径是 0 时没有值)。"""
diff = f"({ours}) - ({theirs})"
return [
_metric(f"ours_{n}", f"{label} · 这次", ours, unit=pair.unit, decimals=pair.decimals),
_metric(f"cal_{n}", f"{label} · 口径", theirs, unit=pair.unit, decimals=pair.decimals),
_metric(f"diff_{n}", f"{label} · 差值", diff, unit=pair.unit, decimals=pair.decimals),
_metric(f"rel_{n}", f"{label} · 相对差", f"({diff}) / ({theirs}) if ({theirs}) else None", decimals=4,
fmt="percent_of_ratio"),
]
def tail_nodes(plan: Plan, *, card_id: str, fetch_id: str) -> list[dict[str, Any]]:
"""派生图的 tail:(多行时)对齐的合并查询 + 对账卡。card_id、fetch_id 是图里口径卡、口径取数节点的 id。"""
from app.ledger.derive import ALIGN_ID, OURS_ID, RECONCILE_CARD, _node
metrics: list[dict[str, Any]] = []
nodes: list[dict[str, Any]] = []
if plan.rows is None:
for j, pair in enumerate(plan.pairs):
ours = f"cell(nodes.{OURS_ID}, 0, {pair.ours!r})"
theirs = (f"nodes.{card_id}.metrics[{pair.index}].value" if pair.metric is not None
else f"cell(nodes.{fetch_id}, 0, {pair.column!r})")
metrics += _four(str(j), pair.name or pair.ours, ours, theirs, pair)
else:
nodes.append(_node(ALIGN_ID, "merge", "按维度对齐", inputs={"o": OURS_ID, "c": fetch_id},
sql=align_sql(plan.keys, plan.pairs, plan.key_values)))
for i, row in enumerate(plan.key_values):
where = ",".join(f"{ours}={v}" for (ours, _), v in zip(plan.keys, row))
# 先核对第 i 行就是这一组的维度:重跑时这次的结果缺了一行、某个维度多出一行(口径那边重复),合并结果会错位,
# 按位置取到的是别的维度的数。对不上的记空值(对账卡 on_missing=null),不拿别的维度的数冒充
guard = _row_guard(i, plan.keys, row)
for j, pair in enumerate(plan.pairs):
ours = f"cell(nodes.{ALIGN_ID}, {i}, 'ours_{j}')"
theirs = f"cell(nodes.{ALIGN_ID}, {i}, 'cal_{j}')"
if guard:
ours, theirs = f"({ours} if {guard} else None)", f"({theirs} if {guard} else None)"
metrics += _four(f"{i}_{j}", f"{where} · {pair.name or pair.ours}", ours, theirs, pair)
nodes.append(_node(RECONCILE_CARD, "metrics", "对账卡", caliber="口径对账", caliber_version="v1", on_missing="null",
metrics=metrics))
return nodes
def _row_guard(i: int, keys: list[tuple[str, str]], values: tuple[Any, ...]) -> str:
"""对齐结果第 i 行是不是这一组(维度列的值都对得上)的条件式;维度值是空值的那一列不核对(表达式里没有空值的写法)。"""
import ast
from app.engine.expressions import _literal
parts = [f"cell(nodes.{_align_id()}, {i}, {ours!r}) == {ast.unparse(_literal(v))}"
for (ours, _), v in zip(keys, values) if v is not None and not isinstance(v, (list, dict))]
return " and ".join(parts)
def _align_id() -> str:
from app.ledger.derive import ALIGN_ID
return ALIGN_ID
def _numeric(rows: list[Any], index: int) -> bool:
values = [r[index] for r in rows if isinstance(r, (list, tuple)) and index < len(r) and r[index] is not None]
return bool(values) and all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in values)
def _plain_cell(expression: str, fetch: str) -> str | None:
"""指标式就是取数第一行某一列(cell(nodes.<fetch>, 0, '列'))时交回列名,别的写法为 None。"""
import ast
from app.engine.expressions import cell_parts
parts = cell_parts(expression.strip())
if parts is None or parts[0] != f"nodes.{fetch}" or parts[1].strip() != "0":
return None
try:
column = ast.literal_eval(parts[2])
except (ValueError, SyntaxError):
return None
return column if isinstance(column, str) else None
def plan_pairs(*, ours_columns: list[str], ours_rows: list[Any], hidden: set[str], caliber_columns: list[str] | None,
metrics: list[dict[str, Any]], fetch: str, card: str, fallback: bool, chosen: Any,
referenced: list[tuple[int, str]], projected: Any = None, cells: Any = None,
caliber: str | None = None) -> Plan:
"""配列、(多行时)定行和键:交回 Plan,配不了的抛 ReconcileRefused(见模块说明)。projected 是比对候选的 projection
(按引用的列投影过的:{ours: [这次留下的列], caliber: [口径留下的列]},按位置一一对应)。cells 是这条查询逐格比的结论
(ledger/match._cells),caliber 是这份口径的键:要人选对照时按它预选(_suggested)。纯计算。"""
def check_column(column: str) -> None:
if column not in ours_columns:
raise ReconcileRefused(422, PAIR_COLUMN.format(column=column))
if column.lower() in hidden:
raise ReconcileRefused(409, PAIR_MASKED.format(column=column))
by_id = {str(m.get("id")): (i, m) for i, m in enumerate(metrics)}
multi = len(ours_rows) > 1
# 要人选对照时给界面的选项:这次结果里是数、没遮罩的列,口径卡上的指标(id、名字、单位)
numeric = [c for i, c in enumerate(ours_columns) if c.lower() not in hidden and _numeric(ours_rows, i)]
options = {"options": {"columns": numeric,
"metrics": [{"id": str(m.get("id")), "name": str(m.get("name") or m.get("id")),
"unit": str(m.get("unit") or "")} for m in metrics if m.get("id")],
"suggested": _suggested(cells, caliber, fetch, numeric, metrics)}}
if multi:
return _plan_rows(ours_columns, ours_rows, hidden, caliber_columns, referenced, check_column)
if chosen is not None:
if not isinstance(chosen, list) or not chosen or not all(isinstance(p, dict) for p in chosen):
raise ReconcileRefused(422, PAIRS_SHAPE, NEEDS_PAIRS, extra=options)
pairs = []
for item in chosen:
ours, metric = str(item.get("ours") or ""), str(item.get("metric") or "")
check_column(ours)
if metric not in by_id:
raise ReconcileRefused(422, PAIR_METRIC.format(card=card, metric=metric))
index, m = by_id[metric]
pairs.append(Pair(ours=ours, metric=metric, index=index, name=str(m.get("name") or metric),
unit=str(m.get("unit") or ""), decimals=m.get("decimals")))
return Plan(pairs=pairs)
if fallback:
raise ReconcileRefused(409, PAIRS_FALLBACK, NEEDS_PAIRS, extra=options)
pairs = []
kept = _projected_pairs(projected, ours_columns)
if kept is not None:
# 按投影之后留下的列配:这次多选的列不算,口径一侧只看投影留下的那几列(列名不分大小写)
for index, m in enumerate(metrics):
column = _plain_cell(str(m.get("expression") or ""), fetch)
ours = next((o for o, c in kept if column is not None and c.lower() == column.lower()), None)
if ours is None or ours.lower() in hidden:
continue
pairs.append(Pair(ours=ours, metric=str(m.get("id")), index=index, name=str(m.get("name") or m.get("id")),
unit=str(m.get("unit") or ""), decimals=m.get("decimals")))
elif caliber_columns is not None and len(caliber_columns) == len(ours_columns):
for index, m in enumerate(metrics):
column = _plain_cell(str(m.get("expression") or ""), fetch)
if column is None or column not in caliber_columns:
continue
ours = ours_columns[caliber_columns.index(column)]
if ours.lower() in hidden:
continue
pairs.append(Pair(ours=ours, metric=str(m.get("id")), index=index, name=str(m.get("name") or m.get("id")),
unit=str(m.get("unit") or ""), decimals=m.get("decimals")))
if not pairs:
raise ReconcileRefused(409, PAIRS_NONE, NEEDS_PAIRS, extra=options)
return Plan(pairs=pairs)
def _suggested(cells: Any, caliber: str | None, fetch: str, columns: list[str],
metrics: list[dict[str, Any]]) -> list[dict[str, str]]:
"""要人选对照时预选哪几对:[{ours, metric}]。按逐格比的结论(ledger/match._cells)——这次结果的这一列单独比,和这份口径
取数节点的某一列等价(逐格证明了,projection.caliber 是那一列),口径卡上只读这一列这一格的指标就是它最可能的对照。只认
证明了的:只是「相关」的那一格比到的是第一个试的列,不是最像的,不替人猜;一个口径列对上几个指标的也不预选。界面照它
预选、人可以改。"""
from app.engine.expressions import ExpressionError, cell_parts, leaf_refs, parse_expression
if not isinstance(cells, dict) or not caliber:
return []
reads: dict[str, list[str]] = {}
for m in metrics:
try:
leaves = leaf_refs(parse_expression(str(m.get("expression") or ""))[0])
except ExpressionError:
continue
cols = {_plain_cell(str(leaf), fetch) for leaf in leaves if cell_parts(str(leaf)) is not None}
if len(leaves) == 1 and len(cols) == 1 and None not in cols and m.get("id"):
reads.setdefault(str(next(iter(cols))).lower(), []).append(str(m["id"]))
out: list[dict[str, str]] = []
for col in columns:
cell = cells.get(col)
cands = cell.get("candidates") if isinstance(cell, dict) else None
proven = next((c for c in cands or [] if isinstance(c, dict) and c.get("status") == "proven"
and c.get("caliber") == caliber and c.get("node_id") == fetch), None)
theirs = ((proven or {}).get("projection") or {}).get("caliber") if proven else None
if not isinstance(theirs, list) or len(theirs) != 1 or not isinstance(theirs[0], str):
continue
hits = reads.get(theirs[0].lower()) or []
if len(hits) == 1:
out.append({"ours": col, "metric": hits[0]})
return out
def _projected_pairs(projected: Any, ours_columns: list[str]) -> list[tuple[str, str]] | None:
"""比对候选的 projection → [(这次的列, 口径的列)],按位置一一对应;不是投影过的、两边列数不一样、这次的列不在结果里的
为 None(照旧按整条的位置配)。"""
if not isinstance(projected, dict):
return None
ours, theirs = projected.get("ours"), projected.get("caliber")
if not isinstance(ours, list) or not isinstance(theirs, list) or not ours or len(ours) != len(theirs):
return None
if any(not isinstance(o, str) or o not in ours_columns or not isinstance(c, str) for o, c in zip(ours, theirs)):
return None
return list(zip(ours, theirs))
def _plan_rows(ours_columns: list[str], rows: list[Any], hidden: set[str], caliber_columns: list[str] | None,
referenced: list[tuple[int, str]], check_column: Any) -> Plan:
if caliber_columns is None:
raise ReconcileRefused(409, ROWS_CALIBER_COLUMNS)
if len(caliber_columns) != len(ours_columns):
raise ReconcileRefused(409, ROWS_COLUMNS.format(ours=len(ours_columns), theirs=len(caliber_columns)))
keys = [i for i in range(len(ours_columns)) if not _numeric(rows, i)]
if not keys:
raise ReconcileRefused(409, ROWS_NO_KEY)
if masked := [ours_columns[i] for i in keys if ours_columns[i].lower() in hidden]:
raise ReconcileRefused(409, ROWS_MASKED_KEY.format(column=masked[0]))
tuples = [tuple(r[i] if isinstance(r, (list, tuple)) and i < len(r) else None for i in keys) for r in rows]
if len(set(map(json.dumps, [list(t) for t in tuples]))) != len(tuples):
raise ReconcileRefused(409, ROWS_DUPLICATE.format(columns="、".join(ours_columns[i] for i in keys)))
wanted_rows = list(dict.fromkeys(r for r, _ in referenced if isinstance(r, int) and 0 <= r < len(rows)))[:MAX_ROWS]
columns = list(dict.fromkeys(c for _, c in referenced if c in ours_columns and ours_columns.index(c) not in keys))
if not wanted_rows or not columns:
raise ReconcileRefused(409, ROWS_NONE)
for column in columns:
check_column(column)
pairs = [Pair(ours=c, column=caliber_columns[ours_columns.index(c)], name=c) for c in columns[:MAX_COLUMNS]]
return Plan(pairs=pairs, rows=wanted_rows, keys=[(ours_columns[i], caliber_columns[i]) for i in keys],
key_values=[tuples[r] for r in wanted_rows])
def _referenced(sealed: Any, artifact: str) -> list[tuple[int, str]]:
"""报告里引用了这条查询的哪几格:[(行, 列)],按正文的先后(单元格、表格里的格)。"""
from app.api.evidence import _reports
from app.engine.evidence import iter_units
out: list[tuple[int, str]] = []
for report in _reports(sealed):
doc = report.doc if report.hash_ok is True else None
if not isinstance(doc, dict):
continue
catalog = doc.get("catalog") if isinstance(doc.get("catalog"), dict) else {}
for _, unit in iter_units(doc):
for seg in unit.get("segments") or []:
cite = seg.get("cite") if isinstance(seg, dict) and isinstance(seg.get("cite"), dict) else {}
entry = catalog.get(cite.get("alias")) if isinstance(catalog.get(cite.get("alias")), dict) else {}
locator = cite.get("locator") if isinstance(cite.get("locator"), dict) else {}
if cite.get("kind") == "cell" and entry.get("artifact") == artifact \
and isinstance(locator.get("row"), int) and isinstance(locator.get("column"), str):
out.append((locator["row"], locator["column"]))
return out
def _caliber_columns(template: str, inputs: dict[str, Any], fields: list[dict[str, Any]], dialect: str | None,
schema: dict[str, Any] | None) -> list[str] | None:
"""口径取数按这次的参数渲染之后选出来的列名(按位置);认不出的为 None。"""
from app.data.sql_semantics import _read, _result_columns, _schema_info, render_bound
try:
rendered = render_bound(template, inputs, fields)
return _result_columns(rendered, _read(dialect), _schema_info(schema))
except Exception: # noqa: BLE001 - 认不出就按认不出说
return None
# ==========================================================================
# 发起
# ==========================================================================
@dataclass
class Request:
query: str
workflow_id: str
version: int
inputs: dict[str, Any] | None = None
confirm: bool = False
pairs: Any = None
async def reconcile(run_id: str, req: Request, *, principal: Any) -> Run:
"""对这次运行的一条查询和一份相关口径对一次账:派生只读图、发起探索运行(来源 reconcile)。不能对的抛 ReconcileRefused。"""
from app.api.deps import can_read_run
from app.api.evidence import _Masks, _sealed
from app.core.artifact_store import load
from app.engine.launch import LaunchRefused, launch_run
from app.ledger import compose, derive, store
from app.ledger.issue import _conversation_of
from app.ledger.labels import caliber_key
from app.ledger.match import _frozen
from app.ledger.view import _hider
from app.tools.datasource import MASK_RESULTS_OPTION, QUERY_PREFIX, READ_ONLY_OPTION
async with SessionLocal() as session:
run = await session.get(Run, run_id)
if run is None:
raise ReconcileRefused(404, NOT_FOUND)
if principal.mode != "off" and not await can_read_run(principal, run):
raise ReconcileRefused(403, NO_ACCESS, FORBIDDEN)
recorded = await store.latest(run_id)
if recorded is None or recorded.content is None:
raise ReconcileRefused(409, NOT_MATCHED)
content = recorded.content
key = caliber_key(req.workflow_id, int(req.version))
query = next((q for q in content.get("queries") or [] if q.get("artifact") == req.query), None)
if query is None:
raise ReconcileRefused(404, NO_QUERY)
async with SessionLocal() as session:
workflow = await session.get(Workflow, req.workflow_id)
name = workflow.name if workflow is not None else req.workflow_id
cand = next((c for c in query.get("candidates") or [] if c.get("caliber") == key and c.get("status") == "related"),
None)
if cand is None:
raise ReconcileRefused(409, NOT_RELATED.format(name=name, version=req.version))
summary = next((c for c in content.get("calibers") or [] if c.get("caliber") == key), {})
pinned = None
async with SessionLocal() as session:
for card_id in summary.get("cards") or []:
try:
candidate = await derive.load_pinned(session, req.workflow_id, int(req.version), str(card_id))
except derive.NotDerivable:
continue
if any(node == cand.get("node_id") for node, _, _ in candidate.reads):
pinned = candidate
break
if pinned is None:
raise ReconcileRefused(409, NO_CARD.format(name=name, version=req.version))
# 参数:比对时绑定出来的(补缺省值),要核对的要人带着核对过的参数再来
sealed = await _sealed(run_id)
hider = await _hider(sealed)
why = _needs_check(cand, list(summary.get("fields") or []), hider.hide)
if req.confirm and req.inputs is not None:
try:
inputs = compose.check_inputs(pinned, req.inputs)
except compose.ComposeRefused as e:
raise ReconcileRefused(422, e.message, CHECK_INPUTS) from e
inputs_from = "checked"
elif why is not None:
raise ReconcileRefused(409, CONFIRM_FIRST if why == CHECK_REASON else why, CHECK_INPUTS)
else:
try:
inputs = compose.check_inputs(pinned, dict(cand.get("binding") or {}))
except compose.ComposeRefused as e:
raise ReconcileRefused(409, f"{CHECK_INCOMPLETE}:{e.message}", CHECK_INPUTS) from e
inputs_from = "binding"
# 这次的那条查询:快照、遮罩列、被引用的格
try:
snap = load(req.query)
except ValueError:
snap = None
if not isinstance(snap, dict) or not isinstance(snap.get("sql"), str) or req.query not in sealed.queries:
raise ReconcileRefused(409, SNAPSHOT)
source = str(snap.get("source") or query.get("source") or "")
columns = [str(c) for c in snap.get("columns") or []]
rows = snap.get("rows") if isinstance(snap.get("rows"), list) else []
masks = _Masks(sealed)
hidden = set((await masks.sets(req.query, source, snap)).hidden)
kind = await masks.kind(source)
schema, _ = _frozen(snap.get("schema_artifact"))
fetch = next(n for n in pinned.fetches if n["id"] == cand.get("node_id"))
template = str(((fetch.get("data") or {}).get("config") or {}).get("args", {}).get("sql") or "")
plan = plan_pairs(ours_columns=columns, ours_rows=rows, hidden=hidden,
caliber_columns=_caliber_columns(template, inputs, list(pinned.entry_fields), kind, schema),
metrics=pinned.metrics, fetch=str(fetch["id"]), card=pinned.card_title,
fallback=bool((cand.get("diff") or {}).get("fallback")), chosen=req.pairs,
referenced=_referenced(sealed, req.query), projected=cand.get("projection"),
cells=query.get("cells"), caliber=key)
graph = build(pinned, sql=snap["sql"], tool=f"{QUERY_PREFIX}{source}", plan=plan, inputs=inputs,
fetch=str(fetch["id"]))
async with SessionLocal() as session:
origin = {"source_run": run_id, **await _conversation_of(session, run), "query": req.query, "caliber": key,
"workflow_id": req.workflow_id, "version": int(req.version), "inputs_from": inputs_from,
"binding_via": cand.get("via"), "pairs": [p.describe() for p in plan.pairs],
**({"rows": plan.rows} if plan.rows is not None else {}),
**({"pairs_chosen": True} if req.pairs is not None else {}), "derive": graph.origin}
options = {READ_ONLY_OPTION: True}
if principal.mode != "off" and not principal.at_least("builder"):
options[MASK_RESULTS_OPTION] = True
try:
return await launch_run(session, graph=graph.graph, input_payload=inputs, run_class="exploratory",
actor=principal.username, origin_kind=ORIGIN, origin=origin, options=options,
workflow_name=f"口径对账:{name} v{req.version}")
except LaunchRefused as e:
raise ReconcileRefused(e.status_code, e.message, e.code or REFUSED) from e
def build(pinned: Any, *, sql: str, tool: str, plan: Plan, inputs: dict[str, Any], fetch: str) -> Any:
"""按 Plan 派生对账图(ledger/derive.reconcile_graph)。fetch 是这条查询比的那个口径取数节点(那一版里的 id)。
纯计算,同样的输入同一张图。"""
from app.ledger import derive
prefix = derive.reconcile_prefix(pinned)
tail = tail_nodes(plan, card_id=f"{prefix}{pinned.node_id}", fetch_id=f"{prefix}{fetch}")
return derive.reconcile_graph(pinned, derive.ours_block(sql, tool), tail, inputs=inputs)
__all__ = ["CHECK_INPUTS", "MAX_COLUMNS", "MAX_ROWS", "NEEDS_PAIRS", "ORIGIN", "REFUSED", "Pair", "Plan",
"ReconcileRefused", "Request", "actions_of", "align_sql", "build", "plan_pairs", "reconcile",
"reconcile_groups", "reconciled_runs", "reconciled_view", "tail_nodes"]