diff --git a/backend/app/api/copilot_context.py b/backend/app/api/copilot_context.py index 71f90fab..56351f90 100644 --- a/backend/app/api/copilot_context.py +++ b/backend/app/api/copilot_context.py @@ -71,13 +71,17 @@ LOOK_WHEN_ANSWER_PROMPT = LOOK_WHEN_PROMPT.replace( 口径,交给 agent 去查,系统会自动拿它的 SQL 和口径对账。查库交给 agent 时它运行时会自己查表结构:只有从概览看不出 该用哪个库、用户点了表名时才看表,把看到的表名写进 agent 的提示词""") -#: 问数据里输出协议多的一段:选用口径(ledger/compose.py)。只在这一轮能看已发布口径时接在协议后面 +#: 问数据里输出协议多的一段:选用口径(ledger/compose.py)。只在这一轮能看已发布口径时接在协议后面。 +#: 两个 id 写清楚(真实验收里模型第一次常把 card 写成自己起的 c1,被拒之后才改对):card 是那一版工作流图里口径卡的节点 id, +#: 照抄查看结果;id 是这次选用自己起的名字,生成的口径卡用它当节点 id USE_CALIBER_PROMPT = """- 选用已发布口径(只在问数据里、look 过 caliber 之后): - {"op":"use_caliber","id":"c1","workflow_id":"…","version":3,"card":"口径卡节点 id","metrics":["指标id"],"inputs":{"字段":"值"}} - 照抄查看结果里给的写法,只改 id、metrics、inputs。系统按那一版生成取数节点(c1_<原 id>)和口径卡(节点 id 就是 c1), - 并把入口连到取数、取数连到口径卡;你**不要**自己写这几个节点,也不要改它们。接下来只需把口径卡连到报告 - (add_edge c1 → report),报告里用 [[m:指标id]] 引用。inputs 按口径的入口字段填(日期、门店这类问题里给出的条件), - 填不对会被拒收、原因会交回给你改;口径对不上问法时不要选用""" + {"op":"use_caliber","id":"c1","workflow_id":"…","version":3,"card":"card","metrics":["指标id"],"inputs":{"字段":"值"}} + 照抄查看结果里给的那一行写法:workflow_id、version、card 原样照抄,只改 id、metrics、inputs。两个 id 别弄混: + · card 填那一版工作流图里口径卡的节点 id(查看结果的写法里已经写好,例如 "card"),指明选哪一张卡,不是你起的名字; + · id 是你给这次选用起的名字(简短英文小写,例如 c1),系统生成的口径卡就用它当节点 id,取数节点是 c1_<原 id>。 + 系统把入口连到取数、取数连到口径卡;你**不要**自己写这几个节点,也不要改它们。接下来只需把口径卡连到报告 + (add_edge c1 → report,这里的 c1 是你起的 id),报告里用 [[m:指标id]] 引用。inputs 按口径的入口字段填(日期、门店这类 + 问题里给出的条件),填不对会被拒收、原因会交回给你改;口径对不上问法时不要选用""" #: 系统代为查看时写进记录的需求(界面上显示) SYSTEM_LOOK_FIX_NEED = "要修的工作流里,查库节点的 SQL 用到的表" diff --git a/backend/app/api/evidence.py b/backend/app/api/evidence.py index 2b6a4a2e..552e7a18 100644 --- a/backend/app/api/evidence.py +++ b/backend/app/api/evidence.py @@ -789,8 +789,7 @@ async def evidence_segment(run_id: str, segment_id: str, report: str | None = No chain = await _chain(seg, doc, sealed, chosen.node_id) covered = sealed.trusted and all(step.get("sealed") for step in chain if "sealed" in step) # 遮罩列:查询步骤里遮着的,加上指标步骤里取自遮罩列的输入(按位置取的那一格可能没有对应的查询步骤) - masked = list(dict.fromkeys(c for step in chain if step.get("step") in ("query", "input") - for c in step.get("masked") or [])) + masked = _chain_masked(chain) verdict = _effective_verdicts(chosen, sealed)[0].get(str(unit.get("id"))) challenged = _challenge_of(sealed, chosen, str(unit.get("id"))) # 口径账本(next-round 02 做法 3):这个数来自哪一份口径。比对在封存之后做、不在封存范围内,标签里写明;没比对过的运行 @@ -823,6 +822,14 @@ async def evidence_segment(run_id: str, segment_id: str, report: str | None = No METRIC_NOT_FOUND = "evidence_metric_not_found" +def _chain_masked(chain: list[dict[str, Any]]) -> list[str]: + """出处链里遮着的列:查询步骤里遮着的,指标步骤里取自遮罩列的输入(按位置取的那一格可能没有对应的查询步骤),转抄的 + 上游指标那几步的输入(跨卡往下追的,见 _upstream_steps)。""" + steps = [*chain, *(i for step in chain if step.get("step") == "upstream" for i in step.get("inputs") or [])] + return list(dict.fromkeys(c for step in steps if step.get("step") in ("query", "input") + for c in step.get("masked") or [])) + + @router.get("/{run_id}/evidence/metrics/{node}/{metric}") async def evidence_metric(run_id: str, node: str, metric: str) -> dict[str, Any]: """点开应用页上的一个指标(应用平台 §3.6):指标 → 输入 → 查询快照的出处链,和报告片段的指标链同一套(_metric_chain)。 @@ -847,8 +854,7 @@ async def evidence_metric(run_id: str, node: str, metric: str) -> dict[str, Any] chain = await _metric_chain({"text": shown}, {"kind": "metric", "locator": {"metric": metric}, "eid": eid}, entry, {"catalog": {}}, sealed, masks) covered = sealed.trusted and all(step.get("sealed") for step in chain if "sealed" in step) - masked = list(dict.fromkeys(c for step in chain if step.get("step") in ("query", "input") - for c in step.get("masked") or [])) + masked = _chain_masked(chain) return {"run_id": run_id, "node": node, "metric": metric, "rendered": shown, "eid": eid, "chain": chain, "seal": {"sealed": sealed.sealed, "ok": sealed.seal["ok"], "covered": covered}, "redacted": {"columns": masked, "note": MASK_NOTE} if masked else {"columns": []}} @@ -992,6 +998,8 @@ async def _metric_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict[s if (found := held.get(str(item.get("path")))) is not None: one = _withheld(one, found) inputs.append(one) + # 转抄另一张口径卡的指标(对账卡、召回卡的「口径」那一项):跨卡往下追,接上那个指标自己的一段出处,一直到查询格子 + upstream = await _upstream_steps(metric or {}, sealed, masks, catalog, wanted, {(artifact, str(metric_id))}) if withheld := {path: _held_word(found) for path, found in held.items() if found.kind != "table"}: # 代入式里那几处同样写「已遮罩」(遮没遮核对不了的写「无法核对」):不然标签里遮着、代入式里亮着,同一个值换个 # 地方照样看得到。整份结果当输入的不换:代入式里本来就只留着它的路径 @@ -1002,7 +1010,77 @@ async def _metric_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict[s # 目录、正文里本来就是「已遮罩」。文档记着不给、这里却认不出的(数据源事后撤了遮罩),照文档不给 step["value"] = word or MASKED queries = [s for a, cells in wanted.items() for s in await _query_steps(a, cells, doc, sealed, masks)] - return [step, *inputs, *queries] + return [step, *inputs, *upstream, *queries] + + +async def _upstream_steps(metric: dict[str, Any], sealed: _Sealed, masks: "_Masks", catalog: dict[str, Any], + wanted: dict[str, list[tuple[Any, Any]]], seen: set[tuple[str, str]], + depth: int = 1) -> list[dict[str, Any]]: + """指标的输入里转抄另一张口径卡的指标的(nodes.<卡>.metrics[i].value,卡上记着 upstream),每个接一步 upstream:那张 + 卡的那个指标(名字、值、式子、代入式、核对状态,取值按同一套遮罩),它自己的输入(同指标步骤的输入;落在查询快照上的 + 并进 wanted,后面照常给查询步骤),追没追到原始数据。转抄的转抄一层层接着追(防环:同一个指标只接一次;最多 + MAX_COPY_DEPTH 层)。of 是这一步接在哪个输入后面(输入的路径)。""" + from app.engine.lineage import MAX_COPY_DEPTH, SNAPSHOT_VIAS + + out: list[dict[str, Any]] = [] + if depth > MAX_COPY_DEPTH: + return out + for item in metric.get("inputs") or []: + up = item.get("upstream") if isinstance(item, dict) and item.get("via") == "metric" else None + if not isinstance(up, dict) or not up.get("artifact"): + continue + artifact, metric_id = str(up["artifact"]), str(up.get("metric") or "") + if (artifact, metric_id) in seen: + continue + seen.add((artifact, metric_id)) + node = str(item.get("node_id") or "") + card, hash_ok = _load_card(artifact) + found = _metric_in(card, metric_id) + step: dict[str, Any] = { + "step": "upstream", "of": item.get("path"), "node_id": node or None, + "label": _node_label(sealed, node) if node else None, "artifact": artifact, "metric": metric_id, + "traced": up.get("traced") is True, "depth": depth, "hash_ok": hash_ok and found is not None, + "sealed": sealed.trusted and (node, artifact) in sealed.ledger, + } + if found is None: + out.append(step) + continue + held, word = await _metric_masks(found, node, artifact, sealed, masks, card=card) + try: + rendered = word or render_metric(found) + except RenderError: + rendered = MISSING + step.update({ + "name": found.get("name") or metric_id, "value": word if word is not None else found.get("value"), + "unit": found.get("unit") or "", "rendered": rendered, "caliber": (card or {}).get("caliber"), + "version": (card or {}).get("caliber_version"), "source": await _caliber_source((card or {}).get("source")), + "expression": found.get("expression"), "substituted": found.get("substituted"), + "recompute_ok": found.get("recompute_ok"), "status": found.get("status"), + **({"incomplete": True, "incomplete_reason": found.get("incomplete_reason") or ""} + if found.get("incomplete") else {}), + **({"sql_check_failed": True, "sql_check_reason": found.get("sql_check_reason") or ""} + if found.get("sql_check_failed") else {}), + "untraced": _untraced_steps(found, sealed), + }) + if withheld := {path: _held_word(h) for path, h in held.items() if h.kind != "table"}: + step["substituted"] = recompute.substituted_without(found, withheld) + inputs: list[dict[str, Any]] = [] + for sub in found.get("inputs") or []: + if not isinstance(sub, dict): + continue + one = {"step": "input", **sub} + if sub.get("artifact") and sub.get("via") in SNAPSHOT_VIAS: + one["query"] = _query_alias(catalog, sub["artifact"]) + if cell := _input_cell(sub, catalog): + one["cell"] = cell + wanted.setdefault(str(sub["artifact"]), []).extend(_located(sub.get("locator") or {})) + if (h := held.get(str(sub.get("path")))) is not None: + one = _withheld(one, h) + inputs.append(one) + step["inputs"] = inputs + out.append(step) + out += await _upstream_steps(found, sealed, masks, catalog, wanted, seen, depth + 1) + return out def _node_label(sealed: _Sealed, node_id: Any) -> str: diff --git a/backend/app/api/ledger.py b/backend/app/api/ledger.py index cdad6346..2fe472ec 100644 --- a/backend/app/api/ledger.py +++ b/backend/app/api/ledger.py @@ -249,7 +249,10 @@ async def certify_dismiss(payload: DismissIn, session: AsyncSession = Depends(ge """「不是口径」:记一条审计(ledger.certify.dismiss),这一项之后不再进队。""" from app.ledger import certify + from app.api.deps import current_principal + if payload.kind is not None and payload.kind not in certify.KINDS: raise HTTPException(422, "可沉淀的问题只有三种:确定性重复、问题聚类、被反复用的答案") - await certify.dismiss(session, payload.item_id, kind=payload.kind, note=payload.note) + await certify.dismiss(session, payload.item_id, kind=payload.kind, note=payload.note, + principal=current_principal()) return {"ok": True, "item_id": payload.item_id} diff --git a/backend/app/chat/questions.py b/backend/app/chat/questions.py index 0c99e042..99d61240 100644 --- a/backend/app/chat/questions.py +++ b/backend/app/chat/questions.py @@ -18,6 +18,12 @@ 聚类用 memory/embeddings 的向量贪心聚:按时间从新到旧,和某一类的种子足够像就并进去,否则自立一类。本地向量是词频 哈希、语义能力弱,结果是下限——调用方据 embedder 照实写明用的是哪个向量模型。 +**类的 id 由成员定。** 每个问题有稳定的 id(Question.key:问数据是那一轮,别的探索运行是那次运行),一类的 id +(QuestionCluster.id)是成员的 id 排好之后取的哈希:同一批问题两次读取,类的 id 一样。以前按种子问题的原文算,种子是一类里 +最新的那一句,同一批问题时间相同的几句先后不定、来了一句新的问法种子就换了,「不是口径」按 id 记就失效了。同一时刻问的 +几句按 id 排出先后(recent_questions),不靠库里读出来的先后。来了新成员 id 照样会变:认证队列按成员记「不是口径」 +(ledger/certify),不只按 id。 + **向量只取一次。** 同一批问题会被聚很多遍:评测页「口径命中与重复度」一次打开,/api/evals/signals 按窗口调 compute_stats (缺省 6 个窗口,每个都聚一次),页面另调 /api/ledger/stats;认证队列、治理页的探索问题聚类也各聚一遍。配了远程向量模型时 每聚一遍都把几百句问题重新发一次,页面慢、还花钱。所以问题原文 → 向量在进程里留一份(_vectors:最多 VECTOR_CACHE_MAX 句, @@ -28,6 +34,7 @@ from __future__ import annotations import asyncio +import hashlib import weakref from collections import OrderedDict from collections.abc import Sequence @@ -55,6 +62,16 @@ class Question: workflow: str | None = None origin: str | None = None + @property + def key(self) -> str: + """稳定的 id:问数据的那一轮(turn:…),别的探索运行是那次运行(run:…);两样都没有的按原文和时间。""" + if self.turn_id: + return f"turn:{self.turn_id}" + if self.run_id: + return f"run:{self.run_id}" + stamp = self.at.isoformat() if self.at is not None else "" + return "text:" + hashlib.sha256(f"{self.text}\n{stamp}".encode()).hexdigest()[:16] + @dataclass class QuestionCluster: @@ -66,6 +83,16 @@ class QuestionCluster: def count(self) -> int: return len(self.members) + @property + def keys(self) -> list[str]: + """成员的稳定 id,排好的。""" + return sorted(q.key for q in self.members) + + @property + def id(self) -> str: + """类的 id:成员的稳定 id 排好之后取哈希(见模块说明)。同一批问题两次读取一样。""" + return hashlib.sha256("\n".join(self.keys).encode()).hexdigest()[:16] + async def recent_questions(session: AsyncSession, principal: Any, *, limit: int) -> list[Question]: """最近问过的问题,从新到旧,最多 limit 条(取法见模块开头)。principal 用来去掉这个人读不了的只给拥有者的运行。""" @@ -94,7 +121,8 @@ async def recent_questions(session: AsyncSession, principal: Any, *, limit: int) text = (payload or {}).get("question") if isinstance(payload, dict) else None if isinstance(text, str) and text.strip(): out.append(Question(text=text.strip(), at=at, run_id=run_id, workflow=workflow, origin=origin)) - out.sort(key=lambda q: q.at.timestamp() if q.at is not None else 0.0, reverse=True) + # 同一时刻问的几句按稳定的 id 排:库里读出来的先后不定,种子、聚类的结果就跟着变(见模块说明) + out.sort(key=lambda q: (q.at.timestamp() if q.at is not None else 0.0, q.key), reverse=True) return out[:limit] diff --git a/backend/app/data/sql_semantics.py b/backend/app/data/sql_semantics.py index ffb7ae58..ecf808e2 100644 --- a/backend/app/data/sql_semantics.py +++ b/backend/app/data/sql_semantics.py @@ -14,6 +14,10 @@ A / B,只差一个方面、又对得上数据目录里的事实(时间归属 (规范形式一字不差)才返回空列表。 - facets:把一条查询拆成表、连接、WHERE 合取式、时间条件、分组、聚合、算出来的列、HAVING、取哪几行(给调用方取 结构用;CTE、子查询按位置编号,见 _Statement)。facets 不受下面等价判定的改动影响。 +- bind_template / match_template:口径模板的参数绑定与等价证明(口径账本,见那一节)。 +- project_select / project_template:按引用的列投影——去掉最外层选择列表里没被引用、去掉也不改变被引用那几列取值的 + 选择项,认不准的一律不投影(见那一节)。投影本身不判等价,投影后的 SQL 照样交给上面的判等价。 +- sql_shape:同一个形状(认证队列的确定性重复):条件的先后、交换律成立处两边的先后、字面量的值都不算(见那一节)。 **等价怎么判。** 过去几轮按方面归纳任意 SQL,每轮修好一批又冒出一批(还回退过),收不拢;现在先判等价、再说差在哪。 两边各自规范化,规范化的每一步都只做保持语义的变换,同一级的两个规范形式一字不差(比语法树逐个节点的类型和参数, @@ -7037,6 +7041,8 @@ class _Explainer: pb, jb = self._shape(B) if pa == pb and ja == jb: return True, {}, set(), set() + if (said := self._left_on_times(A, B, pa, ja, pb, jb)) is not None: + return said if not ja and not jb: only_a = [r for r in A.rels if r.alias not in pb] only_b = [r for r in B.rels if r.alias not in pa] @@ -7066,6 +7072,80 @@ class _Explainer: x = flipped[0] return self._left_inner(A, B, x, 0) if kinds_a[x] == "left" else self._left_inner(B, A, x, 1) + def _left_on_times(self, A: _Block, B: _Block, pa: Any, ja: Any, pb: Any, jb: Any + ) -> tuple[bool, dict[str, str], set[str], set[str]] | None: + """来源两边一样,只差在一个 LEFT JOIN 的 ON 里的时间条件(日期条件写在 LEFT JOIN 的 ON 里):说「A 用 LEFT JOIN + 关联 X 表时只关联 … 的行,B 没有这个条件」「A 用 LEFT JOIN 关联 X 表时对 paid_at 的条件是 …,B 是 …」「A … 时按 + paid_at 筛时间,B 按 created_at」。说不准的返回 None(照旧整条兜底)。 + + 说得准要(每一条都是为了不说出不存在的不同):开头那组来源一样,后面的连接只有这一个的 ON 不同(别名、表、LEFT 都 + 一样);两边 ON 里没配上的条件每一个都是时间条件(timed)、只涉及这张可空一侧的表的列;这张表在 ON 之外只出现在 + 结果里(SELECT 列表、分组、排序),没在 WHERE、HAVING、别的连接里用到(用到了就是反连接、或者关联条件的事); + SELECT 列表里用到了它的列(不然 ON 里的不同只影响行会不会重复,说不准);结果看得见行;这张表在这一段只出现一次。 + 两边都有、同一列上的,按取值逐段算过留下的取值确实不同(_Range.compare,同 _rewrites);只有一边有的,不是恒为真的 + (_always)。aspect 是 join:说的是关联条件,dual_check_facts 不会把它当成业务日期写进数据目录。 + + 只改说明,不碰等价判定:走到这里时两边已经判了不等价(见 explain_sql_diff)。""" + from sqlglot import exp + + if pa != pb or len(ja) != len(jb) or not self.visible: + return None + differing = [i for i, (x, y) in enumerate(zip(ja, jb)) if x != y] + if len(differing) != 1: + return None + i = differing[0] + if ja[i][:3] != jb[i][:3] or ja[i][2] != "left": + return None + alias = ja[i][0] + join_a, join_b = A.joins[i], B.joins[i] + rel = A.rel(alias) + if rel is None or not rel.table or A.names().count(rel.name) != 1 or B.names().count(rel.name) != 1: + return None + on_a, on_b = _conjuncts(join_a.args["on"]), _conjuncts(join_b.args["on"]) + only_a, only_b = self._unmatched(on_a, on_b, {}) + keys: set[tuple[str, str]] = set() + for block, conds in ((A, only_a), (B, only_b)): + for cond in conds: + cols = self.base_columns(cond, block) + found = self.timed(cond, block) + if found is None or not cols or any(r.alias != alias for r, _ in cols): + return None + keys.add(found) + for block, join in ((A, join_a), (B, join_b)): + if self._used_outside(block, alias, join, outputs=False): + return None + if not any(col.table == alias for e in block.exprs for col in e.find_all(exp.Column)): + return None + what = self.what(rel) + if only_a and only_b: + if len(keys) == 1: + key = next(iter(keys)) + same_a = [c for c in on_a if self.refs(c, A) == {key}] + same_b = [c for c in on_b if self.refs(c, B) == {key}] + if self.range.compare(same_a, same_b, never_null=False) is not False: + return None + shown_a, shown_b = self.show_all(_by_bound(same_a), A), self.show_all(_by_bound(same_b), B) + text = _say(self.la, "用 LEFT JOIN 关联", what, "时对", self.named(*key), "的条件是", shown_a, ",", + self.lb, "是", shown_b) + else: + cols_a = {self.timed(c, A) for c in only_a} + cols_b = {self.timed(c, B) for c in only_b} + if len(cols_a) != 1 or len(cols_b) != 1: + return None + shown_a, shown_b = self.show_all(only_a, A), self.show_all(only_b, B) + text = _say(self.la, "用 LEFT JOIN 关联", what, "时按", self.named(*next(iter(cols_a))), "筛时间,", + self.lb, "按", self.named(*next(iter(cols_b)))) + self.add("join", shown_a, shown_b, text) + return True, {}, set(), set() + side, block, conds = (0, A, only_a) if only_a else (1, B, only_b) + if self._always(conds, block) is not False: + return None + shown = self.show_all(_by_bound(conds), block) + me, them = (self.la, self.lb) if side == 0 else (self.lb, self.la) + self.add("join", shown if side == 0 else None, shown if side == 1 else None, + _say(me, "用 LEFT JOIN 关联", what, "时只关联", shown, "的行,", them, "没有这个条件")) + return True, {}, set(), set() + def _swapped(self, A: _Block, B: _Block, x: _Rel, y: _Rel) -> tuple[bool, dict[str, str], set[str], set[str]] | None: """换了一张表:开头那组来源只差一张实表(各自只出现一次、名字不只差在大小写上,都没带库名)。换了表的那一边, 列照它自己的表说。""" @@ -8727,7 +8807,527 @@ def _column_map(sql: str, rendered: str, read: str | None, info: _Schema, return tuple({"index": i, "ours": a, "caliber": b} for i, (a, b) in enumerate(zip(ours, theirs))) +# ========================================================================== +# 按引用的列投影(next-round 口径账本:整题等价按答案引用的那几列比) +# ========================================================================== +# +# 真实的助手写 SQL 总会多选几列(订单数、日期常量、MIN / MAX、COALESCE……),报告只引用其中一列;口径的取数也只选它的 +# 指标要的那几列。拿整条 SQL 去比永远不等价,「上周的销售额是多少」就永远只标相关口径。这里把最外层 SELECT 列表里没被 +# 引用、去掉也不改变被引用那几列取值的选择项去掉(需要时也调换留下的几项的先后),得到一条改写后的 SQL,交给调用方再走 +# 现有的绑定和判等价(match_template → explain_sql_diff):投影本身不判等价、不给信任,判等价的那一步一点不放宽。 +# +# 只在确定去掉不影响时才投影,不然照实给不投影的原因(Projection.refused),调用方照旧整条比: +# - 整条语句就是一段 SELECT(可以带 WITH):UNION 这类合并、外面套着括号的不投影——各支的列按位置合并,去掉一支的一列 +# 就错位了; +# - SELECT 里只有 WITH、选择列表、FROM、JOIN、WHERE、GROUP BY、HAVING、QUALIFY、WINDOW、ORDER BY、LIMIT、OFFSET; +# DISTINCT / DISTINCT ON(按整行去重,去掉一列行数就可能变)和别的修饰(提示、INTO、加锁、采样、LATERAL VIEW……) +# 不投影; +# - 选择列表里没有 * / t.*(展开之后按位置对不准);给了查询结果的列数(width)时要和选择列表的项数一样:被引用的列按 +# 位置对上选择项; +# - ORDER BY / GROUP BY 里没有按位置引用的(ORDER BY 2:去掉、调换之后指到别的列); +# - 有 LIMIT / OFFSET 时只投影不分组的聚合(只出一行,取哪一行和选出来的列无关):别的「取前几行」有并列时取到哪几行 +# 可能跟着选择列表变; +# - 去掉的每一项:不含认不准会不会改变行数的函数(UNNEST / EXPLODE / generate_series 这类展开成多行的、sqlglot 不认识的 +# 函数);它的输出名(别名,没有别名的是列名)在整条语句别处(同一段的 WHERE、GROUP BY、HAVING、QUALIFY、ORDER BY、 +# 窗口、别的选择项、子查询)一次都没有被不带表名地写到——SQLite 的 WHERE、GROUP BY 都认列别名,和表列同名的也算, +# 宁可不投影;不是常量的话,同样的写法没有出现在 GROUP BY、HAVING、ORDER BY、QUALIFY、窗口里(「出现在 GROUP BY 里」 +# 「被 HAVING 用到」都在这一条); +# - 去掉之后这一段聚不聚合不变:没有 GROUP BY、HAVING 时去掉的不能是仅有的聚合(剩下的列就不止一行了);留下的列里没有 +# 既不在聚合里、也不是分组键的列(SQLite 取 MAX 那一行的值、MySQL 取任意一行:去掉别的聚合可能换了取值的那一行); +# - 调换先后时,留下的各项不按名字写到别的选择项的输出名(DuckDB 这类认「前面的列别名」)。 +# +# 改写在原文上做:按方言切词、按括号深度认出最外层 SELECT 列表每一项在原文里的起止,只把留下的几项按新的先后原样拼回去, +# 选择列表前后的原文一字不动(explain_sql_diff 的保真守卫照样看得到原文里的写法)。拼完重新解析,语法树要和「原来的树换成 +# 留下的几项」一模一样,不然不投影。口径模板({{ input.x }})按 _template_slots 的哨兵渲染之后认起止,再按位置换回模板 +# 的原文;MySQL 原文里有被当成两个减号的 --(_mysql_dashes 会改写的)不投影:改写后位置对不上原文。 + +#: 投影的那一段能有的子句 +_PROJECTABLE_ARGS = frozenset({"with_", "expressions", "from_", "joins", "where", "group", "having", "qualify", + "windows", "order", "limit", "offset"}) +#: 去掉的选择项里不许有的、可能把一行展开成多行的函数(sqlglot 的类名;表函数的基类 UDTF、不认识的函数另认) +_ROW_CHANGING = frozenset({"Explode", "Posexplode", "ExplodeOuter", "PosexplodeOuter", "Unnest", "Inline", "Stack", + "ExplodingGenerateSeries", "GenerateSeries"}) +#: 最外层 SELECT 列表到这些词就结束了 +_LIST_ENDS = _CLAUSE_ENDS | {"FROM", "INTO"} +_OPENS = frozenset({"L_PAREN", "L_BRACKET", "L_BRACE"}) +_CLOSES = frozenset({"R_PAREN", "R_BRACKET", "R_BRACE"}) + +#: 不投影的原因(只进比对的说明和测试,不判等价) +_PROJ_UNPARSED = "SQL 无法解析,不投影" +_PROJ_SETOP = "不是一段 SELECT(UNION 这类合并、外面套着括号),不投影" +_PROJ_DISTINCT = "有 DISTINCT,去掉一列行数就可能变,不投影" +_PROJ_CLAUSE = "SELECT 里有投影认不准的子句({clause}),不投影" +_PROJ_STAR = "选择列表里有 *,按位置对不准,不投影" +_PROJ_WIDTH = "查询结果的列数({width})和选择列表的项数({items})对不上,不投影" +_PROJ_KEEP = "要留下的列不对(空的、重复的、超出选择列表),不投影" +_PROJ_ORDINAL = "ORDER BY / GROUP BY 按位置引用了选择项,去掉、调换之后会指到别的列,不投影" +_PROJ_LIMIT = "有 LIMIT / OFFSET,取哪几行可能跟着选择列表变,不投影" +_PROJ_ROWS = "去掉的「{item}」里有可能改变行数的函数,不投影" +_PROJ_NAMED = "去掉的「{item}」在别处被按名字写到了,不投影" +_PROJ_USED = "去掉的「{item}」同样的写法出现在 GROUP BY、HAVING、ORDER BY、QUALIFY 或窗口里,不投影" +_PROJ_AGGREGATE = "去掉的是仅有的聚合,剩下的列就不止一行了,不投影" +_PROJ_LOOSE = "留下的列里有既不在聚合里、也不是分组键的列,去掉别的列可能换了取值的那一行,不投影" +_PROJ_REORDER = "留下的「{item}」按名字写到了别的选择项,不调换先后" +_PROJ_TEXT = "认不出选择列表在原文里的起止,不投影" +_PROJ_DASHES = "原文里有 MySQL 当成两个减号的 --,不投影" + + +@dataclass(frozen=True) +class Projection: + """project_select / project_template 的结果。 + + sql 是投影后的 SQL(模板投影之后仍是模板,{{ input.x }} 原样留着);kept 是留下的选择项在原来的选择列表里的位置 + (按投影后的先后);dropped 是去掉了几项;names 是留下的各项的输出名(别名、列名,别的写成它的 SQL,同 + _result_columns)。refused 不为空时不投影:sql 为 None,写为什么。要留下的就是全部、先后也没变时原样交回(dropped + 为 0)。""" + + sql: str | None + kept: tuple[int, ...] = () + dropped: int = 0 + names: tuple[str, ...] = () + refused: str | None = None + + +class _NotProjectable(Exception): + """不投影:args[0] 是原因。""" + + +def _output_name(item: Any, read: str | None) -> str: + from sqlglot import exp + + name = item.alias_or_name if isinstance(item, (exp.Alias, exp.Column)) else "" + return name or item.sql(dialect=read) + + +def _name_of(item: Any) -> str: + """选择项按名字被引用时的那个名字(别名、列名;小写)。表达式、常量没有。""" + from sqlglot import exp + + return (item.alias_or_name if isinstance(item, (exp.Alias, exp.Column)) else "").translate(_ASCII_LOWER) + + +def _ordinal(node: Any) -> bool: + """ORDER BY / GROUP BY 的一项是按位置引用(一个整数常量)。""" + from sqlglot import exp + + inner = _unparen(node.this if isinstance(node, exp.Ordered) else node) + return isinstance(inner, exp.Literal) and not inner.is_string + + +def _projection_problem(root: Any, keep: Sequence[int], read: str | None) -> str | None: + """按 keep 投影这一段会不会改变留下的那几列的取值(见本节开头):会、或者认不准时交回原因,确定不会时为 None。""" + from sqlglot import exp + + if not isinstance(root, exp.Select): + return _PROJ_SETOP + if root.args.get("distinct") is not None: + return _PROJ_DISTINCT + extra = sorted(k for k, v in root.args.items() if v not in (None, False, []) and k not in _PROJECTABLE_ARGS) + if extra: + return _PROJ_CLAUSE.format(clause=extra[0]) + items = list(root.expressions) + if any(_is_star(item) for item in items): + return _PROJ_STAR + if not keep or len(set(keep)) != len(keep) or any(not isinstance(k, int) or not 0 <= k < len(items) for k in keep): + return _PROJ_KEEP + group, order = root.args.get("group"), root.args.get("order") + for clause in (group, order): + if clause is not None and any(_ordinal(e) for e in clause.expressions): + return _PROJ_ORDINAL + if (root.args.get("limit") is not None or root.args.get("offset") is not None) and \ + not (group is None and _aggregating(root)): + return _PROJ_LIMIT + kept = set(keep) + removed = [i for i in range(len(items)) if i not in kept] + holders = [c for c in (group, root.args.get("having"), order, root.args.get("qualify")) if c is not None] + holders += [w for w in root.args.get("windows") or [] if isinstance(w, exp.Expr)] + holders += [w for k in keep for w in items[k].find_all(exp.Window)] + used = {_fingerprint(n) for holder in holders for n in holder.walk()} + for i in removed: + item = items[i] + shown = item.sql(dialect=read, comments=False)[:60] + if any(isinstance(n, (exp.UDTF, exp.Anonymous)) or type(n).__name__ in _ROW_CHANGING for n in item.walk()): + return _PROJ_ROWS.format(item=shown) + name = _name_of(item) + if name: + inside = {id(n) for n in item.walk()} + if any(isinstance(n, exp.Column) and not n.table and id(n) not in inside + and n.name.translate(_ASCII_LOWER) == name for n in root.walk()): + return _PROJ_NAMED.format(item=shown) + body = item.unalias() + if not _constant(body) and _fingerprint(body) in used: + return _PROJ_USED.format(item=shown) + projected = root.copy() + projected.set("expressions", [items[k].copy() for k in keep]) + if group is None and root.args.get("having") is None and _aggregating(root) != _aggregating(projected): + return _PROJ_AGGREGATE + if _loose_columns(projected): + return _PROJ_LOOSE + if list(keep) != sorted(keep): + names = {_name_of(item) for item in items} - {""} + for k in keep: + others = names - {_name_of(items[k])} + if any(isinstance(n, exp.Column) and not n.table and n.name.translate(_ASCII_LOWER) in others + for n in items[k].walk()): + return _PROJ_REORDER.format(item=items[k].sql(dialect=read, comments=False)[:60]) + return None + + +def _list_spans(text: str, read: str | None) -> list[tuple[int, int]] | None: + """最外层 SELECT 列表每一项在原文里的起止(含两头):按方言切词、按括号深度,深度 0 的 SELECT 之后、深度 0 的子句 + 开头的词(FROM、WHERE……)之前,深度 0 的逗号分开。深度 0 的 SELECT 不止一个、括号对不上、有空项的为 None。""" + try: + tokens = _tokens(text, read) + except Exception: # noqa: BLE001 - 切词都不行:认不出 + return None + depth = 0 + state = 0 # 0:还没到最外层的 SELECT;1:在选择列表里;2:选择列表之后 + spans: list[tuple[int, int]] = [] + start: int | None = None + end = 0 + for token in tokens: + kind = token.token_type.name + if state == 1 and depth == 0 and (kind == "COMMA" or kind in _LIST_ENDS): + if start is None: + return None + spans.append((start, end)) + start = None + if kind != "COMMA": + state = 2 + continue + if kind in _OPENS: + depth += 1 + elif kind in _CLOSES: + depth -= 1 + if depth < 0: + return None + elif depth == 0 and kind == "SELECT": + if state != 0: + return None + state = 1 + continue + if state == 1: + start = token.start if start is None else start + end = token.end + if state == 1: + if start is None: + return None + spans.append((start, end)) + return spans or None + + +def _cut(text: str, spans: Sequence[tuple[int, int]], keep: Sequence[int]) -> str: + """把选择列表换成留下的几项(按 keep 的先后,原文照抄、逗号隔开):选择列表前后的原文一字不动。""" + head, tail = text[:spans[0][0]], text[spans[-1][1] + 1:] + return head + ", ".join(text[spans[k][0]:spans[k][1] + 1] for k in keep) + tail + + +def _plan_projection(text: str, keep: Sequence[int], read: str | None, width: int | None + ) -> tuple[Any, list[tuple[int, int]] | None, Any]: + """(原文的语法树, 选择列表各项的起止, 投影后应有的语法树)。原样(keep 就是全部、先后不变)时后两样为 None。不投影抛 + _NotProjectable。""" + if read == "mysql" and _mysql_dashes(text) != text: + raise _NotProjectable(_PROJ_DASHES) + root, _ = _parse(text, read) + if root is None: + raise _NotProjectable(_PROJ_UNPARSED) + from sqlglot import exp + + items = list(root.expressions) if isinstance(root, exp.Select) else [] + if width is not None and isinstance(root, exp.Select) and len(items) != width: + raise _NotProjectable(_PROJ_WIDTH.format(width=width, items=len(items))) + if isinstance(root, exp.Select) and list(keep) == list(range(len(items))): + return root, None, None + why = _projection_problem(root, keep, read) + if why: + raise _NotProjectable(why) + spans = _list_spans(text, read) + if spans is None or len(spans) != len(items): + raise _NotProjectable(_PROJ_TEXT) + expected = root.copy() + expected.set("expressions", [items[k].copy() for k in keep]) + return root, spans, expected + + +def _same_tree(text: str, read: str | None, expected: Any) -> bool: + root, _ = _parse(text, read) + return root is not None and _fingerprint(root) == _fingerprint(expected) + + +def project_select(sql: str, keep: Sequence[int], dialect: str | None = None, *, + width: int | None = None) -> Projection: + """按引用的列投影一条查询(见本节开头):最外层选择列表只留 keep 那几项(位置从 0 数,按 keep 的先后)。从不抛异常。 + + width 是查询结果的列数(查询快照里记的):给了就要和选择列表的项数一样,被引用的列才按位置对得上选择项。不投影时 + refused 写为什么,调用方照旧整条比。投影出来的 SQL 不判等价:交给 match_template / explain_sql_diff 判。""" + read = _read(dialect) + text = (sql or "").strip() + keep = tuple(keep) + try: + root, spans, expected = _plan_projection(text, keep, read, width) + items = list(root.expressions) + names = tuple(_output_name(items[k], read) for k in keep) + if spans is None: + return Projection(text, keep, 0, names) + out = _cut(text, spans, keep) + if not _same_tree(out, read, expected): + raise _NotProjectable(_PROJ_TEXT) + return Projection(out, keep, len(items) - len(keep), names) + except _NotProjectable as e: + return Projection(None, keep, refused=str(e)) + except Exception: # noqa: BLE001 - 投影本身的缺陷不能连累调用方:当作不投影,照旧整条比 + logger.warning("SQL 投影失败", exc_info=True) + return Projection(None, keep, refused=_PROJ_TEXT) + + +def _template_marks(template: str, spans: Sequence[tuple[int, int, TemplateSlot]]) -> list[tuple[int, int, int, int]]: + """模板里每一处槽位在哨兵渲染的 SQL 里、在模板原文里的起止:[(哨兵起, 哨兵止, 模板起, 模板止)](止不含)。槽位之外 + 两边逐字相同(_template_slots 只换槽位)。""" + out: list[tuple[int, int, int, int]] = [] + shift = 0 + for start, end, slot in spans: + size = len(_STRING_SENTINEL.format(slot.index)) if slot.context == "quoted" else \ + len(str(_NUMBER_SENTINEL + slot.index)) if slot.context == "bare" else 0 + out.append((start - shift, start - shift + size, start, end)) + shift += (end - start) - size + return out + + +def _to_template(pos: int, marks: Sequence[tuple[int, int, int, int]], *, last: bool) -> int | None: + """哨兵 SQL 里的一个位置 → 模板原文里的位置。落在哨兵中间的认不出(选择项的起止只会在一个词的两头)。last:这是一项 + 的最后一个字(含),落在哨兵最后一个字上时对到槽位的最后一个字。""" + delta = 0 + for s0, s1, t0, t1 in marks: + if pos < s0: + break + if pos < s1: + if not last and pos == s0: + return t0 + if last and pos == s1 - 1: + return t1 - 1 + return None + delta = t1 - s1 + return pos + delta + + +def project_template(template_sql: str, keep: Sequence[int], dialect: str | None = None) -> Projection: + """按口径卡读到的列投影口径的取数模板({{ input.<字段> }} 原样留着,见本节开头)。规则同 project_select,在哨兵渲染 + 的 SQL 上判;起止按位置换回模板原文。从不抛异常。""" + read = _read(dialect) + template = (template_sql or "").strip() + keep = tuple(keep) + try: + slots, sentinel = _template_slots(template) + root, spans, expected = _plan_projection(sentinel, keep, read, None) + items = list(root.expressions) + names = tuple(_output_name(items[k], read) for k in keep) + if spans is None: + return Projection(template, keep, 0, names) + if not _same_tree(_cut(sentinel, spans, keep), read, expected): + raise _NotProjectable(_PROJ_TEXT) + marks = _template_marks(template, slots) + mapped = [(_to_template(s, marks, last=False), _to_template(e, marks, last=True)) for s, e in spans] + if any(s is None or e is None for s, e in mapped): + raise _NotProjectable(_PROJ_TEXT) + out = _cut(template, mapped, keep) # type: ignore[arg-type] + # 换回模板之后再按哨兵渲染一遍:结构(字面量只看位置,槽位的编号会因为去掉了几处而变)要和投影后的一样 + _, again = _template_slots(out) + root_again, _ = _parse(again, read) + lits_a: list[Any] = [] + lits_b: list[Any] = [] + if root_again is None or _erased(root_again, lits_a) != _erased(expected, lits_b) or len(lits_a) != len(lits_b): + raise _NotProjectable(_PROJ_TEXT) + return Projection(out, keep, len(items) - len(keep), names) + except _NotComparable as e: + return Projection(None, keep, refused=str(e)) + except _NotProjectable as e: + return Projection(None, keep, refused=str(e)) + except Exception: # noqa: BLE001 - 同 project_select + logger.warning("口径模板投影失败", exc_info=True) + return Projection(None, keep, refused=_PROJ_TEXT) + + +def output_names(sql: str, dialect: str | None = None, *, template: bool = False) -> list[str] | None: + """最外层选择列表各项的输出名(按位置;同 _result_columns,不展开 *:有 * 的为 None)。template 为真时 sql 是口径 + 模板,按哨兵渲染之后认。解析不了、不是一段 SELECT 的为 None。""" + from sqlglot import exp + + read = _read(dialect) + text = (sql or "").strip() + try: + if template: + _, text = _template_slots(text) + root, _ = _parse(_mysql_dashes(text) if read == "mysql" else text, read) + except _NotComparable: + return None + if not isinstance(root, exp.Select) or any(_is_star(i) for i in root.expressions): + return None + return [_output_name(i, read) for i in root.expressions] + + +# ========================================================================== +# 同一个形状(认证队列的「确定性重复」:条件的先后不算) +# ========================================================================== +# +# normalize_sql 的规范文本把字面量换成「?」,但条件照原文的先后:WHERE a = 1 AND b = 2 和 WHERE b = 2 AND a = 1 文本不同, +# 认证队列就拆成两组。这里在不认列的那一级规范形式(_light,和判等价同一套只保持语义的改写)上,把字面量抹成只看类型的 +# 占位,再把交换律成立的地方按抹掉之后的写法重排一遍:AND / OR 的各项、IN 列表、= / <> / + / * 的两边、比大小的两边(换边 +# 时大于小于跟着换)。规范形式里本来的排序看字面量的值(同一个形状换了参数,条件的先后可能就变了),所以要重排。重排时 +# 抹掉之后一样的几项按原值排:同一组里字面量的位置对得上。认不出的(解析不了、sqlglot 会读错的写法、规范化不了的结构) +# 为 None,调用方照旧按 normalize_sql 的文本分组:不同就是不同,只会分得细,不会把不同的并成一组。 + +#: 抹掉字面量之前给每个字面量记的编号(对回 normalize_sql 的字面量用) +_LIT_TAG = "sql_semantics_literal" +#: 对 normalize_sql 的先后时,数字字面量换成的标记:这个整数加编号(同 _NUMBER_SENTINEL,真实 SQL 里几乎不会写) +_LIT_NUMBER = 9_173_402_581_000 +_LIT_STRING = "__agl_l{}__" +_LIT_STRING_RE = re.compile(r"^'__agl_l(\d+)__'$") +#: 交换律成立、两边可以对调的运算 +_SYMMETRIC = ("EQ", "NEQ", "Add", "Mul", "NullSafeEQ") + + +@dataclass(frozen=True) +class SqlShape: + """sql_shape 的结果。key 一样就是同一个形状(只差字面量的值、条件的先后、交换律成立处两边的先后);literals 是按规范 + 形式里的先后排的字面量(SQL 写法,字符串带引号),同一个形状的几条按位置对得上;positions[i] 是第 i 个在 + normalize_sql(sql).literals 里的位置(规范化时新算出来的字面量对不上,为 None)。""" + + key: str + literals: tuple[str, ...] + positions: tuple[int | None, ...] + + +def _normalized_order(root: Any, read: str | None) -> list[int] | None: + """normalize_sql 的字面量(按它重新生成的文本里的先后)各是哪一个字面量节点(_LIT_TAG 的编号):把字面量换成带编号的 + 标记,同样地重新生成、切词,按先后读出编号。对不上(个数不同、有认不出的)为 None。""" + from sqlglot import exp + from sqlglot.optimizer.normalize_identifiers import normalize_identifiers + + marked = normalize_identifiers(root.copy(), dialect=read) + for lit in marked.find_all(exp.Literal): + tag = lit.meta.get(_LIT_TAG) + if tag is None: + return None + lit.set("this", _LIT_STRING.format(tag) if lit.is_string else str(_LIT_NUMBER + tag)) + text = marked.sql(dialect=read, comments=False) + out: list[int] = [] + for token in _tokens(text, read): + if not _is_literal(token): + continue + raw = text[token.start:token.end + 1] + found = _LIT_STRING_RE.match(raw) + if found: + out.append(int(found.group(1))) + continue + try: + out.append(int(raw) - _LIT_NUMBER) + except ValueError: + return None + return out + + +def _shape_print(node: Any, read: str | None) -> tuple[str, list[Any]]: + """规范形式的确定写法(同 _erased:字面量只写类型、不写值,按写出的先后收进列表),交换律成立的地方按抹掉之后的写法 + 重排(抹掉之后一样的几项按原值排)。""" + from sqlglot import exp + + def value_key(lits: list[Any]) -> tuple[str, ...]: + return tuple(lit.sql(dialect=read) for lit in lits) + + def ordered(parts: list[tuple[str, list[Any]]]) -> list[tuple[str, list[Any]]]: + return sorted(parts, key=lambda p: (p[0], value_key(p[1]))) + + if isinstance(node, exp.Paren): + return _shape_print(node.this, read) + if isinstance(node, exp.Literal): + return f"Literal({'s' if node.is_string else 'n'})", [node] + if isinstance(node, (exp.And, exp.Or)): + parts = ordered([_shape_print(p, read) for p in _flat(node, type(node))]) + return (f"{type(node).__name__}[{','.join(p[0] for p in parts)}]", + [lit for _, lits in parts for lit in lits]) + if isinstance(node, exp.Expr) and type(node).__name__ in _FLIP and not _quantified(node.this) \ + and not _quantified(node.expression): + left, right = _shape_print(node.this, read), _shape_print(node.expression, read) + name = type(node).__name__ + later = (left[0], value_key(left[1])) > (right[0], value_key(right[1])) + fixed_left, fixed_right = _fixed(node.this), _fixed(node.expression) + if (fixed_left and not fixed_right) or (fixed_left == fixed_right and later): + left, right, name = right, left, _FLIP[name] + return f"{name}({left[0]},{right[0]})", left[1] + right[1] + if isinstance(node, exp.Expr) and type(node).__name__ in _SYMMETRIC: + parts = ordered([_shape_print(node.this, read), _shape_print(node.expression, read)]) + return f"{type(node).__name__}({','.join(p[0] for p in parts)})", parts[0][1] + parts[1][1] + if isinstance(node, exp.In) and node.args.get("expressions"): + head = _shape_print(node.this, read) + parts = ordered([_shape_print(e, read) for e in node.expressions]) + rest = [f"{k}={_shape_print(v, read)[0]}" for k, v in sorted(node.args.items()) + if k not in ("this", "expressions") and v is not None and v is not False and v != []] + return (f"In({head[0]};[{','.join(p[0] for p in parts)}];{','.join(rest)})", + head[1] + [lit for _, lits in parts for lit in lits]) + if isinstance(node, exp.Expr): + pieces: list[str] = [] + lits: list[Any] = [] + for k, v in sorted(node.args.items()): + if v is None or v is False or v == []: + continue + text, found = _shape_print(v, read) + pieces.append(f"{k}={text}") + lits += found + mark = node.meta.get("sql_semantics_join") if isinstance(node, exp.Join) else None + if mark: + pieces.append(f"@join={mark}") + surface = node.meta.get(_SURFACE) + if surface: + pieces.append(f"@surface={surface}") + return f"{type(node).__name__}({','.join(pieces)})", lits + if isinstance(node, list): + parts = [_shape_print(v, read) for v in node] + return "[" + ",".join(p[0] for p in parts) + "]", [lit for _, found in parts for lit in found] + return repr(node), [] + + +def sql_shape(sql: str, dialect: str | None = None) -> SqlShape | None: + """一条查询的形状(见本节开头):条件的先后、交换律成立处两边的先后、字面量的值都不算;别的不同一律算不同。认不出的为 + None(调用方按 normalize_sql 的文本分组)。从不抛异常。""" + from sqlglot import exp + + read = _read(dialect) + text = (_mysql_dashes(sql) if read == "mysql" and isinstance(sql, str) else sql or "").strip() + root, _ = _parse(text, read) + if root is None: + return None + try: + if _lost_in_parsing(text, read, root) or _respelled(text, read, root): + return None + for i, lit in enumerate(root.find_all(exp.Literal)): + lit.meta[_LIT_TAG] = i + order = _normalized_order(root, read) + if order is None or len(order) != len(normalize_sql(sql, dialect).literals): + return None + marked = _mark_surfaces(root, text, read) + if read == "sqlite": + _mark_comma_joins(root, text, read) + if not marked: + return None + try: + tree = _light(root, read, _Schema()).tree + except _Unsupported: + # 规范化不了的结构(递归 CTE、不确定的函数……):只统一名字,条件的先后照原文 + tree = root.copy() + _normalize_names(tree, read, _Schema()) + printed, lits = _shape_print(tree, read) + except Exception: # noqa: BLE001 - 认形状本身的缺陷:当作认不出,调用方按文本分组 + logger.warning("认 SQL 的形状失败", exc_info=True) + return None + where = {tag: i for i, tag in enumerate(order)} + return SqlShape(hashlib.sha256(printed.encode()).hexdigest(), + tuple(lit.sql(dialect=read) for lit in lits), + tuple(where.get(lit.meta.get(_LIT_TAG)) if lit.meta.get(_LIT_TAG) is not None else None + for lit in lits)) + + __all__ = ["BINDING_LEVELS", "MAX_SQL_CHARS", "PLACEHOLDER", "Aggregate", "ColumnRef", "Computed", "FacetDiff", "Facets", - "JoinFacet", "LimitFacet", "NormalizedSql", "Part", "SqlDiff", "TemplateBinding", "TemplateMatch", - "TemplateSlot", "TimePredicate", "bind_template", "catalog_labels", "diff_facets", "explain_sql_diff", - "facets", "match_template", "normalize_sql", "render_bound"] + "JoinFacet", "LimitFacet", "NormalizedSql", "Part", "Projection", "SqlDiff", "SqlShape", "TemplateBinding", + "TemplateMatch", "TemplateSlot", "TimePredicate", "bind_template", "catalog_labels", "diff_facets", + "explain_sql_diff", "facets", "match_template", "normalize_sql", "output_names", "project_select", + "project_template", "render_bound", "sql_shape"] diff --git a/backend/app/engine/lineage.py b/backend/app/engine/lineage.py index fd9bd004..647a4e69 100644 --- a/backend/app/engine/lineage.py +++ b/backend/app/engine/lineage.py @@ -6,7 +6,9 @@ - 运行时(nodes/metrics._input_of):「只是把调用工具节点的结果原样按 JSON 解析」的数据整形节点(parsed_tool)不改动任何 一格,vars..rows[i][j](row_cell)确定地对到那份查询快照的第 i 行第 j 列,和 cell() 一样按快照核对,记下穿过的整形 - 节点(through); + 节点(through);转抄另一张口径卡的指标(nodes.<卡>.metrics[i].value,metric_copy:对账卡、召回卡的「口径」那一项就是 + 这么写的)跨卡往下追——接上那个指标自己的出处,那个指标的输入都追到了原始数据,这一项才算追到(upstream.traced); + 转抄的转抄一层层接着追,最多 MAX_COPY_DEPTH 层(循环里抄自己上一轮的卡每一轮多一层,到了上限就算没追到); - 证据面板(api/evidence 的指标步骤):卡上记的输入里追不到原始数据的(untraced),照实说出处追到哪个节点为止; - 应用检查(apps/lint):受管级应用里绑定的指标,按 static_stops 静态地认(只有图、没有运行),追不到的给提示。 """ @@ -81,12 +83,39 @@ def row_cell(path: str) -> RowCell | None: return RowCell(rows.value.value.id, rows.value.attr, row.value, column.value) +#: 转抄另一张口径卡的指标时跨卡往下追的层数上限 +MAX_COPY_DEPTH = 8 + + +def metric_copy(path: str) -> tuple[str, str, int] | None: + """路径正好是 nodes.<卡>.metrics[i].value、vars.<卡的 assign_to>.metrics[i].value 这种转抄一个指标的值的样子时拆开: + (root, 名字, 第几个指标)。下标是非负整数字面量;别的样子(整张卡、清单文字、指标的名称单位、下标是变量)为 None。""" + try: + tree = ast.parse(path, mode="eval").body + except SyntaxError: + return None + if not (isinstance(tree, ast.Attribute) and tree.attr == "value" and isinstance(tree.value, ast.Subscript)): + return None + index, metrics = tree.value.slice, tree.value.value + if not (isinstance(metrics, ast.Attribute) and metrics.attr == "metrics" + and isinstance(metrics.value, ast.Attribute) and isinstance(metrics.value.value, ast.Name) + and metrics.value.value.id in ("vars", "nodes")): + return None + if not (isinstance(index, ast.Constant) and type(index.value) is int and index.value >= 0): + return None + return metrics.value.value.id, metrics.value.attr, index.value + + def traced(item: Mapping[str, Any]) -> bool: """卡上记的一个输入追没追到原始数据:落在查询快照的一格上、核对得了快照(cell() 取的格、穿过 JSON 解析按位置取的格、 - Agent 按出处交来的字段),或者是运行输入。""" + Agent 按出处交来的字段),或者是运行输入;转抄另一张口径卡的指标的,那个指标的输入都追到了(运行时跨卡追好记在 + upstream 里:traced 为真、那张卡的指标集落了工件)。""" via = item.get("via") if via in SNAPSHOT_VIAS: return bool(item.get("artifact")) and item.get("status") in _CHECKED + if via == "metric": + up = item.get("upstream") + return isinstance(up, Mapping) and up.get("traced") is True and bool(up.get("artifact")) return via == "input" diff --git a/backend/app/engine/nodes/metrics.py b/backend/app/engine/nodes/metrics.py index c289fdc9..740d545b 100644 --- a/backend/app/engine/nodes/metrics.py +++ b/backend/app/engine/nodes/metrics.py @@ -132,12 +132,18 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: decimals = display["decimals"] value = round_metric(value, decimals) substituted = substitute(tree, values) - incomplete: dict[str, Any] = {} - if use.sources: - sources = list(use.sources.values()) - incomplete = {"incomplete": True, "incomplete_reason": _incomplete_reason(sources), - "truncated_sources": sources} inputs = [_input_of(path, values[path], state, ctx, tctx) for path in paths] + # 转抄另一张口径卡的指标:那个指标不完整、所依据的查询没通过 SQL 检查的,这一个照样不完整、存疑(出处是跨卡追下去 + # 的,判档也按追到底的结果,见 engine/lineage.metric_copy) + carried = [m for item in inputs if (m := _copied_metric(item, tctx)) is not None] + truncated = list(use.sources.values()) + for source in (s for m in carried if m.get("incomplete") for s in m.get("truncated_sources") or []): + if isinstance(source, dict) and source not in truncated: + truncated.append(source) + incomplete: dict[str, Any] = {} + if truncated: + incomplete = {"incomplete": True, "incomplete_reason": _incomplete_reason(truncated), + "truncated_sources": truncated} for path, item in zip(paths, inputs): # 容器型的 vars.* 输入(列表、字典)另存一份完整值:卡里只记了「[12 项]」这样的大概,事后推演 # (engine/recompute.py)要拿完整的值重算。nodes.*、input.* 的完整值在那个节点封存的产出工件里, @@ -146,7 +152,9 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: if _container_var(path, values[path]) and (stored := await _store_value(path, values[path], ctx)): item["value_artifact"] = stored sql_check: dict[str, Any] = {} - if failed := _failed_queries(inputs, list(use.sources.values()), paths, tctx, checked): + copied_checks = [str(c.get("artifact")) for m in carried for c in m.get("sql_check_sources") or [] + if isinstance(c, dict) and c.get("artifact")] + if failed := _failed_queries(inputs, truncated, paths, tctx, checked, extra=copied_checks): sql_check = {"sql_check_failed": True, "sql_check_reason": _sql_check_reason(failed), "sql_check_sources": [{"artifact": artifact, "codes": list(dict.fromkeys( code for p in found for code in p.codes))} for artifact, found in failed]} @@ -456,18 +464,20 @@ _LAST_STEP = re.compile(r"(?:\.[^.\[\]]+|\[[^\[\]]*\])$") def _failed_queries(inputs: list[dict[str, Any]], truncated: list[dict[str, Any]], paths: list[str], - tctx: dict[str, Any], checked: dict[str, list[QueryProblem]], - ) -> list[tuple[str, list[QueryProblem]]]: + tctx: dict[str, Any], checked: dict[str, list[QueryProblem]], *, + extra: list[str] | tuple[str, ...] = ()) -> list[tuple[str, list[QueryProblem]]]: """这个指标用到的查询里,SQL 检查有 error 的:[(查询快照工件 id, 没通过检查的查询)]。合并查询的快照对应它出问题的 那几个输入(sql_problems.query_problems)。 - 用到了哪些查询,三处来历合起来认:输入的出处(cell() 取的格、Agent 经 cite_fields 交来的字段都带着快照 id)、 + 用到了哪些查询,四处来历合起来认:输入的出处(cell() 取的格、Agent 经 cite_fields 交来的字段都带着快照 id)、 截断跟踪记下的整组、取值链上的查询结果本身(数据整形解析过的 vars.res.rows[0][0],往上找到 vars.res 那份 - 带 artifact 的结果)。检查结果从快照里读,不信内存里那份:快照按哈希寻址,就是查询当时存下的那份。 + 带 artifact 的结果),以及 extra:转抄的指标所依据、没通过检查的那几条(跨卡追下去的)。检查结果从快照里读,不信内存里那份:快照按哈希寻址,就是查询当时存下的那份。 读不出来的快照不算(另有别的检查报快照的问题)。Agent 没开 cite_fields 时说不清数出自哪次查询,不算。 """ artifacts = [item.get("artifact") for item in inputs] + [s.get("artifact") for s in truncated] artifacts += [_result_artifact(path, tctx) for path in paths if cell_parts(path) is None] + # 转抄的指标所依据、没通过检查的查询(extra,跨卡追下去的那几条) + artifacts += list(extra) out = [] for artifact in dict.fromkeys(a for a in artifacts if isinstance(a, str) and a): if artifact not in checked: @@ -646,6 +656,10 @@ def _input_of(path: str, value: Any, state: GraphState, ctx: NodeContext, # 沙箱算出来的数喂给口径卡:记下它自认是「取数」还是「计算」,治理规则按它判 entry["role"] = str(producer.config.get("evidence_role") or "compute") entry["status"] = "ok" if value is not None else "missing" + if producer is not None and str(producer.type) == "metrics": + # 转抄另一张口径卡的指标:跨卡往下追(engine/lineage.metric_copy) + if (up := _upstream(path, tctx or template_context(state))) is not None: + entry["upstream"] = up if producer is not None and str(producer.type) == "agent" and field: cited = _agent_field(state, producer.id, root if head else "", field) if cited is not None: @@ -653,6 +667,48 @@ def _input_of(path: str, value: Any, state: GraphState, ctx: NodeContext, return entry +def _copied_card(path: str, tctx: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]] | None: + """转抄另一张口径卡一个指标的输入(engine/lineage.metric_copy 认的写法):(那张卡这一次的产出, 那个指标)。不是这种 + 写法、产出不是指标集、下标越界的为 None。""" + from app.engine.lineage import metric_copy + + found = metric_copy(path) + if found is None: + return None + root, name, index = found + card = _value_of(f"{root}.{name}", tctx) + metrics = card.get("metrics") if isinstance(card, dict) and card.get("kind") == "metric_set" else None + if not isinstance(metrics, list) or index >= len(metrics) or not isinstance(metrics[index], dict): + return None + return card, metrics[index] + + +def _upstream(path: str, tctx: dict[str, Any]) -> dict[str, Any] | None: + """转抄另一张口径卡的指标时,跨卡往下追的出处:{node_id?, metric, artifact, depth, traced}。那张卡的指标集落了工件、 + 那个指标的输入都追到了原始数据(engine/lineage.untraced 为空;它自己转抄的再往下一层层追过了,记在它的输入上)、 + 层数没超过 MAX_COPY_DEPTH,才算追到。不是转抄指标的为 None(照旧停在那张卡)。""" + from app.engine.lineage import MAX_COPY_DEPTH, untraced + + found = _copied_card(path, tctx) + if found is None: + return None + card, metric = found + inputs = [i for i in metric.get("inputs") or [] if isinstance(i, dict)] + depth = 1 + max([int((i.get("upstream") or {}).get("depth") or 0) for i in inputs + if isinstance(i.get("upstream"), dict)] or [0]) + artifact = card.get("artifact") if isinstance(card.get("artifact"), str) else None + return {"metric": metric.get("id"), "artifact": artifact, "depth": depth, + "traced": bool(artifact) and depth <= MAX_COPY_DEPTH and not untraced(inputs)} + + +def _copied_metric(item: dict[str, Any], tctx: dict[str, Any]) -> dict[str, Any] | None: + """卡上的一个输入转抄了另一张口径卡的哪个指标(这一次的产出里那一项);不是转抄的为 None。""" + if item.get("via") != "metric" or not isinstance(item.get("upstream"), dict): + return None + found = _copied_card(str(item.get("path") or ""), tctx) + return found[1] if found is not None else None + + def _field_key(field: str) -> str: """vars.kpi['gmv'] 和 vars.kpi.gmv 是同一个字段:下标写法换成点号。""" return re.sub(r"\[\s*['\"]([^'\"\]]+)['\"]\s*\]", r".\1", field).strip(".") diff --git a/backend/app/ledger/certify.py b/backend/app/ledger/certify.py index f4384c2b..3f1d454c 100644 --- a/backend/app/ledger/certify.py +++ b/backend/app/ledger/certify.py @@ -5,10 +5,15 @@ **认证队列**(GET /api/ledger/certify-queue,搭建者及以上)三种信号: - 确定性重复(shape):问数据的探索运行里执行过、被报告引用(比对工件里标签记着的查询)、又没对上任何口径(none / related) - 的查询,按(数据源、normalize_sql 的规范文本)分组;出现在 ≥ REPEAT_RUNS 次运行、来自 ≥ REPEAT_CONVERSATIONS 段对话的进队。 - 跨运行变化的字面量位置是参数候选,不变的是口径常量。展示的是规范文本(字面量都换成了「?」,不带取值); + 的查询,按(数据源、规范形)分组:sql_semantics.sql_shape 在判等价同一套规范形式上抹掉字面量、把 AND / OR 的各项、IN 列表、 + 交换律成立的比较两边重排——条件的先后不同、AND 两边换位的进同一组,条件真不同的不进;认不出规范形的按 normalize_sql 的 + 规范文本分组(只会分得细)。出现在 ≥ REPEAT_RUNS 次运行、来自 ≥ REPEAT_CONVERSATIONS 段对话的进队。跨运行变化的字面量 + 位置(按规范形里的先后对齐)是参数候选,不变的是口径常量。展示的是代表那条的规范文本(字面量都换成了「?」,不带取值)。 + 这一项的 id 按(数据源、规范形)算;以前按规范文本记的「不是口径」照样认(组里任何一条的规范文本对得上就算); - 问题聚类(cluster):app/chat/questions 全站唯一的聚类(只取问题原文、不含 history、排除系统派生的运行),≥ REPEAT_RUNS - 个问题的类; + 个问题的类。这一项的 id 是类的 id(成员的稳定 id 排好取哈希:同一批问题两次读取一样);「不是口径」记下当时的成员,之后 + 这一类过半的成员是那时记下的就照样不进队(来了几句新问法 id 会变,按成员认就不失效)。以前按种子问题原文算的 id 记的 + 照样认,记的成员读不懂的照实忽略; - 被反复用的答案(reused):导出过 ≥ 2 次(report.exported 审计,第 2 波)、做成过应用(蓝图的来源是这次运行)、在画布里 打开过或提取过(工作流的 origin.run_id 是这次运行)的轮次。 每项给代表问题、轮次链接、(shape 的)SQL 形状和参数候选、有没有相关口径(可以对账的那份)、是不是已经固化过(frozen_into, @@ -90,22 +95,39 @@ def item_id(kind: str, key: str) -> str: @dataclass(frozen=True) class Shape: - """一条查询的形状:数据源、规范文本(字面量换成「?」)、字面量(SQL 写法,字符串带引号)、规范化失败的原因。""" + """一条查询的形状:数据源、规范文本(字面量换成「?」)、字面量(SQL 写法,字符串带引号)、规范化失败的原因。 + + key 是分组用的规范形(sql_semantics.sql_shape,「shape:」开头;认不出时退回规范文本,「text:」开头);canonical 是按 + 规范形里的先后排的字面量(同一组的几条按位置对得上,认哪些位置跨轮变了用它),positions[i] 是它在 literals 里的位置 + (规范化时新算出来的字面量为 None:没法写成模板参数)。""" source: str text: str literals: tuple[str, ...] problem: str | None = None + key: str = "" + canonical: tuple[str, ...] = () + positions: tuple[int | None, ...] = () + + def params(self, canonical_positions: Sequence[int]) -> list[int]: + """规范形里的位置 → literals 里的位置(template_of 要的);对不上的不要。""" + found = (self.positions[i] for i in canonical_positions if i < len(self.positions)) + return sorted(p for p in found if p is not None) def shape_of(snap: Any, kinds: dict[str, str | None]) -> Shape | None: - from app.data.sql_semantics import normalize_sql + from app.data.sql_semantics import normalize_sql, sql_shape if not isinstance(snap, dict) or not isinstance(snap.get("sql"), str) or not snap["sql"].strip(): return None source = str(snap.get("source") or "") norm = normalize_sql(snap["sql"], kinds.get(source)) - return Shape(source, norm.text, norm.literals, norm.parse_error) + found = sql_shape(snap["sql"], kinds.get(source)) if not norm.parse_error else None + if found is None: + return Shape(source, norm.text, norm.literals, norm.parse_error, key=f"text:{norm.text}", + canonical=norm.literals, positions=tuple(range(len(norm.literals)))) + return Shape(source, norm.text, norm.literals, None, key=f"shape:{found.key}", canonical=found.literals, + positions=found.positions) def varying(literals: Sequence[Sequence[str]]) -> list[int]: @@ -174,11 +196,13 @@ def template_of(shape: Shape, params: Sequence[int], dialect: str | None, *, class _Member: run_id: str artifact: str + #: 按规范形里的先后排的字面量(Shape.canonical) literals: tuple[str, ...] conversation_id: str | None turn_id: str | None question: str related: list[dict[str, Any]] = field(default_factory=list) + shape: Shape | None = None def referenced_queries(content: dict[str, Any]) -> list[str]: @@ -220,6 +244,28 @@ async def _dismissed(session: Any) -> set[str]: AuditEvent.action == DISMISS_ACTION, AuditEvent.subject_kind == SUBJECT_KIND))).all() if sid} +async def _dismissed_members(session: Any) -> list[set[str]]: + """问题聚类上点过「不是口径」时记下的成员(每条一组稳定 id)。老记录没记成员、记的读不懂(不是字符串列表)的不算。""" + from app.db.models import AuditEvent + + out: list[set[str]] = [] + for (data,) in (await session.execute(select(AuditEvent.data).where( + AuditEvent.action == DISMISS_ACTION, AuditEvent.subject_kind == SUBJECT_KIND))).all(): + members = data.get("members") if isinstance(data, dict) else None + if isinstance(members, list) and members and all(isinstance(m, str) and m for m in members): + out.append(set(members)) + return out + + +def _cluster_dismissed(cluster: Any, dismissed: set[str], members: list[set[str]]) -> bool: + """这一类点过「不是口径」没有:类的 id 记过;以前按种子问题原文算的 id 记过;或者过半的成员是某一次记下的。""" + legacy = item_id("cluster", " ".join(cluster.members[0].text.split())) + if f"cluster:{cluster.id}" in dismissed or legacy in dismissed: + return True + keys = set(cluster.keys) + return any(len(keys & seen) * 2 > len(keys) for seen in members) + + async def _frozen(session: Any) -> dict[str, list[dict[str, Any]]]: """来源运行 → 从它固化出的工作流 [{workflow_id, name, status, published_version}](origin.sources)。""" from app.db.models import Workflow @@ -258,6 +304,7 @@ async def queue(session: Any, *, principal: Any) -> dict[str, Any]: from app.ledger import store dismissed = await _dismissed(session) + dismissed_members = await _dismissed_members(session) frozen = await _frozen(session) kinds = {name: kind for name, kind in (await session.execute(select(DataSource.name, DataSource.kind))).all()} runs = list((await session.execute(select(Run).where( @@ -297,31 +344,38 @@ async def queue(session: Any, *, principal: Any) -> dict[str, Any]: info = turns.get(run_id) or {} related = [{k: c.get(k) for k in ("caliber", "workflow_id", "version", "level")} for c in row.get("candidates") or [] if c.get("status") == "related"] - groups.setdefault((shape.source, shape.text), []).append(_Member( - run_id, query, shape.literals, info.get("conversation_id"), info.get("turn_id"), - str(info.get("question") or ""), related)) + groups.setdefault((shape.source, shape.key), []).append(_Member( + run_id, query, shape.canonical, info.get("conversation_id"), info.get("turn_id"), + str(info.get("question") or ""), related, shape)) return groups groups = await asyncio.to_thread(read) items: list[dict[str, Any]] = [] - for (source, text), members in groups.items(): + for (source, key), members in groups.items(): once = list({m.run_id: m for m in members}.values()) conversations = {m.conversation_id or f"run:{m.run_id}" for m in once} if len(once) < REPEAT_RUNS or len(conversations) < REPEAT_CONVERSATIONS: continue - iid = item_id("shape", f"{source}\n{text}") - if iid in dismissed: + iid = item_id("shape", f"{source}\n{key}") + # 以前按规范文本记的「不是口径」:组里任何一条的规范文本对得上就照样不进队 + legacy = {item_id("shape", f"{source}\n{m.shape.text}") for m in once if m.shape is not None} + if iid in dismissed or legacy & dismissed: continue - params = varying([m.literals for m in once]) - related = list({r["caliber"]: r for m in once for r in m.related if r.get("caliber")}.values()) rep = once[0] + shape = rep.shape + if shape is None: + continue + varied = varying([m.literals for m in once]) + params = [(i, p) for i in varied if i < len(shape.positions) and (p := shape.positions[i]) is not None] + related = list({r["caliber"]: r for m in once for r in m.related if r.get("caliber")}.values()) items.append({ - "id": iid, "kind": "shape", "source": source, "shape": text, "runs": len(once), + "id": iid, "kind": "shape", "source": source, "shape": shape.text, "runs": len(once), "conversations": len(conversations), "question": rep.question, "turns": [_turn_view(m) for m in once[:TURNS_LIMIT]], - "params": [{"index": i, "distinct": len({m.literals[i] for m in once if len(m.literals) > i})} - for i in params], - "constants": len(rep.literals) - len(params), "related": related, + # index 是代表那条规范文本里的第几个「?」;distinct 按规范形对齐了数 + "params": [{"index": p, "distinct": len({m.literals[i] for m in once if len(m.literals) > i})} + for i, p in sorted(params, key=lambda ip: ip[1])], + "constants": len(shape.literals) - len(params), "related": related, "representative": {"run_id": rep.run_id, "query": rep.artifact}, "peers": [m.run_id for m in once[1:]], "frozen_into": _frozen_of([m.run_id for m in once], frozen), @@ -332,8 +386,8 @@ async def queue(session: Any, *, principal: Any) -> dict[str, Any]: if cluster.count < REPEAT_RUNS: continue seed = cluster.members[0] - iid = item_id("cluster", " ".join(seed.text.split())) - if iid in dismissed: + iid = f"cluster:{cluster.id}" + if _cluster_dismissed(cluster, dismissed, dismissed_members): continue items.append({"id": iid, "kind": "cluster", "question": seed.text, "count": cluster.count, "turns": [{"run_id": q.run_id, "turn_id": q.turn_id, "question": q.text} @@ -387,12 +441,25 @@ async def _reused(session: Any, runs: list[Run], turns: dict[str, dict[str, Any] return out -async def dismiss(session: Any, iid: str, *, kind: str | None = None, note: str | None = None) -> None: - """「不是口径」:写一条审计,之后这一项不再进队。""" +async def dismiss(session: Any, iid: str, *, kind: str | None = None, note: str | None = None, + principal: Any = None) -> None: + """「不是口径」:写一条审计,之后这一项不再进队。问题聚类另记下这一类现在的成员(服务端按同一批问题重新聚一次找到 + 这一类,不收客户端传的成员;找不到——这会儿问题又多了、聚出来的已经不是这一类——就只记 id,照实)。""" from app.auth.audit import record + members: list[str] = [] + if principal is not None and (kind == "cluster" or iid.startswith("cluster:")): + from app.chat.questions import cluster_questions, recent_questions + + try: + questions = await recent_questions(session, principal, limit=QUESTION_LIMIT) + found = next((c for c in await cluster_questions(questions) if f"cluster:{c.id}" == iid), None) \ + if questions else None + members = found.keys if found is not None else [] + except Exception: # noqa: BLE001 - 取向量出错:只记 id,「不是口径」照样记上 + logger.exception("「不是口径」时重新聚类出错,只按 id 记") await record(session, DISMISS_ACTION, SUBJECT_KIND, iid, - data={k: v for k, v in {"kind": kind, "note": note}.items() if v}) + data={k: v for k, v in {"kind": kind, "note": note, "members": members}.items() if v}) await session.commit() @@ -447,7 +514,7 @@ async def freeze(session: Any, run_id: str, *, peers: Sequence[str] = (), name: raise CertifyRefused(409, NO_QUERIES) kinds = {n: k for n, k in (await session.execute(select(DataSource.name, DataSource.kind))).all()} - # 同组的别的运行:它们封存的查询里同形状的那一条的字面量 + # 同组的别的运行:它们封存的查询里同形状(规范形相同,见 Shape.key)的那一条的字面量,按规范形里的先后 peer_literals: dict[tuple[str, str], list[tuple[str, ...]]] = {} peer_sources: list[dict[str, Any]] = [] peer_runs = [r for r in [await session.get(Run, p) for p in dict.fromkeys(peers) if p != run_id] if r is not None] @@ -460,7 +527,7 @@ async def freeze(session: Any, run_id: str, *, peers: Sequence[str] = (), name: except ValueError: continue if shape is not None and not shape.problem: - peer_literals.setdefault((shape.source, shape.text), []).append(shape.literals) + peer_literals.setdefault((shape.source, shape.key), []).append(shape.canonical) sealed = await _sealed(run_id) shapes: list[Shape] = [] snaps: list[dict[str, Any]] = [] @@ -485,7 +552,7 @@ async def freeze(session: Any, run_id: str, *, peers: Sequence[str] = (), name: metrics: list[dict[str, Any]] = [] multi = False for i, (shape, snap) in enumerate(zip(shapes, snaps)): - params = varying([shape.literals, *peer_literals.get((shape.source, shape.text), [])]) + params = shape.params(varying([shape.canonical, *peer_literals.get((shape.source, shape.key), [])])) template, new_fields = template_of(shape, params, kinds.get(shape.source), start=len(fields) + 1) fields += new_fields nodes.append({"id": nid(i), "type": "tool", "position": {"x": 0, "y": 0}, diff --git a/backend/app/ledger/compose.py b/backend/app/ledger/compose.py index 160557f5..50374dd8 100644 --- a/backend/app/ledger/compose.py +++ b/backend/app/ledger/compose.py @@ -47,8 +47,11 @@ _NUMBER = re.compile(r"^\s*-?\d+(?:\.\d+)?\s*$") #: 写给模型的拒收说明(也上界面:过程里那一行) NOT_LISTED = "只能选用这一轮查看到的已发布口径:「{workflow}」v{version} 不在里面(口径要先 look,版本以查看结果为准)" WRONG_VERSION = "「{workflow}」这一轮能选用的是 v{latest}(最新的已发布版本),不是 v{version}" -CARD_UNKNOWN = "「{workflow}」v{version} 里没有口径卡「{card}」(有:{cards})" -CARD_AMBIGUOUS = "「{workflow}」v{version} 有几张口径卡({cards}),请在 card 里写选用哪一张" +#: card 写错时交回的原因要能一次改对:说清 card 填什么、可填哪几个(节点 id);写成了自己起的 id 的另说一句 +CARD_UNKNOWN = ("「{workflow}」v{version} 里没有口径卡「{card}」:card 要填那一版工作流图里口径卡的节点 id,可填的有 {cards}" + "(照抄查看结果里 use_caliber 写法中的 card)") +CARD_IS_OP_ID = ";「{card}」是你给这次选用起的 id,不是口径卡的节点 id" +CARD_AMBIGUOUS = "「{workflow}」v{version} 有几张口径卡(节点 id:{cards}),请在 card 里写选用哪一张的节点 id" NOT_COMPOSABLE = "「{workflow}」v{version} 的口径卡「{card}」不能组合:{reason}" BAD_ID = "use_caliber 的 id 要写成简短的英文小写加下划线(例如 c1),它就是口径卡的节点 id,当前为「{id}」" ID_TAKEN = "use_caliber 的 id「{id}」和图里已有的节点「{node}」撞了,请换一个" @@ -233,12 +236,14 @@ class Composer: if str(version) != str(latest): raise ComposeRefused(WRONG_VERSION.format(workflow=name, latest=latest, version=version)) card = str(op.get("card") or "").strip() - listed = [f"{o.card}({o.node_id})" for o in cards] + listed = [f"「{o.node_id}」(口径卡「{o.card}」)" for o in cards] if card: hit = next((o for o in cards if card in (o.node_id, o.card)), None) if hit is None: - raise ComposeRefused(CARD_UNKNOWN.format(workflow=name, version=latest, card=card, - cards=_names(listed))) + why = CARD_UNKNOWN.format(workflow=name, version=latest, card=card, cards=_names(listed)) + if card == str(op.get("id") or "").strip(): + why += CARD_IS_OP_ID.format(card=card) + raise ComposeRefused(why) return hit if len(cards) > 1: raise ComposeRefused(CARD_AMBIGUOUS.format(workflow=name, version=latest, cards=_names(listed))) diff --git a/backend/app/ledger/labels.py b/backend/app/ledger/labels.py index 08a23cac..14be0c3b 100644 --- a/backend/app/ledger/labels.py +++ b/backend/app/ledger/labels.py @@ -162,6 +162,78 @@ def card_expressions(cards: list[Mapping[str, Any]]) -> list[tuple[str, str, str 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 {} + 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}}, @@ -359,5 +431,5 @@ def related_view(item: Mapping[str, Any], *, hide: Callable[[Any], bool], shown: __all__ = ["CERTIFIED_NOTE", "HOWS", "KINDS", "LEVEL_TEXT", "POST_SEAL_NOTE", "RETRO_NOTE", "binding_view", "caliber_key", - "card_expressions", "cell_label", "diff_view", "label_doc", "metric_label", "related_view", "view_label", - "whole_of"] + "card_expressions", "card_reads", "cell_label", "diff_view", "label_doc", "metric_label", + "referenced_columns", "related_view", "view_label", "whole_of"] diff --git a/backend/app/ledger/match.py b/backend/app/ledger/match.py index 85804248..2a128b40 100644 --- a/backend/app/ledger/match.py +++ b/backend/app/ledger/match.py @@ -11,6 +11,20 @@ MAX_CANDIDATES 份(按工作流、版本、节点排好的先后)。 **怎么比。** data/sql_semantics.match_template:先绑定参数(只是启发),再按绑定渲染口径的取数 SQL 交 explain_sql_diff 证明。 +**按引用的列投影**(_projected,algo 3)。助手写的 SQL 总会多选几列(订单数、日期常量、MIN / MAX……),口径的取数也只选 +它的指标要的那几列:整条比永远不等价。整条比不等价时,答案一侧按报告引用了这条查询的哪几列(labels.referenced_columns: +单元格的列、指标输入落在的列)投影,口径一侧按口径卡上每个指标读了这个取数节点的哪几列(labels.card_reads)投影 +(sql_semantics.project_select / project_template:去掉没被引用、去掉也不改变被引用那几列取值的选择项,认不准的一律 +不投影),投影后的两条照样交 match_template 绑定、判等价——不另开一条给信任的路,判等价那一步一点不放宽。 + +被引用的列两边怎么对上:答案一侧按列名在查询快照记的结果列(columns)里找位置,结果列按位置对上选择列表的各项(列数 +和选择列表的项数要一样);口径一侧按口径卡 cell(nodes.取数, 行, '列') 里写的列名,在取数 SQL 选择列表的输出名(别名, +没有别名的是列名,不分大小写,同名的不止一项时不投影)里找位置。投影后两边留下的几列按位置配对(和整条比时的列映射一样: +外层别名不计、列的先后计),两边列数一样才比;答案一侧留下两三列时另试调换先后。每份口径取数最多试 MAX_PROJECTIONS 种。 +证明了的候选记 projection {ours, dropped, caliber, caliber_dropped};没证明的给第一种投影的比较(差在哪是投影之后的说法, +答案多选了列的在最前面写一句「这次多查了 N 列,不影响这个数」)。没有投影可试的(报告没引用这条查询、两边都不投影、 +投影不了)照旧是整条比的结果,逐对一字不差。 + **助手选用的(composed)。** 问数据的助手 use_caliber 选用口径时,取数节点是服务端照那一版拷进来的(ledger/compose.py), 口径卡的 caliber_from 带着改名映射 {原 id: 新 id}。这几个节点交回的查询照样要证明:证明了的那一份记 how=composed,查询 状态记 composed——数确实来自口径,但「这份口径答的就是这个问题」是助手凭问法判断的,不能说成「等价已证明」(拍板 L1: @@ -40,6 +54,7 @@ import logging import time import weakref from concurrent.futures import ThreadPoolExecutor +from collections.abc import Collection, Mapping from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from typing import Any @@ -62,6 +77,8 @@ CONCURRENCY = 2 GRACE_S = 2.0 #: 每条查询最多比几份口径 MAX_CANDIDATES = 8 +#: 按引用的列投影之后,每份口径取数最多再试几种比法(答案一侧留下的列的先后 × 口径一侧的列组,见 _projected) +MAX_PROJECTIONS = 12 #: 启动时补比最近几天、最多几个 BACKFILL_DAYS = 7 BACKFILL_LIMIT = 200 @@ -76,6 +93,8 @@ PARSE_FAILED = "这次的 SQL 无法解析,没有比对" MERGED = "合并查询的格不和口径比对(它的数来自各个输入的查询)" COMPOSED_UNPROVEN = "这条查询出自助手选用的口径,但和口径的取数没能证明一致(可能被改过)" MORE_CANDIDATES = "另有 {n} 份相关口径没有比对(每条查询最多比 {max} 份)" +#: 按引用的列投影之后没证明等价时,差在哪的第一句:答案只是多选了几列(去掉它们不影响被引用的数,见 sql_semantics 的投影) +EXTRA_COLUMNS = "这次多查了 {n} 列,不影响这个数" #: 不比对的原因(GET 账本时照实说) NOT_FOUND = "运行记录不存在" @@ -124,7 +143,8 @@ class Fetch: @dataclass(frozen=True) class Caliber: """一个工作流最新的已发布、未退役版本(只收里面有查库节点的)。fields 是入口节点的字段原样(取缺省值用,不进工件), - cards 是源头口径卡的节点 id,expressions 是这些卡上每个指标换成记号后的式子(labels.card_expressions)。""" + cards 是源头口径卡的节点 id,expressions 是这些卡上每个指标换成记号后的式子(labels.card_expressions),reads 是每个 + 指标读了哪几个取数节点的哪几列(labels.card_reads:口径一侧按它投影,不进工件)。""" workflow_id: str version: int @@ -134,6 +154,7 @@ class Caliber: fields: tuple[dict[str, Any], ...] = () cards: tuple[str, ...] = () expressions: tuple[tuple[str, str, str], ...] = () + reads: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...] = () @property def key(self) -> str: @@ -201,7 +222,8 @@ def caliber_of(workflow_id: str, row: Any) -> Caliber | None: return Caliber(workflow_id=workflow_id, version=int(row.version), level=str(getattr(row, "level", None) or ""), graph_hash=getattr(row, "graph_hash", None), fetches=tuple(fetches), fields=tuple(fields), cards=tuple(c["node_id"] for c in cards), - expressions=tuple(ledger_labels.card_expressions(cards))) + expressions=tuple(ledger_labels.card_expressions(cards)), + reads=tuple(ledger_labels.card_reads(cards))) async def load_calibers(session: Any) -> list[Caliber]: @@ -331,12 +353,108 @@ def composed_nodes(graph: Any) -> dict[str, tuple[str, str]]: return out +def _wanted(query: Query, referenced: Mapping[str, Collection[str]] | None) -> tuple[int, ...] | None: + """报告引用了这条查询的哪几列:按列名在查询快照记的结果列里找位置(按位置先后,同名的几列都算)。没引用、快照没记 + 结果列的为 None(不投影)。""" + names = (referenced or {}).get(query.artifact) + if not names or query.columns is None: + return None + return tuple(i for i, c in enumerate(query.columns) if c in names) or None + + +def _caliber_sets(caliber: Caliber, fetch: Fetch, names: list[str]) -> list[tuple[int, ...]]: + """口径一侧按哪几组列投影:口径卡上每个指标读了这个取数节点的哪几列(labels.card_reads),按列名(不分大小写)在取数 + SQL 的输出名里找位置、按位置排好;最后加上所有指标读到的列合在一起的那一组。找不到、同名的不止一项的那一组不要。""" + lowered = [n.lower() for n in names] + out: list[tuple[int, ...]] = [] + union: set[int] = set() + for _, _, reads in caliber.reads: + columns = [column for node, column in reads if node == fetch.node_id] + if not columns: + continue + found = [[i for i, n in enumerate(lowered) if n == c.lower()] for c in columns] + if any(len(f) != 1 for f in found): + continue + group = tuple(sorted({f[0] for f in found})) + union |= set(group) + if group not in out: + out.append(group) + whole = tuple(sorted(union)) + if whole and whole not in out: + out.append(whole) + return out + + +def _projection_view(query: Query, ours: Any, theirs: Any) -> dict[str, Any]: + """候选上记的「按引用的列比的」:答案留下的列(快照里的列名)、去掉了几列,口径留下的列、去掉了几列。""" + return {"ours": [str((query.columns or [])[i]) for i in ours.kept], "dropped": ours.dropped, + "caliber": list(theirs.names), "caliber_dropped": theirs.dropped} + + +def _projected(caliber: Caliber, fetch: Fetch, query: Query, wanted: tuple[int, ...], + deadline: float | None) -> dict[str, Any] | None: + """整条比不等价时按引用的列投影再比(见模块说明):证明了的候选(带 projection);都没证明时第一种投影的那次比较; + 没有投影可试的为 None(照旧用整条比的结果)。""" + from itertools import permutations + + from app.data.sql_semantics import FacetDiff, match_template, output_names, project_select, project_template + + width = len(query.columns or []) + whole = project_select(query.sql, tuple(range(width)), query.dialect, width=width) + if whole.sql is None: + return None + ours = [] + if len(wanted) < width: + cut = project_select(query.sql, wanted, query.dialect, width=width) + if cut.sql is not None: + ours.append(cut) + ours = ours or [whole] + for base in list(ours): + if 2 <= len(base.kept) <= 3: + for order in permutations(base.kept): + if order != base.kept and (p := project_select(query.sql, order, query.dialect, width=width)).sql: + ours.append(p) + names = output_names(fetch.sql, query.dialect, template=True) + if names is None: + return None + theirs = [p for group in _caliber_sets(caliber, fetch, names) if len(group) < len(names) + for p in [project_template(fetch.sql, group, query.dialect)] if p.sql is not None] + theirs.append(project_template(fetch.sql, tuple(range(len(names))), query.dialect)) + first: tuple[Any, Any, Any] | None = None + tries = 0 + for a in ours: + for c in theirs: + if c.sql is None or len(a.kept) != len(c.kept) or (not a.dropped and not c.dropped + and a.kept == whole.kept): + continue # 两边都没投影:就是整条比过的那一次 + if tries >= MAX_PROJECTIONS or (deadline is not None and time.monotonic() > deadline): + break + tries += 1 + m = match_template(c.sql, list(caliber.fields), a.sql, query.dialect, schema=query.schema, + names=query.names, columns=[str((query.columns or [])[i]) for i in a.kept], + deadline=deadline) + if m.equivalent: + return {**_candidate(caliber, fetch, m), "projection": _projection_view(query, a, c)} + if first is None: + first = (a, c, m) + if first is None: + return None + a, c, m = first + cand = {**_candidate(caliber, fetch, m), "projection": _projection_view(query, a, c)} + if a.dropped and isinstance(cand.get("diff"), dict): + note = FacetDiff("projection", None, None, EXTRA_COLUMNS.format(n=a.dropped)).as_dict() + cand["diff"] = {**cand["diff"], "items": [note, *cand["diff"]["items"]]} + return cand + + def compare_queries(queries: list[Query], calibers: list[Caliber], *, deadline: float | None = None, - composed: dict[str, tuple[str, str]] | None = None) -> list[dict[str, Any]]: + composed: dict[str, tuple[str, str]] | None = None, + referenced: Mapping[str, Collection[str]] | None = None) -> list[dict[str, Any]]: """每条查询和预筛出来的口径取数逐个比(match_template),给出 [{artifact, node_id, source, tables, status, reason?, candidates}]。status:proven(和某一份等价)/ composed(助手选用的那一份,证明了)/ related(有同源同表的口径,都不 等价)/ none(没有相关的口径)/ skipped(没比完:超时、解析失败、快照读不出)。composed 是 composed_nodes 的结果, - 不给就没有助手选用的。纯计算,从不抛异常。评测逐题比 SQL 也调它。""" + 不给就没有助手选用的。referenced 是报告引用了哪条查询的哪几列(labels.referenced_columns:{查询快照: {列名}}): + 给了的,整条比不等价时按引用的列投影再比(_projected);不给就只整条比。纯计算,从不抛异常。评测逐题比 SQL 也调它。""" from app.data.sql_semantics import match_template, normalize_sql out: list[dict[str, Any]] = [] @@ -347,6 +465,7 @@ def compare_queries(queries: list[Query], calibers: list[Caliber], *, deadline: out.append({**row, "status": "skipped", "reason": query.problem, "candidates": []}) continue chosen = (composed or {}).get(str(query.node_id or "")) + wanted = _wanted(query, referenced) pool = [(c, f) for c in calibers for f in c.fetches if f.source == query.source and f.tables & query.tables] if chosen is not None: # 助手选用的那一份排最前面:候选多的时候也一定比到它 @@ -370,6 +489,11 @@ def compare_queries(queries: list[Query], calibers: list[Caliber], *, deadline: candidates.append(_skipped(caliber, fetch, "比对出错")) continue cand = _candidate(caliber, fetch, m) + if not m.equivalent and m.comparable and wanted is not None: + try: + cand = _projected(caliber, fetch, query, wanted, deadline) or cand + except Exception: # noqa: BLE001 - 投影再比是附加的一步:出错照旧用整条比的结果 + logger.exception("按引用的列投影再比出错(运行查询 %s,口径 %s)", query.artifact[:12], caliber.key) if chosen is not None and (caliber.key, fetch.node_id) == chosen: if cand["status"] == "proven": cand["how"] = "composed" @@ -389,27 +513,10 @@ def compare_queries(queries: list[Query], calibers: list[Caliber], *, deadline: return out -def compute(sealed: Any, calibers: list[Caliber], kinds: dict[str, str | None], *, timeout: float) -> dict[str, Any]: - """一次运行的比对工件内容(见 ledger/store 的模块说明)。在线程里调:读快照、比 SQL、标数都是同步的活。""" - from app.api.evidence import _choose, _reports +def _card_loader(sealed: Any) -> Any: + """按工件读封存范围内的指标集(不在封存台账里的、读不出的为 None)。""" from app.core.artifact_store import load - deadline = time.monotonic() + timeout - queries = read_queries(sealed, kinds) - if not sealed.trusted: - results = [{"artifact": q.artifact, "node_id": q.node_id, "source": q.source, "tables": sorted(q.tables), - "status": "skipped", "reason": SEAL_BROKEN, "candidates": []} for q in queries] - else: - results = compare_queries(queries, calibers, deadline=deadline, composed=composed_nodes(sealed.graph)) - used = {c["caliber"] for q in results for c in q["candidates"]} - considered = [c for c in calibers if c.key in used] - by_key = {c.key: {"level": c.level, "fields": c.summary()["fields"], "expressions": list(c.expressions)} - for c in considered} - by_artifact = {q["artifact"]: q for q in results} - # 合并查询的快照不比(read_queries 跳过了它),引用了它的格照实写为什么没比对;不进工件的 queries,统计不数它 - for artifact in sealed.queries: - by_artifact.setdefault(artifact, {"artifact": artifact, "status": "skipped", "reason": MERGED, - "candidates": []}) sealed_cards = {artifact for _, artifact in sealed.ledger} def load_card(artifact: str) -> dict[str, Any] | None: @@ -421,12 +528,47 @@ def compute(sealed: Any, calibers: list[Caliber], kinds: dict[str, str | None], return None return card if isinstance(card, dict) else None + return load_card + + +def referenced_of(sealed: Any) -> dict[str, set[str]]: + """这次运行封存的报告引用了哪条查询的哪几列(labels.referenced_columns,只读哈希核对一致的报告):compare_queries 的 + referenced。评测逐题比 SQL 时传它,就和账本一样按引用的列投影。在线程里调(读盘)。""" + from app.api.evidence import _reports + + docs = [r.doc for r in _reports(sealed) if r.doc is not None and r.hash_ok is True] + return ledger_labels.referenced_columns(docs, _card_loader(sealed)) + + +def compute(sealed: Any, calibers: list[Caliber], kinds: dict[str, str | None], *, timeout: float) -> dict[str, Any]: + """一次运行的比对工件内容(见 ledger/store 的模块说明)。在线程里调:读快照、比 SQL、标数都是同步的活。""" + from app.api.evidence import _choose, _reports + + deadline = time.monotonic() + timeout + queries = read_queries(sealed, kinds) + load_card = _card_loader(sealed) + found = _reports(sealed) + readable = [report for report in found if report.doc is not None and report.hash_ok is True] + if not sealed.trusted: + results = [{"artifact": q.artifact, "node_id": q.node_id, "source": q.source, "tables": sorted(q.tables), + "status": "skipped", "reason": SEAL_BROKEN, "candidates": []} for q in queries] + else: + referenced = ledger_labels.referenced_columns([report.doc for report in readable], load_card) + results = compare_queries(queries, calibers, deadline=deadline, composed=composed_nodes(sealed.graph), + referenced=referenced) + used = {c["caliber"] for q in results for c in q["candidates"]} + considered = [c for c in calibers if c.key in used] + by_key = {c.key: {"level": c.level, "fields": c.summary()["fields"], "expressions": list(c.expressions)} + for c in considered} + by_artifact = {q["artifact"]: q for q in results} + # 合并查询的快照不比(read_queries 跳过了它),引用了它的格照实写为什么没比对;不进工件的 queries,统计不数它 + for artifact in sealed.queries: + by_artifact.setdefault(artifact, {"artifact": artifact, "status": "skipped", "reason": MERGED, + "candidates": []}) reports: dict[str, Any] = {} primary: str | None = None - found = _reports(sealed) - for report in found: - if report.doc is not None and report.hash_ok is True: - reports[report.node_id] = {"labels": ledger_labels.label_doc(report.doc, by_artifact, by_key, load_card)} + for report in readable: + reports[report.node_id] = {"labels": ledger_labels.label_doc(report.doc, by_artifact, by_key, load_card)} if found: primary = _choose(found, None, sealed).node_id whole = ledger_labels.whole_of((reports.get(primary) or {}).get("labels") or {}, by_key) if primary in reports \ @@ -617,6 +759,7 @@ async def shutdown() -> None: await asyncio.gather(*tasks, return_exceptions=True) -__all__ = ["BACKFILL_DAYS", "CONCURRENCY", "EXPLORE_ORIGINS", "TIMEOUT_S", "Caliber", "Fetch", "Query", "backfill", - "caliber_of", "compare_queries", "composed_nodes", "compute", "drain", "ineligible", "load_calibers", - "match_run", "pending", "read_queries", "schedule", "shutdown", "start_backfill", "wanted"] +__all__ = ["BACKFILL_DAYS", "CONCURRENCY", "EXPLORE_ORIGINS", "EXTRA_COLUMNS", "MAX_PROJECTIONS", "TIMEOUT_S", + "Caliber", "Fetch", "Query", "backfill", "caliber_of", "compare_queries", "composed_nodes", "compute", + "drain", "ineligible", "load_calibers", "match_run", "pending", "read_queries", "referenced_of", + "schedule", "shutdown", "start_backfill", "wanted"] diff --git a/backend/app/ledger/reconcile.py b/backend/app/ledger/reconcile.py index dc997743..db2edfff 100644 --- a/backend/app/ledger/reconcile.py +++ b/backend/app/ledger/reconcile.py @@ -16,7 +16,8 @@ 的字段校验,ledger/compose.check_inputs 同一套),界面写「参数按这次的 SQL 推断,请核对」。 **列怎么配。** 只有一行结果时:口径卡上的指标式就是取数第一行某一列(cell(nodes.取数, 0, '列'))的,按那一列在口径取数 -结果里的位置配这次结果同一位置的列(等价时已实测列的先后计;不等价的两条 SQL 选择列表一样时位置照样对得上)。差异 +结果里的位置配这次结果同一位置的列(等价时已实测列的先后计;不等价的两条 SQL 选择列表一样时位置照样对得上)。比对时 +按引用的列投影过的(候选的 projection,ledger/match 的 _projected),按投影之后两边留下的列逐个配(这次多选的列不算)。差异 有兜底说明(fallback:两条 SQL 对不上结构)、列数不同、一个都配不上时不猜,要人选(pairs:{ours: 这次的列, metric: 口径卡 上的指标 id}),这个选择记进对账运行的 origin。 @@ -327,8 +328,9 @@ def _plain_cell(expression: str, fetch: str) -> str | None: def plan_pairs(*, ours_columns: list[str], ours_rows: list[Any], hidden: set[str], caliber_columns: list[str] | None, metrics: list[dict[str, Any]], fetch: str, card: str, fallback: bool, chosen: Any, - referenced: list[tuple[int, str]]) -> Plan: - """配列、(多行时)定行和键:交回 Plan,配不了的抛 ReconcileRefused(见模块说明)。纯计算。""" + referenced: list[tuple[int, str]], projected: Any = None) -> Plan: + """配列、(多行时)定行和键:交回 Plan,配不了的抛 ReconcileRefused(见模块说明)。projected 是比对候选的 projection + (按引用的列投影过的:{ours: [这次留下的列], caliber: [口径留下的列]},按位置一一对应)。纯计算。""" def check_column(column: str) -> None: if column not in ours_columns: raise ReconcileRefused(422, PAIR_COLUMN.format(column=column)) @@ -360,7 +362,17 @@ def plan_pairs(*, ours_columns: list[str], ours_rows: list[Any], hidden: set[str if fallback: raise ReconcileRefused(409, PAIRS_FALLBACK, NEEDS_PAIRS, extra=options) pairs = [] - if caliber_columns is not None and len(caliber_columns) == len(ours_columns): + kept = _projected_pairs(projected, ours_columns) + if kept is not None: + # 按投影之后留下的列配:这次多选的列不算,口径一侧只看投影留下的那几列(列名不分大小写) + for index, m in enumerate(metrics): + column = _plain_cell(str(m.get("expression") or ""), fetch) + ours = next((o for o, c in kept if column is not None and c.lower() == column.lower()), None) + if ours is None or ours.lower() in hidden: + continue + pairs.append(Pair(ours=ours, metric=str(m.get("id")), index=index, name=str(m.get("name") or m.get("id")), + unit=str(m.get("unit") or ""), decimals=m.get("decimals"))) + elif caliber_columns is not None and len(caliber_columns) == len(ours_columns): for index, m in enumerate(metrics): column = _plain_cell(str(m.get("expression") or ""), fetch) if column is None or column not in caliber_columns: @@ -375,6 +387,19 @@ def plan_pairs(*, ours_columns: list[str], ours_rows: list[Any], hidden: set[str return Plan(pairs=pairs) +def _projected_pairs(projected: Any, ours_columns: list[str]) -> list[tuple[str, str]] | None: + """比对候选的 projection → [(这次的列, 口径的列)],按位置一一对应;不是投影过的、两边列数不一样、这次的列不在结果里的 + 为 None(照旧按整条的位置配)。""" + if not isinstance(projected, dict): + return None + ours, theirs = projected.get("ours"), projected.get("caliber") + if not isinstance(ours, list) or not isinstance(theirs, list) or not ours or len(ours) != len(theirs): + return None + if any(not isinstance(o, str) or o not in ours_columns or not isinstance(c, str) for o, c in zip(ours, theirs)): + return None + return list(zip(ours, theirs)) + + def _plan_rows(ours_columns: list[str], rows: list[Any], hidden: set[str], caliber_columns: list[str] | None, referenced: list[tuple[int, str]], check_column: Any) -> Plan: if caliber_columns is None: @@ -536,7 +561,7 @@ async def reconcile(run_id: str, req: Request, *, principal: Any) -> Run: caliber_columns=_caliber_columns(template, inputs, list(pinned.entry_fields), kind, schema), metrics=pinned.metrics, fetch=str(fetch["id"]), card=pinned.card_title, fallback=bool((cand.get("diff") or {}).get("fallback")), chosen=req.pairs, - referenced=_referenced(sealed, req.query)) + referenced=_referenced(sealed, req.query), projected=cand.get("projection")) graph = build(pinned, sql=snap["sql"], tool=f"{QUERY_PREFIX}{source}", plan=plan, inputs=inputs, fetch=str(fetch["id"])) diff --git a/backend/app/ledger/stats.py b/backend/app/ledger/stats.py index 64f37605..4c4ead14 100644 --- a/backend/app/ledger/stats.py +++ b/backend/app/ledger/stats.py @@ -8,8 +8,10 @@ 这时不再按来源和计入统计筛——调用方点名的就是要算的)。 - E1 等价命中:比对过的查询里 proven / composed / related / none / skipped 各几条(skipped 单独列,不混进 none),有候选口径 - 的查询里各自的比率;证明等价的绑定是在哪一级配上的;没证明的原因(不可比、参数绑不上、配上了但不等价);不等价的差异类型 - 分布(time / filter / aggregate / join / other / parse,每份口径的差异每类只计一次);逐个数的标签各几个、整题等价几次。 + 的查询里各自的比率;证明等价的绑定是在哪一级配上的,其中几份是按引用的列投影之后才证明的(proven_projected,ledger/match + 的 _projected);没证明的原因(不可比、参数绑不上、配上了但不等价);不等价的差异类型分布(time / filter / aggregate / + join / other / parse,每份口径的差异每类只计一次;投影之后那句「这次多查了 N 列」不是差异,不计);逐个数的标签各几个、 + 整题等价几次。 - E2 对账:对账运行(origin=reconcile,第 3 波起由 ledger/reconcile 发起)发起、完成、失败各几次,对过账的答案几个,其中参数 经人核对过的、对照的指标由人选的各几次;相关口径的差异说明里兜底说明的占比;对账结果上「说明有用 / 看不懂」的反馈 (第 4 波,ledger/feedback:对账运行上的封存后事件 ledger.feedback,每人只算最后一次)各几个、有用的比率。 @@ -42,6 +44,8 @@ REPEAT_CONVERSATIONS = 2 ASPECT_GROUP = {"time": "time", "time_range": "time", "filter": "filter", "aggregate": "aggregate", "group_by": "aggregate", "join": "join", "tables": "join", "parse": "parse"} DIFF_GROUPS = ("time", "filter", "aggregate", "join", "other", "parse") +#: 差在哪里不算差异的条目:投影之后的「这次多查了 N 列,不影响这个数」 +NOT_DIFFS = ("projection",) STATUSES = ("proven", "composed", "related", "none", "skipped") #: 聚类最多取几个问题(同治理接口的上限) QUESTION_LIMIT = 500 @@ -98,6 +102,7 @@ def e1_of(contents: list[dict[str, Any]]) -> dict[str, Any]: status = Counter({s: 0 for s in STATUSES}) with_candidates = Counter({s: 0 for s in STATUSES}) via: Counter[str] = Counter() + projected = 0 unproven: Counter[str] = Counter() diffs = Counter({g: 0 for g in DIFF_GROUPS}) labels = Counter({k: 0 for k in ledger_labels.KINDS}) @@ -113,6 +118,7 @@ def e1_of(contents: list[dict[str, Any]]) -> dict[str, Any]: for cand in query.get("candidates") or []: if cand.get("status") == "proven": via[str(cand.get("via") or "parsed")] += 1 + projected += isinstance(cand.get("projection"), dict) continue if cand.get("status") == "skipped": continue @@ -123,7 +129,7 @@ def e1_of(contents: list[dict[str, Any]]) -> dict[str, Any]: else: unproven["参数配上了,但和口径不等价"] += 1 seen = {ASPECT_GROUP.get(str(item.get("aspect")), "other") - for item in (cand.get("diff") or {}).get("items") or []} + for item in (cand.get("diff") or {}).get("items") or [] if item.get("aspect") not in NOT_DIFFS} for group in seen: diffs[group] += 1 for report in (content.get("reports") or {}).values(): @@ -136,7 +142,8 @@ def e1_of(contents: list[dict[str, Any]]) -> dict[str, Any]: "runs": len(contents), "queries": queries, "status": dict(status), "with_candidates": candidates, "rates": {s: _ratio(with_candidates[s], candidates) for s in STATUSES if s != "none"}, - "proven_via": dict(via), "unproven_reasons": dict(unproven.most_common(20)), "diff_aspects": dict(diffs), + "proven_via": dict(via), "proven_projected": projected, "unproven_reasons": dict(unproven.most_common(20)), + "diff_aspects": dict(diffs), "labels": dict(labels), "whole_equivalent": whole, } diff --git a/backend/app/ledger/store.py b/backend/app/ledger/store.py index 6ccb7f39..abe1717c 100644 --- a/backend/app/ledger/store.py +++ b/backend/app/ledger/store.py @@ -31,7 +31,8 @@ FORMAT = "agentlab.caliber_match/1" #: 比对算法的版本:绑定、证明、标签的规则改了就加一,界面和统计据此分得清是哪一版算法比的。 #: 2(第 3 波):认出助手选用的口径(查询 composed、标签 how=composed),指标的标签记它取自哪几条查询(queries,认证队列 #: 认「被报告引用的查询」用) -ALGO = "2" +#: 3:整条比不等价时按报告引用的列投影再比(ledger/match 的 _projected),候选记 projection;没投影的照旧 +ALGO = "3" #: 触发方式(见模块说明)。certified(第 3 波):从问数据固化出的口径发布之后,对来源运行补比的(ledger/certify) TRIGGERS = ("sealed", "backfill", "on_demand", "certified") diff --git a/backend/app/ledger/view.py b/backend/app/ledger/view.py index ce88b960..6ea951d5 100644 --- a/backend/app/ledger/view.py +++ b/backend/app/ledger/view.py @@ -88,6 +88,9 @@ def _query_view(query: dict[str, Any], hider: _Hide, names: dict[str, str]) -> d item["how"] = cand["how"] if cand.get("columns") is not None: item["columns"] = cand["columns"] + if isinstance(cand.get("projection"), dict): + # 按引用的列投影之后比的(ledger/match 的 _projected):两边各留下了哪几列、去掉了几列(列名,不带值) + item["projection"] = cand["projection"] cands.append(item) out["candidates"] = cands return out diff --git a/backend/app/look/calibers.py b/backend/app/look/calibers.py index 3a5ea8f0..17cbc5c4 100644 --- a/backend/app/look/calibers.py +++ b/backend/app/look/calibers.py @@ -41,8 +41,8 @@ DETAIL_NOT_REUSABLE_PROMPT = " 不能复用:{reason}。请按表重新对" DETAIL_FIELDS_PROMPT = " 口径「{workflow}」v{version} 的入口字段:{fields}" DETAIL_FIELD_PROMPT = "{name}({bits}){desc}" DETAIL_NO_FIELDS_PROMPT = " 口径「{workflow}」v{version} 的入口没有字段" -DETAIL_COMPOSE_PROMPT = (" 能组合:选用写法 {op}(id 自己起,它就是口径卡的节点 id;inputs 按上面的入口字段填,没写的取缺省值;" - "metrics 写要用的指标 id)") +DETAIL_COMPOSE_PROMPT = (" 能组合:选用写法 {op}(card 原样照抄,是那一版图里口径卡的节点 id;id 自己起,生成的口径卡用它当节点 id;" + "inputs 按上面的入口字段填,没写的取缺省值;metrics 写要用的指标 id)") DETAIL_NOT_COMPOSABLE_PROMPT = " 不能组合:{reason}。这份口径只能作为参考,查库交给 agent" BRIEF_PROMPT = "· {name}({workflow} v{version}):字数超过上限,只列名字,要看指标式时点名再 look" MISSING_PROMPT = "没找到口径:{items}(只能查看已发布且未退役的版本里的口径卡;别拿名字相近的顶替)" diff --git a/backend/tests/test_ledger_certify.py b/backend/tests/test_ledger_certify.py index 4444064c..5ecbbbd5 100644 --- a/backend/tests/test_ledger_certify.py +++ b/backend/tests/test_ledger_certify.py @@ -270,3 +270,155 @@ async def test_freezing_needs_a_matched_successful_run(client, monkeypatch): row = await S.explore(monkeypatch, S.ELSEWHERE, text="一共几家店,没有引用。") res = await client.post("/api/copilot/from-run", json={"run_id": row.id, "freeze": True}) assert res.status_code == 409 and res.json()["detail"] == certify.NO_QUERIES + + +# -------------------------------------------------------------------------- +# 确定性重复按规范形分组:条件的先后不算,条件真不同的不算一组 +# -------------------------------------------------------------------------- + +#: 同一个形状的三种写法:条件换序、AND 两边换位、比较两边换位 +REORDERED = [ + "SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' AND paid_at >= '{s}' AND paid_at < '{e}'", + "SELECT SUM(amount) AS gmv FROM orders WHERE paid_at < '{e}' AND paid_at >= '{s}' AND status = 'paid'", + "SELECT SUM(amount) AS gmv FROM orders WHERE '{s}' <= paid_at AND ('paid' = status AND paid_at < '{e}')", +] + + +def test_conditions_in_another_order_are_the_same_shape_and_their_literals_line_up(): + from app.data.sql_semantics import sql_shape + + shapes = [sql_shape(sql.format(s=s, e=e), "sqlite") for sql, (s, e) in zip(REORDERED, WEEKS)] + assert all(sh is not None for sh in shapes) + assert len({sh.key for sh in shapes}) == 1 + # 按规范形里的先后对齐:同一个位置是同一个条件里的那个字面量——变了的是两个日期,'paid' 在每一条里都落在同一个位置 + assert len(certify.varying([sh.literals for sh in shapes])) == 2 + assert len({sh.literals.index("'paid'") for sh in shapes}) == 1 + starts = {sh.literals.index(f"'{s}'") for sh, (s, _) in zip(shapes, WEEKS)} + assert len(starts) == 1, "起始日期在每一条里都落在同一个位置" + + +@pytest.mark.parametrize("other", [ + "SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' AND created_at >= '2026-09-01' AND paid_at < '2026-09-08'", + "SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' OR paid_at >= '2026-09-01' AND paid_at < '2026-09-08'", + "SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' AND paid_at >= '2026-09-01'", + "SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' AND paid_at > '2026-09-01' AND paid_at < '2026-09-08'", + "SELECT SUM(amount) AS gmv FROM orders WHERE status = 1 AND paid_at >= '2026-09-01' AND paid_at < '2026-09-08'", + "SELECT SUM(amount) AS gmv FROM orders WHERE status IN ('paid', 'open') AND paid_at >= '2026-09-01' " + "AND paid_at < '2026-09-08'", + "SELECT COUNT(*) AS gmv FROM orders WHERE status = 'paid' AND paid_at >= '2026-09-01' AND paid_at < '2026-09-08'", +], ids=["other-column", "or", "missing-condition", "other-operator", "number-not-string", "in-list", "other-aggregate"]) +def test_really_different_conditions_are_different_shapes(other): + from app.data.sql_semantics import sql_shape + + base = sql_shape(REORDERED[0].format(s="2026-09-01", e="2026-09-08"), "sqlite") + found = sql_shape(other, "sqlite") + assert base is not None and found is not None and found.key != base.key + + +async def test_queue_groups_reordered_conditions_and_freezing_aligns_their_parameters(client, monkeypatch): + a, b = await conversation(), await conversation() + runs = [await ask(monkeypatch, conv, sql.format(s=s, e=e)) + for conv, sql, (s, e) in zip((a, a, b), REORDERED, WEEKS)] + # 条件真不同的(多筛了一个地区)不进这一组 + other = await ask(monkeypatch, b, "SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' AND region = 'east' " + "AND paid_at >= '2026-09-01' AND paid_at < '2026-09-08'") + [item] = shape_items((await client.get("/api/ledger/certify-queue")).json()) + assert item["runs"] == 3 and {t["run_id"] for t in item["turns"]} == {r.id for r in runs} + assert other.id not in {t["run_id"] for t in item["turns"]} + assert len(item["params"]) == 2 and all(p["distinct"] == 3 for p in item["params"]) and item["constants"] == 1 + + rep = item["representative"]["run_id"] + res = await client.post("/api/copilot/from-run", json={"run_id": rep, "freeze": True, "peers": item["peers"]}) + assert res.status_code == 200, res.text + made = res.json() + assert made["params"] == ["p1", "p2"], "条件换了序的几轮照样认得出两个日期是参数" + async with SessionLocal() as session: + wf = await session.get(Workflow, made["workflow_id"]) + sql = {n["id"]: n for n in wf.graph["nodes"]}["fetch"]["data"]["config"]["args"]["sql"] + assert "'paid'" in sql and "'{{ input.p1 }}'" in sql and "'{{ input.p2 }}'" in sql + + +async def test_a_not_a_caliber_recorded_by_the_old_text_id_still_counts(client, monkeypatch): + """以前「不是口径」按规范文本算的 id 记:分组改按规范形之后,组里任何一条的规范文本对得上就照样不进队。""" + await three_asks(monkeypatch) + [item] = shape_items((await client.get("/api/ledger/certify-queue")).json()) + old = certify.item_id("shape", f"{S.CURRENT['source']}\n{item['shape']}") + assert old != item["id"] + res = await client.post("/api/ledger/certify-queue/dismiss", json={"item_id": old, "kind": "shape"}) + assert res.status_code == 200 + assert shape_items((await client.get("/api/ledger/certify-queue")).json()) == [] + + +# -------------------------------------------------------------------------- +# 问题聚类的 id 由成员定;「不是口径」按成员记 +# -------------------------------------------------------------------------- + + +async def test_cluster_ids_come_from_the_members_and_do_not_change_between_reads(): + from datetime import datetime, timezone + + from app.chat.questions import Question, QuestionCluster, cluster_questions + + at = datetime(2026, 10, 1, tzinfo=timezone.utc) + # 同一时刻问的三句(时间相同,库里读出来的先后不定):两次读出来先后不同,种子就不同,类的 id 仍然一样 + texts = ["仓库乙本月出库多少件", "仓库乙本月出库多少件?", "仓库乙本月出库了多少件"] + questions = [Question(text=t, at=at, turn_id=f"t-{i}") for i, t in enumerate(texts)] + first = await cluster_questions(questions) + again = await cluster_questions(list(reversed(questions))) + assert len(first) == 1 and len(again) == 1 and first[0].members[0].text != again[0].members[0].text + assert [c.id for c in first] == [c.id for c in again] + assert first[0].keys == ["turn:t-0", "turn:t-1", "turn:t-2"] + assert QuestionCluster(members=list(reversed(questions))).id == first[0].id + # 成员不同,id 就不同 + assert QuestionCluster(members=questions[:2]).id != first[0].id + + +async def test_not_a_caliber_on_a_cluster_survives_a_new_member_and_old_records_still_count(client, monkeypatch): + import uuid + + from app.auth.audit import record + + text = f"仓库甲本周入库了多少件({uuid.uuid4().hex[:6]})" + a, b = await conversation(), await conversation() + asked = {(await ask(monkeypatch, conv, weekly(*WEEKS[0]), question=text)).id for conv in (a, a, b)} + + async def mine() -> list[dict]: + body = (await client.get("/api/ledger/certify-queue")).json() + return [i for i in body["items"] if i["kind"] == "cluster" and asked & {t["run_id"] for t in i["turns"]}] + + [item] = await mine() + [again] = await mine() + assert item["id"] == again["id"], "同一批问题两次读取,类的 id 一样" + # 读不懂的老记录照实忽略:不报错、不把这一类藏起来 + async with SessionLocal() as session: + await record(session, certify.DISMISS_ACTION, certify.SUBJECT_KIND, "cluster:0000000000000000", + data={"kind": "cluster", "members": "看不懂"}) + await session.commit() + assert [i["id"] for i in await mine()] == [item["id"]] + + res = await client.post("/api/ledger/certify-queue/dismiss", json={"item_id": item["id"], "kind": "cluster"}) + assert res.status_code == 200 + assert await mine() == [] + # 再换个说法问一句:成员多了一个、种子换成了新的这句,类的 id 变了;过半的成员是点「不是口径」时记下的,照样不进队 + newer = await ask(monkeypatch, b, weekly(*WEEKS[1]), question=text + "?") + asked.add(newer.id) + assert await mine() == [] + + +async def test_a_not_a_caliber_recorded_by_the_old_seed_text_id_still_counts(client, monkeypatch): + import uuid + + from app.auth.audit import record + + text = f"仓库丙上周退货多少件({uuid.uuid4().hex[:6]})" + a, b = await conversation(), await conversation() + for conv in (a, a, b): + await ask(monkeypatch, conv, weekly(*WEEKS[0]), question=text) + body = (await client.get("/api/ledger/certify-queue")).json() + assert [i for i in body["items"] if i["kind"] == "cluster" and i["question"] == text] + async with SessionLocal() as session: + await record(session, certify.DISMISS_ACTION, certify.SUBJECT_KIND, certify.item_id("cluster", text), + data={"kind": "cluster"}) + await session.commit() + body = (await client.get("/api/ledger/certify-queue")).json() + assert not [i for i in body["items"] if i["kind"] == "cluster" and i["question"] == text] diff --git a/backend/tests/test_ledger_compose.py b/backend/tests/test_ledger_compose.py index 0c3405b0..6d50941e 100644 --- a/backend/tests/test_ledger_compose.py +++ b/backend/tests/test_ledger_compose.py @@ -253,7 +253,7 @@ async def test_an_answer_built_with_use_caliber_is_labelled_composed(client, mon await ledger_match.drain() recorded = await store.latest(row.id) content = recorded.content - assert content["algo"] == store.ALGO == "2" + assert content["algo"] == store.ALGO == "3" [query] = content["queries"] assert query["status"] == "composed" and query["node_id"] == "c1_fetch" [cand] = query["candidates"] @@ -292,3 +292,33 @@ async def test_users_do_not_get_calibers_in_the_answer_look(monkeypatch): with pytest.raises(RuntimeError): await copilot.answer_ops(GenerateIn(instruction="x", intent="build")).__anext__() assert seen["kinds"] == ("table", "capability") + + +# -------------------------------------------------------------------------- +# card 填什么:协议写清两个 id,写错了交回的原因能一次改对(校验不放宽) +# -------------------------------------------------------------------------- + + +def test_the_protocol_says_card_is_the_cards_node_id_and_id_is_the_name_you_give(): + text = copilot_context.USE_CALIBER_PROMPT + assert '"card":"card"' in text and '"card":"口径卡节点 id"' not in text + assert "card 填那一版工作流图里口径卡的节点 id" in text and "不是你起的名字" in text + assert "id 是你给这次选用起的名字" in text and "workflow_id、version、card 原样照抄" in text + + +async def test_writing_your_own_id_as_the_card_is_refused_with_what_to_write_instead(): + wf = await S.publish() + pool = await composer() + with pytest.raises(compose.ComposeRefused) as e: + pool.compose(use(wf, card="c1"), taken={"start"}) + message = e.value.message + assert "没有口径卡「c1」" in message and "card 要填那一版工作流图里口径卡的节点 id" in message + assert "可填的有 「card」(口径卡「card」)" in message, message + assert "「c1」是你给这次选用起的 id,不是口径卡的节点 id" in message + # 别的写错(不是自己起的 id)不多那一句 + with pytest.raises(compose.ComposeRefused) as other: + pool.compose(use(wf, card="other"), taken={"start"}) + assert "是你给这次选用起的 id" not in other.value.message + # 校验不放宽:照着交回的原因改成节点 id 就过 + made = pool.compose(use(wf, card="card"), taken={"start"}) + assert made.op_id == "c1" diff --git a/backend/tests/test_ledger_projection.py b/backend/tests/test_ledger_projection.py new file mode 100644 index 00000000..869dff52 --- /dev/null +++ b/backend/tests/test_ledger_projection.py @@ -0,0 +1,231 @@ +"""认口径时按引用的列投影(app/ledger/match.py 的 _projected,algo 3)。 + +真实助手的 SQL 总会多选几列(订单数、日期常量、MIN / MAX……),整条比永远不等价,「上周的销售额是多少」从不自动出具。 +整条比不等价时,答案一侧按报告引用的列、口径一侧按口径卡上指标读的列投影,再交给现有的绑定和判等价。这里守住: +- 多选一列、多几个聚合、日期常量列的,按引用的那一列证明等价;口径一侧多选了列的同样认得出;留下两列先后不同的另试调换; +- 不错给信任:多一个分组列、DISTINCT、按别的列排序再取前几行、HAVING 用到多出来的聚合的,一律仍然证明不了,结果和 + 不投影时一字不差; +- 没投影的(报告没引用、两边都不用投影、投影不了)逐对和以前一字不差; +- 没证明的,差在哪按投影之后说:先说「这次多查了 N 列,不影响这个数」,再照常说条件上的不同; +- 跑一次真的探索运行:报告引用销售额那一格,整题等价;E1 照实数出几份是投影之后才证明的。 +""" +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from app.engine.runner import run_manager +from app.ledger import match as ledger_match +from app.ledger import stats as ledger_stats +from app.ledger import store +from tests import ledger_samples as S + +WINDOW = "status = 'paid' AND paid_at >= '2026-09-01' AND paid_at < '2026-09-08'" + + +def caliber(sql: str = S.TEMPLATE, metrics: list[dict] | None = None) -> ledger_match.Caliber: + graph = S.caliber_graph(sql) + if metrics is not None: + graph["nodes"][2]["data"]["config"]["metrics"] = metrics + found = ledger_match.caliber_of("wf", SimpleNamespace(graph=graph, version=1, level="governed", graph_hash="h")) + assert found is not None + return found + + +def query(sql: str, columns: list[str], artifact: str = "q1") -> ledger_match.Query: + return ledger_match.Query(artifact, "q", S.CURRENT["source"], sql, columns, frozenset({"orders"}), "sqlite") + + +def compare(sql: str, columns: list[str], cited: set[str] | None, cal: ledger_match.Caliber | None = None) -> dict: + [row] = ledger_match.compare_queries([query(sql, columns)], [cal or caliber()], + referenced={"q1": cited} if cited is not None else None) + return row + + +# -------------------------------------------------------------------------- +# 认得出 +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("sql, columns", [ + (f"SELECT SUM(amount) AS 销售额, COUNT(*) AS 单数 FROM orders WHERE {WINDOW}", ["销售额", "单数"]), + (f"SELECT COUNT(*) AS 单数, MIN(paid_at) AS 起, MAX(paid_at) AS 止, SUM(amount) AS 销售额, " + f"AVG(amount) AS 客单价 FROM orders WHERE {WINDOW}", ["单数", "起", "止", "销售额", "客单价"]), + (f"SELECT '2026-09-01' AS 周一, '2026-09-07' AS 周日, SUM(amount) AS 销售额 FROM orders WHERE {WINDOW}", + ["周一", "周日", "销售额"]), +], ids=["extra-count", "extra-aggregates", "date-constants"]) +def test_extra_columns_are_projected_away_and_the_cited_one_is_proven(sql, columns): + row = compare(sql, columns, {"销售额"}) + assert row["status"] == "proven", row + [cand] = row["candidates"] + assert cand["status"] == "proven" and cand["binding"] == {"start": "2026-09-01", "end": "2026-09-08"} + assert cand["columns"] == [{"index": 0, "ours": "销售额", "caliber": "gmv"}] + assert cand["projection"] == {"ours": ["销售额"], "dropped": len(columns) - 1, "caliber": ["gmv"], + "caliber_dropped": 0} + # 不投影(报告没说引用了哪一列)时整条比,照旧证明不了 + assert compare(sql, columns, None)["status"] == "related" + + +def test_the_caliber_side_is_projected_to_the_columns_its_metrics_read(): + two = S.TEMPLATE.replace("SUM(amount) AS gmv", "SUM(amount) AS gmv, COUNT(*) AS n") + cal = caliber(two, [{"id": "gmv", "name": "销售额", "expression": "cell(nodes.fetch, 0, 'gmv')"}, + {"id": "n", "name": "单数", "expression": "cell(nodes.fetch, 0, 'n')"}]) + row = compare(f"SELECT SUM(amount) AS 销售额 FROM orders WHERE {WINDOW}", ["销售额"], {"销售额"}, cal) + assert row["status"] == "proven" + [cand] = row["candidates"] + assert cand["columns"] == [{"index": 0, "ours": "销售额", "caliber": "gmv"}] + assert cand["projection"] == {"ours": ["销售额"], "dropped": 0, "caliber": ["gmv"], "caliber_dropped": 1} + # 两列都引用、先后和口径的不同:调换之后按位置对上 + row = compare(f"SELECT COUNT(*) AS 单数, SUM(amount) AS 销售额, MAX(amount) AS 最大 FROM orders WHERE {WINDOW}", + ["单数", "销售额", "最大"], {"单数", "销售额"}, cal) + assert row["status"] == "proven" + [cand] = row["candidates"] + assert [(c["ours"], c["caliber"]) for c in cand["columns"]] == [("销售额", "gmv"), ("单数", "n")] + + +# -------------------------------------------------------------------------- +# 不错给信任 +# -------------------------------------------------------------------------- + + +GROUPED = S.TEMPLATE.replace("SUM(amount) AS gmv", "DISTINCT amount AS gmv") + + +@pytest.mark.parametrize("sql, columns, cited, cal_sql", [ + # 多一个分组列:答案是每个地区一行,口径只有一行 + (f"SELECT region, SUM(amount) AS 销售额 FROM orders WHERE {WINDOW} GROUP BY region", ["region", "销售额"], + {"销售额"}, S.TEMPLATE), + # DISTINCT 按整行去重:去掉 region 之后行数就变了(口径按金额去重,投影了就会错认) + (f"SELECT DISTINCT region, amount AS 金额 FROM orders WHERE {WINDOW}", ["region", "金额"], {"金额"}, GROUPED), + # 按别的列排序再取前几行 + (f"SELECT SUM(amount) AS 销售额, region FROM orders WHERE {WINDOW} GROUP BY region ORDER BY region LIMIT 1", + ["销售额", "region"], {"销售额"}, S.TEMPLATE), + (f"SELECT amount AS 金额, paid_at FROM orders WHERE {WINDOW} ORDER BY paid_at DESC LIMIT 1", ["金额", "paid_at"], + {"金额"}, S.TEMPLATE.replace("SUM(amount) AS gmv", "amount AS gmv") + " ORDER BY amount DESC LIMIT 1"), + # HAVING 用到多出来的聚合(按别名、按同样的写法) + (f"SELECT SUM(amount) AS 销售额, COUNT(*) AS 单数 FROM orders WHERE {WINDOW} HAVING 单数 > 0", ["销售额", "单数"], + {"销售额"}, S.TEMPLATE), + (f"SELECT SUM(amount) AS 销售额, COUNT(*) AS 单数 FROM orders WHERE {WINDOW} HAVING COUNT(*) > 0", + ["销售额", "单数"], {"销售额"}, S.TEMPLATE), +], ids=["extra-group-by", "distinct", "group-order-limit", "order-other-column-limit", "having-alias", + "having-same-aggregate"]) +def test_projection_never_proves_what_the_whole_sql_does_not_say(sql, columns, cited, cal_sql): + expression = "cell(nodes.fetch, 0, 'gmv')" + cal = caliber(cal_sql, [{"id": "gmv", "name": "销售额", "expression": expression}]) + row = compare(sql, columns, cited, cal) + assert row["status"] != "proven", row + assert all("projection" not in c for c in row["candidates"]), "这几种都不该投影" + # 不投影的:和没给引用列时一字不差 + assert row == compare(sql, columns, None, cal) + + +def test_without_a_projection_every_pair_is_exactly_the_old_result(): + """报告引用了全部的列、口径也不用投影的,整条比证明了的,投影不了的:逐对和不给引用列时一字不差。""" + cases = [ + (S.EQUIVALENT, ["销售额"], {"销售额"}), # 整条比就证明了 + (S.WITH_REFUNDS, ["gmv"], {"gmv"}), # 两边都只有这一列:没有可投影的 + (f"SELECT SUM(amount) AS gmv, COUNT(*) AS n FROM orders WHERE {WINDOW}", ["gmv", "n"], {"gmv", "n"}), + (f"SELECT DISTINCT SUM(amount) AS gmv, COUNT(*) AS n FROM orders WHERE {WINDOW}", ["gmv", "n"], {"gmv"}), + (f"SELECT SUM(amount) AS gmv, COUNT(*) AS n FROM orders WHERE {WINDOW}", ["gmv"], {"gmv"}), # 列数对不上 + ] + for sql, columns, cited in cases: + assert compare(sql, columns, cited) == compare(sql, columns, None), sql + + +# -------------------------------------------------------------------------- +# 说得清:没证明的按投影之后说 +# -------------------------------------------------------------------------- + + +def test_an_unproven_projection_says_the_extra_columns_first_then_the_real_difference(): + sql = ("SELECT SUM(amount) AS 销售额, COUNT(*) AS 单数, MIN(paid_at) AS 起, MAX(paid_at) AS 止 FROM orders " + "WHERE paid_at >= '2026-09-01' AND paid_at < '2026-09-08'") + row = compare(sql, ["销售额", "单数", "起", "止"], {"销售额"}) + assert row["status"] == "related" + [cand] = row["candidates"] + items = cand["diff"]["items"] + assert items[0] == {"aspect": "projection", "a": None, "b": None, "text": "这次多查了 3 列,不影响这个数"} + assert items[1]["aspect"] == "filter" and "status = 'paid'" in items[1]["text"] + assert cand["diff"]["fallback"] is False + assert cand["projection"]["dropped"] == 3 + # 不投影时整段退回兜底(真实验收里 E2 的兜底比例 45% 就是这么来的) + [old] = compare(sql, ["销售额", "单数", "起", "止"], None)["candidates"] + assert old["diff"]["fallback"] is True + + +def test_e1_does_not_count_the_extra_columns_note_as_a_difference_and_counts_projected_proofs(): + proven = compare(f"SELECT SUM(amount) AS 销售额, COUNT(*) AS 单数 FROM orders WHERE {WINDOW}", + ["销售额", "单数"], {"销售额"}) + related = compare("SELECT SUM(amount) AS 销售额, COUNT(*) AS 单数 FROM orders WHERE paid_at >= '2026-09-01' " + "AND paid_at < '2026-09-08'", ["销售额", "单数"], {"销售额"}) + e1 = ledger_stats.e1_of([{"queries": [proven, related], "reports": {}, "whole": {}}]) + assert e1["status"]["proven"] == 1 and e1["proven_projected"] == 1 + assert e1["diff_aspects"]["filter"] == 1 and e1["diff_aspects"]["other"] == 0 + + +# -------------------------------------------------------------------------- +# 真的跑一遍 +# -------------------------------------------------------------------------- + + +@pytest.fixture +async def engine_up(tmp_path): + await run_manager.setup() + await S.fresh_source(tmp_path / "shop.db") + yield + await ledger_match.drain() + await run_manager.shutdown() + + +async def test_last_weeks_sales_with_extra_columns_is_whole_equivalent(engine_up, monkeypatch): + wf = await S.publish() + sql = ("SELECT '2026-09-01' AS 周一, SUM(amount) AS 销售额, COUNT(*) AS 单数, MAX(paid_at) AS 最后一单 " + f"FROM orders WHERE {WINDOW}") + row = await S.explore(monkeypatch, sql, text="上周销售额 [[v:Q1.r0.销售额]],共 [[v:Q1.r0.单数]] 单。") + recorded = await store.latest(row.id) + content = recorded.content + assert content["algo"] == "3" + [q] = content["queries"] + assert q["status"] == "related", "销售额、单数两列都被引用,口径只有销售额:投影之后列数对不上,照旧不等价" + + row = await S.explore(monkeypatch, sql, text="上周销售额 [[v:Q1.r0.销售额]]。") + content = (await store.latest(row.id)).content + [q] = content["queries"] + assert q["status"] == "proven" + # 评测逐题比 SQL 时拿同一份「引用了哪几列」(referenced_of),和账本一样投影 + from app.api.evidence import _sealed + + assert ledger_match.referenced_of(await _sealed(row.id)) == {q["artifact"]: {"销售额"}} + [cand] = q["candidates"] + assert cand["projection"]["dropped"] == 3 and cand["columns"] == [{"index": 0, "ours": "销售额", "caliber": "gmv"}] + [labels] = [r["labels"] for r in content["reports"].values()] + [label] = labels.values() + assert label["kind"] == "caliber" and label["column"] == "gmv" and label["caliber"] == f"{wf}@1" + assert content["whole"]["equivalent"] is True and content["whole"]["binding"] == {"start": "2026-09-01", + "end": "2026-09-08"} + + +async def test_reconcile_pairs_the_columns_left_after_the_projection(engine_up, monkeypatch): + """含退款、又多选了几列的答案:对账时按投影之后两边留下的列配(这次的销售额 → 口径卡的 gmv),不用人选。""" + from httpx import ASGITransport, AsyncClient + + from app.main import app + + wf = await S.publish() + sql = ("SELECT COUNT(*) AS 单数, SUM(amount) AS 销售额, MAX(paid_at) AS 最后一单 FROM orders " + "WHERE paid_at >= '2026-09-01' AND paid_at < '2026-09-08'") + row = await S.explore(monkeypatch, sql, text="上周销售额 [[v:Q1.r0.销售额]]。") + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + body = (await client.get(f"/api/runs/{row.id}/ledger")).json() + [q] = body["queries"] + [cand] = q["candidates"] + assert cand["projection"]["ours"] == ["销售额"] and cand["projection"]["caliber"] == ["gmv"] + assert cand["diff"]["items"][0]["text"] == "这次多查了 2 列,不影响这个数" + res = await client.post(f"/api/ledger/answers/{row.id}/reconcile", json={ + "query": q["artifact"], "workflow_id": wf, "version": 1, "confirm": True, + "inputs": {"start": "2026-09-01", "end": "2026-09-08"}}) + assert res.status_code == 201, res.text + done = await S.wait(res.json()["run_id"]) + assert done.status == "succeeded", done.error + assert done.origin["pairs"] == [{"ours": "销售额", "metric": "gmv"}] diff --git a/backend/tests/test_metric_copy_lineage.py b/backend/tests/test_metric_copy_lineage.py new file mode 100644 index 00000000..33b683a4 --- /dev/null +++ b/backend/tests/test_metric_copy_lineage.py @@ -0,0 +1,196 @@ +"""转抄另一张口径卡的指标时,出处链跨卡往下追(engine/lineage.metric_copy、engine/nodes/metrics._upstream、 +api/evidence._upstream_steps)。 + +对账卡的「口径」、召回卡的「旧版 / 新版」都写成 nodes.<口径卡>.metrics[i].value。以前出处停在「来自口径卡指标」,对账运行、 +召回运行一律降档出具(「输入没追到原始数据」);口径卡自己的指标明明追得到取数查询。现在: +- 运行时接上那个指标自己的出处(upstream):那个指标的输入都追到了原始数据,这一项才算追到,判档按追到底的结果; + 那个指标不完整、所依据的查询没通过 SQL 检查的,转抄出来的同样不完整、存疑;转抄的转抄一层层往下追,到了层数上限 + 就算没追到; +- 证据接口:转抄的输入后面接一步 upstream(那张卡的那个指标、它的输入),一直到口径的取数查询(查询步骤照常给); +- 真跑对账、召回:完整出具,三个数都点得到口径的取数查询。 +数据是虚构店铺库。 +""" +from __future__ import annotations + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.engine.lineage import MAX_COPY_DEPTH, traced, untraced +from app.engine.runner import run_manager +from app.ledger import match as ledger_match +from app.ledger import recall +from tests import ledger_samples as S + +WINDOW = "status = 'paid' AND paid_at >= '2026-09-01' AND paid_at < '2026-09-08'" + + +@pytest.fixture(autouse=True) +async def engine_up(tmp_path): + await run_manager.setup() + await S.fresh_source(tmp_path / "shop.db") + yield + await ledger_match.drain() + await recall.drain() + await run_manager.shutdown() + + +@pytest.fixture +async def client(): + from app.main import app + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +def metric(mid: str, expression: str, name: str = "") -> dict: + return {"id": mid, "name": name or mid, "unit": "元", "decimals": 2, "expression": expression} + + +async def run_chain(*middle: dict, contract_from: str) -> object: + graph = S.chain(S.node("start", "input"), *middle, + S.node("out", "output", fields=[{"name": "数", "value": f"{{{{ nodes.{contract_from}.metrics[0].value }}}}"}], + contract={"metrics_from": contract_from})) + run = await run_manager.start(graph=graph, input_payload={}) + row = await S.wait(run.id) + assert row.status == "succeeded", row.error + return row + + +async def card(run_id: str, node_id: str) -> dict: + from app.api.evidence import _sealed, _sealed_cards + + sealed = await _sealed(run_id) + [(_, found)] = [(a, c) for n, a, c in _sealed_cards(sealed) if n == node_id] + return {m["id"]: m for m in found["metrics"]} + + +# -------------------------------------------------------------------------- +# 运行时 +# -------------------------------------------------------------------------- + + +async def test_a_copied_metric_traces_through_the_card_and_issues_formally(): + fetch = S.node("fetch", "tool", tool=S.tool(), args={"sql": f"SELECT SUM(amount) AS gmv FROM orders WHERE {WINDOW}"}) + a = S.node("a", "metrics", caliber="口径", caliber_version="v1", metrics=[metric("gmv", "cell(nodes.fetch, 0, 'gmv')")]) + b = S.node("b", "metrics", caliber="转抄", caliber_version="v1", metrics=[ + metric("cal", "nodes.a.metrics[0].value"), metric("half", "(nodes.a.metrics[0].value) / 2")]) + c = S.node("c", "metrics", caliber="再转抄", caliber_version="v1", metrics=[metric("again", "nodes.b.metrics[0].value")]) + row = await run_chain(fetch, a, b, c, contract_from="c") + first, second = await card(row.id, "b"), await card(row.id, "c") + [item] = first["cal"]["inputs"] + assert item["via"] == "metric" and item["node_id"] == "a" + assert item["upstream"]["traced"] is True and item["upstream"]["depth"] == 1 and item["upstream"]["metric"] == "gmv" + assert traced(item) and untraced(first["half"]["inputs"]) == [] + [deep] = second["again"]["inputs"] + assert deep["upstream"]["traced"] is True and deep["upstream"]["depth"] == 2 + issuance = row.output["_issuance"] + assert issuance["tier"] == "formal", issuance["gaps"] + + +async def test_copying_a_metric_that_is_not_traced_still_stops_there(): + calc = S.node("calc", "transform", mode="json", template='{"v": 5}', assign_to="calc") + a = S.node("a", "metrics", caliber="口径", caliber_version="v1", metrics=[metric("five", "vars.calc.v")]) + b = S.node("b", "metrics", caliber="转抄", caliber_version="v1", metrics=[metric("cal", "nodes.a.metrics[0].value")]) + row = await run_chain(calc, a, b, contract_from="b") + [item] = (await card(row.id, "b"))["cal"]["inputs"] + assert item["upstream"]["traced"] is False and not traced(item) + issuance = row.output["_issuance"] + assert issuance["tier"] == "degraded" + assert any("指标「cal」的输入没追到原始数据" in g for g in issuance["gaps"]), issuance["gaps"] + + +async def test_an_incomplete_upstream_metric_makes_the_copy_incomplete_too(): + fetch = S.node("fetch", "tool", tool=S.tool(), args={"sql": "SELECT id, amount FROM orders ORDER BY id", "limit": 2}) + parse = S.node("parse", "transform", mode="json", template="{{ nodes.fetch }}", assign_to="rows") + a = S.node("a", "metrics", caliber="口径", caliber_version="v1", metrics=[metric("n", "len(vars.rows.rows)")]) + b = S.node("b", "metrics", caliber="转抄", caliber_version="v1", metrics=[metric("cal", "nodes.a.metrics[0].value")]) + row = await run_chain(fetch, parse, a, b, contract_from="b") + upstream, copied = (await card(row.id, "a"))["n"], (await card(row.id, "b"))["cal"] + assert upstream["incomplete"] is True + assert copied["incomplete"] is True and copied["truncated_sources"] == upstream["truncated_sources"] + assert copied["incomplete_reason"] == upstream["incomplete_reason"] + issuance = row.output["_issuance"] + assert issuance["tier"] == "degraded" + assert any(g.startswith("指标「cal」基于被截断的查询结果计算") for g in issuance["gaps"]), issuance["gaps"] + + +def test_copies_of_copies_stop_at_the_depth_limit(): + from app.engine.nodes.metrics import _upstream + + def card_with(depth: int) -> dict: + inputs = [{"path": "nodes.x.metrics[0].value", "value": 1, "via": "metric", "node_id": "x", + "upstream": {"metric": "m", "artifact": "a" * 64, "depth": depth, "traced": True}}] + return {"kind": "metric_set", "artifact": "b" * 64, "metrics": [{"id": "m", "value": 1, "inputs": inputs}]} + + ok = _upstream("nodes.y.metrics[0].value", {"nodes": {"y": card_with(MAX_COPY_DEPTH - 1)}}) + assert ok is not None and ok["traced"] is True and ok["depth"] == MAX_COPY_DEPTH + too_deep = _upstream("nodes.y.metrics[0].value", {"nodes": {"y": card_with(MAX_COPY_DEPTH)}}) + assert too_deep is not None and too_deep["traced"] is False and too_deep["depth"] == MAX_COPY_DEPTH + 1 + # 整张卡、清单文字、指标的名称这类不是转抄一个指标的值:不往下追 + assert _upstream("nodes.y.metrics", {"nodes": {"y": card_with(0)}}) is None + assert _upstream("nodes.y.metrics[0].name", {"nodes": {"y": card_with(0)}}) is None + + +# -------------------------------------------------------------------------- +# 真跑对账、召回:完整出具,证据面板一路点到口径的取数查询 +# -------------------------------------------------------------------------- + + +def _steps(chain: list[dict], kind: str) -> list[dict]: + return [s for s in chain if s["step"] == kind] + + +async def test_a_reconcile_run_traces_the_caliber_side_down_to_its_fetch(client, monkeypatch): + wf = await S.publish() + row = await S.explore(monkeypatch, S.WITH_REFUNDS) + body = (await client.get(f"/api/runs/{row.id}/ledger")).json() + [action] = [a for a in body["actions"] if a["kind"] == "reconcile"] + res = await client.post(f"/api/ledger/answers/{row.id}/reconcile", json={ + "query": action["query"], "workflow_id": wf, "version": 1, "confirm": True, + "inputs": {"start": "2026-09-01", "end": "2026-09-08"}}) + assert res.status_code == 201, res.text + done = await S.wait(res.json()["run_id"]) + assert done.status == "succeeded", done.error + issuance = done.output["_issuance"] + assert issuance["tier"] == "formal", issuance + metrics = await card(done.id, "reconcile") + [cal] = metrics["cal_0"]["inputs"] + assert cal["node_id"] == "card" and cal["upstream"]["traced"] is True + + for mid in ("cal_0", "diff_0"): + res = await client.get(f"/api/runs/{done.id}/evidence/metrics/reconcile/{mid}") + assert res.status_code == 200, res.text + chain = res.json()["chain"] + assert chain[0]["untraced"] == [], "转抄的口径那一项跨卡追到了原始数据" + [up] = _steps(chain, "upstream") + assert up["of"] == "nodes.card.metrics[0].value" and up["node_id"] == "card" and up["metric"] == "gmv" + assert up["traced"] is True and up["sealed"] is True and up["hash_ok"] is True and up["value"] == 170.5 + [read] = up["inputs"] + assert read["path"] == "cell(nodes.fetch, 0, 'gmv')" and read["via"] == "tool_cell" + # 口径的取数查询的那一步照常给(点得到) + fetches = [q for q in _steps(chain, "query") if q.get("artifact") == read["artifact"]] + assert len(fetches) == 1 and fetches[0]["node_id"] == "fetch" + # 「这次」那一项不是转抄的,没有 upstream 这一步 + ours = (await client.get(f"/api/runs/{done.id}/evidence/metrics/reconcile/ours_0")).json()["chain"] + assert _steps(ours, "upstream") == [] + + +async def test_a_recall_run_traces_both_versions_down_to_their_fetches(client, monkeypatch): + from tests.test_ledger_recall import WITH_REFUNDED, answer, ledger_settings, publish_v2, run_recall + + await ledger_settings(auto_issue=False, issue_daily_limit=100_000) + wf = await S.publish() + await answer(monkeypatch) + await publish_v2(wf, WITH_REFUNDED) + [done] = await run_recall(wf) + assert done.status == "succeeded", done.error + assert done.output["_issuance"]["tier"] == "formal", done.output["_issuance"] + res = await client.get(f"/api/runs/{done.id}/evidence/metrics/recall/diff_0") + assert res.status_code == 200, res.text + chain = res.json()["chain"] + ups = {u["node_id"]: u for u in _steps(chain, "upstream")} + assert set(ups) == {"old_card", "new_card"} + assert all(u["traced"] for u in ups.values()) + reads = {u["node_id"]: u["inputs"][0]["artifact"] for u in ups.values()} + queried = {q["node_id"] for q in _steps(chain, "query") if q.get("artifact") in reads.values()} + assert queried == {"old_fetch", "new_fetch"} diff --git a/backend/tests/test_sql_left_on_times.py b/backend/tests/test_sql_left_on_times.py new file mode 100644 index 00000000..93b5617c --- /dev/null +++ b/backend/tests/test_sql_left_on_times.py @@ -0,0 +1,82 @@ +"""日期条件写在 LEFT JOIN 的 ON 里时的差异说明(data/sql_semantics.py 的 _Explainer._left_on_times)。 + +以前两边来源一样、只差在一个 LEFT JOIN 的 ON 里的日期条件时,整段退回「两条 SQL 的写法不同,未能归纳成具体差异」。现在 +说得准的照实说(aspect 是 join:说的是关联条件,不会被当成业务日期写进数据目录),说不准的照实兜底。只改说明:判等价的 +结论一个不变;说出来的不同在虚构库上确实让结果不一样。 +""" +from __future__ import annotations + +import pytest + +from app.data.sql_semantics import explain_sql_diff +from tests.fixtures import sql_pairs + +SCHEMA = sql_pairs.schema_cache() +BASE = "SELECT u.city, SUM(o.amount) AS s FROM users u LEFT JOIN orders o ON o.user_id = u.id{on} GROUP BY u.city" + +SAID = [ + # 一边多一个日期条件 + (BASE.format(on=" AND o.paid_at >= '2026-09-10'"), BASE.format(on=""), + "这次用 LEFT JOIN 关联 orders 表时只关联 orders.paid_at >= '2026-09-10' 的行,口径没有这个条件"), + (BASE.format(on=""), BASE.format(on=" AND o.created_at < '2026-09-20'"), + "口径用 LEFT JOIN 关联 orders 表时只关联 orders.created_at < '2026-09-20' 的行,这次没有这个条件"), + # 按不同的时间列筛 + (BASE.format(on=" AND o.paid_at >= '2026-09-10'"), BASE.format(on=" AND o.created_at >= '2026-09-10'"), + "这次用 LEFT JOIN 关联 orders 表时按 paid_at 筛时间,口径按 created_at"), + # 同一列、范围不同 + (BASE.format(on=" AND o.paid_at >= '2026-09-10' AND o.paid_at < '2026-10-01'"), + BASE.format(on=" AND o.paid_at >= '2026-09-01' AND o.paid_at < '2026-10-01'"), + "这次用 LEFT JOIN 关联 orders 表时对 paid_at 的条件是 orders.paid_at >= '2026-09-10' AND orders.paid_at < " + "'2026-10-01',口径是 orders.paid_at >= '2026-09-01' AND orders.paid_at < '2026-10-01'"), +] + + +@pytest.fixture(scope="module") +def databases(): + conns = sql_pairs.build_databases() + yield conns + for c in conns: + c.close() + + +@pytest.mark.parametrize("a, b, text", SAID, ids=["extra-on-ours", "extra-on-caliber", "other-column", "range"]) +@pytest.mark.parametrize("schema", [None, SCHEMA], ids=["bare", "schema"]) +def test_date_conditions_in_a_left_join_on_are_said(a, b, text, schema, databases): + diff = explain_sql_diff(a, b, "sqlite", schema=schema, labels=("这次", "口径")) + assert diff.equivalent is False and diff.fallback is False + assert [(i.aspect, i.text) for i in diff.items] == [("join", text)] + # 说出来的不同是真的:在虚构库的某组数据上两条结果确实不一样 + assert any(sql_pairs.run(c, a) != sql_pairs.run(c, b) for c in databases) + + +@pytest.mark.parametrize("a, b", [ + # 套着日期函数,按取值认不准两边是不是一样:照实兜底 + (BASE.format(on=" AND DATE(o.paid_at) >= '2026-09-10' AND DATE(o.paid_at) <= '2026-09-30'"), + BASE.format(on=" AND o.paid_at >= '2026-09-10' AND o.paid_at < '2026-10-01'")), + # 可空一侧在 WHERE 里用到了(反连接):说的就不是时间窗口了 + ("SELECT u.city, COUNT(*) AS n FROM users u LEFT JOIN orders o ON o.user_id = u.id AND o.paid_at >= '2026-09-10' " + "WHERE o.id IS NULL GROUP BY u.city", + "SELECT u.city, COUNT(*) AS n FROM users u LEFT JOIN orders o ON o.user_id = u.id WHERE o.id IS NULL " + "GROUP BY u.city"), + # 结果里没用到可空一侧的列:ON 里的不同只影响行会不会重复,说不准 + ("SELECT u.city, COUNT(*) AS n FROM users u LEFT JOIN orders o ON o.user_id = u.id AND o.paid_at >= '2026-09-10' " + "GROUP BY u.city", + "SELECT u.city, COUNT(*) AS n FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.city"), + # ON 里多出来的不是时间条件 + (BASE.format(on=" AND o.status = 'paid'"), BASE.format(on="")), +], ids=["date-function", "anti-join", "not-in-output", "not-time"]) +def test_what_cannot_be_said_precisely_still_gets_the_fallback(a, b): + diff = explain_sql_diff(a, b, "sqlite", labels=("这次", "口径")) + assert diff.equivalent is False and diff.fallback is True + assert not [i for i in diff.items if i.aspect == "join"] + + +@pytest.mark.parametrize("a, b, same", [ + (BASE.format(on=" AND o.paid_at >= '2026-09-10'"), BASE.format(on=" AND o.paid_at >= '2026-09-10'"), True), + ("SELECT u.city, SUM(o.amount) AS s FROM users u LEFT JOIN orders o ON o.paid_at >= '2026-09-10' AND " + "o.user_id = u.id GROUP BY u.city", BASE.format(on=" AND o.paid_at >= '2026-09-10'"), True), + *[(a, b, False) for a, b, _ in SAID], +]) +def test_only_the_explanation_changed_not_the_verdict(a, b, same): + for schema in (None, SCHEMA): + assert explain_sql_diff(a, b, "sqlite", schema=schema).equivalent is same diff --git a/backend/tests/test_sql_projection.py b/backend/tests/test_sql_projection.py new file mode 100644 index 00000000..70bbe654 --- /dev/null +++ b/backend/tests/test_sql_projection.py @@ -0,0 +1,195 @@ +"""按引用的列投影(data/sql_semantics.py 的 project_select / project_template / output_names,next-round 口径账本:整题等价 +按答案引用的那几列比)。 + +投影本身不判等价,只把「没被引用、去掉也不改变被引用那几列取值」的选择项去掉,交给现有的绑定和判等价。这里守两件事: +- 该去的去得掉:多选一列、多几个聚合、日期常量列、带 WITH 的、只出一行的聚合带 LIMIT;口径模板里的 {{ input.x }} 原样留着; +- 去了会变的一律不去(宁可不投影):DISTINCT / DISTINCT ON、出现在 GROUP BY 里、被 ORDER BY / HAVING / QUALIFY / 窗口 + 用到(按别名、按同样的写法、按位置)、和 LIMIT / OFFSET 一起定取哪几行、集合运算、*、可能改变行数的函数、去掉仅有的聚合、 + 留下按任意一行取值的列。 + +另有执行守门:语料(tests/fixtures/sql_pairs.py)里每一条 SQL,按每一列(和「只去掉一列」)投影,投影得了的在虚构库的每组 +数据上,投影后的结果都得等于原结果的那几列。 +""" +from __future__ import annotations + +import pytest + +from app.data.sql_semantics import output_names, project_select, project_template +from tests.fixtures import sql_pairs + +WEEKLY = ("SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' " + "AND paid_at >= '{{ input.start }}' AND paid_at < '{{ input.end }}'") + + +# -------------------------------------------------------------------------- +# 该去的去得掉 +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("sql, keep, want", [ + ("SELECT SUM(amount) AS sales, COUNT(*) AS n FROM orders WHERE status = 'paid'", [0], + "SELECT SUM(amount) AS sales FROM orders WHERE status = 'paid'"), + ("SELECT COUNT(*) AS n, MIN(paid_at) AS first_day, MAX(paid_at) AS last_day, SUM(amount) AS sales " + "FROM orders WHERE paid_at >= '2026-10-05'", [3], + "SELECT SUM(amount) AS sales FROM orders WHERE paid_at >= '2026-10-05'"), + ("SELECT '2026-10-05' AS week_start, '2026-10-11' AS week_end, COALESCE(SUM(amount), 0) AS sales FROM orders", [2], + "SELECT COALESCE(SUM(amount), 0) AS sales FROM orders"), + ("WITH paid AS (SELECT amount FROM orders WHERE status = 'paid') SELECT SUM(amount) AS s, COUNT(*) AS n FROM paid", + [0], "WITH paid AS (SELECT amount FROM orders WHERE status = 'paid') SELECT SUM(amount) AS s FROM paid"), + # 不分组的聚合只出一行:LIMIT 取哪一行和选出来的列无关 + ("SELECT SUM(amount) AS s, COUNT(*) AS n FROM orders LIMIT 100", [0], "SELECT SUM(amount) AS s FROM orders LIMIT 100"), + # 分组的一段去掉别的聚合(分组键留着) + ("SELECT region, SUM(amount) AS s, COUNT(*) AS n FROM orders GROUP BY region", [0, 1], + "SELECT region, SUM(amount) AS s FROM orders GROUP BY region"), + # 调换先后 + ("SELECT COUNT(*) AS n, SUM(amount) AS s FROM orders", [1, 0], "SELECT SUM(amount) AS s, COUNT(*) AS n FROM orders"), +], ids=["extra-count", "extra-aggregates", "date-constants", "cte", "single-row-limit", "grouped", "reorder"]) +def test_items_that_do_not_change_the_referenced_columns_are_dropped(sql, keep, want): + p = project_select(sql, keep, "sqlite") + assert p.refused is None and p.sql == want + assert p.kept == tuple(keep) and p.dropped == len(output_names(sql, "sqlite")) - len(keep) + + +def test_the_text_around_the_select_list_is_kept_as_written(): + sql = "SELECT\n SUM(amount) AS sales, -- 金额\n COUNT(*) AS n\nFROM orders o\nWHERE o.status = 'paid' -- 只算已支付" + p = project_select(sql, [0], "sqlite") + assert p.sql == "SELECT\n SUM(amount) AS sales\nFROM orders o\nWHERE o.status = 'paid' -- 只算已支付" + assert p.names == ("sales",) + + +def test_keeping_everything_in_order_gives_the_sql_back_unchanged(): + sql = "SELECT SUM(amount) AS s FROM orders" + p = project_select(sql, [0], "sqlite", width=1) + assert p.sql == sql and p.dropped == 0 and p.refused is None + + +def test_template_projection_keeps_the_slots(): + template = ("SELECT SUM(amount) AS gmv, COUNT(*) AS n, '{{ input.start }}' AS since FROM orders " + "WHERE status = 'paid' AND paid_at >= '{{ input.start }}' AND paid_at < '{{ input.end }}'") + assert output_names(template, "sqlite", template=True) == ["gmv", "n", "since"] + p = project_template(template, [0], "sqlite") + assert p.refused is None and p.sql == WEEKLY and p.dropped == 2 and p.names == ("gmv",) + p = project_template(template, [1], "sqlite") + assert p.sql == WEEKLY.replace("SUM(amount) AS gmv", "COUNT(*) AS n") + + +# -------------------------------------------------------------------------- +# 去了会变的一律不去 +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("sql, keep, dialect", [ + ("SELECT DISTINCT region, amount FROM orders", [1], "sqlite"), + ("SELECT DISTINCT ON (region) region, amount FROM orders ORDER BY region, amount", [1], "postgres"), + # 多一个分组列:它在 GROUP BY 里 + ("SELECT region, SUM(amount) AS s FROM orders GROUP BY region", [1], "sqlite"), + ("SELECT region AS r, SUM(amount) AS s FROM orders GROUP BY 1", [1], "sqlite"), + # 按别的列排序再取前几行 + ("SELECT amount, paid_at FROM orders ORDER BY paid_at DESC LIMIT 5", [0], "sqlite"), + ("SELECT amount, id FROM orders ORDER BY amount LIMIT 3 OFFSET 1", [0], "sqlite"), + # HAVING 用到多出来的聚合:按别名、按同样的写法 + ("SELECT region, SUM(amount) AS s, COUNT(*) AS n FROM orders GROUP BY region HAVING n > 2", [0, 1], "sqlite"), + ("SELECT region, SUM(amount) AS s, COUNT(*) AS n FROM orders GROUP BY region HAVING COUNT(*) > 2", [0, 1], + "sqlite"), + # ORDER BY 用到、按位置引用 + ("SELECT region, SUM(amount) AS s, COUNT(*) AS n FROM orders GROUP BY region ORDER BY n DESC", [0, 1], "sqlite"), + ("SELECT region, SUM(amount) AS s, COUNT(*) AS n FROM orders GROUP BY region ORDER BY 3 DESC", [0, 1], "sqlite"), + # 窗口、QUALIFY 用到 + ("SELECT id, amount, SUM(amount) OVER (ORDER BY id) AS run FROM orders", [0, 2], "sqlite"), + ("SELECT region, amount, ROW_NUMBER() OVER (PARTITION BY region ORDER BY amount) AS rn FROM orders QUALIFY rn = 1", + [0, 1], "duckdb"), + # SQLite 的 WHERE 认列别名 + ("SELECT amount, DATE(paid_at) AS d FROM orders WHERE d >= '2026-10-05'", [0], "sqlite"), + # 集合运算、* + ("SELECT SUM(amount) AS s, COUNT(*) AS n FROM orders UNION ALL SELECT 1, 2", [0], "sqlite"), + ("SELECT *, amount * 2 AS twice FROM orders", [1], "sqlite"), + # 可能把一行展开成多行的函数、不认识的函数 + ("SELECT id, unnest(tags) AS tag FROM orders", [0], "postgres"), + ("SELECT id, generate_series(1, 3) AS k FROM orders", [0], "postgres"), + ("SELECT id, json_array_elements(payload) AS e FROM orders", [0], "postgres"), + # 去掉仅有的聚合:剩下的不再只是一行 + ("SELECT COUNT(*) AS n, '2026' AS y FROM orders", [1], "sqlite"), + # 留下按任意一行取值的列(SQLite 取 MAX 那一行) + ("SELECT region, MAX(amount) AS m, COUNT(*) AS n FROM orders", [0, 1], "sqlite"), + # MySQL 里紧跟着字的 -- 是两个减号:改写之后位置对不上原文 + ("SELECT SUM(amount)--1 AS s, COUNT(*) AS n FROM orders", [0], "mysql"), +], ids=["distinct", "distinct-on", "group-by-column", "group-by-ordinal", "order-limit", "order-limit-offset", + "having-alias", "having-same-aggregate", "order-by-alias", "order-by-ordinal", "window", "qualify", + "sqlite-where-alias", "union", "star", "unnest", "generate-series", "unknown-function", "only-aggregate", + "loose-column", "mysql-dashes"]) +def test_items_whose_removal_could_change_the_rest_are_never_dropped(sql, keep, dialect): + p = project_select(sql, keep, dialect) + assert p.sql is None and p.refused, (sql, p) + + +def test_the_result_width_must_match_the_select_list(): + p = project_select("SELECT SUM(amount) AS s, COUNT(*) AS n FROM orders", [0], "sqlite", width=3) + assert p.sql is None and "列数" in (p.refused or "") + + +@pytest.mark.parametrize("keep", [[], [0, 0], [5]]) +def test_bad_keep_lists_are_refused(keep): + assert project_select("SELECT SUM(amount) AS s, COUNT(*) AS n FROM orders", keep, "sqlite").sql is None + + +def test_unreorderable_items_keep_their_order(): + # DuckDB 认前面的列别名:调换之后 b 就找不到 a 了 + p = project_select("SELECT amount + 1 AS a, a * 2 AS b FROM orders", [1, 0], "duckdb") + assert p.sql is None and p.refused + + +# -------------------------------------------------------------------------- +# 执行守门:投影得了的,结果就是原结果的那几列 +# -------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def databases(): + conns = sql_pairs.build_databases() + yield conns + for c in conns: + c.close() + + +def _keeps(width: int) -> list[tuple[int, ...]]: + """每一列单独留下,再加上「只去掉一列」的几种(列多的只试前四列)。""" + singles = [(i,) for i in range(min(width, 4))] + drops = [tuple(j for j in range(width) if j != i) for i in range(min(width, 4))] if width > 2 else [] + return singles + drops + + +def _bag(rows: list[tuple]) -> list[tuple]: + """同 sql_pairs.run:行按值排序(先后不算结果的一部分),浮点数按 6 位小数取整。""" + rows = [tuple(sql_pairs._norm(v) for v in row) for row in rows] + return sorted(rows, key=lambda r: tuple((v is None, str(type(v)), v if v is not None else 0) for v in r)) + + +def _corpus() -> list[str]: + seen: dict[str, None] = {} + for pair in sql_pairs.PAIRS: + seen.setdefault(pair.a) + seen.setdefault(pair.b) + return list(seen) + + +def test_projection_keeps_the_values_of_the_kept_columns(databases): + """语料里每一条 SQL:投影得了的,在每组数据上投影后的结果(行的先后不算)都等于原结果只取留下的那几列。同时数一数 + 投影了多少种,免得规则收得太紧、守门形同虚设。""" + projected = 0 + for sql in _corpus(): + width = len(output_names(sql, "sqlite") or []) + if width < 2: + continue + for keep in _keeps(width): + p = project_select(sql, keep, "sqlite", width=width) + if p.sql is None: + continue + projected += 1 + for conn in databases: + try: + full = conn.execute(sql).fetchall() + except Exception: # noqa: BLE001 - 语料里有只在别的方言里成立的写法:原句跑不了的不比 + break + want = _bag([tuple(row[k] for k in keep) for row in full]) + assert sql_pairs.run(conn, p.sql) == want, (sql, keep, p.sql) + assert projected >= 100, projected diff --git a/frontend/src/lib/evidence.ts b/frontend/src/lib/evidence.ts index 18f32faf..36cb0e71 100644 --- a/frontend/src/lib/evidence.ts +++ b/frontend/src/lib/evidence.ts @@ -436,13 +436,20 @@ export function inputSource(input: EvidenceInput): { tone: SourceTone; text: str ? { tone: 'warn', text: EVIDENCE_TEXT.codeSource(input.node_id ?? '') } : { tone: 'alert', text: EVIDENCE_TEXT.codeCompute(input.node_id ?? '') } } + if (input.via === 'metric' && input.upstream) { + // 转抄另一张口径卡的指标:出处跨卡追下去了(服务端记在 upstream 上),追没追到照实说 + return input.upstream.traced === true + ? { tone: 'ok', text: EVIDENCE_TEXT.upstreamTraced } + : { tone: 'warn', text: EVIDENCE_TEXT.upstreamStop } + } if (status === 'missing' || input.value == null) return { tone: 'warn', text: EVIDENCE_TEXT.inputMissing } return { tone: 'muted', text: '' } } -/** 值得在「输入来源」里单独列一行的输入:能核对到快照的、代码节点的、缺的 */ +/** 值得在「输入来源」里单独列一行的输入:能核对到快照的、代码节点的、转抄另一张口径卡的指标(跨卡追下去的)、缺的 */ export const notableInput = (input: EvidenceInput): boolean => input.via === 'agent_field' || input.via === 'tool_cell' || input.via === 'code' + || (input.via === 'metric' && !!input.upstream) || input.status === 'missing' || input.value == null /** diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index cd426a7a..aa1d4356 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -313,6 +313,15 @@ export const EVIDENCE_TEXT = { originChipHint: '这个数由口径卡确定性算出、复算一致,但算式用到的输入没有一路追到查询快照', /** 按位置取的格穿过了原样解析查询结果的整形节点 */ through: (label: string) => `经「${label}」原样解析`, + /** + * 转抄另一张口径卡的指标(对账卡、召回卡的「口径」那一项):出处跨卡往下追,接上那个指标自己的一段,一直到查询格子 + */ + upstream: '转抄的口径卡指标', + upstreamFrom: (card: string, metric: string) => `取自口径卡「${card}」的指标「${metric}」`, + gotoUpstream: (card: string) => `查看口径卡「${card}」的这个指标`, + upstreamTraced: '取自另一张口径卡的指标,那个指标的输入都追到了原始数据', + upstreamStop: '取自另一张口径卡的指标,那个指标的输入没有都追到原始数据', + upstreamUnreadable: '读不到那张口径卡的指标集(文件缺失或与哈希不一致),这一步不能作为证据', input: '运行输入', seal: '封存', sealOk: '已封存 · 核对一致', diff --git a/frontend/src/run/EvidencePanel.tsx b/frontend/src/run/EvidencePanel.tsx index 87df5131..a1a27d27 100644 --- a/frontend/src/run/EvidencePanel.tsx +++ b/frontend/src/run/EvidencePanel.tsx @@ -365,7 +365,25 @@ function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block head.scrollIntoView({ block: 'nearest' }) flashOnce(part) }, [panelId]) - const linkOf = (inp: EvidenceInput) => { + // 转抄另一张口径卡的指标(对账卡、召回卡的「口径」那一项):服务端跨卡接上的那几步,输入点一下跳到对应的那一步 + const upstreams = chain.filter((s) => s.step === 'upstream') + const upstreamId = (i: number) => `${panelId}-u${i}` + const jumpToUpstream = useCallback((i: number) => { + const part = document.getElementById(`${panelId}-u${i}`) + const head = part?.querySelector('[data-ev-upstream-head]') + if (!part || !head) return + head.focus({ preventScroll: true }) + head.scrollIntoView({ block: 'nearest' }) + flashOnce(part) + }, [panelId]) + /** depth:这个输入在第几层(指标自己的输入是 1,转抄的那一步里的输入是那一步的 depth + 1) */ + const linkOf = (inp: EvidenceInput, depth = 1): InputLink => { + const u = inp.via === 'metric' && inp.upstream + ? upstreams.findIndex((s) => s.of === inp.path && (s.depth ?? 1) === depth) : -1 + if (u >= 0) { + const card = host.nodeLabel?.(upstreams[u].node_id ?? '') || upstreams[u].label || upstreams[u].node_id || '' + return { index: u, alias: card, text: EVIDENCE_TEXT.gotoUpstream(card), go: () => jumpToUpstream(u) } + } const i = queryOf(inp, queries) return i >= 0 ? { index: i, alias: queries[i].alias ?? '', go: () => jumpTo(i) } : undefined } @@ -492,9 +510,14 @@ function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block )} {isMetric && inputs.some(notableInput) && ( - + linkOf(inp)} /> )} + {isMetric && upstreams.map((u, i) => ( + linkOf(inp, (u.depth ?? 1) + 1)} /> + ))} + {isInput && !isMetric && (

@@ -983,7 +1006,8 @@ function askError(error: unknown): string { return JUDGE_TEXT.failed(humanizeError(error).title) } -type InputLink = { index: number; alias: string; go: () => void } | undefined +/** 输入点一下跳到哪:查询步骤(alias 是查询编号),或者转抄的那一步(text 是按钮上的字,alias 是那张卡的名字) */ +type InputLink = { index: number; alias: string; go: () => void; text?: string } | undefined /** * 面板里提到的节点。画布右栏里是按钮:点一下在画布上选中并对准它(画布上已经没有这个节点 @@ -1557,8 +1581,8 @@ function InputChip({ input, link }: { input: EvidenceInput; link?: InputLink }) {link ? ( @@ -1591,6 +1615,86 @@ const TONE: Record InputLink +}) { + const host = useEvidenceHost() + const card = host.nodeLabel?.(step.node_id ?? '') || step.label || step.node_id || NONE + const inputs = (step.inputs ?? []).filter((i) => i && typeof i === 'object') + const unreadable = step.hash_ok === false || step.name == null + const untraced = Array.isArray(step.untraced) ? step.untraced.filter((u) => u && typeof u === 'object') : null + const stopAt = (u: EvidenceUntraced) => (u.node_id + ? EVIDENCE_TEXT.originStop(u.path, + EVIDENCE_TEXT.originNode(host.nodeLabel?.(u.node_id) || u.label || u.node_id, u.type ? nodeTypeLabel(u.type) : '')) + : EVIDENCE_TEXT.originUnknown(u.path)) + return ( +

+
{EVIDENCE_TEXT.upstream}
+
+ + {EVIDENCE_TEXT.upstreamFrom(card, step.name || step.metric || NONE)} + + {!unreadable && step.rendered && {step.rendered}} + {step.incomplete && ( + {EVIDENCE_TEXT.incomplete} + )} + {step.sql_check_failed && ( + {EVIDENCE_TEXT.sqlCheckFailed} + )} + {host.onNode && } +
+ + {unreadable ? ( +

+ {EVIDENCE_TEXT.upstreamUnreadable} +

+ ) : ( +
+
+
{EVIDENCE_TEXT.expression}
+
+ {step.expression || NONE} +
+
+
+
{EVIDENCE_TEXT.substituted}
+
+ + {step.substituted || NONE} + + {!!inputs.length && ( +
    + {inputs.map((inp, i) => )} +
+ )} +
+
+ {untraced && ( +
+
{EVIDENCE_TEXT.origin}
+
+ {untraced.length ? ( +
    {untraced.map((u, i) =>
  • {stopAt(u)}
  • )}
+ ) : EVIDENCE_TEXT.originTraced} +
+
+ )} +
+ )} +
+ ) +} + /** * 输入来源:每个能核对到快照的输入(agent 字段、cell() 取数)、代码节点的输入、缺的输入各一行—— * 路径、从哪个节点哪一格来、核对结果。能对到查询步骤的给「看查询 Qn」 @@ -1606,7 +1710,8 @@ function SourcesPart({ inputs, linkOf }: { inputs: EvidenceInput[]; linkOf: (inp const link = linkOf(inp) // 查询编号写报告目录里的全局编号(接口补的 query、对上的查询步骤);agent 字段自己的 ref 是 // 那个节点内部的编号,和面板里的「查询 Qn」不是一回事,不拿来写 - const alias = link?.alias || inp.query || (typeof inp.cell === 'string' ? inp.cell.split('.')[0] : '') + const alias = (link && !link.text ? link.alias : '') || inp.query + || (typeof inp.cell === 'string' ? inp.cell.split('.')[0] : '') const where = [ inp.field && (inp.via === 'agent_field' ? `字段 ${inp.field}` : inp.field), throughText(inp, host.nodeLabel), @@ -1621,7 +1726,7 @@ function SourcesPart({ inputs, linkOf }: { inputs: EvidenceInput[]; linkOf: (inp {inp.path ?? NONE} {link && ( )} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a84652e4..cce85f52 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -2171,6 +2171,19 @@ export interface EvidenceInput { * 不进 masked:面板底下「这些列设置了遮罩」不把它们算进去 */ mask_unverified?: string + /** + * via 为 metric、转抄另一张口径卡的指标(nodes.<卡>.metrics[i].value)时,运行时跨卡往下追的出处:那个指标的 id、那张卡的 + * 指标集工件、转抄了几层、那个指标的输入是不是都追到了原始数据(服务端 engine/lineage.metric_copy)。老运行、别的输入没有 + */ + upstream?: EvidenceUpstreamRef | null +} + +/** 转抄的输入跨卡往下追的出处(EvidenceInput.upstream) */ +export interface EvidenceUpstreamRef { + metric?: string + artifact?: string | null + depth?: number + traced?: boolean } // --------------------------------------------------------------------------- @@ -2319,6 +2332,14 @@ export interface EvidenceUntraced { export interface EvidenceStep extends Omit { step: string + /** + * upstream:转抄另一张口径卡的指标时跨卡接上的那一步(api/evidence._upstream_steps)。of 是接在哪个输入后面(输入的路径), + * label 是那张卡在图里的名字,traced 是那个指标的输入都追到了原始数据,depth 是转抄了几层 + */ + of?: string + label?: string | null + traced?: boolean + depth?: number metric?: string decimals?: number | null format?: string diff --git a/scripts/check-ledger.mjs b/scripts/check-ledger.mjs index 1ac25b3c..e72fa721 100644 --- a/scripts/check-ledger.mjs +++ b/scripts/check-ledger.mjs @@ -8,6 +8,8 @@ // - 口径对账:核对参数(推断的参数要先核对)→ 发起(认不出列怎么配时照 409 带回的选项选对照的指标,再发起)→ 结果卡片: // 这次 / 口径 / 差值 / 相对差四个数各自取值、点开是出处链;请求体逐项核对;做完的卡片上问「差在哪」的说明有没有帮助 // (说明有用 / 看不懂,统计 E2):请求体只有 verdict,选过的按下、写各几个人,能改主意;没做完的不问; +// - 「按口径」那个数转抄口径卡的指标:出处链跨卡接上口径卡那个指标的一步,输入来源里点一下跳到那一步,那一步的输入 +// 点一下跳到口径的取数查询; // - 可沉淀的问题:工作流列表弹窗里的抽屉,三类来源分开写;「固化成口径草稿」走 from-run 的 freeze(带同组的别的运行)、 // 打开画布;聚类按人挑的那一轮固化;「不是口径」要写理由; // - 使用者看到的入口照服务端的动作:不能出具的写原因(请搭建者出具)、不摆出具按钮,能对账的照样对账;出具过的正式运行 @@ -22,7 +24,7 @@ import { readFileSync } from 'node:fs' import { chromium } from '../frontend/node_modules/playwright-core/index.mjs' import { - ISSUE, QUEUE, RECONCILE, RECONCILE_VALUES, WHOLE, WHOLE_LABELS, ledgerOf, metricDetail, reconciledOf, + ISSUE, QUEUE, RECONCILE, RECONCILE_VALUES, WHOLE, WHOLE_LABELS, copiedMetricDetail, ledgerOf, metricDetail, reconciledOf, } from './ledger-fixtures.mjs' const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273' @@ -278,6 +280,52 @@ await section('口径对账:核对参数 → 发起 → 选对照的指标 → check('没有运行时报错', errors.length === 0, errors.join(' | ')) }) +// ---------------------------------------------------------------- 「按口径」那个数的出处跨卡追到取数查询 + +await section('对账结果里「按口径」那个数:出处跨卡追到口径的取数查询', async () => { + const extra = (method, p) => { + const m = p.match(/^\/runs\/run-rec-1\/evidence\/metrics\/reconcile\/([^/]+)$/) + if (method === 'GET' && m) { + return { json: m[1] === 'cal_0' ? copiedMetricDetail('run-rec-1', m[1]) : metricDetail('run-rec-1', m[1]) } + } + return null + } + const { page, errors } = await open('/ui-harness.html?evidence=1', { + reply: evidenceReply(() => ledgerOf(RUN, { reconciled: [reconciledOf('succeeded')] }), extra) }) + await page.waitForSelector('#evidence-wide [data-seg="s8"]', { timeout: 10000 }) + await openSeg(page, 's8') + const card = page.locator('[data-evidence-panel] [data-ledger-reconciled="run-rec-1"]') + await card.waitFor({ timeout: 6000 }) + await until(async () => (await card.innerText()).includes(RECONCILE_VALUES.cal_0), 5000) + await card.locator('[data-ledger-value="cal_0"]').click() + const drawer = page.locator('[data-evidence-panel="side"]').last() + const up = drawer.locator('[data-ev-upstream="card"]') + const shown = await up.waitFor({ timeout: 5000 }).then(() => true, () => false) + const upText = shown ? (await up.innerText()).replace(/\s+/g, ' ') : '' + check('点「按口径」:出处链接上口径卡的那个指标(转抄的口径卡指标)', shown && upText.includes('转抄的口径卡指标') + && upText.includes('取自口径卡「口径卡」的指标「订单数」') && upText.includes('1,180单'), upText) + check('……写那个指标的原式、它的输入追到了原始数据', upText.includes("cell(nodes.fetch, 0, 'orders')") + && await up.locator('[data-ev-upstream-origin="traced"]').count() === 1, upText) + check('指标这一步:输入都追到了原始数据,不挂「未追到原始数据」', await drawer.locator('[data-ev-untraced-chip]').count() === 0 + && await drawer.locator('[data-ev-metric] [data-ev-origin="traced"]').count() === 1) + const src = drawer.locator('[data-ev-source="nodes.card.metrics[0].value"]') + const srcText = await src.innerText().catch(() => '') + check('输入来源:转抄的那一项说清取自另一张口径卡、那个指标的输入都追到了原始数据', + srcText.includes('取自另一张口径卡的指标,那个指标的输入都追到了原始数据') + && srcText.includes('查看口径卡「口径卡」的这个指标'), srcText) + await src.locator('[data-ev-goto]').click() + const focused = await until(() => page.evaluate(() => document.activeElement?.hasAttribute('data-ev-upstream-head') === true), 3000) + check('点「查看口径卡「口径卡」的这个指标」:焦点落到那一步、描一下边', focused + && await up.getAttribute('data-flash') === 'focus') + await up.locator(`[data-ev-input="cell(nodes.fetch, 0, 'orders')"]`).click() + const query = drawer.locator('[data-ev-query="q-cal-fetch"]') + const jumped = await until(async () => await query.getAttribute('data-flash') === 'focus', 3000) + check('那个指标的输入点一下:跳到口径的取数查询(SQL、被引用的那一格都在)', jumped + && (await query.innerText()).includes('SELECT COUNT(*) AS orders FROM orders') + && await query.locator('td[data-highlight="cell"]').count() === 1) + check('没有运行时报错', errors.length === 0, errors.join(' | ')) +}) + // ---------------------------------------------------------------- 可沉淀的问题 const WF = { id: 'wf-led-check', name: '检查用工作流', description: '', version: 1, status: 'draft', tags: [], is_template: false, diff --git a/scripts/ledger-fixtures.mjs b/scripts/ledger-fixtures.mjs index 049e7b35..ef3d7bf9 100644 --- a/scripts/ledger-fixtures.mjs +++ b/scripts/ledger-fixtures.mjs @@ -112,6 +112,31 @@ export const metricDetail = (runId, metric) => ({ seal: { sealed: true, ok: true, covered: true }, redacted: { columns: [] }, }) +/** + * 对账卡上「按口径」那个数的出处(服务端 api/evidence._upstream_steps 的形状):它转抄口径卡的指标 + * (nodes.card.metrics[0].value),出处跨卡接上口径卡「订单数」那一步,再到口径的取数查询(q-cal-fetch) + */ +export const copiedMetricDetail = (runId, metric) => ({ + run_id: runId, node: 'reconcile', metric, rendered: RECONCILE_VALUES[metric] ?? '—', eid: `ev:metric:${metric}`, + chain: [ + { step: 'metric', alias: 'm', metric, name: '订单数 · 口径', value: 1180, rendered: RECONCILE_VALUES[metric], node_id: 'reconcile', + caliber: '口径对账', version: 'v1', expression: 'nodes.card.metrics[0].value', substituted: '1180', recompute_ok: true, + sealed: true, hash_ok: true, untraced: [], + inputs: [{ path: 'nodes.card.metrics[0].value', value: 1180, node_id: 'card', via: 'metric', field: 'metrics[0].value', + status: 'ok', upstream: { metric: 'orders', artifact: 'ms-card', depth: 1, traced: true } }] }, + { step: 'upstream', of: 'nodes.card.metrics[0].value', node_id: 'card', label: '口径卡', artifact: 'ms-card', metric: 'orders', + traced: true, depth: 1, hash_ok: true, sealed: true, name: '订单数', value: 1180, unit: '单', rendered: '1,180单', + caliber: '订单口径', version: 'v1', expression: "cell(nodes.fetch, 0, 'orders')", substituted: '1180', recompute_ok: true, + status: 'ok', untraced: [], + inputs: [{ step: 'input', path: "cell(nodes.fetch, 0, 'orders')", value: 1180, node_id: 'fetch', via: 'tool_cell', + status: 'verified', artifact: 'q-cal-fetch', locator: { row: 0, column: 'orders' }, eid: 'ev:cell:q-cal-fetch' }] }, + { step: 'query', alias: null, artifact: 'q-cal-fetch', node_id: 'fetch', tool: 'db_query__shop', source: 'shop', + sql: "SELECT COUNT(*) AS orders FROM orders WHERE status = 'paid'", columns: ['orders'], rows: [[1180]], row_offset: 0, + row_index: [0], total_rows: 1, highlight: { rows: [0], cols: ['orders'], cells: [[0, 'orders']] }, sealed: true, hash_ok: true }, + ], + seal: { sealed: true, ok: true, covered: true }, redacted: { columns: [] }, +}) + /** 认证队列:三类来源各一项 */ export const QUEUE = { items: [