合入期 3 双盲复算与核查设置(前端):双盲审批卡(并排对照、差异说明、三选一与写目录预告)、检查器与节点卡片钩子、设置页「核查」一节、时间线解读双盲和反方审稿的日志

This commit is contained in:
ThearchyHelios
2026-10-05 18:08:39 +08:00
17 changed files with 4924 additions and 67 deletions
+301
View File
@@ -0,0 +1,301 @@
"""双盲复算审批卡的前端夹具(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),
两边的叫法是「原流程」「盲算」,同节点调用时一样。
**写目录的预告。** 服务端在摘要里另带每个指标的 catalog(采用原流程、采用盲算各会往数据目录里写什么,
dual_check_facts.preview,约定之外另加的),审批卡照它说;老服务端没有时卡上按差异的形状有条件地说,两种说法都按
「服务端到底写不写」核对(2026-10 复核:卡上说会记入、服务端却不写的有好几种)。夹具里的预告(catalog_preview 按指标、
variants 和 peers 各自的 catalog)也是服务端的真实输出:按下面的 SCHEMA、空目录、指标自己的范围留空算。双盲复算的
服务端合入之前没有这个模块,逐项比对那几项跳过,形状和门槛照样查。
**同一次裁决里几个指标落到同一项上。** 预告是每个指标单独算的,服务端写目录前却要把同一次裁决里落到同一张表同一项上的
说法归拢(业务日期、去重规则说法不同的一项都不写,有效记录条件合成一句写),卡上按其余指标当前的选择补上这一层
(2026-10 第 3 轮复核:两个指标分开选,卡上各自说「记为……」,服务端一项都不写)。peers 是换掉「退款率」对不上的那个
输入的几对 SQL,和「销售额」(或换上的变体)落到数据目录的同一项上;merges 是检查脚本看的几种组合,written 是服务端
按这几种选择真写一遍(record_choices,空目录、临时数据源)的结果。它要建数据源,regenerate 不生成:测试报不一致时,
照报出的实际结果改 written,再看审批卡的说法要不要跟着改。
重新生成夹具里的差异说明和预告(只改 diff、摘要的 diff 和 fallback、预告,其余原样;没有预告的模块时预告不动):
cd backend && PYTHONPATH=. python tests/test_dual_check_fixture.py
"""
from __future__ import annotations
import importlib
import json
import uuid
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import pytest
from app.data import catalog
from app.data.catalog import TABLE_FIELD_LABEL
from app.data.sql_semantics import explain_sql_diff
from app.db.base import SessionLocal
from app.db.models import DataSource
from tests.fixtures.sources import drop_source
FIXTURE = Path(__file__).resolve().parents[2] / "frontend" / "src" / "dev" / "fixtures" / "dual_check.json"
DIALECT = "sqlite"
LABELS = ("原流程", "盲算")
#: 摘要里每个指标最多列几条差异(同双盲复算节点的 SUMMARY_DIFFS)
SUMMARY_DIFFS = 3
#: 预告用的数据源名和表结构(虚构):服务端要认得出表、认得出聚合的列属于哪张表,才会预告写哪一项
SOURCE = "shop"
SCHEMA: dict[str, Any] = {"tables": {
"orders": {"name": "orders", "columns": [
{"name": "id", "type": "INTEGER"}, {"name": "user_id", "type": "INTEGER"}, {"name": "amount", "type": "REAL"},
{"name": "status", "type": "TEXT"}, {"name": "channel", "type": "TEXT"}, {"name": "paid_at", "type": "TEXT"},
{"name": "created_at", "type": "TEXT"}], "primary_key": ["id"]},
"refunds": {"name": "refunds", "columns": [
{"name": "id", "type": "INTEGER"}, {"name": "order_id", "type": "INTEGER"}, {"name": "amount", "type": "REAL"},
{"name": "created_at", "type": "TEXT"}], "primary_key": ["id"]},
}}
#: 写目录时认得出的三项(dual_check_facts.FACT_ITEMS)
FACT_ITEMS = ("business_date", "valid_filter", "dedup")
#: 差异里能对上这三项的方面:时间列、筛选条件、聚合
FACT_ASPECTS = ("time", "filter", "aggregate")
#: 变体换掉的是口径卡里的「销售额」,peers 换掉的是「退款率」:预告里的指标名照它
VARIANT_METRIC = "销售额"
PEER_METRIC = "退款率"
#: 一份夹具里换掉 SQL 的两组:变体换「销售额」,peers 换「退款率」
SWAPS = {"variants": VARIANT_METRIC, "peers": PEER_METRIC}
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 _candidate(metric: str, original: str, blind: str) -> dict[str, Any]:
"""双盲复算节点交给写目录那一步的候选(少一个 choice):只有一个输入对不上,差异条目带两边的写法和 detail(工件里
只留 aspect、text,写目录要看完整的)。指标自己的范围留空:夹具里没有口径说明、分组和运行输入。"""
diff = explain_sql_diff(original, blind, DIALECT, schema=SCHEMA, labels=LABELS).as_dict()
return {"metric": metric, "source": SOURCE, "dialect": DIALECT, "sql": {"a": original, "b": blind},
"diff": {"equivalent": diff["equivalent"], "fallback": diff["fallback"], "items": diff["items"]},
"scope": {"texts": [], "columns": []}}
def _preview_cases(fx: dict[str, Any]) -> Iterator[tuple[str, str, str, str, dict[str, Any], dict[str, Any]]]:
"""夹具里每一份预告:(名字, 指标名, 原流程 SQL, 盲算 SQL, 工件里的差异, 预告)。口径卡的指标按对不上的那个输入,
变体和 peers 按各自的一对 SQL(检查脚本拿变体换掉「销售额」、拿 peers 换掉「退款率」)。"""
for metric in fx["card"]["metrics"]:
(a, b), = _differing(metric)
yield (metric["metric_id"], metric["name"], a["sql"], b["sql"], metric["diff"],
fx["catalog_preview"][metric["metric_id"]])
for group, metric_name in SWAPS.items():
for name, swap in fx[group].items():
original, blind = swap["sql"]
yield f"{group}.{name}", metric_name, original, blind, swap["diff"], swap["catalog"]
def _merge_metrics(fx: dict[str, Any], case: dict[str, Any]) -> list[tuple[str, str, tuple[str, str], dict[str, Any]]]:
"""merges 里的一种组合 → 两个指标各自的 (指标 id, 指标名, 两条 SQL, 预告):「销售额」是口径卡原样(gmv 为空)或换上
的变体,「退款率」是换上的 peers。"""
gmv = fx["card"]["metrics"][0]
if case["gmv"]:
variant = fx["variants"][case["gmv"]]
gmv_sql, gmv_preview = tuple(variant["sql"]), variant["catalog"]
else:
(a, b), = _differing(gmv)
gmv_sql, gmv_preview = (a["sql"], b["sql"]), fx["catalog_preview"]["gmv"]
peer = fx["peers"][case["refund_rate"]]
return [("gmv", VARIANT_METRIC, gmv_sql, gmv_preview),
("refund_rate", PEER_METRIC, tuple(peer["sql"]), peer["catalog"])]
def _shown(side: dict[str, Any]) -> dict[str, Any]:
"""一边的预告里审批卡用得到的:写不写,写的话写到哪、写成什么。不写的原因只在时间线上给人看,措辞不比。"""
if side.get("write") is True:
return {k: side.get(k) for k in ("write", "source", "table", "item", "value")}
return {"write": side.get("write")}
def _server(name: str) -> Any:
"""服务端写目录模块里的 preview(预告)或 record_choices(真写);双盲复算的服务端还没合入时返回 None。"""
try:
return getattr(importlib.import_module("app.engine.dual_check_facts"), name, None)
except ImportError:
return None
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()
for group in SWAPS:
assert fx[group], group
for name, swap in fx[group].items():
original, blind = swap["sql"]
assert swap["diff"] == _diff(original, blind), f"{group}.{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 test_catalog_previews_have_the_server_shape():
"""每个指标、每个变体和 peers 都有预告,两边齐全;写的只能是服务端写目录的门槛之内的(两条 SQL 不等价、没有兜底、
恰好一条差异,而且是时间列、筛选条件或聚合),写的是三项之一、有表有取值;不写的有原因。不依赖服务端的模块,一直查。"""
fx = _load()
assert set(fx["catalog_preview"]) == {m["metric_id"] for m in fx["card"]["metrics"]}
cases = list(_preview_cases(fx))
assert len(cases) == len(fx["card"]["metrics"]) + sum(len(fx[group]) for group in SWAPS)
for name, _, _, _, diff, preview in cases:
assert set(preview) == {"a", "b"}, name
single = not diff["equivalent"] and not diff["fallback"] and len(diff["items"]) == 1
for side in ("a", "b"):
said = preview[side]
if said["write"] is True:
assert single and diff["items"][0]["aspect"] in FACT_ASPECTS, (name, side)
assert said["item"] in FACT_ITEMS and said["item"] in TABLE_FIELD_LABEL, (name, side)
assert said["source"] == SOURCE and said["table"] in SCHEMA["tables"] and said["value"], (name, side)
else:
assert said["write"] is False and said["reason"], (name, side)
def test_catalog_previews_follow_the_server():
"""预告里审批卡用得到的部分(写不写、写到哪、写成什么)和服务端(dual_check_facts.preview)一致:服务端改了写目录
的规则,这里先变红——审批卡上照预告说的、检查脚本按预告核对的有条件说法都要跟着看一遍。"""
preview = _server("preview")
if preview is None:
pytest.skip("双盲复算的服务端(dual_check_facts.preview)还没合入")
for name, metric, original, blind, _, expected in _preview_cases(_load()):
got = preview(_candidate(metric, original, blind), SCHEMA, {})
assert {s: _shown(got[s]) for s in ("a", "b")} == {s: _shown(expected[s]) for s in ("a", "b")}, name
def test_merges_put_two_metrics_on_the_same_item():
"""merges 里每一种组合:两个指标按各自的选择,预告都写、写的是同一个数据源同一张表的同一项。检查脚本拿这几种组合看
「同一次裁决里几个指标落到同一项上」卡上怎么说,换了 SQL 落不到同一项上,那几项检查就什么也没测。服务端写了的,
written 里只有这一项。不依赖服务端的模块,一直查。"""
fx = _load()
assert fx["merges"]
for name, case in fx["merges"].items():
sides = [said[case["choices"][metric_id]] for metric_id, _, _, said in _merge_metrics(fx, case)]
assert all(s["write"] is True for s in sides), name
targets = {(s["source"], s["table"], s["item"]) for s in sides}
assert len(targets) == 1, name
assert all((SOURCE, w["table"], w["item"]) in targets for w in case["written"]), name
@pytest.fixture
async def make_source():
"""建一个只有表结构(SCHEMA)、目录为空的临时数据源,返回它的名字和 id;用完连同目录一起删掉。写目录只读表结构,
不连库,所以不用建库文件。"""
made: list[str] = []
async def make() -> tuple[str, str]:
name = f"dc_fixture_{uuid.uuid4().hex[:8]}"
async with SessionLocal() as session:
row = DataSource(name=name, kind="sqlite", readonly=True, enabled=True, schema_cache=SCHEMA)
session.add(row)
await session.commit()
made.append(row.id)
return name, row.id
yield make
for source_id in made:
await drop_source(source_id)
async def test_merged_choices_follow_the_server(make_source):
"""merges 里每一种组合按服务端真写一遍(dual_check_facts.record_choices,空目录):写了哪几项、写成什么,和夹具的
written 一致。审批卡按同一套规矩说——同一张表的同一项,业务日期、去重规则说法不同的一项都不写,有效记录条件用 AND
合成一句写——服务端改了归拢的规矩,这里先变红,卡上的说法(DualCheckApproval 的 peersOf)要跟着看一遍。"""
record = _server("record_choices")
if record is None:
pytest.skip("双盲复算的服务端(dual_check_facts.record_choices)还没合入")
fx = _load()
for name, case in fx["merges"].items():
source, source_id = await make_source()
candidates = [{**_candidate(metric, *sql), "source": source, "choice": case["choices"][metric_id]}
for metric_id, metric, sql, _ in _merge_metrics(fx, case)]
out = await record(candidates, run_id="run-dc-0001", node_title="复核周报口径", actor=None)
written = []
async with SessionLocal() as session:
for w in out["written"]:
entry = await catalog.read_entry(session, source_id, w["table"])
written.append({"table": w["table"], "item": w["item"], "value": entry.notes[w["item"]]["value"]})
assert written == case["written"], (name, out["skipped"])
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 group in SWAPS:
for swap in fx[group].values():
swap["diff"] = _diff(*swap["sql"])
if (preview := _server("preview")) is not None:
fx["catalog_preview"] = {}
for metric in fx["card"]["metrics"]:
(a, b), = _differing(metric)
said = preview(_candidate(metric["name"], a["sql"], b["sql"]), SCHEMA, {})
fx["catalog_preview"][metric["metric_id"]] = said
for group, metric_name in SWAPS.items():
for swap in fx[group].values():
swap["catalog"] = preview(_candidate(metric_name, *swap["sql"]), SCHEMA, {})
FIXTURE.write_text(json.dumps(fx, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if __name__ == "__main__":
regenerate()
+382
View File
@@ -0,0 +1,382 @@
import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { X } from 'lucide-react'
import clsx from 'clsx'
import { api } from '../api/client'
import { useStudio } from '../store/studio'
import { modelOptions, providerOfModel, useCatalog } from '../store/catalog'
import { useRadioGroup } from '../components/ui'
import { DUAL_CHECK_TEXT } from '../lib/terms'
import type { VerifySettings } from '../types'
import type { FieldRenderProps, NodeSummaryContext } from './nodeDefs'
/*
* 双盲复算节点的检查器控件(nodeDefs 里 dual_check 各字段的 render)和节点卡片的摘要。
*
* 口径卡、要复算的指标、口径说明都得看画布上别的节点:从 studio store 读,不往检查器的 props 里加字段。
* 每个控件只写自己那个键(FieldRenderProps.onChange);写回 undefined 就是「留空 = 跟随默认」,配置里不留空值。
* 后端认的键和缺省见 types.ts 的 DualCheckConfig
*/
const T = DUAL_CHECK_TEXT
type CanvasNode = NodeSummaryContext['nodes'][number]
type CanvasEdge = NodeSummaryContext['edges'][number]
const isRecord = (v: unknown): v is Record<string, any> => !!v && typeof v === 'object' && !Array.isArray(v)
const ids = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && !!x) : [])
/** 某个节点的全部上游(沿连线往回走),不含它自己 */
function upstreamOf(id: string, edges: readonly CanvasEdge[]): Set<string> {
const parents = new Map<string, string[]>()
for (const e of edges) parents.set(e.target, [...(parents.get(e.target) ?? []), e.source])
const seen = new Set<string>()
const stack = [...(parents.get(id) ?? [])]
while (stack.length) {
const cur = stack.pop()!
if (seen.has(cur) || cur === id) continue
seen.add(cur)
stack.push(...(parents.get(cur) ?? []))
}
return seen
}
/** 画布上的口径卡:本节点上游的在前(复算只能复算它们),其余的另列 */
function cardsOf(id: string, nodes: readonly CanvasNode[], edges: readonly CanvasEdge[]) {
const up = upstreamOf(id, edges)
const cards = nodes.filter((n) => n.data.nodeType === 'metrics')
return { upstream: cards.filter((n) => up.has(n.id)), other: cards.filter((n) => !up.has(n.id)) }
}
/**
* 实际复算哪张卡:写了 card 就是它(不在画布上时 node 为 null);没写取上游的口径卡,上游只有一张时就是它,
* 好几张时说不准,node 为 null、由调用方照实说
*/
function targetOf(config: Record<string, any>, id: string, nodes: readonly CanvasNode[], edges: readonly CanvasEdge[]) {
const { upstream } = cardsOf(id, nodes, edges)
const card = typeof config.card === 'string' && config.card ? config.card : ''
if (card) return { node: nodes.find((n) => n.id === card && n.data.nodeType === 'metrics') ?? null, card, upstream }
return { node: upstream.length === 1 ? upstream[0] : null, card: '', upstream }
}
/** 一张口径卡在摘要里的名字:节点名,没起名的用口径名,再没有用 id */
const cardName = (n: CanvasNode): string =>
String(n.data.label || n.data.config?.caliber || n.id)
/** 口径卡里定义的指标;引用别的工作流发布的口径(caliber_from)时指标运行时才知道,返回 null */
function metricsOf(n: CanvasNode): { id: string; name: string }[] | null {
const cfg = n.data.config ?? {}
if (isRecord(cfg.caliber_from)) return null
return (Array.isArray(cfg.metrics) ? cfg.metrics : [])
.filter((m: any) => isRecord(m) && typeof m.id === 'string' && m.id)
.map((m: any) => ({ id: String(m.id), name: typeof m.name === 'string' ? m.name : '' }))
}
/**
* 节点卡片的摘要:复算哪张口径卡、几个指标(NodeDef.summary)。NodeCard 在 studio store 的选择器里调它,
* 每条事件都会跑一遍:只做几次数组查找,返回字符串
*/
export function dualCheckSummary({ id, config, nodes, edges }: NodeSummaryContext): string {
const S = T.summary
const { node, card, upstream } = targetOf(config, id, nodes, edges)
const picked = ids(config.metrics)
let text: string
if (node) {
const all = metricsOf(node)
text = S.card(cardName(node), picked.length ? picked.length : null, all ? all.length : null)
} else if (card) {
text = S.card(card, picked.length ? picked.length : null, null)
} else {
text = upstream.length > 1 ? S.many(upstream.length) : S.noCard
}
return config.enabled === false ? `${text} · ${S.off}` : text
}
// ---------------------------------------------------------------- 设置里的默认值
/**
* 设置里核查一组的值:相对误差、启用的「跟随设置」要写出现在是多少。每次打开检查器都重取一次——去设置页关掉双盲
* 复算、改了误差再回到画布,这里得是新值(不同于检查器的运行默认值,核查开关是常改的)。先用上次取到的画,免得
* 闪一下;同一次打开里的几个字段共用一个请求;取不到就沿用上次的(没有就不写),不挡编辑
*/
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(() => {
let alive = true
void fetchVerify().then((v) => { if (alive) setValue(v) })
return () => { alive = false }
}, [])
return value
}
/** 字段下面的一行提醒(所选口径卡不在上游之类),提醒色 */
function Warn({ children, attr }: { children: ReactNode; attr: string }) {
return (
<div className="mt-1 text-2xs leading-snug" style={{ color: 'var(--st-waiting)' }} {...{ [attr]: '' }}>{children}</div>
)
}
// ---------------------------------------------------------------- 各字段
/** 要复算的口径卡:下拉选画布上的口径卡,上游的一组、其余的一组;留空取上游的口径卡 */
function CardField({ id, nodeId, value, invalid, describedBy, onChange }: FieldRenderProps) {
const nodes = useStudio((s) => s.nodes)
const edges = useStudio((s) => s.edges)
const { upstream, other } = useMemo(() => cardsOf(nodeId, nodes, edges), [nodeId, nodes, edges])
const current = typeof value === 'string' ? value : ''
const known = [...upstream, ...other].some((n) => n.id === current)
const option = (n: CanvasNode) => (
<option key={n.id} value={n.id}>{T.cardOption(String(n.data.label || n.id), n.data.config?.caliber)}</option>
)
return (
<>
<select id={id} className="field" value={current} aria-invalid={invalid || undefined} aria-describedby={describedBy}
data-dual-field="card" onChange={(e) => onChange(e.target.value || undefined)}>
<option value="">{T.cardDefault(upstream.length === 1 ? cardName(upstream[0]) : null, upstream.length)}</option>
{upstream.length > 0 && <optgroup label={T.cardUpstream}>{upstream.map(option)}</optgroup>}
{other.length > 0 && <optgroup label={T.cardOther}>{other.map(option)}</optgroup>}
{/* 写着的卡已经不在画布上:照实列出来(不让它看着像「默认」),下面再说一句 */}
{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>}
</>
)
}
/**
* 复算的指标:所选口径卡的指标逐个勾。没写(全部勾着)是缺省——复算输入来自查询的全部指标;全部勾回来时写回
* undefined,配置里不留一份和缺省等价的清单。最后一个不让取消:一个都不勾和缺省分不清
*/
function MetricsField({ id, nodeId, value, config, describedBy, onChange }: FieldRenderProps) {
const nodes = useStudio((s) => s.nodes)
const edges = useStudio((s) => s.edges)
const target = useMemo(() => targetOf(config, nodeId, nodes, edges), [config, nodeId, nodes, edges])
const picked = ids(value)
const metrics = target.node ? metricsOf(target.node) : undefined
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.cardChoose(target.upstream.length) : T.metricsNoCard}
</div>
)
}
if (metrics === null || !metrics.length) {
return (
<div id={id} aria-describedby={describedBy} data-dual-metrics={metrics === null ? 'pinned' : 'empty'}>
<div className="text-2xs leading-snug text-faint">{metrics === null ? T.metricsPinned : T.metricsNone}</div>
{picked.length > 0 && (
<button type="button" className="btn btn-xs mt-1" onClick={() => onChange(undefined)}>{T.metricsClear}</button>
)}
</div>
)
}
const known = new Set(metrics.map((m) => m.id))
const valid = picked.filter((p) => known.has(p))
const stale = picked.filter((p) => !known.has(p))
const all = !picked.length
const checked = new Set(all ? metrics.map((m) => m.id) : valid)
const write = (next: Set<string>) => {
const ordered = metrics.map((m) => m.id).filter((m) => next.has(m))
onChange(ordered.length === metrics.length ? undefined : ordered)
}
return (
<div id={id} role="group" aria-label={T.field.metrics} aria-describedby={describedBy} data-dual-metrics="list">
<div className="space-y-0.5 rounded border px-2 py-1.5">
{metrics.map((m) => {
const on = checked.has(m.id)
const last = on && checked.size === 1
return (
<label key={m.id} className={clsx('flex items-center gap-2 py-0.5 text-xs', last ? 'cursor-not-allowed' : 'cursor-pointer')}
title={last ? T.metricsLast : undefined} data-dual-metric-option={m.id}>
<input type="checkbox" className="accent-[var(--accent)]" checked={on} disabled={last}
onChange={(e) => {
const next = new Set(checked)
if (e.target.checked) next.add(m.id)
else next.delete(m.id)
write(next)
}} />
<span className="min-w-0 flex-1 truncate">{T.metricOption(m.name, m.id)}</span>
</label>
)
})}
</div>
<div className="mt-1 text-2xs leading-snug text-faint" data-dual-metrics-state={all ? 'default' : 'picked'}>
{all ? T.metricsDefault : T.metricsPicked(checked.size, metrics.length)}
</div>
{stale.length > 0 && (
<Warn attr="data-dual-metrics-stale">
{T.metricsStale(stale)}
<button type="button" className="btn btn-xs ml-1 align-baseline" onClick={() => onChange(valid.length ? valid : undefined)}>
{T.metricsClear}
</button>
</Warn>
)}
</div>
)
}
/**
* 口径说明:要复算的每个指标一段,给盲算模型看的业务定义(它看不到原流程的 SQL)。存成 {指标 id: 说明},
* 清空的那一项去掉,全清空写回 undefined。已经写了、指标却不在复算范围里的照样列出来,好让人看见并删掉
*/
function DefinitionsField({ id, nodeId, value, config, describedBy, onChange }: FieldRenderProps) {
const nodes = useStudio((s) => s.nodes)
const edges = useStudio((s) => s.edges)
const target = useMemo(() => targetOf(config, nodeId, nodes, edges), [config, nodeId, nodes, edges])
const defs: Record<string, string> = isRecord(value)
? Object.fromEntries(Object.entries(value).filter(([, v]) => typeof v === 'string')) as Record<string, string> : {}
const metrics = target.node ? metricsOf(target.node) : undefined
const picked = ids(config.metrics)
const rows = metrics ? (picked.length ? metrics.filter((m) => picked.includes(m.id)) : metrics) : []
const gone = Object.keys(defs).filter((k) => !rows.some((r) => r.id === k))
const set = (metric: string, text: string | null) => {
const next = { ...defs }
if (text != null && text.trim()) next[metric] = text
else delete next[metric]
onChange(Object.keys(next).length ? next : undefined)
}
if (!rows.length && !gone.length) {
return (
<div id={id} className="text-2xs leading-snug text-faint" aria-describedby={describedBy} data-dual-definitions="none">
{metrics === null ? T.definitionsPinned : T.definitionsNone}
</div>
)
}
return (
<div id={id} role="group" aria-label={T.field.definitions} aria-describedby={describedBy} className="space-y-1.5"
data-dual-definitions="list">
{rows.map((m) => (
<div key={m.id} data-dual-definition={m.id}>
<label className="mb-0.5 block truncate text-2xs text-dim" htmlFor={`${id}-${m.id}`}>{T.metricOption(m.name, m.id)}</label>
<textarea id={`${id}-${m.id}`} className="field text-xs" rows={2} value={defs[m.id] ?? ''}
placeholder={T.definitionPlaceholder(m.name || m.id)} onChange={(e) => set(m.id, e.target.value)} />
</div>
))}
{gone.map((k) => (
<Warn key={k} attr="data-dual-definition-gone">
<span className="flex items-start gap-1">
<span className="min-w-0 flex-1">{T.definitionGone(k)}</span>
<button type="button" className="shrink-0 rounded p-0.5 hover:bg-hover" aria-label={`${T.definitionRemove}:${k}`}
title={T.definitionRemove} onClick={() => set(k, null)}>
<X size={11} aria-hidden />
</button>
</span>
</Warn>
))}
</div>
)
}
/**
* 盲算模型 · 接入。照报告节点「结论句裁判」的写法:留空跟随设置;只填了模型时接入按模型名找,留空那一项改叫
* 「按模型名找接入」(verify_model_spec 哪一级写了就整组用哪一级,不跨级拼)
*/
function ProviderField({ id, value, config, invalid, describedBy, onChange }: FieldRenderProps) {
const providers = useCatalog((s) => s.providers)
const provider = typeof value === 'string' ? value : ''
const known = providers.some((p) => p.name === provider)
const byModel = !provider && typeof config.model === 'string' && !!config.model.trim()
return (
<select id={id} className="field" value={provider} aria-invalid={invalid || undefined} aria-describedby={describedBy}
data-dual-field="provider" onChange={(e) => onChange(e.target.value || undefined)}>
<option value="">{byModel ? T.providerFromModel : T.providerFollow}</option>
{provider && !known && providers.length > 0 && <option value={provider}>{T.providerGone(provider)}</option>}
{providers.map((p) => <option key={p.id} value={p.name}>{p.name}{p.enabled ? '' : T.providerOff}</option>)}
</select>
)
}
/** 盲算模型 · 模型:可以手填,下拉给所选接入(没选接入时给全部启用接入)的模型;只填了模型时写明实际用哪个接入 */
function ModelField({ id, value, config, invalid, describedBy, onChange }: FieldRenderProps) {
const providers = useCatalog((s) => s.providers)
const provider = typeof config.provider === 'string' ? config.provider : ''
const pv = provider ? providers.find((p) => p.name === provider) : undefined
const model = typeof value === 'string' ? value : ''
const byModel = !provider && !!model.trim()
const owner = byModel ? providerOfModel(providers, model.trim()) : undefined
const enabled = providers.filter((p) => p.enabled)
const fallback = enabled.find((p) => p.kind !== 'mock') ?? enabled[0]
const hint = byModel && providers.length > 0
? T.byModel(model.trim(), owner && { name: owner.provider.name, enabled: owner.enabled }, fallback?.name) : ''
const models = pv ? (pv.models ?? []).map((m) => m.id) : [...new Set(modelOptions(providers).map((o) => o.value))]
return (
<>
<input id={id} className="field mono text-xs" list={`${id}-models`} value={model} spellCheck={false}
placeholder={provider ? T.modelOfProvider : T.modelFollow} aria-invalid={invalid || undefined}
aria-describedby={describedBy} data-dual-field="model"
onChange={(e) => onChange(e.target.value.trim() ? e.target.value : undefined)} />
<datalist id={`${id}-models`}>{models.map((m) => <option key={m} value={m} />)}</datalist>
{hint && (
<div className="mt-1 text-2xs leading-snug" data-dual-by-model={owner ? (owner.enabled ? 'found' : 'disabled') : 'default'}
style={owner?.enabled ? undefined : { color: 'var(--st-waiting)' }}>
<span className={owner?.enabled ? 'text-faint' : undefined}>{hint}</span>
</div>
)}
</>
)
}
/** 允许的相对误差:留空跟随设置,占位里写出设置里眼下是多少;负数标出来(服务端也不收) */
function ToleranceField({ id, value, invalid, describedBy, onChange }: FieldRenderProps) {
const settings = useVerifyDefaults()
const bad = typeof value === 'number' && (!Number.isFinite(value) || value < 0)
return (
<>
<input id={id} className="field tnum" type="number" min={0} step="any" value={value ?? ''}
placeholder={T.tolerancePlaceholder(settings?.dual_check?.tolerance)}
aria-invalid={invalid || bad || undefined} aria-describedby={[describedBy, bad ? `${id}-bad` : ''].filter(Boolean).join(' ') || undefined}
style={bad ? { borderColor: 'var(--err)' } : undefined} data-dual-field="tolerance"
onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))} />
{bad && <div id={`${id}-bad`} className="mt-1 text-2xs leading-snug" style={{ color: 'var(--err)' }}>{T.toleranceInvalid}</div>}
</>
)
}
const ENABLED = ['follow', 'on', 'off'] as const
type Enabled = typeof ENABLED[number]
/** 启用:跟随设置 / 开启 / 关闭三态(null 或不写、true、false)。跟随设置时写回 undefined,不在配置里留 null */
function EnabledField({ id, value, describedBy, onChange }: FieldRenderProps) {
const settings = useVerifyDefaults()
const current: Enabled = value === true ? 'on' : value === false ? 'off' : 'follow'
const pick = (v: Enabled) => onChange(v === 'on' ? true : v === 'off' ? false : undefined)
const radio = useRadioGroup(ENABLED, current, pick)
return (
<div id={id} role="radiogroup" aria-label={T.field.enabled} aria-describedby={describedBy}
className="flex flex-wrap gap-0.5 rounded border p-0.5" data-dual-field="enabled">
{ENABLED.map((v) => (
<button key={v} type="button" {...radio(v)} data-value={v}
// 按字数分宽度、不折行:「跟随设置(当前开启)」比「开启」长得多,平分会把它挤成两行;太窄时整段换到下一行
className={clsx('flex-auto whitespace-nowrap rounded px-2 py-1 text-2xs transition-colors',
current === v ? 'font-medium text-fg' : 'text-dim hover:bg-hover')}
style={current === v ? { background: 'var(--accent-soft)', boxShadow: 'inset 0 0 0 1px var(--accent)' } : undefined}
onClick={() => pick(v)}>
{v === 'follow' ? T.enabledFollow(settings?.dual_check?.enabled) : T.enabled[v]}
</button>
))}
</div>
)
}
/** nodeDefs 里 dual_check 各字段的 render */
export const dualCheckFields = {
card: (p: FieldRenderProps) => <CardField {...p} />,
metrics: (p: FieldRenderProps) => <MetricsField {...p} />,
definitions: (p: FieldRenderProps) => <DefinitionsField {...p} />,
provider: (p: FieldRenderProps) => <ProviderField {...p} />,
model: (p: FieldRenderProps) => <ModelField {...p} />,
tolerance: (p: FieldRenderProps) => <ToleranceField {...p} />,
enabled: (p: FieldRenderProps) => <EnabledField {...p} />,
}
+3
View File
@@ -618,6 +618,9 @@ function FieldInput({ field, id, nodeId, syntax, value, config, invalid, describ
const defaults = useRunDefaults(field.key === 'scope' || field.type === 'collection')
const aria = { 'aria-invalid': invalid || undefined, 'aria-describedby': describedBy }
if (field.render) {
return <>{field.render({ id, nodeId, value, config, invalid, describedBy, disabled, onChange })}</>
}
switch (field.type) {
case 'text':
if (syntax !== 'plain') {
+4 -3
View File
@@ -95,8 +95,6 @@ function summarize(type: NodeType, config: Record<string, any>): string {
case 'subgraph':
return config.workflow_id ? '嵌套工作流' : '未选择工作流'
// 还是占位的两种:卡片上直接说尚未生效,免得以为放上去就会执行
case 'dual_check':
return DUAL_CHECK_TEXT.pending
case 'challenge':
return CHALLENGE_TEXT.pending
default:
@@ -768,10 +766,13 @@ function NodeCardImpl({ id, data, selected, isConnectable }: NodeProps<FlowNode>
const reportStamp = useReportStamp(id, type === 'report')
// 只有排队中的卡片要知道自己有几路上游,别的卡片不必为改图重跑这一趟
const fanIn = useStudio((s) => (state === 'queued' ? fanInOf(s.nodes, s.edges)[id] ?? 0 : 0))
// 节点定义给了摘要钩子(摘要要提到别的节点:双盲复算复算的是哪张口径卡)就用它,没给照旧走 summarize。
// 选择器只返回字符串:别的节点改了、这一句没变,卡片就不重渲染
const hookedSummary = useStudio((s) => def?.summary?.({ id, config: data.config ?? {}, nodes: s.nodes, edges: s.edges }) ?? '')
const Icon = def?.icon ?? Sparkles
const handles = sourceHandles(type, data.config)
const summary = summarize(type, data.config)
const summary = hookedSummary || summarize(type, data.config)
const meta = statusMeta(state)
// 循环容器在两轮之间也是 running,但在跑的是循环体:不转圈、不挂光弧。
// 回放时取游标那一刻的(航迹上的 looping 是最后的值)
+70 -4
View File
@@ -1,3 +1,4 @@
import type { ReactNode } from 'react'
import {
Bot, Braces, Brain, CheckCircle2, Code2, Combine, Database, FileInput, FileOutput, FileText,
Gauge, GitBranch, GitCompareArrows, Hand, Repeat, Search, ShieldAlert, Shuffle, Users, Wrench,
@@ -6,6 +7,7 @@ import {
APPROVAL_POLICY_LABEL, CHALLENGE_TEXT, DUAL_CHECK_TEXT, MERGE_TEXT, NODE_TYPE_LABEL, UPGRADE_POLICY_LABEL,
} from '../lib/terms'
import type { NodeType } from '../types'
import { dualCheckFields, dualCheckSummary } from './DualCheckFields'
export type FieldType =
| 'text' | 'textarea' | 'prompt' | 'code' | 'number' | 'select' | 'switch'
@@ -51,6 +53,23 @@ export interface FieldDef {
*/
unknownLabel?: (value: string) => string
advanced?: boolean
/**
* 现有字段类型画不了的配置(选画布上某一类节点、三态开关、接入加模型)交给它画。
* 给了 render 就不再按 type 渲染;type 仍用于默认值和校验
*/
render?: (p: FieldRenderProps) => ReactNode
}
/** FieldDef.render 拿到的:当前值、写回、所在节点和配置,以及无障碍属性 */
export interface FieldRenderProps {
id: string
nodeId: string
value: any
config: Record<string, any>
invalid: boolean
describedBy?: string
disabled?: boolean
onChange: (v: any) => void
}
/**
@@ -91,6 +110,19 @@ export interface NodeDef {
sources: HandleDef[] | null
fields: FieldDef[]
defaults: Record<string, any>
/**
* 卡片正文那一行摘要要提到画布上别的节点时给它(双盲复算复算的是哪张口径卡、几个指标),没给的照旧走
* NodeCard 的 summarize。NodeCard 在 studio store 的选择器里调它、每条事件都会跑:要快、没有副作用、只返回字符串
*/
summary?: (ctx: NodeSummaryContext) => string
}
/** NodeDef.summary 拿到的:这个节点的 id 和配置,以及画布上的全部节点和连线 */
export interface NodeSummaryContext {
id: string
config: Record<string, any>
nodes: readonly { id: string; data: { nodeType: NodeType; label?: string; config?: Record<string, any> } }[]
edges: readonly { source: string; target: string }[]
}
const MODEL_FIELDS: FieldDef[] = [
@@ -658,14 +690,48 @@ export const NODE_DEFS: Record<NodeType, NodeDef> = {
metrics: [{ id: 'total', name: '', unit: '', expression: '' }],
},
},
// 下面两种还是占位:服务端的执行器原样跳过(engine/nodes/dual_check.py、challenge.py),没有可配的字段。
// 说明里写明尚未生效,检查器顶上就是这一句;各自的工作包补上字段时把这句去掉
dual_check: {
type: 'dual_check', label: NODE_TYPE_LABEL.dual_check, category: '把关', icon: GitCompareArrows,
description: `${DUAL_CHECK_TEXT.description}。${DUAL_CHECK_TEXT.pending}`,
description: DUAL_CHECK_TEXT.description,
hasTarget: true, sources: [{ id: 'out', label: '' }],
fields: [],
// 配置的键和缺省见 types.ts 的 DualCheckConfig(期 3 约定)。口径卡、指标、口径说明要看画布上别的节点,
// 盲算模型是接入加模型,启用是三态,都交给 DualCheckFields 画;type 只管默认值和校验时当什么看。
// 盲算模型不放进高级选项:时间线上「模型相同」那一行的「打开设置」要定位到它,高级选项收着时定位不到
fields: [
{ key: 'card', label: DUAL_CHECK_TEXT.field.card, type: 'select', help: DUAL_CHECK_TEXT.help.card, render: dualCheckFields.card },
{
key: 'metrics', label: DUAL_CHECK_TEXT.field.metrics, type: 'json', help: DUAL_CHECK_TEXT.help.metrics,
render: dualCheckFields.metrics,
},
{
key: 'definitions', label: DUAL_CHECK_TEXT.field.definitions, type: 'json', help: DUAL_CHECK_TEXT.help.definitions,
render: dualCheckFields.definitions,
},
{
key: 'enabled', label: DUAL_CHECK_TEXT.field.enabled, type: 'select', help: DUAL_CHECK_TEXT.help.enabled,
render: dualCheckFields.enabled,
},
{
// 没写和写 degrade 是一回事:选项从 degrade 排起,没写时下拉框落在它上面,正好是缺省
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', render: dualCheckFields.provider },
{
key: 'model', label: DUAL_CHECK_TEXT.field.model, type: 'text', help: DUAL_CHECK_TEXT.help.model,
render: dualCheckFields.model,
},
{
key: 'tolerance', label: DUAL_CHECK_TEXT.field.tolerance, type: 'number', min: 0, advanced: true,
help: DUAL_CHECK_TEXT.help.tolerance, render: dualCheckFields.tolerance,
},
],
defaults: {},
summary: dualCheckSummary,
},
challenge: {
type: 'challenge', label: NODE_TYPE_LABEL.challenge, category: '把关', icon: ShieldAlert,
+825
View File
@@ -0,0 +1,825 @@
{
"_note": "双盲复算的预览用例和 scripts/check-verify.mjs 共用的夹具。表名、数字都是虚构的通用示例,不来自任何真实数据。各处的 diff(差异说明)是服务端 explain_sql_diff 对同一对 SQL 的真实输出(方言 sqlite),catalog_preview 和 variants、peers 里的 catalog(采用哪一边会往数据目录里写什么)是服务端写目录预告的真实输出,都由 backend/tests/test_dual_check_fixture.py 核对和重新生成,不要手改;merges 里的 written 是服务端按那几种选择真写一遍(record_choices)的结果,同一个测试核对,它要建临时数据源才算得出,不重新生成,报不一致时照报出的实际结果改。variants 是另几对 SQL 及其差异说明,检查脚本拿来换掉「销售额」那个指标,看不同的差异下卡上怎么说;peers 换掉「退款率」对不上的那个输入,和「销售额」落到数据目录的同一项上,merges 是检查脚本看的几种组合(同一次裁决里几个指标说法不同、要合写时卡上怎么说)",
"approval": {
"id": "ap-dc-0001",
"run_id": "run-dc-0001",
"node_id": "dc",
"mode": "input",
"title": "双盲复算:2 个指标不一致",
"status": "pending",
"response": {},
"workflow_name": "门店周报",
"node_label": "复核周报口径",
"run_class": "formal",
"payload": {
"kind": "dual_check",
"node_id": "dc",
"title": "双盲复算:2 个指标不一致",
"card_artifact": "d0ab1e5c0ffee000d0ab1e5c0ffee000d0ab1e5c0ffee000d0ab1e5c0ffee000",
"summary": [
{
"metric_id": "gmv",
"name": "销售额",
"unit": "元",
"original_rendered": "45,678.5元",
"blind_rendered": "51,388.3元",
"delta_pct": 12.5,
"diff": [
"原流程按 paid_at 筛时间,盲算按 created_at"
],
"fallback": false
},
{
"metric_id": "refund_rate",
"name": "退款率",
"unit": "",
"original_rendered": "2.35%",
"blind_rendered": "3.10%",
"delta_pct": 31.91,
"diff": [
"两条 SQL 的写法不同,未能归纳成具体差异,请对照原文"
],
"fallback": true
}
]
}
},
"card": {
"model": "blind-model-x",
"same_model": false,
"metrics": [
{
"metric_id": "gmv",
"name": "销售额",
"unit": "元",
"decimals": 1,
"original": {
"value": 45678.5,
"rendered": "45,678.5元",
"inputs": [
{
"path": "vars.kpi.gmv",
"value": 45678.5,
"sql": "SELECT SUM(amount) AS gmv\nFROM orders\nWHERE paid_at >= '2026-09-01'\n AND paid_at < '2026-09-08'",
"source": "shop",
"alias": "Q1"
}
]
},
"blind": {
"value": 51388.3,
"rendered": "51,388.3元",
"inputs": [
{
"path": "vars.kpi.gmv",
"value": 51388.3,
"sql": "SELECT SUM(amount) AS gmv\nFROM orders\nWHERE created_at >= '2026-09-01'\n AND created_at < '2026-09-08'",
"source": "shop",
"alias": "B1",
"rationale": "销售额按下单时间统计本周全部订单金额之和"
}
]
},
"diff": {
"equivalent": false,
"items": [
{
"aspect": "time",
"text": "原流程按 paid_at 筛时间,盲算按 created_at"
}
],
"fallback": false,
"text_diff": [
"- 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'"
]
}
},
{
"metric_id": "refund_rate",
"name": "退款率",
"unit": "",
"decimals": 4,
"original": {
"value": 0.0235,
"rendered": "2.35%",
"inputs": [
{
"path": "vars.kpi.refunds",
"value": 47,
"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"
},
{
"path": "vars.kpi.orders",
"value": 2000,
"sql": "SELECT COUNT(*) AS orders FROM orders WHERE status <> 'test'",
"source": "shop",
"alias": "Q3"
}
]
},
"blind": {
"value": 0.031,
"rendered": "3.10%",
"inputs": [
{
"path": "vars.kpi.refunds",
"value": 62,
"sql": "SELECT COUNT(*) AS refunds\nFROM refunds r\nWHERE r.order_id IN (SELECT id FROM orders)",
"source": "shop",
"alias": "B2",
"rationale": "退款笔数:订单表中能找到对应订单的退款记录数"
},
{
"path": "vars.kpi.orders",
"value": 2000,
"sql": "SELECT COUNT(*) AS orders FROM orders WHERE status <> 'test'",
"source": "shop",
"alias": "B3",
"rationale": "订单总数,不含测试订单"
}
]
},
"diff": {
"equivalent": false,
"items": [
{
"aspect": "other",
"text": "两条 SQL 的写法不同,未能归纳成具体差异,请对照原文"
}
],
"fallback": true,
"text_diff": [
"- EXISTS(",
"+ refunds.order_id IN (",
"- 1",
"+ \"orders@1\".id",
"- WHERE",
"- \"orders@1\".id = refunds.order_id AND \"orders@1\".status <> 'test'"
]
}
}
]
},
"catalog_preview": {
"gmv": {
"a": {
"write": true,
"source": "shop",
"table": "orders",
"item": "business_date",
"value": {
"column": "paid_at"
}
},
"b": {
"write": true,
"source": "shop",
"table": "orders",
"item": "business_date",
"value": {
"column": "created_at"
}
}
},
"refund_rate": {
"a": {
"write": false,
"reason": "指标「退款率」两边的 SQL 不止差一个方面,或有未能归纳的差异,无法确定审核人认可的是哪一处,不写入数据目录"
},
"b": {
"write": false,
"reason": "指标「退款率」两边的 SQL 不止差一个方面,或有未能归纳的差异,无法确定审核人认可的是哪一处,不写入数据目录"
}
}
},
"graph": {
"nodes": [
{
"id": "start",
"type": "input",
"position": {
"x": 0,
"y": 120
},
"data": {
"label": "输入",
"config": {
"fields": [
{
"name": "week",
"required": true
}
]
}
}
},
{
"id": "fetch",
"type": "agent",
"position": {
"x": 280,
"y": 120
},
"data": {
"label": "取数",
"config": {
"prompt": "查询本周各门店的订单",
"tools": [
"db_query__shop"
],
"assign_to": "kpi"
}
}
},
{
"id": "card",
"type": "metrics",
"position": {
"x": 560,
"y": 40
},
"data": {
"label": "周报口径卡",
"config": {
"caliber": "周报口径",
"caliber_version": "v1",
"metrics": [
{
"id": "gmv",
"name": "销售额",
"unit": "元",
"expression": "vars.kpi.gmv"
},
{
"id": "orders",
"name": "订单数",
"unit": "单",
"expression": "vars.kpi.orders"
},
{
"id": "aov",
"name": "客单价",
"unit": "元",
"expression": "vars.kpi.gmv / vars.kpi.orders"
}
]
}
}
},
{
"id": "card2",
"type": "metrics",
"position": {
"x": 560,
"y": 260
},
"data": {
"label": "月报口径卡",
"config": {
"caliber": "月报口径",
"caliber_version": "v2",
"metrics": [
{
"id": "m_gmv",
"name": "月销售额",
"unit": "元",
"expression": "vars.kpi.gmv"
}
]
}
}
},
{
"id": "dc",
"type": "dual_check",
"position": {
"x": 840,
"y": 40
},
"data": {
"label": "复核周报口径",
"config": {}
}
},
{
"id": "done",
"type": "output",
"position": {
"x": 1120,
"y": 40
},
"data": {
"label": "成果",
"config": {
"fields": [
{
"name": "结果",
"value": "{{ last_message }}"
}
]
}
}
}
],
"edges": [
{
"id": "e1",
"source": "start",
"target": "fetch"
},
{
"id": "e2",
"source": "fetch",
"target": "card"
},
{
"id": "e3",
"source": "fetch",
"target": "card2"
},
{
"id": "e4",
"source": "card",
"target": "dc"
},
{
"id": "e5",
"source": "dc",
"target": "done"
}
]
},
"providers": [
{
"id": "prov-a",
"name": "云端接入",
"kind": "anthropic",
"base_url": null,
"default_model": "model-a",
"models": [
{
"id": "model-a"
},
{
"id": "model-b"
}
],
"enabled": true,
"extra": {}
},
{
"id": "prov-b",
"name": "备用接入",
"kind": "openai",
"base_url": null,
"default_model": "model-c",
"models": [
{
"id": "model-c"
}
],
"enabled": false,
"extra": {}
}
],
"verify": {
"provider": null,
"model": null,
"dual_check": {
"enabled": true,
"tolerance": 1e-06,
"max_queries": 6,
"timeout_s": 120
},
"challenge": {
"enabled": true,
"max_claims": 5,
"max_queries": 4,
"timeout_s": 90
},
"inquiry": {
"enabled": true,
"max_queries": 6,
"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'"
]
},
"catalog": {
"a": {
"write": false,
"reason": "指标「销售额」两边差在时间范围,对不上数据目录里的业务日期、有效记录条件或去重规则,不写入数据目录"
},
"b": {
"write": false,
"reason": "指标「销售额」两边差在时间范围,对不上数据目录里的业务日期、有效记录条件或去重规则,不写入数据目录"
}
}
},
"time_one_side": {
"sql": [
"SELECT SUM(amount) AS gmv\nFROM orders\nWHERE paid_at >= '2026-09-01'\n AND paid_at < '2026-09-08'",
"SELECT SUM(amount) AS gmv\nFROM orders"
],
"diff": {
"equivalent": false,
"items": [
{
"aspect": "time",
"text": "原流程按 paid_at 筛时间,盲算没有按时间筛选"
}
],
"fallback": false,
"text_diff": [
"- WHERE",
"- orders.paid_at >= '2026-09-01' AND orders.paid_at < '2026-09-08'"
]
},
"catalog": {
"a": {
"write": false,
"reason": "指标「销售额」只有被采纳的原流程按时间筛选,盲算没有:审核人判断的是要不要限定时间,不是按哪一列归属,没有可记的业务日期,不写入数据目录"
},
"b": {
"write": false,
"reason": "指标「销售额」被采纳的盲算没有按时间筛选,没有可记的业务日期,不写入数据目录"
}
}
},
"filter_extra": {
"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'"
]
},
"catalog": {
"a": {
"write": true,
"source": "shop",
"table": "orders",
"item": "valid_filter",
"value": "status <> 'test'"
},
"b": {
"write": false,
"reason": "指标「销售额」多出筛选条件的是原流程,被采纳的盲算没有这个条件,不写入数据目录"
}
}
},
"filter_scope": {
"sql": [
"SELECT COUNT(*) AS orders\nFROM orders\nWHERE channel = 'web'",
"SELECT COUNT(*) AS orders\nFROM orders"
],
"diff": {
"equivalent": false,
"items": [
{
"aspect": "filter",
"text": "原流程只保留 channel = 'web' 的行,盲算没有这个条件"
}
],
"fallback": false,
"text_diff": [
"- WHERE",
"- orders.channel = 'web'"
]
},
"catalog": {
"a": {
"write": false,
"reason": "指标「销售额」多出的条件「channel = 'web'」不是在排除退款、作废、测试这类无效记录,可能是指标自己的筛选范围(例如只看某个地区、某个渠道),不是整张表的有效记录条件,不写入数据目录"
},
"b": {
"write": false,
"reason": "指标「销售额」多出筛选条件的是原流程,被采纳的盲算没有这个条件,不写入数据目录"
}
}
},
"two_filters": {
"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'"
]
},
"catalog": {
"a": {
"write": false,
"reason": "指标「销售额」两边的 SQL 不止差一个方面,或有未能归纳的差异,无法确定审核人认可的是哪一处,不写入数据目录"
},
"b": {
"write": false,
"reason": "指标「销售额」两边的 SQL 不止差一个方面,或有未能归纳的差异,无法确定审核人认可的是哪一处,不写入数据目录"
}
}
},
"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"
]
},
"catalog": {
"a": {
"write": false,
"reason": "指标「销售额」两边差在关联,对不上数据目录里的业务日期、有效记录条件或去重规则,不写入数据目录"
},
"b": {
"write": false,
"reason": "指标「销售额」两边差在关联,对不上数据目录里的业务日期、有效记录条件或去重规则,不写入数据目录"
}
}
},
"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)"
]
},
"catalog": {
"a": {
"write": true,
"source": "shop",
"table": "orders",
"item": "dedup",
"value": "对 user_id 计数时去重"
},
"b": {
"write": false,
"reason": "指标「销售额」被采纳的盲算没有去重:不去重是这个指标自己的算法,不是表的去重规则,不写入数据目录"
}
}
},
"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": []
},
"catalog": {
"a": {
"write": false,
"reason": "指标「销售额」两边差在能否解析,对不上数据目录里的业务日期、有效记录条件或去重规则,不写入数据目录"
},
"b": {
"write": false,
"reason": "指标「销售额」两边差在能否解析,对不上数据目录里的业务日期、有效记录条件或去重规则,不写入数据目录"
}
}
}
},
"peers": {
"time": {
"sql": [
"SELECT COUNT(*) AS refunds\nFROM refunds r\nJOIN orders o ON o.id = r.order_id\nWHERE o.paid_at >= '2026-09-01'\n AND o.paid_at < '2026-09-08'",
"SELECT COUNT(*) AS refunds\nFROM refunds r\nJOIN orders o ON o.id = r.order_id\nWHERE o.created_at >= '2026-09-01'\n AND o.created_at < '2026-09-08'"
],
"diff": {
"equivalent": false,
"items": [
{
"aspect": "time",
"text": "原流程按 paid_at 筛时间,盲算按 created_at"
}
],
"fallback": false,
"text_diff": [
"- AND orders.paid_at >= '2026-09-01'",
"+ AND orders.created_at >= '2026-09-01'",
"- AND orders.paid_at < '2026-09-08'",
"+ AND orders.created_at < '2026-09-08'"
]
},
"catalog": {
"a": {
"write": true,
"source": "shop",
"table": "orders",
"item": "business_date",
"value": {
"column": "paid_at"
}
},
"b": {
"write": true,
"source": "shop",
"table": "orders",
"item": "business_date",
"value": {
"column": "created_at"
}
}
}
},
"dedup": {
"sql": [
"SELECT COUNT(DISTINCT o.id) AS refunds\nFROM refunds r\nJOIN orders o ON o.id = r.order_id",
"SELECT COUNT(o.id) AS refunds\nFROM refunds r\nJOIN orders o ON o.id = r.order_id"
],
"diff": {
"equivalent": false,
"items": [
{
"aspect": "aggregate",
"text": "原流程对 orders.id 去重计数,盲算没去重"
}
],
"fallback": false,
"text_diff": [
"- COUNT(DISTINCT orders.id)",
"+ COUNT(orders.id)"
]
},
"catalog": {
"a": {
"write": true,
"source": "shop",
"table": "orders",
"item": "dedup",
"value": "对 id 计数时去重"
},
"b": {
"write": false,
"reason": "指标「退款率」被采纳的盲算没有去重:不去重是这个指标自己的算法,不是表的去重规则,不写入数据目录"
}
}
},
"filter": {
"sql": [
"SELECT COUNT(*) AS refunds\nFROM refunds r\nJOIN orders o ON o.id = r.order_id\nWHERE o.status <> 'cancelled'",
"SELECT COUNT(*) AS refunds\nFROM refunds r\nJOIN orders o ON o.id = r.order_id"
],
"diff": {
"equivalent": false,
"items": [
{
"aspect": "filter",
"text": "原流程排除了 orders.status = 'cancelled' 的行,盲算没有"
}
],
"fallback": false,
"text_diff": [
"- orders.id = refunds.order_id AND orders.status <> 'cancelled'",
"+ orders.id = refunds.order_id"
]
},
"catalog": {
"a": {
"write": true,
"source": "shop",
"table": "orders",
"item": "valid_filter",
"value": "status <> 'cancelled'"
},
"b": {
"write": false,
"reason": "指标「退款率」多出筛选条件的是原流程,被采纳的盲算没有这个条件,不写入数据目录"
}
}
}
},
"merges": {
"time_split": {
"gmv": null,
"refund_rate": "time",
"choices": {
"gmv": "a",
"refund_rate": "b"
},
"written": []
},
"time_same": {
"gmv": null,
"refund_rate": "time",
"choices": {
"gmv": "a",
"refund_rate": "a"
},
"written": [
{
"table": "orders",
"item": "business_date",
"value": {
"column": "paid_at"
}
}
]
},
"dedup_columns": {
"gmv": "dedup",
"refund_rate": "dedup",
"choices": {
"gmv": "a",
"refund_rate": "a"
},
"written": []
},
"filters": {
"gmv": "filter_extra",
"refund_rate": "filter",
"choices": {
"gmv": "a",
"refund_rate": "a"
},
"written": [
{
"table": "orders",
"item": "valid_filter",
"value": "status <> 'test' AND status <> 'cancelled'"
}
]
}
}
}
+120
View File
@@ -0,0 +1,120 @@
import { useState } from 'react'
import type { JSX } from 'react'
import { ReactFlow, ReactFlowProvider } from '@xyflow/react'
import { ApprovalCard } from '../../run/RunPanel'
import { Inspector } from '../../canvas/Inspector'
import { NodeCard } from '../../canvas/NodeCard'
import { toFlow, useStudio } from '../../store/studio'
import { useCatalog } from '../../store/catalog'
import type { Approval, GraphSpec, Provider } from '../../types'
import data from './dual_check.json'
/**
* 双盲复算的预览用例,scripts/check-verify.mjs 按它们检查(夹具在同目录的 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})、这次运行的图快照(GET /api/runs/{id}/graph,卡上按它说节点的
* 「未采用原流程时」)、审批回复、设置、校验都由检查脚本用 page.route 伪造,直接打开时卡上照实说无法加载完整对照、
* 有条件地说后果。提交后 onResolved 只把运行 id 记到 window.__dualResolved,不去接事件流。
*
* ?syn=dual-check-approval 另认地址上的几个参数(检查脚本用;不带时 payload 只有约定里的几项,同老服务端):
* - variant=<名字>:「销售额」的摘要换成 variants 里那一对 SQL 的(前 3 条差异的原话、是否只有兜底,同节点的写法),
* 工件由检查脚本按同一个变体伪造
* - peer=<名字>:「退款率」的摘要同样换成 peers 里那一对 SQL 的。peers 和「销售额」(或换上的变体)落到数据目录的
* 同一项上,看同一次裁决里几个指标说法不同、要合写时卡上怎么说(夹具的 merges)
* - catalog=1:摘要里每个指标带上服务端预告的「采用哪一边会往数据目录里写什么」(catalog_preview;换了变体、peers 的
* 指标用它们自己的 catalog)。catalog=bad:「销售额」带一份认不出的预告
* - on_reject=<值>:payload 带上节点实际生效的「未采用原流程时」
*/
const approval = (patch: Partial<Approval> = {}): Approval => ({
...(data.approval as unknown as Approval),
created_at: new Date(Date.now() - 180_000).toISOString().replace('Z', ''),
...patch,
})
interface Variant { diff: { items: { aspect: string; text: string }[] }; catalog: unknown }
function approvalOf(search: string): Approval {
const q = new URLSearchParams(search)
const variant = (data.variants as unknown as Record<string, Variant>)[q.get('variant') ?? '']
const peer = (data.peers as unknown as Record<string, Variant>)[q.get('peer') ?? '']
const catalog = q.get('catalog')
const onReject = q.get('on_reject')
const previews = data.catalog_preview as unknown as Record<string, unknown>
const summary = data.approval.payload.summary.map((s) => {
const swapped = s.metric_id === 'gmv' && variant ? variant : s.metric_id === 'refund_rate' && peer ? peer : null
const items = swapped?.diff.items
return {
...s,
...(items ? { diff: items.slice(0, 3).map((i) => i.text), fallback: !items.some((i) => i.aspect !== 'other' && i.aspect !== 'parse') } : {}),
...(catalog === '1' ? { catalog: swapped ? swapped.catalog : previews[s.metric_id] } : {}),
...(catalog === 'bad' && s.metric_id === 'gmv' ? { catalog: { a: { write: true, table: 'orders' }, b: { write: false } } } : {}),
}
})
return approval({ payload: { ...data.approval.payload, summary, ...(onReject ? { on_reject: onReject } : {}) } })
}
function Card({ value }: { value: Approval }) {
return (
<div className="overflow-hidden rounded-lg border" style={{ borderColor: 'var(--warn)' }}>
<ApprovalCard approval={value} onResolved={(id) => { (window as any).__dualResolved = id }} />
</div>
)
}
const nodeTypes = { card: NodeCard }
/**
* 检查器用例:第一次渲染前把夹具里的图放进 studio store、选中双盲复算节点,模型接入直接写进目录 store(不走接口)。
* 用例都在一个预览页里预先加载,store 只能在这里(用到时)改,不能在模块顶层改
*/
function InspectorCase() {
useState(() => {
const { nodes, edges } = toFlow(data.graph as unknown as GraphSpec)
useStudio.setState({ nodes, edges, selectedId: 'dc' })
useCatalog.setState({ providers: data.providers as unknown as Provider[] })
return true
})
const nodes = useStudio((s) => s.nodes)
const edges = useStudio((s) => s.edges)
return (
<div className="flex h-full min-h-0 max-w-full" data-fixture="dual-check-inspector">
<div className="min-w-0 flex-1 max-sm:hidden" data-dual-canvas="">
<ReactFlowProvider>
<ReactFlow nodes={nodes} edges={edges} nodeTypes={nodeTypes} fitView nodesDraggable={false} nodesConnectable={false}
proOptions={{ hideAttribution: true }} />
</ReactFlowProvider>
</div>
<div className="h-full w-[360px] max-w-full shrink-0 overflow-hidden border-l bg-panel" data-dual-inspector="">
<Inspector />
</div>
</div>
)
}
export const cases: Record<string, () => JSX.Element> = {
'dual-check-approval': () => (
<div className="mx-auto max-w-3xl p-4 text-sm" data-fixture="dual-check-approval"><Card value={approvalOf(location.search)} /></div>
),
'dual-check-approval-rail': () => (
<div className="h-full w-[360px] max-w-full overflow-y-auto border-r bg-panel" data-fixture="dual-check-approval-rail">
<Card value={approval()} />
</div>
),
'dual-check-approval-exploratory': () => (
<div className="mx-auto max-w-3xl p-4 text-sm" data-fixture="dual-check-approval-exploratory">
<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 />,
}
+335 -4
View File
@@ -3334,10 +3334,268 @@ export const WHATIF_TEXT = {
empty: '这个指标的计算式没有引用任何输入,无可推演',
}
/** 双盲复算节点:节点库说明、检查器和运行结果 */
/**
* 双盲复算:节点库说明、检查器、节点卡片摘要、两边对不上时的审批卡,以及时间线上双盲复算和反方审稿的日志。
*
* 两边的叫法固定为「原流程」「盲算」(服务端 explain_sql_diff 的 labels 也是这两个词),审核人的三个选择固定为
* 「采用原流程的结果」「采用盲算的结果」「两边都不对」。选「采用盲算的结果」不会替换报告里的数,只记缺口,
* 卡上要说清;探索运行不出具,出具相关的话只对正式运行说
*/
export const DUAL_CHECK_TEXT = {
description: '另起一路独立计算,与原结果逐项比对',
pending: `${PENDING_TEXT}:本节点暂未生效,运行时将跳过`,
original: '原流程',
blind: '盲算',
// ---- 检查器(nodeDefs 的 dual_check、canvas/DualCheckFields.tsx)
/**
* 字段名,键就是配置键。服务端校验和报错引用的是同一套叫法(schema.py 的 DUAL_CHECK_FIELD_LABEL,check-verify 在
* 服务端合入后逐项比对):问题挂在字段下面、出现在问题面板里时,人才找得到是哪一项。模型两项照「裁判模型 · 接入」
* 「裁判模型」的写法
*/
field: {
card: '口径卡',
metrics: '复算的指标',
definitions: '口径说明',
provider: '盲算模型 · 接入',
model: '盲算模型',
tolerance: '允许的相对误差',
on_reject: '未采用原流程时',
enabled: '启用双盲复算',
},
help: {
card: '留空则复算上游的口径卡。只能复算本节点上游的口径卡:运行到本节点时,它的结果已经算出',
metrics: '默认复算口径卡中输入来自查询结果的全部指标,取消勾选可以只复算其中一部分。输入不是来自查询的指标无法盲算',
definitions: '给盲算模型看的业务定义,例如「销售额按支付时间统计,不含退款」。盲算模型看不到原流程的 SQL,'
+ '只能依据指标名称、这里的说明和数据目录编写查询,说明越具体,比对越有意义',
model: '接入和模型均未填写时,使用「设置 → 偏好设置 → 核查」中的核查模型;填写任一项后,整组以节点配置为准,'
+ '不与设置合并。建议与原流程使用的模型不同,同一模型不易发现自身的错误',
tolerance: '两边的值相差不超过这个比例时算一致,例如 0.001 表示千分之一,0 表示必须完全相同。留空则使用设置中的值',
on_reject: '盲算与原流程不一致时运行暂停,由审核人逐个指标选择采用哪一边。选择「采用盲算的结果」或「两边都不对」时按这里处理',
enabled: '关闭时本节点不复算,直接放行,并在时间线上记录一条说明',
},
/**
* 口径卡下拉留空那一项:上游没有、只有一张、有好几张。好几张时服务端校验要求选一张(不选会挡住发起运行),
* 写成要选,不写「建议」
*/
cardDefault: (name: string | null, upstream: number) => (upstream === 0 ? '默认:上游的口径卡(上游没有口径卡)'
: upstream === 1 && name ? `默认:上游的口径卡「${name}」` : `请选择要复算的口径卡(上游有 ${upstream} 张)`),
cardChoose: (n: number) => `上游有 ${n} 张口径卡,请选择要复算的那一张`,
cardUpstream: '上游的口径卡',
cardOther: '其他口径卡(需连接到本节点上游)',
/** 画布上一张口径卡的叫法:节点名,口径名不同时附在后面 */
cardOption: (label: string, caliber?: string | null) => (caliber && caliber !== label ? `${label}(${caliber})` : label),
cardGone: (id: string) => `${id}(不在画布上)`,
cardNotUpstream: '所选口径卡不在本节点上游,运行到本节点时还没有它的结果。请把它连接到本节点之前',
cardMissing: (id: string) => `口径卡「${id}」不在画布上,请重新选择`,
metricsAll: '全部指标',
metricsDefault: '未指定:复算全部输入来自查询的指标',
metricsPicked: (n: number, total: number) => `已选 ${n} / ${total} 个指标`,
metricsNoCard: '先选择要复算的口径卡',
metricsNone: '这张口径卡还没有定义指标',
metricsPinned: '这张口径卡引用了其他工作流发布的口径,指标在运行时按引用的版本确定:将复算其中输入来自查询的全部指标',
metricsStale: (ids: string[]) => `已选的指标不在这张口径卡中:${ids.join('、')}`,
metricsClear: '清除不在卡中的指标',
metricsLast: '至少保留一个指标;要复算全部指标时全部勾选即可',
metricOption: (name: string | null | undefined, id: string) => (name && name !== id ? `${name}(${id})` : id),
definitionPlaceholder: (name: string) => `「${name}」的口径说明`,
definitionsNone: '选择要复算的口径卡后,可以为每个指标写一段口径说明',
definitionsPinned: '这张口径卡引用了其他工作流发布的口径,指标在运行时才确定,无法在这里逐个填写口径说明',
definitionGone: (id: string) => `指标「${id}」已不在复算范围内,这段说明不会交给盲算模型`,
definitionRemove: '删除这段说明',
providerFollow: '跟随设置',
providerFromModel: '按模型名找接入',
providerGone: (name: string) => `${name}(不存在)`,
providerOff: '(已停用)',
modelFollow: '跟随设置',
modelOfProvider: '留空则使用该接入的默认模型',
/**
* 节点上只填了盲算模型、接入留空时实际用哪个接入(verify_model_spec:哪一级写了就整组用哪一级,不跨级拼;
* 再按模型名找接入)。found:按模型名找到的接入;fallback:认不出时去调的默认接入
*/
byModel: (model: string, found: { name: string; enabled: boolean } | null | undefined, fallback: string | null | undefined) => {
if (found && !found.enabled) return `只填了模型:「${model}」属于已停用的接入「${found.name}」,盲算调用将失败。请启用该接入或选择其他接入`
if (found) return `只填了模型:将按模型名使用接入「${found.name}」。接入和模型都未填写时,才跟随设置中的核查模型`
return `只填了模型:所有接入的模型列表中都没有「${model}」,将使用默认接入${fallback ? `「${fallback}」` : ''}调用。`
+ '如该模型不属于默认接入,请选择对应的接入'
},
tolerancePlaceholder: (v: number | null | undefined) => (v == null ? '跟随设置' : `跟随设置(${v})`),
toleranceInvalid: '请填写不小于 0 的数',
/** 选项以服务端报错里的短说法(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 ? '开启' : '关闭'})`),
// ---- 节点卡片的摘要:复算哪张卡、几个指标
summary: {
card: (name: string, picked: number | null, total: number | null) => `复算「${name}」 · ${
picked != null ? `${picked} 个指标` : total != null ? `全部 ${total} 个指标` : '全部指标'}`,
noCard: '复算上游的口径卡 · 尚未连接口径卡',
many: (n: number) => `复算上游的口径卡 · 上游有 ${n} 张,请指定`,
off: '已关闭',
},
// ---- 审批卡(run/DualCheckApproval.tsx)
approval: {
intro: (n: number) => `盲算与原流程有 ${n} 个指标不一致,请逐个选择采用哪一边的结果`,
model: (model: string) => `盲算模型:${model}`,
sameModel: '盲算模型与原流程相同,复算缺乏独立性',
/** delta_pct 已经乘过 100、带正负号写好(「+12.5%」) */
delta: (text: string) => `相差 ${text}`,
deltaZero: '原流程值为 0,无法计算相差比例',
deltaHint: '相差比例 =(盲算值 − 原流程值)÷ |原流程值|',
diffHead: '差异说明',
/** 一条具体差异都没有时的固定说法(服务端兜底说明的前半句);兜底的原话另有原因(比较超时、解析失败)时附在下面 */
fallbackOnly: '两条 SQL 的写法不同,未能归纳成具体差异',
equivalent: '两条 SQL 的写法等价,差异出在取用的结果上,请对照两边取用的值',
loading: '正在加载完整对照…',
loadFailed: '无法加载完整对照,以下仅为摘要',
compare: '对照两条 SQL',
compareClose: '收起对照',
sqlOriginal: '原流程的 SQL',
sqlBlind: '盲算的 SQL',
sqlMissing: '没有记录 SQL',
source: (s: string) => `数据源 ${s}`,
inputs: '输入取值',
textDiff: '规范化后不同的行',
textDiffNone: '这一边没有不同的行',
rationale: '盲算的说明',
/**
* 服务端没有预告(老服务端,见 catalogPreview)、两边又只差一处时,提交前说清选择可能被记进数据目录。卡上只看得到
* 差异的方面,看不到服务端写目录的其余条件(dual_check_facts.derive:只有一边按时间筛的不记,多出的条件得在排除
* 无效记录、又不是指标自己的范围,只差去不去重时得采用去重的一边;同一次裁决里几个指标给同一张表记的业务日期、
* 去重规则不同时一项都不写),所以每句只说必要条件(「仅当……才」),不写的几种常见情况照实说出来,免得审核人
* 为了不让目录被写错而避开本来对的一边。卡上是「方面那句 + tail」。tail 紧跟在「……时不记」后面,开头写明说的是
* 写入的项
*/
catalog: {
time: '仅当两边按不同的列筛时间,采用的一边所用的时间列才会作为该表的业务日期记入数据目录;只有一边按时间筛时不记,'
+ '同一次裁决里几个指标给同一张表记的业务日期不同时都不记',
filter: '仅当多出的条件只在一边、并且是在排除退款、作废、测试这类无效记录,采用有它的一边时,这个条件才会作为该表的'
+ '有效记录条件记入数据目录;只看某个地区、某个渠道这类限定指标范围的条件不记',
aggregate: '仅当两边只差是否去重、并且采用的是去重的一边,去重的做法才会作为该表的去重规则记入数据目录;采用不去重的一边时不记,'
+ '同一次裁决里几个指标给同一张表记的去重规则不同时都不记',
tail: '。写入的项记为人工确认的事实;选「两边都不对」时不记,数据目录中已有人工确认的不同说法时不覆盖,是否记入以时间线上的记录为准',
},
/**
* 服务端预告了采用哪一边会往数据目录里写什么(payload.summary[].catalog)时照它说:三个选择各一句,写的说哪张表的
* 哪一项记成什么,不写的只说不记(原因在裁决后的时间线上)。两边都不写时卡上不提数据目录。卡上是
* 「head + 三句(;分隔)+ tail」,审核人当前选的那一句加重。
*
* 预告是每个指标单独算的,服务端写目录前却把同一次裁决里落到同一张表同一项上的说法归拢(dual_check_facts._merged):
* 业务日期、去重规则说法不同的一项都不写(conflict),有效记录条件用 AND 合成一句写(joined)。卡上按其余指标当前的
* 选择补上这一层,tail 里说明预告是按当前的选择算的
*/
catalogPreview: {
head: '记入数据目录:',
/** where 同时间线(log.catalogItem):「orders」(shop)的业务日期;value 是要写进去的说法 */
write: (choice: string, where: string, value: string) => `${choice}时,${where}记为「${value}」`,
/** 两边写的是同一张表的同一项、只差取值时,后一边省掉「哪张表的哪一项」 */
writeSame: (choice: string, value: string) => `${choice}时记为「${value}」`,
skip: (choice: string) => `${choice}时不记`,
/** metrics 是 metricNames 写好的、和这一边说法不同的指标(按它们当前的选择) */
conflict: (choice: string, where: string, metrics: string) => `${choice}时,${where}与${metrics}的选择说法不同,都不记`,
/** 同 writeSame:两边写的是同一项时,后一边省掉「哪张表的哪一项」 */
conflictSame: (choice: string, metrics: string) => `${choice}时与${metrics}的选择说法不同,都不记`,
/** 有效记录条件接在 write / writeSame 后面:和这些指标当前采用的条件合成一句写 */
joined: (metrics: string) => `,与${metrics}采用的条件用 AND 合成一句`,
/** 「退款率」、「订单数」;多于 3 个只列前 3 个 */
metricNames: (names: string[]) => names.slice(0, 3).map((n) => `「${n}」`).join('、')
+ (names.length > 3 ? `等 ${names.length} 个指标` : ''),
tail: '。写入的项记为人工确认的事实;以上按发起审批时的数据目录和各指标当前的选择预告,实际写入以时间线上的记录为准',
},
choices: { a: '采用原流程的结果', b: '采用盲算的结果', neither: '两边都不对' } as Record<'a' | 'b' | 'neither', string>,
choiceGroup: (name: string) => `「${name}」采用哪一边的结果`,
/** 三个选择的后果,按运行类别说:探索运行不出具,不提出具;老服务端没给类别的照实说「正式运行」 */
consequence: (runClass: string | null | undefined) => (runClass === 'formal'
? '采用原流程的结果:沿用原流程的值,不影响出具。采用盲算的结果或两边都不对:报告中的数仍取自原流程,这个指标记为出具缺口,出具降档'
: runClass === 'exploratory'
? '采用原流程的结果:沿用原流程的值。采用盲算的结果或两边都不对:报告中的数仍取自原流程,这个指标记为缺口'
: '采用原流程的结果:沿用原流程的值。采用盲算的结果或两边都不对:报告中的数仍取自原流程,这个指标记为缺口,正式运行的出具随之降档'),
/**
* 节点的「未采用原流程时」设为判为失败(从这次运行的图快照里读到的):采用盲算或都不对时节点失败、运行终止,
* 不再说出具降档
*/
consequenceFail: '采用原流程的结果:沿用原流程的值,运行继续。采用盲算的结果或两边都不对:节点的「未采用原流程时」'
+ '设为「判为失败」,提交后运行将终止',
/** 读不到节点怎么设的(图快照取不到、节点在子工作流里):降档那句后面有条件地补一句 */
consequenceMaybeFail: '如节点的「未采用原流程时」设为「判为失败」,选择后两项会终止运行',
note: '备注(可选)',
notePlaceholder: '说明裁决的依据,会随裁决记入运行记录',
submit: '提交裁决',
pending: (n: number) => `还有 ${n} 个指标未选择`,
/** 提交后的提示:全采用原流程、或节点按缺省处置时运行继续;判为失败时运行终止;读不到节点设置时不下结论 */
done: '已提交裁决,运行继续',
doneFail: '已提交裁决,运行将按节点设置终止',
doneUnknown: '已提交裁决',
empty: '审批内容不完整,没有可以裁决的指标',
/** 认不出任何指标时留的出口:驳回后节点按人工驳回失败 */
reject: '驳回并终止',
emptyReject: '驳回后节点失败,本次运行终止。可以在备注中说明原因',
rejected: '已驳回,本次运行终止',
},
// ---- 时间线(run/decode.ts explainLog):按日志的 code 和结构化字段说,不从原话里认
/** 日志里的 limit 字段(达到了哪个上限) */
limitLabel: {
max_queries: '查询次数上限', timeout_s: '时长上限', max_cost_usd: '金额上限', daily_max_usd: '每日金额上限', max_claims: '句数上限',
} as Record<string, string>,
limitAny: '上限',
limitDailyNext: '今日核查费用已达上限。请在「设置 → 偏好设置 → 核查」中调高每日金额上限,或等到次日再运行',
log: {
skippedDisabled: '双盲复算未启用,已跳过',
skippedDisabledNext: '如需复算,请在节点的「启用双盲复算」中选择「开启」,或在「设置 → 偏好设置 → 核查」中启用双盲复算',
skippedNoInputs: '没有输入来自查询的指标,已跳过复算',
skippedNoInputsNext: '双盲复算只复算输入来自查询结果的指标。请检查节点的「复算的指标」,或确认口径卡的输入取自查询',
skipped: '双盲复算已跳过',
sameModel: (model?: string) => (model ? `盲算模型与原流程同为「${model}」,复算缺乏独立性` : '盲算模型与原流程相同,复算缺乏独立性'),
sameModelNext: '请在节点的「盲算模型」中指定其他模型,或在「设置 → 偏好设置 → 核查」中修改核查模型',
agreed: (n?: number) => (n ? `双盲复算一致:${formatNumber(n)} 个指标与盲算结果相符` : '双盲复算一致:与盲算结果相符'),
disagreed: (n?: number) => (n ? `双盲复算有 ${formatNumber(n)} 个指标与盲算结果不一致,等待审核人裁决`
: '双盲复算有指标与盲算结果不一致,等待审核人裁决'),
disagreedNext: '请在审批卡上对照两边的 SQL,逐个指标选择采用哪一边的结果',
resolved: (parts: string[]) => (parts.length ? `审核人已裁决:${parts.join(',')}` : '审核人已裁决'),
resolvedA: (n: number) => `采用原流程 ${formatNumber(n)} 个`,
resolvedB: (n: number) => `采用盲算 ${formatNumber(n)} 个`,
resolvedNeither: (n: number) => `两边都不对 ${formatNumber(n)} 个`,
gaps: (n: number) => (n ? `${formatNumber(n)} 项记为缺口` : '没有缺口'),
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: '可在「数据 → 数据目录」中查看或修改',
/** metric 是指标 ID(服务端日志只带 ID,中文名在原话里),不当名字加引号 */
limit: (label: string, metric?: string) => (metric ? `指标 ${metric} 的盲算已达${label},按无法复算处理` : `双盲复算已达${label}`),
limitNext: '请在「设置 → 偏好设置 → 核查」中调高双盲复算的上限',
},
/** 反方审稿日志的时间线说法:时间线解读统一在这里做,反方审稿的工作包不另写一份 */
challengeLog: {
skippedDisabled: '反方审稿未启用,已跳过',
skippedDisabledNext: '如需审稿,请在节点中开启,或在「设置 → 偏好设置 → 核查」中启用反方审稿',
// no_report:要审的报告节点找得到(找不到时节点直接报错,到不了这里),但它的报告文档读不出来——没有写出、
// 保存失败,或者它在这次没有执行到的分支上
skippedNoReport: '要审的报告没有可读取的报告文档,已跳过反方审稿',
skippedNoReportNext: '请确认报告撰写节点是否执行成功、是否写出了报告;它位于条件分支上时,确认本次运行执行到了它',
skippedNoClaims: '报告中没有需要审的关键结论,已跳过反方审稿',
skipped: '反方审稿已跳过',
sameModel: (model?: string) => (model ? `反方审稿模型与报告撰写模型同为「${model}」,审稿缺乏独立性`
: '反方审稿模型与报告撰写模型相同,审稿缺乏独立性'),
sameModelNext: '请在节点中指定其他审稿模型,或在「设置 → 偏好设置 → 核查」中修改核查模型',
done: (n?: number) => (n ? `反方审稿完成:审阅了 ${formatNumber(n)} 句结论` : '反方审稿完成'),
verdict: { stands: '经受住', weakened: '被削弱', refuted: '被推翻', inconclusive: '无法判断' } as Record<string, string>,
refuted: (n?: number) => (n ? `反方审稿推翻了 ${formatNumber(n)} 句结论` : '反方审稿推翻了报告中的结论'),
refutedNext: '请在证据中对照反方审稿的查询和理由,核实这些结论',
limit: (label: string, skipped?: number) => (skipped ? `反方审稿已达${label},${formatNumber(skipped)} 句结论未审` : `反方审稿已达${label}`),
limitNext: '请在节点或「设置 → 偏好设置 → 核查」中调高反方审稿的上限',
},
}
/** 反方审稿节点:节点库说明、检查器和运行结果 */
@@ -3346,5 +3604,78 @@ export const CHALLENGE_TEXT = {
pending: `${PENDING_TEXT}:本节点暂未生效,运行时将跳过`,
}
/** 设置页里复核套件(双盲复算、反方审稿等)的默认值一栏 */
export const VERIFY_SETTING_TEXT = {}
/**
* 设置页「核查」一节(双盲复算、反方审稿、审批质询共用的核查模型、各自的开关和上限、每日金额上限)。
*
* 顶层是各项的叫法,和服务端 api/settings.py 的 VERIFY_SETTING_LABEL 一字不差(保存时 422 的报错写的就是这些,
* 界面上的字段名得对得上),嵌套的项写成「组.项」;check-verify 从 settings.py 读出来逐一比对。
* 这一节其余的标题、说明、今日花费放在 section 里
*/
export const VERIFY_SETTING_TEXT = {
provider: '核查模型 · 接入',
model: '核查模型 · 模型',
daily_max_usd: '核查每日金额上限(美元)',
dual_check: '双盲复算',
'dual_check.enabled': '双盲复算 · 启用',
'dual_check.tolerance': '双盲复算 · 允许的相对误差',
'dual_check.max_queries': '双盲复算 · 每项最多查询次数',
'dual_check.timeout_s': '双盲复算 · 每项时长上限(秒)',
challenge: '反方审稿',
'challenge.enabled': '反方审稿 · 启用',
'challenge.max_claims': '反方审稿 · 每份报告最多审几句',
'challenge.max_queries': '反方审稿 · 每句最多查询次数',
'challenge.timeout_s': '反方审稿 · 每句时长上限(秒)',
inquiry: '审批质询',
'inquiry.enabled': '审批质询 · 启用',
'inquiry.max_queries': '审批质询 · 每次最多查询次数',
'inquiry.timeout_s': '审批质询 · 每次时长上限(秒)',
section: {
title: '核查',
hint: '双盲复算、反方审稿和审批质询都由只读的 Agent 查询数据来核对:三项共用核查模型和每日金额上限,'
+ '各自可以单独开关、设置上限。节点中已设置的项以节点为准。',
model: '核查模型',
/** 接入和模型都没填:服务端沿用证据裁判模型的取值链(verify_model_spec)。醒目地说一句,并给出建议 */
notSet: '未单独配置,将使用证据裁判的模型',
notSetAdvice: '建议配置一个与撰写报告、查询数据的模型都不同的模型:同一模型核对自身的输出时,不易发现其中的错误',
differ: '建议与撰写报告、查询数据的模型不同:同一模型不易发现自身的错误',
providerDefault: '跟随证据裁判模型',
providerFromModel: '按模型名找接入',
providerGone: (name: string) => `模型接入「${name}」不存在:核查调用将失败`,
providerGoneOption: (name: string) => `${name}(不存在)`,
providerOff: '(已停用)',
modelDefault: '留空:用接入的默认模型',
modelFollow: '留空:跟随证据裁判模型',
modelOf: (model: string) => `留空则使用 ${model}`,
/** 只填了核查模型、接入留空时实际用哪个接入(同裁判的说法,只是上一级是证据裁判模型) */
byModel: (model: string, found: { name: string; enabled: boolean } | null | undefined, fallback: string | null | undefined) => {
if (found && !found.enabled) return `只填了模型:「${model}」属于已停用的接入「${found.name}」,核查调用将失败。请启用该接入或选择其他接入`
if (found) return `只填了模型:将按模型名使用接入「${found.name}」。「跟随证据裁判模型」仅在接入和模型都留空时生效`
return `只填了模型:所有接入的模型列表中都没有「${model}」,将使用默认接入${fallback ? `「${fallback}」` : ''}调用。`
+ '如该模型不属于默认接入,请选择对应的接入。「跟随证据裁判模型」仅在接入和模型都留空时生效'
},
parts: {
dual_check: '另起一路,由盲算模型在看不到原流程 SQL 的情况下独立查询,再与原流程的结果逐项比对',
challenge: '从报告中挑出关键结论,由反方模型查询数据尝试推翻',
inquiry: '审批人在批准前就报告提问,由核查模型查询数据作答,问答随运行一并封存',
} as Record<string, string>,
toleranceHint: '两边的值相差不超过这个比例时算一致,例如 0.000001 表示百万分之一,0 表示必须完全相同',
unlimited: '不限',
/** 某项设成不限时写明它还受什么约束,不要悄悄生效。by:眼下还设着上限的相关项 */
unlimitedNote: (kind: 'queries' | 'claims' | 'timeout' | 'daily', by: string[]) => {
if (kind === 'timeout') return '不设上限,时长仅受模型接口自身的超时约束'
const what = kind === 'queries' ? '查询次数' : kind === 'claims' ? '句数' : '每日核查费用'
return by.length ? `不设上限,${what}仅受${by.join('、')}约束` : `不设上限,${what}不受其他上限约束`
},
bound: { timeout: '时长上限', daily: '每日金额上限', claims: '报告中关键结论的数量', queries: '各项的查询次数上限', timeouts: '各项的时长上限' },
invalid: {
count: '请填写正整数,或勾选「不限」',
seconds: '请填写大于 0 的秒数,或勾选「不限」',
usd: '请填写大于 0 的金额,或勾选「不限」',
tolerance: '请填写不小于 0 的数',
},
spend: (usd: string, calls: number) => `今日已花费 $${usd}(${formatNumber(calls)} 次调用)`,
spendNone: '今日尚无核查调用',
unpriced: (n: number) => `另有 ${formatNumber(n)} 次调用无法估算金额(模型不在价格目录中),未计入`,
saveInvalid: '核查设置中有无效值,请修正后再保存',
},
}
+29 -8
View File
@@ -22,8 +22,9 @@ import type { Provider } from '../types'
import { normalizeTheme } from '../lib/theme'
import { useLeaveGuard } from '../lib/leave'
import { localActor, setLocalActor } from '../lib/actor'
import { AGENT_GUARD_TEXT, JUDGE_SETTING_TEXT, TOOL_GATE_TEXT, judgeByModelText, judgeUnlimitedText } from '../lib/terms'
import { AGENT_GUARD_TEXT, JUDGE_SETTING_TEXT, TOOL_GATE_TEXT, VERIFY_SETTING_TEXT, judgeByModelText, judgeUnlimitedText } from '../lib/terms'
import type { ThemePref } from '../lib/theme'
import { VerifySection, verifyChanged, verifyDraftOf, verifyErrors, verifyLabels, verifyPayload, type VerifyDraft } from './VerifySettings'
// 提到模块级:tab 名同时是 URL 的最后一段,两处各写一份迟早对不上。
// 数据源搬去了顶级的「数据」页(/data),/settings/datasources 跳过去
@@ -615,6 +616,9 @@ function PrefsTab() {
jClaims: string | null; jCost: string | null; jTimeout: string | null; jClick: string | null; jDaily: string | null
} | null>(null)
const [spend, setSpend] = useState<{ usd?: number; calls?: number; unpriced_calls?: number } | null>(null)
// 核查一组(双盲复算、反方审稿、审批质询)的草稿另放一份,和上面的共用保存条;verifySaves 是存过几次,今日核查花费跟着再取
const [verifyDraft, setVerifyDraft] = useState<VerifyDraft | null>(null)
const [verifySaves, setVerifySaves] = useState(0)
const [saving, setSaving] = useState(false)
const [savedFlash, setSavedFlash] = useState(0)
const [saveError, setSaveError] = useState<unknown>(null)
@@ -686,9 +690,14 @@ function PrefsTab() {
jClick: judgeError('jClick'), jDaily: judgeError('jDaily'),
}
const judgeInvalid = Object.values(judgeErrors).some(Boolean)
const invalid = guardInvalid || judgeInvalid
const verifySaved = verifyDraftOf(values?.verify)
const verifyCur = verifyDraft ?? verifySaved
const verifyKeys = verifyChanged(verifyCur, verifySaved)
const verifyErrs = verifyErrors(verifyCur, verifySaved)
const verifyInvalid = Object.values(verifyErrs).some(Boolean)
const invalid = guardInvalid || judgeInvalid || verifyInvalid
const changed = (Object.keys(saved) as (keyof typeof saved)[]).filter((k) => cur[k] !== saved[k])
const dirty = values ? changed.length : 0
const dirty = values ? changed.length + verifyKeys.length : 0
// 外壳唯一的 blocker 在地址(pathname)变化时问:切标签、导航、⌘K、⌥ 数字、后退都算
useLeaveGuard(dirty > 0, () => confirmLeave(dirty))
@@ -702,6 +711,10 @@ function PrefsTab() {
setSaveError(null)
setDraft({ ...cur, ...patch })
}
const editVerify = (patch: Partial<VerifyDraft>) => {
setSaveError(null)
setVerifyDraft({ ...verifyCur, ...patch })
}
/**
* 署名只在这台浏览器里,先落本机:它不该跟着后端一起「没存上」。运行默认值
@@ -730,7 +743,9 @@ function PrefsTab() {
click_max_cost_usd: parseLimit(cur.jClick),
daily_max_usd: parseLimit(cur.jDaily),
} : null
if (runChanged || judge) {
// 核查一组同样整组写(VerifySettings 的 verifyPayload)
const verify = verifyKeys.length ? verifyPayload(verifyCur) : null
if (runChanged || judge || verify) {
const run = {
...(values.run ?? {}),
default_memory_scope: cur.scope,
@@ -742,16 +757,19 @@ function PrefsTab() {
agent_budget_tokens: cur.budgetTokens.trim() === '' ? null : Number(cur.budgetTokens),
agent_budget_usd: cur.budgetUsd.trim() === '' ? null : Number(cur.budgetUsd),
}
const out = await api.settings.put({ ...(runChanged ? { run } : {}), ...(judge ? { judge } : {}) })
const out = await api.settings.put({ ...(runChanged ? { run } : {}), ...(judge ? { judge } : {}), ...(verify ? { verify } : {}) })
setValues((v) => ({
...(v ?? {}),
...(runChanged ? { run: out?.run ?? run } : {}),
...(judge ? { judge: out?.judge ?? judge } : {}),
...(verify ? { verify: out?.verify ?? verify } : {}),
}))
// 每日上限改了:旁边那句「今天已花」的分母跟着变,顺手再取一次
if (judge) void loadSpend()
if (verify) setVerifySaves((n) => n + 1)
}
setDraft(null)
setVerifyDraft(null)
setSavedFlash(Date.now())
} catch (e) {
// 署名已经存上了:留在草稿里的只剩运行默认值,条上的计数跟着变少
@@ -1029,6 +1047,8 @@ function PrefsTab() {
</div>
</section>
)}
{values.verify && <VerifySection cur={verifyCur} errors={verifyErrs} onEdit={editVerify} refresh={verifySaves} />}
</>
)}
@@ -1052,7 +1072,7 @@ function PrefsTab() {
judgeProvider: '裁判模型的接入', judgeModel: '裁判模型',
jClaims: '每份报告最多裁判句数', jCost: '每份报告的金额上限', jTimeout: '每份报告的时长上限',
jClick: '每次点击的金额上限', jDaily: '每日金额上限',
})[k]).join('、')}
})[k]).concat(verifyLabels(verifyKeys)).join('、')}
</span>
</span>
{saveError != null && (
@@ -1060,10 +1080,11 @@ function PrefsTab() {
)}
<span className="flex-1" />
<button className="btn btn-sm btn-ghost" disabled={saving}
onClick={() => { setDraft(null); setSaveError(null) }}>放弃</button>
onClick={() => { setDraft(null); setVerifyDraft(null); setSaveError(null) }}>放弃</button>
<button className="btn btn-sm btn-primary" disabled={saving || invalid}
title={guardInvalid ? '护栏设置中有无效值,请修正后再保存'
: judgeInvalid ? '证据裁判的上限有无效值,请修正后再保存' : undefined}
: judgeInvalid ? '证据裁判的上限有无效值,请修正后再保存'
: verifyInvalid ? VERIFY_SETTING_TEXT.section.saveInvalid : undefined}
onClick={() => void save()}>
{saving ? <Spinner size={11} /> : <Check size={12} aria-hidden />} 保存设置
</button>
+337
View File
@@ -0,0 +1,337 @@
import { useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { AlertTriangle } from 'lucide-react'
import { get } from '../api/http'
import { modelOptions, providerOfModel, useCatalog } from '../store/catalog'
import { Field, SectionBar } from '../components/ui'
import { VERIFY_SETTING_TEXT } from '../lib/terms'
import type { VerifySpend } from '../types'
/*
* 设置页「偏好设置」里「核查」一节:双盲复算、反方审稿、审批质询共用的核查模型、各自的开关和上限、每日金额上限
* (服务端 api/settings.py 的 verify 一组)。
*
* 草稿由 PrefsTab 持有、和其余设置共用一条保存条(这一节只画、只报改动),存的时候整组写 verify:服务端对没给的项
* 回落到默认值,只发改过的那一项会把别的项冲回默认。字段名就是服务端 VERIFY_SETTING_LABEL 的叫法,422 的报错
* 写的也是它,人找得到是哪一项
*/
const T = VERIFY_SETTING_TEXT
const S = VERIFY_SETTING_TEXT.section
type Limit = string | null
/** 草稿:开关是布尔,相对误差是正在填的字符串,上限是字符串或 null(不限)。键就是服务端叫法表里的键 */
export interface VerifyDraft {
provider: string
model: string
'dual_check.enabled': boolean
'dual_check.tolerance': string
'dual_check.max_queries': Limit
'dual_check.timeout_s': Limit
'challenge.enabled': boolean
'challenge.max_claims': Limit
'challenge.max_queries': Limit
'challenge.timeout_s': Limit
'inquiry.enabled': boolean
'inquiry.max_queries': Limit
'inquiry.timeout_s': Limit
daily_max_usd: Limit
}
export type VerifyKey = keyof VerifyDraft
/** 能设成不限的几项 */
type LimitKey = 'dual_check.max_queries' | 'dual_check.timeout_s' | 'challenge.max_claims' | 'challenge.max_queries'
| 'challenge.timeout_s' | 'inquiry.max_queries' | 'inquiry.timeout_s' | 'daily_max_usd'
type Part = 'dual_check' | 'challenge' | 'inquiry'
/** 勾掉「不限」时填回的数:同服务端 VERIFY_DEFAULTS */
const FALLBACK: Record<LimitKey, string> = {
'dual_check.max_queries': '6', 'dual_check.timeout_s': '120', 'challenge.max_claims': '5', 'challenge.max_queries': '4',
'challenge.timeout_s': '90', 'inquiry.max_queries': '6', 'inquiry.timeout_s': '120', daily_max_usd: '2',
}
const COUNTS = new Set<VerifyKey>(['dual_check.max_queries', 'challenge.max_claims', 'challenge.max_queries', 'inquiry.max_queries'])
const TIMEOUTS = new Set<VerifyKey>(['dual_check.timeout_s', 'challenge.timeout_s', 'inquiry.timeout_s'])
const isRecord = (v: unknown): v is Record<string, any> => !!v && typeof v === 'object' && !Array.isArray(v)
/** 一项上限 → 草稿:null 是不限,没有这一项(老服务端、存坏了)当空串 */
const limitText = (v: unknown): Limit => (v === null ? null : v === undefined ? '' : String(v))
/** GET /settings 的 verify 一组(服务端给的是引擎实际生效的样子:存坏了的显示默认,null 是不限)→ 草稿 */
export function verifyDraftOf(group: unknown): VerifyDraft {
const g = isRecord(group) ? group : {}
const part = (p: Part): Record<string, any> => (isRecord(g[p]) ? g[p] : {})
const tol = part('dual_check').tolerance
return {
provider: typeof g.provider === 'string' ? g.provider : '',
model: typeof g.model === 'string' ? g.model : '',
'dual_check.enabled': part('dual_check').enabled !== false,
'dual_check.tolerance': typeof tol === 'number' ? String(tol) : '',
'dual_check.max_queries': limitText(part('dual_check').max_queries),
'dual_check.timeout_s': limitText(part('dual_check').timeout_s),
'challenge.enabled': part('challenge').enabled !== false,
'challenge.max_claims': limitText(part('challenge').max_claims),
'challenge.max_queries': limitText(part('challenge').max_queries),
'challenge.timeout_s': limitText(part('challenge').timeout_s),
'inquiry.enabled': part('inquiry').enabled !== false,
'inquiry.max_queries': limitText(part('inquiry').max_queries),
'inquiry.timeout_s': limitText(part('inquiry').timeout_s),
daily_max_usd: limitText(g.daily_max_usd),
}
}
/** 改过的项(按草稿里的先后) */
export function verifyChanged(cur: VerifyDraft, saved: VerifyDraft): VerifyKey[] {
return (Object.keys(saved) as VerifyKey[]).filter((k) => cur[k] !== saved[k])
}
/** 保存条上这几项的叫法:和字段名、服务端报错同一套 */
export const verifyLabels = (keys: VerifyKey[]): string[] => keys.map((k) => T[k])
/** 各项的问题。同裁判那一组:只校验改过的(存着的值不合规不该挡住别的设置保存),null(不限)总是对的 */
export function verifyErrors(cur: VerifyDraft, saved: VerifyDraft): Partial<Record<VerifyKey, string>> {
const out: Partial<Record<VerifyKey, string>> = {}
for (const k of verifyChanged(cur, saved)) {
const v = cur[k]
if (typeof v !== 'string') continue
const n = Number(v.trim())
if (k === 'dual_check.tolerance') {
if (!(v.trim() !== '' && Number.isFinite(n) && n >= 0)) out[k] = S.invalid.tolerance
} else if (COUNTS.has(k)) {
if (!(/^\d+$/.test(v.trim()) && n >= 1)) out[k] = S.invalid.count
} else if (TIMEOUTS.has(k)) {
if (!(v.trim() !== '' && Number.isFinite(n) && n > 0)) out[k] = S.invalid.seconds
} else if (k === 'daily_max_usd') {
if (!(v.trim() !== '' && Number.isFinite(n) && n > 0)) out[k] = S.invalid.usd
}
}
return out
}
/** 草稿 → 存进去的整组 verify:不限存 null,空着的接入、模型存 null(沿用证据裁判模型) */
export function verifyPayload(cur: VerifyDraft): Record<string, any> {
const limit = (v: Limit) => (v === null ? null : Number(v.trim()))
return {
provider: cur.provider || null,
model: cur.model.trim() || null,
dual_check: {
enabled: cur['dual_check.enabled'],
tolerance: Number(cur['dual_check.tolerance'].trim()),
max_queries: limit(cur['dual_check.max_queries']),
timeout_s: limit(cur['dual_check.timeout_s']),
},
challenge: {
enabled: cur['challenge.enabled'],
max_claims: limit(cur['challenge.max_claims']),
max_queries: limit(cur['challenge.max_queries']),
timeout_s: limit(cur['challenge.timeout_s']),
},
inquiry: {
enabled: cur['inquiry.enabled'],
max_queries: limit(cur['inquiry.max_queries']),
timeout_s: limit(cur['inquiry.timeout_s']),
},
daily_max_usd: limit(cur.daily_max_usd),
}
}
/** 今天花了多少美元:小额按分以下的精度写,不四舍五入成 0(同裁判那一行) */
const formatSpend = (usd: number | undefined): string =>
usd == null || !Number.isFinite(usd) ? '0' : usd >= 1 ? usd.toFixed(2) : String(Number(usd.toFixed(4)))
const fieldId = (k: VerifyKey) => `pref-verify-${k.replace('.', '-')}`
/**
* 「核查」一节。refresh 变了(刚存过 verify 一组)就再取一次今日花费:每日上限变了,那一行的分母跟着变。
* 取不到(老服务端没有这个接口)就不写那一行
*/
export function VerifySection({ cur, errors, onEdit, refresh }: {
cur: VerifyDraft
errors: Partial<Record<VerifyKey, string>>
onEdit: (patch: Partial<VerifyDraft>) => void
refresh: number
}) {
const providers = useCatalog((s) => s.providers)
const [spend, setSpend] = useState<VerifySpend | null>(null)
useEffect(() => {
let alive = true
get<VerifySpend>('/settings/verify/spend').then((s) => { if (alive) setSpend(s) }, () => { if (alive) setSpend(null) })
return () => { alive = false }
}, [refresh])
// 核查模型的接入:两项都留空时服务端沿用证据裁判模型的取值链;只填了模型时这一级整组生效,按模型名找接入
//(verify_model_spec 不跨级拼)——留空那一项不能还叫「跟随证据裁判模型」
const enabledProviders = providers.filter((p) => p.enabled)
const defaultProvider = enabledProviders.find((p) => p.kind !== 'mock') ?? enabledProviders[0]
const provider = cur.provider ? providers.find((p) => p.name === cur.provider) : undefined
const providerMissing = !!cur.provider && providers.length > 0 && !provider
const modelName = cur.model.trim()
const byModel = !cur.provider && !!modelName
const notSet = !cur.provider && !modelName
const owner = byModel ? providerOfModel(providers, modelName) : undefined
const byModelHint = byModel && providers.length > 0
? S.byModel(modelName, owner && { name: owner.provider.name, enabled: owner.enabled }, defaultProvider?.name) : ''
const models = provider ? (provider.models ?? []).map((m) => m.id) : [...new Set(modelOptions(providers).map((o) => o.value))]
const pickProvider = (name: string) => {
// 换了接入,原来填的模型不在新接入的清单里就清掉:拿 A 家的模型名去调 B 家必然失败
const next = name ? providers.find((p) => p.name === name) : undefined
const keep = !name || !cur.model || (next?.models ?? []).some((m) => m.id === cur.model) || next?.default_model === cur.model
onEdit({ provider: name, ...(keep ? {} : { model: '' }) })
}
/** 某项设成不限时,它还受哪些眼下设着的上限约束 */
const note = (k: LimitKey): string => {
if (TIMEOUTS.has(k)) return S.unlimitedNote('timeout', [])
if (k === 'challenge.max_claims') return S.unlimitedNote('claims', [S.bound.claims])
if (k === 'daily_max_usd') {
const counts = [...COUNTS].some((c) => cur[c] !== null)
const times = [...TIMEOUTS].some((c) => cur[c] !== null)
return S.unlimitedNote('daily', [counts ? S.bound.queries : '', times ? S.bound.timeouts : ''].filter(Boolean))
}
const part = k.split('.')[0] as Part
return S.unlimitedNote('queries', [cur[`${part}.timeout_s` as LimitKey] !== null ? S.bound.timeout : '',
cur.daily_max_usd !== null ? S.bound.daily : ''].filter(Boolean))
}
const limit = (k: LimitKey, extra?: ReactNode) => (
<LimitInput key={k} k={k} value={cur[k]} fallback={FALLBACK[k]} integer={COUNTS.has(k)} error={errors[k]}
unlimited={note(k)} onChange={(v) => onEdit({ [k]: v } as Partial<VerifyDraft>)} extra={extra} />
)
const toggle = (k: 'dual_check.enabled' | 'challenge.enabled' | 'inquiry.enabled') => (
<label className="mb-2 flex cursor-pointer items-center gap-2 text-xs">
<input type="checkbox" checked={cur[k]} data-verify-toggle={k} onChange={(e) => onEdit({ [k]: e.target.checked })} />
<span>{T[k]}</span>
</label>
)
return (
<section data-verify-settings="">
<SectionBar title={S.title} hint={S.hint} />
<div role="group" aria-labelledby="pref-verify-model-label" aria-describedby="pref-verify-model-hint" data-verify-model="">
<div id="pref-verify-model-label" className="text-xs font-medium">{S.model}</div>
{notSet ? (
// 两项都没填:核查会用证据裁判的模型(没配裁判模型时就是助手的模型)。醒目地说,并给出建议
<div id="pref-verify-model-hint" role="note" data-verify-not-set=""
className="mb-2 mt-1 flex items-start gap-1.5 rounded border-l-2 py-0.5 pl-2 text-2xs leading-relaxed"
style={{ borderColor: 'var(--st-waiting)' }}>
<AlertTriangle size={12} aria-hidden className="mt-0.5 shrink-0" style={{ color: 'var(--st-waiting)' }} />
<span>
<span className="font-medium">{S.notSet}</span>
<span className="block text-dim">{S.notSetAdvice}</span>
</span>
</div>
) : (
<p id="pref-verify-model-hint" className="mb-2 mt-0.5 text-2xs leading-relaxed text-faint">{S.differ}</p>
)}
<div className="grid grid-cols-2 gap-3 max-sm:grid-cols-1">
<Field label={T.provider} htmlFor={fieldId('provider')}
error={providerMissing ? S.providerGone(cur.provider) : undefined}
hint={byModelHint ? (
<span data-verify-by-model={owner ? (owner.enabled ? 'found' : 'disabled') : 'default'}
style={owner?.enabled ? undefined : { color: 'var(--st-waiting)' }}>{byModelHint}</span>
) : undefined}>
{(p) => (
<select {...p} className="field" value={cur.provider} onChange={(e) => pickProvider(e.target.value)}>
<option value="">{byModel ? S.providerFromModel : S.providerDefault}</option>
{providerMissing && <option value={cur.provider}>{S.providerGoneOption(cur.provider)}</option>}
{providers.map((pv) => <option key={pv.id} value={pv.name}>{pv.name}{pv.enabled ? '' : S.providerOff}</option>)}
</select>
)}
</Field>
<Field label={T.model} htmlFor={fieldId('model')} hint={provider?.default_model ? S.modelOf(provider.default_model) : undefined}>
{(p) => (
<>
<input {...p} className="field mono text-xs" value={cur.model} list="pref-verify-models" spellCheck={false}
placeholder={cur.provider ? S.modelDefault : S.modelFollow} onChange={(e) => onEdit({ model: e.target.value })} />
<datalist id="pref-verify-models">{models.map((m) => <option key={m} value={m} />)}</datalist>
</>
)}
</Field>
</div>
</div>
<PartGroup part="dual_check">
{toggle('dual_check.enabled')}
<div className="grid grid-cols-3 gap-3 max-sm:grid-cols-1">
<Field label={T['dual_check.tolerance']} htmlFor={fieldId('dual_check.tolerance')} error={errors['dual_check.tolerance']}
hint={S.toleranceHint}>
{(p) => (
<input {...p} className="field tnum" inputMode="decimal" value={cur['dual_check.tolerance']}
onChange={(e) => onEdit({ 'dual_check.tolerance': e.target.value })} />
)}
</Field>
{limit('dual_check.max_queries')}
{limit('dual_check.timeout_s')}
</div>
</PartGroup>
<PartGroup part="challenge">
{toggle('challenge.enabled')}
<div className="grid grid-cols-3 gap-3 max-sm:grid-cols-1">
{limit('challenge.max_claims')}
{limit('challenge.max_queries')}
{limit('challenge.timeout_s')}
</div>
</PartGroup>
<PartGroup part="inquiry">
{toggle('inquiry.enabled')}
<div className="grid grid-cols-3 gap-3 max-sm:grid-cols-1">
{limit('inquiry.max_queries')}
{limit('inquiry.timeout_s')}
</div>
</PartGroup>
<div className="mt-4 grid grid-cols-3 gap-3 max-sm:grid-cols-1" data-verify-daily="">
{limit('daily_max_usd', spend && (
<div className="mt-1 text-2xs leading-relaxed text-dim" data-verify-spend="">
{spend.calls ? S.spend(formatSpend(spend.usd), spend.calls) : S.spendNone}
{!!spend.unpriced_calls && (
<span className="block" style={{ color: 'var(--st-waiting)' }} data-verify-unpriced="">{S.unpriced(spend.unpriced_calls)}</span>
)}
</div>
))}
</div>
</section>
)
}
/** 三项各一组:标题是服务端叫法表里那一组的叫法,下面一句说它做什么 */
function PartGroup({ part, children }: { part: Part; children: ReactNode }) {
return (
<div className="mt-4" role="group" aria-labelledby={`pref-verify-${part}-label`} aria-describedby={`pref-verify-${part}-hint`}
data-verify-part={part}>
<div id={`pref-verify-${part}-label`} className="text-xs font-medium">{T[part]}</div>
<p id={`pref-verify-${part}-hint`} className="mb-2 mt-0.5 text-2xs leading-relaxed text-faint">{S.parts[part]}</p>
{children}
</div>
)
}
/**
* 一项上限:数字框加「不限」(照裁判那一组的 LimitField)。勾了不限,框变灰,下面写明不设上限之后还受什么约束——
* 不许悄悄生效;勾掉时填回之前的数(没有就填默认值)
*/
function LimitInput({ k, value, fallback, integer, error, unlimited, onChange, extra }: {
k: LimitKey; value: Limit; fallback: string; integer?: boolean; error?: string; unlimited: string
onChange: (v: Limit) => void; extra?: ReactNode
}) {
const last = useRef(value ?? fallback)
if (value !== null) last.current = value || last.current
const off = value === null
const label = T[k]
return (
<div className="min-w-0" data-verify-limit={k} data-unlimited={off ? '' : undefined}>
<Field label={label} htmlFor={fieldId(k)} error={error}
hint={off ? <span style={{ color: 'var(--st-waiting)' }} data-verify-unlimited="">{unlimited}</span> : undefined}>
{(p) => (
<div className="flex items-center gap-2">
<input {...p} className="field tnum min-w-0 flex-1" inputMode={integer ? 'numeric' : 'decimal'}
value={off ? '' : value} disabled={off} placeholder={off ? S.unlimited : undefined}
onChange={(e) => onChange(e.target.value)} />
<label className="flex shrink-0 cursor-pointer items-center gap-1 text-xs">
<input type="checkbox" checked={off} aria-label={`${label}:${S.unlimited}`} aria-describedby={p['aria-describedby']}
data-verify-unlimited-toggle="" onChange={(e) => onChange(e.target.checked ? null : last.current || fallback)} />
{S.unlimited}
</label>
</div>
)}
</Field>
{extra}
</div>
)
}
+747 -6
View File
@@ -1,15 +1,756 @@
import type { Approval } from '../types'
import { Fragment, useEffect, useId, useMemo, useState } from 'react'
import type { CSSProperties } from 'react'
import { AlertTriangle, Ban, Check, ChevronRight } from 'lucide-react'
import clsx from 'clsx'
import { api } from '../api/client'
import { useCatalog } from '../store/catalog'
import { useStudio } from '../store/studio'
import { Spinner, useToast } from '../components/ui'
import { NONE } from '../lib/format'
import { CATALOG_TABLE_FIELD_LABEL, DUAL_CHECK_TEXT } from '../lib/terms'
import { patchValueText } from './catalogPatch'
import type {
Approval, DualCheckBlindInput, DualCheckCard, DualCheckChoice, DualCheckInput, DualCheckMetric, DualCheckReply,
DualCheckSummaryItem,
} from '../types'
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' 的审批:双盲复算两路对不上、交给人裁决 */
approval: Approval
/**
* 提交之后谁来重新接上运行的事件流,同 ApprovalCard 的 onResolved:对话页、运行页各自接,没给就走画布的 attachRun。
* 两处都接会开两条订阅,各收一份事件
*/
onResolved?: (runId: string) => void | Promise<void>
}
/** 一个不一致的指标在卡上要画的东西:先由 payload.summary 填,完整对照(card_artifact)到了再补全 */
interface MetricView {
id: string
name: string
original: string | null
blind: string | null
/** 已乘过 100;null 是原流程值为 0(约定),undefined 是没给 */
deltaPct: number | null | undefined
/** 具体差异,一条一句(兜底说明、解析失败不算) */
items: string[]
/** 一条具体差异都没有:卡上不列条目,默认展开对照,审核人只能对照原文判断 */
fallbackOnly: boolean
/** fallbackOnly 时写不写那句固定的「两条 SQL 的写法不同,未能归纳成具体差异」(一边没有 SQL 时不写,只写原因) */
fallbackHead: boolean
/** 兜底、解析失败的原话里固定那句以外的说法(比较超时、解析出错的位置……),附在下面 */
notes: string[]
/** 具体差异之外还有未能归纳的部分(服务端那句「另有未能归纳的差异」) */
rest: string | null
/** 两条 SQL 等价,差在取用的结果上 */
equivalent: boolean
/** 服务端没有预告时用:两边只差一处、又可能对得上数据目录的一项事实,审核人的选择可能被记进数据目录(见 catalogOf) */
catalog: CatalogAspect | null
/** 服务端预告的采用哪一边会往数据目录里写什么(见 previewOf);有它就照它说,不再按 catalog 有条件地说 */
preview: CatalogPreview | null
full: DualCheckMetric | null
}
/**
* 审批卡里双盲复算那一块(RunPanel 的 ApprovalCard 在 payload.kind === 'dual_check' 时渲染它)。
*
* 现在是占位,什么都不画;双盲复算的工作包在这里补上两路结果的对照,payload 的形状由那边定。
* 服务端预告的一边:写的话写到哪(where 是 「orders」(shop)的业务日期 这样的说法)、写成什么(value 是给人看的
* 写法,raw 是原样的取值,和别的指标的说法比是不是同一个时用)
*/
export function DualCheckApproval(_props: DualCheckApprovalProps) {
return null
type PreviewSide =
| { write: true; table: string; source: string | null; item: string; where: string; value: string; raw: unknown }
| { write: false }
type WritingSide = Extract<PreviewSide, { write: true }>
interface CatalogPreview { a: PreviewSide; b: PreviewSide }
/**
* 同一次裁决里,别的指标按当前的选择落到同一项上(同一数据源、同一张表、同一项)的:differ 是说法和这一边不同的指标名
* ——业务日期、去重规则说法不同时服务端一项都不写;joined 是条件和这一边不同的指标名——有效记录条件用 AND 合成一句写
*/
interface Peers { differ: string[]; joined: string[] }
type PreviewPeers = Partial<Record<'a' | 'b', Peers>>
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[] {
return Array.isArray(raw)
? raw.filter((s): s is DualCheckSummaryItem => !!s && typeof s === 'object' && typeof s.metric_id === 'string' && !!s.metric_id)
: []
}
/** 工件内容像不像一份对照:至少得有 metrics 数组,里面的项带 metric_id */
function cardOf(content: unknown): DualCheckCard | null {
if (!content || typeof content !== 'object' || !Array.isArray((content as DualCheckCard).metrics)) return null
const card = content as DualCheckCard
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 不等价、没用兜底、差异恰好一条,而且是时间列、筛选条件或聚合。几个输入都对不上时节点把各自的差异合成
* 一份(每个输入至少一条),所以「恰好一条」也就是只有一个输入对不上。过了这道门槛也不一定写:只有一边按时间筛、
* 多出的条件是指标自己的范围、采用了不去重的一边都不写,同一次裁决里几个指标给同一张表记的业务日期、去重规则不同时
* 一项都不写,这些卡上看不出来(差异条目只有方面和原话,没有预告也认不出写到哪张表),所以只说必要条件,并把不写的
* 几种照实说出来(DUAL_CHECK_TEXT.approval.catalog)
*/
function catalogOf(metric: DualCheckMetric, items: { aspect: string; text: string }[]): CatalogAspect | null {
if (metric.diff?.equivalent === true || metric.diff?.fallback === true || items.length !== 1) return null
return CATALOG_ASPECTS.has(items[0].aspect) ? items[0].aspect as CatalogAspect : null
}
/** 预告的一边:写的得有表、认得出的目录项和取值;认不出返回 undefined */
function previewSideOf(raw: unknown): PreviewSide | undefined {
if (!raw || typeof raw !== 'object') return undefined
const s = raw as Record<string, unknown>
if (s.write === false) return { write: false }
const table = str(s.table)
const item = str(s.item)
if (s.write !== true || !table || !item || !Object.hasOwn(CATALOG_TABLE_FIELD_LABEL, item) || s.value == null || s.value === '') {
return undefined
}
const source = str(s.source) ?? null
const label = CATALOG_TABLE_FIELD_LABEL[item as keyof typeof CATALOG_TABLE_FIELD_LABEL]
return {
write: true, table, source, item,
where: DUAL_CHECK_TEXT.log.catalogItem(table, source ?? undefined, label), value: patchValueText(item, s.value), raw: s.value,
}
}
/**
* payload.summary[].catalog(约定之外、服务端另带的预告):采用原流程、采用盲算各会往数据目录里写什么,和裁决后真写的
* 是同一套判断。两边有一边认不出就整份不认,卡上回到按差异形状有条件地说(catalogOf)——宁可少说,不替服务端编
*/
function previewOf(raw: unknown): CatalogPreview | null {
if (!raw || typeof raw !== 'object') return null
const a = previewSideOf((raw as Record<string, unknown>).a)
const b = previewSideOf((raw as Record<string, unknown>).b)
return a && b ? { a, b } : null
}
/** 两边写的是不是同一个数据源、同一张表的同一项(服务端写目录前按这三样归拢) */
const sameTarget = (a: PreviewSide, b: PreviewSide): boolean =>
a.write && b.write && a.table === b.table && a.source === b.source && a.item === b.item
const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
/** 同一项的两个说法是不是同一个(同服务端的 _same_fact):业务日期只看哪一列,其余是文字、压掉多余的空白比 */
function sameFact(item: string, a: unknown, b: unknown): boolean {
if (item === 'business_date') return isRecord(a) && isRecord(b) && a.column === b.column
const flat = (s: string) => s.split(/\s+/).filter(Boolean).join(' ')
return typeof a === 'string' && typeof b === 'string' && flat(a) === flat(b)
}
/**
* 每个指标的预告是单独算的,服务端写目录前却把同一次裁决里落到同一张表同一项上的说法归拢成一项
* (dual_check_facts._merged):业务日期一张表只有一个、去重规则说的未必是同一件事,说法不同就一项都不写;有效记录
* 条件都在排除无效记录,用 AND 合成一句写。这里按其余指标当前的选择,给每个指标的两种选择各找出落到同一项上的指标
* (见 Peers),卡上照它说。只看预告会写的一边:选「两边都不对」、还没选的,服务端不会并进来;预告说不写的,多半是
* 差异对不上目录项,服务端同样不并。少数是因为目录里已有的说法才不写,服务端仍会把它并进来、可能让这一项都不写,
* 可预告里没有它落到哪一项,卡上看不出,所以结尾说以时间线上的记录为准
*/
function peersOf(views: MetricView[], choices: Record<string, DualCheckChoice>): Map<string, PreviewPeers> {
const adopted: { id: string; name: string; side: WritingSide }[] = []
for (const v of views) {
const c = choices[v.id]
const side = c === 'a' || c === 'b' ? v.preview?.[c] : undefined
if (side?.write) adopted.push({ id: v.id, name: v.name, side })
}
const out = new Map<string, PreviewPeers>()
for (const v of views) {
const peers: PreviewPeers = {}
for (const c of ['a', 'b'] as const) {
const mine = v.preview?.[c]
if (!mine?.write) continue
// 说法和这一边不同的指标:业务日期、去重规则让整组都不写,有效记录条件要合成一句
const names = [...new Set(adopted
.filter((o) => o.id !== v.id && sameTarget(o.side, mine) && !sameFact(mine.item, o.side.raw, mine.raw))
.map((o) => o.name))]
if (!names.length) continue
peers[c] = mine.item === 'valid_filter' ? { differ: [], joined: names } : { differ: names, joined: [] }
}
out.set(v.id, peers)
}
return out
}
function viewsOf(summary: DualCheckSummaryItem[], card: DualCheckCard | null): MetricView[] {
const full = new Map((card?.metrics ?? []).map((m) => [m.metric_id, m]))
// 先按摘要的顺序,工件里另有的(约定里不该有,有就照实列出来)跟在后面
const ids = [...new Set([...summary.map((s) => s.metric_id), ...full.keys()])]
return ids.map((id) => {
const s = summary.find((x) => x.metric_id === id)
const f = full.get(id) ?? null
const base = {
id,
name: str(s?.name) ?? str(f?.name) ?? id,
original: str(s?.original_rendered) ?? str(f?.original?.rendered) ?? null,
blind: str(s?.blind_rendered) ?? str(f?.blind?.rendered) ?? null,
deltaPct: s && 'delta_pct' in s ? num(s.delta_pct) ?? null : f?.delta_pct === null ? null : num(f?.delta_pct),
// 预告跟着摘要来:完整对照还没到、或者取不到时也照它说
preview: previewOf(s?.catalog),
}
if (f) {
const items = (Array.isArray(f.diff?.items) ? f.diff.items : [])
.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 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,
fallbackHead: !notes.length || standard || comparable,
notes: fallbackOnly ? notes : [],
rest: concrete.length && other.length ? other.map((i) => i.text).join(';') : null,
equivalent,
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: fallbackOnly ? [] : texts,
fallbackOnly,
fallbackHead: true,
notes: fallbackOnly ? notesOf(texts).notes : [],
rest: null,
equivalent: false,
catalog: null,
full: null,
}
})
}
/** 相差比例:服务端给的已乘过 100、保留两位(12.5 是相差 12.5%),带正负号;小到两位小数写不出来的写「<0.01%」 */
function deltaText(pct: number | null | undefined): string | null {
if (pct === undefined) return null
if (pct === null) return T.deltaZero
const abs = Math.abs(pct)
const sign = pct > 0 ? '+' : pct < 0 ? '-' : ''
return T.delta(abs < 0.01 ? `${sign}<0.01%` : `${sign}${abs.toLocaleString('en-US', { maximumFractionDigits: 2 })}%`)
}
/**
* 这次运行里节点的「未采用原流程时」。服务端在 payload 里带了实际生效的处置(given,约定之外另带的)就照它说;
* 老服务端不带,从运行的图快照(GET /runs/{id}/graph)里按节点 id 读:没写、写了认不出的按缺省的 degrade(节点执行时
* 同样这么认);图里找不到这个节点(子工作流里的)、图快照取不到时是 unknown,卡上有条件地说
*/
type OnReject = 'degrade' | 'fail' | 'unknown' | 'loading'
function useOnReject(runId: string, nodeId: string | undefined, given: unknown): OnReject {
const known = given === 'fail' || given === 'degrade' ? given : null
const key = `${runId}\n${nodeId ?? ''}`
const [got, setGot] = useState<{ key: string; value: OnReject } | null>(null)
useEffect(() => {
if (known || !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 }
}, [known, runId, nodeId, key])
if (known) return known
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}。摘要和工件里都认不出指标时没法裁决,只留「驳回并终止」(approved 为假,
* 节点按人工驳回失败),免得运行一直挂着
*/
export function DualCheckApproval({ approval, onResolved }: DualCheckApprovalProps) {
const toast = useToast()
const refreshApprovals = useCatalog((s) => s.refreshApprovals)
const attachRun = useStudio((s) => s.attachRun)
const uid = useId()
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), payload.on_reject)
const [card, setCard] = useState<{ id: string; data?: DualCheckCard | null } | null>(null)
useEffect(() => {
if (!cardId) return
let alive = true
setCard({ id: cardId })
api.artifact(cardId).then(
(res) => { if (alive) setCard({ id: cardId, data: cardOf(res?.content) }) },
// 取不到(工件被清理、网络断了)和取回来认不出一样处理:按摘要裁决
() => { if (alive) setCard({ id: cardId, data: null }) },
)
return () => { alive = false }
}, [cardId])
const slot = card?.id === cardId ? card : null
const data = slot?.data ?? null
const loading = !!cardId && slot?.data === undefined
const lost = !cardId || slot?.data === null
const views = useMemo(() => viewsOf(summary, data), [summary, data])
const [choices, setChoices] = useState<Record<string, DualCheckChoice>>({})
const [note, setNote] = useState('')
const [busy, setBusy] = useState(false)
const pending = views.filter((v) => !choices[v.id]).length
// 预告要按其余指标当前的选择补上「同一项上说法不同」「合成一句」:选择一变,各块的说法跟着变
const peers = useMemo(() => peersOf(views, choices), [views, choices])
/** 提交裁决(approved 为真,value 是 {choices, note})或驳回(认不出指标时的出口) */
const send = async (approved: boolean) => {
if (busy || (approved && (pending || !views.length))) return
setBusy(true)
try {
const value: DualCheckReply = {
choices: Object.fromEntries(views.map((v) => [v.id, choices[v.id]])),
note: note.trim(),
}
// 照 ApprovalCard 里 mode 为 input 时的写法:备注外层也放一份,节点拿到的是 {approved, 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)
// 采用盲算、两边都不对时,节点设为「判为失败」就会让运行终止:提示照节点的设置说,读不到设置时不替它下结论
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 {
setBusy(false)
}
}
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>}
{loading && (
<div className="flex items-center gap-1.5 text-2xs text-faint" role="status" data-dual-loading="">
<Spinner size={10} /> {T.loading}
</div>
)}
{lost && views.length > 0 && (
<div className="text-2xs leading-relaxed" style={{ color: 'var(--st-waiting)' }} data-dual-load-failed="">{T.loadFailed}</div>
)}
{data && (str(data.model) || data.same_model) && (
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-2xs">
{str(data.model) && <span className="mono text-dim [overflow-wrap:anywhere]" data-dual-model="">{T.model(data.model!)}</span>}
{data.same_model && (
<span className="flex items-center gap-1" style={{ color: 'var(--st-waiting)' }} data-dual-same-model="">
<AlertTriangle size={11} aria-hidden className="shrink-0" /> {T.sameModel}
</span>
)}
</div>
)}
{!views.length ? (
!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) => (
<MetricBlock key={v.id} view={v} name={`${uid}-${v.id}`} choice={choices[v.id]} peers={peers.get(v.id)}
onChoose={(c) => setChoices((cur) => ({ ...cur, [v.id]: c }))} />
))}
{/* 选了会怎样,常显在提交按钮上面:审核人得在提交之前知道「采用盲算」并不替换报告里的数,以及运行会不会终止 */}
<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 send(true)}>
{busy ? <Spinner size={11} /> : <Check size={12} aria-hidden />} {T.submit}
</button>
</>
)}
</div>
)
}
/** 一个不一致的指标:名字、两个值、相差多少、差异说明、对照两条 SQL、三选一 */
function MetricBlock({ view, name, choice, peers, onChoose }: {
view: MetricView; name: string; choice?: DualCheckChoice; peers?: PreviewPeers; 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">
<span id={`${uid}-name`} className="min-w-0 text-xs font-medium [overflow-wrap:anywhere]" data-dual-name="">{view.name}</span>
{delta && (
<span className="tnum ml-auto shrink-0 text-2xs font-medium" style={{ color: 'var(--st-waiting)' }} title={T.deltaHint}
data-dual-delta="">{delta}</span>
)}
</div>
<dl className="mt-1 grid grid-cols-2 gap-2" data-dual-values="">
<div className="min-w-0">
<dt className="text-2xs text-faint">{DUAL_CHECK_TEXT.original}</dt>
<dd className="tnum m-0 text-sm font-medium [overflow-wrap:anywhere]" data-dual-value="original">{view.original ?? NONE}</dd>
</div>
<div className="min-w-0">
<dt className="text-2xs text-faint">{DUAL_CHECK_TEXT.blind}</dt>
<dd className="tnum m-0 text-sm font-medium [overflow-wrap:anywhere]" data-dual-value="blind">{view.blind ?? NONE}</dd>
</div>
</dl>
<div className="mt-1.5" data-dual-diff="">
<div className="text-2xs text-faint">{T.diffHead}</div>
{view.equivalent ? (
<p className="text-2xs leading-relaxed text-dim" data-dual-equivalent="">{T.equivalent}</p>
) : view.fallbackOnly ? (
<>
{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>)}
</ul>
)}
{view.rest && <p className="mt-0.5 text-2xs leading-relaxed text-faint" data-dual-diff-rest="">{view.rest}</p>}
</div>
{view.full && (
<button type="button" className="btn btn-xs mt-1.5" aria-expanded={expanded} aria-controls={expanded ? `${uid}-cmp` : undefined}
data-dual-compare-toggle="" onClick={() => setOpen(!expanded)}>
<ChevronRight size={11} aria-hidden style={{ transform: expanded ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }} />
{expanded ? T.compareClose : T.compare}
</button>
)}
{expanded && view.full && <Compare id={`${uid}-cmp`} metric={view.full} />}
{view.preview ? (
// 服务端预告了:两边都不写就不提数据目录
(view.preview.a.write || view.preview.b.write) && <CatalogPreviewNote preview={view.preview} peers={peers} choice={choice} />
) : view.catalog && (
<p className="mt-1.5 text-2xs leading-relaxed text-dim" data-dual-catalog={view.catalog}>{T.catalog[view.catalog]}{T.catalog.tail}</p>
)}
{/* 原生单选:Tab 进组、方向键换选项,读屏念得出是哪个指标的哪一项 */}
<fieldset className="mt-2 min-w-0 border-0 p-0" data-dual-choice={view.id}>
<legend className="sr-only">{T.choiceGroup(view.name)}</legend>
<div className="flex flex-wrap gap-1.5">
{CHOICES.map((c) => (
<label key={c}
className={clsx('flex cursor-pointer items-center gap-1.5 rounded border px-2 py-1 text-2xs transition-colors hover:bg-hover',
choice === c ? 'font-medium' : 'text-dim')}
style={choice === c ? { borderColor: 'var(--accent)', background: 'var(--accent-soft)', color: 'var(--text)' } : undefined}>
<input type="radio" className="accent-[var(--accent)]" name={name} value={c} checked={choice === c}
onChange={() => onChoose(c)} />
{T.choices[c]}
</label>
))}
</div>
</fieldset>
</div>
)
}
/** 预告里的一句:说什么、这一选会不会写(说法不同、都不写的不算)、是不是说法不同、是不是要和别的条件合写 */
interface Clause { side: DualCheckChoice; text: string; write: boolean; conflict: boolean; joined: boolean }
/**
* 服务端预告的「记入数据目录」:三个选择各一句,审核人当前选的那一句加重——选之前三句都看得到,选了之后一眼看出这一选
* 会往目录里写什么。两边写的是同一项(只差取值,比如业务日期按哪一列)时,后一句省掉「哪张表的哪一项」。
* peers 是同一次裁决里别的指标按当前的选择落到同一项上的(peersOf):说法不同的这一句改说都不记,有效记录条件补一句
* 合写;选择变了跟着变
*/
function CatalogPreviewNote({ preview, peers, choice }: { preview: CatalogPreview; peers?: PreviewPeers; choice?: DualCheckChoice }) {
const P = T.catalogPreview
const skip = (side: DualCheckChoice): Clause => ({ side, text: P.skip(T.choices[side]), write: false, conflict: false, joined: false })
const clause = (side: 'a' | 'b'): Clause => {
const s = preview[side]
if (!s.write) return skip(side)
// 前一句已经写明了是哪张表的哪一项(说法不同的那句也写),这一句省掉
const short = side === 'b' && sameTarget(preview.a, s)
const { differ = [], joined = [] } = peers?.[side] ?? {}
if (differ.length) {
const names = P.metricNames(differ)
const text = short ? P.conflictSame(T.choices.b, names) : P.conflict(T.choices[side], s.where, names)
return { side, text, write: false, conflict: true, joined: false }
}
const text = short ? P.writeSame(T.choices.b, s.value) : P.write(T.choices[side], s.where, s.value)
return joined.length
? { side, text: text + P.joined(P.metricNames(joined)), write: true, conflict: false, joined: true }
: { side, text, write: true, conflict: false, joined: false }
}
const clauses = [clause('a'), clause('b'), skip('neither')]
return (
<p className="mt-1.5 text-2xs leading-relaxed text-dim [overflow-wrap:anywhere]" data-dual-catalog="preview">
{P.head}
{clauses.map(({ side, text, write, conflict, joined }, i) => (
<Fragment key={side}>
{i > 0 && ';'}
<span className={clsx(choice === side && 'font-medium')} style={choice === side ? { color: 'var(--text)' } : undefined}
data-dual-catalog-side={side} data-write={write ? '' : undefined} data-current={choice === side ? '' : undefined}
data-conflict={conflict ? '' : undefined} data-joined={joined ? '' : undefined}>
{text}
</span>
</Fragment>
))}
{P.tail}
</p>
)
}
/** 一个输入在两边的取值和 SQL */
interface Pair {
path: string
original?: DualCheckInput
blind?: DualCheckBlindInput
source: string | null
/** SQL 或取到的值不同 */
differs: boolean
}
/** 比 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
try { return JSON.stringify(a) === JSON.stringify(b) } catch { return false }
}
function pairsOf(metric: DualCheckMetric): Pair[] {
const byPath = new Map<string, { original?: DualCheckInput; blind?: DualCheckBlindInput }>()
const valid = (i: unknown): i is DualCheckInput => !!i && typeof i === 'object' && typeof (i as DualCheckInput).path === 'string'
for (const i of (Array.isArray(metric.original?.inputs) ? metric.original.inputs : []).filter(valid)) {
byPath.set(i.path, { ...byPath.get(i.path), original: i })
}
for (const i of (Array.isArray(metric.blind?.inputs) ? metric.blind.inputs : []).filter(valid)) {
byPath.set(i.path, { ...byPath.get(i.path), blind: i })
}
return [...byPath].map(([path, p]) => ({
path, ...p,
source: str(p.original?.source) ?? str(p.blind?.source) ?? null,
differs: normSql(p.original?.sql) !== normSql(p.blind?.sql) || !sameValue(p.original?.value, p.blind?.value),
}))
}
/** 按行数算 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])
}
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)
if (typeof v === 'string' || typeof v === 'boolean') return String(v)
try {
const s = JSON.stringify(v)
return s.length > 80 ? `${s.slice(0, 79)}…` : s
} catch {
return String(v)
}
}
/** 高亮的行:两边都用同一种提醒底色——谁对谁错由审核人判断,不用红绿暗示哪边是「删掉的」 */
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 时上下排,按卡片
* 自己的宽度算,画布右栏里也是上下排),两段原文按行比出的不同行高亮;最后是规范化后不同的行(服务端的 text_diff),
* 同样分原流程、盲算两栏,每行高亮——原文格式差得太远、按行比不出东西时,差在哪就看这一段
*/
function Compare({ id, metric }: { id: string; metric: DualCheckMetric }) {
const pairs = useMemo(() => pairsOf(metric), [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="">
{pairs.length > 0 && (
<div>
<div className="mb-0.5 text-2xs text-faint">{T.inputs}</div>
<ul className="space-y-0.5">
{pairs.map((p) => (
<li key={p.path} className="flex min-w-0 flex-wrap items-baseline gap-x-2 text-2xs" data-dual-input={p.path}
data-differs={p.differs ? '' : undefined}>
<code className="mono min-w-0 [overflow-wrap:anywhere]">{p.path}</code>
<span className="tnum text-dim">{DUAL_CHECK_TEXT.original} {valueText(p.original?.value)}</span>
<span className={clsx('tnum', !p.differs && 'text-dim')} style={p.differs ? { color: 'var(--st-waiting)' } : undefined}>
{DUAL_CHECK_TEXT.blind} {valueText(p.blind?.value)}
</span>
</li>
))}
</ul>
</div>
)}
{(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="grid grid-cols-1 gap-2 @lg:grid-cols-2">
<NormPane side="original" lines={norm.original} />
<NormPane side="blind" lines={norm.blind} />
</div>
</div>
)}
</div>
)
}
/** 一个对不上的输入:两条 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', 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(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>
)
}
+38 -33
View File
@@ -104,6 +104,9 @@ export function ApprovalCard({ approval, onResolved, showWorkflow = true }: {
const where = approval.node_label || approval.node_id
// 人工审批节点挂起的审批(工具、代码的审批另有 kind):卡上标明运行类别、批了会怎样,审的是报告时附上核对概况
const humanNode = payload.kind === 'human_node'
// 双盲复算两路对不上的审批:mode 是 input,但三选一和提交由 DualCheckApproval 自己画——通用的输入框、备注和
// 「提交 / 驳回并终止」会和它重复,通用的提交还会把一个字符串当 value 发出去
const dualCheck = payload.kind === 'dual_check'
const reports: ApprovalReport[] = Array.isArray(payload.reports)
? payload.reports.filter((r: unknown): r is ApprovalReport =>
!!r && typeof r === 'object' && typeof (r as ApprovalReport).node_id === 'string')
@@ -147,7 +150,7 @@ export function ApprovalCard({ approval, onResolved, showWorkflow = true }: {
)}
{/* 双盲复算对不上、交给人裁决的审批,卡上画两路结果的对照 */}
{payload.kind === 'dual_check' && <DualCheckApproval approval={approval} />}
{dualCheck && <DualCheckApproval approval={approval} onResolved={onResolved} />}
<ApprovalReports reports={reports} runId={approval.run_id} runClass={approval.run_class} />
<ChallengeSummary reports={reports} runId={approval.run_id} />
@@ -169,7 +172,7 @@ export function ApprovalCard({ approval, onResolved, showWorkflow = true }: {
</pre>
)}
{!approveMode && (
{!approveMode && !dualCheck && (
<textarea
className="field mb-2"
rows={4}
@@ -180,40 +183,42 @@ export function ApprovalCard({ approval, onResolved, showWorkflow = true }: {
/>
)}
<input
className="field mb-2"
aria-label="备注"
placeholder="备注(将传递给后续节点)"
value={note}
onChange={(e) => setNote(e.target.value)}
/>
{!dualCheck && (<>
<input
className="field mb-2"
aria-label="备注"
placeholder="备注(将传递给后续节点)"
value={note}
onChange={(e) => setNote(e.target.value)}
/>
<div className="flex gap-2">
<button className="btn btn-primary flex-1 justify-center" disabled={busy} onClick={() => decide(true)}>
<Check size={12} aria-hidden /> {approveMode ? '批准' : '提交'}
</button>
{trustKey && (
<button className="btn flex-1 justify-center" disabled={busy} data-approval-always
title={TOOL_TRUST_TEXT.alwaysHint(trustKey)} aria-describedby={`approval-always-${approval.id}`}
onClick={() => decide(true, true)}>
<CheckCheck size={12} aria-hidden /> {TOOL_TRUST_TEXT.alwaysButton}
<div className="flex gap-2">
<button className="btn btn-primary flex-1 justify-center" disabled={busy} onClick={() => decide(true)}>
<Check size={12} aria-hidden /> {approveMode ? '批准' : '提交'}
</button>
{trustKey && (
<button className="btn flex-1 justify-center" disabled={busy} data-approval-always
title={TOOL_TRUST_TEXT.alwaysHint(trustKey)} aria-describedby={`approval-always-${approval.id}`}
onClick={() => decide(true, true)}>
<CheckCheck size={12} aria-hidden /> {TOOL_TRUST_TEXT.alwaysButton}
</button>
)}
<button className="btn btn-danger flex-1 justify-center" disabled={busy} onClick={() => decide(false)}>
<Ban size={12} aria-hidden /> {approveMode ? '驳回' : '驳回并终止'}
</button>
)}
<button className="btn btn-danger flex-1 justify-center" disabled={busy} onClick={() => decide(false)}>
<Ban size={12} aria-hidden /> {approveMode ? '驳回' : '驳回并终止'}
</button>
</div>
{/* 批了会怎样,常显而不是藏在 hover 里:审批人得在按下去之前知道 */}
<div className="mt-1.5 text-2xs leading-relaxed text-dim">
{approveMode
? '批准:继续运行;驳回:转入「驳回」分支'
: '提交:以你填写的内容继续运行;驳回:终止本次运行'}
</div>
{trustKey && (
<div id={`approval-always-${approval.id}`} className="mt-0.5 text-2xs leading-relaxed text-dim">
{TOOL_TRUST_TEXT.alwaysButton}:{TOOL_TRUST_TEXT.alwaysHint(trustKey)}
</div>
)}
{/* 批了会怎样,常显而不是藏在 hover 里:审批人得在按下去之前知道 */}
<div className="mt-1.5 text-2xs leading-relaxed text-dim">
{approveMode
? '批准:继续运行;驳回:转入「驳回」分支'
: '提交:以你填写的内容继续运行;驳回:终止本次运行'}
</div>
{trustKey && (
<div id={`approval-always-${approval.id}`} className="mt-0.5 text-2xs leading-relaxed text-dim">
{TOOL_TRUST_TEXT.alwaysButton}:{TOOL_TRUST_TEXT.alwaysHint(trustKey)}
</div>
)}
</>)}
<div className="mt-1 flex items-center gap-1 text-2xs leading-relaxed"
style={actor ? { color: 'var(--text-dim)' } : { color: 'var(--st-waiting)' }}>
<PenLine size={10} aria-hidden className="shrink-0" />
+151 -2
View File
@@ -3,6 +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,
CATALOG_TABLE_FIELD_LABEL, DUAL_CHECK_TEXT,
claimTally,
evidenceTally, issuanceLabel, nodeTypeLabel,
} from '../lib/terms'
@@ -714,13 +715,34 @@ function gateTitle(tool: string, verdict: 'allow' | 'escalate' | 'team', reason:
return reason ? `${head}:${reason}` : head
}
/**
* 双盲复算、反方审稿的日志代码(期 3 前后端共用的约定)。这几条按 code 和结构化字段解读,不看原话;后端发成 info 的
* (一致、裁决、记入数据目录、审稿完成)也上时间线,不然这两种节点就是一个跑了几十秒、什么都没说的黑盒
*/
const VERIFY_LOG_CODES = new Set([
'dual_check_skipped', 'dual_check_same_model', 'dual_check_agreed', 'dual_check_disagreed', 'dual_check_resolved',
'dual_check_catalog', 'dual_check_limit',
'challenge_skipped', 'challenge_same_model', 'challenge_done', 'challenge_refuted', 'challenge_limit',
])
const DC = DUAL_CHECK_TEXT
const CL = DUAL_CHECK_TEXT.challengeLog
const isRecord = (v: unknown): v is Record<string, any> => !!v && typeof v === 'object' && !Array.isArray(v)
/** 老日志、别的服务端版本可能给的是一句中文原因而不是代码:中文的照写,认不得的代码不往界面上放 */
const cjkText = (v: unknown): string | undefined => (typeof v === 'string' && /[㐀-鿿]/.test(v) ? v : undefined)
/** 个数:给的是数就取整,给的是清单就数一下,别的(缺了、写坏了)当不知道 */
const countOf = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.round(v)
: Array.isArray(v) ? v.length : undefined)
/**
* 几种「看着跑完了、其实没做成」的状况(NI-3/4/5)。后端这几条日志是写给排查的,原样
* 贴出来是一串标记和术语,而且不说该去哪儿改——这里说成人话,并给出下一步。
* 认不得的 code 返回 null,照原话显示
* 认不得的 code 返回 null,照原话显示。
*
* 双盲复算、反方审稿那几条(VERIFY_LOG_CODES)另带 level(该不该用提醒色)和 artifact(完整对照、审稿记录可下钻)
*/
function explainLog(code: string | undefined, message: string, d: Record<string, any> = {},
): Pick<Step, 'title' | 'sub' | 'next' | 'fix' | 'fixNode' | 'fixField' | 'checks'> | null {
): Pick<Step, 'title' | 'sub' | 'next' | 'fix' | 'fixNode' | 'fixField' | 'checks' | 'level' | 'artifact'> | null {
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined)
const strs = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && !!x) : [])
switch (code) {
@@ -971,6 +993,120 @@ function explainLog(code: string | undefined, message: string, d: Record<string,
fix: 'canvas',
}
}
// ---- 双盲复算:只读结构化字段。老日志缺了字段就不点名、不报数,标题照样说得通
case 'dual_check_skipped': {
const reason = str(d.reason)
if (reason === 'disabled') {
return { title: DC.log.skippedDisabled, next: DC.log.skippedDisabledNext, fix: 'canvas', fixField: 'enabled', level: 'info' }
}
// 放了这个节点却一个指标都复算不了:多半是配错了,用提醒色
if (reason === 'no_inputs') {
return { title: DC.log.skippedNoInputs, next: DC.log.skippedNoInputsNext, fix: 'canvas', fixField: 'metrics', level: 'warn' }
}
return { title: DC.log.skipped, sub: cjkText(d.reason), level: 'info' }
}
case 'dual_check_same_model':
return { title: DC.log.sameModel(str(d.model)), next: DC.log.sameModelNext, fix: 'canvas', fixField: 'model', level: 'warn' }
case 'dual_check_agreed':
return { title: DC.log.agreed(countOf(d.metrics) || undefined), level: 'info' }
case 'dual_check_disagreed': {
// metrics 是 [{id, name}]:副标题点名,没起名字的指标写 id
const list = Array.isArray(d.metrics) ? d.metrics.filter(isRecord) : []
const names = list.map((m) => str(m.name) ?? str(m.id)).filter((n): n is string => !!n)
return {
title: DC.log.disagreed(list.length || undefined),
sub: names.length ? names.map((n) => `「${n}」`).join('') : undefined,
next: DC.log.disagreedNext,
level: 'warn',
...(str(d.card_artifact) ? { artifact: str(d.card_artifact) } : {}),
}
}
case 'dual_check_resolved': {
// choices 是 {指标 id: a | b | neither};gaps 是记成缺口的个数(给成清单的也数得出来)
const picked = isRecord(d.choices) ? Object.values(d.choices) : []
const n = (c: string) => picked.filter((v) => v === c).length
const parts = [
n('a') ? DC.log.resolvedA(n('a')) : '', n('b') ? DC.log.resolvedB(n('b')) : '', n('neither') ? DC.log.resolvedNeither(n('neither')) : '',
].filter(Boolean)
const gaps = countOf(d.gaps)
// 没给缺口数时按约定推:采用盲算、两边都不对都会记缺口
const warn = gaps != null ? gaps > 0 : n('b') + n('neither') > 0
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}] 没写的(已有已确认的不覆盖……)。
// 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 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(';')}` : ''}` : ''
return {
title: written.length ? DC.log.catalog(written.length) : DC.log.catalogNone,
sub: [what.join(';'), rest].filter(Boolean).join(';') || undefined,
next: written.length ? DC.log.catalogNext : undefined,
level: 'info',
}
}
case 'dual_check_limit': {
const which = str(d.limit)
return {
title: DC.log.limit((which && DC.limitLabel[which]) || DC.limitAny, str(d.metric)),
next: which === 'daily_max_usd' ? DC.limitDailyNext : DC.log.limitNext,
level: 'warn',
}
}
// ---- 反方审稿:同上,文案在 DUAL_CHECK_TEXT.challengeLog(时间线的解读统一在这里做)
case 'challenge_skipped': {
const reason = str(d.reason)
if (reason === 'disabled') {
return { title: CL.skippedDisabled, next: CL.skippedDisabledNext, fix: 'canvas', fixField: 'enabled', level: 'info' }
}
if (reason === 'no_report') return { title: CL.skippedNoReport, next: CL.skippedNoReportNext, fix: 'canvas', level: 'warn' }
if (reason === 'no_claims') return { title: CL.skippedNoClaims, level: 'info' }
return { title: CL.skipped, sub: cjkText(d.reason), level: 'info' }
}
case 'challenge_same_model':
return { title: CL.sameModel(str(d.model)), next: CL.sameModelNext, fix: 'canvas', fixField: 'model', level: 'warn' }
case 'challenge_done': {
// counts:{stands, weakened, refuted, inconclusive},四种结论各几句;为 0 的不写
const c = isRecord(d.counts) ? d.counts : {}
const keys = ['stands', 'weakened', 'refuted', 'inconclusive']
const nums = keys.map((k) => countOf(c[k]) ?? 0)
const total = nums.reduce((a, b) => a + b, 0)
const parts = keys.map((k, i) => (nums[i] ? `${CL.verdict[k]} ${formatNumber(nums[i])}` : '')).filter(Boolean)
return {
title: CL.done(total || undefined),
sub: parts.length ? parts.join(' · ') : undefined,
level: nums[2] > 0 ? 'warn' : 'info',
...(str(d.artifact) ? { artifact: str(d.artifact) } : {}),
}
}
case 'challenge_refuted': {
const items = Array.isArray(d.items) ? d.items.filter(isRecord) : []
const texts = items.map((i) => str(i.text)).filter((t): t is string => !!t)
return {
title: CL.refuted(items.length || undefined),
sub: texts.length ? texts.map((t) => `「${t}」`).join('') : undefined,
next: CL.refutedNext,
level: 'warn',
}
}
case 'challenge_limit': {
const which = str(d.limit)
const skipped = countOf(d.claims_skipped)
return {
title: CL.limit((which && DC.limitLabel[which]) || DC.limitAny, skipped || undefined),
next: which === 'daily_max_usd' ? DC.limitDailyNext : CL.limitNext,
level: 'warn',
}
}
default:
return null
}
@@ -2146,6 +2282,19 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt
break
}
}
// 双盲复算、反方审稿的日志按 code 和结构化字段解读:info 的也上时间线,没有原话的老日志照样说得出来。
// 后端标了 warn / error 的照它的来,explainLog 认为该提醒的(有缺口、有推翻)也用提醒色
if (typeof d.code === 'string' && VERIFY_LOG_CODES.has(d.code)) {
const said = explainLog(d.code, message, d)
if (said) {
const tone = level === 'error' ? 'error' : level === 'warn' || said.level === 'warn' ? 'warn' : 'info'
push({
id: `lg-${seq}`, seq, kind: 'note', nodeId, status: 'done', code: d.code, ...said, level: tone,
...(message ? { detail: message } : {}),
}, nodeId)
break
}
}
// 其余 info 是给排查用的,不进主流程——但 warn/error 用户必须看到
if (level === 'info' || !message) break
const code = typeof d.code === 'string' && d.code ? d.code : undefined
+137
View File
@@ -3480,3 +3480,140 @@ export interface RunComparison {
/** 运行接口回的运行带着发起它的定时任务(RunOut.schedule_id);Run 上没写这一项,对比页的运行卡要用 */
export type RunWithSchedule = Run & { schedule_id?: string | null }
// ---------------------------------------------------------------------------
// 双盲复算:审批 payload、完整对照工件、节点配置,以及设置里「核查」一组(期 3 前后端共用的约定)
// ---------------------------------------------------------------------------
/** 审核人对一个指标的裁决:a 采用原流程的结果,b 采用盲算的结果,neither 两边都不对 */
export type DualCheckChoice = 'a' | 'b' | 'neither'
/** 双盲复算挂起时 payload.summary 的一项:审批卡先用它画,完整对照另从 card_artifact 取 */
export interface DualCheckSummaryItem {
metric_id: string
name: string
unit?: string | null
original_rendered: string | null
blind_rendered: string | null
/** (盲算值 − 原流程值) ÷ |原流程值| × 100,保留两位小数(12.5 表示相差 12.5%);原流程值为 0 时为 null */
delta_pct: number | null
/** 具体差异,一条一句中文,最多 3 条 */
diff: string[]
/** 只有兜底说明:两条 SQL 的写法不同,未能归纳成具体差异 */
fallback: boolean
/**
* 约定之外、服务端另带的:采用原流程(a)、采用盲算(b)各会往数据目录里写什么。老服务端没有这一项,卡上按差异的
* 形状有条件地说
*/
catalog?: { a: DualCheckCatalogSide; b: DualCheckCatalogSide }
}
/**
* 采用一边的结果会往数据目录里写什么:服务端按发起审批时的数据目录预告(dual_check_facts.preview,和裁决后真写的
* 是同一套判断)。write 为真时 table 的 item(business_date / valid_filter / dedup)会记为 value(有效记录条件是和
* 目录里已有的条件合起来之后的整句);为假时 reason 说明为什么不写
*/
export type DualCheckCatalogSide =
| { write: true; source?: string | null; table: string; item: string; value: unknown }
| { write: false; reason?: string | null }
/** payload.kind 为 dual_check 的审批 payload(审批的 mode 是 input) */
export interface DualCheckPayload {
kind: 'dual_check'
node_id: string
title: string
/** 完整对照放在这个工件里(GET /api/artifacts/{id}),内容见 DualCheckCard */
card_artifact: string
summary: DualCheckSummaryItem[]
/** 约定之外、服务端另带的:节点实际生效的「未采用原流程时」。没有时卡上从运行的图快照里读 */
on_reject?: 'degrade' | 'fail'
}
/** 审核人的回复:POST /approvals/{id}/decide 的 value */
export interface DualCheckReply {
choices: Record<string, DualCheckChoice>
note: string
}
/** 指标的一个输入:取自哪条查询的哪一格(alias 是查询快照的编号) */
export interface DualCheckInput {
path: string
value: unknown
sql?: string | null
source?: string | null
alias?: string | null
}
/** 盲算那一边的输入多一段盲算模型自己的说明 */
export interface DualCheckBlindInput extends DualCheckInput {
rationale?: string | null
}
export interface DualCheckSide<I extends DualCheckInput = DualCheckInput> {
value: number | null
rendered: string | null
inputs: I[]
}
/** 两条 SQL 差在哪(服务端 sql_semantics.explain_sql_diff)。aspect 为 other 的是兜底说明,parse 是解析不了 */
export interface DualCheckDiff {
equivalent: boolean
items: { aspect: string; text: string }[]
/** 用了兜底说明(一条具体说法都没有,或者具体说法只覆盖了一部分) */
fallback: boolean
/** 规范化之后两条 SQL 不同的行:「- 」开头是原流程的,「+ 」开头是盲算的 */
text_diff: string[]
}
export interface DualCheckMetric {
metric_id: string
name: string
unit?: string | null
decimals?: number | null
original: DualCheckSide
blind: DualCheckSide<DualCheckBlindInput>
diff: DualCheckDiff
/** 相差比例,口径同 DualCheckSummaryItem.delta_pct(有的话) */
delta_pct?: number | null
}
/** card_artifact 工件的内容 */
export interface DualCheckCard {
metrics: DualCheckMetric[]
/** 盲算用的模型 */
model?: string | null
/** 盲算模型和原流程的模型相同 */
same_model?: boolean
}
/** 双盲复算节点的配置。留空的项:card 取上游的口径卡,metrics 取输入来自查询的全部指标,其余跟随设置 */
export interface DualCheckConfig {
card?: string
metrics?: string[]
definitions?: Record<string, string>
provider?: string
model?: string
tolerance?: number
on_reject?: 'degrade' | 'fail'
/** null 或不写:跟随设置 */
enabled?: boolean | null
}
/** 设置里的 verify 一组(GET /settings 给的是补齐默认之后的完整一组;上限为 null 表示不限) */
export interface VerifySettings {
provider: string | null
model: string | null
dual_check: { enabled: boolean; tolerance: number; max_queries: number | null; timeout_s: number | null }
challenge: { enabled: boolean; max_claims: number | null; max_queries: number | null; timeout_s: number | null }
inquiry: { enabled: boolean; max_queries: number | null; timeout_s: number | null }
daily_max_usd: number | null
}
/** GET /settings/verify/spend:今天(本地日期)核查花了多少。unpriced_calls 是估不出金额、没算进 usd 的调用次数 */
export interface VerifySpend {
date?: string
usd?: number
calls?: number
unpriced_calls?: number
daily_max_usd?: number | null
}
+1 -1
View File
@@ -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: 1, inquiry: 1, bundle: 16, whatif: 33,
schedules: 60, compare: 45, verify: 71, inquiry: 1, bundle: 16, whatif: 33,
}
// 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段
// 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。
+163
View File
@@ -2018,5 +2018,168 @@ await section('并行查询配对(A1)、查询的 SQL 检查(tool.end.chec
check('……下一步说改来源查询的 SQL', !!m?.next?.includes('来源查询的 SQL'), m?.next)
})
await section('双盲复算与反方审稿的日志(期 3):按 code 和结构化字段说人话,info 也上时间线,老日志缺字段不崩', async () => {
// 期 3 前后端共用的约定:后端发 log 时带 code 和结构化字段,时间线按 code 解读、不从原话里认。原话照旧留在展开区。
// 一致、裁决、记入数据目录这几条后端发的是 info:不上时间线的话,双盲复算节点就是一个跑了几十秒、什么都没说的黑盒
const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 1790000000 + seq, data })
const CARD = 'c0ffee'.repeat(10) + 'beef'
const DONE = 'facade'.repeat(10) + 'f00d'
const events = [
E(1, 'run.started', null, { total: 5 }),
E(2, 'node.started', 'dc', { node_type: 'dual_check', label: '复核销售额' }),
E(3, 'log', 'dc', { level: 'warn', code: 'dual_check_same_model', model: 'mx-1', message: '盲算模型与原流程相同(mx-1)' }),
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 个指标与盲算不一致,已暂停等待审核' }),
// 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: '审核人已裁决' }),
// 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 和结构化字段)照样解读
E(10, 'node.started', 'dc2', { node_type: 'dual_check', label: '复核订单数' }),
E(11, 'log', 'dc2', { level: 'info', code: 'dual_check_skipped', reason: 'disabled' }),
E(12, 'node.finished', 'dc2', { duration_ms: 5 }),
E(13, 'node.started', 'dc3', { node_type: 'dual_check', label: '复核退款' }),
E(14, 'log', 'dc3', { level: 'info', code: 'dual_check_skipped', reason: 'no_inputs', message: '没有可复算的指标' }),
E(15, 'node.finished', 'dc3', { duration_ms: 5 }),
E(16, 'node.started', 'ch', { node_type: 'challenge', label: '反方审稿' }),
E(17, 'log', 'ch', { level: 'warn', code: 'challenge_same_model', model: 'wm-2', message: '审稿模型与撰写模型相同' }),
E(18, 'log', 'ch', { level: 'warn', code: 'challenge_limit', limit: 'timeout_s', claims_skipped: 2, message: '已达时长上限' }),
E(19, 'log', 'ch', { level: 'warn', code: 'challenge_refuted', message: '2 句结论被推翻',
items: [{ unit: 'u3', text: '增长主要来自新客' }, { unit: 'u5', text: '退款率下降' }] }),
E(20, 'log', 'ch', { level: 'info', code: 'challenge_done', counts: { stands: 3, weakened: 1, refuted: 2, inconclusive: 0 },
artifact: DONE, message: '反方审稿完成' }),
E(21, 'node.finished', 'ch', { duration_ms: 9000 }),
E(22, 'node.started', 'ch2', { node_type: 'challenge', label: '反方审稿二' }),
E(23, 'log', 'ch2', { level: 'info', code: 'challenge_skipped', reason: 'disabled', message: '反方审稿已关闭' }),
E(24, 'node.finished', 'ch2', { duration_ms: 5 }),
E(25, 'run.finished', null, { status: 'succeeded' }),
]
const all = flatten(mod.decodeRun(events))
const by = (code, node) => all.find((s) => s.code === code && (!node || s.nodeId === node))
const CODES = ['dual_check_skipped', 'dual_check_same_model', 'dual_check_agreed', 'dual_check_disagreed', 'dual_check_resolved',
'dual_check_catalog', 'dual_check_limit', 'challenge_skipped', 'challenge_same_model', 'challenge_done', 'challenge_refuted', 'challenge_limit']
check('约定里的 12 个日志代码各有一行(info 的也在)', CODES.every((c) => by(c)), CODES.filter((c) => !by(c)).join('、'))
check('……都挂在自己的节点下,不是平铺', CODES.every((c) => all.some((s) => s.kind === 'node' && s.children?.some((x) => x.code === c))))
check('……标题里没有内部代码', !all.some((s) => /dual_check_|challenge_/.test(`${s.title}${s.sub ?? ''}${s.next ?? ''}`)))
check('……有原话的,原话留在展开区', by('dual_check_agreed')?.detail === '2 个指标与盲算一致'
&& by('challenge_done')?.detail === '反方审稿完成', `${by('dual_check_agreed')?.detail} / ${by('challenge_done')?.detail}`)
const same = by('dual_check_same_model')
check('dual_check_same_model:点名模型、缺乏独立性、提醒色,入口落到节点的盲算模型', same?.title === '盲算模型与原流程同为「mx-1」,复算缺乏独立性'
&& same.level === 'warn' && !!same.next?.includes('盲算模型') && same.fix === 'canvas' && same.fixField === 'model',
`${same?.title} / ${same?.level} / ${same?.fixField}`)
const agreed = by('dual_check_agreed')
check('dual_check_agreed:几个指标相符,不用提醒色', agreed?.title === '双盲复算一致:2 个指标与盲算结果相符' && agreed.level === 'info',
`${agreed?.title} / ${agreed?.level}`)
const dis = by('dual_check_disagreed')
check('dual_check_disagreed:几个指标不一致、等待裁决,副标题点名(没名字的写 id)', dis?.title === '双盲复算有 2 个指标与盲算结果不一致,等待审核人裁决'
&& 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:哪个指标(给的是 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 === '已将 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}`)
const none = by('dual_check_skipped', 'dc3')
check('dual_check_skipped(no_inputs):没有可复算的指标是提醒,入口落到「复算的指标」', none?.title === '没有输入来自查询的指标,已跳过复算'
&& none.level === 'warn' && none.fix === 'canvas' && none.fixField === 'metrics', `${none?.title} / ${none?.level} / ${none?.fixField}`)
const csame = by('challenge_same_model')
check('challenge_same_model:点名模型、缺乏独立性', csame?.title === '反方审稿模型与报告撰写模型同为「wm-2」,审稿缺乏独立性'
&& csame.level === 'warn' && !!csame.next, csame?.title)
const climit = by('challenge_limit')
check('challenge_limit:哪个上限、几句没审、去哪儿调', climit?.title === '反方审稿已达时长上限,2 句结论未审' && climit.level === 'warn'
&& !!climit.next?.includes('反方审稿'), `${climit?.title} / ${climit?.next}`)
const ref = by('challenge_refuted')
check('challenge_refuted:推翻了几句,句子放副标题', ref?.title === '反方审稿推翻了 2 句结论' && ref.sub === '「增长主要来自新客」「退款率下降」'
&& ref.level === 'warn' && !!ref.next, `${ref?.title} / ${ref?.sub}`)
const done = by('challenge_done')
check('challenge_done:审了几句、四种结论各几句(为 0 的不写),有推翻时是提醒、工件可下钻', done?.title === '反方审稿完成:审阅了 6 句结论'
&& done.sub === '经受住 3 · 被削弱 1 · 被推翻 2' && done.level === 'warn' && done.artifact === DONE, `${done?.title} / ${done?.sub} / ${done?.level}`)
const coff = by('challenge_skipped')
check('challenge_skipped(disabled):未启用、怎么开启', coff?.title === '反方审稿未启用,已跳过' && coff.level === 'info' && !!coff.next,
`${coff?.title} / ${coff?.level}`)
check('……节点照常完成', all.filter((s) => s.kind === 'node').every((s) => s.status === 'done'))
// 换一种取值:每日金额上限、选择全是原流程、缺口是一份清单、推翻 0 句、跳过原因是一句中文
const alt = flatten(mod.decodeRun([
E(1, 'node.started', 'dc', { node_type: 'dual_check' }),
E(2, 'log', 'dc', { level: 'warn', code: 'dual_check_limit', limit: 'daily_max_usd', message: '今日核查费用已达上限' }),
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, '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('今日核查费用'),
`${daily?.title} / ${daily?.next}`)
const [allA, oneB] = alt.filter((s) => s.code === 'dual_check_resolved')
check('全部采用原流程:没有缺口,不用提醒色', allA?.title === '审核人已裁决:采用原流程 2 个' && allA.sub === '没有缺口' && allA.level === 'info',
`${allA?.title} / ${allA?.sub} / ${allA?.level}`)
check('缺口给的是一份清单:照样数出几项', oneB?.title === '审核人已裁决:采用盲算 1 个' && oneB.sub === '1 项记为缺口' && oneB.level === 'warn',
`${oneB?.title} / ${oneB?.sub}`)
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, 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 = [
['dual_check_skipped', {}], ['dual_check_same_model', { model: 7 }], ['dual_check_agreed', { metrics: 'x' }],
['dual_check_disagreed', { metrics: 'x', card_artifact: 3 }], ['dual_check_resolved', { choices: null, gaps: 'x' }],
['dual_check_catalog', { written: 'x', skipped: { reason: 1 } }], ['dual_check_limit', { limit: 9 }],
['challenge_skipped', { reason: ['x'] }], ['challenge_same_model', {}], ['challenge_done', { counts: [1, 2] }],
['challenge_refuted', { items: {} }], ['challenge_limit', { claims_skipped: 'x' }],
]
let broken = null
let steps = []
try {
steps = flatten(mod.decodeRun([E(1, 'node.started', 'x', { node_type: 'dual_check' }),
...odd.map(([code, data], i) => E(i + 2, 'log', 'x', { level: 'warn', code, ...data })), E(99, 'node.finished', 'x', {})]))
} catch (e) {
broken = String(e?.message ?? e)
}
check('缺字段的老日志:不抛异常', broken === null, broken ?? '')
const rows = odd.map(([code]) => steps.find((s) => s.code === code))
check('……每个代码照样一行,标题不空', rows.every((s) => s && s.title), odd.filter((_, i) => !rows[i]?.title).map(([c]) => c).join('、'))
check('……不出 undefined、NaN、[object Object],也不瞎填数', !rows.some((s) => /undefined|NaN|object Object|\d/.test(`${s?.title}${s?.sub ?? ''}`)),
rows.filter((s) => /undefined|NaN|object Object|\d/.test(`${s?.title}${s?.sub ?? ''}`)).map((s) => `${s?.title}|${s?.sub}`).join(' ; '))
check('……缺字段的工件 id 不当成可下钻的工件', !rows.some((s) => s?.artifact != null))
})
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 解码器全部通过')
process.exit(failed ? 1 : 0)
+1281 -6
View File
File diff suppressed because it is too large Load Diff