mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 18:22:00 +00:00
7589c350bd
复核第 3 轮(91aa34d)找到三处 major、两处 minor:循环里卡转抄自己上一轮、更早一轮取过号码(SQL 带模板)时,写作目录只看 运行状态里最后一次的产出,号码进了报告正文和成果,自查通过、正式运行成功,封存之后的面板才写「已遮罩」;整张卡、清单文字、 下标不是字面量的转抄只按值认,由遮罩格算出来的年薪(月薪 × 12)在写作目录、报告、成果、证据面板、可验证报告里照给;显示 文字的白名单里还收着易混字(’ 当千分位、刂 当竖线、×10⁴ / T / bn / m 改量级、en dash 当负号),伪造页总览照样是绿色的 「全部通过」;先起别名再套没起别名的表达式、+phone 这类 SQL 写法认不出遮罩列;段内软换行紧挨着数字、数字前的 ¥ ¥ 被判 无法核对,原样导出的正常报告总览变成中性。 转抄的保守规则(recompute.masked_closure 的 CopyEdge.card;写作目录、报告自查、证据面板、推演、可验证报告同一个判断) - 卡上的输入转抄上游口径卡(字面量下标、下标不是字面量、整张卡、清单文字、整份指标列表都算),只要那张卡在这次运行的任何 一次执行里有一个指标不给,转抄出来的就不给(masked,整张卡转抄不再降成 table 按值认);那张卡一个不给的指标都没有时照常, 没设遮罩的工作流、循环里累加自己上一轮的卡照给(第 3 轮修好的那条不回退)。最小不动点上按卡汇总:每张卡「有指标不给」 只从无到有、遮罩列只增不减,变一次只重认转抄它的那几个,工作量仍和边数成正比 - 每一次执行的指标集按台账认:写作目录(withheld_metrics)把运行状态里台账的 metric_set 条目交给 _CopyGraph(_executions, 连同它们的输入先在线程里读好),封存之后按封存范围内的台账;可验证报告(_Store._executions)按 node.finished 的台账条目 - 转抄的是哪张卡按读到的产出上记着的指标集认(经数据整形赋给变量的也认得出是哪张卡),认不出的按取值链上的节点 - 推演(recompute._copied)、证据面板(_copied_held)随之去掉整张卡转抄的 table 一档 - 行为变化:转抄了带遮罩指标的卡的普通指标(客户数、数指标个数、清单字数)也写「已遮罩」、推演不让改,正式运行引用它判 失败;那张卡自己的普通指标照给。test_report_masked_card_copies、test_report_masked_copy_closure 里按老规则写的期望相应改了 显示文字核对(verifier.js) - 弯撇号(U+2018、U+2019)移出常见写法的名单;偏旁形的汉字(刂冫氵亻阝扌忄犭礻衤讠钅饣纟丬)、康熙部首、部首补充、扩展 B 区以后的字不算常见写法:紧挨着数字判无法核对 - 数字后面紧接 ×、*、^ 再接数字判无法核对(新原因「后面紧接着乘号或乘方和数字」),后面那个数本身也核对过的(两个数相乘) 不算 - 数字后面紧接着、或隔着空白和左括号的英文量级写法(T、G、m、b,bn、mn、tn,thousand、million、billion、trillion)按整串 字母认,判无法核对;ms、GB 这类别的单位不算 - en dash 归入像负号的短横:紧挨在数字前面判无法核对(不判不通过),前面是数字、字母的区间照常 - 段内的换行(页面上显示成一个空格)、金额前面的「¥」「¥」收进名单 - 「(单位:万元)」、x10 这类隔着几个字或换了写法的认不出:名单只是兜底,bundle/build.py 的模块说明写明真伪以封存哈希为准 已知局限不再加规则:api/evidence.py、bundle/build.py 的模块说明写明遮罩只减少暴露、不是安全边界,列出认不出的 SQL 写法 (先起别名再套没起别名的表达式、+phone、Postgres 给没起别名的表达式起的列名、视图、自己建的函数、数据整形取出来的号码)。 验证 - 新增 tests/test_report_masked_copy_any_run.py(20 项):纯函数上的边规则(四种写法、循环三轮、之前认好的指标、读到的产出 读不出来);循环里转抄更早一轮的号码(护栏写法、只按字面量下标两种)写作目录写「已遮罩」、正式运行判失败,标注时文档、 成果、面板、可验证报告都没有号码;循环之后的卡转抄最后一轮,写作目录、面板、推演、可验证报告同一个判断,可验证报告自己 (没有报告的运行、老文档)也认;经数据整形转抄的按卡本身认;年薪的五种转抄写法写作目录都写「已遮罩」,标注时文件里没有 年薪;没碰号码列的循环、卡上没有遮罩指标的照常 - test_bundle_shown:复核点名的几类各自判无法核对、写明原因、总览中性;软换行、¥ ¥、两个核对过的数相乘、en dash 区间、 ms / GB 照常通过;PROSE 样本去掉 en dash,加上软换行和 ¥ ¥,17 处全部通过 - check-bundle 补弯撇号、刂、扩展 B 区、×10⁴、bn、billion、en dash 七种篡改(各自判无法核对、点名、总览中性),prose 样本 17 处全绿 - 复核者探针:test_probe_r3a、test_probe_r3c 的写法都拦住;test_probe_n1c(没设遮罩的循环自引用)照常;layout_probe 的 108 种写法里点名的几类都判无法核对,¥ ¥ 照常通过;Chrome 断网打开 forge3、forge4 的伪造页,点名的几类都不是绿的、提示 都显示 - 开发数据 39 份报告 1591 处、验收数据 58 份 1695 处引用按新规则扫过,判不通过和无法核对都是 0 - 每处改动都做了「去掉实现、测试变红」:17 个定点变异(不看整张卡、不读每一次执行、写作目录不交台账、可验证报告不读每一次 执行、整张卡退回按值认、推演不认、按取值链上的节点认卡两处;弯撇号、偏旁、扩展 B 区、乘号乘方、英文量级、en dash、软换行、 ¥ ¥、两数相乘豁免)全部变红
1621 lines
95 KiB
Python
1621 lines
95 KiB
Python
"""数字推演的重算:在一张口径卡上改几个输入的值、排除查询快照里的几行,算出指标会变成多少。
|
||
|
||
推演永远不是出具。这里只算数:不读库、不写工件、不追加事件。接口(api/whatif.py)把这次运行封存范围内取得到的
|
||
工件交进来(Sources),结果原样交回给点开证据面板的人,运行记录和报告一个字都不动。
|
||
|
||
口径卡怎么算的,这里就怎么算:同一条原式、同一个求值器(expressions.eval_expression),取整用口径卡的同一个函数
|
||
(metrics.round_metric,只取整浮点),渲染走 evidence.render_metric。重算前先把每个输入当时的值还原出来,卡上记着的
|
||
就用卡上的:
|
||
|
||
- 标量输入(数、文本、布尔、空值):卡上记的 value 就是当时代进去的值。cell() 取的格、agent 的单格字段和数组里的
|
||
一个元素也是标量,它们落在查询快照的哪一行记在 locator 里:排除了那一行,这个输入就当缺失,指标标「排除后无法计算」
|
||
- 容器输入卡上只记了「[12 项]」这样的大概(metrics._brief),完整值另找:
|
||
- agent 的数组字段:agent 节点封存的产出里的 data;第 k 个元素是快照的第 a + k 行(locator.rows),排除行就去掉对应的元素
|
||
- nodes.* / input.*:那个节点在口径卡执行之前最后一次的产出工件
|
||
- vars.*:有 value_artifact(口径卡当时另存的完整值)才还原得出来;老运行照实说当时没有保存
|
||
- 容器本身是一份查询结果(带 artifact 的 {columns, rows},或者和某份快照的 rows 一模一样的列表):排除行就去掉那几行
|
||
- 遮罩列取来的输入不给值、也不拿来推演:改一个输入、排除一行,新值和原值一比就能算出遮住的那一格。vars.res.rows[0][2]
|
||
这种按位置取自查询结果的标量没有定位,顺着取值链认出是哪一列(positional_cell,证据面板的指标步骤用同一个认法);
|
||
下标不是字面量的(rows[vars.i][2])按列认,整行(rows[0])按整行的列认(wide)。
|
||
转抄上游口径卡的输入(nodes.<卡>.metrics[0].value、nodes.<卡>.text)按那张卡认:调用方在指标依赖图上一次认好
|
||
(masked_closure,证据面板、写作目录、可验证报告同一个函数),交进来(Sources.copied),转抄的那张卡在这次运行的任何一次
|
||
执行里有一个指标取自遮罩格就不给。
|
||
指标本身的原值给不给(withheld_value,证据面板指标步骤的值同一个判断):用到了遮罩列的一格、一行,原值写「已遮罩」——
|
||
指标的值可能就是那一格,输入里拿掉了、指标上照给,等于没遮;最外层的输入遮没遮核对不了,写「无法核对」。整份结果
|
||
当输入(len(vars.res.rows))、认不出出处的输入(数据整形算出来再赋给变量的一格),按值认:值里有这次运行查到过的
|
||
遮罩值(Sources.secrets)才不给,数行数的照给。认不出出处、值就是遮罩值的输入同样不给值、不推演
|
||
- 落在读不出来(文件不在、与哈希不一致)的查询快照上的输入同样不给值、不推演:那一格遮没遮核对不了,宁可多遮
|
||
|
||
有的指标不论改什么都推演不了(_Plan.blocked):卡上没记全输入、用到了遮罩列、最外层的输入还原不了(容器的完整值
|
||
找不回来、所在的快照读不出来)。这种不改动也照实交回 unsupported / masked 和原因,输入一个都不让改、不给行段:
|
||
界面一打开就只写原因,不写「无变化」,也不摆改了、勾了也推演不出来的输入框和勾选框。
|
||
|
||
同一张口径卡的指标不能互相引用(算这张卡时卡上的值还不存在),所以只有用到了被改的输入、被排除的行的指标需要重算,
|
||
别的原样交回。排除行时有两种例外,都只能说不知道(unsupported),不能报「无变化」:
|
||
- 指标的某个输入对不上任何快照(完整值找不回来的容器、加工过的结果、按位置从结果里取的一格、由查询结果算出来的数),
|
||
它可能就是从被排除了行的那份结果来的,重算不了。写它的节点在那份快照交回之前就跑完了的,影响不到它,照常不算
|
||
- 决定取哪一格的容器(cell(nodes.q, len(vars.res.rows) - 1, …) 里的 vars.res.rows)少了几行:取到的是哪一格说不准。
|
||
这种容器也就不给可勾的行段(勾了只能说不知道);决定取哪一格的单格照给,勾掉它确定是排除后无法计算
|
||
给了新值的以新值为准:只嵌在给了新值的引用里的输入(cell(…) 那一格给了新值,它行号里的 vars.res.rows)取到哪一格
|
||
已无关紧要,它那一行排除了、它说不准受不受影响都不碍事(_Plan.moot)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import ast
|
||
import copy
|
||
import json
|
||
import math
|
||
import re
|
||
from collections.abc import Callable, Iterable, Mapping
|
||
from dataclasses import dataclass, field
|
||
from typing import Any
|
||
|
||
from app.core.errors import describe_exception
|
||
from app.engine.evidence import DEFAULT_FORMAT, MISSING, RenderError, is_number, iter_units, render_metric, render_number
|
||
from app.engine.expressions import (
|
||
ExpressionError,
|
||
_cell_call,
|
||
_chain_root,
|
||
eval_expression,
|
||
leaf_refs,
|
||
parse_expression,
|
||
substitute,
|
||
)
|
||
from app.engine.nodes.metrics import _REF_HEAD, _brief, round_metric
|
||
|
||
NO_LINEAGE = "这个指标生成于系统升级前,口径卡没有记录输入,不能推演"
|
||
NO_FULL_VALUE = "当时没有保存这个输入的完整值"
|
||
NOT_RESTORABLE = "这类输入当时没有保存完整值"
|
||
MASKED_INPUT = "这一列在数据源中设置了遮罩,不能用于推演"
|
||
MASKED_TABLE = "这份查询结果里有设置了遮罩的列,不能用于推演"
|
||
MASKED_METRIC = "这个指标用到了设置了遮罩的列,不能推演"
|
||
#: 转抄上游口径卡的输入,转抄的是取自遮罩列的指标(Sources.copied,和证据面板同一张依赖图)
|
||
MASKED_COPY = "转抄的口径卡指标取自设置了遮罩的列,不能用于推演"
|
||
#: 认不出出处的输入(数据整形算出来再赋给变量的一格),值和这次运行查到过的遮罩值一样(按值认,Sources.secrets)
|
||
MASKED_VALUE_INPUT = "这个输入的值与设置了遮罩的列中的值相同,不能用于推演"
|
||
MASKED_VALUE_METRIC = "这个指标的值或输入与设置了遮罩的列中的值相同,不能推演"
|
||
#: 按位置取自查询结果的一格(vars.res.rows[0][2])遮没遮核对不了:取值链上节点的产出读不出来,或者那份结果没存快照、
|
||
#: 只带着数据源名(查询当时记下的遮罩在快照里)。和读不出来的快照一样宁可多遮(_positional)
|
||
POSITION_UNREADABLE = "查询结果无法读取,核对不了这一格是否取自设置了遮罩的列"
|
||
POSITION_NO_SNAPSHOT = "查询结果没有保存快照,核对不了这一格是否取自设置了遮罩的列"
|
||
#: recompute(双盲复算的预检、盲值)里整个指标重算不了的说法:同 _Plan.blocked,只是不说「推演」——那里不是推演
|
||
NO_LINEAGE_PLAIN = "这个指标生成于系统升级前,口径卡没有记录输入"
|
||
MASKED_PLAIN = "这个指标用到了设置了遮罩的列"
|
||
SELECTOR = "这个输入决定取哪一行、哪一项,修改它需要重新取数,不能推演"
|
||
EXCLUDED = "排除后无法计算"
|
||
UNREADABLE = "查询快照无法读取"
|
||
TAMPERED = "查询快照与哈希不一致,疑似被修改"
|
||
#: 不能改、也没有行可勾的输入(_fixed)
|
||
FIXED_CONTAINER = "列表或对象不能直接修改,也没有对应的查询结果可以排除行"
|
||
NOT_NUMBER = "不是数值,不能修改"
|
||
#: 排除了行,却说不准这个输入受不受影响(_doubt)
|
||
DOUBT = "排除行对 {path} 的影响无法确定:{why}"
|
||
DOUBT_UNMATCHED = "它对不上任何查询快照,可能是由查询结果加工而来"
|
||
DOUBT_DERIVED = "它不是直接取自查询结果的格,可能由查询结果算出"
|
||
DOUBT_SELECTOR = "它决定取哪一行、哪一项,排除行后需要重新取数"
|
||
NOT_FINITE = "计算结果不是有限的数值(溢出或非数),无法推演"
|
||
|
||
#: 卡上给容器记的大概(metrics._brief):「[12 项]」「{3 个字段}」
|
||
_BRIEF = re.compile(r"^(?:\[\d+ 项\]|\{\d+ 个字段\})$")
|
||
#: 重算时最外层引用换成的占位名。不在 EXPRESSION_ROOTS 里,原式自己写不出这种名字
|
||
_PLACEHOLDER = "__whatif_{}"
|
||
|
||
|
||
class WhatIfError(ValueError):
|
||
"""推演请求本身不合法:路径不存在、值不是数、行号越界、工件不属于这次运行……接口回 422,消息原样给人看。"""
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Changes:
|
||
"""一次推演的改动:inputs 是输入路径 → 新值;exclude 是查询快照的工件 id → 要排除的行号(从 0 数)。"""
|
||
|
||
inputs: Mapping[str, Any] = field(default_factory=dict)
|
||
exclude: Mapping[str, frozenset[int]] = field(default_factory=dict)
|
||
|
||
|
||
@dataclass
|
||
class Sources:
|
||
"""还原输入要用到的东西,由调用方从这次运行的封存范围里准备好。
|
||
|
||
- load:工件 id → 内容。只认封存范围内的工件,范围外的、文件不在的返回 None,内容和哈希对不上抛 ValueError
|
||
(artifact_store.load 的约定)
|
||
- queries:封存范围内的查询快照,按在事件里第一次出现的顺序。排除行只认这些快照;容器按内容对快照时也按这个顺序
|
||
- outputs:节点 id → 口径卡执行之前它最后一次的产出工件(nodes.*、input.* 的容器、agent 的数组字段从这里还原)
|
||
- masked:查询快照 → 要遮的列(小写):查询当时记下的加数据源现在设的。只用到 get,调用方可以给一个用到哪份
|
||
才算哪份的映射(快照读不出来时照样按台账记的数据源算)
|
||
- returned:查询快照 → 它第一次交回时的事件序号;finished:节点 → 口径卡执行之前它最后一次跑完的事件序号。
|
||
排除行只可能影响在那份快照交回之后才算出来的值(_doubt)。不知道的一律当可能受影响
|
||
- secrets:这次运行查到过的遮罩值(masking._Secrets,没有为 None),用到时才取:认不出出处的输入、整份结果当输入的
|
||
指标,值给不给按它认(withheld_value)。没给的(双盲复算)不按值认
|
||
- copied:卡上转抄上游口径卡的输入给不给值,指标在卡上的位置 → 输入路径 → (种类, 原因),种类同 Judged.copied。
|
||
调用方按证据面板同一张指标依赖图认好(api/evidence.py 的 copied_inputs → masked_closure);没给的(双盲复算)当
|
||
认不出出处
|
||
"""
|
||
|
||
load: Callable[[str], Any]
|
||
queries: tuple[str, ...] = ()
|
||
outputs: Mapping[str, str] = field(default_factory=dict)
|
||
masked: Mapping[str, frozenset[str]] = field(default_factory=dict)
|
||
returned: Mapping[str, int] = field(default_factory=dict)
|
||
finished: Mapping[str, int] = field(default_factory=dict)
|
||
secrets: Callable[[], Any] | None = None
|
||
copied: Mapping[int, Mapping[str, tuple[str | None, str | None]]] = field(default_factory=dict)
|
||
|
||
|
||
class _Store:
|
||
"""一次推演里取过的工件:同一份快照、产出只读一次。get 给 (内容, 问题),问题是 None、missing 或 tampered。"""
|
||
|
||
def __init__(self, sources: Sources) -> None:
|
||
self.sources = sources
|
||
self._memo: dict[str, tuple[Any, str | None]] = {}
|
||
|
||
def get(self, artifact: Any) -> tuple[Any, str | None]:
|
||
key = str(artifact or "")
|
||
if key not in self._memo:
|
||
try:
|
||
content = self.sources.load(key) if key else None
|
||
self._memo[key] = (content, None if content is not None else "missing")
|
||
except ValueError:
|
||
self._memo[key] = (None, "tampered")
|
||
return self._memo[key]
|
||
|
||
def snapshot(self, artifact: Any) -> dict[str, Any] | None:
|
||
content, _ = self.get(artifact)
|
||
return content if isinstance(content, dict) and isinstance(content.get("rows"), list) else None
|
||
|
||
def output(self, node_id: Any) -> tuple[Any, str | None]:
|
||
artifact = self.sources.outputs.get(str(node_id)) if node_id else None
|
||
return self.get(artifact) if artifact else (None, "missing")
|
||
|
||
|
||
@dataclass
|
||
class _Input:
|
||
item: dict[str, Any]
|
||
path: str
|
||
#: scalar:卡上记的就是当时的值;container:卡上只记了大概,value 是另找来的完整值
|
||
shape: str = "scalar"
|
||
value: Any = None
|
||
#: 完整值找不回来的原因(只有容器会有)
|
||
problem: str | None = None
|
||
#: 落在哪份查询快照上:单格是 row,数组字段是一段行 rows,table 是整份结果(或它的 rows)
|
||
snapshot: str | None = None
|
||
row: int | None = None
|
||
rows: tuple[int, int] | None = None
|
||
table: bool = False
|
||
masked: bool = False
|
||
#: 落在的查询快照读不出来(文件不在、与哈希不一致):遮没遮核对不了,值不给,也不拿来推演
|
||
unreadable: bool = False
|
||
#: 出现在别的输入的下标、cell() 的行列里:它决定取哪一格,改它要重新取数
|
||
selector: bool = False
|
||
#: 认得出取自哪份查询结果(落在快照上,或者顺着取值链认出来)。认不出的按值认遮罩(withheld_value)
|
||
traced: bool = False
|
||
#: 认不出出处,值里却有这次运行查到过的遮罩值(按值认的遮罩)
|
||
by_value: bool = False
|
||
#: 遮着的是一整份结果(或者值里有遮罩值的容器、整张转抄、读到的产出里有指标核对不了的口径卡):整个指标推演不了,
|
||
#: 可它不一定把遮罩值带进指标的值,指标的原值按值认(len(vars.res.rows) 照给)
|
||
whole: bool = False
|
||
#: 转抄上游口径卡(Sources.copied 里有它):给不给按依赖图认好了,不再按取值链、按值认
|
||
copy: bool = False
|
||
|
||
@property
|
||
def cell(self) -> bool:
|
||
"""取自遮罩列的一格、一行(或者值就是遮罩值的一格):指标的值可能就是它,原值不给。"""
|
||
return self.masked and not (self.table or self.whole)
|
||
|
||
|
||
@dataclass
|
||
class _Plan:
|
||
metric: dict[str, Any]
|
||
tree: ast.Expression | None = None
|
||
#: 最外层的引用,按出现顺序:重算时换成占位名的就是这些
|
||
tops: list[str] = field(default_factory=list)
|
||
inputs: dict[str, _Input] = field(default_factory=dict)
|
||
problem: str | None = None
|
||
#: problem 不说「推演」的说法(recompute 给双盲复算用);和 problem 一样时不填
|
||
plain: str | None = None
|
||
#: 嵌在别的引用里的输入 → 包着它的最外层引用(_refs)
|
||
within: dict[str, set[str]] = field(default_factory=dict)
|
||
#: 指标本身的原值给不给(withheld_value):None 照给,masked 写「已遮罩」,unverified 写「无法核对」
|
||
held: str | None = None
|
||
|
||
def settled(self, path: str, changes: Changes) -> bool:
|
||
"""包着这个输入的最外层引用都给了新值:它决定取哪一格已无关紧要,那几个引用以新值为准。"""
|
||
outer = self.within.get(path)
|
||
return bool(outer) and all(top in changes.inputs for top in outer)
|
||
|
||
def moot(self, path: str, changes: Changes) -> bool:
|
||
"""这个输入这次用不上:只嵌在别的引用里,包着它的又都给了新值。它那一行排除了、它说不准受不受影响,都碍不着
|
||
重算。它自己也是最外层引用的不算:那一处照常拿它的值。"""
|
||
return path not in self.tops and self.settled(path, changes)
|
||
|
||
@property
|
||
def blocked(self) -> tuple[str, str] | None:
|
||
"""整个指标推演不了(不论改什么):(status, 原因),推演得了的是 None。
|
||
|
||
卡上没记全输入、表达式有误;用到了遮罩列(哪怕只是嵌在下标里的一格),或者值里有这次运行查到过的遮罩值(按值认,
|
||
指标的原值不给时也算:新值一减就是原值);最外层的输入还原不了——容器的完整值找不回来,或者所在的查询快照读不
|
||
出来。嵌在下标里的输入还原不了不碍事:它取到的那一格的值卡上记着。
|
||
遮罩排在还原不了前面:同一格既遮着、快照又读不出来时,说遮罩(快照修好了它照样遮着)。
|
||
"""
|
||
if self.problem:
|
||
return "unsupported", self.problem
|
||
masked = [inp for inp in self.inputs.values() if inp.masked]
|
||
if masked or self.held == "masked":
|
||
return "masked", MASKED_METRIC if any(not inp.by_value for inp in masked) else MASKED_VALUE_METRIC
|
||
for path in self.tops:
|
||
if problem := self.inputs[path].problem:
|
||
return "unsupported", f"无法推演:{path}({problem})"
|
||
return None
|
||
|
||
@property
|
||
def hides(self) -> bool:
|
||
"""指标的原值也不给(held,withheld_value 同一个判断)。成立时一定推演不了(blocked 不为 None),新值本来就是空的。"""
|
||
return self.held is not None
|
||
|
||
@property
|
||
def stuck(self) -> str | None:
|
||
"""blocked 的原因换成不说「推演」的说法:recompute 交给双盲复算的预检、盲值,那里说「不能推演」驴唇不对马嘴。
|
||
判断和先后与 blocked 一字不差,只换措辞;推演得了的是 None。"""
|
||
blocked = self.blocked
|
||
if blocked is None:
|
||
return None
|
||
if self.problem:
|
||
return self.plain or self.problem
|
||
if blocked[0] == "masked":
|
||
return MASKED_PLAIN
|
||
path = next(p for p in self.tops if self.inputs[p].problem)
|
||
return f"输入 {path} 无法还原({self.inputs[path].problem})"
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 入口
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def whatif(card: dict[str, Any], changes: Changes, sources: Sources, *, metric: str | None = None) -> dict[str, Any]:
|
||
"""在一张口径卡上推演:{metrics: [每个指标的旧值、新值、输入…], unsupported: [不能用于推演的输入和原因],
|
||
maybe_changed: 有没有指标可能因这次改动而变}。
|
||
|
||
metric 给了就只推演这一个指标。改动不合法时抛 WhatIfError,一个指标都不算。没碰到改动的指标不重算,
|
||
原值原样交回(status: unchanged);整个推演不了的指标(_Plan.blocked)不论有没有改动都交回 unsupported / masked
|
||
和原因。unsupported 不论有没有改动都列全(遮罩的、还原不了的、不能改也没有行可勾的;整个指标推演不了的另有一条
|
||
input 为空的),界面一打开就能说清哪些输入不能动。
|
||
|
||
maybe_changed 只给调用方决定要不要标下游口径卡:改动碰到了某个指标,它又算出来变了或者说不准(推演不了的指标
|
||
新值不知道)。推演不了的指标 changed 一律为 None,只看它分不出「什么都没改」。
|
||
"""
|
||
store = _Store(sources)
|
||
metrics = [(i, m) for i, m in enumerate(card.get("metrics") or [])
|
||
if isinstance(m, dict) and m.get("id") is not None]
|
||
if metric is not None:
|
||
metrics = [(i, m) for i, m in metrics if str(m["id"]) == str(metric)]
|
||
if not metrics:
|
||
raise WhatIfError(f"口径卡中没有指标「{metric}」")
|
||
plans = [_plan(m, store, copied=sources.copied.get(i)) for i, m in metrics]
|
||
_check(changes, plans, store, metric=metric)
|
||
outcomes = [_outcome(p, changes, sources) for p in plans]
|
||
return {"metrics": [result for result, _ in outcomes], "unsupported": _unsupported(plans),
|
||
"maybe_changed": any(moved for _, moved in outcomes)}
|
||
|
||
|
||
def recompute(metric: dict[str, Any], changes: Changes, sources: Sources) -> dict[str, Any]:
|
||
"""重算一个指标,不管改动碰没碰到它,也不校验改动。核对还原是否忠实时用:什么都不改,重算出来应当和卡上记的一样。
|
||
|
||
调用方是双盲复算(预检、按盲算的输入代回原式),不是推演:整个指标重算不了时原因不说「推演」(_Plan.stuck)。"""
|
||
plan = _plan(metric, _Store(sources))
|
||
result = _outcome(plan, changes, sources, force=True)[0]
|
||
if (plain := plan.stuck) is not None:
|
||
result["reason"] = plain
|
||
return result
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 还原输入
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def _plan(metric: dict[str, Any], store: _Store, *, copied: Mapping[str, tuple[str | None, str | None]] | None = None
|
||
) -> _Plan:
|
||
expr = str(metric.get("expression") or "").strip()
|
||
items = metric.get("inputs")
|
||
if not expr or not isinstance(items, list):
|
||
# 卡上没记输入(升级前的卡):认不出出处,原值按值认
|
||
return _Plan(metric, problem=NO_LINEAGE, plain=NO_LINEAGE_PLAIN,
|
||
held=withheld_value(metric, metric.get("value"), uncertain=True, secrets=store.sources.secrets))
|
||
try:
|
||
tree, _, _ = parse_expression(expr)
|
||
except ExpressionError as e:
|
||
return _Plan(metric, problem=f"表达式有误({e})",
|
||
held=withheld_value(metric, metric.get("value"), uncertain=True, secrets=store.sources.secrets))
|
||
tops, within = _refs(tree)
|
||
remaining = _remaining(metric.get("substituted"), [*tops, *within])
|
||
inputs: dict[str, _Input] = {}
|
||
for item in items:
|
||
if isinstance(item, dict) and isinstance(item.get("path"), str) and item["path"] not in inputs:
|
||
inputs[item["path"]] = _restore(item, remaining, set(within), store, (copied or {}).get(item["path"]))
|
||
plan = _Plan(metric, tree, tops, inputs, within=within)
|
||
if lost := [p for p in tops if p not in inputs]:
|
||
plan.plain = f"口径卡缺少输入 {'、'.join(lost[:3])} 的记录"
|
||
plan.problem = f"{plan.plain},不能推演"
|
||
# 指标的原值给不给:和证据面板的指标步骤同一个判断(withheld_value),输入按同一套分类交进去
|
||
plan.held = withheld_value(
|
||
metric, metric.get("value"), masked=[p for p, i in inputs.items() if i.cell],
|
||
unverified=[p for p, i in inputs.items() if i.unreadable and not i.whole],
|
||
uncertain=any((i.masked and not i.cell) or (i.unreadable and i.whole) or not i.traced for i in inputs.values()),
|
||
secrets=store.sources.secrets)
|
||
return plan
|
||
|
||
|
||
def _refs(tree: ast.AST) -> tuple[list[str], dict[str, set[str]]]:
|
||
"""表达式引用的值:(最外层的引用, 嵌在别的引用里面的 → 包着它的最外层引用)。和 leaf_refs 按同一套规则认引用,
|
||
只多分一下里外:vars.rows[vars.i] 里的 vars.i、cell(nodes.q, vars.r, 'gmv') 里的 vars.r 是嵌在里面的,它们决定
|
||
取哪一格。嵌了几层的也记最外层那个:重算时只有最外层的引用拿值(_evaluate),给了新值的就是它们。"""
|
||
top: list[str] = []
|
||
within: dict[str, set[str]] = {}
|
||
|
||
def add(node: ast.AST, outer: str | None) -> str:
|
||
"""记下这个引用,交回它里面的引用归谁:已经嵌在里面的归原来那个最外层引用,最外层的归它自己。"""
|
||
path = ast.unparse(node)
|
||
if outer is not None:
|
||
within.setdefault(path, set()).add(outer)
|
||
return outer
|
||
if path not in top:
|
||
top.append(path)
|
||
return path
|
||
|
||
def visit(node: ast.AST, outer: str | None) -> None:
|
||
if _cell_call(node):
|
||
owner = add(node, outer)
|
||
for arg in node.args[1:]:
|
||
visit(arg, owner)
|
||
return
|
||
if isinstance(node, (ast.Attribute, ast.Subscript, ast.Name)) and _chain_root(node):
|
||
owner = add(node, outer)
|
||
cur: ast.AST = node
|
||
while isinstance(cur, (ast.Attribute, ast.Subscript)):
|
||
if isinstance(cur, ast.Subscript):
|
||
visit(cur.slice, owner)
|
||
cur = cur.value
|
||
return
|
||
for child in ast.iter_child_nodes(node):
|
||
visit(child, outer)
|
||
|
||
visit(tree, None)
|
||
return top, within
|
||
|
||
|
||
def _remaining(substituted: Any, paths: list[str]) -> set[str]:
|
||
"""代入式里还留着原路径的引用:没被代成字面量的,也就是容器(或卡上记的不是原值)。代入式读不出来就当全都留着。"""
|
||
try:
|
||
return set(leaf_refs(parse_expression(str(substituted))[0])) if substituted else set(paths)
|
||
except ExpressionError:
|
||
return set(paths)
|
||
|
||
|
||
def _restore(item: dict[str, Any], remaining: set[str], nested: set[str], store: _Store,
|
||
copied: tuple[str | None, str | None] | None = None) -> _Input:
|
||
path = item["path"]
|
||
inp = _Input(item=item, path=path, value=item.get("value"), selector=path in nested)
|
||
locator = item.get("locator") if isinstance(item.get("locator"), dict) else {}
|
||
artifact = item.get("artifact") if isinstance(item.get("artifact"), str) and item.get("artifact") else None
|
||
if item.get("via") in ("tool_cell", "agent_field") and artifact:
|
||
if _row(locator.get("row")) is not None:
|
||
inp.snapshot, inp.row = artifact, locator["row"]
|
||
elif (span := _span(locator.get("rows"))) is not None:
|
||
inp.snapshot, inp.rows = artifact, span
|
||
# 卡上记的是「[3 项]」、代入式里又还留着原路径:当时的值是容器,卡上只记了大概。真是这几个字的短文本会被代成字面量
|
||
if item.get("value_artifact") or (isinstance(inp.value, str) and _BRIEF.match(inp.value) and path in remaining):
|
||
inp.shape = "container"
|
||
inp.value, inp.problem = _full_value(item, store)
|
||
if inp.problem is None and inp.rows is not None \
|
||
and not (isinstance(inp.value, list) and len(inp.value) == inp.rows[1] - inp.rows[0] + 1):
|
||
inp.snapshot, inp.rows = None, None # 元素和行对不上号:不按行排除
|
||
elif inp.problem is None and inp.snapshot is None:
|
||
inp.snapshot = _table_of(inp.value, store)
|
||
inp.table = inp.snapshot is not None
|
||
if inp.snapshot is not None:
|
||
_, trouble = store.get(inp.snapshot)
|
||
if trouble or store.snapshot(inp.snapshot) is None:
|
||
# 卡上记着这一格的值,可快照读不出来:这一列遮没遮核对不了(查询当时记下的遮罩在快照里),值不给,
|
||
# 也不拿来推演。数据源现在设的遮罩照样认(_masked),认出来了就按遮罩说
|
||
inp.unreadable = True
|
||
inp.problem = inp.problem or (TAMPERED if trouble == "tampered" else UNREADABLE)
|
||
if copied is not None:
|
||
_copied(inp, *copied)
|
||
return inp
|
||
inp.masked = _masked(inp, locator, store)
|
||
inp.traced = inp.snapshot is not None or (item.get("via") in ("tool_cell", "agent_field") and artifact is not None)
|
||
if not inp.masked:
|
||
_positional(inp, store)
|
||
if not inp.masked and not inp.traced:
|
||
_by_value(inp, store)
|
||
return inp
|
||
|
||
|
||
def _copied(inp: _Input, kind: str | None, reason: str | None) -> None:
|
||
"""转抄上游口径卡的输入(Sources.copied,和证据面板同一张依赖图认的):转抄的那张卡有指标取自遮罩格(哪种写法都一样,
|
||
masked_closure 的 CopyEdge.card),不给值、不推演,指标的原值也不给;读到的上游指标核对不了的,同读不出来的快照,不给值、
|
||
不推演(整张卡转抄的指标的原值另按值认);照给的照常推演。认得出出处,不再按值认:以前只按值认,转抄来的值不是够长的
|
||
号码就照给、还能改。"""
|
||
inp.traced = inp.copy = True
|
||
if kind == "masked":
|
||
inp.masked = True
|
||
elif kind in ("unverified", "table_unverified"):
|
||
inp.unreadable, inp.whole = True, kind == "table_unverified"
|
||
inp.problem = inp.problem or reason or CARD_UNREADABLE
|
||
|
||
|
||
def _full_value(item: dict[str, Any], store: _Store) -> tuple[Any, str | None]:
|
||
"""容器输入的完整值:(值, 找不回来的原因)。"""
|
||
path = item["path"]
|
||
if ref := item.get("value_artifact"):
|
||
content, problem = store.get(ref)
|
||
if problem == "tampered":
|
||
return None, "保存的完整值与哈希不一致,疑似被修改"
|
||
if not isinstance(content, dict) or "value" not in content:
|
||
return None, "无法读取保存的完整值"
|
||
return content["value"], None
|
||
locator = item.get("locator") if isinstance(item.get("locator"), dict) else {}
|
||
if item.get("via") == "agent_field" and _span(locator.get("rows")) is not None and item.get("field"):
|
||
# agent 的数组字段:assign_to 写进 vars 的、nodes.<agent>.data 下的,都是核对后的 data(值以快照为准)
|
||
output, problem = store.output(item.get("node_id"))
|
||
value = _dig(output.get("data") if isinstance(output, dict) else None, str(item["field"]))
|
||
if isinstance(value, list):
|
||
return value, None
|
||
return None, _output_problem(item.get("node_id"), problem)
|
||
head = _REF_HEAD.match(path)
|
||
root = head.group(1) if head else None
|
||
if root in ("nodes", "input"):
|
||
node_id = head.group(2) if root == "nodes" else item.get("node_id")
|
||
output, problem = store.output(node_id)
|
||
if problem or output is None:
|
||
return None, _output_problem(node_id, problem)
|
||
if not _static(path):
|
||
return None, "取值路径里有变量下标,无法从节点的产出中还原"
|
||
try:
|
||
value = eval_expression(path, {"nodes": {str(node_id): output}} if root == "nodes" else {"input": output})
|
||
except Exception: # noqa: BLE001 - 取不出来就是还原不了
|
||
value = None
|
||
if isinstance(value, (list, dict)):
|
||
return value, None
|
||
return None, f"无法从节点「{node_id}」当时的产出中还原这个输入"
|
||
if root == "vars":
|
||
return None, NO_FULL_VALUE
|
||
return None, NOT_RESTORABLE
|
||
|
||
|
||
def _output_problem(node_id: Any, problem: str | None) -> str:
|
||
if problem == "tampered":
|
||
return f"节点「{node_id}」的产出与哈希不一致,疑似被修改"
|
||
return f"无法读取节点「{node_id}」当时的产出"
|
||
|
||
|
||
def _table_of(value: Any, store: _Store) -> str | None:
|
||
"""容器是不是一份查询结果(或它的 rows):带着封存范围内快照的 artifact,或者 rows 和某份快照一模一样。
|
||
空的不算:空列表和哪份空结果都对得上,排除行也无从谈起。"""
|
||
if isinstance(value, dict) and isinstance(value.get("rows"), list):
|
||
artifact = value.get("artifact")
|
||
if isinstance(artifact, str) and artifact in store.sources.queries:
|
||
return artifact # 快照读不读得出来由 _restore 核对
|
||
rows = value["rows"]
|
||
elif isinstance(value, list):
|
||
rows = value
|
||
else:
|
||
return None
|
||
if not rows:
|
||
return None
|
||
for artifact in store.sources.queries:
|
||
snap = store.snapshot(artifact)
|
||
if snap is not None and len(snap["rows"]) == len(rows) and snap["rows"] == rows:
|
||
return artifact
|
||
return None
|
||
|
||
|
||
def _masked(inp: _Input, locator: dict[str, Any], store: _Store) -> bool:
|
||
"""这个输入取自遮罩列:单格、数组字段看用到的那几列;整份查询结果只要有一列遮着就算。"""
|
||
hidden = store.sources.masked.get(inp.snapshot) if inp.snapshot else None
|
||
if not hidden:
|
||
return False
|
||
if inp.table:
|
||
snap = store.snapshot(inp.snapshot) or {}
|
||
return any(str(c).lower() in hidden for c in snap.get("columns") or [])
|
||
columns = [locator.get("column")] if locator.get("column") else list((locator.get("columns") or {}).values())
|
||
return any(isinstance(c, str) and c.lower() in hidden for c in columns)
|
||
|
||
|
||
def _positional(inp: _Input, store: _Store) -> None:
|
||
"""按位置取自查询结果的一格(vars.res.rows[0][2])、一行(vars.res.rows[0]):卡上没记定位,顺着取值链找到那份结果、
|
||
认出是哪一列(input_origin,wide:下标不是字面量的按列认,整行按整行的列认)。
|
||
|
||
落在遮罩列上的按遮罩(masked)。遮没遮核对不了的——取值链上节点的产出读不出来,或者那份结果没存快照、只带着数据源名
|
||
——和读不出来的快照同一个处理:值不给、也不拿来推演(unreadable,原因记在 problem),宁可多遮。认得出、可那份结果
|
||
既没带快照的 id 也没带数据源名的(数据整形改过形状的结果)认不出出处,照常推演,见模块说明。整份结果(vars.res.rows)
|
||
不在这里认:对得上快照的按内容认过了(_table_of),对不上的当认不出出处,指标的原值按值认(withheld_value)。"""
|
||
if inp.snapshot is not None or inp.item.get("via") in ("tool_cell", "agent_field"):
|
||
return
|
||
origin = input_origin(inp.item, lambda node_id: store.output(node_id)[0], wide=True)
|
||
if origin is None or origin.table:
|
||
return
|
||
inp.traced = True
|
||
if origin.unverified is None and origin.artifact:
|
||
hidden = store.sources.masked.get(origin.artifact)
|
||
inp.masked = bool(hidden) and any(c.lower() in hidden for c in origin.columns)
|
||
return
|
||
if origin.unverified is not None or origin.source:
|
||
inp.unreadable = True
|
||
inp.problem = inp.problem or origin.unverified or POSITION_NO_SNAPSHOT
|
||
|
||
|
||
def _by_value(inp: _Input, store: _Store) -> None:
|
||
"""认不出出处的输入按值认遮罩(数据整形把 vars.res.rows[0][2] 算出来再赋给 vars.first_phone 这种,取值链认不出):值里
|
||
有这次运行查到过的遮罩值(Sources.secrets),就当它取自遮罩列,不给值、也不拿来推演。一格的值就是遮罩值的按一格算
|
||
(指标的原值不给);容器(还原出来的完整值)按整份结果算,指标的原值另按值认。运行输入(input.*)是发起运行的人自己
|
||
填的,不认;完整值找不回来的容器只有卡上记的大概,认不了。"""
|
||
secrets = store.sources.secrets
|
||
value = inp.value if inp.problem is None else None
|
||
if secrets is None or inp.path.startswith("input.") or not maybe_masked_value(value) \
|
||
or not carries_masked(value, secrets()):
|
||
return
|
||
inp.masked = inp.by_value = True
|
||
inp.whole = inp.shape == "container"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Origin:
|
||
"""一个输入取自哪份查询结果的哪几列(input_origin)。
|
||
|
||
- artifact、source:那份查询结果的快照 id 和数据源名,结果里没带的是 None
|
||
- columns:用到的列(列名照原样)。按位置取的一格、取值链上的产出读不出来时认不出是哪一列,为空;整行、整份结果、
|
||
列号不是字面量的(wide)是那份结果的全部列
|
||
- unverified:遮没遮核对不了的原因(取值链上节点的产出读不出来);核对得了的是 None
|
||
- table:取的是整份结果(vars.res.rows、vars.res,wide 才认):它不一定把哪一格的值带进指标
|
||
"""
|
||
|
||
artifact: str | None
|
||
source: str | None
|
||
columns: tuple[str, ...]
|
||
unverified: str | None = None
|
||
table: bool = False
|
||
|
||
|
||
def input_origin(item: dict[str, Any], output: Callable[[Any], Any], *, wide: bool = False) -> Origin | None:
|
||
"""卡上的一个输入取自哪份查询结果的哪几列,遮罩按列认时用(推演、证据面板的指标步骤、可验证报告是同一个认法)。
|
||
|
||
cell() 的格、Agent 交来的字段按定位里的列认;vars.res.rows[0][2] 这种按位置取的一格顺着取值链认(positional_cell,
|
||
output 同它)。不是取自查询结果的格(常量、运行输入、Agent 没按出处交的字段、加工过的数)是 None。
|
||
|
||
wide(推演、证据面板的指标步骤):取值链伸进查询结果、下标却不全是字面量的也认——rows[vars.i][2] 按第 2 列认,
|
||
rows[0][vars.j] 按全部列认;整行 rows[0] 按整行的列认,整份结果 rows、vars.res 按全部列认、标 table。以前认不出,
|
||
这一格的值就当可以给,指标的原值照给。可验证报告、双盲复算照旧只认字面量下标的一格(它们另有认不出时的处理)。"""
|
||
if item.get("via") in ("tool_cell", "agent_field") and isinstance(item.get("artifact"), str) and item["artifact"]:
|
||
locator = item.get("locator") if isinstance(item.get("locator"), dict) else {}
|
||
columns = [locator.get("column")] if locator.get("column") else list((locator.get("columns") or {}).values())
|
||
return Origin(item["artifact"], None, tuple(c for c in columns if isinstance(c, str) and c))
|
||
found = positional_cell(str(item.get("path") or ""), item.get("node_id"), output, wide=wide)
|
||
if found is None:
|
||
return None
|
||
if found.get("unreadable"):
|
||
return Origin(None, None, (), POSITION_UNREADABLE)
|
||
columns = tuple(found.get("columns") or ([found["column"]] if found.get("column") else []))
|
||
return Origin(found["artifact"], found["source"], columns, table=bool(found.get("table")))
|
||
|
||
|
||
#: 取值链没走通
|
||
_GONE = object()
|
||
#: 下标不是字面量(rows[vars.i]、rows[len(vars.res.rows) - 1]):wide 时认作「哪一行、哪一列说不准」
|
||
_ANY = object()
|
||
|
||
#: 口径卡产出里不带指标值的字段:取这些的输入不算转抄了指标的值
|
||
_CARD_PLAIN = frozenset({"kind", "caliber", "caliber_version", "artifact", "source"})
|
||
#: 一个指标里不带值的字段(名称、单位、表达式、显示格式、状态这类)。值、渲染、输入、代入式都带着值
|
||
_METRIC_PLAIN = frozenset({"id", "name", "unit", "expression", "decimals", "format", "status", "recompute_ok",
|
||
"incomplete", "incomplete_reason", "sql_check_failed", "sql_check_reason"})
|
||
#: 转抄的上游口径卡当时的产出读不出来(文件不在、与哈希不一致、不知道是谁写的):转抄来的是不是遮罩值核对不了
|
||
CARD_UNREADABLE = "转抄的口径卡结果无法读取,核对不了这个值是否取自设置了遮罩的列"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class CardInput:
|
||
"""卡上的一个输入转抄了上游口径卡的产出(card_input):node_id 是那张卡的节点,card 是它当时的产出(指标集加清单
|
||
文字),picked 是这个输入带着值的那几个指标(在 card["metrics"] 里的位置)。whole:整张卡、整份指标列表、清单文字、
|
||
下标不是字面量的一个指标——说不准把哪个指标的值带进来。给不给不按 picked 细分:那张卡在这次运行的任何一次执行里有一个
|
||
指标不给,转抄它的输入就不给(masked_closure 的 CopyEdge.card)。
|
||
|
||
unreadable:那张卡当时的产出现在读不出来,card 为 None、picked 为空,这里写原因。卡上记着这个输入转抄来了值(不是空的),
|
||
算的时候产出是在的,现在读不出来只能是产出丢了或被改了:同按位置取的一格取值链上的产出读不出来(positional_cell 的
|
||
unreadable),遮没遮核对不了,宁可多遮。以前当作认不出出处,只按出处认的写作目录、可验证报告照给,号码随卡出去了。"""
|
||
|
||
node_id: str
|
||
card: dict[str, Any] | None
|
||
picked: tuple[int, ...]
|
||
whole: bool
|
||
unreadable: str | None = None
|
||
|
||
|
||
def card_input(item: dict[str, Any], output: Callable[[Any], Any]) -> CardInput | None:
|
||
"""卡上的一个输入是不是转抄上游口径卡的产出:nodes.<卡>.metrics[0].value、vars.<卡的 assign_to>.metrics[0].value、
|
||
nodes.<卡>.text(清单文字逐条写着「指标(名称)= 值」)、整张卡。是的话交回那张卡和带着值的那几个指标,值给不给按
|
||
那张卡认(masked_closure:证据面板、写作目录、推演、可验证报告同一个判断):那张卡在这次运行里有一个指标取自遮罩格,
|
||
转抄它的这个指标同样不给。
|
||
|
||
output 同 input_origin:节点 id → 那个节点当时的产出;nodes.X 是 X 的产出,vars.X 是写它的节点(输入记着的 node_id)
|
||
的产出——口径卡的 assign_to 交给变量的就是它的产出本身。产出不是指标集的(别的节点)交回 None;卡上记着这个输入出自
|
||
口径卡(via 为 metric)、转抄来了值,产出却读不出来的交回 unreadable(见 CardInput)。只取口径名、版本、指标的名称、
|
||
单位这类不带值的字段的,也交回 None。"""
|
||
try:
|
||
tree = ast.parse(str(item.get("path") or ""), mode="eval").body
|
||
except SyntaxError:
|
||
return None
|
||
keys: list[Any] = []
|
||
while isinstance(tree, (ast.Attribute, ast.Subscript)):
|
||
if isinstance(tree, ast.Attribute):
|
||
keys.append(tree.attr)
|
||
else:
|
||
key = _literal_key(tree.slice)
|
||
keys.append(_ANY if key is _GONE else key)
|
||
tree = tree.value
|
||
keys.reverse()
|
||
if not isinstance(tree, ast.Name) or tree.id not in ("vars", "nodes") or not keys or not isinstance(keys[0], str):
|
||
return None
|
||
node_id = keys[0] if tree.id == "nodes" else item.get("node_id")
|
||
rest = keys[1:]
|
||
whole = not rest or rest[0] == "text" or rest[0] is _ANY \
|
||
or (rest[0] == "metrics" and (len(rest) == 1 or rest[1] is _ANY))
|
||
plain = len(rest) > 2 and isinstance(rest[2], str) and rest[2] in _METRIC_PLAIN
|
||
if not whole and (rest[0] != "metrics" or plain):
|
||
# 口径名、版本、工件 id,指标的名称、单位这类,或者产出里本来没有的键:不带指标的值
|
||
return None
|
||
card = output(node_id) if node_id else None
|
||
if not (isinstance(card, dict) and card.get("kind") == "metric_set" and isinstance(card.get("metrics"), list)):
|
||
if node_id and item.get("via") == "metric" and item.get("value") is not None:
|
||
return CardInput(str(node_id), None, (), whole, CARD_UNREADABLE)
|
||
return None
|
||
metrics = card["metrics"]
|
||
if whole:
|
||
return CardInput(str(node_id), card, tuple(range(len(metrics))), True)
|
||
index = rest[1]
|
||
if not isinstance(index, int) or not -len(metrics) <= index < len(metrics):
|
||
return None
|
||
return CardInput(str(node_id), card, (index % len(metrics),), False)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class CopyEdge:
|
||
"""指标依赖图上的一条转抄边(masked_closure):卡上路径为 path 的输入转抄了上游口径卡的产出,upstream 是它读到的那一次
|
||
产出里带着值的那几个指标(键由调用方定,和 MetricLinks 的键同一套)。whole、unreadable 同 CardInput;读不出来的
|
||
upstream 为空。
|
||
|
||
card:转抄的那张卡(节点 id)。转抄的保守规则按整张卡认:这张卡在这次运行的任何一次执行里有一个指标不给(调用方把它每一
|
||
次执行的每个指标都放进图里,MetricLinks.card 记着是哪张卡),转抄出来的这个输入就不给——不管下标是不是字面量、转抄的
|
||
是一个指标、整张卡还是清单文字,也不管读到的是哪一次执行的产出。以前按读到的那一次产出、带着值的那几个指标细分:写作
|
||
目录只有运行状态里最后一次的产出,循环里抄自己上一轮的卡最后一轮没碰号码列就照给,可它转抄来的正是第一轮的号码;整张
|
||
卡、清单文字只按值认,由遮罩格算出来的数(年薪 = 月薪 × 12)原样照给。没给 card 的只看 upstream。"""
|
||
|
||
path: str
|
||
upstream: tuple[Any, ...]
|
||
whole: bool
|
||
unreadable: str | None = None
|
||
card: Any = None
|
||
|
||
|
||
@dataclass
|
||
class MetricLinks:
|
||
"""指标依赖图上的一个指标(masked_closure 的输入):metric 是卡上的那个指标(认表达式的最外层引用、值);direct 是
|
||
转抄以外的输入里不给值的,路径 → (种类, 遮罩列),种类同 api/evidence.py 的 _Held(masked、table、unverified、value);
|
||
uncertain:有认不出出处、指标的值要按值认的输入;copies:转抄上游口径卡的输入;card:这个指标所在的口径卡(节点 id),
|
||
转抄这张卡的边按它认(CopyEdge.card)。"""
|
||
|
||
metric: dict[str, Any]
|
||
direct: dict[str, tuple[str, tuple[str, ...]]] = field(default_factory=dict)
|
||
uncertain: bool = False
|
||
copies: list[CopyEdge] = field(default_factory=list)
|
||
card: Any = None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Judged:
|
||
"""masked_closure 认出来的一个指标:word 是指标的值给不给(None 照给,masked 不给,unverified 核对不了,同
|
||
withheld_value);copied 是每个转抄的输入:路径 → (种类, 遮罩列, 原因),种类 None 照给,masked 是转抄的那张卡有指标
|
||
不给(哪种写法都一样),unverified 是读到的那几个指标核对不了,table_unverified 是整张卡转抄、读到的那次产出里有指标
|
||
核对不了(同整份查询结果:指标的值另按值认),原因只在上游产出读不出来时有;columns 是让它不给的遮罩列,转抄它的输入上
|
||
写这几列;card 同 MetricLinks.card,之后再认的指标转抄这张卡时按它算进去。"""
|
||
|
||
word: str | None
|
||
copied: Mapping[str, tuple[str | None, tuple[str, ...], str | None]] = field(default_factory=dict)
|
||
columns: tuple[str, ...] = ()
|
||
card: Any = None
|
||
|
||
|
||
#: 指标的值给不给,从松到严:照给、核对不了、不给
|
||
_STRICTNESS = {None: 0, "unverified": 1, "masked": 2}
|
||
|
||
|
||
def masked_closure(links: Mapping[Any, MetricLinks], known: Mapping[Any, Judged] | None = None,
|
||
secrets: Callable[[], Any] | None = None) -> dict[Any, Judged]:
|
||
"""转抄上游口径卡的指标值给不给:在指标依赖图上一次算出的最小不动点。证据面板、写作目录、推演、可验证报告都按这一个
|
||
函数认(各自按自己的口径认好转抄以外的输入,见 MetricLinks.direct),不各写一套。
|
||
|
||
先按转抄以外的输入认每个指标(取自遮罩格的、核对不了的),再沿转抄的边往下游传,一直传到不再变化:
|
||
- 转抄的保守规则(CopyEdge.card):转抄的那张卡在这次运行的任何一次执行里有一个指标不给,转抄它的输入就是 masked,
|
||
字面量下标、下标不是字面量、整张卡、清单文字、整份指标列表都一样,读到的那次产出读不出来的也一样。那张卡一个不给的
|
||
指标都没有时照常
|
||
- 读到的那几个指标(upstream)有不给的,同样 masked;只是核对不了的,转抄它的输入核对不了(整张卡、清单文字这类是
|
||
table_unverified,指标的值另按值认)
|
||
环(循环里一张卡抄自己上一轮的产出、几张卡互相转抄)本身不带来遮罩:从「都照给」起往上收,环上没有哪个指标别的输入取自
|
||
遮罩格,整个环照给;有一个取自遮罩格,整个环都不给。以前逐个往上递归、追够八层就按遮罩算:没设任何遮罩的工作流里,
|
||
循环里累加上一轮结果的口径卡也成了「已遮罩」,引用它的正式运行判失败。
|
||
|
||
links:这次要认的指标(键 → MetricLinks),转抄的边指到的上游要么也在里面,要么在 known 里(之前认好的,结果不再变),
|
||
都不在的(卡上没记输入的老指标、写死的数)照给;一张卡的指标可以一部分在 known 里、一部分在 links 里,按 card 合起来认。
|
||
secrets 同 withheld_value,整份结果当输入、认不出出处的输入要按值认时才取。每个指标的结论只会往严里变(最多两次),每张卡
|
||
「有指标不给」只会从无到有、遮罩列只增不减,每变一次只重认转抄它的那几个:总的工作量和边数成正比。"""
|
||
known = known or {}
|
||
words: dict[Any, str | None] = {key: None for key in links}
|
||
columns: dict[Any, tuple[str, ...]] = {key: () for key in links}
|
||
below: dict[Any, set[Any]] = {}
|
||
readers: dict[Any, set[Any]] = {}
|
||
for key, node in links.items():
|
||
for edge in node.copies:
|
||
for up in edge.upstream:
|
||
if up in links:
|
||
below.setdefault(up, set()).add(key)
|
||
if edge.card is not None:
|
||
readers.setdefault(edge.card, set()).add(key)
|
||
# 被转抄的卡里有指标不给的:卡 → 让它们不给的遮罩列(没有不给的卡不在里面)。之前认好的先算进去
|
||
cards: dict[Any, tuple[str, ...]] = {}
|
||
for found in known.values():
|
||
if found.word == "masked" and found.card is not None and found.card in readers:
|
||
cards[found.card] = tuple(dict.fromkeys((*cards.get(found.card, ()), *found.columns)))
|
||
|
||
def state(key: Any) -> tuple[str | None, tuple[str, ...]]:
|
||
if key in links:
|
||
return words[key], columns[key]
|
||
found = known.get(key)
|
||
return (found.word, found.columns) if found is not None else (None, ())
|
||
|
||
def judge(key: Any) -> Judged:
|
||
node = links[key]
|
||
masked = [p for p, (kind, _) in node.direct.items() if kind in ("masked", "value")]
|
||
unverified = [p for p, (kind, _) in node.direct.items() if kind == "unverified"]
|
||
uncertain = node.uncertain or any(kind == "table" for kind, _ in node.direct.values())
|
||
because = [c for kind, cols in node.direct.values() if kind in ("masked", "table") for c in cols]
|
||
copied: dict[str, tuple[str | None, tuple[str, ...], str | None]] = {}
|
||
for edge in node.copies:
|
||
ups = [state(up) for up in edge.upstream]
|
||
if (edge.card is not None and edge.card in cards) or any(word == "masked" for word, _ in ups):
|
||
cols = (*cards.get(edge.card, ()), *(c for word, cs in ups if word == "masked" for c in cs))
|
||
copied[edge.path] = ("masked", tuple(dict.fromkeys(cols)), None)
|
||
elif edge.unreadable is not None:
|
||
copied[edge.path] = ("table_unverified" if edge.whole else "unverified", (), edge.unreadable)
|
||
elif any(word == "unverified" for word, _ in ups):
|
||
copied[edge.path] = ("table_unverified" if edge.whole else "unverified", (), None)
|
||
else:
|
||
copied[edge.path] = (None, (), None)
|
||
kind, cols, _ = copied[edge.path]
|
||
if kind == "masked":
|
||
masked.append(edge.path)
|
||
because.extend(cols)
|
||
elif kind == "unverified":
|
||
unverified.append(edge.path)
|
||
elif kind is not None:
|
||
uncertain = True
|
||
word = withheld_value(node.metric, node.metric.get("value"), masked=masked, unverified=unverified,
|
||
uncertain=uncertain, secrets=secrets)
|
||
return Judged(word, copied, tuple(dict.fromkeys(because)) if word == "masked" else (), node.card)
|
||
|
||
queue = list(links)
|
||
waiting = set(queue)
|
||
while queue:
|
||
key = queue.pop()
|
||
waiting.discard(key)
|
||
got = judge(key)
|
||
# 只往严里收:withheld_value 先看取自遮罩格的、再看核对不了的,上游变严时这里也不会松回去
|
||
word = got.word if _STRICTNESS[got.word] >= _STRICTNESS[words[key]] else words[key]
|
||
cols = tuple(dict.fromkeys((*columns[key], *got.columns))) if word == "masked" else ()
|
||
if (word, cols) == (words[key], columns[key]):
|
||
continue
|
||
words[key], columns[key] = word, cols
|
||
downs = set(below.get(key, ()))
|
||
card = links[key].card
|
||
if word == "masked" and card is not None and card in readers:
|
||
merged = tuple(dict.fromkeys((*cards.get(card, ()), *cols)))
|
||
if card not in cards or cards[card] != merged:
|
||
cards[card] = merged
|
||
downs |= readers[card]
|
||
for down in downs:
|
||
if down not in waiting:
|
||
waiting.add(down)
|
||
queue.append(down)
|
||
out: dict[Any, Judged] = {}
|
||
for key in links:
|
||
got = judge(key)
|
||
out[key] = Judged(words[key], got.copied, columns[key], links[key].card)
|
||
return out
|
||
|
||
|
||
def positional_cell(path: str, node_id: Any, output: Callable[[Any], Any], *, wide: bool = False) -> dict[str, Any] | None:
|
||
"""按位置取自一份查询结果的一格:vars.res.rows[0][2]、nodes.parse.rows[1]['amount'] 这种标量引用——查询结果经数据
|
||
整形解析成对象之后按行号、列号(或列名)取。返回 {artifact, source, row, column}:artifact、source 是那份结果里带着的
|
||
(数据源工具、合并查询交回的结果都带,没有的是 None),row 是从 0 数的行号,column 是列名。认不出来的返回 None。
|
||
|
||
遮罩要按列认(证据面板的指标步骤、推演都用这个):cell() 和 Agent 字段有定位,这种引用卡上只记了路径和值。
|
||
|
||
output(节点 id) 交回那个节点当时的产出(读不出来给 None)。nodes.X 的根就是 X 的产出;vars.X 是写它的节点(输入记着
|
||
的 node_id)交给变量的那个值——数据整形、合并查询交的是产出本身,Agent、LLM、代码节点交的是产出里的 data,工具节点
|
||
交的是 JSON 文本,都试一遍,取值链走得通的那个就是。下标得是字面量:rows[vars.i] 要另取一个值才知道是哪一行,不认。
|
||
|
||
wide(见 input_origin):rows 之后的下标可以不是字面量,整行、整份结果也认,另交回 columns(用到的列)和 table
|
||
(整份结果);row、column 认不出时为 None。走到 rows 之前的下标仍得是字面量(vars.r[vars.k].rows 说不清是哪一份)。
|
||
|
||
路径就是按位置取一格的样子(以 .rows[行号][列] 结尾),可那个节点的产出读不出来(文件不在、与哈希不一致、不知道是
|
||
谁写的)时返回 {"unreadable": True}:这一格取自哪一列核对不了,调用方宁可多遮。卡上记着这个输入的值,算的时候
|
||
取值链是走得通的,现在读不出来只能是产出丢了或被改了。wide 时伸进了行的(rows 后面还有下标)都算;整份结果不算。
|
||
"""
|
||
try:
|
||
tree = ast.parse(path, mode="eval").body
|
||
except SyntaxError:
|
||
return None
|
||
keys: list[Any] = []
|
||
while isinstance(tree, (ast.Attribute, ast.Subscript)):
|
||
if isinstance(tree, ast.Attribute):
|
||
keys.append(tree.attr)
|
||
else:
|
||
key = _literal_key(tree.slice)
|
||
if key is _GONE:
|
||
if not wide:
|
||
return None
|
||
key = _ANY
|
||
keys.append(key)
|
||
tree = tree.value
|
||
keys.reverse()
|
||
if not isinstance(tree, ast.Name) or tree.id not in ("vars", "nodes") or not keys \
|
||
or not isinstance(keys[0], str) or (not wide and len(keys) < 4):
|
||
return None
|
||
base = output(keys[0] if tree.id == "nodes" else node_id)
|
||
if base is None:
|
||
if wide:
|
||
positional = "rows" in keys[1:-1]
|
||
else:
|
||
positional = keys[-3] == "rows" and isinstance(keys[-2], int) and isinstance(keys[-1], (int, str))
|
||
return {"unreadable": True} if positional else None
|
||
candidates = [base]
|
||
if isinstance(base, dict) and "data" in base:
|
||
candidates.append(base["data"])
|
||
if isinstance(base, str):
|
||
try:
|
||
candidates.append(json.loads(base))
|
||
except ValueError:
|
||
pass
|
||
for value in candidates:
|
||
if (found := (_cells_in if wide else _cell_in)(value, keys[1:])) is not None:
|
||
return found
|
||
return None
|
||
|
||
|
||
def _literal_key(node: ast.AST) -> Any:
|
||
"""下标里的字面量:整数(含负数)或者文本。别的(变量、算式)交回 _GONE。"""
|
||
if isinstance(node, ast.Constant) and isinstance(node.value, (int, str)) and not isinstance(node.value, bool):
|
||
return node.value
|
||
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub) and isinstance(node.operand, ast.Constant) \
|
||
and isinstance(node.operand.value, int) and not isinstance(node.operand.value, bool):
|
||
return -node.operand.value
|
||
return _GONE
|
||
|
||
|
||
def _cell_in(value: Any, keys: list[Any]) -> dict[str, Any] | None:
|
||
"""沿取值链往下走,走到一份查询结果(带 rows 列表的对象)、剩下的正好是 rows[行][列] 时交回那一格。"""
|
||
for i, key in enumerate(keys):
|
||
if isinstance(value, dict) and isinstance(value.get("rows"), list) and len(keys) - i == 3 and key == "rows":
|
||
return _cell_at(value, keys[i + 1], keys[i + 2])
|
||
if isinstance(value, dict) and isinstance(key, str):
|
||
value = value.get(key, _GONE)
|
||
elif isinstance(value, list) and isinstance(key, int) and -len(value) <= key < len(value):
|
||
value = value[key]
|
||
else:
|
||
return None
|
||
if value is _GONE:
|
||
return None
|
||
return None
|
||
|
||
|
||
def _cells_in(value: Any, keys: list[Any]) -> dict[str, Any] | None:
|
||
"""wide 的 _cell_in:走到一份查询结果,剩下的是 rows、rows[行]、rows[行][列…],或者什么都不剩(整份结果)。"""
|
||
for i, key in enumerate(keys):
|
||
if isinstance(value, dict) and isinstance(value.get("rows"), list) and key == "rows":
|
||
return _cells_at(value, keys[i + 1:])
|
||
if isinstance(value, dict) and isinstance(key, str):
|
||
value = value.get(key, _GONE)
|
||
elif isinstance(value, list) and isinstance(key, int) and -len(value) <= key < len(value):
|
||
value = value[key]
|
||
else:
|
||
return None
|
||
if value is _GONE:
|
||
return None
|
||
if isinstance(value, dict) and isinstance(value.get("rows"), list):
|
||
return _cells_at(value, None)
|
||
return None
|
||
|
||
|
||
def _cells_at(result: dict[str, Any], rest: list[Any] | None) -> dict[str, Any] | None:
|
||
"""一份查询结果里 rest(rows 后面的下标)取到的那几列:整份结果(rest 为 None 或空)、整行、一格,列号认不出的算全部列。
|
||
字面量的行号越界、列名对不上的认不出(None):卡上记着的值不是从这里取的。"""
|
||
rows = result["rows"]
|
||
columns = [str(c) for c in result.get("columns") or []]
|
||
artifact, source = result.get("artifact"), result.get("source")
|
||
out = {"artifact": artifact if isinstance(artifact, str) and artifact else None,
|
||
"source": source if isinstance(source, str) and source else None,
|
||
"row": None, "column": None, "columns": columns, "table": not rest}
|
||
if not rest:
|
||
return out
|
||
row = rest[0]
|
||
if row is not _ANY:
|
||
if not isinstance(row, int) or not -len(rows) <= row < len(rows):
|
||
return None
|
||
out["row"] = row % len(rows)
|
||
if len(rest) == 1 or rest[1] is _ANY:
|
||
return out
|
||
column = rest[1]
|
||
record = rows[out["row"]] if out["row"] is not None else None
|
||
if isinstance(column, int) and -len(columns) <= column < len(columns):
|
||
name: str | None = columns[column]
|
||
elif isinstance(column, str) and (column in columns or (isinstance(record, dict) and column in record)):
|
||
name = column
|
||
else:
|
||
return None
|
||
return {**out, "column": name, "columns": [name]}
|
||
|
||
|
||
def _cell_at(result: dict[str, Any], row: Any, column: Any) -> dict[str, Any] | None:
|
||
rows = result["rows"]
|
||
if not isinstance(row, int) or not -len(rows) <= row < len(rows):
|
||
return None
|
||
row = row % len(rows)
|
||
record = rows[row]
|
||
columns = [str(c) for c in result.get("columns") or []]
|
||
if isinstance(column, int) and isinstance(record, (list, tuple)) and -len(columns) <= column < len(columns):
|
||
name: str | None = columns[column]
|
||
elif isinstance(column, str) and (column in columns or (isinstance(record, dict) and column in record)):
|
||
name = column
|
||
else:
|
||
name = None
|
||
if name is None:
|
||
return None
|
||
artifact, source = result.get("artifact"), result.get("source")
|
||
return {"artifact": artifact if isinstance(artifact, str) and artifact else None,
|
||
"source": source if isinstance(source, str) and source else None, "row": row, "column": name}
|
||
|
||
|
||
def _static(path: str) -> bool:
|
||
"""取值路径的下标全是字面量(nodes.q.rows[0]),没有 nodes.q.rows[vars.i] 这种要另取一个值才知道是哪一项的。"""
|
||
try:
|
||
tree = ast.parse(path, mode="eval")
|
||
except SyntaxError:
|
||
return False
|
||
for node in ast.walk(tree):
|
||
if isinstance(node, ast.Subscript) and not isinstance(node.slice, ast.Constant) and not (
|
||
isinstance(node.slice, ast.UnaryOp) and isinstance(node.slice.operand, ast.Constant)):
|
||
return False
|
||
return True
|
||
|
||
|
||
def _dig(data: Any, key: str) -> Any:
|
||
for part in key.split("."):
|
||
data = data.get(part) if isinstance(data, dict) else None
|
||
return data
|
||
|
||
|
||
def _row(value: Any) -> int | None:
|
||
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else None
|
||
|
||
|
||
def _span(rows: Any) -> tuple[int, int] | None:
|
||
if isinstance(rows, list) and len(rows) == 2 and all(_row(r) is not None for r in rows) and rows[0] <= rows[1]:
|
||
return rows[0], rows[1]
|
||
return None
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 校验改动
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def _number(value: Any) -> bool:
|
||
"""算得动的数:bool 不算;无穷大、非数不算;浮点放不下的大整数(10 ** 400)也不算——math.isfinite 对它抛
|
||
OverflowError,JSON 写得出来,界面却只能读成 Infinity。"""
|
||
if not is_number(value):
|
||
return False
|
||
try:
|
||
return math.isfinite(value)
|
||
except OverflowError:
|
||
return False
|
||
|
||
|
||
def _check(changes: Changes, plans: list[_Plan], store: _Store, *, metric: str | None) -> None:
|
||
known: dict[str, list[_Input]] = {}
|
||
for plan in plans:
|
||
for path, inp in plan.inputs.items():
|
||
known.setdefault(path, []).append(inp)
|
||
for path, value in changes.inputs.items():
|
||
found = known.get(path)
|
||
if not found:
|
||
raise WhatIfError(f"指标「{metric}」没有输入「{path}」" if metric is not None else f"口径卡里没有输入「{path}」")
|
||
if not is_number(value):
|
||
raise WhatIfError(f"输入「{path}」的新值需要是数值,当前为{_shown(value)}")
|
||
if not _number(value):
|
||
raise WhatIfError(f"输入「{path}」的新值超出可计算的范围,需要是有限的数值")
|
||
if any(i.masked and not i.by_value for i in found):
|
||
raise WhatIfError(f"输入「{path}」取自设置了遮罩的列,不能用于推演")
|
||
if any(i.masked for i in found):
|
||
raise WhatIfError(f"输入「{path}」的值与设置了遮罩的列中的值相同,不能用于推演")
|
||
if unreadable := next((i for i in found if i.unreadable), None):
|
||
raise WhatIfError(f"输入「{path}」所在的{unreadable.problem},不能用于推演")
|
||
if any(i.selector for i in found):
|
||
raise WhatIfError(f"输入「{path}」决定取哪一行、哪一项,修改它需要重新取数,不能推演")
|
||
if found[0].shape != "scalar":
|
||
raise WhatIfError(f"输入「{path}」是列表或对象,不能直接修改,可以在查询结果里勾选要排除的行")
|
||
if not (found[0].value is None or _number(found[0].value)):
|
||
raise WhatIfError(f"输入「{path}」不是数值,不能修改")
|
||
for artifact, rows in changes.exclude.items():
|
||
if artifact not in store.sources.queries:
|
||
raise WhatIfError(f"工件 {str(artifact)[:12]}… 不属于这次运行,或不在封存范围内,不能排除其中的行")
|
||
_, problem = store.get(artifact)
|
||
if problem == "tampered":
|
||
raise WhatIfError("查询快照与哈希不一致,疑似被修改,不能用于推演")
|
||
snap = store.snapshot(artifact)
|
||
if snap is None:
|
||
raise WhatIfError("无法读取查询快照,不能排除其中的行")
|
||
total = len(snap["rows"])
|
||
for row in sorted(rows):
|
||
if not 0 <= row < total:
|
||
raise WhatIfError(f"行号 {row} 超出范围:这份查询结果只有 {total} 行(行号从 0 开始)")
|
||
|
||
|
||
def _shown(value: Any) -> str:
|
||
"""不合法的新值写给人看:空值写「空值」,文本照原样,别的按 JSON 的写法——不露出 None、单引号这些 Python 写法。"""
|
||
if value is None:
|
||
return "空值"
|
||
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)
|
||
return f"「{text if len(text) <= 40 else text[:40] + '…'}」"
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 重算
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def _apply(inp: _Input, changes: Changes) -> tuple[Any, list[int] | None, bool]:
|
||
"""改动之后这个输入的值:(值, 起了作用的被排除行, 是不是改了值)。给了新值的以新值为准,不看排除。"""
|
||
if inp.path in changes.inputs:
|
||
return changes.inputs[inp.path], None, True
|
||
excluded = changes.exclude.get(inp.snapshot) if inp.snapshot else None
|
||
if not excluded:
|
||
return inp.value, None, False
|
||
if inp.row is not None:
|
||
return (None, [inp.row], False) if inp.row in excluded else (inp.value, None, False)
|
||
if inp.rows is not None:
|
||
# 数组字段只用到快照里的一段行:段外的行排除了也影响不到它
|
||
first, last = inp.rows
|
||
hit = sorted(r for r in excluded if first <= r <= last)
|
||
if not hit:
|
||
return inp.value, None, False
|
||
if not isinstance(inp.value, list):
|
||
# 完整值找不回来(inp.problem):用到的行少了,它一定变了,变成什么不知道。照样算碰到了它,指标因这个
|
||
# 输入还原不了而推演不了(_Plan.blocked),不会拿原值算出一个「无变化」
|
||
return None, hit, False
|
||
return [v for k, v in enumerate(inp.value) if first + k not in excluded], hit, False
|
||
if inp.table:
|
||
rows = inp.value["rows"] if isinstance(inp.value, dict) else inp.value
|
||
hit = sorted(r for r in excluded if r < len(rows))
|
||
if not hit:
|
||
return inp.value, None, False
|
||
kept = [r for i, r in enumerate(rows) if i not in excluded]
|
||
return ({**inp.value, "rows": kept} if isinstance(inp.value, dict) else kept), hit, False
|
||
return inp.value, None, False
|
||
|
||
|
||
def _outcome(plan: _Plan, changes: Changes, sources: Sources, *,
|
||
force: bool = False) -> tuple[dict[str, Any], bool]:
|
||
"""(这个指标的推演结果, 它会不会因这次改动而变:碰到了它,又算出来变了或者说不准)。"""
|
||
values: dict[str, Any] = {}
|
||
notes: dict[str, list[int]] = {}
|
||
touched = False
|
||
for path, inp in plan.inputs.items():
|
||
values[path], excluded, overridden = _apply(inp, changes)
|
||
if excluded:
|
||
notes[path] = excluded
|
||
touched = touched or overridden or bool(excluded)
|
||
doubt = _doubt(plan, changes, sources)
|
||
# 说不准受不受影响的也算碰到了;卡上没记全输入的指标不知道它用了什么,有任何改动都说不准它变不变
|
||
touched = touched or doubt is not None or (plan.problem is not None and bool(changes.inputs or changes.exclude))
|
||
if blocked := plan.blocked:
|
||
# 整个推演不了:不改动也照实说,不拿原值报「无变化」。碰没碰到它只决定下游要不要标
|
||
status, reason = blocked
|
||
result = _result(plan, status, None, reason, values, notes)
|
||
elif not touched and not force:
|
||
result = _result(plan, "unchanged", plan.metric.get("value"), None, values, notes)
|
||
else:
|
||
status, new, reason = _compute(plan, values, notes, changes, doubt)
|
||
result = _result(plan, status, new, reason, values, notes)
|
||
return result, touched and result["changed"] is not False
|
||
|
||
|
||
def _doubt(plan: _Plan, changes: Changes, sources: Sources) -> str | None:
|
||
"""排除了行,这个指标却有输入说不准受不受影响:返回原因;没排除行、或者输入都对得上号的返回 None。
|
||
|
||
对得上号的输入(cell() 那一格、agent 的单格字段和数组字段、整份查询结果)排除行怎么影响它是确定的,_apply 照办;
|
||
数组字段的完整值找不回来时也知道它用到哪几行,排除了那几行就算碰到了它(指标因它还原不了而推演不了)。
|
||
别的输入——完整值找不回来的容器、对不上任何快照的容器(加工过的结果)、不是直接取自快照的数(按位置从整份结果里
|
||
取的一格、transform 由查询结果算出来的数)——都可能来自被排除了行的那份结果,又重算不了,只能说不知道,不能拿
|
||
原值算出一个「无变化」或者一个不对应任何一份数据的数。给了新值的以新值为准,只嵌在给了新值的引用里的也就无关紧要
|
||
(_Plan.moot);写它的节点在那份快照交回之前就跑完了的,影响不到它(_after)。
|
||
"""
|
||
if not changes.exclude:
|
||
return None
|
||
for path, inp in plan.inputs.items():
|
||
if path in changes.inputs or inp.snapshot is not None or plan.moot(path, changes):
|
||
continue
|
||
if any(_after(snapshot, inp, sources) for snapshot in changes.exclude):
|
||
why = inp.problem or (DOUBT_UNMATCHED if inp.shape == "container" else DOUBT_DERIVED)
|
||
return DOUBT.format(path=path, why=why)
|
||
return None
|
||
|
||
|
||
def _after(snapshot: str, inp: _Input, sources: Sources) -> bool:
|
||
"""这个输入可能是在这份快照交回之后才算出来的:写它的节点不早于快照交回跑完(合并查询的快照就记在它自己跑完的
|
||
那条事件里),或者两样有一样不知道。"""
|
||
writer = inp.item.get("node_id")
|
||
returned = sources.returned.get(snapshot)
|
||
finished = sources.finished.get(str(writer)) if writer else None
|
||
return returned is None or finished is None or finished >= returned
|
||
|
||
|
||
def _compute(plan: _Plan, values: dict[str, Any], notes: dict[str, list[int]], changes: Changes,
|
||
doubt: str | None) -> tuple[str, Any, str | None]:
|
||
"""(status, 新值, 原因)。整个推演不了的(_Plan.blocked)到不了这里。排除后缺了的、说不准受不受影响的先说,
|
||
都没有才求值。只嵌在给了新值的引用里的输入(_Plan.moot)不论排除了什么都不碍事:那几个引用以新值为准。"""
|
||
hit = [(path, inp) for path, inp in plan.inputs.items()
|
||
if path in notes and path not in changes.inputs and not plan.moot(path, changes)]
|
||
for path, inp in hit:
|
||
if inp.row is not None:
|
||
return "excluded", None, f"{EXCLUDED}:{path} 所在的第 {inp.row + 1} 行已排除"
|
||
for path, inp in hit:
|
||
if inp.selector and not plan.settled(path, changes):
|
||
# 嵌在下标、cell() 行列里的容器少了几行:外面那个引用取到的是哪一格跟着变,卡上记的值不再作数。
|
||
# 包着它的引用都给了新值时,它只剩自己作为最外层引用的那一处(排除了哪几行是确定的),照常算
|
||
return "unsupported", None, DOUBT.format(path=path, why=DOUBT_SELECTOR)
|
||
if doubt:
|
||
return "unsupported", None, doubt
|
||
value, reason = _evaluate(plan, values)
|
||
if reason is None and is_number(value) and not _number(value):
|
||
# 溢出成无穷大、算出非数、乘出浮点放不下的整数:口径卡渲染成「—」,这里也说清算不出来(JSON 里也放不下)
|
||
reason = NOT_FINITE
|
||
return ("ok", value, None) if reason is None else ("error", None, reason)
|
||
|
||
|
||
class _Bind(ast.NodeTransformer):
|
||
"""把最外层的引用换成占位名,值从上下文里按名字取:和口径卡当时在完整的上下文里取到的是同一个值。
|
||
认引用的规则和 expressions._Substitute 一样(取值链、整个 cell() 调用)。"""
|
||
|
||
def __init__(self, names: dict[str, str]) -> None:
|
||
self.names = names
|
||
|
||
def _swap(self, node: ast.AST) -> ast.AST | None:
|
||
name = self.names.get(ast.unparse(node))
|
||
return None if name is None else ast.copy_location(ast.Name(id=name, ctx=ast.Load()), node)
|
||
|
||
def visit_Call(self, node: ast.Call) -> ast.AST:
|
||
if _cell_call(node) and (swapped := self._swap(node)) is not None:
|
||
return swapped
|
||
return self.generic_visit(node)
|
||
|
||
def _chain(self, node: ast.AST) -> ast.AST:
|
||
if _chain_root(node) is None:
|
||
return self.generic_visit(node)
|
||
swapped = self._swap(node)
|
||
return swapped if swapped is not None else node
|
||
|
||
visit_Attribute = visit_Subscript = visit_Name = _chain
|
||
|
||
|
||
def _evaluate(plan: _Plan, values: dict[str, Any]) -> tuple[Any, str | None]:
|
||
"""按原式求值,报错的说法和口径卡一致。取整和口径卡共用 metrics.round_metric(只取整浮点)。"""
|
||
names = {path: _PLACEHOLDER.format(i) for i, path in enumerate(plan.tops)}
|
||
bound = ast.fix_missing_locations(_Bind(names).visit(copy.deepcopy(plan.tree)))
|
||
ctx = {names[p]: values.get(p) for p in plan.tops}
|
||
try:
|
||
value = eval_expression(ast.unparse(bound), ctx)
|
||
except ExpressionError as e:
|
||
return None, f"表达式有误({e})"
|
||
except ZeroDivisionError:
|
||
return None, "除以零"
|
||
except Exception as e: # noqa: BLE001 - None 参与算术、类型不对:和口径卡一样说人话
|
||
if any(v is None for v in ctx.values()) or "NoneType" in str(e):
|
||
return None, "有输入为空值,无法计算"
|
||
if isinstance(e, TypeError):
|
||
return None, "输入的类型不正确,无法计算"
|
||
return None, f"无法计算({describe_exception(e)})"
|
||
# 取整和口径卡是同一个函数:卡上怎么取整的,推演就怎么取整
|
||
return round_metric(value, plan.metric.get("decimals")), None
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 结果
|
||
# --------------------------------------------------------------------------
|
||
|
||
#: 这几种状态下新值是确定的(算出来了,或者确定算不出来);unsupported、masked 是不知道
|
||
_KNOWN = frozenset({"unchanged", "ok", "excluded", "error"})
|
||
|
||
|
||
def _result(plan: _Plan, status: str, new: Any, reason: str | None, values: dict[str, Any],
|
||
notes: dict[str, list[int]]) -> dict[str, Any]:
|
||
metric = plan.metric
|
||
old = metric.get("value")
|
||
known = status in _KNOWN
|
||
blocked = plan.blocked is not None
|
||
delta, rendered_delta = _delta(metric, old, new) if known else (None, None)
|
||
substituted = metric.get("substituted") if status == "unchanged" \
|
||
else substitute(plan.tree, values) if status == "ok" and plan.tree is not None else None
|
||
if plan.hides or any(inp.masked or inp.unreadable for inp in plan.inputs.values()):
|
||
substituted = None # 代入式里代进去的就是遮罩列(或者遮没遮核对不了)的那一格
|
||
out = {
|
||
"id": metric.get("id"), "name": metric.get("name") or metric.get("id"), "unit": metric.get("unit") or "",
|
||
"decimals": metric.get("decimals"), "format": metric.get("format") or DEFAULT_FORMAT,
|
||
"old": _finite(old), "new": _finite(new), "delta": delta,
|
||
"rendered_old": _render(metric, old), "rendered_new": _render(metric, new) if known else None,
|
||
"rendered_delta": rendered_delta,
|
||
"changed": (not _same(old, new)) if known else None,
|
||
"status": status, "reason": reason, "substituted": substituted,
|
||
"inputs": [_input_view(inp, values, notes, blocked=blocked) for inp in plan.inputs.values()],
|
||
}
|
||
if plan.hides:
|
||
# 指标的值就是(或者算自)遮罩列的那一格:原值同样写「已遮罩」,遮没遮核对不了的写「无法核对」。以前输入里
|
||
# 拿掉了、指标的 old 照给,同一个号码换个地方照样看得到;原因在 reason 里(推演不了的原因)。本来就没有值的
|
||
# 照旧是空的,缺输入还得看得出来
|
||
out["withheld"] = True
|
||
if old is not None:
|
||
out["old"] = out["rendered_old"] = _masked_word() if plan.held == "masked" else _unverified_word()
|
||
return out
|
||
|
||
|
||
def _render(metric: dict[str, Any], value: Any, conv: str | None = None) -> str:
|
||
"""按卡上的格式渲染:和口径卡的 rendered、报告里的字同一套规则。渲染不出来的显示「—」(metrics._rendered 同样)。"""
|
||
try:
|
||
return render_metric({**metric, "value": value}, conv)
|
||
except RenderError:
|
||
return MISSING
|
||
|
||
|
||
def _finite(value: Any) -> Any:
|
||
"""无穷大、非数、浮点放不下的整数换成 None:老卡上可能记着(口径卡渲染成「—」),JSON 里放不下或界面读不准。"""
|
||
return None if is_number(value) and not _number(value) else value
|
||
|
||
|
||
def _same(a: Any, b: Any) -> bool:
|
||
if is_number(a) and is_number(b):
|
||
return a == b or (isinstance(a, float) and isinstance(b, float) and math.isnan(a) and math.isnan(b))
|
||
return a == b
|
||
|
||
|
||
def _delta(metric: dict[str, Any], old: Any, new: Any) -> tuple[Any, str | None]:
|
||
"""变化量和它的写法。比率、百分数的变化写「个百分点」:8.7% 到 19.0% 是多了 10.3 个百分点,不是 10.3%。"""
|
||
if not (_number(old) and _number(new)):
|
||
return None, None
|
||
decimals = metric.get("decimals")
|
||
places = decimals if isinstance(decimals, int) and not isinstance(decimals, bool) else None
|
||
# 变化量按卡上的小数位取整,规则同指标本身(round_metric):不然 8.7 到 19.0 会写成 +10.299999999999999
|
||
delta = round_metric(new - old, places)
|
||
if delta == 0:
|
||
return delta, None
|
||
fmt = metric.get("format") or DEFAULT_FORMAT
|
||
unit = str(metric.get("unit") or "")
|
||
try:
|
||
if fmt == "percent_of_ratio":
|
||
text = render_number(abs(delta), decimals=places, fmt=fmt).rstrip("%") + " 个百分点"
|
||
elif unit in ("%", "%"):
|
||
text = render_number(abs(delta), decimals=places, fmt=fmt) + " 个百分点"
|
||
else:
|
||
text = render_number(abs(delta), unit=unit, decimals=places, fmt=fmt)
|
||
except RenderError:
|
||
return delta, None
|
||
return delta, ("+" if delta > 0 else "-") + text
|
||
|
||
|
||
def _kind(inp: _Input) -> str:
|
||
if inp.shape == "container":
|
||
return "container"
|
||
value = inp.item.get("value")
|
||
return "missing" if value is None else "bool" if isinstance(value, bool) \
|
||
else "number" if is_number(value) else "text"
|
||
|
||
|
||
def _editable(inp: _Input, *, blocked: bool) -> bool:
|
||
"""推演里能改的输入:数值(或原本缺失)的标量,不是遮罩列、不决定取哪一格、还原得出。整个指标推演不了时一个都不算:
|
||
改了也推演不出来。"""
|
||
return not blocked and inp.shape == "scalar" and not inp.masked and not inp.selector and inp.problem is None \
|
||
and (inp.value is None or _number(inp.value))
|
||
|
||
|
||
def _row_span(inp: _Input) -> tuple[int, int] | None:
|
||
"""这个输入实际用到的快照行(含两端):单格一行、数组字段一段、整份结果全部。排除别的行影响不到它,界面据此只让
|
||
这几行可勾。遮罩的、还原不了的(完整值找不回来、快照读不出来,都记在 problem 里)不给:排除它们的行什么也推演
|
||
不出来。决定取哪一格的数组字段、整份结果也不给:少了几行,外面那个引用取到哪一格说不准(_compute),勾了只能
|
||
说不知道,「不支持推演的输入」里又说它不能用。决定取哪一格的单格照给:勾掉它,确定是排除后无法计算。"""
|
||
if inp.snapshot is None or inp.masked or inp.problem is not None:
|
||
return None
|
||
if inp.row is not None:
|
||
return inp.row, inp.row
|
||
if inp.selector:
|
||
return None
|
||
if inp.rows is not None:
|
||
return inp.rows
|
||
rows = inp.value.get("rows") if isinstance(inp.value, dict) else inp.value
|
||
return (0, len(rows) - 1) if inp.table and isinstance(rows, list) and rows else None
|
||
|
||
|
||
def _input_view(inp: _Input, values: dict[str, Any], notes: dict[str, list[int]], *, blocked: bool) -> dict[str, Any]:
|
||
"""一个输入在推演里的样子:原值、新值、排除了哪几行、用到快照的哪几行(row_span)。遮罩列取来的、落在读不出来的
|
||
快照上的不给值。容器写大概(「[12 项]」)。整个指标推演不了时(blocked)一个都不让改、不给行段。"""
|
||
item = inp.item
|
||
span = None if blocked else _row_span(inp)
|
||
view: dict[str, Any] = {"path": inp.path, "via": item.get("via"), "node_id": item.get("node_id"),
|
||
**({"artifact": inp.snapshot} if inp.snapshot else {}),
|
||
**({"locator": item["locator"]} if isinstance(item.get("locator"), dict) else {}),
|
||
**({"row_span": list(span)} if span else {}),
|
||
"kind": _kind(inp), "editable": _editable(inp, blocked=blocked)}
|
||
if inp.masked:
|
||
return {**view, "old": None, "new": None, "changed": False, "masked": True}
|
||
if inp.unreadable and inp.shape == "scalar":
|
||
return {**view, "old": None, "new": None, "changed": False, "withheld": True}
|
||
if inp.shape == "container":
|
||
old = _outline(inp.value, inp.table) if inp.problem is None else item.get("value")
|
||
new = _outline(values.get(inp.path), inp.table) if inp.problem is None else None
|
||
# 完整值找不回来的数组排除了它用到的行:少了几项是确定的,只是新的样子不知道(new 为空)
|
||
changed = not _same(old, new) if inp.problem is None else inp.path in notes
|
||
else:
|
||
old, new = _finite(item.get("value")), _finite(values.get(inp.path))
|
||
changed = not _same(old, new)
|
||
view.update(old=old, new=new, changed=changed)
|
||
if inp.path in notes:
|
||
view["excluded_rows"] = notes[inp.path]
|
||
return view
|
||
|
||
|
||
def _outline(value: Any, table: bool) -> Any:
|
||
"""容器的大概。整份查询结果写行数(「[12 项]」),排除了几行看得出来;别的照卡上的写法。"""
|
||
if table and isinstance(value, dict) and isinstance(value.get("rows"), list):
|
||
return _brief(value["rows"])
|
||
return _brief(value) if isinstance(value, (list, dict)) else value
|
||
|
||
|
||
def _unsupported(plans: list[_Plan]) -> list[dict[str, Any]]:
|
||
"""不能用于推演的输入和原因:遮罩的、完整值找不回来的(含快照读不出来的)、决定取哪一格的、既不能改也没有行可勾的。
|
||
|
||
整个指标推演不了的(_Plan.blocked)先有一条 input 为空的,原因和指标上的一样:界面据此只写原因,不画输入框、
|
||
勾选框。之后只列卡住它的输入(遮罩的、还原不了的),别的输入能不能改已无从谈起;卡本身没记输入的只有这一条。
|
||
"""
|
||
out: list[dict[str, Any]] = []
|
||
for plan in plans:
|
||
metric_id = plan.metric.get("id")
|
||
if blocked := plan.blocked:
|
||
out.append({"metric": metric_id, "input": None, "reason": blocked[1]})
|
||
if plan.problem:
|
||
continue
|
||
for inp in plan.inputs.values():
|
||
if blocked and not (inp.masked or inp.problem):
|
||
continue
|
||
reason = (MASKED_VALUE_INPUT if inp.by_value else MASKED_COPY if inp.copy else MASKED_TABLE if inp.table
|
||
else MASKED_INPUT) if inp.masked \
|
||
else f"无法推演:{inp.problem}" if inp.problem \
|
||
else SELECTOR if inp.selector else _fixed(inp)
|
||
if reason:
|
||
out.append({"metric": metric_id, "input": inp.path, "reason": reason})
|
||
return out
|
||
|
||
|
||
def _fixed(inp: _Input) -> str | None:
|
||
"""还原得出、没遮罩,却既不能改值、也没有行可勾的输入:说清为什么,不让人对着一个改不了的值干看。"""
|
||
if inp.shape == "container":
|
||
return None if _row_span(inp) is not None else FIXED_CONTAINER
|
||
value = inp.item.get("value")
|
||
return None if value is None or is_number(value) else NOT_NUMBER
|
||
|
||
|
||
def withheld_value(metric: dict[str, Any], value: Any, *, masked: Iterable[str] = (), unverified: Iterable[str] = (),
|
||
uncertain: bool = False, secrets: Callable[[], Any] | None = None) -> str | None:
|
||
"""指标本身的值给不给(推演的原值、证据面板指标步骤的值,两处同一个判断):None 照给;"masked" 写「已遮罩」;
|
||
"unverified" 写「无法核对」。masked 是取自遮罩列的一格、一行(或者值就是遮罩值的一格)的输入的路径,unverified 是
|
||
遮没遮核对不了的输入的路径,uncertain 是有没有整份结果当输入、认不出出处的输入。
|
||
|
||
- 有输入取自遮罩列的一格、一行:不给,哪怕它只嵌在下标里(推演把整个指标按遮罩处理,_Plan.blocked)。指标的值可能
|
||
就是那一格(cell(nodes.q, 0, 'phone')),也可能是算出来的数,宁可多遮:改一个别的输入、拿指标的值一减,就能算出
|
||
遮住的那一格
|
||
- 最外层的输入遮没遮核对不了:不给,它的值就在指标值里。只嵌在下标里的不算:它只决定取哪一格,取到的值卡上另记着。
|
||
认不出最外层引用(表达式有误)时,有一个遮没遮核对不了就不给。说法和遮着的分开:读的人不该以为这里设了遮罩
|
||
- 整份结果当输入、认不出出处的输入(uncertain):按值认,值里有这次运行查到过的遮罩值(secrets(),masking._Secrets)
|
||
才不给。整份结果不一定把哪一格带进指标:len(vars.res.rows) 数行数,照给;str(vars.res.rows)、max(r[2] for r in …)
|
||
值里就是号码,不给。以前整份结果有一列遮着就不给,推演写「已遮罩」、证据面板的指标步骤照写行数,同一个数两样说法
|
||
"""
|
||
if any(True for _ in masked):
|
||
return "masked"
|
||
unverified = set(unverified)
|
||
if unverified:
|
||
try:
|
||
tops, _ = _refs(parse_expression(str(metric.get("expression") or ""))[0])
|
||
except ExpressionError:
|
||
return "unverified"
|
||
if unverified & set(tops):
|
||
return "unverified"
|
||
if uncertain and secrets is not None and maybe_masked_value(value) and carries_masked(value, secrets()):
|
||
return "masked"
|
||
return None
|
||
|
||
|
||
def maybe_masked_value(value: Any) -> bool:
|
||
"""值有没有可能是按值认得出的遮罩值(masking 的门槛:够长的数、两个字以上的文字),容器一律算。不可能的不必去取这次
|
||
运行的遮罩值(要把封存的查询快照读一遍)。"""
|
||
if value is None or isinstance(value, bool):
|
||
return False
|
||
if isinstance(value, (list, tuple, dict)):
|
||
return True
|
||
from app.engine.masking import _forms, _strong
|
||
|
||
return any(_strong(form) for form in _forms(value))
|
||
|
||
|
||
def carries_masked(value: Any, secrets: Any) -> bool:
|
||
"""值里有没有按值认得出的遮罩值(secrets 是 masking._Secrets,None 为没有):整个值就是一个遮罩值(数写法不同也算),
|
||
或者文字、容器(按 JSON 写出来)里写着一个(换了写法的长号码也认)。"""
|
||
if secrets is None or value is None or isinstance(value, bool):
|
||
return False
|
||
if isinstance(value, (list, tuple, dict)):
|
||
return bool(secrets.spans(json.dumps(value, ensure_ascii=False, default=str), loose=True))
|
||
if secrets.hit(value):
|
||
return True
|
||
return isinstance(value, str) and bool(secrets.spans(value, loose=True))
|
||
|
||
|
||
def _masked_word() -> str:
|
||
"""「已遮罩」:证据面板、推演同一个说法。api.evidence 导入了这个模块,用到时再导入。"""
|
||
from app.api.evidence import MASKED
|
||
|
||
return MASKED
|
||
|
||
|
||
def _unverified_word() -> str:
|
||
"""「无法核对」:遮没遮核对不了、不给的值(和「已遮罩」分开:读的人不该以为这里设了遮罩)。同 _masked_word。"""
|
||
from app.api.evidence import UNVERIFIED_VALUE
|
||
|
||
return UNVERIFIED_VALUE
|
||
|
||
|
||
def substituted_without(metric: dict[str, Any], paths: Iterable[str] | Mapping[str, str],
|
||
token: str = "") -> str | None:
|
||
"""卡上的代入式,只把 paths 这几个输入代成 token(证据面板给取自遮罩列的输入写「已遮罩」),别的照卡上代。paths 也可以
|
||
是 {路径: 代成的字},各写各的(遮没遮核对不了的那一处写「无法核对」)。
|
||
|
||
按卡上记的输入值重新代一遍:先核对一个都不换时代出来的和卡上记的一字不差,对不上(卡上记的不是原值、老卡的写法)
|
||
就整条不给,交回 None——宁可不显示代入式,也不能让遮着的值从对不上的那一处漏出来。卡上没代成字面量的(容器、太长的
|
||
文本)照旧留原路径。嵌在别的引用里的输入不单独换:外面那个引用代成了它的值,里面那一格本来就不出现。
|
||
"""
|
||
expr, recorded = metric.get("expression"), metric.get("substituted")
|
||
if not isinstance(expr, str) or not isinstance(recorded, str):
|
||
return None
|
||
try:
|
||
tree, _, _ = parse_expression(expr)
|
||
kept = set(leaf_refs(parse_expression(recorded)[0]))
|
||
except ExpressionError:
|
||
return None
|
||
values = {str(i["path"]): i.get("value") for i in metric.get("inputs") or []
|
||
if isinstance(i, dict) and isinstance(i.get("path"), str) and i["path"] not in kept}
|
||
if substitute(tree, values) != recorded:
|
||
return None
|
||
tokens = dict(paths) if isinstance(paths, Mapping) else {p: token for p in paths}
|
||
swapped = ast.fix_missing_locations(_Bind(tokens).visit(copy.deepcopy(tree)))
|
||
return substitute(swapped, {p: v for p, v in values.items() if p not in tokens})
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 报告里引用的地方、下游口径卡
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def report_segments(reports: Iterable[tuple[str, dict[str, Any]]], *, card_node: str, card_artifact: str,
|
||
results: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||
"""报告里引用了这些指标的片段,推演之后会写成什么:[{report, unit, sid, metric, rendered_old, rendered_new, changed}]。
|
||
|
||
只认指向这张卡这一份指标集的引用(目录条目的 node_id、artifact):循环里同一张卡算过几次,报告引用的是哪一次
|
||
就是哪一次。新值按片段自己的换算(|万、|pct……)渲染,和报告里的字同一套规则。指标推演不了(unsupported、masked)
|
||
时 rendered_new、changed 为 None:不知道会变成什么。缺输入、当时就显示「—」的引用也列上:推演里补上了值,它会变。
|
||
原值不给的指标(withheld),rendered_old 同样写「已遮罩」或「无法核对」。
|
||
"""
|
||
by_id = {str(r.get("id")): r for r in results}
|
||
out: list[dict[str, Any]] = []
|
||
for report_node, doc in reports:
|
||
catalog = doc.get("catalog") or {}
|
||
for _, unit in iter_units(doc):
|
||
for seg in unit.get("segments") or []:
|
||
cite = seg.get("cite") or {}
|
||
entry = catalog.get(cite.get("alias")) or {}
|
||
if entry.get("kind") != "metric" or entry.get("node_id") != card_node \
|
||
or entry.get("artifact") != card_artifact or not _cites(seg, cite, entry):
|
||
continue
|
||
result = by_id.get(str((entry.get("locator") or {}).get("metric")))
|
||
if result is None:
|
||
continue
|
||
new = None if result.get("rendered_new") is None else _render(result, result.get("new"), cite.get("conv"))
|
||
# 原值不给的指标(_Plan.hides):报告里引用它的地方同样写「已遮罩」(遮没遮核对不了的写「无法核对」),
|
||
# 推演的答复里不出现它的值
|
||
old = result.get("rendered_old") if result.get("withheld") and result.get("old") is not None \
|
||
else seg.get("text")
|
||
out.append({"report": report_node, "unit": unit.get("id"), "sid": seg.get("id"), "metric": result["id"],
|
||
"rendered_old": old, "rendered_new": new,
|
||
"changed": None if new is None else new != seg.get("text")})
|
||
return out
|
||
|
||
|
||
def _cites(seg: dict[str, Any], cite: dict[str, Any], entry: dict[str, Any]) -> bool:
|
||
"""片段引用了这个指标:解析成功的引用,或者指标在、只是这次缺输入没有值(显示「—」)的那种。"""
|
||
if cite.get("kind") != "metric":
|
||
return False
|
||
if seg.get("state") == "deterministic" and cite.get("status") == "resolved":
|
||
return True
|
||
return cite.get("status") == "unresolved" and entry.get("value") is None and entry.get("status") == "missing_input"
|
||
|
||
|
||
def downstream_cards(cards: Iterable[tuple[str, str, dict[str, Any]]], node_id: str, *,
|
||
edges: Iterable[tuple[Any, Any]] = ()) -> list[dict[str, Any]]:
|
||
"""引用了这张口径卡的下游口径卡:[{node_id, artifact, caliber, version, metrics, direct}]。
|
||
|
||
只标「可能受影响」,不重算:下游卡怎么用这张卡的值(取第几个指标、和别的什么一起算)要整条链重放才说得准。
|
||
认引用看下游指标的输入从哪个节点来(node_id,或者 nodes.<卡> 这样的路径):直接来自这张卡的标 direct。
|
||
经别的卡间接用到的也标;给了画布上的边(edges)时,从这张卡顺着边走得到的节点也算上游——下游卡经 transform 之类
|
||
的中间节点用到卡上的值(输入写的是 vars.t.g,来自中间节点),同样可能受影响。循环的回边会让循环里的节点都走得到,
|
||
宁可多标。同一张卡执行过几次的按最后一次。
|
||
"""
|
||
latest: dict[str, tuple[str, dict[str, Any]]] = {}
|
||
for nid, artifact, card in cards:
|
||
latest[str(nid)] = (artifact, card)
|
||
latest.pop(str(node_id), None)
|
||
|
||
def uses(card: dict[str, Any], upstream: set[str]) -> list[str]:
|
||
found = []
|
||
for metric in card.get("metrics") or []:
|
||
for item in (metric.get("inputs") or []) if isinstance(metric, dict) else []:
|
||
head = _REF_HEAD.match(str(item.get("path") or "")) if isinstance(item, dict) else None
|
||
origins = {str(item.get("node_id") or "")} | ({head.group(2)} if head and head.group(1) == "nodes" else set())
|
||
if origins & upstream:
|
||
found.append(metric.get("id"))
|
||
break
|
||
return found
|
||
|
||
upstream = {str(node_id)} | _reach(edges, str(node_id))
|
||
flagged: dict[str, dict[str, Any]] = {}
|
||
grew = True
|
||
while grew:
|
||
# 标出来的卡本身也成了上游:经它间接用到的再标一轮,直到没有新的
|
||
grew = False
|
||
for nid, (artifact, card) in latest.items():
|
||
if nid in flagged or not (metrics := uses(card, upstream)):
|
||
continue
|
||
flagged[nid] = {"node_id": nid, "artifact": artifact, "caliber": card.get("caliber") or "",
|
||
"version": card.get("caliber_version") or "", "metrics": metrics,
|
||
"direct": bool(uses(card, {str(node_id)}))}
|
||
upstream.add(nid)
|
||
grew = True
|
||
return [flagged[nid] for nid in latest if nid in flagged]
|
||
|
||
|
||
def _reach(edges: Iterable[tuple[Any, Any]], start: str) -> set[str]:
|
||
"""从 start 顺着边走得到的节点(不含 start 自己,除非绕回来)。"""
|
||
after: dict[str, list[str]] = {}
|
||
for a, b in edges:
|
||
after.setdefault(str(a), []).append(str(b))
|
||
seen: set[str] = set()
|
||
todo = [start]
|
||
while todo:
|
||
for nxt in after.get(todo.pop(), []):
|
||
if nxt not in seen:
|
||
seen.add(nxt)
|
||
todo.append(nxt)
|
||
return seen
|