mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 08:11:59 +00:00
549 lines
33 KiB
Python
549 lines
33 KiB
Python
"""逐个数标口径(docs/design/next-round/02-口径账本.md 做法 3):报告里每个引用了查询格、口径卡指标的数,来自哪一份口径。
|
||
|
||
标签(KINDS):
|
||
- caliber:来自口径「X」vN。how 是怎么认的:independent「等价已证明」(这次 Agent 自己写的 SQL 被证明和口径的取数等价)、
|
||
composed「助手选用」(助手 use_caliber 按口径组合出来的取数,证明了和那一版一致:ledger/match 的 composed)。
|
||
- external:外部常量(口径卡上的 kind=external),带来源、生效日期、是否过期。
|
||
- derived:规则型指标(kind=rule),数由结构化规则从输入算出,不是某条查询与口径等价。
|
||
- assessed:评估型指标(kind=assessment),按评分协议算出的评估值,不是测量值。
|
||
- related:相关口径——同一个数据源、查了同样的表的已发布口径,但不等价;附差在哪(explain_sql_diff 的条目)。
|
||
- reasoning:这次的推理——没有相关的已发布口径。
|
||
- unknown:没比对、比对没做完(超时、解析失败)、快照读不出来。
|
||
|
||
怎么认:
|
||
- 单元格([[v:]]、[[table:]] 里的格):按证据目录找到查询快照,取那条查询的比对状态;这条查询逐格比过(algo 4:整条按全部
|
||
引用列投影没证明时,被引用的每一列单独投影、和口径的每个指标列逐个比,ledger/match._cells)的,按这一格所在的那一列的
|
||
结论——同一条查询里一列等价、一列不等价各标各的。等价时按列映射写出口径那边的列名。
|
||
- 指标([[m:]]):指标集工件里这个指标的每个输入都要来自查询快照,而且这些快照都被证明等价于同一份口径的同一版、绑定一致;
|
||
指标式还要和那一版里某张口径卡上的某个指标相同——两边的式子把每个输入换成「口径的哪个取数节点、第几行、哪一列」之后
|
||
一字不差(caliber_from 转抄来的式子本来就是那一张卡的,同样按这个比)。输入有不来自查询的(运行输入、整形、代码)不算来自口径。
|
||
逐格比过的输入按它那一格的结论;同一条查询的好几格合起来用时,只在这条查询一定只出一行(one_row)时才按逐格的结论,
|
||
别的按整条查询:多行的结果里每一列各自对得上,不等于每一行的几列一起对得上。
|
||
|
||
整题(whole_of):主报告里所有带标签的数都来自同一口径的同一版、绑定一致,就记 equivalent,并写出还缺的入口字段(口径入口
|
||
节点声明了、这次的绑定里没有的;required 且没有缺省值的另列)。
|
||
|
||
分析结论里的数([[a:A3]]、[[a:A3.键]]、[[v:A3.r0.列]]、整表 [[table:A3]] 展开的格)不标:它们是从查询快照派生出来的,
|
||
不是口径(docs/design/analysis-round/00-总方案.md D11),不和口径比对、不进整题判断的计数,也不算成「没比对」。
|
||
|
||
这里只是纯函数:标签在比对时算好、存进比对工件(ledger/match.py);接口读的时候按这次运行查到过的遮罩值遮一遍(view_label):
|
||
绑定的值是遮罩值的不给值,差异条目里的 SQL 片段走证据面板同一套遮罩(api/evidence._Shown)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Callable, Mapping
|
||
from typing import Any
|
||
|
||
from app.analysis.contract import LEDGER_KIND
|
||
|
||
#: 标签的四种、认法的两种
|
||
KINDS = ("caliber", "external", "derived", "assessed", "related", "reasoning", "unknown")
|
||
HOWS = ("independent", "composed")
|
||
#: 界面上标签要写明的两句:比对在封存之后做,不在封存范围内;事后补比的是按现在的口径回看
|
||
POST_SEAL_NOTE = "封存后比对,不在封存范围内"
|
||
RETRO_NOTE = "按现在的口径回看"
|
||
#: 固化出的口径发布之后对来源运行补比的(store 的 trigger=certified)
|
||
CERTIFIED_NOTE = "按之后发布的口径回看"
|
||
#: 发布级别的叫法(同 blueprints/publish_plan.LEVEL_TEXT、前端 lib/terms.ts)
|
||
LEVEL_TEXT = {"published": "已发布", "governed": "受管"}
|
||
MASKED = "已遮罩"
|
||
|
||
#: unknown 的几种原因
|
||
NO_RECORD = "这一格出自的查询没有比对记录"
|
||
CARD_UNREADABLE = "指标集读不出来,没有比对"
|
||
METRIC_GONE = "指标集里没有这个指标,没有比对"
|
||
UNFINISHED = "比对没有做完"
|
||
#: related 的几种说明
|
||
FORMULA_DIFFERS = "取数和口径等价,但指标的算法和口径卡上的不同"
|
||
#: 指标按「同一行」认来自口径,可那一行重跑时不一定还是它:多行结果、查询又没有 ORDER BY(sql_semantics.row_stable)
|
||
ROW_ORDER_UNSTABLE = ("指标取的是多行结果里按行号的一格,查询没有 ORDER BY,行序不定:重新执行时这一行可能是另一行,"
|
||
"不能认定对应口径的同一格")
|
||
INPUTS_ELSEWHERE = "指标有的输入不来自查询(运行输入、整形或代码),不能算来自口径"
|
||
MIXED_CALIBERS = "指标的几个输入分别对上了不同的口径"
|
||
BINDING_CONFLICT = "指标的几个输入对上了同一份口径,但参数不一致"
|
||
|
||
#: 能追到查询快照里那一格的输入(同 engine/lineage.SNAPSHOT_VIAS)
|
||
_SNAPSHOT_VIAS = ("tool_cell", "agent_field")
|
||
|
||
|
||
def caliber_key(workflow_id: str, version: int) -> str:
|
||
"""口径版本的写法「工作流 id@版本」:工件 meta、召回反查、统计都按它认。"""
|
||
return f"{workflow_id}@{version}"
|
||
|
||
|
||
def _unknown(reason: str) -> dict[str, Any]:
|
||
return {"kind": "unknown", "reason": reason}
|
||
|
||
|
||
def _related(query: Mapping[str, Any], *, reason: str | None = None) -> dict[str, Any]:
|
||
"""相关口径:这条查询比过、没等价的那几份(附差在哪)。一份相关的都没有时是这次的推理。"""
|
||
related = [{k: cand[k] for k in ("caliber", "workflow_id", "version", "level", "node_id", "comparable",
|
||
"reason", "diff", "binding", "via") if k in cand}
|
||
for cand in query.get("candidates") or [] if cand.get("status") == "related"]
|
||
if not related:
|
||
return {"kind": "reasoning"}
|
||
out: dict[str, Any] = {"kind": "related", "related": related}
|
||
if reason:
|
||
out["reason"] = reason
|
||
return out
|
||
|
||
|
||
def _caliber(cand: Mapping[str, Any], **extra: Any) -> dict[str, Any]:
|
||
"""来自口径的标签。定义的内容指纹取自候选:版本级的 caliber_hash,或者候选自带 / extra 覆盖的 definition_hash
|
||
(指标标签按它所在的那张卡的哈希覆盖版本级,见 metric_label)。"""
|
||
out: dict[str, Any] = {
|
||
"kind": "caliber", "how": "composed" if cand.get("how") == "composed" else "independent",
|
||
"caliber": cand.get("caliber"),
|
||
"workflow_id": cand.get("workflow_id"), "version": cand.get("version"), "level": cand.get("level"),
|
||
"binding": dict(cand.get("binding") or {}),
|
||
}
|
||
if hash := cand.get("definition_hash") or cand.get("caliber_hash"):
|
||
out["definition_hash"] = hash
|
||
return {**out, **extra}
|
||
|
||
|
||
def _proven(query: Mapping[str, Any]) -> list[Mapping[str, Any]]:
|
||
return [c for c in query.get("candidates") or [] if c.get("status") == "proven"]
|
||
|
||
|
||
def _caliber_column(cand: Mapping[str, Any], column: Any) -> str | None:
|
||
"""这次结果里的列 → 口径那边同一位置的列名(列映射,按位置);没有映射、对不上时为 None。"""
|
||
for item in cand.get("columns") or []:
|
||
if item.get("ours") == column:
|
||
return item.get("caliber")
|
||
return None
|
||
|
||
|
||
def cells_of(query: Mapping[str, Any]) -> list[Mapping[str, Any]]:
|
||
"""这条查询逐格比出来的几格(algo 4 的 cells,见 ledger/match._cells);老工件、没逐格比的为空。"""
|
||
cells = query.get("cells")
|
||
return [c for c in cells.values() if isinstance(c, Mapping)] if isinstance(cells, Mapping) else []
|
||
|
||
|
||
def candidates_of(query: Mapping[str, Any], *, shown: bool = False) -> list[Mapping[str, Any]]:
|
||
"""一条查询的候选:整条比的,加上逐格比出来的。召回反查、使用次数、工件 meta 都要算上逐格证明了的那几份口径。shown 为真
|
||
时只要标签上给人看的那一份:逐格比过的只要逐格的(整条的那一份不在标签上),别的照旧是整条的(统计兜底说明的比例用)。"""
|
||
cells = [c for cell in cells_of(query) for c in cell.get("candidates") or [] if isinstance(c, Mapping)]
|
||
whole = [c for c in query.get("candidates") or [] if isinstance(c, Mapping)]
|
||
if shown:
|
||
return cells or whole
|
||
return whole + cells
|
||
|
||
|
||
def _view(query: Mapping[str, Any], column: Any, *, alone: bool = True) -> Mapping[str, Any]:
|
||
"""这一格按什么判:这条查询逐格比过这一列(cells 里有)的按那一格的结论,别的按整条查询的。alone 为假(一个指标用了
|
||
这条查询的好几格)时,只有这条查询一定只出一行(one_row)才按逐格的(见模块说明)。老工件没有 cells,照旧按整条。"""
|
||
cells = query.get("cells")
|
||
cell = cells.get(column) if isinstance(cells, Mapping) and isinstance(column, str) else None
|
||
if isinstance(cell, Mapping) and (alone or query.get("one_row") is True):
|
||
return cell
|
||
return query
|
||
|
||
|
||
def cell_label(artifact: str, column: Any, queries: Mapping[str, Mapping[str, Any]]) -> dict[str, Any]:
|
||
"""单元格的标签:它所在的那条查询的比对状态(逐格比过的按这一格,_view)。"""
|
||
found = queries.get(artifact)
|
||
if found is None:
|
||
return _unknown(NO_RECORD)
|
||
query = _view(found, column)
|
||
status = query.get("status")
|
||
if status in ("proven", "composed"):
|
||
cand = _proven(query)[0] if _proven(query) else {}
|
||
label = _caliber(cand, query=artifact, node_id=cand.get("node_id"),
|
||
column=_caliber_column(cand, column) if column is not None else None)
|
||
if status == "composed":
|
||
label["how"] = "composed"
|
||
return label
|
||
if status == "related":
|
||
return {**_related(query), "query": artifact}
|
||
if status == "none":
|
||
return {"kind": "reasoning", "query": artifact}
|
||
return {**_unknown(str(query.get("reason") or UNFINISHED)), "query": artifact}
|
||
|
||
|
||
def _row_stable(query: Mapping[str, Any], item: Mapping[str, Any]) -> bool:
|
||
"""这个输入取的那一行重跑时还认得住(match 记的 ordered / one_row,sql_semantics.row_stable 的认法):最外层带 ORDER BY,
|
||
或者一定只出一行、取的是第 0 行。老的比对记录没记 ordered 的照旧(当时不判)。"""
|
||
if "ordered" not in query:
|
||
return True
|
||
where = item.get("locator") if isinstance(item.get("locator"), dict) else {}
|
||
return bool(query.get("ordered")) or (where.get("row") in (0, None) and bool(query.get("one_row")))
|
||
|
||
|
||
def _leaf_token(node_id: Any, row: Any, column: Any) -> str:
|
||
"""把指标式里的一个输入换成「口径的哪个取数节点、第几行、哪一列」:两边的式子按这个换完再逐字比。"""
|
||
return f"⟦{node_id}|{row}|{column}⟧"
|
||
|
||
|
||
def _mapped_expression(expression: str, tokens: Mapping[str, str]) -> str | None:
|
||
"""指标式里的输入按 tokens(输入路径 → 记号)换掉之后的写法;解析不了、有输入没换到的为 None。"""
|
||
from app.engine.expressions import ExpressionError, leaf_refs, parse_expression, substitute
|
||
|
||
try:
|
||
tree = parse_expression(expression)[0]
|
||
except ExpressionError:
|
||
return None
|
||
leaves = leaf_refs(tree)
|
||
if not leaves or any(leaf not in tokens for leaf in leaves):
|
||
return None
|
||
return substitute(tree, dict(tokens))
|
||
|
||
|
||
def card_expressions(cards: list[Mapping[str, Any]]) -> list[tuple[str, str, str]]:
|
||
"""口径版本里每张源头口径卡的每个指标:(卡的节点 id, 指标 id, 换成记号后的式子)。卡上的输入要是 cell(nodes.X, 行, '列')
|
||
(look/calibers.card_inputs 认的那种),别的写法的指标不收。"""
|
||
import ast
|
||
|
||
from app.engine.expressions import ExpressionError, cell_parts, leaf_refs, parse_expression
|
||
|
||
out: list[tuple[str, str, str]] = []
|
||
for card in cards:
|
||
for metric in card.get("metrics") or []:
|
||
expression = str((metric or {}).get("expression") or "")
|
||
try:
|
||
tree = parse_expression(expression)[0]
|
||
except ExpressionError:
|
||
continue
|
||
tokens: dict[str, str] = {}
|
||
for leaf in leaf_refs(tree):
|
||
parts = cell_parts(leaf)
|
||
if parts is None or not parts[0].startswith("nodes."):
|
||
break
|
||
try:
|
||
row, column = ast.literal_eval(parts[1]), ast.literal_eval(parts[2])
|
||
except (ValueError, SyntaxError):
|
||
break
|
||
tokens[leaf] = _leaf_token(parts[0][len("nodes."):], row, column)
|
||
else:
|
||
if (mapped := _mapped_expression(expression, tokens)) is not None:
|
||
out.append((str(card.get("node_id")), str(metric.get("id")), mapped))
|
||
return out
|
||
|
||
|
||
def card_reads(cards: list[Mapping[str, Any]]) -> list[tuple[str, str, tuple[tuple[str, str], ...]]]:
|
||
"""口径版本里每张源头口径卡的每个指标读了哪几格的列:(卡的节点 id, 指标 id, ((取数节点, 列), …)),按式子里出现的先后、
|
||
去重。只认 cell(nodes.X, 行, '列') 这种输入(同 card_expressions);有别的写法的指标不收。比对时口径一侧按它投影到
|
||
「这个指标引用的列」(ledger/match 的 _projected)。"""
|
||
import ast
|
||
|
||
from app.engine.expressions import ExpressionError, cell_parts, leaf_refs, parse_expression
|
||
|
||
out: list[tuple[str, str, tuple[tuple[str, str], ...]]] = []
|
||
for card in cards:
|
||
for metric in card.get("metrics") or []:
|
||
try:
|
||
tree = parse_expression(str((metric or {}).get("expression") or ""))[0]
|
||
except ExpressionError:
|
||
continue
|
||
reads: list[tuple[str, str]] = []
|
||
for leaf in leaf_refs(tree):
|
||
parts = cell_parts(leaf)
|
||
if parts is None or not parts[0].startswith("nodes."):
|
||
break
|
||
try:
|
||
column = ast.literal_eval(parts[2])
|
||
except (ValueError, SyntaxError):
|
||
break
|
||
if not isinstance(column, str):
|
||
break
|
||
read = (parts[0][len("nodes."):], column)
|
||
if read not in reads:
|
||
reads.append(read)
|
||
else:
|
||
if reads:
|
||
out.append((str(card.get("node_id")), str(metric.get("id")), tuple(reads)))
|
||
return out
|
||
|
||
|
||
def referenced_columns(docs: list[Mapping[str, Any]],
|
||
load_card: Callable[[str], Mapping[str, Any] | None]) -> dict[str, set[str]]:
|
||
"""报告里引用了哪条查询的哪几列:{查询快照: {列名}}。和 label_doc 标的是同一批数——单元格([[v:]]、[[table:]] 里的格)
|
||
按证据目录找到的快照和列;指标([[m:]])按指标集里这个指标的输入(落在查询快照一格上的)。比对时按它投影:只比被
|
||
引用的那几列(ledger/match 的 _projected)。"""
|
||
from app.engine.evidence import iter_units
|
||
|
||
out: dict[str, set[str]] = {}
|
||
cards: dict[str, Mapping[str, Any] | None] = {}
|
||
for doc in docs:
|
||
catalog = doc.get("catalog") if isinstance(doc.get("catalog"), dict) else {}
|
||
for _, unit in iter_units(doc):
|
||
for seg in unit.get("segments") or []:
|
||
if not isinstance(seg, dict) or seg.get("kind") not in ("number", "value") or not seg.get("id"):
|
||
continue
|
||
cite = seg.get("cite") if isinstance(seg.get("cite"), dict) else {}
|
||
if cite.get("status") != "resolved":
|
||
continue
|
||
entry = catalog.get(cite.get("alias")) if isinstance(catalog.get(cite.get("alias")), dict) else {}
|
||
if entry.get("kind") == LEDGER_KIND:
|
||
continue # 分析结论的结果表不是查询,不比对
|
||
locator = cite.get("locator") if isinstance(cite.get("locator"), dict) else {}
|
||
artifact = str(entry.get("artifact") or "")
|
||
if cite.get("kind") == "cell" and artifact and isinstance(locator.get("column"), str):
|
||
out.setdefault(artifact, set()).add(locator["column"])
|
||
elif cite.get("kind") == "metric" and artifact:
|
||
if artifact not in cards:
|
||
cards[artifact] = load_card(artifact)
|
||
card = cards[artifact] or {}
|
||
metric = next((m for m in card.get("metrics") or []
|
||
if isinstance(m, dict) and m.get("id") == locator.get("metric")), None)
|
||
for item in (metric or {}).get("inputs") or []:
|
||
where = item.get("locator") if isinstance(item, dict) else None
|
||
if isinstance(item, dict) and item.get("artifact") and item.get("via") in _SNAPSHOT_VIAS \
|
||
and isinstance(where, dict) and isinstance(where.get("column"), str):
|
||
out.setdefault(str(item["artifact"]), set()).add(where["column"])
|
||
return out
|
||
|
||
|
||
def metric_label(card: Mapping[str, Any] | None, metric_id: Any, queries: Mapping[str, Mapping[str, Any]],
|
||
calibers: Mapping[str, Mapping[str, Any]]) -> dict[str, Any]:
|
||
"""指标的标签(见模块说明)。card 是封存的指标集工件(读不出为 None);calibers 是 {「工作流@版本」: {level, cards}},
|
||
cards 是那一版里源头口径卡的指标式(match 从版本快照取)。"""
|
||
if card is None:
|
||
return _unknown(CARD_UNREADABLE)
|
||
metric = next((m for m in card.get("metrics") or [] if isinstance(m, dict) and m.get("id") == metric_id), None)
|
||
if metric is None:
|
||
return _unknown(METRIC_GONE)
|
||
external = metric.get("external")
|
||
if isinstance(external, dict):
|
||
# 外部常量不来自查询:没有等价证明可做,标签单独一类;带来源和生效日期,过期照实标
|
||
return {"kind": "external", "source": external.get("source"), "as_of": external.get("as_of"),
|
||
"stale": bool(external.get("stale")),
|
||
**({"definition_hash": card.get("definition_hash")} if card.get("definition_hash") else {})}
|
||
inputs = [i for i in metric.get("inputs") or [] if isinstance(i, dict)]
|
||
from_queries = [i for i in inputs if i.get("artifact") and i.get("via") in _SNAPSHOT_VIAS]
|
||
if metric.get("rule") is not None:
|
||
# 规则型指标:数由规则从输入算出,不是某条查询与口径等价;输入仍然逐格可追溯。
|
||
# 标签里只给结构(输入名、条件条数、then/else),不给条件里的字面量——那可能是遮罩列的取值
|
||
rule = metric.get("rule") if isinstance(metric.get("rule"), dict) else {}
|
||
return {"kind": "derived",
|
||
"rule": {"inputs": sorted(str(k) for k in (rule.get("inputs") or {})),
|
||
"conditions": len(rule.get("all") or []),
|
||
"then": rule.get("then", 1), "else": rule.get("else", 0)},
|
||
"queries": [str(i["artifact"]) for i in from_queries],
|
||
**({"definition_hash": card.get("definition_hash")} if card.get("definition_hash") else {})}
|
||
if metric.get("assessment") is not None:
|
||
# 评估值:按评分协议从输入算出,不是测量值,也不参与自动出具。标签里不带各维度的原始取值
|
||
# (可能是遮罩列的取值),只给得分、等级和维度名/权重
|
||
assessment = metric.get("assessment") if isinstance(metric.get("assessment"), dict) else {}
|
||
dimensions = [{"name": d.get("name"), "weight": d.get("weight")}
|
||
for d in assessment.get("dimensions") or [] if isinstance(d, dict)]
|
||
return {"kind": "assessed",
|
||
"assessment": {"score": assessment.get("score"), "level": assessment.get("level"),
|
||
"dimensions": dimensions,
|
||
"levels": [{"name": l.get("name"), "min": l.get("min")}
|
||
for l in assessment.get("levels") or [] if isinstance(l, dict)]},
|
||
"queries": [str(i["artifact"]) for i in from_queries],
|
||
**({"definition_hash": card.get("definition_hash")} if card.get("definition_hash") else {})}
|
||
if not from_queries:
|
||
return {"kind": "reasoning"}
|
||
rows = [queries.get(str(i["artifact"])) for i in from_queries]
|
||
if any(q is None for q in rows):
|
||
return _unknown(NO_RECORD)
|
||
# 每个输入按它那一格判(_view):同一条查询用了好几格的,那条查询一定只出一行才按逐格的结论
|
||
used: dict[str, set[Any]] = {}
|
||
for item in from_queries:
|
||
where = item.get("locator") if isinstance(item.get("locator"), dict) else {}
|
||
used.setdefault(str(item["artifact"]), set()).add(where.get("column"))
|
||
found: list[Mapping[str, Any] | None] = [
|
||
_view(q, (i.get("locator") if isinstance(i.get("locator"), dict) else {}).get("column"),
|
||
alone=len(used[str(i["artifact"])]) == 1) if q is not None else None
|
||
for q, i in zip(rows, from_queries)]
|
||
if any(q.get("status") == "skipped" for q in found): # type: ignore[union-attr]
|
||
return _unknown(next(str(q.get("reason") or UNFINISHED) for q in found
|
||
if q.get("status") == "skipped")) # type: ignore[union-attr]
|
||
queries_found: list[Mapping[str, Any]] = [q for q in found if q is not None]
|
||
proven_keys = [{str(c.get("caliber")) for c in _proven(q)} for q in queries_found]
|
||
common = set.intersection(*proven_keys) if proven_keys else set()
|
||
nearby = _brief([c for q in queries_found for c in q.get("candidates") or []
|
||
if c.get("status") in ("proven", "related")])
|
||
queried = {"queries": [str(i["artifact"]) for i in from_queries]}
|
||
if len(from_queries) < len(inputs):
|
||
return {"kind": "related", "related": nearby, "reason": INPUTS_ELSEWHERE, **queried} if nearby \
|
||
else {"kind": "reasoning", **queried}
|
||
if not common:
|
||
if all(proven_keys):
|
||
return {"kind": "related", "related": nearby, "reason": MIXED_CALIBERS, **queried}
|
||
related = next((q for q in queries_found if q.get("status") == "related"), None)
|
||
return {**_related(related), **queried} if related is not None else {"kind": "reasoning", **queried}
|
||
if unstable := [i for i, q in zip(from_queries, queries_found) if not _row_stable(q, i)]:
|
||
# 按「同一行」认来自口径(_leaf_token 记的是行号):多行结果又没有 ORDER BY 的,重跑时第几行不一定是哪一行,
|
||
# 照实标相关、写明行序不定,不认成来自口径
|
||
return {"kind": "related", "related": nearby, "reason": ROW_ORDER_UNSTABLE, **queried}
|
||
reason = FORMULA_DIFFERS
|
||
for key in sorted(common):
|
||
chosen = [next(c for c in _proven(q) if c.get("caliber") == key) for q in queries_found]
|
||
binding: dict[str, Any] = {}
|
||
conflict = False
|
||
for cand in chosen:
|
||
for name, value in (cand.get("binding") or {}).items():
|
||
if name in binding and binding[name] != value:
|
||
conflict = True
|
||
binding.setdefault(name, value)
|
||
if conflict:
|
||
reason = BINDING_CONFLICT
|
||
continue
|
||
tokens: dict[str, str] = {}
|
||
for item, cand in zip(from_queries, chosen):
|
||
locator = item.get("locator") if isinstance(item.get("locator"), dict) else {}
|
||
mapped = _caliber_column(cand, locator.get("column"))
|
||
if mapped is None or item.get("path") is None:
|
||
tokens = {}
|
||
break
|
||
tokens[str(item["path"])] = _leaf_token(cand.get("node_id"), locator.get("row"), mapped)
|
||
mine = _mapped_expression(str(metric.get("expression") or ""), tokens) if tokens else None
|
||
if mine is None:
|
||
continue
|
||
hit = next(((node, mid) for node, mid, theirs in (calibers.get(key) or {}).get("expressions") or []
|
||
if theirs == mine), None)
|
||
if hit is None:
|
||
continue
|
||
# 指标按它对上的那张卡的哈希记(版本里不止一张卡时版本级哈希不够细);版本信息里没有这张卡的哈希就保留候选里的
|
||
card_hash = ((calibers.get(key) or {}).get("card_hashes") or {}).get(hit[0])
|
||
label = {**_caliber(chosen[0], card=hit[0], metric=hit[1],
|
||
**({"definition_hash": card_hash} if card_hash else {})), "binding": binding,
|
||
"queries": [str(i["artifact"]) for i in from_queries]}
|
||
if any(c.get("how") == "composed" for c in chosen):
|
||
# 几个输入里有一个是助手选用的,整个指标就按助手选用说(不能说成都是独立写出来的)
|
||
label["how"] = "composed"
|
||
return label
|
||
return {"kind": "related", "related": _brief([c for q in queries_found for c in _proven(q)
|
||
if c.get("caliber") in common]), "reason": reason, **queried}
|
||
|
||
|
||
def _brief(cands: list[Mapping[str, Any]]) -> list[dict[str, Any]]:
|
||
"""指标上的相关口径:只写是哪一版、哪个取数节点(差在哪在各个输入的查询上),同一版只写一次,最多 5 个。"""
|
||
out: list[dict[str, Any]] = []
|
||
for cand in cands:
|
||
if any(o.get("caliber") == cand.get("caliber") for o in out):
|
||
continue
|
||
out.append({k: cand[k] for k in ("caliber", "workflow_id", "version", "level", "node_id") if k in cand})
|
||
return out[:5]
|
||
|
||
|
||
def label_doc(doc: Mapping[str, Any], queries: Mapping[str, Mapping[str, Any]],
|
||
calibers: Mapping[str, Mapping[str, Any]],
|
||
load_card: Callable[[str], Mapping[str, Any] | None]) -> dict[str, dict[str, Any]]:
|
||
"""一份报告文档里每个数的标签:{片段 id: 标签}。只标引用解析了的数字、取值片段(单元格、指标);运行输入、表名、引文、
|
||
没有引用的数不标,分析结论里的数(具名标量、结果表的格)也不标(D11:派生的数,不是口径)。"""
|
||
from app.engine.evidence import iter_units
|
||
|
||
catalog = doc.get("catalog") if isinstance(doc.get("catalog"), dict) else {}
|
||
cards: dict[str, Mapping[str, Any] | None] = {}
|
||
out: dict[str, dict[str, Any]] = {}
|
||
for _, unit in iter_units(doc):
|
||
for seg in unit.get("segments") or []:
|
||
if not isinstance(seg, dict) or seg.get("kind") not in ("number", "value") or not seg.get("id"):
|
||
continue
|
||
cite = seg.get("cite") if isinstance(seg.get("cite"), dict) else {}
|
||
if cite.get("status") != "resolved":
|
||
continue
|
||
entry = catalog.get(cite.get("alias")) if isinstance(catalog.get(cite.get("alias")), dict) else {}
|
||
if entry.get("kind") == LEDGER_KIND:
|
||
continue
|
||
locator = cite.get("locator") if isinstance(cite.get("locator"), dict) else {}
|
||
artifact = str(entry.get("artifact") or "")
|
||
if cite.get("kind") == "cell" and artifact:
|
||
out[str(seg["id"])] = cell_label(artifact, locator.get("column"), queries)
|
||
elif cite.get("kind") == "metric" and artifact:
|
||
if artifact not in cards:
|
||
cards[artifact] = load_card(artifact)
|
||
out[str(seg["id"])] = metric_label(cards[artifact], locator.get("metric"), queries, calibers)
|
||
return out
|
||
|
||
|
||
def whole_of(labels: Mapping[str, Mapping[str, Any]], calibers: Mapping[str, Mapping[str, Any]]) -> dict[str, Any]:
|
||
"""整题判断(主报告):所有带标签的数都来自同一口径的同一版、绑定一致时 equivalent,附还缺的入口字段。"""
|
||
counts = {kind: 0 for kind in KINDS}
|
||
for label in labels.values():
|
||
counts[str(label.get("kind"))] = counts.get(str(label.get("kind")), 0) + 1
|
||
if not labels:
|
||
return {"equivalent": False, "reason": "报告里没有可标口径的数", "counts": counts}
|
||
keys = {label.get("caliber") for label in labels.values() if label.get("kind") == "caliber"}
|
||
if counts["caliber"] != len(labels):
|
||
# 逐格判之后常见「一部分数来自口径、另几个只是相关」:照实说是不一致,不说成这次的推理
|
||
reason = ("有的数没有比对" if counts["unknown"]
|
||
else "有的数是外部常量,不是测量值" if counts["external"]
|
||
else "有的数是规则派生的,不是测量值" if counts["derived"]
|
||
else "有的数是评估值,不是测量值" if counts["assessed"]
|
||
else "没有数来自口径" if not counts["caliber"]
|
||
else "有的数和相关口径不一致,不来自口径" if counts["related"] else "有的数是这次的推理")
|
||
return {"equivalent": False, "reason": reason, "counts": counts}
|
||
if len(keys) != 1:
|
||
return {"equivalent": False, "reason": "数来自不同的口径", "counts": counts}
|
||
binding: dict[str, Any] = {}
|
||
for label in labels.values():
|
||
for name, value in (label.get("binding") or {}).items():
|
||
if name in binding and binding[name] != value:
|
||
return {"equivalent": False, "reason": "各个数的参数不一致", "counts": counts}
|
||
binding.setdefault(name, value)
|
||
key = str(next(iter(keys)))
|
||
info = calibers.get(key) or {}
|
||
fields = [f for f in info.get("fields") or [] if isinstance(f, dict) and f.get("name")]
|
||
first = next(iter(labels.values()))
|
||
return {
|
||
"equivalent": True, "caliber": key, "workflow_id": first.get("workflow_id"), "version": first.get("version"),
|
||
"level": first.get("level"),
|
||
"how": "composed" if any(label.get("how") == "composed" for label in labels.values()) else "independent",
|
||
"binding": binding,
|
||
"missing": [f["name"] for f in fields if f["name"] not in binding],
|
||
"missing_required": [f["name"] for f in fields
|
||
if f["name"] not in binding and f.get("required") and not f.get("has_default")],
|
||
"counts": counts,
|
||
}
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 读的时候遮:绑定的值、差异条目里的 SQL 片段
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def binding_view(binding: Mapping[str, Any] | None, hide: Callable[[Any], bool]) -> list[dict[str, Any]]:
|
||
"""绑定 → [{field, value} 或 {field, masked: true}]:值是这次运行查到过的遮罩值的不给值(也就不能拿它代填出具)。"""
|
||
out = []
|
||
for name, value in (binding or {}).items():
|
||
out.append({"field": name, "masked": True, "value": MASKED} if hide(value) else {"field": name, "value": value})
|
||
return out
|
||
|
||
|
||
def diff_view(diff: Mapping[str, Any] | None, shown: Callable[[Any], Any]) -> dict[str, Any] | None:
|
||
"""差在哪:条目的两边写法、说明和逐行对照里的 SQL 片段按遮罩值换掉。"""
|
||
if not isinstance(diff, Mapping):
|
||
return None
|
||
items = [{**item, "a": shown(item.get("a")), "b": shown(item.get("b")), "text": shown(item.get("text"))}
|
||
for item in diff.get("items") or [] if isinstance(item, Mapping)]
|
||
return {"items": items, "fallback": bool(diff.get("fallback")),
|
||
"text_diff": [shown(line) for line in diff.get("text_diff") or []]}
|
||
|
||
|
||
def view_label(label: Mapping[str, Any], *, hide: Callable[[Any], bool], shown: Callable[[Any], Any],
|
||
names: Mapping[str, str]) -> dict[str, Any]:
|
||
"""一个标签交给界面的样子:绑定按遮罩给、差在哪按遮罩换,口径写上工作流名和级别的叫法(名字按现在的)。"""
|
||
out = {k: v for k, v in label.items() if k not in ("binding", "related")}
|
||
if label.get("kind") == "caliber":
|
||
out["binding"] = binding_view(label.get("binding"), hide)
|
||
out["workflow_name"] = names.get(str(label.get("workflow_id") or ""))
|
||
out["level_text"] = LEVEL_TEXT.get(str(label.get("level") or ""), "")
|
||
if label.get("kind") == "related":
|
||
out["related"] = [related_view(item, hide=hide, shown=shown, names=names)
|
||
for item in label.get("related") or []]
|
||
return out
|
||
|
||
|
||
def related_view(item: Mapping[str, Any], *, hide: Callable[[Any], bool], shown: Callable[[Any], Any],
|
||
names: Mapping[str, str]) -> dict[str, Any]:
|
||
out = {k: item[k] for k in ("caliber", "workflow_id", "version", "level", "node_id", "comparable", "reason", "via")
|
||
if k in item}
|
||
out["workflow_name"] = names.get(str(item.get("workflow_id") or ""))
|
||
out["level_text"] = LEVEL_TEXT.get(str(item.get("level") or ""), "")
|
||
if "binding" in item:
|
||
out["binding"] = binding_view(item.get("binding"), hide)
|
||
if "diff" in item:
|
||
out["diff"] = diff_view(item.get("diff"), shown)
|
||
return out
|
||
|
||
|
||
__all__ = ["CERTIFIED_NOTE", "HOWS", "KINDS", "LEVEL_TEXT", "POST_SEAL_NOTE", "RETRO_NOTE", "binding_view", "caliber_key",
|
||
"candidates_of", "card_expressions", "card_reads", "cell_label", "cells_of", "diff_view", "label_doc",
|
||
"metric_label",
|
||
"referenced_columns", "related_view", "view_label", "whole_of"]
|