mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 05:21:57 +00:00
双盲复算前端复核修正(第 2 轮):「记入数据目录」不再说得比服务端宽——有服务端预告时照预告说,没有时只说必要条件、不写的几种照实说
问题:审批卡上那句「记入数据目录」的提示是照 vs/f1a 早先的规则写的。F1a 在1491b5c把写目录的规则收紧以后,有三种常见情况卡上说会记入,服务端却都不写: - 只有一边按时间筛:卡上写「两边只差在按哪一列筛时间」,和正上方那条差异「盲算没有按时间筛选」自相矛盾 - 只多一个指标自己范围的条件(channel = 'web'):服务端只把排除退款、作废、测试这类无效记录的条件记成有效记录条件 - 只差去不去重,而审核人采用的是不去重的一边:服务端不写 审核人可能为了不让目录被写错,避开本来正确的原流程,结果出具降档;节点设为「判为失败」时运行还会终止。 审批卡(run/DualCheckApproval.tsx) - 服务端在摘要里另带预告(payload.summary[].catalog,F1a 在f7d1a2e加的,约定之外另带)时照它说:三个选择各说一句,会写的写明哪张表的哪一项记成什么,不写的写「不记」。两边都不写就不提数据目录。审核人当前选中的那一句加重,选完一眼就能看出这一选会写什么。预告跟着摘要一起来,完整对照还没到或取不到时也照样能说。预告的形状认不出时整份不用,回到有条件的说法。 - 没有预告时(老服务端、升级前挂起的审批),三句改成只说必要条件(「仅当……才」),并把不写的几种照实写出来:只有一边按时间筛时不记;多出的条件得只在一边、并且是在排除无效记录,只看某个地区、某个渠道这类条件不记;采用不去重的一边时不记。 - payload 带了实际生效的「未采用原流程时」(同样是f7d1a2e加的)就照它说,不再去取图快照。子工作流里的节点在图快照里找不到,有了它也能说准;值认不出时仍回到图快照。 夹具与检查 - 夹具新增两个变体:只有一边按时间筛、只多一个渠道条件,差异说明都是 explain_sql_diff 的真实输出。每个指标、每个变体都附上服务端预告的真实输出(catalog_preview、variants[].catalog)。backend/tests/test_dual_check_fixture.py 有两项检查:一项不依赖服务端,查预告的形状和写目录的门槛;另一项在服务端模块合入后,逐项比对夹具和 dual_check_facts.preview。regenerate 也会一并重新生成预告。 - 预览用例 dual-check-approval 认地址参数 variant / catalog / on_reject,不带参数时 payload 只有约定里的几项。 - check-verify 按服务端的预告核对两种说法:没有预告的一节,在三种情况下都要以「仅当」开头,并照实说出不写;只差去不去重时,两种选择各断言一次。有预告的一节,核对两边写什么或不记、两边都不写时不提、选中那一句加重(去重的两种选择分别看)、认不出时回到有条件的说法、取不到完整对照时照样说,以及 390px 下深浅色的对比度。payload 的 on_reject 那一节核对:照它说、不取图快照、子工作流也说准、认不出时回到图快照。 - 去掉实现以后,检查脚本在这三种情况、预告和 on_reject 上共有 13 项变红。改动后为 274 项全部通过(原来 218 项)。check-all 里 check-verify 的耗时估计按实测改成 65 秒。
This commit is contained in:
@@ -9,15 +9,26 @@ check-verify 在夹具上能过,真实数据上却一处都不高亮(2026-10
|
||||
差异都没有」,delta_pct 是 (盲算值 − 原流程值) ÷ |原流程值| × 100 保留两位。夹具的数据源当 SQLite 比(方言 sqlite),
|
||||
两边的叫法是「原流程」「盲算」,同节点调用时一样。
|
||||
|
||||
重新生成夹具里的差异说明(只改 diff、摘要的 diff 和 fallback,其余原样):
|
||||
**写目录的预告。** 服务端在摘要里另带每个指标的 catalog(采用原流程、采用盲算各会往数据目录里写什么,
|
||||
dual_check_facts.preview,约定之外另加的),审批卡照它说;老服务端没有时卡上按差异的形状有条件地说,两种说法都按
|
||||
「服务端到底写不写」核对(2026-10 复核:卡上说会记入、服务端却不写的有好几种)。夹具里的预告(catalog_preview 按指标、
|
||||
variants 各自的 catalog)也是服务端的真实输出:按下面的 SCHEMA、空目录、指标自己的范围留空算。双盲复算的服务端合入
|
||||
之前没有这个模块,逐项比对那一项跳过,形状和门槛照样查。
|
||||
|
||||
重新生成夹具里的差异说明和预告(只改 diff、摘要的 diff 和 fallback、预告,其余原样;没有预告的模块时预告不动):
|
||||
cd backend && PYTHONPATH=. python tests/test_dual_check_fixture.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.data.catalog import TABLE_FIELD_LABEL
|
||||
from app.data.sql_semantics import explain_sql_diff
|
||||
|
||||
FIXTURE = Path(__file__).resolve().parents[2] / "frontend" / "src" / "dev" / "fixtures" / "dual_check.json"
|
||||
@@ -25,6 +36,23 @@ DIALECT = "sqlite"
|
||||
LABELS = ("原流程", "盲算")
|
||||
#: 摘要里每个指标最多列几条差异(同双盲复算节点的 SUMMARY_DIFFS)
|
||||
SUMMARY_DIFFS = 3
|
||||
#: 预告用的数据源名和表结构(虚构):服务端要认得出表、认得出聚合的列属于哪张表,才会预告写哪一项
|
||||
SOURCE = "shop"
|
||||
SCHEMA: dict[str, Any] = {"tables": {
|
||||
"orders": {"name": "orders", "columns": [
|
||||
{"name": "id", "type": "INTEGER"}, {"name": "user_id", "type": "INTEGER"}, {"name": "amount", "type": "REAL"},
|
||||
{"name": "status", "type": "TEXT"}, {"name": "channel", "type": "TEXT"}, {"name": "paid_at", "type": "TEXT"},
|
||||
{"name": "created_at", "type": "TEXT"}], "primary_key": ["id"]},
|
||||
"refunds": {"name": "refunds", "columns": [
|
||||
{"name": "id", "type": "INTEGER"}, {"name": "order_id", "type": "INTEGER"}, {"name": "amount", "type": "REAL"},
|
||||
{"name": "created_at", "type": "TEXT"}], "primary_key": ["id"]},
|
||||
}}
|
||||
#: 写目录时认得出的三项(dual_check_facts.FACT_ITEMS)
|
||||
FACT_ITEMS = ("business_date", "valid_filter", "dedup")
|
||||
#: 差异里能对上这三项的方面:时间列、筛选条件、聚合
|
||||
FACT_ASPECTS = ("time", "filter", "aggregate")
|
||||
#: 变体换掉的是口径卡里的「销售额」:预告里的指标名照它
|
||||
VARIANT_METRIC = "销售额"
|
||||
|
||||
|
||||
def _diff(original: str, blind: str) -> dict[str, Any]:
|
||||
@@ -56,6 +84,42 @@ def _load() -> dict[str, Any]:
|
||||
return json.loads(FIXTURE.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _candidate(metric: str, original: str, blind: str) -> dict[str, Any]:
|
||||
"""双盲复算节点交给写目录那一步的候选(少一个 choice):只有一个输入对不上,差异条目带两边的写法和 detail(工件里
|
||||
只留 aspect、text,写目录要看完整的)。指标自己的范围留空:夹具里没有口径说明、分组和运行输入。"""
|
||||
diff = explain_sql_diff(original, blind, DIALECT, schema=SCHEMA, labels=LABELS).as_dict()
|
||||
return {"metric": metric, "source": SOURCE, "dialect": DIALECT, "sql": {"a": original, "b": blind},
|
||||
"diff": {"equivalent": diff["equivalent"], "fallback": diff["fallback"], "items": diff["items"]},
|
||||
"scope": {"texts": [], "columns": []}}
|
||||
|
||||
|
||||
def _preview_cases(fx: dict[str, Any]) -> Iterator[tuple[str, str, str, str, dict[str, Any], dict[str, Any]]]:
|
||||
"""夹具里每一份预告:(名字, 指标名, 原流程 SQL, 盲算 SQL, 工件里的差异, 预告)。口径卡的指标按对不上的那个输入,
|
||||
变体按它的一对 SQL(检查脚本拿它换掉「销售额」)。"""
|
||||
for metric in fx["card"]["metrics"]:
|
||||
(a, b), = _differing(metric)
|
||||
yield (metric["metric_id"], metric["name"], a["sql"], b["sql"], metric["diff"],
|
||||
fx["catalog_preview"][metric["metric_id"]])
|
||||
for name, variant in fx["variants"].items():
|
||||
original, blind = variant["sql"]
|
||||
yield name, VARIANT_METRIC, original, blind, variant["diff"], variant["catalog"]
|
||||
|
||||
|
||||
def _shown(side: dict[str, Any]) -> dict[str, Any]:
|
||||
"""一边的预告里审批卡用得到的:写不写,写的话写到哪、写成什么。不写的原因只在时间线上给人看,措辞不比。"""
|
||||
if side.get("write") is True:
|
||||
return {k: side.get(k) for k in ("write", "source", "table", "item", "value")}
|
||||
return {"write": side.get("write")}
|
||||
|
||||
|
||||
def _server_preview() -> Any:
|
||||
"""服务端的预告(dual_check_facts.preview);双盲复算的服务端还没合入时返回 None。"""
|
||||
try:
|
||||
return getattr(importlib.import_module("app.engine.dual_check_facts"), "preview", None)
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
def test_card_metrics_carry_the_real_diff():
|
||||
fx = _load()
|
||||
for metric in fx["card"]["metrics"]:
|
||||
@@ -92,6 +156,37 @@ def test_text_diff_is_not_the_raw_text():
|
||||
assert shown and not any(line in raw for line in shown)
|
||||
|
||||
|
||||
def test_catalog_previews_have_the_server_shape():
|
||||
"""每个指标、每个变体都有预告,两边齐全;写的只能是服务端写目录的门槛之内的(两条 SQL 不等价、没有兜底、恰好一条
|
||||
差异,而且是时间列、筛选条件或聚合),写的是三项之一、有表有取值;不写的有原因。不依赖服务端的模块,一直查。"""
|
||||
fx = _load()
|
||||
assert set(fx["catalog_preview"]) == {m["metric_id"] for m in fx["card"]["metrics"]}
|
||||
cases = list(_preview_cases(fx))
|
||||
assert len(cases) == len(fx["card"]["metrics"]) + len(fx["variants"])
|
||||
for name, _, _, _, diff, preview in cases:
|
||||
assert set(preview) == {"a", "b"}, name
|
||||
single = not diff["equivalent"] and not diff["fallback"] and len(diff["items"]) == 1
|
||||
for side in ("a", "b"):
|
||||
said = preview[side]
|
||||
if said["write"] is True:
|
||||
assert single and diff["items"][0]["aspect"] in FACT_ASPECTS, (name, side)
|
||||
assert said["item"] in FACT_ITEMS and said["item"] in TABLE_FIELD_LABEL, (name, side)
|
||||
assert said["source"] == SOURCE and said["table"] in SCHEMA["tables"] and said["value"], (name, side)
|
||||
else:
|
||||
assert said["write"] is False and said["reason"], (name, side)
|
||||
|
||||
|
||||
def test_catalog_previews_follow_the_server():
|
||||
"""预告里审批卡用得到的部分(写不写、写到哪、写成什么)和服务端(dual_check_facts.preview)一致:服务端改了写目录
|
||||
的规则,这里先变红——审批卡上照预告说的、检查脚本按预告核对的有条件说法都要跟着看一遍。"""
|
||||
preview = _server_preview()
|
||||
if preview is None:
|
||||
pytest.skip("双盲复算的服务端(dual_check_facts.preview)还没合入")
|
||||
for name, metric, original, blind, _, expected in _preview_cases(_load()):
|
||||
got = preview(_candidate(metric, original, blind), SCHEMA, {})
|
||||
assert {s: _shown(got[s]) for s in ("a", "b")} == {s: _shown(expected[s]) for s in ("a", "b")}, name
|
||||
|
||||
|
||||
def regenerate() -> None:
|
||||
fx = _load()
|
||||
for metric in fx["card"]["metrics"]:
|
||||
@@ -102,6 +197,14 @@ def regenerate() -> None:
|
||||
summary[metric["metric_id"]].update(_summary_of(metric))
|
||||
for variant in fx["variants"].values():
|
||||
variant["diff"] = _diff(*variant["sql"])
|
||||
if (preview := _server_preview()) is not None:
|
||||
fx["catalog_preview"] = {}
|
||||
for metric in fx["card"]["metrics"]:
|
||||
(a, b), = _differing(metric)
|
||||
said = preview(_candidate(metric["name"], a["sql"], b["sql"]), SCHEMA, {})
|
||||
fx["catalog_preview"][metric["metric_id"]] = said
|
||||
for variant in fx["variants"].values():
|
||||
variant["catalog"] = preview(_candidate(VARIANT_METRIC, *variant["sql"]), SCHEMA, {})
|
||||
FIXTURE.write_text(json.dumps(fx, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"_note": "双盲复算的预览用例和 scripts/check-verify.mjs 共用的夹具。表名、数字都是虚构的通用示例,不来自任何真实数据。各处的 diff(差异说明)是服务端 explain_sql_diff 对同一对 SQL 的真实输出(方言 sqlite),由 backend/tests/test_dual_check_fixture.py 核对和重新生成,不要手改。variants 是另几对 SQL 及其差异说明,检查脚本拿来换掉「销售额」那个指标,看不同的差异下卡上怎么说",
|
||||
"_note": "双盲复算的预览用例和 scripts/check-verify.mjs 共用的夹具。表名、数字都是虚构的通用示例,不来自任何真实数据。各处的 diff(差异说明)是服务端 explain_sql_diff 对同一对 SQL 的真实输出(方言 sqlite),catalog_preview 和 variants 里的 catalog(采用哪一边会往数据目录里写什么)是服务端写目录预告的真实输出,都由 backend/tests/test_dual_check_fixture.py 核对和重新生成,不要手改。variants 是另几对 SQL 及其差异说明,检查脚本拿来换掉「销售额」那个指标,看不同的差异下卡上怎么说",
|
||||
"approval": {
|
||||
"id": "ap-dc-0001",
|
||||
"run_id": "run-dc-0001",
|
||||
@@ -163,6 +163,38 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"catalog_preview": {
|
||||
"gmv": {
|
||||
"a": {
|
||||
"write": true,
|
||||
"source": "shop",
|
||||
"table": "orders",
|
||||
"item": "business_date",
|
||||
"value": {
|
||||
"column": "paid_at"
|
||||
}
|
||||
},
|
||||
"b": {
|
||||
"write": true,
|
||||
"source": "shop",
|
||||
"table": "orders",
|
||||
"item": "business_date",
|
||||
"value": {
|
||||
"column": "created_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
"refund_rate": {
|
||||
"a": {
|
||||
"write": false,
|
||||
"reason": "指标「退款率」两边的 SQL 不止差一个方面,或有未能归纳的差异,无法确定审核人认可的是哪一处,不写入数据目录"
|
||||
},
|
||||
"b": {
|
||||
"write": false,
|
||||
"reason": "指标「退款率」两边的 SQL 不止差一个方面,或有未能归纳的差异,无法确定审核人认可的是哪一处,不写入数据目录"
|
||||
}
|
||||
}
|
||||
},
|
||||
"graph": {
|
||||
"nodes": [
|
||||
{
|
||||
@@ -394,6 +426,46 @@
|
||||
"- orders.paid_at >= '2026-09-01' AND orders.paid_at < '2026-09-08'",
|
||||
"+ orders.paid_at >= '2026-09-01' AND orders.paid_at < '2026-09-09'"
|
||||
]
|
||||
},
|
||||
"catalog": {
|
||||
"a": {
|
||||
"write": false,
|
||||
"reason": "指标「销售额」两边差在时间范围,对不上数据目录里的业务日期、有效记录条件或去重规则,不写入数据目录"
|
||||
},
|
||||
"b": {
|
||||
"write": false,
|
||||
"reason": "指标「销售额」两边差在时间范围,对不上数据目录里的业务日期、有效记录条件或去重规则,不写入数据目录"
|
||||
}
|
||||
}
|
||||
},
|
||||
"time_one_side": {
|
||||
"sql": [
|
||||
"SELECT SUM(amount) AS gmv\nFROM orders\nWHERE paid_at >= '2026-09-01'\n AND paid_at < '2026-09-08'",
|
||||
"SELECT SUM(amount) AS gmv\nFROM orders"
|
||||
],
|
||||
"diff": {
|
||||
"equivalent": false,
|
||||
"items": [
|
||||
{
|
||||
"aspect": "time",
|
||||
"text": "原流程按 paid_at 筛时间,盲算没有按时间筛选"
|
||||
}
|
||||
],
|
||||
"fallback": false,
|
||||
"text_diff": [
|
||||
"- WHERE",
|
||||
"- orders.paid_at >= '2026-09-01' AND orders.paid_at < '2026-09-08'"
|
||||
]
|
||||
},
|
||||
"catalog": {
|
||||
"a": {
|
||||
"write": false,
|
||||
"reason": "指标「销售额」只有被采纳的原流程按时间筛选,盲算没有:审核人判断的是要不要限定时间,不是按哪一列归属,没有可记的业务日期,不写入数据目录"
|
||||
},
|
||||
"b": {
|
||||
"write": false,
|
||||
"reason": "指标「销售额」被采纳的盲算没有按时间筛选,没有可记的业务日期,不写入数据目录"
|
||||
}
|
||||
}
|
||||
},
|
||||
"filter_extra": {
|
||||
@@ -414,6 +486,49 @@
|
||||
"- WHERE",
|
||||
"- orders.status <> 'test'"
|
||||
]
|
||||
},
|
||||
"catalog": {
|
||||
"a": {
|
||||
"write": true,
|
||||
"source": "shop",
|
||||
"table": "orders",
|
||||
"item": "valid_filter",
|
||||
"value": "status <> 'test'"
|
||||
},
|
||||
"b": {
|
||||
"write": false,
|
||||
"reason": "指标「销售额」多出筛选条件的是原流程,被采纳的盲算没有这个条件,不写入数据目录"
|
||||
}
|
||||
}
|
||||
},
|
||||
"filter_scope": {
|
||||
"sql": [
|
||||
"SELECT COUNT(*) AS orders\nFROM orders\nWHERE channel = 'web'",
|
||||
"SELECT COUNT(*) AS orders\nFROM orders"
|
||||
],
|
||||
"diff": {
|
||||
"equivalent": false,
|
||||
"items": [
|
||||
{
|
||||
"aspect": "filter",
|
||||
"text": "原流程只保留 channel = 'web' 的行,盲算没有这个条件"
|
||||
}
|
||||
],
|
||||
"fallback": false,
|
||||
"text_diff": [
|
||||
"- WHERE",
|
||||
"- orders.channel = 'web'"
|
||||
]
|
||||
},
|
||||
"catalog": {
|
||||
"a": {
|
||||
"write": false,
|
||||
"reason": "指标「销售额」多出的条件「channel = 'web'」不是在排除退款、作废、测试这类无效记录,可能是指标自己的筛选范围(例如只看某个地区、某个渠道),不是整张表的有效记录条件,不写入数据目录"
|
||||
},
|
||||
"b": {
|
||||
"write": false,
|
||||
"reason": "指标「销售额」多出筛选条件的是原流程,被采纳的盲算没有这个条件,不写入数据目录"
|
||||
}
|
||||
}
|
||||
},
|
||||
"two_filters": {
|
||||
@@ -438,6 +553,16 @@
|
||||
"- WHERE",
|
||||
"- orders.channel = 'web' AND orders.status <> 'refunded'"
|
||||
]
|
||||
},
|
||||
"catalog": {
|
||||
"a": {
|
||||
"write": false,
|
||||
"reason": "指标「销售额」两边的 SQL 不止差一个方面,或有未能归纳的差异,无法确定审核人认可的是哪一处,不写入数据目录"
|
||||
},
|
||||
"b": {
|
||||
"write": false,
|
||||
"reason": "指标「销售额」两边的 SQL 不止差一个方面,或有未能归纳的差异,无法确定审核人认可的是哪一处,不写入数据目录"
|
||||
}
|
||||
}
|
||||
},
|
||||
"join": {
|
||||
@@ -462,6 +587,16 @@
|
||||
"- ON orders.id = refunds.order_id",
|
||||
"+ orders.id = refunds.order_id"
|
||||
]
|
||||
},
|
||||
"catalog": {
|
||||
"a": {
|
||||
"write": false,
|
||||
"reason": "指标「销售额」两边差在关联,对不上数据目录里的业务日期、有效记录条件或去重规则,不写入数据目录"
|
||||
},
|
||||
"b": {
|
||||
"write": false,
|
||||
"reason": "指标「销售额」两边差在关联,对不上数据目录里的业务日期、有效记录条件或去重规则,不写入数据目录"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dedup": {
|
||||
@@ -482,6 +617,19 @@
|
||||
"- COUNT(DISTINCT orders.user_id)",
|
||||
"+ COUNT(orders.user_id)"
|
||||
]
|
||||
},
|
||||
"catalog": {
|
||||
"a": {
|
||||
"write": true,
|
||||
"source": "shop",
|
||||
"table": "orders",
|
||||
"item": "dedup",
|
||||
"value": "对 user_id 计数时去重"
|
||||
},
|
||||
"b": {
|
||||
"write": false,
|
||||
"reason": "指标「销售额」被采纳的盲算没有去重:不去重是这个指标自己的算法,不是表的去重规则,不写入数据目录"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parse": {
|
||||
@@ -499,6 +647,16 @@
|
||||
],
|
||||
"fallback": false,
|
||||
"text_diff": []
|
||||
},
|
||||
"catalog": {
|
||||
"a": {
|
||||
"write": false,
|
||||
"reason": "指标「销售额」两边差在能否解析,对不上数据目录里的业务日期、有效记录条件或去重规则,不写入数据目录"
|
||||
},
|
||||
"b": {
|
||||
"write": false,
|
||||
"reason": "指标「销售额」两边差在能否解析,对不上数据目录里的业务日期、有效记录条件或去重规则,不写入数据目录"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,14 @@ import data from './dual_check.json'
|
||||
*
|
||||
* 完整对照的工件(GET /api/artifacts/{id})、这次运行的图快照(GET /api/runs/{id}/graph,卡上按它说节点的
|
||||
* 「未采用原流程时」)、审批回复、设置、校验都由检查脚本用 page.route 伪造,直接打开时卡上照实说无法加载完整对照、
|
||||
* 有条件地说后果。提交后 onResolved 只把运行 id 记到 window.__dualResolved,不去接事件流
|
||||
* 有条件地说后果。提交后 onResolved 只把运行 id 记到 window.__dualResolved,不去接事件流。
|
||||
*
|
||||
* ?syn=dual-check-approval 另认地址上的几个参数(检查脚本用;不带时 payload 只有约定里的几项,同老服务端):
|
||||
* - variant=<名字>:「销售额」的摘要换成 variants 里那一对 SQL 的(前 3 条差异的原话、是否只有兜底,同节点的写法),
|
||||
* 工件由检查脚本按同一个变体伪造
|
||||
* - catalog=1:摘要里每个指标带上服务端预告的「采用哪一边会往数据目录里写什么」(catalog_preview;换了变体的
|
||||
* 「销售额」用变体的 catalog)。catalog=bad:「销售额」带一份认不出的预告
|
||||
* - on_reject=<值>:payload 带上节点实际生效的「未采用原流程时」
|
||||
*/
|
||||
const approval = (patch: Partial<Approval> = {}): Approval => ({
|
||||
...(data.approval as unknown as Approval),
|
||||
@@ -28,6 +35,27 @@ const approval = (patch: Partial<Approval> = {}): Approval => ({
|
||||
...patch,
|
||||
})
|
||||
|
||||
interface Variant { diff: { items: { aspect: string; text: string }[] }; catalog: unknown }
|
||||
|
||||
function approvalOf(search: string): Approval {
|
||||
const q = new URLSearchParams(search)
|
||||
const variant = (data.variants as unknown as Record<string, Variant>)[q.get('variant') ?? '']
|
||||
const catalog = q.get('catalog')
|
||||
const onReject = q.get('on_reject')
|
||||
const previews = data.catalog_preview as unknown as Record<string, unknown>
|
||||
const summary = data.approval.payload.summary.map((s) => {
|
||||
const swapped = s.metric_id === 'gmv' && variant ? variant : null
|
||||
const items = swapped?.diff.items
|
||||
return {
|
||||
...s,
|
||||
...(items ? { diff: items.slice(0, 3).map((i) => i.text), fallback: !items.some((i) => i.aspect !== 'other' && i.aspect !== 'parse') } : {}),
|
||||
...(catalog === '1' ? { catalog: swapped ? swapped.catalog : previews[s.metric_id] } : {}),
|
||||
...(catalog === 'bad' && s.metric_id === 'gmv' ? { catalog: { a: { write: true, table: 'orders' }, b: { write: false } } } : {}),
|
||||
}
|
||||
})
|
||||
return approval({ payload: { ...data.approval.payload, summary, ...(onReject ? { on_reject: onReject } : {}) } })
|
||||
}
|
||||
|
||||
function Card({ value }: { value: Approval }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border" style={{ borderColor: 'var(--warn)' }}>
|
||||
@@ -68,7 +96,7 @@ function InspectorCase() {
|
||||
|
||||
export const cases: Record<string, () => JSX.Element> = {
|
||||
'dual-check-approval': () => (
|
||||
<div className="mx-auto max-w-3xl p-4 text-sm" data-fixture="dual-check-approval"><Card value={approval()} /></div>
|
||||
<div className="mx-auto max-w-3xl p-4 text-sm" data-fixture="dual-check-approval"><Card value={approvalOf(location.search)} /></div>
|
||||
),
|
||||
'dual-check-approval-rail': () => (
|
||||
<div className="h-full w-[360px] max-w-full overflow-y-auto border-r bg-panel" data-fixture="dual-check-approval-rail">
|
||||
|
||||
@@ -3462,15 +3462,31 @@ export const DUAL_CHECK_TEXT = {
|
||||
textDiffNone: '这一边没有不同的行',
|
||||
rationale: '盲算的说明',
|
||||
/**
|
||||
* 两边只差一处、又可能对得上数据目录里的一项事实时,提交前说清选择会被记下来(门槛同服务端的 dual_check_facts:
|
||||
* 只有一个输入对不上、恰好一条差异、没有兜底,而且是时间列、筛选条件或聚合)。一边多出条件、只差去不去重、目录里
|
||||
* 已有人工确认的说法这几条只有服务端查得了,所以有条件地说,以时间线上的记录为准。卡上是「方面那句 + tail」
|
||||
* 服务端没有预告(老服务端,见 catalogPreview)、两边又只差一处时,提交前说清选择可能被记进数据目录。卡上只看得到
|
||||
* 差异的方面,看不到服务端写目录的其余条件(dual_check_facts.derive:只有一边按时间筛的不记,多出的条件得在排除
|
||||
* 无效记录、又不是指标自己的范围,只差去不去重时得采用去重的一边),所以每句只说必要条件(「仅当……才」),
|
||||
* 不写的几种常见情况照实说出来,免得审核人为了不让目录被写错而避开本来对的一边。卡上是「方面那句 + tail」
|
||||
*/
|
||||
catalog: {
|
||||
time: '两边只差在按哪一列筛时间:采用一边的结果后,这一边筛时间所用的列会作为该表的业务日期',
|
||||
filter: '两边只差一个筛选条件:若这个条件只有一边有,采用有它的一边时,它会作为该表的有效记录条件',
|
||||
aggregate: '两边只差在聚合方式:若只差是否去重,采用一边的结果后,它的做法会作为该表的去重规则',
|
||||
tail: ',以人工确认的事实记入数据目录。选「两边都不对」时不记,数据目录中已有人工确认的不同说法时不覆盖,以时间线上的记录为准',
|
||||
time: '仅当两边按不同的列筛时间,采用的一边所用的时间列才会作为该表的业务日期记入数据目录;只有一边按时间筛时不记',
|
||||
filter: '仅当多出的条件只在一边、并且是在排除退款、作废、测试这类无效记录,采用有它的一边时,这个条件才会作为该表的'
|
||||
+ '有效记录条件记入数据目录;只看某个地区、某个渠道这类限定指标范围的条件不记',
|
||||
aggregate: '仅当两边只差是否去重、并且采用的是去重的一边,去重的做法才会作为该表的去重规则记入数据目录;采用不去重的一边时不记',
|
||||
tail: '。记为人工确认的事实;选「两边都不对」时不记,数据目录中已有人工确认的不同说法时不覆盖,是否记入以时间线上的记录为准',
|
||||
},
|
||||
/**
|
||||
* 服务端预告了采用哪一边会往数据目录里写什么(payload.summary[].catalog)时照它说:三个选择各一句,写的说哪张表的
|
||||
* 哪一项记成什么,不写的只说不记(原因在裁决后的时间线上)。两边都不写时卡上不提数据目录。卡上是
|
||||
* 「head + 三句(;分隔)+ tail」,审核人当前选的那一句加重
|
||||
*/
|
||||
catalogPreview: {
|
||||
head: '记入数据目录:',
|
||||
/** where 同时间线(log.catalogItem):「orders」(shop)的业务日期;value 是要写进去的说法 */
|
||||
write: (choice: string, where: string, value: string) => `${choice}时,${where}记为「${value}」`,
|
||||
/** 两边写的是同一张表的同一项、只差取值时,后一边省掉「哪张表的哪一项」 */
|
||||
writeSame: (choice: string, value: string) => `${choice}时记为「${value}」`,
|
||||
skip: (choice: string) => `${choice}时不记`,
|
||||
tail: '。记为人工确认的事实;以上按发起审批时的数据目录预告,实际写入以时间线上的记录为准',
|
||||
},
|
||||
choices: { a: '采用原流程的结果', b: '采用盲算的结果', neither: '两边都不对' } as Record<'a' | 'b' | 'neither', string>,
|
||||
choiceGroup: (name: string) => `「${name}」采用哪一边的结果`,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { Fragment, useEffect, useId, useMemo, useState } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { AlertTriangle, Ban, Check, ChevronRight } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
@@ -7,7 +7,8 @@ import { useCatalog } from '../store/catalog'
|
||||
import { useStudio } from '../store/studio'
|
||||
import { Spinner, useToast } from '../components/ui'
|
||||
import { NONE } from '../lib/format'
|
||||
import { DUAL_CHECK_TEXT } from '../lib/terms'
|
||||
import { CATALOG_TABLE_FIELD_LABEL, DUAL_CHECK_TEXT } from '../lib/terms'
|
||||
import { patchValueText } from './catalogPatch'
|
||||
import type {
|
||||
Approval, DualCheckBlindInput, DualCheckCard, DualCheckChoice, DualCheckInput, DualCheckMetric, DualCheckReply,
|
||||
DualCheckSummaryItem,
|
||||
@@ -51,11 +52,19 @@ interface MetricView {
|
||||
rest: string | null
|
||||
/** 两条 SQL 等价,差在取用的结果上 */
|
||||
equivalent: boolean
|
||||
/** 两边只差一处、又可能对得上数据目录的一项事实:审核人的选择可能被记进数据目录(见 catalogOf) */
|
||||
/** 服务端没有预告时用:两边只差一处、又可能对得上数据目录的一项事实,审核人的选择可能被记进数据目录(见 catalogOf) */
|
||||
catalog: CatalogAspect | null
|
||||
/** 服务端预告的采用哪一边会往数据目录里写什么(见 previewOf);有它就照它说,不再按 catalog 有条件地说 */
|
||||
preview: CatalogPreview | null
|
||||
full: DualCheckMetric | null
|
||||
}
|
||||
|
||||
/** 服务端预告的一边:写的话写到哪(where 是 「orders」(shop)的业务日期 这样的说法)、写成什么 */
|
||||
type PreviewSide =
|
||||
| { write: true; table: string; source: string | null; item: string; where: string; value: string }
|
||||
| { write: false }
|
||||
interface CatalogPreview { a: PreviewSide; b: PreviewSide }
|
||||
|
||||
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined)
|
||||
const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined)
|
||||
const hasSql = (v: unknown): boolean => typeof v === 'string' && !!v.trim()
|
||||
@@ -85,16 +94,46 @@ function notesOf(texts: string[]): { standard: boolean; notes: string[] } {
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核人的选择会不会被记进数据目录:照服务端写目录的门槛(dual_check_facts.derive)——两条 SQL 不等价、没用兜底、
|
||||
* 差异恰好一条,而且是时间列、筛选条件或聚合。几个输入都对不上时节点把各自的差异合成一份(每个输入至少一条),
|
||||
* 所以「恰好一条」也就是只有一个输入对不上。后面还有只有服务端查得了的条件(一边多出条件、只差去不去重、目录里
|
||||
* 已有人工确认的说法),卡上有条件地说
|
||||
* 服务端没有预告时(老服务端),审核人的选择可能被记进数据目录的形状:照服务端写目录的门槛(dual_check_facts.derive)
|
||||
* ——两条 SQL 不等价、没用兜底、差异恰好一条,而且是时间列、筛选条件或聚合。几个输入都对不上时节点把各自的差异合成
|
||||
* 一份(每个输入至少一条),所以「恰好一条」也就是只有一个输入对不上。过了这道门槛也不一定写:只有一边按时间筛、
|
||||
* 多出的条件是指标自己的范围、采用了不去重的一边都不写,这些卡上看不出来(差异条目只有方面和原话),所以只说必要
|
||||
* 条件,并把不写的几种照实说出来(DUAL_CHECK_TEXT.approval.catalog)
|
||||
*/
|
||||
function catalogOf(metric: DualCheckMetric, items: { aspect: string; text: string }[]): CatalogAspect | null {
|
||||
if (metric.diff?.equivalent === true || metric.diff?.fallback === true || items.length !== 1) return null
|
||||
return CATALOG_ASPECTS.has(items[0].aspect) ? items[0].aspect as CatalogAspect : null
|
||||
}
|
||||
|
||||
/** 预告的一边:写的得有表、认得出的目录项和取值;认不出返回 undefined */
|
||||
function previewSideOf(raw: unknown): PreviewSide | undefined {
|
||||
if (!raw || typeof raw !== 'object') return undefined
|
||||
const s = raw as Record<string, unknown>
|
||||
if (s.write === false) return { write: false }
|
||||
const table = str(s.table)
|
||||
const item = str(s.item)
|
||||
if (s.write !== true || !table || !item || !Object.hasOwn(CATALOG_TABLE_FIELD_LABEL, item) || s.value == null || s.value === '') {
|
||||
return undefined
|
||||
}
|
||||
const source = str(s.source) ?? null
|
||||
const label = CATALOG_TABLE_FIELD_LABEL[item as keyof typeof CATALOG_TABLE_FIELD_LABEL]
|
||||
return {
|
||||
write: true, table, source, item,
|
||||
where: DUAL_CHECK_TEXT.log.catalogItem(table, source ?? undefined, label), value: patchValueText(item, s.value),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* payload.summary[].catalog(约定之外、服务端另带的预告):采用原流程、采用盲算各会往数据目录里写什么,和裁决后真写的
|
||||
* 是同一套判断。两边有一边认不出就整份不认,卡上回到按差异形状有条件地说(catalogOf)——宁可少说,不替服务端编
|
||||
*/
|
||||
function previewOf(raw: unknown): CatalogPreview | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const a = previewSideOf((raw as Record<string, unknown>).a)
|
||||
const b = previewSideOf((raw as Record<string, unknown>).b)
|
||||
return a && b ? { a, b } : null
|
||||
}
|
||||
|
||||
function viewsOf(summary: DualCheckSummaryItem[], card: DualCheckCard | null): MetricView[] {
|
||||
const full = new Map((card?.metrics ?? []).map((m) => [m.metric_id, m]))
|
||||
// 先按摘要的顺序,工件里另有的(约定里不该有,有就照实列出来)跟在后面
|
||||
@@ -108,6 +147,8 @@ function viewsOf(summary: DualCheckSummaryItem[], card: DualCheckCard | null): M
|
||||
original: str(s?.original_rendered) ?? str(f?.original?.rendered) ?? null,
|
||||
blind: str(s?.blind_rendered) ?? str(f?.blind?.rendered) ?? null,
|
||||
deltaPct: s && 'delta_pct' in s ? num(s.delta_pct) ?? null : f?.delta_pct === null ? null : num(f?.delta_pct),
|
||||
// 预告跟着摘要来:完整对照还没到、或者取不到时也照它说
|
||||
preview: previewOf(s?.catalog),
|
||||
}
|
||||
if (f) {
|
||||
const items = (Array.isArray(f.diff?.items) ? f.diff.items : [])
|
||||
@@ -158,16 +199,17 @@ function deltaText(pct: number | null | undefined): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* 这次运行里节点的「未采用原流程时」:审批 payload 不带它,从运行的图快照(GET /runs/{id}/graph)里按节点 id 读。
|
||||
* 没写、写了认不出的按缺省的 degrade(节点执行时同样这么认);图里找不到这个节点(子工作流里的)、图快照取不到
|
||||
* 时是 unknown,卡上有条件地说
|
||||
* 这次运行里节点的「未采用原流程时」。服务端在 payload 里带了实际生效的处置(given,约定之外另带的)就照它说;
|
||||
* 老服务端不带,从运行的图快照(GET /runs/{id}/graph)里按节点 id 读:没写、写了认不出的按缺省的 degrade(节点执行时
|
||||
* 同样这么认);图里找不到这个节点(子工作流里的)、图快照取不到时是 unknown,卡上有条件地说
|
||||
*/
|
||||
type OnReject = 'degrade' | 'fail' | 'unknown' | 'loading'
|
||||
function useOnReject(runId: string, nodeId: string | undefined): OnReject {
|
||||
function useOnReject(runId: string, nodeId: string | undefined, given: unknown): OnReject {
|
||||
const known = given === 'fail' || given === 'degrade' ? given : null
|
||||
const key = `${runId}\n${nodeId ?? ''}`
|
||||
const [got, setGot] = useState<{ key: string; value: OnReject } | null>(null)
|
||||
useEffect(() => {
|
||||
if (!runId || !nodeId) return
|
||||
if (known || !runId || !nodeId) return
|
||||
let alive = true
|
||||
api.runs.graph(runId).then(
|
||||
(res) => {
|
||||
@@ -178,7 +220,8 @@ function useOnReject(runId: string, nodeId: string | undefined): OnReject {
|
||||
() => { if (alive) setGot({ key, value: 'unknown' }) },
|
||||
)
|
||||
return () => { alive = false }
|
||||
}, [runId, nodeId, key])
|
||||
}, [known, runId, nodeId, key])
|
||||
if (known) return known
|
||||
if (!runId || !nodeId) return 'unknown'
|
||||
return got?.key === key ? got.value : 'loading'
|
||||
}
|
||||
@@ -200,7 +243,7 @@ export function DualCheckApproval({ approval, onResolved }: DualCheckApprovalPro
|
||||
const payload = approval.payload ?? {}
|
||||
const summary = useMemo(() => summaryOf(payload.summary), [payload.summary])
|
||||
const cardId = str(payload.card_artifact) ?? ''
|
||||
const onReject = useOnReject(approval.run_id, str(payload.node_id) ?? str(approval.node_id))
|
||||
const onReject = useOnReject(approval.run_id, str(payload.node_id) ?? str(approval.node_id), payload.on_reject)
|
||||
|
||||
const [card, setCard] = useState<{ id: string; data?: DualCheckCard | null } | null>(null)
|
||||
useEffect(() => {
|
||||
@@ -380,7 +423,10 @@ function MetricBlock({ view, name, choice, onChoose }: {
|
||||
</button>
|
||||
)}
|
||||
{expanded && view.full && <Compare id={`${uid}-cmp`} metric={view.full} />}
|
||||
{view.catalog && (
|
||||
{view.preview ? (
|
||||
// 服务端预告了:两边都不写就不提数据目录
|
||||
(view.preview.a.write || view.preview.b.write) && <CatalogPreviewNote preview={view.preview} choice={choice} />
|
||||
) : view.catalog && (
|
||||
<p className="mt-1.5 text-2xs leading-relaxed text-dim" data-dual-catalog={view.catalog}>{T.catalog[view.catalog]}{T.catalog.tail}</p>
|
||||
)}
|
||||
|
||||
@@ -404,6 +450,40 @@ function MetricBlock({ view, name, choice, onChoose }: {
|
||||
)
|
||||
}
|
||||
|
||||
const sameTarget = (a: PreviewSide, b: PreviewSide): boolean =>
|
||||
a.write && b.write && a.table === b.table && a.source === b.source && a.item === b.item
|
||||
|
||||
/**
|
||||
* 服务端预告的「记入数据目录」:三个选择各一句,审核人当前选的那一句加重——选之前三句都看得到,选了之后一眼看出这一选
|
||||
* 会往目录里写什么。两边写的是同一项(只差取值,比如业务日期按哪一列)时,后一句省掉「哪张表的哪一项」
|
||||
*/
|
||||
function CatalogPreviewNote({ preview, choice }: { preview: CatalogPreview; choice?: DualCheckChoice }) {
|
||||
const P = T.catalogPreview
|
||||
const said = (side: 'a' | 'b'): string => {
|
||||
const s = preview[side]
|
||||
if (!s.write) return P.skip(T.choices[side])
|
||||
return side === 'b' && sameTarget(preview.a, s) ? P.writeSame(T.choices.b, s.value) : P.write(T.choices[side], s.where, s.value)
|
||||
}
|
||||
const clauses: [DualCheckChoice, string, boolean][] = [
|
||||
['a', said('a'), preview.a.write], ['b', said('b'), preview.b.write], ['neither', P.skip(T.choices.neither), false],
|
||||
]
|
||||
return (
|
||||
<p className="mt-1.5 text-2xs leading-relaxed text-dim [overflow-wrap:anywhere]" data-dual-catalog="preview">
|
||||
{P.head}
|
||||
{clauses.map(([side, text, write], i) => (
|
||||
<Fragment key={side}>
|
||||
{i > 0 && ';'}
|
||||
<span className={clsx(choice === side && 'font-medium')} style={choice === side ? { color: 'var(--text)' } : undefined}
|
||||
data-dual-catalog-side={side} data-write={write ? '' : undefined} data-current={choice === side ? '' : undefined}>
|
||||
{text}
|
||||
</span>
|
||||
</Fragment>
|
||||
))}
|
||||
{P.tail}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
/** 一个输入在两边的取值和 SQL */
|
||||
interface Pair {
|
||||
path: string
|
||||
|
||||
@@ -3501,8 +3501,22 @@ export interface DualCheckSummaryItem {
|
||||
diff: string[]
|
||||
/** 只有兜底说明:两条 SQL 的写法不同,未能归纳成具体差异 */
|
||||
fallback: boolean
|
||||
/**
|
||||
* 约定之外、服务端另带的:采用原流程(a)、采用盲算(b)各会往数据目录里写什么。老服务端没有这一项,卡上按差异的
|
||||
* 形状有条件地说
|
||||
*/
|
||||
catalog?: { a: DualCheckCatalogSide; b: DualCheckCatalogSide }
|
||||
}
|
||||
|
||||
/**
|
||||
* 采用一边的结果会往数据目录里写什么:服务端按发起审批时的数据目录预告(dual_check_facts.preview,和裁决后真写的
|
||||
* 是同一套判断)。write 为真时 table 的 item(business_date / valid_filter / dedup)会记为 value(有效记录条件是和
|
||||
* 目录里已有的条件合起来之后的整句);为假时 reason 说明为什么不写
|
||||
*/
|
||||
export type DualCheckCatalogSide =
|
||||
| { write: true; source?: string | null; table: string; item: string; value: unknown }
|
||||
| { write: false; reason?: string | null }
|
||||
|
||||
/** payload.kind 为 dual_check 的审批 payload(审批的 mode 是 input) */
|
||||
export interface DualCheckPayload {
|
||||
kind: 'dual_check'
|
||||
@@ -3511,6 +3525,8 @@ export interface DualCheckPayload {
|
||||
/** 完整对照放在这个工件里(GET /api/artifacts/{id}),内容见 DualCheckCard */
|
||||
card_artifact: string
|
||||
summary: DualCheckSummaryItem[]
|
||||
/** 约定之外、服务端另带的:节点实际生效的「未采用原流程时」。没有时卡上从运行的图快照里读 */
|
||||
on_reject?: 'degrade' | 'fail'
|
||||
}
|
||||
|
||||
/** 审核人的回复:POST /approvals/{id}/decide 的 value */
|
||||
|
||||
@@ -96,7 +96,7 @@ const ESTIMATE = {
|
||||
stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87,
|
||||
evidence: 91, versions: 60, catalog: 45, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
|
||||
// 占位,换成真检查时按实测改
|
||||
schedules: 60, compare: 45, verify: 45, inquiry: 1, bundle: 16, whatif: 33,
|
||||
schedules: 60, compare: 45, verify: 65, inquiry: 1, bundle: 16, whatif: 33,
|
||||
}
|
||||
// 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段
|
||||
// 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。
|
||||
|
||||
+195
-19
@@ -5,10 +5,14 @@
|
||||
// 只有兜底说明(或只有解析失败)时写明「未能归纳成具体差异」并默认展开对照;对照两条 SQL 时左右并排(窄栏上下排),
|
||||
// 原文按行比出的不同行高亮,规范化后不同的行(text_diff)分两栏高亮——夹具里的差异说明是服务端的真实输出
|
||||
// (backend/tests/test_dual_check_fixture.py 核对),规范化的行和原文对不上,高亮不能指望两边逐行相等;
|
||||
// 「只差一个方面、会记入数据目录」只在服务端真会写目录的形状下说(恰好一条具体差异,且是时间列、筛选条件或聚合);
|
||||
// 「记入数据目录」不能说得比服务端宽:服务端在摘要里预告了采用哪一边写什么(payload.summary[].catalog)时照它说,
|
||||
// 两边都不写就不提,当前选的那一句加重;老服务端没有预告时只在可能写的形状下(恰好一条具体差异,且是时间列、筛选
|
||||
// 条件或聚合)说必要条件,只有一边按时间筛、多出的是指标自己范围的条件、采用不去重的一边这几种不写的照实说——
|
||||
// 夹具里每个变体都带着服务端对这一对 SQL 的真实预告,两种说法都按它核对;
|
||||
// 三个选择缺一个都不能提交,提交的回复是 {choices, note};卡上不能再冒出通用的输入框和「提交 / 驳回并终止」
|
||||
// (mode 是 input,通用那套会把一个字符串当 value 发出去),认不出指标时另给「驳回并终止」;后果按运行类别和节点的
|
||||
// 「未采用原流程时」说(从运行的图快照里读),探索运行不提出具,读不到节点设置时有条件地说;
|
||||
// 「未采用原流程时」说(payload 带了实际生效的处置就照它说,老服务端从运行的图快照里读),探索运行不提出具,读不到
|
||||
// 节点设置时有条件地说;
|
||||
// - 检查器:口径卡、指标、口径说明、盲算模型、相对误差、未采用原流程时、启用,每一项都真的写进节点配置;字段的
|
||||
// 叫法和服务端校验报错里的(schema.py 的 DUAL_CHECK_FIELD_LABEL,合入后)一字不差;盲算模型不藏在高级选项里
|
||||
// (时间线「模型相同」那一行要能定位过来);上游有好几张口径卡时写明必须选;「跟随设置」的当前值每次打开都重取;
|
||||
@@ -152,9 +156,9 @@ const browser = await chromium.launch({ executablePath: CHROME })
|
||||
/**
|
||||
* 开一个预览用例。完整对照的工件按 artifact 答(默认夹具里的那份;给函数可以延迟、出错),这次运行的图快照按 graph
|
||||
* 答(默认夹具里的图:双盲复算节点没写「未采用原流程时」,按缺省的计入缺口),审批回复记下请求体、回一个伪造的运行;
|
||||
* 审批列表回空;别的非 GET 一律拦掉,别的 GET 放行
|
||||
* 审批列表回空;别的非 GET 一律拦掉,别的 GET 放行。query 接在预览页地址后面(用例认的参数见 dual_check.tsx)
|
||||
*/
|
||||
async function preview(syn, { width = 1280, height = 900, theme = 'dark', artifact = null, graph = null, extra = null } = {}) {
|
||||
async function preview(syn, { width = 1280, height = 900, theme = 'dark', artifact = null, graph = null, extra = null, query = '' } = {}) {
|
||||
const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: theme, timezoneId: 'Asia/Shanghai' })
|
||||
opened.add(ctx)
|
||||
ctx.setDefaultTimeout(6000)
|
||||
@@ -189,7 +193,7 @@ async function preview(syn, { width = 1280, height = 900, theme = 'dark', artifa
|
||||
if (url.pathname === '/api/approvals') return route.fulfill({ json: [] })
|
||||
return route.continue()
|
||||
})
|
||||
await page.goto(`${WEB}/preview.html?syn=${syn}&theme=${theme}&actor=张工`, { waitUntil: 'domcontentloaded' })
|
||||
await page.goto(`${WEB}/preview.html?syn=${syn}&theme=${theme}&actor=张工${query ? `&${query}` : ''}`, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForSelector(`[data-fixture="${syn}"]`)
|
||||
return { ctx, page, sent, errors }
|
||||
}
|
||||
@@ -237,9 +241,11 @@ await section('审批卡:先按摘要画,再取完整对照', async () => {
|
||||
&& await rr.locator('[data-dual-compare]').count() === 1)
|
||||
check('……兜底的原话不再列成一条「具体差异」,也不另附一遍', await rr.locator('[data-dual-diff-item]').count() === 0
|
||||
&& await rr.locator('[data-dual-fallback-note]').count() === 0)
|
||||
// 销售额只差按哪一列筛时间(一条 time):服务端会把采用的一边记成该表的业务日期,卡上有条件地说
|
||||
// 销售额只差按哪一列筛时间(一条 time):夹具的 payload 只有约定里的几项(没有服务端的预告),卡上只看得到差异的
|
||||
// 方面——服务端会把采用的一边记成该表的业务日期,可同样一条 time 也可能是只有一边按时间筛(服务端不记),只说必要条件
|
||||
const note = await text(gmv.locator('[data-dual-catalog]'))
|
||||
check('只差在时间列的指标:说明采用一边后会记成该表的业务日期,有条件地说', note.includes('业务日期') && note.includes('记入数据目录')
|
||||
check('只差在时间列的指标:说明两边按不同的列筛时间才会记成该表的业务日期,只说必要条件', note.startsWith('仅当')
|
||||
&& note.includes('业务日期') && note.includes('记入数据目录') && note.includes('只有一边按时间筛时不记')
|
||||
&& note.includes('两边都不对') && note.includes('不覆盖') && await rr.locator('[data-dual-catalog]').count() === 0, note)
|
||||
await page.waitForSelector('[data-dual-consequence][data-on-reject="degrade"]')
|
||||
const after = await text(page.locator('[data-dual-consequence]'))
|
||||
@@ -334,24 +340,58 @@ function cardWith(name, patch = {}) {
|
||||
return async () => ({ json: { id: CARD_ID, content: card } })
|
||||
}
|
||||
|
||||
await section('审批卡:「记入数据目录」只在服务端真会写的形状下说', async () => {
|
||||
// dual_check_facts.derive 的门槛:只有一个输入对不上、恰好一条具体差异、没有兜底,而且这一条是时间列、筛选条件或聚合
|
||||
/** 预告里写进去的说法在卡上的写法:业务日期写列名,其余原样(同 catalogPatch 的 patchValueText) */
|
||||
const previewValue = (v) => (typeof v === 'string' ? v : v?.column)
|
||||
|
||||
await section('审批卡:没有预告时,「记入数据目录」只说必要条件,服务端不写的几种照实说', async () => {
|
||||
// 老服务端的 payload 没有预告(payload.summary[].catalog),卡上只看得到差异的方面和原话。可能写目录的形状同
|
||||
// dual_check_facts.derive 的门槛(只有一个输入对不上、恰好一条具体差异、没有兜底,而且是时间列、筛选条件或聚合),
|
||||
// 过了门槛服务端也不一定写:只有一边按时间筛、多出的条件是指标自己的范围(不是排除无效记录)、采用了不去重的一边都
|
||||
// 不写。卡上不能说得比服务端宽——每句只说必要条件(「仅当」开头),这几种不写的照实说,免得审核人为了不让目录被写错
|
||||
// 而避开本来对的一边。变体的 catalog 是服务端对这一对 SQL 的真实预告,按它核对
|
||||
const { CATALOG_TABLE_FIELD_LABEL: LABEL } = await import(await load('/src/lib/terms.ts'))
|
||||
// [变体, 说明, 服务端不写的那一种卡上照实说的那句(只在卡上提数据目录时有)]
|
||||
const cases = [
|
||||
['time_range', null, '只差时间范围(不是换了时间列)'],
|
||||
['two_filters', null, '差两个筛选条件'],
|
||||
['join', null, '差在关联方式'],
|
||||
['parse', null, '一边解析失败'],
|
||||
['filter_extra', '有效记录条件', '只差一个筛选条件'],
|
||||
['dedup', '去重规则', '只差去不去重'],
|
||||
['time_range', '只差时间范围(不是换了时间列)'],
|
||||
['two_filters', '差两个筛选条件'],
|
||||
['join', '差在关联方式'],
|
||||
['parse', '一边解析失败'],
|
||||
['time_one_side', '只有一边按时间筛', '只有一边按时间筛时不记'],
|
||||
['filter_extra', '只多一个排除测试单的条件', '只看某个地区、某个渠道这类限定指标范围的条件不记'],
|
||||
['filter_scope', '只多一个渠道条件', '只看某个地区、某个渠道这类限定指标范围的条件不记'],
|
||||
['dedup', '只差去不去重', '采用不去重的一边时不记'],
|
||||
]
|
||||
for (const [name, want, what] of cases) {
|
||||
for (const [name, what, unwritten] of cases) {
|
||||
const { page, errors } = await preview('dual-check-approval', { artifact: cardWith(name) })
|
||||
await page.waitForSelector('[data-dual-check][data-state="ready"]')
|
||||
const gmv = metric(page, 'gmv')
|
||||
const note = await text(gmv.locator('[data-dual-catalog]'))
|
||||
check(want ? `${what}:说明会记成该表的${want}` : `${what}:不说会记入数据目录`, want ? note.includes(want) && note.includes('记入数据目录') : !note,
|
||||
note || '(无)')
|
||||
if (name === 'filter_extra') check('……筛选条件只在条件只有一边有、且采用了有它的一边时才记,照实说', note.includes('只有一边有') && note.includes('采用有它的一边'), note)
|
||||
const expect = fx.variants[name].catalog
|
||||
const writes = ['a', 'b'].filter((s) => expect[s].write)
|
||||
if (!unwritten) {
|
||||
// 差异的形状就写不了目录(服务端两边都不写):卡上不提
|
||||
check(`${what}:不说会记入数据目录(服务端两边都不写)`, !note && !writes.length, note || '(无)')
|
||||
} else {
|
||||
check(`${what}:只说必要条件(「仅当」开头),服务端不写的那一种照实说`, note.startsWith('仅当') && note.includes(unwritten)
|
||||
&& note.includes('记入数据目录') && note.includes('两边都不对') && note.includes('不覆盖') && note.includes('时间线'), note)
|
||||
// 服务端会写的那一边,卡上的必要条件里得说到写的是哪一项
|
||||
const items = writes.map((s) => LABEL[expect[s].item])
|
||||
check(`……服务端会写的(${items.join('、') || '无'})卡上说到了`, items.every((i) => note.includes(i)), note)
|
||||
}
|
||||
if (name === 'time_one_side') {
|
||||
// 正上方那条差异说「盲算没有按时间筛选」:卡上不能再说两边只差在按哪一列筛时间
|
||||
check('……不和正上方那条差异自相矛盾', (await text(gmv.locator('[data-dual-diff-item]'))).includes('没有按时间筛选')
|
||||
&& !note.includes('两边只差') && !note.includes('按哪一列'), note)
|
||||
}
|
||||
if (name === 'filter_extra' || name === 'filter_scope') {
|
||||
check('……多出的条件得只在一边、是在排除无效记录,采用有它的一边才记', note.includes('只在一边')
|
||||
&& note.includes('排除退款、作废、测试这类无效记录') && note.includes('采用有它的一边'), note)
|
||||
}
|
||||
if (name === 'dedup') {
|
||||
// 服务端:采用去重的原流程记去重规则,采用不去重的盲算不记。两种选择卡上都说清
|
||||
check('……采用去重的一边(这里是原流程)才记', expect.a.write && note.includes('采用的是去重的一边'), note)
|
||||
check('……采用不去重的一边(这里是盲算)不记', !expect.b.write && note.includes('采用不去重的一边时不记'), note)
|
||||
}
|
||||
if (name === 'two_filters') {
|
||||
check('……两条差异都列出来', await gmv.locator('[data-dual-diff-item]').count() === 2)
|
||||
}
|
||||
@@ -380,6 +420,123 @@ await section('审批卡:「记入数据目录」只在服务端真会写的
|
||||
check('几个输入都差在同一方面(合并后两条):不说会记入数据目录', await metric(two.page, 'gmv').locator('[data-dual-catalog]').count() === 0)
|
||||
})
|
||||
|
||||
/** 一块里预告的三句:{a, b, neither} → {text, write, current} */
|
||||
const previewClauses = (loc) => loc.evaluate((el) => Object.fromEntries([...el.querySelectorAll('[data-dual-catalog="preview"] [data-dual-catalog-side]')]
|
||||
.map((s) => [s.dataset.dualCatalogSide, {
|
||||
text: s.textContent.trim(), write: s.dataset.write != null, current: s.dataset.current != null, weight: Number(getComputedStyle(s).fontWeight),
|
||||
}])))
|
||||
|
||||
await section('审批卡:服务端预告了写目录时照它说,当前选的那一句加重', async () => {
|
||||
// payload.summary[].catalog(约定之外、服务端另带的):采用原流程、采用盲算各会往数据目录里写什么,和裁决后真写的
|
||||
// 是同一套判断(dual_check_facts.preview)。有它就照它说:两边都不写不提数据目录,写的那一边说清哪张表的哪一项记成
|
||||
// 什么。夹具里的预告是服务端对这几对 SQL 的真实输出(test_dual_check_fixture.py 核对)
|
||||
const { CATALOG_TABLE_FIELD_LABEL: LABEL } = await import(await load('/src/lib/terms.ts'))
|
||||
|
||||
// 口径卡原样:销售额两边换了时间列(选哪边记哪一列),退款率只有兜底(两边都不写)。预告跟着摘要来,完整对照没到也说
|
||||
let release
|
||||
const gate = new Promise((r) => { release = r })
|
||||
const base = await preview('dual-check-approval', {
|
||||
query: 'catalog=1', artifact: async () => { await gate; return { json: { id: CARD_ID, content: fx.card } } },
|
||||
})
|
||||
await base.page.waitForSelector('[data-dual-metric="gmv"] [data-dual-catalog="preview"]')
|
||||
const gmv = metric(base.page, 'gmv')
|
||||
check('完整对照还没到:已照预告说', (await base.page.locator('[data-dual-check]').getAttribute('data-state')) === 'loading')
|
||||
let c = await previewClauses(gmv)
|
||||
check('两边换了时间列:采用原流程记原流程那一列', c.a?.text === '采用原流程的结果时,「orders」(shop)的业务日期记为「paid_at」' && c.a.write, c.a?.text)
|
||||
check('……采用盲算记盲算那一列(同一项,省掉哪张表的哪一项)', c.b?.text === '采用盲算的结果时记为「created_at」' && c.b.write, c.b?.text)
|
||||
check('……两边都不对时不记', c.neither?.text === '两边都不对时不记' && !c.neither.write, c.neither?.text)
|
||||
const said = await text(gmv.locator('[data-dual-catalog]'))
|
||||
check('……开头说记入数据目录,结尾说以时间线为准,不再用有条件的说法', said.startsWith('记入数据目录:') && said.includes('人工确认')
|
||||
&& said.includes('时间线') && !said.includes('仅当'), said)
|
||||
check('退款率(服务端两边都不写):不提数据目录', await metric(base.page, 'refund_rate').locator('[data-dual-catalog]').count() === 0)
|
||||
release()
|
||||
await base.page.waitForSelector('[data-dual-check][data-state="ready"]')
|
||||
check('完整对照到了:照样按预告说,不换成有条件的说法', await gmv.locator('[data-dual-catalog="preview"]').count() === 1
|
||||
&& await base.page.locator('[data-dual-catalog="time"]').count() === 0)
|
||||
check('没选之前三句都不加重', !c.a.current && !c.b.current && !c.neither.current)
|
||||
for (const [choice, label] of [['a', '采用原流程的结果'], ['b', '采用盲算的结果'], ['neither', '两边都不对']]) {
|
||||
await gmv.locator('label', { hasText: label }).click()
|
||||
c = await previewClauses(gmv)
|
||||
const others = ['a', 'b', 'neither'].filter((s) => s !== choice)
|
||||
check(`选「${label}」:这一句加重,另两句不加重`, c[choice].current && c[choice].weight >= 500
|
||||
&& others.every((s) => !c[s].current && c[s].weight < 500), JSON.stringify(c))
|
||||
}
|
||||
await shot(base.page, 'approval-preview-dark')
|
||||
|
||||
// 几个变体:预告说两边都不写的,卡上就不提(没有预告时这几种会有条件地提);写的那一边说清写到哪、写成什么
|
||||
for (const [name, what] of [['time_one_side', '只有一边按时间筛'], ['filter_scope', '只多一个渠道条件'], ['filter_extra', '只多一个排除测试单的条件'],
|
||||
['dedup', '只差去不去重'], ['time_range', '只差时间范围']]) {
|
||||
const { page, errors } = await preview('dual-check-approval', { query: `variant=${name}&catalog=1`, artifact: cardWith(name) })
|
||||
await page.waitForSelector('[data-dual-check][data-state="ready"]')
|
||||
const m = metric(page, 'gmv')
|
||||
const expect = fx.variants[name].catalog
|
||||
const writes = ['a', 'b'].filter((s) => expect[s].write)
|
||||
check(`${what}(变体):摘要换成这一对 SQL 的差异`, (await text(m.locator('[data-dual-diff-item]'))) === fx.variants[name].diff.items[0].text,
|
||||
await text(m.locator('[data-dual-diff-item]')))
|
||||
if (!writes.length) {
|
||||
check(`${what}:服务端两边都不写,卡上不提数据目录`, await m.locator('[data-dual-catalog]').count() === 0, await text(m.locator('[data-dual-catalog]')))
|
||||
} else {
|
||||
const got = await previewClauses(m)
|
||||
const ok = ['a', 'b'].every((s) => (expect[s].write
|
||||
? got[s]?.write && got[s].text.includes(LABEL[expect[s].item]) && got[s].text.includes(`记为「${previewValue(expect[s].value)}」`)
|
||||
: !got[s]?.write && got[s]?.text.endsWith('时不记')))
|
||||
check(`${what}:写的一边说清写到哪、写成什么,不写的一边说不记`, ok, `${got.a?.text} ‖ ${got.b?.text}`)
|
||||
if (name === 'dedup') {
|
||||
// 采用去重的原流程记去重规则,采用不去重的盲算不记:两种选择分别看加重的那一句
|
||||
await m.locator('label', { hasText: '采用原流程的结果' }).click()
|
||||
let cur = await previewClauses(m)
|
||||
check('只差去不去重,选原流程(去重):加重的那一句说记为去重规则', cur.a.current
|
||||
&& cur.a.text === '采用原流程的结果时,「orders」(shop)的去重规则记为「对 user_id 计数时去重」', cur.a.text)
|
||||
await m.locator('label', { hasText: '采用盲算的结果' }).click()
|
||||
cur = await previewClauses(m)
|
||||
check('……选盲算(不去重):加重的那一句说不记', cur.b.current && cur.b.text === '采用盲算的结果时不记' && !cur.a.current, cur.b.text)
|
||||
}
|
||||
}
|
||||
check(`${what}:页面没有报错`, errors.length === 0, errors.join(';'))
|
||||
}
|
||||
|
||||
// 预告认不出(老版本、写坏了):不照它说,回到按差异形状有条件地说
|
||||
const bad = await preview('dual-check-approval', { query: 'catalog=bad' })
|
||||
await bad.page.waitForSelector('[data-dual-check][data-state="ready"]')
|
||||
const fallback = await text(metric(bad.page, 'gmv').locator('[data-dual-catalog]'))
|
||||
check('预告认不出:回到有条件的说法', (await metric(bad.page, 'gmv').locator('[data-dual-catalog]').getAttribute('data-dual-catalog')) === 'time'
|
||||
&& fallback.startsWith('仅当'), fallback)
|
||||
|
||||
// 完整对照取不到:预告跟着摘要来,照样说
|
||||
const lost = await preview('dual-check-approval', { query: 'catalog=1', artifact: async () => ({ status: 404, json: { detail: '工件不存在' } }) })
|
||||
await lost.page.waitForSelector('[data-dual-load-failed]')
|
||||
check('完整对照取不到:照预告说', (await previewClauses(metric(lost.page, 'gmv'))).a?.write === true)
|
||||
|
||||
// 窄屏、深浅色:预告里的条件、取值很长也不撑宽;平常那几句和加重的那一句都读得清
|
||||
for (const theme of ['dark', 'light']) {
|
||||
const narrow = await preview('dual-check-approval', {
|
||||
query: 'variant=filter_extra&catalog=1', artifact: cardWith('filter_extra'), width: 390, height: 844, theme,
|
||||
})
|
||||
await narrow.page.waitForSelector('[data-dual-check][data-state="ready"]')
|
||||
await metric(narrow.page, 'gmv').locator('label', { hasText: '采用原流程的结果' }).click()
|
||||
const w = await narrow.page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||||
check(`390px(${theme}):预告不横向溢出`, w <= 0, `${w}px`)
|
||||
const c = await narrow.page.evaluate(() => {
|
||||
const base = getComputedStyle(document.body).backgroundColor
|
||||
const bgOf = (el) => {
|
||||
for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
|
||||
const v = getComputedStyle(n).backgroundColor
|
||||
if (v !== 'rgba(0, 0, 0, 0)' && v !== 'transparent') return v
|
||||
}
|
||||
return 'rgba(0, 0, 0, 0)'
|
||||
}
|
||||
const pick = (sel) => { const el = document.querySelector(sel); return el && { fg: getComputedStyle(el).color, bg: bgOf(el) } }
|
||||
return { base, note: pick('[data-dual-catalog="preview"]'), current: pick('[data-dual-catalog-side][data-current]') }
|
||||
})
|
||||
for (const key of ['note', 'current']) {
|
||||
const v = c[key]
|
||||
const ratio = v ? contrast(v.fg, v.bg, c.base) : 0
|
||||
check(`${theme}:${key === 'note' ? '预告' : '预告里加重的那一句'}对比度 ≥ 4.5`, ratio >= 4.5, `${ratio.toFixed(2)}(${v?.fg} on ${v?.bg})`)
|
||||
}
|
||||
await shot(narrow.page, `approval-preview-narrow-${theme}`)
|
||||
}
|
||||
})
|
||||
|
||||
await section('审批卡:只有解析失败、兜底原话另有原因时', async () => {
|
||||
// 只有解析失败:和摘要阶段(服务端把它算作兜底)说法一致,默认展开对照,解析的原话附在下面
|
||||
const { page } = await preview('dual-check-approval', { artifact: cardWith('parse') })
|
||||
@@ -458,6 +615,25 @@ await section('审批卡:后果和提交后的提示按节点的「未采用
|
||||
await lost.page.waitForFunction(() => document.querySelector('[role="status"][aria-live="polite"]')?.textContent?.includes('已提交裁决'))
|
||||
const toast = await toastOf(lost.page)
|
||||
check('……提交后的提示不替节点下结论', toast === '已提交裁决', toast)
|
||||
|
||||
// 服务端在 payload 里带了实际生效的处置(约定之外另带的):照它说,不再取图快照——子工作流里的节点图快照里找不到,
|
||||
// 有了它也说得准
|
||||
let asked = 0
|
||||
const given = await preview('dual-check-approval', {
|
||||
query: 'on_reject=fail', graph: async () => { asked++; return { json: { graph: fx.graph, workflow_id: null, version: null } } },
|
||||
})
|
||||
await given.page.waitForSelector('[data-dual-check][data-state="ready"]')
|
||||
await given.page.waitForSelector('[data-dual-consequence][data-on-reject="fail"]')
|
||||
check('payload 带了「判为失败」:照它说(图快照里是缺省的计入缺口)', (await text(given.page.locator('[data-dual-consequence]'))).includes('运行将终止'))
|
||||
check('……不再取运行的图快照', asked === 0, `${asked} 次`)
|
||||
const sub = await preview('dual-check-approval', { query: 'on_reject=degrade', graph: async () => ({ json: { graph: { nodes: [], edges: [] } } }) })
|
||||
await sub.page.waitForSelector('[data-dual-consequence][data-on-reject="degrade"]')
|
||||
check('……图快照里找不到节点(子工作流)也照 payload 说,不挂「如节点设为判为失败」那句',
|
||||
!(await text(sub.page.locator('[data-dual-consequence]'))).includes('如节点'))
|
||||
// 认不出的值:不照它说,回到图快照
|
||||
const odd = await preview('dual-check-approval', { query: 'on_reject=bogus', graph: graphWith('fail') })
|
||||
const back = await odd.page.waitForSelector('[data-dual-consequence][data-on-reject="fail"]').then(() => true, () => false)
|
||||
check('payload 里的值认不出:回到图快照', back, await odd.page.locator('[data-dual-consequence]').getAttribute('data-on-reject'))
|
||||
})
|
||||
|
||||
await section('审批卡:认不出任何指标时留一个出口', async () => {
|
||||
|
||||
Reference in New Issue
Block a user