mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:11:59 +00:00
双盲复算前端复核修正(第 1 轮):高亮按服务端真实的差异输出做,「记入数据目录」只在服务端真会写时有条件地说,后果和提示按节点的处置说
审批卡 - 对照两条 SQL:text_diff 是规范化之后的行(列名带表名、条件并成一行),和原文逐行对不上,原来按「两边逐行相等」高亮在真实输出上一处都标不出。改成两段原文按行做一次 LCS(不看空白、大小写和行尾逗号分号)高亮不同的行,一行都对不上时不乱标;text_diff 分原流程、盲算两栏,每行高亮(窄栏上下排)。 - 「只差一个方面、会记入数据目录」:照服务端 dual_check_facts 的门槛收紧——不等价、没用兜底、差异恰好一条(几个输入都对不上时合并后至少两条,所以也就是只有一个输入对不上),且是时间列、筛选条件或聚合;按这三种分别有条件地说会记成哪一项,选「两边都不对」不记、已有人工确认的不覆盖、以时间线为准。时间范围、两条筛选、关联、解析失败、多个输入合并都不再说。 - 后果和提交后的提示:payload 不带 on_reject,从这次运行的图快照里按节点 id 读「未采用原流程时」;判为失败时写明采用盲算或都不对会终止运行、提示也照实说,缺省时不再挂那句,读不到时有条件地说、提示不下结论。 - 只有解析失败时和摘要阶段一样按兜底说(固定那句 + 解析的原话),默认展开对照;兜底的原话另有原因(比较超时)时附在下面,一边没有 SQL 时不说「两条 SQL 的写法不同」。 - 认不出任何指标时留一个「驳回并终止」(approved 为假),运行不再一直挂着。 时间线 - dual_check_catalog 的 item 是目录项的键(business_date 等),按数据目录的叫法写成「「orders」(shop)的业务日期」,认不出的键不显示。 - dual_check_limit 的 metric 是指标 ID,不再当名字加引号。 - challenge_skipped 的 no_report 是报告文档读不出来(报告节点找不到时节点直接报错),说法和下一步改成去查报告节点有没有执行成功、在不在执行到的分支上。 检查器 - 字段名和服务端校验报错的叫法(schema.py 的 DUAL_CHECK_FIELD_LABEL)对齐,处置选项以服务端的短说法开头;check-verify 在服务端合入后逐项比对。 - 盲算模型移出高级选项:时间线「模型相同」的定位要找得到它。 - 上游有几张口径卡时写明必须选(服务端校验会挡住发起运行),不再说「建议指定」。 - 「跟随设置」的当前值每次打开检查器都重取,改了设置回到画布不再是旧值。 测试 - 夹具里的差异说明换成 explain_sql_diff 对同一对 SQL 的真实输出,新增几对 SQL 的变体;backend/tests/test_dual_check_fixture.py 核对夹具和真实输出一致,并可重新生成。 - check-verify 补真实形状下的高亮、各种差异下「记入数据目录」说不说、只有解析失败和兜底原因、按处置说后果和提示、认不出指标时的驳回、叫法比对、多张口径卡、设置重取;check-decode 改用服务端真实的日志形状。check-verify 的耗时估计按实测改成 45 秒。
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
"""双盲复算审批卡的前端夹具(frontend/src/dev/fixtures/dual_check.json)要和服务端的真实输出对得上。
|
||||
|
||||
审批卡上「对照两条 SQL」的高亮、「只差一个方面会记入数据目录」的提示,都是照 explain_sql_diff 的输出画的。它的
|
||||
text_diff 是规范化之后的行(sqlglot pretty:列名带表名、条件并成一行),和原文的行对不上;夹具要是照原文手写,
|
||||
check-verify 在夹具上能过,真实数据上却一处都不高亮(2026-10 复核时发现)。所以这里按夹具里的两条 SQL 重新算一遍
|
||||
差异说明,逐个指标、逐个变体和夹具里写的比。sql_semantics 改了说法或规范形式,这里先变红,按下面的办法重新生成。
|
||||
|
||||
审批 payload 的摘要照双盲复算节点的写法核对:diff 是前 3 条差异的原话,fallback 是「除了兜底和解析失败,一条具体
|
||||
差异都没有」,delta_pct 是 (盲算值 − 原流程值) ÷ |原流程值| × 100 保留两位。夹具的数据源当 SQLite 比(方言 sqlite),
|
||||
两边的叫法是「原流程」「盲算」,同节点调用时一样。
|
||||
|
||||
重新生成夹具里的差异说明(只改 diff、摘要的 diff 和 fallback,其余原样):
|
||||
cd backend && PYTHONPATH=. python tests/test_dual_check_fixture.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.data.sql_semantics import explain_sql_diff
|
||||
|
||||
FIXTURE = Path(__file__).resolve().parents[2] / "frontend" / "src" / "dev" / "fixtures" / "dual_check.json"
|
||||
DIALECT = "sqlite"
|
||||
LABELS = ("原流程", "盲算")
|
||||
#: 摘要里每个指标最多列几条差异(同双盲复算节点的 SUMMARY_DIFFS)
|
||||
SUMMARY_DIFFS = 3
|
||||
|
||||
|
||||
def _diff(original: str, blind: str) -> dict[str, Any]:
|
||||
"""工件里的 diff 只留界面用得到的几项:条目只有 aspect、text,不带两边的写法和 detail(同节点写工件时)。"""
|
||||
out = explain_sql_diff(original, blind, DIALECT, labels=LABELS).as_dict()
|
||||
return {"equivalent": out["equivalent"], "items": [{"aspect": i["aspect"], "text": i["text"]} for i in out["items"]],
|
||||
"fallback": out["fallback"], "text_diff": out["text_diff"]}
|
||||
|
||||
|
||||
def _differing(metric: dict[str, Any]) -> list[tuple[dict[str, Any], dict[str, Any]]]:
|
||||
"""两边取值不同的输入(按 path 配对):节点只比这些输入的 SQL。"""
|
||||
blind = {i["path"]: i for i in metric["blind"]["inputs"]}
|
||||
return [(i, blind[i["path"]]) for i in metric["original"]["inputs"] if i["value"] != blind[i["path"]]["value"]]
|
||||
|
||||
|
||||
def _summary_of(metric: dict[str, Any]) -> dict[str, Any]:
|
||||
items = metric["diff"]["items"]
|
||||
original, blind = metric["original"]["value"], metric["blind"]["value"]
|
||||
return {
|
||||
"diff": [i["text"] for i in items[:SUMMARY_DIFFS]],
|
||||
"fallback": not any(i["aspect"] not in ("other", "parse") for i in items),
|
||||
"delta_pct": None if original == 0 else round((blind - original) / abs(original) * 100, 2),
|
||||
"original_rendered": metric["original"]["rendered"],
|
||||
"blind_rendered": metric["blind"]["rendered"],
|
||||
}
|
||||
|
||||
|
||||
def _load() -> dict[str, Any]:
|
||||
return json.loads(FIXTURE.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_card_metrics_carry_the_real_diff():
|
||||
fx = _load()
|
||||
for metric in fx["card"]["metrics"]:
|
||||
pairs = _differing(metric)
|
||||
# 夹具里每个指标只有一个输入对不上:节点这时直接用这一处的差异说明,几处时才会合并、加前缀
|
||||
assert len(pairs) == 1, metric["metric_id"]
|
||||
(a, b), = pairs
|
||||
assert metric["diff"] == _diff(a["sql"], b["sql"]), metric["metric_id"]
|
||||
|
||||
|
||||
def test_summary_follows_the_card():
|
||||
fx = _load()
|
||||
summary = {s["metric_id"]: s for s in fx["approval"]["payload"]["summary"]}
|
||||
assert list(summary) == [m["metric_id"] for m in fx["card"]["metrics"]]
|
||||
for metric in fx["card"]["metrics"]:
|
||||
got = summary[metric["metric_id"]]
|
||||
assert {k: got[k] for k in _summary_of(metric)} == _summary_of(metric), metric["metric_id"]
|
||||
|
||||
|
||||
def test_variants_carry_the_real_diff():
|
||||
fx = _load()
|
||||
assert fx["variants"]
|
||||
for name, variant in fx["variants"].items():
|
||||
original, blind = variant["sql"]
|
||||
assert variant["diff"] == _diff(original, blind), name
|
||||
|
||||
|
||||
def test_text_diff_is_not_the_raw_text():
|
||||
"""守住这次的教训:至少有一处规范化之后的行和原文的任何一行都不一样(真实输出就是这样),夹具不再照原文手写。"""
|
||||
fx = _load()
|
||||
gmv = next(m for m in fx["card"]["metrics"] if m["metric_id"] == "gmv")
|
||||
raw = {" ".join(line.split()).lower() for side in ("original", "blind") for line in gmv[side]["inputs"][0]["sql"].split("\n")}
|
||||
shown = [" ".join(line[2:].split()).lower() for line in gmv["diff"]["text_diff"]]
|
||||
assert shown and not any(line in raw for line in shown)
|
||||
|
||||
|
||||
def regenerate() -> None:
|
||||
fx = _load()
|
||||
for metric in fx["card"]["metrics"]:
|
||||
(a, b), = _differing(metric)
|
||||
metric["diff"] = _diff(a["sql"], b["sql"])
|
||||
summary = {s["metric_id"]: s for s in fx["approval"]["payload"]["summary"]}
|
||||
for metric in fx["card"]["metrics"]:
|
||||
summary[metric["metric_id"]].update(_summary_of(metric))
|
||||
for variant in fx["variants"].values():
|
||||
variant["diff"] = _diff(*variant["sql"])
|
||||
FIXTURE.write_text(json.dumps(fx, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
regenerate()
|
||||
@@ -95,24 +95,27 @@ export function dualCheckSummary({ id, config, nodes, edges }: NodeSummaryContex
|
||||
// ---------------------------------------------------------------- 设置里的默认值
|
||||
|
||||
/**
|
||||
* 设置里核查一组的值:相对误差、启用的「跟随设置」要写出眼下是多少。整个会话取一次(同检查器的运行默认值),
|
||||
* 取不到就不写,不挡编辑
|
||||
* 设置里核查一组的值:相对误差、启用的「跟随设置」要写出现在是多少。每次打开检查器都重取一次——去设置页关掉双盲
|
||||
* 复算、改了误差再回到画布,这里得是新值(不同于检查器的运行默认值,核查开关是常改的)。先用上次取到的画,免得
|
||||
* 闪一下;同一次打开里的几个字段共用一个请求;取不到就沿用上次的(没有就不写),不挡编辑
|
||||
*/
|
||||
let verifyPending: Promise<VerifySettings | null> | null = null
|
||||
let verifyValue: VerifySettings | null | undefined
|
||||
function fetchVerify(): Promise<VerifySettings | null> {
|
||||
verifyPending ??= api.settings.get().then(
|
||||
(s) => (verifyValue = isRecord(s?.verify) ? s.verify as VerifySettings : null),
|
||||
() => verifyValue ?? null,
|
||||
).finally(() => { verifyPending = null })
|
||||
return verifyPending
|
||||
}
|
||||
function useVerifyDefaults(): VerifySettings | null {
|
||||
const [value, setValue] = useState<VerifySettings | null>(verifyValue ?? null)
|
||||
useEffect(() => {
|
||||
if (verifyValue !== undefined) return
|
||||
verifyPending ??= api.settings.get().then(
|
||||
(s) => (verifyValue = isRecord(s?.verify) ? s.verify as VerifySettings : null),
|
||||
() => { verifyPending = null; return null }, // 这次没取到,下次打开再试
|
||||
)
|
||||
let alive = true
|
||||
void verifyPending.then((v) => { if (alive) setValue(v) })
|
||||
void fetchVerify().then((v) => { if (alive) setValue(v) })
|
||||
return () => { alive = false }
|
||||
}, [])
|
||||
return verifyValue ?? value
|
||||
return value
|
||||
}
|
||||
|
||||
/** 字段下面的一行提醒(所选口径卡不在上游之类),提醒色 */
|
||||
@@ -144,6 +147,8 @@ function CardField({ id, nodeId, value, invalid, describedBy, onChange }: FieldR
|
||||
{/* 写着的卡已经不在画布上:照实列出来(不让它看着像「默认」),下面再说一句 */}
|
||||
{current && !known && <option value={current}>{T.cardGone(current)}</option>}
|
||||
</select>
|
||||
{/* 上游有好几张又没选:服务端校验会挡住发起运行,用提醒色写明要选 */}
|
||||
{!current && upstream.length > 1 && <Warn attr="data-dual-card-warn">{T.cardChoose(upstream.length)}</Warn>}
|
||||
{current && known && !upstream.some((n) => n.id === current) && <Warn attr="data-dual-card-warn">{T.cardNotUpstream}</Warn>}
|
||||
{current && !known && <Warn attr="data-dual-card-warn">{T.cardMissing(current)}</Warn>}
|
||||
</>
|
||||
@@ -163,7 +168,7 @@ function MetricsField({ id, nodeId, value, config, describedBy, onChange }: Fiel
|
||||
if (!target.node || metrics === undefined) {
|
||||
return (
|
||||
<div id={id} className="text-2xs leading-snug text-faint" aria-describedby={describedBy} data-dual-metrics="none">
|
||||
{target.upstream.length > 1 && !target.card ? T.summary.many(target.upstream.length) : T.metricsNoCard}
|
||||
{target.upstream.length > 1 && !target.card ? T.cardChoose(target.upstream.length) : T.metricsNoCard}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -695,7 +695,8 @@ export const NODE_DEFS: Record<NodeType, NodeDef> = {
|
||||
description: DUAL_CHECK_TEXT.description,
|
||||
hasTarget: true, sources: [{ id: 'out', label: '' }],
|
||||
// 配置的键和缺省见 types.ts 的 DualCheckConfig(期 3 约定)。口径卡、指标、口径说明要看画布上别的节点,
|
||||
// 盲算模型是接入加模型,启用是三态,都交给 DualCheckFields 画;type 只管默认值和校验时当什么看
|
||||
// 盲算模型是接入加模型,启用是三态,都交给 DualCheckFields 画;type 只管默认值和校验时当什么看。
|
||||
// 盲算模型不放进高级选项:时间线上「模型相同」那一行的「打开设置」要定位到它,高级选项收着时定位不到
|
||||
fields: [
|
||||
{ key: 'card', label: DUAL_CHECK_TEXT.field.card, type: 'select', help: DUAL_CHECK_TEXT.help.card, render: dualCheckFields.card },
|
||||
{
|
||||
@@ -712,16 +713,16 @@ export const NODE_DEFS: Record<NodeType, NodeDef> = {
|
||||
},
|
||||
{
|
||||
// 没写和写 degrade 是一回事:选项从 degrade 排起,没写时下拉框落在它上面,正好是缺省
|
||||
key: 'on_reject', label: DUAL_CHECK_TEXT.field.onReject, type: 'select', help: DUAL_CHECK_TEXT.help.onReject,
|
||||
key: 'on_reject', label: DUAL_CHECK_TEXT.field.on_reject, type: 'select', help: DUAL_CHECK_TEXT.help.on_reject,
|
||||
options: [
|
||||
{ value: 'degrade', label: DUAL_CHECK_TEXT.onReject.degrade },
|
||||
{ value: 'fail', label: DUAL_CHECK_TEXT.onReject.fail },
|
||||
],
|
||||
unknownLabel: DUAL_CHECK_TEXT.onRejectUnknown,
|
||||
},
|
||||
{ key: 'provider', label: DUAL_CHECK_TEXT.field.provider, type: 'select', advanced: true, render: dualCheckFields.provider },
|
||||
{ key: 'provider', label: DUAL_CHECK_TEXT.field.provider, type: 'select', render: dualCheckFields.provider },
|
||||
{
|
||||
key: 'model', label: DUAL_CHECK_TEXT.field.model, type: 'text', advanced: true, help: DUAL_CHECK_TEXT.help.model,
|
||||
key: 'model', label: DUAL_CHECK_TEXT.field.model, type: 'text', help: DUAL_CHECK_TEXT.help.model,
|
||||
render: dualCheckFields.model,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"_note": "双盲复算的预览用例和 scripts/check-verify.mjs 共用的夹具。表名、数字都是虚构的通用示例,不来自任何真实数据。",
|
||||
"_note": "双盲复算的预览用例和 scripts/check-verify.mjs 共用的夹具。表名、数字都是虚构的通用示例,不来自任何真实数据。各处的 diff(差异说明)是服务端 explain_sql_diff 对同一对 SQL 的真实输出(方言 sqlite),由 backend/tests/test_dual_check_fixture.py 核对和重新生成,不要手改。variants 是另几对 SQL 及其差异说明,检查脚本拿来换掉「销售额」那个指标,看不同的差异下卡上怎么说",
|
||||
"approval": {
|
||||
"id": "ap-dc-0001",
|
||||
"run_id": "run-dc-0001",
|
||||
@@ -25,7 +25,7 @@
|
||||
"blind_rendered": "51,388.3元",
|
||||
"delta_pct": 12.5,
|
||||
"diff": [
|
||||
"原流程按支付时间 paid_at 筛选时间范围,盲算按下单时间 created_at 筛选"
|
||||
"原流程按 paid_at 筛时间,盲算按 created_at"
|
||||
],
|
||||
"fallback": false
|
||||
},
|
||||
@@ -36,7 +36,9 @@
|
||||
"original_rendered": "2.35%",
|
||||
"blind_rendered": "3.10%",
|
||||
"delta_pct": 31.91,
|
||||
"diff": [],
|
||||
"diff": [
|
||||
"两条 SQL 的写法不同,未能归纳成具体差异,请对照原文"
|
||||
],
|
||||
"fallback": true
|
||||
}
|
||||
]
|
||||
@@ -83,15 +85,13 @@
|
||||
"items": [
|
||||
{
|
||||
"aspect": "time",
|
||||
"text": "原流程按支付时间 paid_at 筛选时间范围,盲算按下单时间 created_at 筛选"
|
||||
"text": "原流程按 paid_at 筛时间,盲算按 created_at"
|
||||
}
|
||||
],
|
||||
"fallback": false,
|
||||
"text_diff": [
|
||||
"- WHERE paid_at >= '2026-09-01'",
|
||||
"- AND paid_at < '2026-09-08'",
|
||||
"+ WHERE created_at >= '2026-09-01'",
|
||||
"+ AND created_at < '2026-09-08'"
|
||||
"- orders.paid_at >= '2026-09-01' AND orders.paid_at < '2026-09-08'",
|
||||
"+ orders.created_at >= '2026-09-01' AND orders.created_at < '2026-09-08'"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -107,7 +107,7 @@
|
||||
{
|
||||
"path": "vars.kpi.refunds",
|
||||
"value": 47,
|
||||
"sql": "SELECT COUNT(*) AS refunds FROM refunds r JOIN orders o ON o.id = r.order_id WHERE o.status <> 'test'",
|
||||
"sql": "SELECT COUNT(*) AS refunds\nFROM refunds r\nWHERE EXISTS (\n SELECT 1 FROM orders o\n WHERE o.id = r.order_id AND o.status <> 'test'\n)",
|
||||
"source": "shop",
|
||||
"alias": "Q2"
|
||||
},
|
||||
@@ -127,10 +127,10 @@
|
||||
{
|
||||
"path": "vars.kpi.refunds",
|
||||
"value": 62,
|
||||
"sql": "SELECT COUNT(DISTINCT order_id) AS refunds FROM refunds WHERE order_id IN (SELECT id FROM orders)",
|
||||
"sql": "SELECT COUNT(*) AS refunds\nFROM refunds r\nWHERE r.order_id IN (SELECT id FROM orders)",
|
||||
"source": "shop",
|
||||
"alias": "B2",
|
||||
"rationale": "退款笔数按有退款记录的订单去重计数"
|
||||
"rationale": "退款笔数:订单表中能找到对应订单的退款记录数"
|
||||
},
|
||||
{
|
||||
"path": "vars.kpi.orders",
|
||||
@@ -152,8 +152,12 @@
|
||||
],
|
||||
"fallback": true,
|
||||
"text_diff": [
|
||||
"- SELECT COUNT(*) AS refunds FROM refunds AS r JOIN orders AS o ON o.id = r.order_id WHERE o.status <> 'test'",
|
||||
"+ SELECT COUNT(DISTINCT order_id) AS refunds FROM refunds WHERE order_id IN (SELECT id FROM orders)"
|
||||
"- EXISTS(",
|
||||
"+ refunds.order_id IN (",
|
||||
"- 1",
|
||||
"+ \"orders@1\".id",
|
||||
"- WHERE",
|
||||
"- \"orders@1\".id = refunds.order_id AND \"orders@1\".status <> 'test'"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -370,5 +374,132 @@
|
||||
"timeout_s": 120
|
||||
},
|
||||
"daily_max_usd": 2.0
|
||||
},
|
||||
"variants": {
|
||||
"time_range": {
|
||||
"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\nWHERE paid_at >= '2026-09-01'\n AND paid_at < '2026-09-09'"
|
||||
],
|
||||
"diff": {
|
||||
"equivalent": false,
|
||||
"items": [
|
||||
{
|
||||
"aspect": "time_range",
|
||||
"text": "原流程的时间范围是 paid_at >= '2026-09-01' AND paid_at < '2026-09-08',盲算是 paid_at >= '2026-09-01' AND paid_at < '2026-09-09'"
|
||||
}
|
||||
],
|
||||
"fallback": false,
|
||||
"text_diff": [
|
||||
"- 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'"
|
||||
]
|
||||
}
|
||||
},
|
||||
"filter_extra": {
|
||||
"sql": [
|
||||
"SELECT COUNT(*) AS orders\nFROM orders\nWHERE status <> 'test'",
|
||||
"SELECT COUNT(*) AS orders\nFROM orders"
|
||||
],
|
||||
"diff": {
|
||||
"equivalent": false,
|
||||
"items": [
|
||||
{
|
||||
"aspect": "filter",
|
||||
"text": "原流程排除了 status = 'test' 的行,盲算没有"
|
||||
}
|
||||
],
|
||||
"fallback": false,
|
||||
"text_diff": [
|
||||
"- WHERE",
|
||||
"- orders.status <> 'test'"
|
||||
]
|
||||
}
|
||||
},
|
||||
"two_filters": {
|
||||
"sql": [
|
||||
"SELECT COUNT(*) AS orders\nFROM orders\nWHERE channel = 'web'\n AND status <> 'refunded'",
|
||||
"SELECT COUNT(*) AS orders\nFROM orders"
|
||||
],
|
||||
"diff": {
|
||||
"equivalent": false,
|
||||
"items": [
|
||||
{
|
||||
"aspect": "filter",
|
||||
"text": "原流程只保留 channel = 'web' 的行,盲算没有这个条件"
|
||||
},
|
||||
{
|
||||
"aspect": "filter",
|
||||
"text": "原流程排除了 status = 'refunded' 的行,盲算没有"
|
||||
}
|
||||
],
|
||||
"fallback": false,
|
||||
"text_diff": [
|
||||
"- WHERE",
|
||||
"- orders.channel = 'web' AND orders.status <> 'refunded'"
|
||||
]
|
||||
}
|
||||
},
|
||||
"join": {
|
||||
"sql": [
|
||||
"SELECT COUNT(*) AS orders\nFROM orders o\nLEFT JOIN refunds r ON r.order_id = o.id",
|
||||
"SELECT COUNT(*) AS orders\nFROM orders o\nJOIN refunds r ON r.order_id = o.id"
|
||||
],
|
||||
"diff": {
|
||||
"equivalent": false,
|
||||
"items": [
|
||||
{
|
||||
"aspect": "join",
|
||||
"text": "原流程用 LEFT JOIN 关联 refunds 表,盲算用 INNER JOIN"
|
||||
}
|
||||
],
|
||||
"fallback": false,
|
||||
"text_diff": [
|
||||
"- FROM orders AS orders",
|
||||
"- LEFT JOIN refunds AS refunds",
|
||||
"+ FROM orders AS orders, refunds AS refunds",
|
||||
"+ WHERE",
|
||||
"- ON orders.id = refunds.order_id",
|
||||
"+ orders.id = refunds.order_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"dedup": {
|
||||
"sql": [
|
||||
"SELECT COUNT(DISTINCT user_id) AS buyers\nFROM orders",
|
||||
"SELECT COUNT(user_id) AS buyers\nFROM orders"
|
||||
],
|
||||
"diff": {
|
||||
"equivalent": false,
|
||||
"items": [
|
||||
{
|
||||
"aspect": "aggregate",
|
||||
"text": "原流程对 user_id 去重计数,盲算没去重"
|
||||
}
|
||||
],
|
||||
"fallback": false,
|
||||
"text_diff": [
|
||||
"- COUNT(DISTINCT orders.user_id)",
|
||||
"+ COUNT(orders.user_id)"
|
||||
]
|
||||
}
|
||||
},
|
||||
"parse": {
|
||||
"sql": [
|
||||
"SELECT SUM(amount) AS gmv\nFROM orders",
|
||||
"SELEC SUM(amount) AS gmv\nFROM orders WHERE"
|
||||
],
|
||||
"diff": {
|
||||
"equivalent": false,
|
||||
"items": [
|
||||
{
|
||||
"aspect": "parse",
|
||||
"text": "盲算:SQL 无法解析(第 1 行第 10 列附近),没有逐项比较"
|
||||
}
|
||||
],
|
||||
"fallback": false,
|
||||
"text_diff": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,13 @@ import data from './dual_check.json'
|
||||
* - ?syn=dual-check-approval:正式运行里两路对不上的审批卡,宽栏(两条 SQL 左右并排)
|
||||
* - ?syn=dual-check-approval-rail:同一张卡摆在 360px 的画布右栏里(两条 SQL 上下排)
|
||||
* - ?syn=dual-check-approval-exploratory:探索运行,后果里不提出具
|
||||
* - ?syn=dual-check-approval-empty:摘要里一个指标都没有(老服务端、写坏了的 payload),只能驳回
|
||||
* - ?syn=dual-check-inspector:一张小画布(真的节点卡片)加 360px 的检查器,选中双盲复算节点;检查器改的是
|
||||
* studio store 里的节点配置,卡片上的摘要跟着变
|
||||
*
|
||||
* 完整对照的工件(GET /api/artifacts/{id})、审批回复、设置、校验都由检查脚本用 page.route 伪造,直接打开时卡上照实说
|
||||
* 无法加载完整对照。提交后 onResolved 只把运行 id 记到 window.__dualResolved,不去接事件流
|
||||
* 完整对照的工件(GET /api/artifacts/{id})、这次运行的图快照(GET /api/runs/{id}/graph,卡上按它说节点的
|
||||
* 「未采用原流程时」)、审批回复、设置、校验都由检查脚本用 page.route 伪造,直接打开时卡上照实说无法加载完整对照、
|
||||
* 有条件地说后果。提交后 onResolved 只把运行 id 记到 window.__dualResolved,不去接事件流
|
||||
*/
|
||||
const approval = (patch: Partial<Approval> = {}): Approval => ({
|
||||
...(data.approval as unknown as Approval),
|
||||
@@ -78,5 +80,10 @@ export const cases: Record<string, () => JSX.Element> = {
|
||||
<Card value={approval({ run_class: 'exploratory' })} />
|
||||
</div>
|
||||
),
|
||||
'dual-check-approval-empty': () => (
|
||||
<div className="mx-auto max-w-3xl p-4 text-sm" data-fixture="dual-check-approval-empty">
|
||||
<Card value={approval({ payload: { ...data.approval.payload, summary: [] } })} />
|
||||
</div>
|
||||
),
|
||||
'dual-check-inspector': () => <InspectorCase />,
|
||||
}
|
||||
|
||||
+64
-17
@@ -3347,15 +3347,20 @@ export const DUAL_CHECK_TEXT = {
|
||||
blind: '盲算',
|
||||
|
||||
// ---- 检查器(nodeDefs 的 dual_check、canvas/DualCheckFields.tsx)
|
||||
/**
|
||||
* 字段名,键就是配置键。服务端校验和报错引用的是同一套叫法(schema.py 的 DUAL_CHECK_FIELD_LABEL,check-verify 在
|
||||
* 服务端合入后逐项比对):问题挂在字段下面、出现在问题面板里时,人才找得到是哪一项。模型两项照「裁判模型 · 接入」
|
||||
* 「裁判模型」的写法
|
||||
*/
|
||||
field: {
|
||||
card: '要复算的口径卡',
|
||||
card: '口径卡',
|
||||
metrics: '复算的指标',
|
||||
definitions: '口径说明',
|
||||
provider: '盲算模型 · 接入',
|
||||
model: '盲算模型 · 模型',
|
||||
model: '盲算模型',
|
||||
tolerance: '允许的相对误差',
|
||||
onReject: '审核人不采用原流程的结果时',
|
||||
enabled: '启用',
|
||||
on_reject: '未采用原流程时',
|
||||
enabled: '启用双盲复算',
|
||||
},
|
||||
help: {
|
||||
card: '留空则复算上游的口径卡。只能复算本节点上游的口径卡:运行到本节点时,它的结果已经算出',
|
||||
@@ -3365,12 +3370,16 @@ export const DUAL_CHECK_TEXT = {
|
||||
model: '接入和模型均未填写时,使用「设置 → 偏好设置 → 核查」中的核查模型;填写任一项后,整组以节点配置为准,'
|
||||
+ '不与设置合并。建议与原流程使用的模型不同,同一模型不易发现自身的错误',
|
||||
tolerance: '两边的值相差不超过这个比例时算一致,例如 0.001 表示千分之一,0 表示必须完全相同。留空则使用设置中的值',
|
||||
onReject: '盲算与原流程不一致时运行暂停,由审核人逐个指标选择采用哪一边。选择「采用盲算的结果」或「两边都不对」时按这里处理',
|
||||
on_reject: '盲算与原流程不一致时运行暂停,由审核人逐个指标选择采用哪一边。选择「采用盲算的结果」或「两边都不对」时按这里处理',
|
||||
enabled: '关闭时本节点不复算,直接放行,并在时间线上记录一条说明',
|
||||
},
|
||||
/** 口径卡下拉留空那一项:上游没有、只有一张、有好几张 */
|
||||
/**
|
||||
* 口径卡下拉留空那一项:上游没有、只有一张、有好几张。好几张时服务端校验要求选一张(不选会挡住发起运行),
|
||||
* 写成要选,不写「建议」
|
||||
*/
|
||||
cardDefault: (name: string | null, upstream: number) => (upstream === 0 ? '默认:上游的口径卡(上游没有口径卡)'
|
||||
: upstream === 1 && name ? `默认:上游的口径卡「${name}」` : `默认:上游的口径卡(共 ${upstream} 张,建议指定)`),
|
||||
: upstream === 1 && name ? `默认:上游的口径卡「${name}」` : `请选择要复算的口径卡(上游有 ${upstream} 张)`),
|
||||
cardChoose: (n: number) => `上游有 ${n} 张口径卡,请选择要复算的那一张`,
|
||||
cardUpstream: '上游的口径卡',
|
||||
cardOther: '其他口径卡(需连接到本节点上游)',
|
||||
/** 画布上一张口径卡的叫法:节点名,口径名不同时附在后面 */
|
||||
@@ -3411,10 +3420,11 @@ export const DUAL_CHECK_TEXT = {
|
||||
},
|
||||
tolerancePlaceholder: (v: number | null | undefined) => (v == null ? '跟随设置' : `跟随设置(${v})`),
|
||||
toleranceInvalid: '请填写不小于 0 的数',
|
||||
onReject: { degrade: '记为出具缺口,出具降档(默认)', fail: '判为失败,运行终止' } as Record<string, string>,
|
||||
/** 选项以服务端报错里的短说法(schema.py 的 DUAL_CHECK_ON_REJECT_LABEL)开头,后面可以带一句用途 */
|
||||
onReject: { degrade: '计入缺口、出具降档(默认)', fail: '判为失败,运行终止' } as Record<string, string>,
|
||||
onRejectUnknown: (v: string) => `${v}:无法识别的值,请从上方选项中选择`,
|
||||
enabled: { follow: '跟随设置', on: '开启', off: '关闭' },
|
||||
/** 「跟随设置」那一项附上设置里眼下是开是关(设置还没取回来就不附) */
|
||||
/** 「跟随设置」那一项附上设置里现在是开是关(设置还没取到就不附) */
|
||||
enabledFollow: (on: boolean | null | undefined) => (on == null ? '跟随设置' : `跟随设置(当前${on ? '开启' : '关闭'})`),
|
||||
|
||||
// ---- 节点卡片的摘要:复算哪张卡、几个指标
|
||||
@@ -3436,6 +3446,7 @@ export const DUAL_CHECK_TEXT = {
|
||||
deltaZero: '原流程值为 0,无法计算相差比例',
|
||||
deltaHint: '相差比例 =(盲算值 − 原流程值)÷ |原流程值|',
|
||||
diffHead: '差异说明',
|
||||
/** 一条具体差异都没有时的固定说法(服务端兜底说明的前半句);兜底的原话另有原因(比较超时、解析失败)时附在下面 */
|
||||
fallbackOnly: '两条 SQL 的写法不同,未能归纳成具体差异',
|
||||
equivalent: '两条 SQL 的写法等价,差异出在取用的结果上,请对照两边取用的值',
|
||||
loading: '正在加载完整对照…',
|
||||
@@ -3448,8 +3459,19 @@ export const DUAL_CHECK_TEXT = {
|
||||
source: (s: string) => `数据源 ${s}`,
|
||||
inputs: '输入取值',
|
||||
textDiff: '规范化后不同的行',
|
||||
textDiffNone: '这一边没有不同的行',
|
||||
rationale: '盲算的说明',
|
||||
catalog: '两边只差一个方面:选择任一边的结果后,这项口径会作为已确认的事实记入数据目录',
|
||||
/**
|
||||
* 两边只差一处、又可能对得上数据目录里的一项事实时,提交前说清选择会被记下来(门槛同服务端的 dual_check_facts:
|
||||
* 只有一个输入对不上、恰好一条差异、没有兜底,而且是时间列、筛选条件或聚合)。一边多出条件、只差去不去重、目录里
|
||||
* 已有人工确认的说法这几条只有服务端查得了,所以有条件地说,以时间线上的记录为准。卡上是「方面那句 + tail」
|
||||
*/
|
||||
catalog: {
|
||||
time: '两边只差在按哪一列筛时间:采用一边的结果后,这一边筛时间所用的列会作为该表的业务日期',
|
||||
filter: '两边只差一个筛选条件:若这个条件只有一边有,采用有它的一边时,它会作为该表的有效记录条件',
|
||||
aggregate: '两边只差在聚合方式:若只差是否去重,采用一边的结果后,它的做法会作为该表的去重规则',
|
||||
tail: ',以人工确认的事实记入数据目录。选「两边都不对」时不记,数据目录中已有人工确认的不同说法时不覆盖,以时间线上的记录为准',
|
||||
},
|
||||
choices: { a: '采用原流程的结果', b: '采用盲算的结果', neither: '两边都不对' } as Record<'a' | 'b' | 'neither', string>,
|
||||
choiceGroup: (name: string) => `「${name}」采用哪一边的结果`,
|
||||
/** 三个选择的后果,按运行类别说:探索运行不出具,不提出具;老服务端没给类别的照实说「正式运行」 */
|
||||
@@ -3458,13 +3480,27 @@ export const DUAL_CHECK_TEXT = {
|
||||
: runClass === 'exploratory'
|
||||
? '采用原流程的结果:沿用原流程的值。采用盲算的结果或两边都不对:报告中的数仍取自原流程,这个指标记为缺口'
|
||||
: '采用原流程的结果:沿用原流程的值。采用盲算的结果或两边都不对:报告中的数仍取自原流程,这个指标记为缺口,正式运行的出具随之降档'),
|
||||
consequenceFail: '节点设为「判为失败」时,选择后两项会终止运行',
|
||||
/**
|
||||
* 节点的「未采用原流程时」设为判为失败(从这次运行的图快照里读到的):采用盲算或都不对时节点失败、运行终止,
|
||||
* 不再说出具降档
|
||||
*/
|
||||
consequenceFail: '采用原流程的结果:沿用原流程的值,运行继续。采用盲算的结果或两边都不对:节点的「未采用原流程时」'
|
||||
+ '设为「判为失败」,提交后运行将终止',
|
||||
/** 读不到节点怎么设的(图快照取不到、节点在子工作流里):降档那句后面有条件地补一句 */
|
||||
consequenceMaybeFail: '如节点的「未采用原流程时」设为「判为失败」,选择后两项会终止运行',
|
||||
note: '备注(可选)',
|
||||
notePlaceholder: '说明裁决的依据,会随裁决记入运行记录',
|
||||
submit: '提交裁决',
|
||||
pending: (n: number) => `还有 ${n} 个指标未选择`,
|
||||
/** 提交后的提示:全采用原流程、或节点按缺省处置时运行继续;判为失败时运行终止;读不到节点设置时不下结论 */
|
||||
done: '已提交裁决,运行继续',
|
||||
doneFail: '已提交裁决,运行将按节点设置终止',
|
||||
doneUnknown: '已提交裁决',
|
||||
empty: '审批内容不完整,没有可以裁决的指标',
|
||||
/** 认不出任何指标时留的出口:驳回后节点按人工驳回失败 */
|
||||
reject: '驳回并终止',
|
||||
emptyReject: '驳回后节点失败,本次运行终止。可以在备注中说明原因',
|
||||
rejected: '已驳回,本次运行终止',
|
||||
},
|
||||
|
||||
// ---- 时间线(run/decode.ts explainLog):按日志的 code 和结构化字段说,不从原话里认
|
||||
@@ -3476,7 +3512,7 @@ export const DUAL_CHECK_TEXT = {
|
||||
limitDailyNext: '今日核查费用已达上限。请在「设置 → 偏好设置 → 核查」中调高每日金额上限,或等到次日再运行',
|
||||
log: {
|
||||
skippedDisabled: '双盲复算未启用,已跳过',
|
||||
skippedDisabledNext: '如需复算,请在节点的「启用」中选择「开启」,或在「设置 → 偏好设置 → 核查」中启用双盲复算',
|
||||
skippedDisabledNext: '如需复算,请在节点的「启用双盲复算」中选择「开启」,或在「设置 → 偏好设置 → 核查」中启用双盲复算',
|
||||
skippedNoInputs: '没有输入来自查询的指标,已跳过复算',
|
||||
skippedNoInputsNext: '双盲复算只复算输入来自查询结果的指标。请检查节点的「复算的指标」,或确认口径卡的输入取自查询',
|
||||
skipped: '双盲复算已跳过',
|
||||
@@ -3491,19 +3527,30 @@ export const DUAL_CHECK_TEXT = {
|
||||
resolvedB: (n: number) => `采用盲算 ${formatNumber(n)} 个`,
|
||||
resolvedNeither: (n: number) => `两边都不对 ${formatNumber(n)} 个`,
|
||||
gaps: (n: number) => (n ? `${formatNumber(n)} 项记为缺口` : '没有缺口'),
|
||||
catalog: (n: number) => `已将 ${formatNumber(n)} 项裁决记入数据目录`,
|
||||
catalogNone: '裁决未记入数据目录',
|
||||
catalog: (n: number) => `已将 ${formatNumber(n)} 项人工确认的事实记入数据目录`,
|
||||
catalogNone: '本次裁决未写入数据目录',
|
||||
/**
|
||||
* 写进数据目录的一项:「orders」(shop)的业务日期。item 是目录项的中文叫法(认不出的键不传,只写表);
|
||||
* 老版本给的一句中文也从这里过
|
||||
*/
|
||||
catalogItem: (table?: string, source?: string, item?: string) => {
|
||||
const where = table ? `「${table}」${source ? `(${source})` : ''}` : ''
|
||||
return where && item ? `${where}的${item}` : where || item || ''
|
||||
},
|
||||
catalogSkipped: (n: number) => `${formatNumber(n)} 项未写入`,
|
||||
catalogNext: '可在「数据 → 数据目录」中查看或修改',
|
||||
limit: (label: string, metric?: string) => (metric ? `复算指标「${metric}」时已达${label},该指标未完成盲算` : `双盲复算已达${label}`),
|
||||
/** metric 是指标 ID(服务端日志只带 ID,中文名在原话里),不当名字加引号 */
|
||||
limit: (label: string, metric?: string) => (metric ? `指标 ${metric} 的盲算已达${label},按无法复算处理` : `双盲复算已达${label}`),
|
||||
limitNext: '请在「设置 → 偏好设置 → 核查」中调高双盲复算的上限',
|
||||
},
|
||||
/** 反方审稿日志的时间线说法:时间线解读统一在这里做,反方审稿的工作包不另写一份 */
|
||||
challengeLog: {
|
||||
skippedDisabled: '反方审稿未启用,已跳过',
|
||||
skippedDisabledNext: '如需审稿,请在节点中开启,或在「设置 → 偏好设置 → 核查」中启用反方审稿',
|
||||
skippedNoReport: '没有找到要审的报告,已跳过反方审稿',
|
||||
skippedNoReportNext: '请在反方审稿节点的上游连接报告撰写节点,或在节点中指定要审的报告',
|
||||
// no_report:要审的报告节点找得到(找不到时节点直接报错,到不了这里),但它的报告文档读不出来——没有写出、
|
||||
// 保存失败,或者它在这次没有执行到的分支上
|
||||
skippedNoReport: '要审的报告没有可读取的报告文档,已跳过反方审稿',
|
||||
skippedNoReportNext: '请确认报告撰写节点是否执行成功、是否写出了报告;它位于条件分支上时,确认本次运行执行到了它',
|
||||
skippedNoClaims: '报告中没有需要审的关键结论,已跳过反方审稿',
|
||||
skipped: '反方审稿已跳过',
|
||||
sameModel: (model?: string) => (model ? `反方审稿模型与报告撰写模型同为「${model}」,审稿缺乏独立性`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { AlertTriangle, Check, ChevronRight } from 'lucide-react'
|
||||
import { AlertTriangle, Ban, Check, ChevronRight } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { api } from '../api/client'
|
||||
import { useCatalog } from '../store/catalog'
|
||||
@@ -15,6 +15,11 @@ import type {
|
||||
|
||||
const T = DUAL_CHECK_TEXT.approval
|
||||
const CHOICES: DualCheckChoice[] = ['a', 'b', 'neither']
|
||||
/** 不算具体差异的两种:兜底说明(other)和解析失败(parse)。服务端摘要的 fallback 也是这么算的 */
|
||||
const NOT_CONCRETE = new Set(['other', 'parse'])
|
||||
/** 能对上数据目录里一项事实的三种差异:时间列 → 业务日期,筛选条件 → 有效记录条件,聚合 → 去重规则 */
|
||||
type CatalogAspect = 'time' | 'filter' | 'aggregate'
|
||||
const CATALOG_ASPECTS = new Set<string>(['time', 'filter', 'aggregate'])
|
||||
|
||||
export interface DualCheckApprovalProps {
|
||||
/** payload.kind 为 'dual_check' 的审批:双盲复算两路对不上、交给人裁决 */
|
||||
@@ -34,21 +39,26 @@ interface MetricView {
|
||||
blind: string | null
|
||||
/** 已乘过 100;null 是原流程值为 0(约定),undefined 是没给 */
|
||||
deltaPct: number | null | undefined
|
||||
/** 具体差异,一条一句(兜底说明不算) */
|
||||
/** 具体差异,一条一句(兜底说明、解析失败不算) */
|
||||
items: string[]
|
||||
/** 只有兜底说明:两条 SQL 的写法不同,未能归纳成具体差异 */
|
||||
/** 一条具体差异都没有:卡上不列条目,默认展开对照,审核人只能对照原文判断 */
|
||||
fallbackOnly: boolean
|
||||
/** fallbackOnly 时写不写那句固定的「两条 SQL 的写法不同,未能归纳成具体差异」(一边没有 SQL 时不写,只写原因) */
|
||||
fallbackHead: boolean
|
||||
/** 兜底、解析失败的原话里固定那句以外的说法(比较超时、解析出错的位置……),附在下面 */
|
||||
notes: string[]
|
||||
/** 具体差异之外还有未能归纳的部分(服务端那句「另有未能归纳的差异」) */
|
||||
rest: string | null
|
||||
/** 两条 SQL 等价,差在取用的结果上 */
|
||||
equivalent: boolean
|
||||
/** 只差一个方面:审核人的选择会记入数据目录 */
|
||||
single: boolean
|
||||
/** 两边只差一处、又可能对得上数据目录的一项事实:审核人的选择可能被记进数据目录(见 catalogOf) */
|
||||
catalog: CatalogAspect | null
|
||||
full: DualCheckMetric | null
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
/** payload.summary 里认得出的项(老服务端、写坏了的项跳过,不让整张卡崩掉) */
|
||||
function summaryOf(raw: unknown): DualCheckSummaryItem[] {
|
||||
@@ -64,6 +74,27 @@ function cardOf(content: unknown): DualCheckCard | null {
|
||||
return { ...card, metrics: card.metrics.filter((m) => !!m && typeof m === 'object' && typeof m.metric_id === 'string') }
|
||||
}
|
||||
|
||||
/**
|
||||
* 兜底、解析失败的原话 → standard:其中有那句固定的兜底说明(几个输入合并时前面带「输入「…」:」);notes:其余的
|
||||
* 说法,去掉句末的「请对照原文」(对照就在下面),去重
|
||||
*/
|
||||
function notesOf(texts: string[]): { standard: boolean; notes: string[] } {
|
||||
const plain = [...new Set(texts.map((t) => t.replace(/,?请对照原文。?$/, '').trim()).filter(Boolean))]
|
||||
const isStandard = (t: string) => t.replace(/^输入「[^」]*」:/, '') === T.fallbackOnly
|
||||
return { standard: plain.some(isStandard), notes: plain.filter((t) => !isStandard(t)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核人的选择会不会被记进数据目录:照服务端写目录的门槛(dual_check_facts.derive)——两条 SQL 不等价、没用兜底、
|
||||
* 差异恰好一条,而且是时间列、筛选条件或聚合。几个输入都对不上时节点把各自的差异合成一份(每个输入至少一条),
|
||||
* 所以「恰好一条」也就是只有一个输入对不上。后面还有只有服务端查得了的条件(一边多出条件、只差去不去重、目录里
|
||||
* 已有人工确认的说法),卡上有条件地说
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
function viewsOf(summary: DualCheckSummaryItem[], card: DualCheckCard | null): MetricView[] {
|
||||
const full = new Map((card?.metrics ?? []).map((m) => [m.metric_id, m]))
|
||||
// 先按摘要的顺序,工件里另有的(约定里不该有,有就照实列出来)跟在后面
|
||||
@@ -79,30 +110,39 @@ function viewsOf(summary: DualCheckSummaryItem[], card: DualCheckCard | null): M
|
||||
deltaPct: s && 'delta_pct' in s ? num(s.delta_pct) ?? null : f?.delta_pct === null ? null : num(f?.delta_pct),
|
||||
}
|
||||
if (f) {
|
||||
const items = (Array.isArray(f.diff?.items) ? f.diff.items : []).filter((i) => !!i && typeof i.text === 'string' && !!i.text)
|
||||
// aspect 为 other 的是兜底说明,不算具体差异;只有它的时候卡上写那句固定的说法、默认展开对照
|
||||
const concrete = items.filter((i) => i.aspect !== 'other')
|
||||
const items = (Array.isArray(f.diff?.items) ? f.diff.items : [])
|
||||
.filter((i) => !!i && typeof i.text === 'string' && !!i.text && typeof i.aspect === 'string')
|
||||
const concrete = items.filter((i) => !NOT_CONCRETE.has(i.aspect))
|
||||
const other = items.filter((i) => i.aspect === 'other')
|
||||
const equivalent = f.diff?.equivalent === true
|
||||
const aspects = new Set(concrete.map((i) => i.aspect))
|
||||
const fallbackOnly = !equivalent && !concrete.length
|
||||
const { standard, notes } = notesOf(items.filter((i) => NOT_CONCRETE.has(i.aspect)).map((i) => i.text))
|
||||
// 对不上的输入里没有一个两边都有 SQL 的(节点的原话「有一边没有可比较的 SQL」):不说「两条 SQL 的写法不同」
|
||||
const comparable = pairsOf(f).some((p) => p.differs && hasSql(p.original?.sql) && hasSql(p.blind?.sql))
|
||||
return {
|
||||
...base,
|
||||
items: concrete.map((i) => i.text),
|
||||
fallbackOnly: !equivalent && !concrete.length,
|
||||
fallbackOnly,
|
||||
fallbackHead: !notes.length || standard || comparable,
|
||||
notes: fallbackOnly ? notes : [],
|
||||
rest: concrete.length && other.length ? other.map((i) => i.text).join(';') : null,
|
||||
equivalent,
|
||||
// 「只差一个方面」以服务端为准(它决定写不写数据目录),这里只在没用兜底、具体差异都落在同一方面时提示
|
||||
single: !equivalent && f.diff?.fallback !== true && aspects.size === 1 && !aspects.has('parse'),
|
||||
catalog: catalogOf(f, items),
|
||||
full: f,
|
||||
}
|
||||
}
|
||||
// 只有摘要:服务端把兜底和解析失败都算作 fallback,diff 里是前几条差异的原话
|
||||
const texts = Array.isArray(s?.diff) ? s.diff.filter((t): t is string => typeof t === 'string' && !!t) : []
|
||||
const fallbackOnly = s?.fallback === true
|
||||
return {
|
||||
...base,
|
||||
items: Array.isArray(s?.diff) ? s.diff.filter((t): t is string => typeof t === 'string' && !!t) : [],
|
||||
fallbackOnly: s?.fallback === true,
|
||||
items: fallbackOnly ? [] : texts,
|
||||
fallbackOnly,
|
||||
fallbackHead: true,
|
||||
notes: fallbackOnly ? notesOf(texts).notes : [],
|
||||
rest: null,
|
||||
equivalent: false,
|
||||
single: false,
|
||||
catalog: null,
|
||||
full: null,
|
||||
}
|
||||
})
|
||||
@@ -117,13 +157,40 @@ function deltaText(pct: number | null | undefined): string | null {
|
||||
return T.delta(abs < 0.01 ? `${sign}<0.01%` : `${sign}${abs.toLocaleString('en-US', { maximumFractionDigits: 2 })}%`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 这次运行里节点的「未采用原流程时」:审批 payload 不带它,从运行的图快照(GET /runs/{id}/graph)里按节点 id 读。
|
||||
* 没写、写了认不出的按缺省的 degrade(节点执行时同样这么认);图里找不到这个节点(子工作流里的)、图快照取不到
|
||||
* 时是 unknown,卡上有条件地说
|
||||
*/
|
||||
type OnReject = 'degrade' | 'fail' | 'unknown' | 'loading'
|
||||
function useOnReject(runId: string, nodeId: string | undefined): OnReject {
|
||||
const key = `${runId}\n${nodeId ?? ''}`
|
||||
const [got, setGot] = useState<{ key: string; value: OnReject } | null>(null)
|
||||
useEffect(() => {
|
||||
if (!runId || !nodeId) return
|
||||
let alive = true
|
||||
api.runs.graph(runId).then(
|
||||
(res) => {
|
||||
const node = Array.isArray(res?.graph?.nodes) ? res.graph.nodes.find((n) => n?.id === nodeId) : undefined
|
||||
const value: OnReject = !node ? 'unknown' : node.data?.config?.on_reject === 'fail' ? 'fail' : 'degrade'
|
||||
if (alive) setGot({ key, value })
|
||||
},
|
||||
() => { if (alive) setGot({ key, value: 'unknown' }) },
|
||||
)
|
||||
return () => { alive = false }
|
||||
}, [runId, nodeId, key])
|
||||
if (!runId || !nodeId) return 'unknown'
|
||||
return got?.key === key ? got.value : 'loading'
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批卡里双盲复算那一块(RunPanel 的 ApprovalCard 在 payload.kind === 'dual_check' 时渲染它,通用的输入框、备注和
|
||||
* 「提交 / 驳回并终止」不画——mode 是 input,通用那套会把一个字符串当 value 发出去)。
|
||||
*
|
||||
* 先按 payload.summary 画:两个值、相差多少、摘要里的差异,选择也能先做;完整对照(card_artifact)到了再补上盲算模型、
|
||||
* 全部具体差异和「对照两条 SQL」。取不到完整对照时照实说,按摘要照样能裁决。每个指标三选一、缺一个都不能提交,
|
||||
* 回复走审批接口:value 为 {choices, note}
|
||||
* 回复走审批接口:value 为 {choices, note}。摘要和工件里都认不出指标时没法裁决,只留「驳回并终止」(approved 为假,
|
||||
* 节点按人工驳回失败),免得运行一直挂着
|
||||
*/
|
||||
export function DualCheckApproval({ approval, onResolved }: DualCheckApprovalProps) {
|
||||
const toast = useToast()
|
||||
@@ -133,6 +200,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 [card, setCard] = useState<{ id: string; data?: DualCheckCard | null } | null>(null)
|
||||
useEffect(() => {
|
||||
@@ -157,8 +225,9 @@ export function DualCheckApproval({ approval, onResolved }: DualCheckApprovalPro
|
||||
const [busy, setBusy] = useState(false)
|
||||
const pending = views.filter((v) => !choices[v.id]).length
|
||||
|
||||
const submit = async () => {
|
||||
if (busy || pending || !views.length) return
|
||||
/** 提交裁决(approved 为真,value 是 {choices, note})或驳回(认不出指标时的出口) */
|
||||
const send = async (approved: boolean) => {
|
||||
if (busy || (approved && (pending || !views.length))) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const value: DualCheckReply = {
|
||||
@@ -166,11 +235,13 @@ export function DualCheckApproval({ approval, onResolved }: DualCheckApprovalPro
|
||||
note: note.trim(),
|
||||
}
|
||||
// 照 ApprovalCard 里 mode 为 input 时的写法:备注外层也放一份,节点拿到的是 {approved, note, value}
|
||||
await api.approvals.decide(approval.id, { approved: true, note: value.note, value })
|
||||
await api.approvals.decide(approval.id, approved ? { approved: true, note: value.note, value } : { approved: false, note: value.note })
|
||||
await refreshApprovals()
|
||||
if (onResolved) await onResolved(approval.run_id)
|
||||
else await attachRun(approval.run_id)
|
||||
toast(T.done, 'ok')
|
||||
// 采用盲算、两边都不对时,节点设为「判为失败」就会让运行终止:提示照节点的设置说,读不到设置时不替它下结论
|
||||
const rejected = Object.values(value.choices).some((c) => c !== 'a')
|
||||
toast(!approved ? T.rejected : !rejected || onReject === 'degrade' ? T.done : onReject === 'fail' ? T.doneFail : T.doneUnknown, 'ok')
|
||||
} catch (e) {
|
||||
toast.error(e)
|
||||
} finally {
|
||||
@@ -178,6 +249,14 @@ export function DualCheckApproval({ approval, onResolved }: DualCheckApprovalPro
|
||||
}
|
||||
}
|
||||
|
||||
const noteField = (
|
||||
<div>
|
||||
<label className="label" htmlFor={`${uid}-note`}>{T.note}</label>
|
||||
<textarea id={`${uid}-note`} className="field" rows={2} value={note} placeholder={T.notePlaceholder}
|
||||
data-dual-note="" onChange={(e) => setNote(e.target.value)} />
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="@container mb-2 space-y-2" data-dual-check="" data-state={loading ? 'loading' : lost ? 'summary' : 'ready'}>
|
||||
{views.length > 0 && <p className="text-xs leading-relaxed" data-dual-intro="">{T.intro(views.length)}</p>}
|
||||
@@ -201,7 +280,18 @@ export function DualCheckApproval({ approval, onResolved }: DualCheckApprovalPro
|
||||
)}
|
||||
|
||||
{!views.length ? (
|
||||
<p className="text-2xs leading-relaxed text-dim" data-dual-empty="">{T.empty}</p>
|
||||
!loading && (
|
||||
// 认不出任何指标:没法裁决,留一个驳回的出口(RunPanel 在双盲复算的审批上不画通用的按钮行)
|
||||
<>
|
||||
<p className="text-2xs leading-relaxed text-dim" data-dual-empty="">{T.empty}</p>
|
||||
{noteField}
|
||||
<button type="button" className="btn btn-danger w-full justify-center" disabled={busy} aria-describedby={`${uid}-reject`}
|
||||
data-dual-reject="" onClick={() => void send(false)}>
|
||||
{busy ? <Spinner size={11} /> : <Ban size={12} aria-hidden />} {T.reject}
|
||||
</button>
|
||||
<div id={`${uid}-reject`} className="text-2xs leading-relaxed text-dim">{T.emptyReject}</div>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{views.map((v) => (
|
||||
@@ -209,23 +299,19 @@ export function DualCheckApproval({ approval, onResolved }: DualCheckApprovalPro
|
||||
onChoose={(c) => setChoices((cur) => ({ ...cur, [v.id]: c }))} />
|
||||
))}
|
||||
|
||||
{/* 选了会怎样,常显在提交按钮上面:审核人得在提交之前知道「采用盲算」并不替换报告里的数 */}
|
||||
<div className="text-2xs leading-relaxed text-dim" data-dual-consequence="">
|
||||
{T.consequence(approval.run_class)}
|
||||
<span className="mt-0.5 block">{T.consequenceFail}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor={`${uid}-note`}>{T.note}</label>
|
||||
<textarea id={`${uid}-note`} className="field" rows={2} value={note} placeholder={T.notePlaceholder}
|
||||
data-dual-note="" onChange={(e) => setNote(e.target.value)} />
|
||||
{/* 选了会怎样,常显在提交按钮上面:审核人得在提交之前知道「采用盲算」并不替换报告里的数,以及运行会不会终止 */}
|
||||
<div className="text-2xs leading-relaxed text-dim" data-dual-consequence="" data-on-reject={onReject}>
|
||||
{onReject === 'fail' ? T.consequenceFail : T.consequence(approval.run_class)}
|
||||
{(onReject === 'unknown' || onReject === 'loading') && <span className="mt-0.5 block">{T.consequenceMaybeFail}</span>}
|
||||
</div>
|
||||
{noteField}
|
||||
{pending > 0 && (
|
||||
<div id={`${uid}-pending`} className="text-2xs" style={{ color: 'var(--st-waiting)' }} data-dual-pending="">
|
||||
{T.pending(pending)}
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className="btn btn-primary w-full justify-center" disabled={busy || pending > 0}
|
||||
aria-describedby={pending > 0 ? `${uid}-pending` : undefined} data-dual-submit="" onClick={() => void submit()}>
|
||||
aria-describedby={pending > 0 ? `${uid}-pending` : undefined} data-dual-submit="" onClick={() => void send(true)}>
|
||||
{busy ? <Spinner size={11} /> : <Check size={12} aria-hidden />} {T.submit}
|
||||
</button>
|
||||
</>
|
||||
@@ -239,10 +325,12 @@ function MetricBlock({ view, name, choice, onChoose }: {
|
||||
view: MetricView; name: string; choice?: DualCheckChoice; onChoose: (c: DualCheckChoice) => void
|
||||
}) {
|
||||
const uid = useId()
|
||||
// 只有兜底说明时默认展开:一条具体说法都没有,审核人只能对照原文判断
|
||||
// 只有兜底说明(或只有解析失败)时默认展开:一条具体说法都没有,审核人只能对照原文判断
|
||||
const [open, setOpen] = useState<boolean | null>(null)
|
||||
const expanded = !!view.full && (open ?? view.fallbackOnly)
|
||||
const delta = deltaText(view.deltaPct)
|
||||
// 一边没有 SQL 时不写固定那句,原因顶上来用提醒色
|
||||
const [lead, ...more] = view.fallbackHead ? [T.fallbackOnly, ...view.notes] : view.notes
|
||||
return (
|
||||
<div className="rounded border bg-bg p-2" role="group" aria-labelledby={`${uid}-name`} data-dual-metric={view.id}>
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
@@ -268,7 +356,14 @@ function MetricBlock({ view, name, choice, onChoose }: {
|
||||
{view.equivalent ? (
|
||||
<p className="text-2xs leading-relaxed text-dim" data-dual-equivalent="">{T.equivalent}</p>
|
||||
) : view.fallbackOnly ? (
|
||||
<p className="text-2xs leading-relaxed" style={{ color: 'var(--st-waiting)' }} data-dual-fallback="">{T.fallbackOnly}</p>
|
||||
<>
|
||||
{lead && (
|
||||
<p className="text-2xs leading-relaxed [overflow-wrap:anywhere]" style={{ color: 'var(--st-waiting)' }} data-dual-fallback="">{lead}</p>
|
||||
)}
|
||||
{more.map((t, i) => (
|
||||
<p key={i} className="mt-0.5 text-2xs leading-relaxed text-dim [overflow-wrap:anywhere]" data-dual-fallback-note="">{t}</p>
|
||||
))}
|
||||
</>
|
||||
) : view.items.length > 0 && (
|
||||
<ul className="list-disc space-y-0.5 pl-4 text-2xs leading-relaxed [overflow-wrap:anywhere]">
|
||||
{view.items.map((t, i) => <li key={i} data-dual-diff-item="">{t}</li>)}
|
||||
@@ -285,7 +380,9 @@ function MetricBlock({ view, name, choice, onChoose }: {
|
||||
</button>
|
||||
)}
|
||||
{expanded && view.full && <Compare id={`${uid}-cmp`} metric={view.full} />}
|
||||
{view.single && <p className="mt-1.5 text-2xs leading-relaxed text-dim" data-dual-catalog="">{T.catalog}</p>}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* 原生单选:Tab 进组、方向键换选项,读屏念得出是哪个指标的哪一项 */}
|
||||
<fieldset className="mt-2 min-w-0 border-0 p-0" data-dual-choice={view.id}>
|
||||
@@ -319,7 +416,7 @@ interface Pair {
|
||||
|
||||
/** 比 SQL 时不看空白和大小写:原样写成一行和换了行的是同一条 */
|
||||
const normSql = (sql: unknown): string => (typeof sql === 'string' ? sql.replace(/\s+/g, ' ').trim().toLowerCase() : '')
|
||||
/** 比一行时再去掉行尾的逗号、分号:规范化的写法逗号在行尾,原文不一定 */
|
||||
/** 比一行时再去掉行尾的逗号、分号:只是换了个地方断行的两行算同一行 */
|
||||
const normLine = (line: string): string => line.replace(/\s+/g, ' ').trim().replace(/[,;]+$/, '').trim().toLowerCase()
|
||||
const sameValue = (a: unknown, b: unknown): boolean => {
|
||||
if (typeof a === 'number' && typeof b === 'number') return a === b
|
||||
@@ -342,20 +439,43 @@ function pairsOf(metric: DualCheckMetric): Pair[] {
|
||||
}))
|
||||
}
|
||||
|
||||
/** text_diff(「- 」原流程、「+ 」盲算)→ 两边各自要高亮的行(按 normLine 比) */
|
||||
function marksOf(textDiff: unknown): { original: Set<string>; blind: Set<string> } {
|
||||
const original = new Set<string>()
|
||||
const blind = new Set<string>()
|
||||
for (const line of Array.isArray(textDiff) ? textDiff : []) {
|
||||
if (typeof line !== 'string') continue
|
||||
if (line.startsWith('- ')) original.add(normLine(line.slice(2)))
|
||||
else if (line.startsWith('+ ')) blind.add(normLine(line.slice(2)))
|
||||
/** 按行数算 LCS 的上限:再长的 SQL 不在浏览器里逐行比,原文不标(规范化后不同的行照样列) */
|
||||
const MAX_LCS_CELLS = 250_000
|
||||
|
||||
/**
|
||||
* 两段原文按行比(不看空白、大小写和行尾的逗号分号),返回两边各自不在最长公共子序列里的行号。
|
||||
*
|
||||
* 不拿 text_diff 去对原文:服务端的 text_diff 是规范化之后的行(列名带表名、条件并成一行),和原文的行几乎从来
|
||||
* 对不上。一行都对不上(写法完全不同、各写成一行)时返回 null:整段都标出来没有信息量,差在哪看规范化后的行
|
||||
*/
|
||||
function lineDiff(a: string[], b: string[]): { a: Set<number>; b: Set<number> } | null {
|
||||
const na = a.map(normLine)
|
||||
const nb = b.map(normLine)
|
||||
const n = na.length
|
||||
const m = nb.length
|
||||
if (!n || !m || n * m > MAX_LCS_CELLS) return null
|
||||
// lcs[i][j]:na[i..] 和 nb[j..] 的最长公共子序列有几行
|
||||
const lcs = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1))
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--) lcs[i][j] = na[i] === nb[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1])
|
||||
}
|
||||
original.delete('')
|
||||
blind.delete('')
|
||||
return { original, blind }
|
||||
if (!lcs[0][0]) return null
|
||||
const da = new Set<number>()
|
||||
const db = new Set<number>()
|
||||
let i = 0
|
||||
let j = 0
|
||||
while (i < n && j < m) {
|
||||
if (na[i] === nb[j]) { i++; j++ } else if (lcs[i + 1][j] >= lcs[i][j + 1]) da.add(i++)
|
||||
else db.add(j++)
|
||||
}
|
||||
while (i < n) da.add(i++)
|
||||
while (j < m) db.add(j++)
|
||||
return { a: da, b: db }
|
||||
}
|
||||
|
||||
/** SQL 原文按行切(去掉末尾的空白行) */
|
||||
const linesOf = (sql: unknown): string[] | null => (hasSql(sql) ? (sql as string).replace(/\s+$/, '').split('\n') : null)
|
||||
|
||||
function valueText(v: unknown): string {
|
||||
if (v == null) return NONE
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v.toLocaleString('en-US', { maximumFractionDigits: 6 }) : String(v)
|
||||
@@ -372,18 +492,24 @@ function valueText(v: unknown): string {
|
||||
const MARK: CSSProperties = {
|
||||
background: 'color-mix(in srgb, var(--warn) 16%, transparent)', color: 'var(--text)', boxShadow: 'inset 2px 0 0 var(--warn)',
|
||||
}
|
||||
/** 代码框:折行显示、不横向撑开,太长时框里纵向滚动,框能用键盘聚焦 */
|
||||
const CODE_BOX = 'overflow-y-auto whitespace-pre-wrap rounded border bg-panel px-1 py-1.5 text-2xs leading-relaxed [overflow-wrap:anywhere] '
|
||||
+ 'focus-visible:outline-2 focus-visible:outline-[color:var(--accent)]'
|
||||
|
||||
/**
|
||||
* 对照两条 SQL:先列每个输入两边各取到多少,再把不同的输入的两条 SQL 左右并排(卡片窄于 32rem 时上下排,按卡片
|
||||
* 自己的宽度算,画布右栏里也是上下排),规范化后不同的行在原文里高亮;规范化的写法和原文对不上(模型常把 SQL 写成
|
||||
* 一行)时原文不乱标,规范化后不同的行另列一份,每行标是哪一边
|
||||
* 对照两条 SQL:先列每个输入两边各取到多少;再把不同的输入的两条 SQL 原文左右并排(卡片窄于 32rem 时上下排,按卡片
|
||||
* 自己的宽度算,画布右栏里也是上下排),两段原文按行比出的不同行高亮;最后是规范化后不同的行(服务端的 text_diff),
|
||||
* 同样分原流程、盲算两栏,每行高亮——原文格式差得太远、按行比不出东西时,差在哪就看这一段
|
||||
*/
|
||||
function Compare({ id, metric }: { id: string; metric: DualCheckMetric }) {
|
||||
const pairs = useMemo(() => pairsOf(metric), [metric])
|
||||
const marks = useMemo(() => marksOf(metric.diff?.text_diff), [metric])
|
||||
const lines = useMemo(() => (Array.isArray(metric.diff?.text_diff) ? metric.diff.text_diff : [])
|
||||
.filter((l): l is string => typeof l === 'string' && (l.startsWith('- ') || l.startsWith('+ ')))
|
||||
.map((l) => ({ side: l.startsWith('- ') ? 'original' as const : 'blind' as const, text: l.slice(2) })), [metric])
|
||||
const norm = useMemo(() => {
|
||||
const lines = (Array.isArray(metric.diff?.text_diff) ? metric.diff.text_diff : []).filter((l): l is string => typeof l === 'string')
|
||||
return {
|
||||
original: lines.filter((l) => l.startsWith('- ')).map((l) => l.slice(2)),
|
||||
blind: lines.filter((l) => l.startsWith('+ ')).map((l) => l.slice(2)),
|
||||
}
|
||||
}, [metric])
|
||||
const differing = pairs.filter((p) => p.differs)
|
||||
return (
|
||||
<div id={id} className="mt-1.5 min-w-0 space-y-2" data-dual-compare="">
|
||||
@@ -404,34 +530,13 @@ function Compare({ id, metric }: { id: string; metric: DualCheckMetric }) {
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{(differing.length ? differing : pairs).map((p) => (
|
||||
<div key={p.path} className="min-w-0" data-dual-pair={p.path}>
|
||||
<div className="mb-0.5 text-2xs text-faint [overflow-wrap:anywhere]">
|
||||
<code className="mono">{p.path}</code>{p.source ? ` · ${T.source(p.source)}` : ''}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 @lg:grid-cols-2">
|
||||
<SqlPane side="original" label={T.sqlOriginal} sql={p.original?.sql} marks={marks.original} />
|
||||
<SqlPane side="blind" label={T.sqlBlind} sql={p.blind?.sql} marks={marks.blind} />
|
||||
</div>
|
||||
{str(p.blind?.rationale) && (
|
||||
<p className="mt-1 text-2xs leading-relaxed text-dim [overflow-wrap:anywhere]" data-dual-rationale="">
|
||||
<span className="text-faint">{T.rationale}:</span>{p.blind!.rationale}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{lines.length > 0 && (
|
||||
{(differing.length ? differing : pairs).map((p) => <PairSql key={p.path} pair={p} />)}
|
||||
{(norm.original.length > 0 || norm.blind.length > 0) && (
|
||||
<div data-dual-text-diff="">
|
||||
<div className="mb-0.5 text-2xs text-faint">{T.textDiff}</div>
|
||||
<div className="mono max-h-48 overflow-y-auto rounded border bg-panel px-2 py-1.5 text-2xs leading-relaxed focus-visible:outline-2 focus-visible:outline-[color:var(--accent)]"
|
||||
tabIndex={0} role="group" aria-label={T.textDiff}>
|
||||
{lines.map((l, i) => (
|
||||
<div key={i} className="flex min-w-0 gap-2" data-diff-side={l.side}>
|
||||
{/* 标签列等宽:两边的行首对齐,一眼看出是同一处的两种写法 */}
|
||||
<span className="w-[3em] shrink-0 font-sans text-faint">{l.side === 'original' ? DUAL_CHECK_TEXT.original : DUAL_CHECK_TEXT.blind}</span>
|
||||
<span className="min-w-0 whitespace-pre-wrap text-dim [overflow-wrap:anywhere]">{l.text}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="grid grid-cols-1 gap-2 @lg:grid-cols-2">
|
||||
<NormPane side="original" lines={norm.original} />
|
||||
<NormPane side="blind" lines={norm.blind} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -439,21 +544,61 @@ function Compare({ id, metric }: { id: string; metric: DualCheckMetric }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** 一条 SQL:逐行画,text_diff 里有的行高亮。折行显示、不横向撑开,太长时框里纵向滚动,框能用键盘聚焦 */
|
||||
function SqlPane({ side, label, sql, marks }: { side: 'original' | 'blind'; label: string; sql?: string | null; marks: Set<string> }) {
|
||||
const lines = typeof sql === 'string' && sql.trim() ? sql.replace(/\s+$/, '').split('\n') : null
|
||||
/** 一个对不上的输入:两条 SQL 原文并排,按行比出的不同行高亮,盲算的说明跟在下面 */
|
||||
function PairSql({ pair }: { pair: Pair }) {
|
||||
const a = useMemo(() => linesOf(pair.original?.sql), [pair.original?.sql])
|
||||
const b = useMemo(() => linesOf(pair.blind?.sql), [pair.blind?.sql])
|
||||
const diff = useMemo(() => (a && b ? lineDiff(a, b) : null), [a, b])
|
||||
return (
|
||||
<div className="min-w-0" data-dual-pair={pair.path}>
|
||||
<div className="mb-0.5 text-2xs text-faint [overflow-wrap:anywhere]">
|
||||
<code className="mono">{pair.path}</code>{pair.source ? ` · ${T.source(pair.source)}` : ''}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 @lg:grid-cols-2">
|
||||
<SqlPane side="original" label={T.sqlOriginal} lines={a} marks={diff?.a} />
|
||||
<SqlPane side="blind" label={T.sqlBlind} lines={b} marks={diff?.b} />
|
||||
</div>
|
||||
{str(pair.blind?.rationale) && (
|
||||
<p className="mt-1 text-2xs leading-relaxed text-dim [overflow-wrap:anywhere]" data-dual-rationale="">
|
||||
<span className="text-faint">{T.rationale}:</span>{pair.blind!.rationale}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 一条 SQL 原文:逐行画,marks 里的行(按行比出的不同行)高亮;空白行不标 */
|
||||
function SqlPane({ side, label, lines, marks }: { side: 'original' | 'blind'; label: string; lines: string[] | null; marks?: Set<number> }) {
|
||||
return (
|
||||
<figure className="m-0 min-w-0">
|
||||
<figcaption className="mb-0.5 text-2xs text-faint">{label}</figcaption>
|
||||
<pre className={clsx('max-h-56 overflow-y-auto whitespace-pre-wrap rounded border bg-panel px-1 py-1.5 text-2xs leading-relaxed [overflow-wrap:anywhere] focus-visible:outline-2 focus-visible:outline-[color:var(--accent)]',
|
||||
lines ? 'mono text-dim' : 'font-sans text-faint')}
|
||||
tabIndex={0} aria-label={label} data-dual-sql={side}>
|
||||
<pre className={clsx('max-h-56', CODE_BOX, lines ? 'mono text-dim' : 'font-sans text-faint')} tabIndex={0} aria-label={label} data-dual-sql={side}>
|
||||
{lines
|
||||
? lines.map((line, i) => (marks.has(normLine(line))
|
||||
? <mark key={i} className="block rounded-sm px-1" style={MARK} data-diff-line={side === 'original' ? 'del' : 'add'}>{line || ' '}</mark>
|
||||
? lines.map((line, i) => (marks?.has(i) && line.trim()
|
||||
? <mark key={i} className="block rounded-sm px-1" style={MARK} data-diff-line={side === 'original' ? 'del' : 'add'}>{line}</mark>
|
||||
: <span key={i} className="block px-1">{line || ' '}</span>))
|
||||
: <span className="px-1">{T.sqlMissing}</span>}
|
||||
</pre>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
/** 规范化后不同的行的一栏:这一边的「- 」(原流程)或「+ 」(盲算)行,每行高亮;这一边一行都没有时照实说 */
|
||||
function NormPane({ side, lines }: { side: 'original' | 'blind'; lines: string[] }) {
|
||||
const label = side === 'original' ? DUAL_CHECK_TEXT.original : DUAL_CHECK_TEXT.blind
|
||||
return (
|
||||
<figure className="m-0 min-w-0" data-dual-norm={side}>
|
||||
<figcaption className="mb-0.5 text-2xs text-faint">{label}</figcaption>
|
||||
<div className={clsx('max-h-48', CODE_BOX, lines.length ? 'mono' : 'font-sans text-faint')} tabIndex={0} role="group"
|
||||
aria-label={`${T.textDiff} · ${label}`}>
|
||||
{lines.length
|
||||
? lines.map((line, i) => (
|
||||
<mark key={i} className="mb-px block rounded-sm px-1 last:mb-0" style={MARK} data-diff-line={side === 'original' ? 'del' : 'add'}>
|
||||
{line || ' '}
|
||||
</mark>
|
||||
))
|
||||
: <span className="px-1">{T.textDiffNone}</span>}
|
||||
</div>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { NodeState, RunPhase } from './trace'
|
||||
import { formatDuration, formatNumber } from '../lib/format'
|
||||
import {
|
||||
ABSENCE_CLAIM_TEXT, CATALOG_DRIFT_TEXT, CATALOG_PATCH_TEXT, COPILOT_CONTEXT_TEXT, JUDGE_TEXT, MERGE_TEXT, RUN_SQL_CHECK_TEXT, SCHEMA_PARTIAL_TEXT, SQL_CHECK_TEXT, TYPE_LABEL,
|
||||
DUAL_CHECK_TEXT,
|
||||
CATALOG_TABLE_FIELD_LABEL, DUAL_CHECK_TEXT,
|
||||
claimTally,
|
||||
evidenceTally, issuanceLabel, nodeTypeLabel,
|
||||
} from '../lib/terms'
|
||||
@@ -1034,13 +1034,16 @@ function explainLog(code: string | undefined, message: string, d: Record<string,
|
||||
return { title: DC.log.resolved(parts), sub: gaps != null ? DC.log.gaps(gaps) : undefined, level: warn ? 'warn' : 'info' }
|
||||
}
|
||||
case 'dual_check_catalog': {
|
||||
// written:[{source, table, item}] 写进数据目录的已确认事实;skipped:[{reason}] 没写的(已有已确认的不覆盖……)
|
||||
// written:[{source, table, item}] 写进数据目录的已确认事实;skipped:[{reason}] 没写的(已有已确认的不覆盖……)。
|
||||
// item 是表级目录项的键(business_date / valid_filter / dedup),按数据目录页的叫法写;认不出的键不往界面上放,
|
||||
// 只写表;老版本给的一句中文照写
|
||||
const written = Array.isArray(d.written) ? d.written.filter(isRecord) : []
|
||||
const skipped = Array.isArray(d.skipped) ? d.skipped.filter(isRecord) : []
|
||||
const what = written.map((w) => {
|
||||
const table = str(w.table)
|
||||
const where = table ? `${table}${str(w.source) ? `(${str(w.source)})` : ''}` : ''
|
||||
return [where, str(w.item)].filter(Boolean).join(' · ')
|
||||
const key = str(w.item)
|
||||
const item = key && Object.hasOwn(CATALOG_TABLE_FIELD_LABEL, key)
|
||||
? CATALOG_TABLE_FIELD_LABEL[key as keyof typeof CATALOG_TABLE_FIELD_LABEL] : cjkText(key)
|
||||
return DC.log.catalogItem(str(w.table), str(w.source), item)
|
||||
}).filter(Boolean)
|
||||
const why = skipped.map((s) => str(s.reason)).filter((r): r is string => !!r)
|
||||
const rest = skipped.length ? `${DC.log.catalogSkipped(skipped.length)}${why.length ? `:${why.join(';')}` : ''}` : ''
|
||||
|
||||
@@ -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: 25, inquiry: 1, bundle: 16, whatif: 33,
|
||||
schedules: 60, compare: 45, verify: 45, inquiry: 1, bundle: 16, whatif: 33,
|
||||
}
|
||||
// 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段
|
||||
// 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。
|
||||
|
||||
+29
-15
@@ -2031,10 +2031,13 @@ await section('双盲复算与反方审稿的日志(期 3):按 code 和结
|
||||
E(4, 'log', 'dc', { level: 'info', code: 'dual_check_agreed', metrics: 2, message: '2 个指标与盲算一致' }),
|
||||
E(5, 'log', 'dc', { level: 'warn', code: 'dual_check_disagreed', metrics: [{ id: 'gmv', name: '销售额' }, { id: 'aov', name: '' }],
|
||||
card_artifact: CARD, message: '2 个指标与盲算不一致,已暂停等待审核' }),
|
||||
E(6, 'log', 'dc', { level: 'warn', code: 'dual_check_limit', limit: 'max_queries', metric: '客单价', message: '已达查询次数上限' }),
|
||||
// metric 是指标 ID(F1a 发的是 job["metric_id"]),中文名只在原话里
|
||||
E(6, 'log', 'dc', { level: 'warn', code: 'dual_check_limit', limit: 'max_queries', metric: 'aov',
|
||||
message: '指标「客单价」的盲算已达每项最多查询次数(6 次),按无法复算处理' }),
|
||||
E(7, 'log', 'dc', { level: 'info', code: 'dual_check_resolved', choices: { gmv: 'a', aov: 'neither' }, gaps: 1, message: '审核人已裁决' }),
|
||||
E(8, 'log', 'dc', { level: 'info', code: 'dual_check_catalog', message: '裁决已记入数据目录',
|
||||
written: [{ source: 'shop', table: 'orders', item: '业务日期:支付时间 paid_at' }],
|
||||
// written 的 item 是数据目录表级项的键(F1a 的 dual_check_facts.FACT_ITEMS),界面上要写成中文叫法
|
||||
E(8, 'log', 'dc', { level: 'info', code: 'dual_check_catalog', message: '已写入数据目录:「orders」的业务日期',
|
||||
written: [{ source: 'shop', table: 'orders', item: 'business_date' }, { source: 'shop', table: 'refunds', item: 'valid_filter' }],
|
||||
skipped: [{ reason: '已有已确认的有效行过滤,未覆盖' }] }),
|
||||
E(9, 'node.finished', 'dc', { duration_ms: 3000 }),
|
||||
// 没有原话的老日志(只有 code 和结构化字段)照样解读
|
||||
@@ -2079,15 +2082,16 @@ await section('双盲复算与反方审稿的日志(期 3):按 code 和结
|
||||
&& dis.sub === '「销售额」「aov」' && dis.level === 'warn', `${dis?.title} / ${dis?.sub}`)
|
||||
check('……下一步说去审批卡上逐个选,完整对照的工件可下钻', !!dis?.next?.includes('审批卡') && dis.artifact === CARD, `${dis?.next} / ${dis?.artifact}`)
|
||||
const lim = by('dual_check_limit')
|
||||
check('dual_check_limit:哪个指标、哪个上限、去哪儿调', lim?.title === '复算指标「客单价」时已达查询次数上限,该指标未完成盲算'
|
||||
check('dual_check_limit:哪个指标(给的是 ID,不当名字加引号)、哪个上限、去哪儿调', lim?.title === '指标 aov 的盲算已达查询次数上限,按无法复算处理'
|
||||
&& lim.level === 'warn' && !!lim.next?.includes('核查'), `${lim?.title} / ${lim?.next}`)
|
||||
const res = by('dual_check_resolved')
|
||||
check('dual_check_resolved:按选择计数(为 0 的不写),缺口放副标题', res?.title === '审核人已裁决:采用原流程 1 个,两边都不对 1 个'
|
||||
&& res.sub === '1 项记为缺口' && res.level === 'warn', `${res?.title} / ${res?.sub} / ${res?.level}`)
|
||||
const cat = by('dual_check_catalog')
|
||||
check('dual_check_catalog:记入几项、写的是什么、没写入的照实说', cat?.title === '已将 1 项裁决记入数据目录'
|
||||
&& !!cat.sub?.includes('orders') && cat.sub.includes('业务日期:支付时间 paid_at') && cat.sub.includes('1 项未写入')
|
||||
check('dual_check_catalog:记入几项、写的是哪张表的哪一项(中文叫法)、没写入的照实说', cat?.title === '已将 2 项人工确认的事实记入数据目录'
|
||||
&& !!cat.sub?.includes('「orders」(shop)的业务日期') && cat.sub.includes('「refunds」(shop)的有效记录条件') && cat.sub.includes('1 项未写入')
|
||||
&& cat.sub.includes('已有已确认的有效行过滤,未覆盖') && !!cat.next?.includes('数据目录') && cat.level === 'info', `${cat?.title} / ${cat?.sub}`)
|
||||
check('……副标题里没有目录项的键名(business_date、valid_filter 之类)', !/business_date|valid_filter|\bdedup\b|[a-z]+_[a-z_]+/.test(cat?.sub ?? ''), cat?.sub)
|
||||
const off = by('dual_check_skipped', 'dc2')
|
||||
check('dual_check_skipped(disabled,没有原话):照样上时间线,说怎么开启', off?.title === '双盲复算未启用,已跳过'
|
||||
&& !!off.next?.includes('启用') && off.level === 'info' && off.detail === undefined, `${off?.title} / ${off?.next} / ${off?.detail}`)
|
||||
@@ -2118,12 +2122,17 @@ await section('双盲复算与反方审稿的日志(期 3):按 code 和结
|
||||
E(3, 'log', 'dc', { level: 'info', code: 'dual_check_resolved', choices: { gmv: 'a', aov: 'a' }, gaps: [], message: '审核人已裁决' }),
|
||||
E(4, 'log', 'dc', { level: 'info', code: 'dual_check_resolved', choices: { gmv: 'b' }, gaps: ['指标「销售额」采用了盲算的结果'], message: '审核人已裁决' }),
|
||||
E(5, 'log', 'dc', { level: 'info', code: 'dual_check_catalog', written: [], skipped: [{ reason: '差异不止一个方面' }], message: '未写入' }),
|
||||
E(6, 'node.finished', 'dc', {}),
|
||||
E(7, 'node.started', 'ch', { node_type: 'challenge' }),
|
||||
E(8, 'log', 'ch', { level: 'info', code: 'challenge_done', counts: { stands: 4, weakened: 0, refuted: 0, inconclusive: 1 }, message: '完成' }),
|
||||
E(9, 'log', 'ch', { level: 'info', code: 'challenge_skipped', reason: '报告节点没有产出文档', message: '跳过' }),
|
||||
E(10, 'log', 'ch', { level: 'info', code: 'challenge_skipped', reason: 'no_claims', message: '跳过' }),
|
||||
E(11, 'node.finished', 'ch', {}),
|
||||
// 认不出的目录项键不往界面上放,只写表;老版本给的是一句中文的照写
|
||||
E(6, 'log', 'dc', { level: 'info', code: 'dual_check_catalog', message: '已写入',
|
||||
written: [{ source: 'shop', table: 'stores', item: 'grain_x' }, { table: 'orders', item: '业务日期:支付时间 paid_at' }], skipped: [] }),
|
||||
E(7, 'node.finished', 'dc', {}),
|
||||
E(8, 'node.started', 'ch', { node_type: 'challenge' }),
|
||||
E(9, 'log', 'ch', { level: 'info', code: 'challenge_done', counts: { stands: 4, weakened: 0, refuted: 0, inconclusive: 1 }, message: '完成' }),
|
||||
E(10, 'log', 'ch', { level: 'info', code: 'challenge_skipped', reason: '报告节点没有产出文档', message: '跳过' }),
|
||||
E(11, 'log', 'ch', { level: 'info', code: 'challenge_skipped', reason: 'no_claims', message: '跳过' }),
|
||||
// no_report:上游的报告节点找得到(找不到时节点直接报错),但它的报告文档读不出来(没写出、保存失败、所在分支没执行)
|
||||
E(12, 'log', 'ch', { level: 'info', code: 'challenge_skipped', reason: 'no_report', message: '报告「周报」的报告文档无法读取,本节点已跳过' }),
|
||||
E(13, 'node.finished', 'ch', {}),
|
||||
]))
|
||||
const daily = alt.find((s) => s.code === 'dual_check_limit')
|
||||
check('每日金额上限:不点名指标,下一步说今日费用已达上限', daily?.title === '双盲复算已达每日金额上限' && !!daily.next?.includes('今日核查费用'),
|
||||
@@ -2133,15 +2142,20 @@ await section('双盲复算与反方审稿的日志(期 3):按 code 和结
|
||||
`${allA?.title} / ${allA?.sub} / ${allA?.level}`)
|
||||
check('缺口给的是一份清单:照样数出几项', oneB?.title === '审核人已裁决:采用盲算 1 个' && oneB.sub === '1 项记为缺口' && oneB.level === 'warn',
|
||||
`${oneB?.title} / ${oneB?.sub}`)
|
||||
const cat0 = alt.find((s) => s.code === 'dual_check_catalog')
|
||||
check('一项都没写入数据目录:标题照实说,原因放副标题', cat0?.title === '裁决未记入数据目录' && !!cat0.sub?.includes('差异不止一个方面'),
|
||||
const [cat0, catOdd] = alt.filter((s) => s.code === 'dual_check_catalog')
|
||||
check('一项都没写入数据目录:标题照实说,原因放副标题', cat0?.title === '本次裁决未写入数据目录' && !!cat0.sub?.includes('差异不止一个方面'),
|
||||
`${cat0?.title} / ${cat0?.sub}`)
|
||||
check('认不出的目录项键不显示(只写表),一句中文的照写', catOdd?.sub === '「stores」(shop);「orders」的业务日期:支付时间 paid_at',
|
||||
catOdd?.sub)
|
||||
const calm = alt.find((s) => s.code === 'challenge_done')
|
||||
check('没有推翻:不用提醒色,0 句的不写', calm?.title === '反方审稿完成:审阅了 5 句结论' && calm.sub === '经受住 4 · 无法判断 1'
|
||||
&& calm.level === 'info', `${calm?.title} / ${calm?.sub} / ${calm?.level}`)
|
||||
const [said, noClaims] = alt.filter((s) => s.code === 'challenge_skipped')
|
||||
const [said, noClaims, noDoc] = alt.filter((s) => s.code === 'challenge_skipped')
|
||||
check('跳过原因是一句中文:照写在副标题;认得的原因说人话', said?.title === '反方审稿已跳过' && said.sub === '报告节点没有产出文档'
|
||||
&& noClaims?.title === '报告中没有需要审的关键结论,已跳过反方审稿', `${said?.title} / ${said?.sub} / ${noClaims?.title}`)
|
||||
check('no_report:说的是报告文档读不出来(不是没找到报告节点),下一步指向报告节点有没有执行成功、在不在执行到的分支上',
|
||||
noDoc?.title === '要审的报告没有可读取的报告文档,已跳过反方审稿' && !!noDoc.next?.includes('报告撰写节点是否执行成功')
|
||||
&& !!noDoc.next?.includes('分支') && !noDoc.next?.includes('连接'), `${noDoc?.title} / ${noDoc?.next}`)
|
||||
|
||||
// 老日志缺字段、字段类型不对:不崩,标题照样说人话,不出 undefined、NaN、[object Object],不瞎填数
|
||||
const odd = [
|
||||
|
||||
+298
-27
@@ -2,10 +2,16 @@
|
||||
//
|
||||
// 双盲复算这一半守的是这些「看着没坏、其实用不了」的地方:
|
||||
// - 审批卡:两路对不上时,卡上要先按摘要画出来(完整对照还在路上时不能一片空白),再按工件补全;具体差异逐条列,
|
||||
// 只有兜底说明时写明「未能归纳成具体差异」并默认展开对照;对照两条 SQL 时左右并排(窄栏上下排),规范化后不同的
|
||||
// 行高亮;三个选择缺一个都不能提交,提交的回复是 {choices, note};卡上不能再冒出通用的输入框和「提交 / 驳回并终止」
|
||||
// (mode 是 input,通用那套会把一个字符串当 value 发出去);后果按运行类别说,探索运行不提出具;
|
||||
// - 检查器:口径卡、指标、口径说明、盲算模型、相对误差、不采用原流程时、启用,每一项都真的写进节点配置;
|
||||
// 只有兜底说明(或只有解析失败)时写明「未能归纳成具体差异」并默认展开对照;对照两条 SQL 时左右并排(窄栏上下排),
|
||||
// 原文按行比出的不同行高亮,规范化后不同的行(text_diff)分两栏高亮——夹具里的差异说明是服务端的真实输出
|
||||
// (backend/tests/test_dual_check_fixture.py 核对),规范化的行和原文对不上,高亮不能指望两边逐行相等;
|
||||
// 「只差一个方面、会记入数据目录」只在服务端真会写目录的形状下说(恰好一条具体差异,且是时间列、筛选条件或聚合);
|
||||
// 三个选择缺一个都不能提交,提交的回复是 {choices, note};卡上不能再冒出通用的输入框和「提交 / 驳回并终止」
|
||||
// (mode 是 input,通用那套会把一个字符串当 value 发出去),认不出指标时另给「驳回并终止」;后果按运行类别和节点的
|
||||
// 「未采用原流程时」说(从运行的图快照里读),探索运行不提出具,读不到节点设置时有条件地说;
|
||||
// - 检查器:口径卡、指标、口径说明、盲算模型、相对误差、未采用原流程时、启用,每一项都真的写进节点配置;字段的
|
||||
// 叫法和服务端校验报错里的(schema.py 的 DUAL_CHECK_FIELD_LABEL,合入后)一字不差;盲算模型不藏在高级选项里
|
||||
// (时间线「模型相同」那一行要能定位过来);上游有好几张口径卡时写明必须选;「跟随设置」的当前值每次打开都重取;
|
||||
// 节点卡片的摘要写出复算哪张口径卡、几个指标;
|
||||
// - 设置页「核查」一节:读回各项,改了之后整组写 verify(不限存 null),叫法和服务端 VERIFY_SETTING_LABEL 一字不差;
|
||||
// - 390px 窄屏不横向溢出,深浅色都读得清,键盘能操作。
|
||||
@@ -113,15 +119,42 @@ await section('核查设置的叫法和服务端一字不差', async () => {
|
||||
check('……这一节其余的文案放在 section 里', !!T.section?.title && typeof T.section.spend === 'function')
|
||||
})
|
||||
|
||||
await section('双盲复算检查器的叫法和服务端校验报错一致', async () => {
|
||||
// 校验问题挂在字段下方、出现在问题面板里时,报错引用的是 schema.py 的 DUAL_CHECK_FIELD_LABEL:检查器上得是同一个名字,
|
||||
// 人才找得到是哪一项。处置的取值:报错里用短说法,下拉的选项以它开头、后面可以带一句用途(labels.py 的约定)
|
||||
const { DUAL_CHECK_TEXT: D } = await import(await load('/src/lib/terms.ts'))
|
||||
const KEYS = ['card', 'metrics', 'definitions', 'provider', 'model', 'tolerance', 'on_reject', 'enabled']
|
||||
check('检查器的字段名按配置键放在 DUAL_CHECK_TEXT.field', KEYS.every((k) => typeof D.field?.[k] === 'string' && D.field[k]),
|
||||
KEYS.filter((k) => !D.field?.[k]).join('、'))
|
||||
check('……模型的两项照「裁判模型 · 接入」「裁判模型」的叫法', D.field.provider === '盲算模型 · 接入' && D.field.model === '盲算模型',
|
||||
`${D.field.provider} / ${D.field.model}`)
|
||||
const py = readFileSync(`${root}backend/app/engine/schema.py`, 'utf8')
|
||||
const block = py.match(/^DUAL_CHECK_FIELD_LABEL = \{([\s\S]*?)^\}/m)?.[1]
|
||||
if (block == null) {
|
||||
// 双盲复算的服务端在另一个工作包里,合入之前 schema.py 还没有这一组:合入后这一节就逐项比对
|
||||
check('schema.py 还没有 DUAL_CHECK_FIELD_LABEL(双盲复算服务端合入后逐项比对)', true)
|
||||
return
|
||||
}
|
||||
const labels = Object.fromEntries([...block.matchAll(/"([^"]+)":\s*"([^"]+)"/g)].map((m) => [m[1], m[2]]))
|
||||
const wrong = KEYS.filter((k) => labels[k] !== D.field[k])
|
||||
check('每个字段的叫法和 DUAL_CHECK_FIELD_LABEL 一字不差', !wrong.length && Object.keys(labels).length === KEYS.length,
|
||||
wrong.map((k) => `${k}:${D.field[k]} ≠ ${labels[k]}`).join(';'))
|
||||
const opts = py.match(/^DUAL_CHECK_ON_REJECT_LABEL = \{([^}]*)\}/m)?.[1] ?? ''
|
||||
const short = Object.fromEntries([...opts.matchAll(/"([^"]+)":\s*"([^"]+)"/g)].map((m) => [m[1], m[2]]))
|
||||
check('「未采用原流程时」的选项以服务端的短说法开头', Object.keys(short).length === 2
|
||||
&& Object.entries(short).every(([k, v]) => D.onReject?.[k]?.startsWith(v)), JSON.stringify(short))
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 页面
|
||||
|
||||
const browser = await chromium.launch({ executablePath: CHROME })
|
||||
|
||||
/**
|
||||
* 开一个预览用例。完整对照的工件按 artifact 答(默认夹具里的那份;给函数可以延迟、出错),审批回复记下请求体、
|
||||
* 回一个伪造的运行;审批列表回空;别的非 GET 一律拦掉,别的 GET 放行
|
||||
* 开一个预览用例。完整对照的工件按 artifact 答(默认夹具里的那份;给函数可以延迟、出错),这次运行的图快照按 graph
|
||||
* 答(默认夹具里的图:双盲复算节点没写「未采用原流程时」,按缺省的计入缺口),审批回复记下请求体、回一个伪造的运行;
|
||||
* 审批列表回空;别的非 GET 一律拦掉,别的 GET 放行
|
||||
*/
|
||||
async function preview(syn, { width = 1280, height = 900, theme = 'dark', artifact = null, extra = null } = {}) {
|
||||
async function preview(syn, { width = 1280, height = 900, theme = 'dark', artifact = null, graph = null, extra = null } = {}) {
|
||||
const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: theme, timezoneId: 'Asia/Shanghai' })
|
||||
opened.add(ctx)
|
||||
ctx.setDefaultTimeout(6000)
|
||||
@@ -149,6 +182,10 @@ async function preview(syn, { width = 1280, height = 900, theme = 'dark', artifa
|
||||
const out = artifact ? await artifact() : { json: { id: CARD_ID, content: fx.card } }
|
||||
return route.fulfill({ status: out.status ?? 200, json: out.json })
|
||||
}
|
||||
if (url.pathname === `/api/runs/${AP.run_id}/graph`) {
|
||||
const out = graph ? await graph() : { json: { graph: fx.graph, workflow_id: null, version: null } }
|
||||
return route.fulfill({ status: out.status ?? 200, json: out.json })
|
||||
}
|
||||
if (url.pathname === '/api/approvals') return route.fulfill({ json: [] })
|
||||
return route.continue()
|
||||
})
|
||||
@@ -158,6 +195,8 @@ async function preview(syn, { width = 1280, height = 900, theme = 'dark', artifa
|
||||
}
|
||||
|
||||
const metric = (page, id) => page.locator(`[data-dual-metric="${id}"]`)
|
||||
/** 弹出的提示(toast 的播报区)里的文字:卡上的后果说明里也有「运行继续」,不能拿整页的文字比 */
|
||||
const toastOf = (page) => text(page.locator('[role="status"][aria-live="polite"]'))
|
||||
|
||||
await section('审批卡:先按摘要画,再取完整对照', async () => {
|
||||
// 完整对照晚一点到:这段时间里卡上照样有两个值、相差多少、摘要里的差异,选择也能先做
|
||||
@@ -196,17 +235,36 @@ await section('审批卡:先按摘要画,再取完整对照', async () => {
|
||||
const rr = metric(page, 'refund_rate')
|
||||
check('……只有兜底的那个指标默认展开对照', (await rr.locator('[data-dual-compare-toggle]').getAttribute('aria-expanded')) === 'true'
|
||||
&& await rr.locator('[data-dual-compare]').count() === 1)
|
||||
check('……兜底的原话不再列成一条「具体差异」', await rr.locator('[data-dual-diff-item]').count() === 0)
|
||||
check('只差一个方面的指标:说明选择会记入数据目录', (await text(gmv.locator('[data-dual-catalog]'))).includes('记入数据目录')
|
||||
&& await rr.locator('[data-dual-catalog]').count() === 0)
|
||||
check('……兜底的原话不再列成一条「具体差异」,也不另附一遍', await rr.locator('[data-dual-diff-item]').count() === 0
|
||||
&& await rr.locator('[data-dual-fallback-note]').count() === 0)
|
||||
// 销售额只差按哪一列筛时间(一条 time):服务端会把采用的一边记成该表的业务日期,卡上有条件地说
|
||||
const note = await text(gmv.locator('[data-dual-catalog]'))
|
||||
check('只差在时间列的指标:说明采用一边后会记成该表的业务日期,有条件地说', 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]'))
|
||||
check('正式运行:后果写明报告里的数仍取自原流程、采用盲算或都不对会让出具降档', after.includes('报告中的数仍取自原流程') && after.includes('出具降档'), after)
|
||||
check('……节点按缺省(计入缺口)处置:不再挂「判为失败会终止」那句', !after.includes('判为失败') && !after.includes('终止'), after)
|
||||
check('没有发出任何写请求', sent.length === 0, JSON.stringify(sent))
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await shot(page, 'approval-dark')
|
||||
})
|
||||
|
||||
await section('审批卡:并排对照与高亮', async () => {
|
||||
/** 一块里高亮的行:原文两栏(data-dual-sql)和规范化后的两栏(data-dual-norm)各自的 <mark> 文字 */
|
||||
const marksIn = (loc) => loc.evaluate((el) => {
|
||||
const pick = (sel) => [...el.querySelectorAll(sel)].map((m) => m.textContent.trim())
|
||||
return {
|
||||
del: pick('[data-dual-sql="original"] [data-diff-line="del"]'), add: pick('[data-dual-sql="blind"] [data-diff-line="add"]'),
|
||||
normDel: pick('[data-dual-norm="original"] [data-diff-line="del"]'), normAdd: pick('[data-dual-norm="blind"] [data-diff-line="add"]'),
|
||||
}
|
||||
})
|
||||
/** text_diff 里两边各有哪几行(去掉「- 」「+ 」和首尾空白) */
|
||||
const sidesOf = (diff) => ({
|
||||
del: diff.text_diff.filter((l) => l.startsWith('- ')).map((l) => l.slice(2).trim()),
|
||||
add: diff.text_diff.filter((l) => l.startsWith('+ ')).map((l) => l.slice(2).trim()),
|
||||
})
|
||||
|
||||
await section('审批卡:并排对照与高亮(服务端真实的差异输出)', async () => {
|
||||
const { page, errors } = await preview('dual-check-approval')
|
||||
await page.waitForSelector('[data-dual-check][data-state="ready"]')
|
||||
const gmv = metric(page, 'gmv')
|
||||
@@ -221,36 +279,205 @@ await section('审批卡:并排对照与高亮', async () => {
|
||||
const blind = gmv.locator('[data-dual-sql="blind"]')
|
||||
check('两条 SQL 都在,各标是哪一边', (await text(orig)).includes('paid_at') && (await text(blind)).includes('created_at')
|
||||
&& (await text(gmv.locator('[data-dual-pair]'))).includes('原流程的 SQL') && (await text(gmv.locator('[data-dual-pair]'))).includes('盲算的 SQL'))
|
||||
const marks = await gmv.evaluate((el) => ({
|
||||
del: [...el.querySelectorAll('[data-dual-sql="original"] [data-diff-line="del"]')].map((m) => m.textContent.trim()),
|
||||
add: [...el.querySelectorAll('[data-dual-sql="blind"] [data-diff-line="add"]')].map((m) => m.textContent.trim()),
|
||||
}))
|
||||
check('text_diff 的行在原流程那边高亮', marks.del.join('|') === "WHERE paid_at >= '2026-09-01'|AND paid_at < '2026-09-08'", marks.del.join('|'))
|
||||
// 夹具的 text_diff 是 explain_sql_diff 的真实输出:规范化后列名带表名、两个条件并成一行,和原文的任何一行都对不上
|
||||
const real = sidesOf(fx.card.metrics[0].diff)
|
||||
const rawLines = new Set(fx.card.metrics[0].original.inputs[0].sql.split('\n').map((l) => l.trim()))
|
||||
check('(夹具)text_diff 是规范化后的行,和原文逐行对不上', real.del.length > 0 && !real.del.some((l) => rawLines.has(l)), real.del.join(' | '))
|
||||
const marks = await marksIn(gmv)
|
||||
check('原文按行比:不同的行在原流程那边高亮', marks.del.join('|') === "WHERE paid_at >= '2026-09-01'|AND paid_at < '2026-09-08'", marks.del.join('|'))
|
||||
check('……在盲算那边也高亮', marks.add.join('|') === "WHERE created_at >= '2026-09-01'|AND created_at < '2026-09-08'", marks.add.join('|'))
|
||||
check('……相同的行不高亮', !(await orig.locator('[data-diff-line]').allInnerTexts()).some((t) => t.includes('FROM orders')))
|
||||
check('text_diff 的行分两栏高亮:原流程一栏是「- 」行', marks.normDel.join('|') === real.del.join('|'), marks.normDel.join('|'))
|
||||
check('……盲算一栏是「+ 」行', marks.normAdd.join('|') === real.add.join('|'), marks.normAdd.join('|'))
|
||||
const hl = await gmv.locator('[data-dual-norm] [data-diff-line]').first().evaluate((el) => getComputedStyle(el).backgroundColor)
|
||||
check('……高亮行有底色', !!hl && hl !== 'rgba(0, 0, 0, 0)' && hl !== 'transparent', hl)
|
||||
const o = await orig.boundingBox()
|
||||
const b = await blind.boundingBox()
|
||||
check('宽栏里左右并排:同一行、盲算在右边', !!o && !!b && Math.abs(o.y - b.y) < 2 && b.x >= o.x + o.width - 1, JSON.stringify({ o, b }))
|
||||
const no = await gmv.locator('[data-dual-norm="original"]').boundingBox()
|
||||
const nb = await gmv.locator('[data-dual-norm="blind"]').boundingBox()
|
||||
check('……规范化后的两栏也左右并排', !!no && !!nb && Math.abs(no.y - nb.y) < 2 && nb.x >= no.x + no.width - 1, JSON.stringify({ no, nb }))
|
||||
check('盲算的说明', (await text(gmv.locator('[data-dual-rationale]'))).includes('销售额按下单时间统计本周全部订单金额之和'))
|
||||
check('输入取值:路径、两边的值', (await text(gmv.locator('[data-dual-input="vars.kpi.gmv"]'))).includes('45,678.5')
|
||||
&& (await text(gmv.locator('[data-dual-input="vars.kpi.gmv"]'))).includes('51,388.3'))
|
||||
const lines = await gmv.locator('[data-dual-text-diff] [data-diff-side]').evaluateAll((els) => els.map((e) => e.dataset.diffSide))
|
||||
check('规范化后不同的行另列一份,每行标是哪一边', lines.join(',') === 'original,original,blind,blind', lines.join(','))
|
||||
const rr = metric(page, 'refund_rate')
|
||||
check('两个输入、只有一个不同:只对照不同的那一个', (await rr.locator('[data-dual-pair]').evaluateAll((els) => els.map((e) => e.dataset.dualPair))).join(',')
|
||||
=== 'vars.kpi.refunds')
|
||||
check('……输入取值两项都列,不同的那项标出来', await rr.locator('[data-dual-input]').count() === 2
|
||||
&& await rr.locator('[data-dual-input="vars.kpi.refunds"][data-differs]').count() === 1
|
||||
&& await rr.locator('[data-dual-input="vars.kpi.orders"][data-differs]').count() === 0)
|
||||
// 原流程那条规范化后多了 AS,和原文对不上:不乱标;盲算那条规范化后和原文一字不差:照样高亮
|
||||
check('……一行写完的 SQL:规范化后对不上原文的不乱标,对得上的照样高亮', await rr.locator('[data-dual-sql="original"] [data-diff-line]').count() === 0
|
||||
&& await rr.locator('[data-dual-sql="blind"] [data-diff-line="add"]').count() === 1)
|
||||
check('……规范化后不同的行照样另列', await rr.locator('[data-dual-text-diff] [data-diff-side]').count() === 2)
|
||||
// 退款这一项两边写法差得多(EXISTS 对 IN):原文只有前两行相同,其余各自高亮;规范化后的行照样分两栏
|
||||
const rm = await marksIn(rr)
|
||||
const rreal = sidesOf(fx.card.metrics[1].diff)
|
||||
check('……原文按行比:相同的前两行不标,其余各自高亮', rm.del.length === 4 && rm.add.join('|') === 'WHERE r.order_id IN (SELECT id FROM orders)'
|
||||
&& !rm.del.concat(rm.add).some((t) => t.startsWith('SELECT COUNT') || t.startsWith('FROM refunds')), `${rm.del.join('|')} ‖ ${rm.add.join('|')}`)
|
||||
check('……规范化后的行两栏各自高亮(「- 」「+ 」交错给的也分得开)', rm.normDel.join('|') === rreal.del.join('|') && rm.normAdd.join('|') === rreal.add.join('|'),
|
||||
`${rm.normDel.join('|')} ‖ ${rm.normAdd.join('|')}`)
|
||||
await page.keyboard.press('Enter')
|
||||
check('再按一次回车收起', (await toggle.getAttribute('aria-expanded')) === 'false' && await gmv.locator('[data-dual-compare]').count() === 0)
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
})
|
||||
|
||||
/**
|
||||
* 把「销售额」换成 variants 里的一对 SQL 及其真实的差异说明(取值照旧不同),别的指标原样;diff 给了就用给的
|
||||
* (拼几个输入的、兜底的原话)
|
||||
*/
|
||||
function cardWith(name, patch = {}) {
|
||||
const v = fx.variants[name]
|
||||
const card = JSON.parse(JSON.stringify(fx.card))
|
||||
const gmv = card.metrics[0]
|
||||
gmv.original.inputs[0].sql = v?.sql[0] ?? gmv.original.inputs[0].sql
|
||||
gmv.blind.inputs[0].sql = v?.sql[1] ?? gmv.blind.inputs[0].sql
|
||||
gmv.diff = JSON.parse(JSON.stringify(v?.diff ?? gmv.diff))
|
||||
Object.assign(gmv, patch.metric ?? {})
|
||||
if (patch.diff) gmv.diff = patch.diff
|
||||
return async () => ({ json: { id: CARD_ID, content: card } })
|
||||
}
|
||||
|
||||
await section('审批卡:「记入数据目录」只在服务端真会写的形状下说', async () => {
|
||||
// dual_check_facts.derive 的门槛:只有一个输入对不上、恰好一条具体差异、没有兜底,而且这一条是时间列、筛选条件或聚合
|
||||
const cases = [
|
||||
['time_range', null, '只差时间范围(不是换了时间列)'],
|
||||
['two_filters', null, '差两个筛选条件'],
|
||||
['join', null, '差在关联方式'],
|
||||
['parse', null, '一边解析失败'],
|
||||
['filter_extra', '有效记录条件', '只差一个筛选条件'],
|
||||
['dedup', '去重规则', '只差去不去重'],
|
||||
]
|
||||
for (const [name, want, what] 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)
|
||||
if (name === 'two_filters') {
|
||||
check('……两条差异都列出来', await gmv.locator('[data-dual-diff-item]').count() === 2)
|
||||
}
|
||||
const diff = fx.variants[name].diff
|
||||
if (diff.text_diff.length) {
|
||||
await gmv.locator('[data-dual-compare-toggle]').click()
|
||||
const m = await marksIn(gmv)
|
||||
const real = sidesOf(diff)
|
||||
check(`……规范化后的行按真实输出分两栏高亮(${real.del.length} + ${real.add.length} 行)`,
|
||||
m.normDel.join('|') === real.del.join('|') && m.normAdd.join('|') === real.add.join('|'), `${m.normDel.join('|')} ‖ ${m.normAdd.join('|')}`)
|
||||
if (!real.add.length) {
|
||||
check('……一边没有不同的行时照实说', (await text(gmv.locator('[data-dual-norm="blind"]'))).includes('没有不同的行'))
|
||||
}
|
||||
}
|
||||
check(`${what}:页面没有报错`, errors.length === 0, errors.join(';'))
|
||||
}
|
||||
|
||||
// 几个输入都差在时间列:节点把两处的差异合成一份(每条前面写明是哪个输入),不止一条,不写目录
|
||||
const t = fx.variants.time_range.diff.items[0]
|
||||
const merged = {
|
||||
equivalent: false, fallback: false, text_diff: fx.card.metrics[0].diff.text_diff,
|
||||
items: [{ aspect: 'time', text: `输入「gmv_web」:${fx.card.metrics[0].diff.items[0].text}` }, { aspect: 'time', text: `输入「gmv_app」:${t.text}` }],
|
||||
}
|
||||
const two = await preview('dual-check-approval', { artifact: cardWith(null, { diff: merged }) })
|
||||
await two.page.waitForSelector('[data-dual-check][data-state="ready"]')
|
||||
check('几个输入都差在同一方面(合并后两条):不说会记入数据目录', await metric(two.page, 'gmv').locator('[data-dual-catalog]').count() === 0)
|
||||
})
|
||||
|
||||
await section('审批卡:只有解析失败、兜底原话另有原因时', async () => {
|
||||
// 只有解析失败:和摘要阶段(服务端把它算作兜底)说法一致,默认展开对照,解析的原话附在下面
|
||||
const { page } = await preview('dual-check-approval', { artifact: cardWith('parse') })
|
||||
await page.waitForSelector('[data-dual-check][data-state="ready"]')
|
||||
const gmv = metric(page, 'gmv')
|
||||
check('只有解析失败:写那句固定的兜底说明,不当成一条具体差异', (await text(gmv.locator('[data-dual-fallback]'))) === '两条 SQL 的写法不同,未能归纳成具体差异'
|
||||
&& await gmv.locator('[data-dual-diff-item]').count() === 0)
|
||||
check('……解析的原话附在下面', (await text(gmv.locator('[data-dual-fallback-note]'))).includes('盲算:SQL 无法解析'))
|
||||
check('……默认展开对照', (await gmv.locator('[data-dual-compare-toggle]').getAttribute('aria-expanded')) === 'true')
|
||||
|
||||
// 有一边没有 SQL(节点的原话「有一边没有可比较的 SQL」):不能再说「两条 SQL 的写法不同」
|
||||
const noSql = { equivalent: false, fallback: true, text_diff: [], items: [{ aspect: 'other', text: '有一边没有可比较的 SQL,请对照原文' }] }
|
||||
const card = cardWith(null, { diff: noSql })
|
||||
const lone = await preview('dual-check-approval', {
|
||||
artifact: async () => {
|
||||
const out = await card()
|
||||
out.json.content.metrics[0].blind.inputs[0].sql = null
|
||||
return out
|
||||
},
|
||||
})
|
||||
await lone.page.waitForSelector('[data-dual-check][data-state="ready"]')
|
||||
const g2 = metric(lone.page, 'gmv')
|
||||
const said = await text(g2.locator('[data-dual-fallback]'))
|
||||
check('一边没有 SQL:照节点的原话说,不写「两条 SQL 的写法不同」', said === '有一边没有可比较的 SQL' && !(await text(g2)).includes('写法不同'), said)
|
||||
check('……盲算那一栏写没有记录 SQL', (await text(g2.locator('[data-dual-sql="blind"]'))) === '没有记录 SQL')
|
||||
|
||||
// 比较超时:固定那句之外附上原因
|
||||
const slow = { equivalent: false, fallback: true, text_diff: fx.card.metrics[0].diff.text_diff,
|
||||
items: [{ aspect: 'other', text: '两条 SQL 比较超时,未能归纳成具体差异,请对照原文' }] }
|
||||
const late = await preview('dual-check-approval', { artifact: cardWith(null, { diff: slow }) })
|
||||
await late.page.waitForSelector('[data-dual-check][data-state="ready"]')
|
||||
const g3 = metric(late.page, 'gmv')
|
||||
check('比较超时:固定那句之外附上原因(去掉「请对照原文」)', (await text(g3.locator('[data-dual-fallback]'))) === '两条 SQL 的写法不同,未能归纳成具体差异'
|
||||
&& (await text(g3.locator('[data-dual-fallback-note]'))) === '两条 SQL 比较超时,未能归纳成具体差异', await text(g3.locator('[data-dual-fallback-note]')))
|
||||
})
|
||||
|
||||
/** 运行的图快照里双盲复算节点的「未采用原流程时」写成 value(undefined 是没写) */
|
||||
const graphWith = (value) => async () => {
|
||||
const graph = JSON.parse(JSON.stringify(fx.graph))
|
||||
const dc = graph.nodes.find((n) => n.id === 'dc')
|
||||
dc.data.config = { ...dc.data.config, ...(value === undefined ? {} : { on_reject: value }) }
|
||||
return { json: { graph, workflow_id: null, version: null } }
|
||||
}
|
||||
|
||||
await section('审批卡:后果和提交后的提示按节点的「未采用原流程时」说', async () => {
|
||||
// 判为失败:采用盲算或都不对会让运行终止,卡上只说这一种,提交后的提示也照实说
|
||||
const fail = await preview('dual-check-approval', { graph: graphWith('fail') })
|
||||
await fail.page.waitForSelector('[data-dual-consequence][data-on-reject="fail"]')
|
||||
const said = await text(fail.page.locator('[data-dual-consequence]'))
|
||||
check('判为失败:后果写明采用盲算或都不对时运行将终止,不再说出具降档', said.includes('运行将终止') && !said.includes('降档') && !said.includes('如节点'), said)
|
||||
for (const id of ['gmv', 'refund_rate']) await metric(fail.page, id).locator('label', { hasText: '采用原流程的结果' }).click()
|
||||
await metric(fail.page, 'gmv').locator('label', { hasText: '采用盲算的结果' }).click()
|
||||
await fail.page.locator('[data-dual-submit]').click()
|
||||
await fail.page.waitForFunction(() => window.__dualResolved != null)
|
||||
await fail.page.waitForFunction(() => document.querySelector('[role="status"][aria-live="polite"]')?.textContent?.includes('已提交裁决'))
|
||||
const failToast = await toastOf(fail.page)
|
||||
check('……提交后的提示:运行将终止,不说「运行继续」', failToast.includes('已提交裁决,运行将按节点设置终止') && !failToast.includes('运行继续'), failToast)
|
||||
|
||||
// 判为失败但全选原流程:照常继续
|
||||
const ok = await preview('dual-check-approval', { graph: graphWith('fail') })
|
||||
await ok.page.waitForSelector('[data-dual-consequence][data-on-reject="fail"]')
|
||||
for (const id of ['gmv', 'refund_rate']) await metric(ok.page, id).locator('label', { hasText: '采用原流程的结果' }).click()
|
||||
await ok.page.locator('[data-dual-submit]').click()
|
||||
await ok.page.waitForFunction(() => window.__dualResolved != null)
|
||||
await ok.page.waitForFunction(() => document.querySelector('[role="status"][aria-live="polite"]')?.textContent?.includes('已提交裁决'))
|
||||
check('……全部采用原流程:提示运行继续', (await toastOf(ok.page)) === '已提交裁决,运行继续', await toastOf(ok.page))
|
||||
|
||||
// 图快照取不到(老运行、接口出错):两种处置都可能,有条件地说;提交后的提示不说继续还是终止
|
||||
const lost = await preview('dual-check-approval', { graph: async () => ({ status: 404, json: { detail: '运行不存在' } }) })
|
||||
await lost.page.waitForSelector('[data-dual-consequence][data-on-reject="unknown"]')
|
||||
const maybe = await text(lost.page.locator('[data-dual-consequence]'))
|
||||
check('取不到节点设置:降档那句之外,有条件地说判为失败会终止', maybe.includes('出具降档') && maybe.includes('如节点的「未采用原流程时」设为「判为失败」'), maybe)
|
||||
for (const id of ['gmv', 'refund_rate']) await metric(lost.page, id).locator('label', { hasText: '两边都不对' }).click()
|
||||
await lost.page.locator('[data-dual-submit]').click()
|
||||
await lost.page.waitForFunction(() => window.__dualResolved != null)
|
||||
await lost.page.waitForFunction(() => document.querySelector('[role="status"][aria-live="polite"]')?.textContent?.includes('已提交裁决'))
|
||||
const toast = await toastOf(lost.page)
|
||||
check('……提交后的提示不替节点下结论', toast === '已提交裁决', toast)
|
||||
})
|
||||
|
||||
await section('审批卡:认不出任何指标时留一个出口', async () => {
|
||||
const { page, sent, errors } = await preview('dual-check-approval-empty', {
|
||||
artifact: async () => ({ json: { id: CARD_ID, content: { metrics: [], model: null, same_model: false } } }),
|
||||
})
|
||||
await page.waitForSelector('[data-dual-check][data-state="ready"]')
|
||||
check('写明没有可以裁决的指标', (await text(page.locator('[data-dual-empty]'))).includes('没有可以裁决的指标'))
|
||||
check('……没有提交裁决的按钮', await page.locator('[data-dual-submit]').count() === 0)
|
||||
const reject = page.locator('[data-dual-reject]')
|
||||
check('……给一个「驳回并终止」,说清后果', (await text(reject)) === '驳回并终止' && (await text(page.locator('[data-dual-check]'))).includes('运行终止'))
|
||||
await page.locator('[data-dual-note]').fill('审批内容缺失,重新运行')
|
||||
await reject.click()
|
||||
await page.waitForFunction(() => window.__dualResolved != null)
|
||||
const body = sent.find((s) => s.path === `/api/approvals/${AP.id}/decide`)?.body
|
||||
check('……驳回:approved 为假、带上备注,不带 value', body?.approved === false && body.note === '审批内容缺失,重新运行' && !('value' in body), JSON.stringify(body))
|
||||
check('……提示已驳回、运行终止', (await text(page.locator('body'))).includes('已驳回,本次运行终止'))
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
})
|
||||
|
||||
await section('审批卡:不选全不能提交,提交的回复是 {choices, note}', async () => {
|
||||
const { page, sent, errors } = await preview('dual-check-approval')
|
||||
await page.waitForSelector('[data-dual-check][data-state="ready"]')
|
||||
@@ -367,11 +594,11 @@ await section('审批卡:390px 不横向溢出、窄栏里上下排、深浅
|
||||
* 检查器用例:设置(相对误差、启用的「跟随设置」要写出眼下的值)按夹具答,改配置触发的校验、变量分析回空,
|
||||
* 节点配置直接从 studio store 读(window.__studio,开发服务器才挂)
|
||||
*/
|
||||
async function inspector(opts = {}) {
|
||||
async function inspector({ verify = () => fx.verify, ...opts } = {}) {
|
||||
const out = await preview('dual-check-inspector', {
|
||||
...opts,
|
||||
extra: (req, url) => {
|
||||
if (req.method() === 'GET' && url.pathname === '/api/settings') return { json: { run: {}, verify: fx.verify } }
|
||||
if (req.method() === 'GET' && url.pathname === '/api/settings') return { json: { run: {}, verify: verify() } }
|
||||
if (req.method() === 'POST' && url.pathname === '/api/workflows/validate') return { json: { issues: [] } }
|
||||
if (req.method() === 'POST' && url.pathname === '/api/workflows/variables') return { json: { variables: [], issues: [] } }
|
||||
return null
|
||||
@@ -396,6 +623,14 @@ await section('检查器:每一项都写进节点配置,节点卡片的摘
|
||||
const panel = page.locator('[data-dual-inspector]')
|
||||
check('说明里不再写「功能开发中」', !(await text(panel)).includes('功能开发中') && (await text(panel)).includes('另起一路独立计算'))
|
||||
check('节点卡片的摘要:复算哪张口径卡、全部几个指标', await summaryOf(page) === '复算「周报口径卡」 · 全部 3 个指标', await summaryOf(page))
|
||||
// 字段名就是服务端校验报错里写的叫法(上面「叫法」一节和 schema.py 比过)
|
||||
const { DUAL_CHECK_TEXT: D } = await import(await load('/src/lib/terms.ts'))
|
||||
const shown = {}
|
||||
for (const k of ['card', 'metrics', 'definitions', 'provider', 'model', 'on_reject', 'enabled']) {
|
||||
shown[k] = (await page.locator(`[data-field="${k}"] label`).first().innerText()).trim()
|
||||
}
|
||||
const misnamed = Object.entries(shown).filter(([k, v]) => v !== D.field[k])
|
||||
check('检查器上的字段名用 DUAL_CHECK_TEXT.field 的叫法', !misnamed.length, misnamed.map(([k, v]) => `${k}:${v} ≠ ${D.field[k]}`).join(';'))
|
||||
|
||||
// 口径卡:下拉选画布上的口径卡,上游的一组、其余的一组;留空取上游的那张
|
||||
const card = page.locator('[data-field="card"] select')
|
||||
@@ -459,13 +694,16 @@ await section('检查器:每一项都写进节点配置,节点卡片的摘
|
||||
await enabled.locator('[data-value="follow"]').click()
|
||||
check('跟随设置:enabled 去掉,不留 null', (await config(page)).enabled === undefined, JSON.stringify(await config(page)))
|
||||
|
||||
// 不采用原流程时
|
||||
// 未采用原流程时
|
||||
const reject = page.locator('[data-field="on_reject"] select')
|
||||
check('不采用原流程时:默认落在「记为出具缺口」上', (await reject.locator('option:checked').innerText()) === '记为出具缺口,出具降档(默认)')
|
||||
check('未采用原流程时:默认落在「计入缺口、出具降档」上', (await reject.locator('option:checked').innerText()) === '计入缺口、出具降档(默认)')
|
||||
await reject.selectOption('fail')
|
||||
check('……选判为失败:on_reject 为 fail', (await config(page)).on_reject === 'fail')
|
||||
|
||||
// 高级:盲算模型、相对误差
|
||||
// 盲算模型不在高级选项里:时间线「模型相同」那一行的「打开设置」要能直接定位到它(高级选项收着时定位不到)
|
||||
check('盲算模型的接入和模型不用展开高级选项就能看到', await page.locator('[data-field="provider"] select').isVisible()
|
||||
&& await page.locator('[data-field="model"] input').isVisible() && await page.locator('[data-field="tolerance"]').count() === 0)
|
||||
// 高级:相对误差
|
||||
await page.locator('button', { hasText: '高级选项' }).click()
|
||||
const provider = page.locator('[data-field="provider"] select')
|
||||
check('盲算模型 · 接入:留空跟随设置,停用的接入照实标出', (await provider.locator('option').allInnerTexts()).join('|') === '跟随设置|云端接入|备用接入(已停用)',
|
||||
@@ -495,6 +733,39 @@ await section('检查器:每一项都写进节点配置,节点卡片的摘
|
||||
await shot(page, 'inspector-dark')
|
||||
})
|
||||
|
||||
await section('检查器:上游有几张口径卡时必须选;「跟随设置」的当前值每次打开都重取', async () => {
|
||||
let verifyNow = fx.verify
|
||||
const { page, errors } = await inspector({ verify: () => verifyNow })
|
||||
// 月报口径卡也接到双盲复算上游:服务端校验要求选一张(不选会挡住发起运行),不是「建议」
|
||||
await page.evaluate(() => window.__studio.setState((s) => ({
|
||||
edges: [...s.edges, { ...s.edges.find((e) => e.id === 'e4'), id: 'e-card2', source: 'card2' }],
|
||||
})))
|
||||
const card = page.locator('[data-field="card"] select')
|
||||
await page.waitForFunction(() => document.querySelector('[data-field="card"] select option')?.textContent?.includes('2 张'))
|
||||
check('上游两张:留空那一项写明要选,不说「建议」', (await card.locator('option').first().innerText()) === '请选择要复算的口径卡(上游有 2 张)'
|
||||
&& !(await text(page.locator('[data-field="card"]'))).includes('建议'), await card.locator('option').first().innerText())
|
||||
check('……下面用提醒色写明必须选一张', (await text(page.locator('[data-dual-card-warn]'))) === '上游有 2 张口径卡,请选择要复算的那一张')
|
||||
const many = await summaryBecomes(page, '复算上游的口径卡 · 上游有 2 张,请指定')
|
||||
check('……节点卡片的摘要也这么说', many.ok, many.seen)
|
||||
await card.selectOption('card')
|
||||
check('……选了一张:提醒收起', await page.locator('[data-dual-card-warn]').count() === 0 && (await config(page)).card === 'card')
|
||||
|
||||
// 去设置里关掉双盲复算、改了相对误差,再回到画布打开这个节点:「跟随设置」写的是新的值
|
||||
const enabled = page.locator('[data-field="enabled"] [role="radio"][data-value="follow"]')
|
||||
check('打开时:跟随设置(当前开启)', (await text(enabled)) === '跟随设置(当前开启)', await text(enabled))
|
||||
verifyNow = { ...fx.verify, dual_check: { ...fx.verify.dual_check, enabled: false, tolerance: 0.01 } }
|
||||
await page.evaluate(() => window.__studio.setState({ selectedId: null }))
|
||||
await page.waitForSelector('[data-field="enabled"]', { state: 'detached' })
|
||||
await page.evaluate(() => window.__studio.setState({ selectedId: 'dc' }))
|
||||
await page.waitForFunction(() => document.querySelector('[data-field="enabled"] [data-value="follow"]')?.textContent === '跟随设置(当前关闭)', null, { timeout: 3000 })
|
||||
.catch(() => {})
|
||||
check('再打开:重取设置,写当前关闭', (await text(enabled)) === '跟随设置(当前关闭)', await text(enabled))
|
||||
await page.locator('button', { hasText: '高级选项' }).click()
|
||||
check('……相对误差的占位也是新值', await page.locator('[data-field="tolerance"] input').getAttribute('placeholder') === '跟随设置(0.01)',
|
||||
await page.locator('[data-field="tolerance"] input').getAttribute('placeholder'))
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
})
|
||||
|
||||
await section('检查器:360px 栏、390px 窄屏不横向溢出,深浅色可读', async () => {
|
||||
for (const [width, theme] of [[1280, 'dark'], [390, 'light']]) {
|
||||
const { page, errors } = await inspector({ width, height: 900, theme })
|
||||
|
||||
Reference in New Issue
Block a user