onNext?: () => void
onList?: () => void
+ /** 出具档位:给了、又不是完整出具时,数字都有出处也不用绿色 */
+ tier?: string
}) {
const suspect = counts.suspect ?? 0
const claims = counts.claims
@@ -532,7 +540,8 @@ export function EvidenceTally({ counts, onNext, onList }: {
return (
{numbers && (
-
+
{numbers}
)}
diff --git a/frontend/src/run/EvidencePanel.tsx b/frontend/src/run/EvidencePanel.tsx
index 4a5ff4ff..00a35156 100644
--- a/frontend/src/run/EvidencePanel.tsx
+++ b/frontend/src/run/EvidencePanel.tsx
@@ -18,7 +18,7 @@ import {
} from '../lib/evidence'
import { humanizeError } from '../lib/errors'
import { formatDateTime, formatNumber, NONE, shortId } from '../lib/format'
-import { EVIDENCE_TEXT, JUDGE_TEXT, MERGE_TEXT, NODE_TYPE_LABEL, SQL_CHECK_TEXT, nodeTypeLabel } from '../lib/terms'
+import { EVIDENCE_TEXT, JUDGE_TEXT, MERGE_TEXT, NODE_TYPE_LABEL, RUN_SQL_CHECK_TEXT, SQL_CHECK_TEXT, nodeTypeLabel } from '../lib/terms'
import { useCatalog } from '../store/catalog'
import { askKey, segmentKey, useEvidence, useExplore, useVerdicts } from '../store/evidence'
import type {
@@ -1371,6 +1371,13 @@ function QueryPart({ id, step, seal, masked }: { id: string; step: EvidenceStep;
step.sql ?? ''} />
)}
+ {!!step.catalog?.length && (
+ // SQL 检查对照的是哪一版数据目录(查询当时冻结进表结构快照的那份):事后目录改了、升了版本,看得出这次按的是哪一版
+
+ {RUN_SQL_CHECK_TEXT.catalogLine(step.catalog.map((c) => ({ name: c.label || c.table, version: c.version })))}
+
+ )}
{step.merge && }
{step.sql && step.merge && {MERGE_TEXT.sql}
}
{step.sql && (
@@ -1471,6 +1478,13 @@ function MergeInputs({ merge }: { merge: EvidenceMerge }) {
.filter(Boolean).join(' · ')}
{host.onNode && i.node_id && }
+ {!!i.checks?.length && (
+ // 输入查询自己的 SQL 检查:被引用的格追不到逐格来历时,下面没有这个输入的查询步骤,问题只能在这里看到
+
+
{RUN_SQL_CHECK_TEXT.mergeInputChecks(i.alias)}
+
+
+ )}
))}
diff --git a/frontend/src/run/__tests__/synthetic.ts b/frontend/src/run/__tests__/synthetic.ts
index ab4cc518..3177ed1f 100644
--- a/frontend/src/run/__tests__/synthetic.ts
+++ b/frontend/src/run/__tests__/synthetic.ts
@@ -188,6 +188,29 @@ export const CALIBERS_OUTPUT = {
},
}
+/**
+ * 数字都有出处,却因为算数的查询没通过 SQL 检查而降档(engine/nodes/io.py 的 sql_checks):横幅单列一块说明,
+ * 那一句不在其余缺口里重复;「都有出处」不画成绿色
+ */
+export const SQL_GAP = `查询「订单金额查询」(Q1)未通过 SQL 检查(${'「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会重复计算'}),结果不可靠;受影响的引用:指标「订单金额」、Q1 第 1 行「gmv」`
+export const SQL_DEGRADED_OUTPUT = {
+ answer: '上周订单金额 **119,160** 元。',
+ _issuance: {
+ tier: 'degraded',
+ calibers: [{ node: 'k1', caliber: '订单口径', version: 'v2' }],
+ metrics_checked: 1,
+ missing_required: [],
+ missing_expected: [],
+ unmatched_numbers: [],
+ matched_numbers: 1,
+ matched: [{ token: '119,160', metric: 'gmv', caliber: '订单口径 @ v2' }],
+ gaps: [SQL_GAP, '协作团队「分析团队」用完 2 轮仍未完成,交付的是成员最后的回复'],
+ sql_checks: [{ query: '查询「订单金额查询」(Q1)', node_id: 'q_a',
+ problems: ['「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会重复计算'],
+ refs: ['指标「订单金额」', 'Q1 第 1 行「gmv」'], gap: SQL_GAP }],
+ },
+}
+
/** 只有一张口径卡、逐个出处都指向它:直说「都来自这张卡」 */
export const CALIBER_ONE_OUTPUT = {
answer: '上周订单 **128** 单,已付款 **96** 单。',
@@ -365,15 +388,53 @@ export function mergeRun(): RunEvent[] {
truncated: false, duration_ms: 12, query_artifact: 'mq-merge', lineage: true,
warnings: [{ code: 'key_type_mismatch', message: '键类型' }, { code: 'rows_grew', message: '行数' }],
}, 0.02)
- ev('log', 'merge', { level: 'warn', code: 'merge_key_type',
+ // 结构化字段和 engine/merge_query._warnings 一致:界面读字段,不从 message 里解析
+ ev('log', 'merge', { level: 'warn', code: 'merge_key_type', keys: ['s.门店', 'v.门店'],
+ detail: "s.门店 是文本(例如 '01'),v.门店 是数值(例如 1)",
message: "合并键类型不一致:s.门店 是文本(例如 '01'),v.门店 是数值(例如 1)。SQLite 比较时会做隐式转换:文本形式的编号(如 '001'、'01')都会等于数值 1,也可能完全匹配不上。请在源查询中统一类型,或在合并 SQL 中用 CAST 明确转换" })
- ev('log', 'merge', { level: 'warn', code: 'merge_rows_grew',
+ ev('log', 'merge', { level: 'warn', code: 'merge_rows_grew', rows: 8, input: 's', input_rows: 4,
+ duplicates: '「s」中按(门店)有重复的键,例如 01 出现 2 次等 2 组;「v」中按(门店)有重复的键,例如 1 出现 2 次等 2 组',
message: '合并结果有 8 行,多于行数最多的输入「s」(4 行):合并键可能不唯一,同一行被重复匹配。「s」中按(门店)有重复的键,例如 01 出现 2 次等 2 组;「v」中按(门店)有重复的键,例如 1 出现 2 次等 2 组。请检查合并条件是否覆盖了全部键(例如同时按日期和门店),或先在源库里聚合到相同粒度' })
ev('node.finished', 'merge', { duration_ms: 15, attempt: 1 })
ev('run.finished', null, { output: {}, timing: { wall_ms: 400, active_ms: 400, wait_ms: 0 } })
return out
}
+/**
+ * 两个查询节点并行、用同一个数据源工具(独立的调用工具节点不带 call_id):时间线要按节点配对,不能按工具名。
+ * 第一条查询一对多关联后求和,tool.end 带着 SQL 检查结果;口径卡里两个指标取自它,日志按查询归并成一行,
+ * 带着来源查询节点和它的 SQL 那一栏(engine/nodes/metrics._emit_sql_check_logs)
+ */
+export const PARALLEL_SQL_A = 'SELECT SUM(o.total_amount) AS gmv FROM orders o JOIN order_items i ON i.order_id = o.id'
+export const PARALLEL_SQL_B = 'SELECT COUNT(*) AS refunds FROM refunds'
+export const FANOUT_PROBLEM = '「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会重复计算'
+export const FANOUT_CHECK = { code: 'fanout_sum', level: 'error', table: 'orders', column: 'total_amount',
+ relation_id: 'r1a2b3c4d5e6', message: `${FANOUT_PROBLEM}。请先按订单汇总明细再关联` }
+export function parallelQueryRun(): RunEvent[] {
+ const { out, ev } = builder('syn-parallel')
+ ev('run.started', null, { nodes: 4, resumed: false })
+ ev('node.started', 'q_a', { node_type: 'tool', label: '订单金额查询' })
+ ev('node.started', 'q_b', { node_type: 'tool', label: '退款查询' })
+ ev('tool.start', 'q_a', { tool: 'db_query__shop', args: { sql: PARALLEL_SQL_A } })
+ ev('tool.start', 'q_b', { tool: 'db_query__shop', args: { sql: PARALLEL_SQL_B } })
+ // 先开始的那条先结束:按工具名配对时,它会配到后开始的那一行上
+ ev('tool.end', 'q_a', { tool: 'db_query__shop', duration_ms: 40, artifact: 'ts-q_a', query_artifact: 'qs-q_a',
+ preview: JSON.stringify({ columns: ['gmv'], rows: [[119160]], row_count: 1, truncated: false, sql: PARALLEL_SQL_A, source: 'shop' }),
+ checks: [FANOUT_CHECK] }, 0.04)
+ ev('tool.end', 'q_b', { tool: 'db_query__shop', duration_ms: 30, artifact: 'ts-q_b', query_artifact: 'qs-q_b',
+ preview: JSON.stringify({ columns: ['refunds'], rows: [[7]], row_count: 1, truncated: false, sql: PARALLEL_SQL_B, source: 'shop' }) }, 0.03)
+ ev('node.finished', 'q_a', { duration_ms: 45, attempt: 1 })
+ ev('node.finished', 'q_b', { duration_ms: 35, attempt: 1 })
+ ev('node.started', 'card', { node_type: 'metrics', label: '订单口径' })
+ ev('log', 'card', { level: 'warn', code: 'metric_sql_check', metric: 'gmv', metrics: ['gmv', 'aov'],
+ metric_names: ['订单金额', '客单价'], reason: `所依据的查询未通过 SQL 检查(${FANOUT_PROBLEM}),结果不可靠`,
+ problems: [FANOUT_PROBLEM], checks: [FANOUT_CHECK], query_artifact: 'qs-q_a', source_node: 'q_a', source_field: 'args.sql',
+ message: `指标「订单金额」「客单价」所依据的查询未通过 SQL 检查(${FANOUT_PROBLEM}),结果不可靠` })
+ ev('node.finished', 'card', { duration_ms: 3, attempt: 1 })
+ ev('run.finished', null, { output: {}, timing: { wall_ms: 300, active_ms: 300, wait_ms: 0 } })
+ return out
+}
+
/** 结构化校验:原文里没有数,修复两次都编出了 total_count=0,两次都作废,判失败 */
export function repairRun(): RunEvent[] {
const { out, ev } = builder('syn-repair')
diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts
index 3c697991..cd668956 100644
--- a/frontend/src/run/decode.ts
+++ b/frontend/src/run/decode.ts
@@ -2,12 +2,13 @@ import type { CatalogDriftTable, RunEvent, SqlCheckItem, TeamMember, TeamRound,
import type { NodeState, RunPhase } from './trace'
import { formatDuration, formatNumber } from '../lib/format'
import {
- CATALOG_DRIFT_TEXT, CATALOG_PATCH_TEXT, JUDGE_TEXT, MERGE_TEXT, SCHEMA_PARTIAL_TEXT, SQL_CHECK_TEXT, TYPE_LABEL, claimTally,
+ 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,
+ claimTally,
evidenceTally, issuanceLabel, nodeTypeLabel,
} from '../lib/terms'
import { catalogPatchOf, patchValueText, patchWhere } from './catalogPatch'
import { claimCountsOf, isJudged, statsTally } from '../lib/evidence'
-import { isSqlCheckCode, sortSqlChecks, sqlCheckWhere, sqlRuleLabel } from '../lib/sqlcheck'
+import { isSqlCheckCode, sortSqlChecks, sqlCheckOf, sqlChecksOf, sqlCheckWhere, sqlRuleLabel } from '../lib/sqlcheck'
// 泳道数据画布也要用(supervisor 节点要展开成协作矩阵),所以类型放在
// types.ts 里;这里再导出一遍,老引用不用改
@@ -152,6 +153,13 @@ export interface Step {
tier?: string
/** 合并查询那一行(merge.end):合并了哪几个输入、几条警告。SQL 在 detail,预览在 result */
merge?: MergeStep
+ /** 对照数据目录的 SQL 检查结果:查询那一行(tool.end.checks)、指标所依据的查询没通过检查那一行(log.checks) */
+ checks?: SqlCheckItem[]
+ /** fix 要去的不是这一行所在的节点(指标的问题出在来源查询节点的 SQL 上):去哪个节点、落到哪一栏 */
+ fixNode?: string
+ fixField?: string
+ /** fixNode 在画布上的名字(入口写「打开「取数」的 SQL」) */
+ fixLabel?: string
}
/** 合并查询的一个输入:合并 SQL 里的别名、上游节点、行数、数据源 */
@@ -441,8 +449,10 @@ export interface ResultTable {
rows: unknown[][]
/** 查询本身撞了行数上限——数据库里还有更多,是 guard 没让它全取回来 */
truncated: boolean
- /** 这份预览被按字符数切断了——取回来的行数比这里显示的多,只是没存下来 */
+ /** 这份预览被按字符数切断了,或者本来就只存了前几行——实际的行数比这里显示的多,只是没存下来 */
clipped?: boolean
+ /** 实际一共几行(结果里的 row_count)。比 rows 多时,表格脚注写「显示前 5 / 6 行」 */
+ total?: number
}
/**
@@ -477,7 +487,9 @@ function tryTable(text: string): ResultTable | null {
try {
const data = JSON.parse(text)
if (Array.isArray(data?.columns) && Array.isArray(data?.rows)) {
- return { columns: data.columns, rows: data.rows, truncated: !!data.truncated }
+ const total = typeof data.row_count === 'number' && data.row_count > data.rows.length ? data.row_count : undefined
+ return { columns: data.columns, rows: data.rows, truncated: !!data.truncated,
+ ...(data.clipped === true || total != null ? { clipped: true } : {}), ...(total != null ? { total } : {}) }
}
} catch {
/* 交给调用方决定要不要修 */
@@ -704,7 +716,10 @@ function gateTitle(tool: string, verdict: 'allow' | 'escalate' | 'team', reason:
* 贴出来是一串标记和术语,而且不说该去哪儿改——这里说成人话,并给出下一步。
* 认不得的 code 返回 null,照原话显示
*/
-function explainLog(code: string | undefined, message: string): Pick | null {
+function explainLog(code: string | undefined, message: string, d: Record = {},
+): Pick | 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) {
case 'tool_markup_leak': {
// 「数据查询把工具调用写成了文字(<||DSML||…),没有真正调用工具,已提醒它重试一次」
@@ -770,28 +785,34 @@ function explainLog(code: string | undefined, message: string): Pick x[1])
+ // 结构化字段(engine/merge_query._warnings):keys(两边的列)、detail(「s.门店 是文本(例如 '01'),v.门店 是数值(例如 1)」)
+ const keys = strs(d.keys)
return {
title: keys.length === 2 ? `合并键类型不一致:${keys[0]} 与 ${keys[1]}` : '合并键类型不一致',
- sub: m?.[1],
+ sub: str(d.detail),
next: '两边类型不同时,SQLite 按隐式转换比较,可能错配或完全匹配不上。请在源查询中统一类型,或在合并 SQL 中用 CAST 明确转换',
fix: 'canvas',
}
}
case 'merge_rows_grew': {
- // 「合并结果有 8 行,多于行数最多的输入「s」(4 行):合并键可能不唯一,同一行被重复匹配。…」
- const head = message.match(/^合并结果有\s*([\d,]+)\s*行,多于行数最多的输入「(.+?)」(([\d,]+)\s*行)/)
- const dup = message.match(/重复匹配。(.+?)。请检查/)?.[1]
+ // 结构化字段:rows(合并结果行数)、input / input_rows(行数最多的输入)、duplicates(各输入里重复的键)
+ const rows = num(d.rows)
+ const input = str(d.input)
+ const inputRows = num(d.input_rows)
return {
title: '合并结果行数多于输入,合并键可能不唯一',
- sub: [head ? `合并结果 ${head[1]} 行,行数最多的输入「${head[2]}」${head[3]} 行` : '', dup ?? '']
- .filter(Boolean).join(';') || undefined,
+ sub: [rows != null && input && inputRows != null
+ ? `合并结果 ${formatNumber(rows)} 行,行数最多的输入「${input}」${formatNumber(inputRows)} 行` : '',
+ str(d.duplicates) ?? ''].filter(Boolean).join(';') || undefined,
next: '请检查合并条件是否覆盖了全部合并键(例如同时按日期和门店),或先在源库里聚合到相同粒度',
fix: 'canvas',
}
}
case 'merge_truncated': {
- const rows = message.match(/只保留了前\s*([\d,]+)\s*行/)?.[1]
+ const rows = num(d.rows)
return {
- title: `合并结果超过上限,${rows ? `只保留了前 ${rows} 行` : '已截断'}`,
+ title: `合并结果超过上限,${rows != null ? `只保留了前 ${formatNumber(rows)} 行` : '已截断'}`,
next: '下游拿到的不是完整结果。请在合并 SQL 中聚合或加条件缩小范围',
fix: 'canvas',
}
@@ -895,6 +916,12 @@ function explainLog(code: string | undefined, message: string): Pick, seq?: number): string {
+ if (d.call_id) return `call:${String(d.call_id)}`
+ return `${nodeId ?? '_'}:${String(d.tool || (seq ?? ''))}`
+}
+
function toolStep(seq: number, tool: string, args: Record): Step {
const base = { id: `tool-${seq}`, seq, status: 'running' as StepStatus }
// 数据库工具单独认:它是这个产品最主要的取数方式,"调用 db_query__warehouse"
@@ -1629,15 +1656,16 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt
// 协作成员调的工具说清是谁调的
if (d.agent) step.sub = `${d.agent} 调用`
// call_id 才是可靠的配对键:同一节点并发调同名工具时,按名字配会错位。
- // 独立 Tool 节点不带 call_id,回退到工具名。
- pendingTools.set(String(d.call_id || d.tool || seq), step)
+ // 独立 Tool 节点不带 call_id,回退到「节点 + 工具名」:两个并行的查询节点用同一个工具时,
+ // 只按工具名配,后开始的那条会顶掉先开始的,结果配错节点
+ pendingTools.set(toolKey(nodeId, d, seq), step)
push(step, nodeId)
break
}
case 'tool.end':
case 'tool.error': {
- const key = String(d.call_id || d.tool || '')
+ const key = toolKey(nodeId, d)
const step = pendingTools.get(key)
const preview = String(d.preview ?? d.error ?? '')
if (step) {
@@ -1662,6 +1690,16 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt
step.meta = parts.join(' · ') || undefined
step.artifact = d.artifact
if (typeof d.detail === 'string' && d.detail) step.raw = d.detail
+ // 这次查询对照数据目录的 SQL 检查(tool.end.checks):preview 只是结果的前一截,检查结果通常被截掉,单独带着。
+ // 副标题数一下错误、提醒、提示,展开看每一条
+ const checks = sqlChecksOf(d.checks)
+ if (checks.length && !failed) {
+ step.checks = sortSqlChecks(checks)
+ const n = (lv: string) => checks.filter((c) => c.level === lv).length
+ const counts = SQL_CHECK_TEXT.assistantTitle(SQL_CHECK_TEXT.counts(n('error'), n('warning'), n('info')))
+ step.sub = step.sub ? `${step.sub} · ${counts}` : counts
+ if (n('error') || n('warning')) step.level = 'warn'
+ }
if (step.kind === 'query' || step.kind === 'schema' || failed) {
// 查询保留 SQL 作为 detail,结果另挂;失败时结果就是错误原因
step.detail = failed ? `${step.detail ?? ''}\n\n${preview}`.trim() : step.detail
@@ -2051,7 +2089,7 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt
// 其余 info 是给排查用的,不进主流程——但 warn/error 用户必须看到
if (level === 'info' || !message) break
const code = typeof d.code === 'string' && d.code ? d.code : undefined
- const said = explainLog(code, message)
+ const said = explainLog(code, message, d)
if (code === 'repair_invented') {
// 修复编出了原文没有的值:折进刚才那一行「让模型修复格式」,一件事一行
const repair = repairs.get(nodeId ?? '_')
@@ -2063,12 +2101,14 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt
}
}
if (code === 'team_exhausted') trackTeam(event)
+ // 要改的是另一个节点(来源查询):入口写它的名字,认不出就只写「打开来源查询的 SQL」
+ const fixLabel = said?.fixNode ? nodeSteps.get(said.fixNode)?.title : undefined
push({
id: `lg-${seq}`, seq, kind: 'note', nodeId, status: 'done',
level: level === 'error' ? 'error' : 'warn',
...(code ? { code } : {}),
...(said
- ? { ...said, detail: message }
+ ? { ...said, detail: message, ...(fixLabel ? { fixLabel } : {}) }
: { title: clip(message, 120), ...(message.length > 120 ? { detail: message } : {}) }),
}, nodeId)
break
@@ -2187,8 +2227,11 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt
...(warnings ? { level: 'warn' as const, sub: MERGE_TEXT.warnings(warnings) } : {}),
title: MERGE_TEXT.stepTitle(inputs.map((i) => i.alias)),
detail: typeof d.sql === 'string' && d.sql ? d.sql : undefined,
+ // 预览只存了前几行:那是「此处仅为预览」(clipped),不是「查询已达行数上限」(truncated)。
+ // 合并结果本身超过上限才是 truncated;row_count 记全部行数,表格脚注写「显示前 5 / 6 行」
result: Array.isArray(d.columns)
- ? JSON.stringify({ columns: d.columns, rows: preview, truncated: rows != null && rows > preview.length })
+ ? JSON.stringify({ columns: d.columns, rows: preview, truncated: !!d.truncated,
+ clipped: rows != null && rows > preview.length, ...(rows != null ? { row_count: rows } : {}) })
: undefined,
meta: [rows != null ? MERGE_TEXT.rows(rows) : '', dur(ms) ?? ''].filter(Boolean).join(' · ') || undefined,
ms,
@@ -2472,10 +2515,8 @@ export interface CopilotIssue {
code?: string
/** error / warning / info。老会话、老后端不给 */
level?: 'error' | 'warning' | 'info'
- /** SQL 检查的问题另带:涉及的表和列、SQL 片段 */
- table?: string
- column?: string
- sqlExcerpt?: string
+ /** SQL 检查的问题按检查读出来的那一条(lib/sqlcheck 的 sqlCheckOf:级别、规则、表和列、关系编号、SQL 片段) */
+ sql?: SqlCheckItem
}
/**
@@ -2491,15 +2532,16 @@ function parseIssue(v: unknown, labels?: (id: string) => string | undefined): Co
if (v && typeof v === 'object') {
const o = v as Record
const str = (x: unknown) => (typeof x === 'string' && x ? x : undefined)
+ // SQL 检查和证据面板、运行时间线同一个读法(sqlCheckOf,关系编号也留着)。自查交回模型改的问题老后端不给级别,
+ // 那时交回去的都是错误:没给级别的按错误读
+ const sql = isSqlCheckCode(o.code) ? sqlCheckOf({ ...o, level: o.level ?? 'error' }) : null
return named({
message: String(o.message ?? ''),
...(o.node_id ? { nodeId: String(o.node_id) } : {}),
...(str(o.field) ? { field: str(o.field) } : {}),
...(str(o.code) ? { code: str(o.code) } : {}),
...(o.level === 'error' || o.level === 'warning' || o.level === 'info' ? { level: o.level } : {}),
- ...(str(o.table) ? { table: str(o.table) } : {}),
- ...(str(o.column) ? { column: str(o.column) } : {}),
- ...(str(o.sql_excerpt) ? { sqlExcerpt: str(o.sql_excerpt) } : {}),
+ ...(sql ? { sql } : {}),
})
}
const text = String(v ?? '')
@@ -2512,20 +2554,16 @@ function parseIssue(v: unknown, labels?: (id: string) => string | undefined): Co
* 不露规则编号
*/
export const issueLine = (x: CopilotIssue): string => {
- const where = isSqlCheckCode(x.code) ? sqlCheckWhere({ table: x.table ?? '', column: x.column }) : ''
- const body = isSqlCheckCode(x.code)
- ? `${sqlRuleLabel(x.code)}:${x.message}${where ? `(${SQL_CHECK_TEXT.where} ${where})` : ''}`
+ const where = x.sql ? sqlCheckWhere(x.sql) : ''
+ const body = x.sql
+ ? `${sqlRuleLabel(x.sql.code)}:${x.message}${where ? `(${SQL_CHECK_TEXT.where} ${where})` : ''}`
: x.message
return x.nodeId ? `「${x.label ?? x.nodeId}」${body}` : body
}
/** 一条自查问题按 SQL 检查读(界面上画级别、规则名、表和列、SQL 片段);不是 SQL 检查的返回 null */
export function issueSqlCheck(x: CopilotIssue): SqlCheckItem | null {
- if (!isSqlCheckCode(x.code)) return null
- return {
- code: x.code, level: x.level ?? 'error', message: x.message, table: x.table ?? '',
- ...(x.column ? { column: x.column } : {}), ...(x.sqlExcerpt ? { sql_excerpt: x.sqlExcerpt } : {}),
- }
+ return x.sql ?? null
}
/** final.issues 里对照数据目录的 SQL 检查结果(不挡运行的提醒、提示也在里面);自查没修好、已经列过的错误不再算 */
@@ -2665,26 +2703,40 @@ function contextLine(g: CopilotContextSource): string {
* 标题直说只给了表名,原因放在展开区。认不出的形状返回 null:不出这一行,也不报错。
*/
export function copilotContext(op: CopilotOp): { title: string; sub?: string; detail: string; ms?: number } | null {
- const groups = contextSources(op)
+ // 展开区里按需求挑过表的库排在前面:请求里点名的那个库往往就是它,排在一串小库后面得在内层滚动框里翻才看得到
+ const order: Record = { model: 0, all: 1, fallback: 2 }
+ const groups = contextSources(op).map((g, i) => ({ g, i }))
+ .sort((a, b) => order[a.g.selectedBy] - order[b.g.selectedBy] || a.i - b.i).map((x) => x.g)
if (!groups.length) return null
+ // 两个数分开说:带着全部字段的表(count),和只提供了表名的表(namesOnly:没挑成的库、一张没挑中的库)。
+ // 以前标题只写前者,「参考了 26 张表」看不出另有 206 张只给了表名
const count = groups.reduce((n, g) => n + g.tables.length, 0)
+ const namesOnly = groups.reduce((n, g) => n + namesOnlyOf(g), 0)
const failed = groups.filter((g) => g.selectedBy === 'fallback')
// 有表带着字段(小库,或者现有工作流用到的表)时标题仍说几张,没挑成的事放副标题
+ const who = failed.length && failed.length < groups.length ? `「${failed.map((g) => g.source).join('」「')}」` : ''
const sub = count && failed.length
- ? failed.length === groups.length ? CONTEXT_FALLBACK_TEXT
- : `「${failed.map((g) => g.source).join('」「')}」${CONTEXT_FALLBACK_TEXT}`
- : undefined
+ ? namesOnly ? `${who}${COPILOT_CONTEXT_TEXT.notPicked},${COPILOT_CONTEXT_TEXT.namesOnly(namesOnly)}` : `${who}${CONTEXT_FALLBACK_TEXT}`
+ : count && namesOnly ? COPILOT_CONTEXT_TEXT.namesOnly(namesOnly) : undefined
const reasons = [...new Set(failed.map((g) => g.reason).filter(Boolean))]
// 挑过表才有 elapsed_ms:挑表是生成之前多出来的一次模型调用,没挑成(比如超时)也要让人看到等了多久
const ms = num(op.elapsed_ms)
return {
- title: count ? `参考了 ${count} 张表` : CONTEXT_FALLBACK_TEXT,
+ title: count ? (namesOnly ? COPILOT_CONTEXT_TEXT.withFields(count) : `参考了 ${count} 张表`) : CONTEXT_FALLBACK_TEXT,
...(sub ? { sub } : {}),
detail: [...groups.map(contextLine), ...reasons.map((r) => `原因:${r}`)].join('\n'),
...(ms != null && ms >= 10 ? { ms } : {}),
}
}
+/** 这个库里只提供了表名、没带字段的表有几张(模型看得到的那些:探查截断了的按探查到的算) */
+function namesOnlyOf(g: CopilotContextSource): number {
+ const seen = g.explored ?? g.total
+ if (seen == null) return 0
+ if (g.selectedBy === 'fallback' || (g.selectedBy === 'model' && !g.tables.length)) return Math.max(0, seen - g.tables.length)
+ return 0
+}
+
/** 用了限定范围之外的数据源:自查交回模型改也改不掉时,人要知道是范围的事,不是图写错了 */
const OUT_OF_SCOPE_NEXT = '本轮限定了查询的数据源,但工作流使用了范围之外的数据库。请取消限定后重新提问,或将所需数据库加入查询范围'
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 215e4b66..5e00c98b 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -2108,6 +2108,32 @@ export interface EvidenceMergeInput {
source?: string | null
artifact?: string | null
sealed?: boolean
+ /** 这个输入查询对照数据目录查出的问题(形状同查询步骤的 checks)。没查出问题、快照读不出来时没有这个键 */
+ checks?: SqlCheckItem[]
+}
+
+/**
+ * 出具声明(output._issuance、issuance 事件)里的 sql_checks:没通过 SQL 检查的查询,一条一个。报告页、出具横幅据此
+ * 在显眼处说「因为 SQL 检查没通过而降档」。gap 是 gaps 里对应的那一句(单列之后不在其余缺口里重复)
+ */
+export interface IssuanceSqlCheck {
+ /** 「查询「取数」(Q1)」;给不出时没有 */
+ query?: string
+ node_id?: string
+ /** 每条问题那半句 */
+ problems?: string[]
+ /** 受影响的引用:「指标「订单金额」」「Q1 第 1 行「gmv」」 */
+ refs?: string[]
+ gap?: string
+}
+
+/** 查询用到的一张表、查询当时它的数据目录版本 */
+export interface EvidenceCatalogVersion {
+ /** 表结构里的表名 */
+ table: string
+ /** 目录里的中文名 */
+ label?: string
+ version?: number | null
}
/** 被引用的一格追到了哪个输入的哪一格;追不到时 input 为空,note 说明只有表级来历 */
@@ -2166,6 +2192,10 @@ export interface EvidenceStep extends Omit {
sql_check_reason?: string
/** query:这次查询对照数据目录查出的问题。没查出问题时没有这个键 */
checks?: SqlCheckItem[]
+ /** query:查询当时冻结的表结构快照(工件 id)。合并查询、老快照没有 */
+ schema_artifact?: string
+ /** query:这条查询用到的表在表结构快照里冻结的数据目录版本(SQL 检查对照的那一版)。没有目录时没有这个键 */
+ catalog?: EvidenceCatalogVersion[]
inputs?: EvidenceInput[]
/** 口径卡钉在哪个工作流的哪一版(metric;方案第 5 节的写法,接口实际给在 source 上) */
caliber_from?: EvidenceCaliberSource | null
@@ -2622,6 +2652,11 @@ export interface CatalogRelation {
status: CatalogStatus
note?: string
updated_at?: string
+ /**
+ * 基数是数据剖析用数据核实的(子表一侧数过是否唯一),只由剖析写。外键、命名推断的基数没有这个键。
+ * 一对多关联后重复计算的检查只在它为真、或者关系人工确认过时报「错误」,否则最多是「提醒」
+ */
+ cardinality_checked?: boolean
}
/** 一张表的目录(notes)。字段固定,服务端不认识的一律拒收 */
@@ -2731,8 +2766,13 @@ export interface CatalogDraftRow {
version: number
/** 这张表没有起草(表结构里没有、写入一直冲突) */
error: string | null
- /** 只是模型那部分失败,其余来源照常写入 */
+ /** 只是模型那部分失败,其余来源照常写入。模型给了这张表、却一项可用内容都没有,也记在这里 */
model_error: string | null
+ /**
+ * 模型给了这张表几项可用内容(并入目录之前)。没用模型、模型这部分失败时为 null(老后端没有这个键)。
+ * 大于 0 而新增、更新、删除都是 0,才是「模型给了内容、只是和现有目录一致」
+ */
+ model_items?: number | null
}
/** POST /datasources/{id}/catalog/draft */
@@ -2922,6 +2962,11 @@ export interface CatalogProfileOut {
stopped: CatalogProfileStop | null
tables: CatalogProfileTable[]
total: { added: number; updated: number; removed: number }
+ /**
+ * 不指定表时,挑表途中估过行数、确知是空表而跳过的表(老后端没有这个键;指定了表时是空列表)。
+ * 估行数的查询算在 queries_used 里,不算在各表的 queries 里
+ */
+ empty_tables?: string[]
/** 一张表都没有剖析时的说明 */
note: string | null
}
@@ -2933,7 +2978,7 @@ export interface CatalogProfileOut {
export type SqlCheckCode = 'fanout_sum' | 'stock_summed' | 'join_unconfirmed' | 'ratio_aggregated' | 'missing_valid_filter'
| 'unknown_code' | 'wrong_date_column'
-/** error:依据有确证、结果必然有误;warning:依据有确证、很可能有误;info:依据只是推断 */
+/** error:依据已核实、结果很可能有误;warning:依据有确证、结果可能有误;info:依据只是推断 */
export type SqlCheckLevel = 'error' | 'warning' | 'info'
/** 一条检查结果(证据接口查询步骤里的 checks、助手自查和发布前检查的问题)。交回模型改写用的那句不上界面,这里不收 */
diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs
index 69c59810..c3ef2f87 100644
--- a/scripts/check-decode.mjs
+++ b/scripts/check-decode.mjs
@@ -123,17 +123,29 @@ await section('合并查询', async () => {
check('标题说合并的是哪几个输入', m?.title === '合并 s、v 的查询结果', m?.title)
check('合并 SQL 留在详情里', m?.detail === synthetic.MERGE_SQL)
check('预览按查询结果的形状给出,能画成表格', !!mod.parseQueryResult(m?.result ?? '')?.columns?.includes('到店人数'))
+ // 合并结果 8 行、预览只存了 5 行:这是「此处仅为预览」,不是「查询已达行数上限」(A3)
+ const table = mod.parseQueryResult(m?.result ?? '')
+ check('预览只存了前几行:记为预览(clipped),不记为查询撞了行数上限(truncated),并带上一共几行', table?.truncated === false
+ && table?.clipped === true && table?.total === 8 && table?.rows.length === 5, JSON.stringify({ t: table?.truncated, c: table?.clipped, n: table?.total }))
check('输入、行数、数据源都在', m?.merge?.inputs?.length === 2 && m.merge.inputs[1].source === 'members'
&& m.merge.inputs[0].label === '门店销售' && m.merge.rows === 8)
check('行数和耗时写在行尾', !!m?.meta?.includes('8 行'), m?.meta)
check('工件可下钻到合并结果的快照', m?.artifact === 'mq-merge')
check('有警告时这一行标成警告、说有几条', m?.level === 'warn' && !!m.sub?.includes('2 条警告'), m?.sub)
const key = all.find((s) => s.code === 'merge_key_type')
- check('键类型不一致说成人话:标题点名两列', !!key?.title.includes('s.门店') && key.title.includes('v.门店'), key?.title)
+ check('键类型不一致说成人话:标题点名两列(读 keys 字段)', key?.title === '合并键类型不一致:s.门店 与 v.门店', key?.title)
+ check('……副标题是 detail 字段', key?.sub === "s.门店 是文本(例如 '01'),v.门店 是数值(例如 1)", key?.sub)
check('键类型不一致给出下一步', !!key?.next?.includes('CAST') && key.fix === 'canvas')
const grew = all.find((s) => s.code === 'merge_rows_grew')
- check('行数放大说成人话,并带上行数和重复的键', !!grew?.title.includes('不唯一') && !!grew.sub?.includes('8 行')
- && !!grew.sub?.includes('出现 2 次'), grew?.sub)
+ check('行数放大说成人话,并带上行数和重复的键(读 rows、input、input_rows、duplicates 字段)', !!grew?.title.includes('不唯一')
+ && grew.sub === '合并结果 8 行,行数最多的输入「s」4 行;「s」中按(门店)有重复的键,例如 01 出现 2 次等 2 组;「v」中按(门店)有重复的键,例如 1 出现 2 次等 2 组',
+ grew?.sub)
+ // 老运行的日志没有结构化字段:不从中文里解析,只给标题,原话留在展开区
+ const old = flatten(mod.decodeRun([
+ { seq: 1, type: 'node.started', node_id: 'merge', ts: 1, data: { node_type: 'merge' } },
+ { seq: 2, type: 'log', node_id: 'merge', ts: 2, data: { level: 'warn', code: 'merge_key_type', message: '合并键类型不一致:s.门店 是文本,v.门店 是数值。' } },
+ ])).find((s) => s.code === 'merge_key_type')
+ check('老日志没有结构化字段:只给标题,不解析原话', old?.title === '合并键类型不一致' && !old.sub && !!old.detail?.includes('s.门店'), old?.title)
check('警告原文各占一行,不在合并那一行里重复', all.filter((s) => s.level === 'warn' && s.nodeId === 'merge').length === 3)
check('合并节点照常完成,两次源查询仍是普通查询步骤', all.filter((s) => s.kind === 'query' && s.code !== 'merge').length === 2
&& steps.find((s) => s.nodeId === 'merge')?.status === 'done')
@@ -673,18 +685,30 @@ await section('Copilot:这一轮参考了哪些表(context 操作)', async
const seeded = mod.copilotContext({ op: 'context', sources: [
{ source: 'scenic', tables: ['stores'], selected_by: 'fallback', total: 51, reason: '模型未挑出任何表' }] })
- check('没挑成但现有工作流的表带着字段:标题说几张、副标题说没挑成',
- seeded?.title === '参考了 1 张表' && seeded?.sub === '未能按需求挑选,已提供全部表名'
+ check('没挑成但现有工作流的表带着字段:标题说几张带字段、副标题说没挑成和只给了表名的张数',
+ seeded?.title === '参考了 1 张表的字段' && seeded?.sub === '未能按需求挑选,另有 50 张表只提供了表名'
&& seeded.detail.includes('现有工作流用到的 1 张表附带全部字段:stores'), JSON.stringify(seeded))
const mixed = mod.copilotContext({ op: 'context', sources: [
{ source: 'a', tables: ['x'], selected_by: 'all', total: 1 },
{ source: 'b', tables: [], selected_by: 'fallback', total: 80, reason: 'r' }] })
- check('只有一个库没挑成:副标题点名是哪个库', mixed?.sub === '「b」未能按需求挑选,已提供全部表名', mixed?.sub)
+ check('只有一个库没挑成:副标题点名是哪个库,并写只提供了表名的张数', mixed?.title === '参考了 1 张表的字段'
+ && mixed?.sub === '「b」未能按需求挑选,另有 80 张表只提供了表名', `${mixed?.title} / ${mixed?.sub}`)
const none = mod.copilotContext({ op: 'context', sources: [
{ source: 'a', tables: ['x'], selected_by: 'model', total: 60 },
{ source: 'b', tables: [], selected_by: 'model', total: 70 }] })
- check('挑表成功但某个库一张没挑中:说清只给了表名', !!none?.detail.includes('「b」共 70 张表,未挑中与需求相关的表,已提供全部表名'),
- none?.detail)
+ check('挑表成功但某个库一张没挑中:说清只给了表名', !!none?.detail.includes('「b」共 70 张表,未挑中与需求相关的表,已提供全部表名')
+ && none.title === '参考了 1 张表的字段' && none.sub === '另有 70 张表只提供了表名', `${none?.title} / ${none?.sub} / ${none?.detail}`)
+ // R2 走查(B14):几个小库全给、点名的大库按需求挑、另一个大库没挑成。展开后挑表的库排在最前面,
+ // 两个数分开说:26 张带字段,206 张只给了表名
+ const r2 = mod.copilotContext({ op: 'context', sources: [
+ { source: 'small_a', tables: Array.from({ length: 12 }, (_, i) => `a${i}`), selected_by: 'all', total: 12 },
+ { source: 'small_b', tables: Array.from({ length: 11 }, (_, i) => `b${i}`), selected_by: 'all', total: 11 },
+ { source: 'big', tables: [], selected_by: 'fallback', total: 206, reason: '超时' },
+ { source: 'named', tables: ['visits', 'channels', 'parks'], selected_by: 'model', total: 51 }] })
+ check('展开后按需求挑表的库排在第一行,没挑成的排在最后', r2?.detail.split('\n')[0].startsWith('「named」按需求从 51 张表中挑出 3 张')
+ && r2.detail.split('\n')[3].startsWith('「big」'), r2?.detail.split('\n').map((l) => l.slice(0, 12)).join(' | '))
+ check('两个数分开说:带字段的 26 张、只提供了表名的 206 张', r2?.title === '参考了 26 张表的字段'
+ && r2.sub === '「big」未能按需求挑选,另有 206 张表只提供了表名', `${r2?.title} / ${r2?.sub}`)
// 探查结构截断了(每个数据源最多取 200 张表):total 是数据库里的总数,explored 是探查到的
const partial = mod.copilotContext({ op: 'context', sources: [
{ source: 'wide', tables: ['t001', 't002'], selected_by: 'model', total: 205, explored: 200 },
@@ -1468,16 +1492,17 @@ await section('agent 字段按出处核对(可点击证据第二期):抽
await section('口径卡指标不完整(数据目录阶段 0):截断的查询结果整组拿来算的指标,警告说人话并指到 SQL', async () => {
const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 1790000000 + seq, data })
- // 原话和 backend/app/engine/nodes/metrics.py 的 _incomplete_reason 同一个句式。先核对后端源码还是这么说的
- const INCOMPLETE = '指标「入园人次」基于被截断的查询结果计算(只取回了前 1000 行),结果不完整'
+ // 日志带着结构化字段(metric_name、reason),界面读字段,不从原话里解析。先核对后端还发这两个字段
+ const REASON = '基于被截断的查询结果计算(只取回了前 1000 行),结果不完整'
+ const INCOMPLETE = `指标「入园人次」${REASON}`
const metricsPy = readFileSync(`${root}backend/app/engine/nodes/metrics.py`, 'utf8')
- check('后端发指标不完整警告的格式还是夹具里这一种', metricsPy.includes('code="metric_incomplete"')
- && metricsPy.includes('message=f"指标「{m[\'name\']}」{m[\'incomplete_reason\']}"')
- && metricsPy.includes('f"基于被截断的查询结果计算(只取回了{fetched[0]}),结果不完整"'),
- '改了 metrics.py 的说法就同步改 decode.ts 的 explainLog 和这里的夹具')
+ check('后端发指标不完整警告时带着 metric_name、reason 字段', metricsPy.includes('code="metric_incomplete"')
+ && metricsPy.includes('metric_name=m["name"]') && metricsPy.includes('reason=m["incomplete_reason"]'),
+ '改了 metrics.py 的字段就同步改 decode.ts 的 explainLog 和这里的夹具')
const all = flatten(mod.decodeRun([
E(1, 'node.started', 'card', { node_type: 'metrics' }),
- E(2, 'log', 'card', { level: 'warn', code: 'metric_incomplete', metric: 'visits', message: INCOMPLETE }),
+ E(2, 'log', 'card', { level: 'warn', code: 'metric_incomplete', metric: 'visits', metric_name: '入园人次', reason: REASON,
+ rows: [1000], message: INCOMPLETE }),
E(3, 'node.finished', 'card', { duration_ms: 3 }),
]))
const inc = all.find((s) => s.code === 'metric_incomplete')
@@ -1486,6 +1511,12 @@ await section('口径卡指标不完整(数据目录阶段 0):截断的查
`${inc?.sub} / ${inc?.detail}`)
check('……是提醒(warn),下一步指到 SQL,节点本身照样成功', inc?.level === 'warn' && !!inc?.next?.includes('SQL')
&& all.find((s) => s.kind === 'node')?.status === 'done', `${inc?.level} ${inc?.next}`)
+ const bare = flatten(mod.decodeRun([
+ E(1, 'node.started', 'card', { node_type: 'metrics' }),
+ E(2, 'log', 'card', { level: 'warn', code: 'metric_incomplete', metric: 'visits', message: INCOMPLETE }),
+ ])).find((s) => s.code === 'metric_incomplete')
+ check('老日志没有结构化字段:只给不点名的标题,不解析原话', bare?.title === '指标结果不完整' && !bare.sub && bare.detail === INCOMPLETE,
+ bare?.title)
})
await section('结论句裁判(可点击证据第四期):裁判调用单独成行、几条警告说人话、封存后按需裁判有记录', async () => {
@@ -1765,45 +1796,44 @@ await section('按后端原文匹配的地方:后端现在的原文、整改
await section('SQL 检查(数据目录阶段 4B):指标所依据的查询未通过检查说成一行;助手自查和交付的问题写中文规则名', async () => {
const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 1790000000 + seq, data })
- // 原话和 backend/app/engine/nodes/metrics.py 的 _sql_check_reason 同一个句式。先核对后端源码还是这么说的
+ // 日志按查询归并(同一条查询的问题一行),带着结构化字段:metric_names、problems、checks、source_node、source_field。
+ // 界面读字段,不从原话里解析。先核对后端还发这些字段
const metricsPy = readFileSync(`${root}backend/app/engine/nodes/metrics.py`, 'utf8')
- check('后端发指标未通过 SQL 检查警告的格式还是夹具里这几种', metricsPy.includes('code="metric_sql_check"')
- && metricsPy.includes('message=f"指标「{m[\'name\']}」{m[\'sql_check_reason\']}"')
- && metricsPy.includes('"所依据的查询未通过 SQL 检查,结果不可靠"')
- && metricsPy.includes('f"所依据的查询未通过 SQL 检查({problems[0]}),结果不可靠"')
- && metricsPy.includes('f"所依据的查询有 {len(problems)} 处未通过 SQL 检查({problems[0]}等),结果不可靠"'),
- '改了 metrics.py 的说法就同步改 decode.ts 的 explainLog 和这里的夹具')
+ check('后端发指标未通过 SQL 检查的日志时带着结构化字段', metricsPy.includes('code="metric_sql_check"')
+ && ['metrics=ids', 'metric_names=', 'problems=problem.problems', 'checks=', 'source_node', 'source_field'].every((k) => metricsPy.includes(k)),
+ '改了 metrics.py 的字段就同步改 decode.ts 的 explainLog 和这里的夹具')
const CODES = /fanout_sum|stock_summed|join_unconfirmed|ratio_aggregated|missing_valid_filter|unknown_code|wrong_date_column/
- const decodeOne = (message) => {
+ const decodeOne = (fields) => {
const all = flatten(mod.decodeRun([
E(1, 'node.started', 'card', { node_type: 'metrics' }),
- E(2, 'log', 'card', { level: 'warn', code: 'metric_sql_check', metric: 'aov', message }),
+ E(2, 'log', 'card', { level: 'warn', code: 'metric_sql_check', metric: 'aov', ...fields }),
E(3, 'node.finished', 'card', { duration_ms: 3 }),
]))
return { step: all.find((s) => s.code === 'metric_sql_check'), node: all.find((s) => s.kind === 'node') }
}
- // 问题本身带括号(列的中文名里写了单位):副标题要取到「,结果不可靠」前面最后一个右括号
- const ONE = '指标「客单价」所依据的查询未通过 SQL 检查(「订单」关联「订单明细」是一对多,对「订单」的「订单金额(元)」求和会重复计算),结果不可靠'
- const one = decodeOne(ONE)
+ // 问题本身带括号(列的中文名里写了单位):读字段就不存在截断的问题
+ const P1 = '「订单」关联「订单明细」是一对多,对「订单」的「订单金额(元)」求和会重复计算'
+ const ONE = `指标「客单价」所依据的查询未通过 SQL 检查(${P1}),结果不可靠`
+ const one = decodeOne({ metric_names: ['客单价'], problems: [P1], message: ONE })
check('metric_sql_check 一行:标题点名哪个指标', one.step?.title === '指标「客单价」所依据的查询未通过 SQL 检查', one.step?.title)
- check('……原因放副标题(问题里的括号不截断),原话留在展开区',
- one.step?.sub === '「订单」关联「订单明细」是一对多,对「订单」的「订单金额(元)」求和会重复计算' && one.step?.detail === ONE,
+ check('……原因放副标题(读 problems 字段),原话留在展开区', one.step?.sub === P1 && one.step?.detail === ONE,
`${one.step?.sub} / ${one.step?.detail}`)
check('……是提醒(warn),下一步指到 SQL 和数据目录,节点本身照样成功', one.step?.level === 'warn'
&& !!one.step?.next?.includes('SQL') && !!one.step?.next?.includes('数据目录') && one.step?.fix === 'canvas'
&& one.node?.status === 'done', `${one.step?.level} ${one.step?.next}`)
- const two = decodeOne('指标「客单价」所依据的查询有 2 处未通过 SQL 检查(「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会重复计算等),结果不可靠')
- check('几处问题:标题写处数,副标题是第一处', two.step?.title === '指标「客单价」所依据的查询有 2 处未通过 SQL 检查'
- && two.step?.sub === '「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会重复计算等', `${two.step?.title} / ${two.step?.sub}`)
- const bare = decodeOne('指标「客单价」所依据的查询未通过 SQL 检查,结果不可靠')
- check('没有括号里的问题:只有标题,不编副标题', bare.step?.title === '指标「客单价」所依据的查询未通过 SQL 检查' && !bare.step?.sub,
- `${bare.step?.title} / ${bare.step?.sub}`)
+ const P2 = '「库存」按日期以外的列求和会把存量跨期加总'
+ const two = decodeOne({ metric_names: ['客单价', '订单金额'], problems: [P1, P2], message: 'x' })
+ check('一条查询两处问题、影响两个指标:标题点名两个指标并写处数,副标题列出两处', two.step?.title === '指标「客单价」「订单金额」所依据的查询有 2 处未通过 SQL 检查'
+ && two.step?.sub === `${P1};${P2}`, `${two.step?.title} / ${two.step?.sub}`)
+ const bare = decodeOne({ message: '指标「客单价」所依据的查询未通过 SQL 检查(某个问题),结果不可靠' })
+ check('老日志没有结构化字段:只给不点名的标题,不解析原话', bare.step?.title === '指标所依据的查询未通过 SQL 检查' && !bare.step?.sub
+ && !bare.step?.fixNode, `${bare.step?.title} / ${bare.step?.sub}`)
// 助手自查(check)和交付(final.issues)里的 SQL 检查问题:code 是规则编号,field 是 args.sql,另带表、列和 SQL 片段
const sqlIssue = (level, code, message, extra = {}) => ({ level, node_id: 'q', edge_id: null, field: 'args.sql', code, message,
table: 'orders', ...extra })
const ERR = sqlIssue('error', 'fanout_sum', '「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会重复计算。请先按订单汇总明细再关联',
- { column: 'total_amount', sql_excerpt: 'SUM(o.total_amount)' })
+ { column: 'total_amount', sql_excerpt: 'SUM(o.total_amount)', relation_id: 'r0a1b2c3d4e5' })
const WARN = sqlIssue('warning', 'missing_valid_filter', '「订单」定义了有效记录条件「status <> 9」,查询中没有按它筛选')
const INFO = sqlIssue('info', 'join_unconfirmed', '「订单」与「门店」按推断的关系关联(o.store_id = s.id),这条关系尚未确认', { table: 'stores' })
const ops = [
@@ -1840,6 +1870,8 @@ await section('SQL 检查(数据目录阶段 4B):指标所依据的查询
const asCheck = mod.issueSqlCheck(out.check.issues[0])
check('问题按 SQL 检查读得出级别、表、列、SQL 片段', asCheck?.level === 'error' && asCheck.table === 'orders' && asCheck.column === 'total_amount'
&& asCheck.sql_excerpt === 'SUM(o.total_amount)' && out.check.issues[0].field === 'args.sql', JSON.stringify(asCheck))
+ // 和证据面板、运行时间线同一个读法(lib/sqlcheck 的 sqlCheckOf):关系编号不丢
+ check('……关系编号也留着(以前另写的一份丢了 relation_id)', asCheck?.relation_id === 'r0a1b2c3d4e5', JSON.stringify(asCheck))
check('不是 SQL 检查的问题不按它读', mod.issueSqlCheck({ message: 'x', code: 'tools_dropped' }) === null)
const infoOnly = mod.decodeCopilot([{ op: 'final', graph: { nodes: [{ id: 'q' }] }, issues: [INFO] }], { context: 'canvas' })
.find((s) => s.code === 'sql_check')
@@ -1847,5 +1879,28 @@ await section('SQL 检查(数据目录阶段 4B):指标所依据的查询
`${infoOnly?.level} ${infoOnly?.next}`)
})
+await section('并行查询配对(A1)、查询的 SQL 检查(tool.end.checks)、指标的问题指到来源查询的 SQL(B2)', async () => {
+ const all = flatten(mod.decodeRun(synthetic.parallelQueryRun(), { status: 'succeeded' }))
+ const qa = all.find((s) => s.kind === 'query' && s.nodeId === 'q_a')
+ const qb = all.find((s) => s.kind === 'query' && s.nodeId === 'q_b')
+ check('两个并行的查询节点用同一个工具:各自的结果配回各自的节点', !!qa?.result?.includes('119160') && !!qb?.result?.includes('"refunds"')
+ && !qb.result.includes('119160'), `${qa?.result?.slice(0, 40)} | ${qb?.result?.slice(0, 40)}`)
+ check('……两行都收尾了,没有一直显示进行中', qa?.status === 'done' && qb?.status === 'done', `${qa?.status} ${qb?.status}`)
+ check('……检查结果挂在出问题的那条查询上', qa?.checks?.length === 1 && !qb?.checks, JSON.stringify(qb?.checks))
+ const [c] = qa?.checks ?? []
+ check('查询那一行带着 SQL 检查结果(级别、规则、表和列、关系编号),副标题数一下', c?.code === 'fanout_sum' && c.level === 'error'
+ && c.table === 'orders' && c.column === 'total_amount' && c.relation_id === 'r1a2b3c4d5e6'
+ && qa.sub === 'SQL 检查:1 处错误' && qa.level === 'warn', `${qa?.sub} ${qa?.level}`)
+ const rows = all.filter((s) => s.code === 'metric_sql_check')
+ check('同一条查询影响两个指标:时间线一行', rows.length === 1, String(rows.length))
+ const m = rows[0]
+ check('……标题点名两个指标,副标题是问题', m?.title === '指标「订单金额」「客单价」所依据的查询未通过 SQL 检查'
+ && m.sub === synthetic.FANOUT_PROBLEM, `${m?.title} / ${m?.sub}`)
+ check('……「打开设置」指到来源查询节点的 SQL,不是口径卡', m?.fix === 'canvas' && m.fixNode === 'q_a' && m.fixField === 'args.sql'
+ && m.fixLabel === '订单金额查询' && m.nodeId === 'card', `${m?.fixNode} ${m?.fixField} ${m?.fixLabel}`)
+ check('……检查结果也挂在这一行,展开能看', m?.checks?.length === 1 && m.checks[0].code === 'fanout_sum')
+ check('……下一步说改来源查询的 SQL', !!m?.next?.includes('来源查询的 SQL'), m?.next)
+})
+
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 解码器全部通过')
process.exit(failed ? 1 : 0)
diff --git a/scripts/check-evidence.mjs b/scripts/check-evidence.mjs
index 48e6fb7a..5126e1c0 100644
--- a/scripts/check-evidence.mjs
+++ b/scripts/check-evidence.mjs
@@ -1039,6 +1039,7 @@ await section('incomplete', '截断的查询结果整组算出来的指标:标
await section('sqlcheck', 'SQL 检查(数据目录阶段 4B):查询步骤列出级别、规则名、说明、表和列、SQL 片段;指标未通过时标出来并写明原因', async () => {
await openQ(page, '1,288')
check('没查出问题的查询步骤不出 SQL 检查', await panel(page).locator('[data-ev-sql-checks]').count() === 0)
+ check('没有冻结目录的查询步骤不写数据目录版本', await panel(page).locator('[data-ev-catalog]').count() === 0)
await page.keyboard.press('Escape')
await openQ(page, '45,678.5元', { wait: '[data-ev-expression]' })
check('通过的指标不出「SQL 检查未通过」', await panel(page).locator('[data-ev-sql-check-failed], [data-ev-sql-check-reason]').count() === 0)
@@ -1051,10 +1052,15 @@ await section('sqlcheck', 'SQL 检查(数据目录阶段 4B):查询步骤
{ code: 'missing_valid_filter', level: 'warning', message: '「订单」定义了有效记录条件「status <> 9」,查询中没有按它筛选。', table: 'orders' },
]
const REASON = '所依据的查询未通过 SQL 检查(「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会重复计算),结果不可靠'
+ // 查询步骤带着这次查询对照的数据目录版本(_catalog_of:表结构快照里冻结的那份)
+ const CATALOG = [{ table: 'orders', label: '订单', version: 3 }, { table: 'order_items', version: 1 }]
const bad = await probeQ((id, body) => ({ ...body, chain: (body.chain ?? []).map((st) => (st.step === 'query'
- ? { ...st, checks: CHECKS } : st.step === 'metric' ? { ...st, sql_check_failed: true, sql_check_reason: REASON } : st)) }))
+ ? { ...st, checks: CHECKS, schema_artifact: 'c'.repeat(64), catalog: CATALOG }
+ : st.step === 'metric' ? { ...st, sql_check_failed: true, sql_check_reason: REASON } : st)) }))
await openQ(bad.page, '1,288', { wait: '[data-ev-sql-checks]' })
const p = bad.page.locator('[data-evidence-panel]')
+ const cat = await p.locator('[data-ev-catalog]').innerText().catch(() => '')
+ check('查询步骤写明对照的数据目录版本:有中文名写中文名,没有写表名', cat === '数据目录:订单 第 3 版、order_items 第 1 版', cat)
const items = p.locator('[data-ev-sql-checks] [data-sql-check]')
const levels = await items.evaluateAll((els) => els.map((e) => e.getAttribute('data-level')))
check('三条都列在 SQL 下面,错误在前、提示在后', levels.join(',') === 'error,warning,info', levels.join(','))
@@ -1067,7 +1073,7 @@ await section('sqlcheck', 'SQL 检查(数据目录阶段 4B):查询步骤
const want = [await tokenColor(bad.page, 'color', 'var(--st-failed)'), await tokenColor(bad.page, 'color', 'var(--st-waiting)'),
await tokenColor(bad.page, 'color', 'var(--accent)')]
check('级别的颜色走令牌:错误 --st-failed、提醒 --st-waiting、提示 --accent', tones.join('|') === want.join('|'), tones.join('|'))
- check('悬停写明依据的确证程度', (await items.first().locator('[data-sql-level]').getAttribute('title')) === '依据已确认,结果必然有误')
+ check('悬停写明依据的确证程度', (await items.first().locator('[data-sql-level]').getAttribute('title')) === '依据已核实,结果很可能有误,需要修改')
check('不露规则编号', !/fanout_sum|missing_valid_filter|join_unconfirmed/.test(await p.innerText()))
await bad.page.keyboard.press('Escape')
await openQ(bad.page, '45,678.5元', { wait: '[data-ev-sql-check-failed]' })
@@ -3046,14 +3052,18 @@ if (SHOTS) {
// 推断来源的答复带 merge(经过的每一次合并)。答复按 backend/app/api/evidence.py 的 _merge_view、_query_steps 的形状改
// ---------------------------------------------------------------------------
const MERGE_SQL = 'SELECT o.order_id, o.region, o.amount, t.target FROM o JOIN t ON o.region = t.region ORDER BY o.order_id'
-function asMerge(body, traced, { withInput = true } = {}) {
+// 合并输入自己的 SQL 检查(_merge_view 的 inputs[].checks,形状同查询步骤的 checks)
+const INPUT_CHECKS = [{ code: 'fanout_sum', level: 'error', table: 'orders', column: 'amount',
+ message: '「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会重复计算。请先按订单汇总明细再关联。' }]
+function asMerge(body, traced, { withInput = true, inputChecks = false } = {}) {
const q = body.chain.find((st) => st.step === 'query')
const merged = {
...q, tool: undefined, source: '合并查询', sql: MERGE_SQL,
merge: {
sql: MERGE_SQL,
inputs: [
- { alias: 'o', node_id: 'fetch', label: '订单明细', query: 'Q1', rows: 12, source: 'shop', artifact: 'a'.repeat(64), sealed: true },
+ { alias: 'o', node_id: 'fetch', label: '订单明细', query: 'Q1', rows: 12, source: 'shop', artifact: 'a'.repeat(64), sealed: true,
+ ...(inputChecks ? { checks: INPUT_CHECKS } : {}) },
{ alias: 't', node_id: 'targets', label: '区域目标', query: null, rows: 4, source: 'plan', artifact: 'b'.repeat(64), sealed: true },
],
warnings: [{ code: 'rows_grew', message: '合并结果有 14 行,多于行数最多的输入「o」(12 行):合并键可能不唯一,同一行被重复匹配' }],
@@ -3097,15 +3107,24 @@ await section('merge', '合并查询:表级来历(输入、合并 SQL、警
check('输入步骤标明是哪次合并的输入,高亮追到的那一格', into.includes('合并查询 Q3 的输入')
&& await parts.nth(1).locator('td[data-highlight="cell"]').count() === 1, into)
check('合并查询不是数据源:不说它「已不存在」', await p.locator('[data-ev-query-mask-note]').count() === 0)
+ check('输入没查出问题:合并步骤里不出输入的 SQL 检查', await p.locator('[data-ev-merge-input-checks]').count() === 0)
await traced.ctx.close()
const flat = await probeQ((id, body) => (id === qseg('1,288') ? asMerge(body, [{
cell: [5, 'amount'], input: null, query: null, row: null, column: null, note: '没有逐格来历',
- }], { withInput: false }) : body))
+ }], { withInput: false, inputChecks: true }) : body))
await openQ(flat.page, '1,288')
const none = await flat.page.locator('[data-evidence-panel] [data-ev-merge-trace="none"]').innerText().catch(() => '')
check('追不到的格:写明没有逐格来历、只有表级来历,不接输入步骤', none.includes('没有逐格来历')
&& none.includes('表级来历') && await flat.page.locator('[data-evidence-panel] [data-ev-query]').count() === 1, none)
+ // 没有输入步骤时,输入查询的 SQL 检查结果要在合并步骤里看得到(指标正是因为它标了「存疑」)
+ const inChecks = flat.page.locator('[data-evidence-panel] [data-ev-merge-input-checks="o"]')
+ const inText = await inChecks.innerText().catch(() => '')
+ check('追不到逐格来历时,合并步骤的输入下面列出它自己的 SQL 检查(级别、规则名、说明)', inText.includes('输入 o 的 SQL 检查')
+ && inText.includes('错误') && inText.includes('一对多关联后重复计算') && inText.includes('会重复计算')
+ && await inChecks.locator('[data-sql-check="fanout_sum"][data-level="error"]').count() === 1, inText.replace(/\s+/g, ' '))
+ check('……没查出问题的输入不出', await flat.page.locator('[data-evidence-panel] [data-ev-merge-input-checks="t"]').count() === 0)
+ check('……不露规则编号', !/fanout_sum/.test(await flat.page.locator('[data-evidence-panel] [data-ev-merge]').innerText()))
await flat.ctx.close()
const wideId = pseg('wide')
diff --git a/scripts/check-runs.mjs b/scripts/check-runs.mjs
index fc4e9ef4..16f37a15 100644
--- a/scripts/check-runs.mjs
+++ b/scripts/check-runs.mjs
@@ -1981,6 +1981,37 @@ await section('证据页签:左边报告、右边常驻面板、下方审计
await page.keyboard.press('Escape')
})
+await section('证据页签:降档时报告上方写明档位和原因,SQL 检查没通过的单独说,「都有出处」不画成绿色(B5)', async () => {
+ // 成果的 _issuance 带 sql_checks(engine/nodes/io.py):数字都有出处,降档却是因为算数的查询没通过 SQL 检查
+ const EV_SQL = 'fake0evsqldegrade000000000000'
+ const PROBLEM = '「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会重复计算'
+ const GAP = `查询「取订单」(Q1)未通过 SQL 检查(${PROBLEM}),结果不可靠;受影响的引用:Q1 第 1 行「gmv」`
+ const issuance = { tier: 'degraded', calibers: [], metrics_checked: 0, missing_required: [], missing_expected: [],
+ unmatched_numbers: [], matched_numbers: 10, matched: [], gaps: [GAP],
+ sql_checks: [{ query: '查询「取订单」(Q1)', node_id: 'fetch', problems: [PROBLEM], refs: ['Q1 第 1 行「gmv」'], gap: GAP }] }
+ evFakes(EV_SQL, { output: { ...fxe.output, _issuance: issuance }, graph: fxe.graph, audit: fxe.audit, csv: fxe.audit_csv })
+ await page.route(new RegExp(`/api/runs/${EV_SQL}/evidence/segments/`), (r) => r.fulfill({ status: 404, json: { detail: '没有这个片段' } }))
+ try {
+ await openEvidence(EV_SQL)
+ await page.locator('[data-evidence-report=write] [data-evidence-doc]').waitFor({ timeout: 5000 }).catch(() => {})
+ const banner = page.locator('[data-view-pane=evidence] [data-evidence-issuance] [data-issuance-banner=degraded]')
+ check('报告上方写明出具档位', await banner.count() === 1 && (await banner.innerText().catch(() => '')).includes('降档出具'))
+ const sql = (await banner.locator('[data-issuance-sql-checks]').innerText().catch(() => '')).replace(/\s+/g, ' ')
+ check('……SQL 检查导致的降档在显眼处说明:哪次查询、什么问题、受影响的引用', sql.includes('降档原因:所依据的查询未通过 SQL 检查')
+ && sql.includes('查询「取订单」(Q1)') && sql.includes(PROBLEM) && sql.includes('Q1 第 1 行「gmv」'), sql)
+ check('……那一句不在其余缺口里重复', await banner.locator('[data-issuance-gaps]').count() === 0)
+ const tally = page.locator('[data-evidence-report=write] [data-evidence-tally-numbers]')
+ const [color, done] = await Promise.all([tally.evaluate((el) => getComputedStyle(el).color).catch(() => ''),
+ page.evaluate(() => { const s = document.createElement('span'); s.style.color = 'var(--st-done)'; document.body.append(s)
+ const c = getComputedStyle(s).color; s.remove(); return c })])
+ check('降档时报告上的数字计数不画成绿色', !!color && color !== done, `${color} vs ${done}`)
+ } finally {
+ for (const k of [...fakes.keys()]) if (k.includes(EV_SQL)) fakes.delete(k)
+ }
+ await openEvidence(EV)
+ check('没有出具契约的运行:报告上方不出出具横幅', await page.locator('[data-view-pane=evidence] [data-evidence-issuance]').count() === 0)
+})
+
await section('证据页签:同一段文字里好几条同样的违规、正文里点不开的行', async () => {
// 夹具同样是后端真跑的:粗体、链接里反引号写的可疑名字只记违规、不切片段——同一段文字上两条 unknown_entity、
// 两条 unverified_entity(片段和 code 都相同),列表序号 100. 是结构片段上的裸数字。这几行正文里都点不开
diff --git a/scripts/check-stream.mjs b/scripts/check-stream.mjs
index 3e7ddf01..f4660f41 100644
--- a/scripts/check-stream.mjs
+++ b/scripts/check-stream.mjs
@@ -709,6 +709,10 @@ await section('合并查询:输入、合并 SQL、结果预览,警告各占
check('合并 SQL 有标签、带复制', (await row.innerText()).includes('合并 SQL')
&& await row.getByRole('button', { name: '复制合并 SQL' }).count() === 1)
check('结果预览画成表格(列是合并结果的列)', (await row.locator('table th').allInnerTexts()).some((t) => t.includes('到店人数')))
+ // 合并结果 8 行、预览只存了 5 行:说「此处仅为预览」和「显示前 5 / 8 行」,不说「查询已达行数上限」(A3)
+ const foot = (await row.innerText().catch(() => '')).replace(/\s+/g, ' ')
+ check('预览只存了前几行:写「显示前 5 / 8 行」和「仅为预览」,不说查询已达行数上限', foot.includes('显示前 5 / 8 行')
+ && foot.includes('此处仅为预览') && !foot.includes('查询已达行数上限'), foot.slice(-120))
check('输入写在合并 SQL 前面:先看合并的是什么,再看怎么合并', await row.evaluate((el) => {
const m = el.querySelector('[data-step-merge]')
const pre = el.querySelector('pre')
@@ -731,6 +735,32 @@ await section('合并查询:输入、合并 SQL、结果预览,警告各占
await narrow.page.close()
})
+await section('并行查询各配各的;查询的 SQL 检查;指标的问题指到来源查询的 SQL(A1、B2)', async () => {
+ const { page, errors } = await open('syn=parallel&link=1', { w: 1100, h: 900, name: 'parallel' })
+ const qa = page.locator('[data-node-id="q_a"]').filter({ hasText: 'SQL 检查:1 处错误' }).last()
+ check('出问题的那条查询:行上就说「SQL 检查:1 处错误」', await qa.count() === 1)
+ await qa.locator('button').first().click()
+ await page.waitForTimeout(200)
+ const list = qa.locator('[data-step-sql-checks] [data-sql-check="fanout_sum"][data-level="error"]')
+ const text = (await qa.locator('[data-step-sql-checks]').innerText().catch(() => '')).replace(/\s+/g, ' ')
+ check('展开:用 SQL 检查清单列出级别、规则名、说明、涉及的表和列', await list.count() === 1 && text.includes('错误')
+ && text.includes('一对多关联后重复计算') && text.includes('orders.total_amount') && !text.includes('fanout_sum'), text)
+ const qb = (await page.locator('[data-node-id="q_b"]').last().innerText().catch(() => '')).replace(/\s+/g, ' ')
+ check('另一条查询没有检查结果,也没被配上别人的结果', !qb.includes('SQL 检查') && !qb.includes('119'), qb)
+ const metric = page.locator('[data-step-code="metric_sql_check"]')
+ check('两个指标出自同一条查询:时间线一行', await metric.count() === 1, String(await metric.count()))
+ const fix = metric.locator('[data-fix="canvas"]')
+ check('「打开设置」落到来源查询节点的 SQL:写「打开「订单金额查询」的 SQL」', (await fix.innerText().catch(() => '')).trim()
+ === '打开「订单金额查询」的 SQL' && await fix.getAttribute('data-fix-node') === 'q_a'
+ && await fix.getAttribute('data-fix-field') === 'args.sql', await fix.innerText().catch(() => ''))
+ await fix.click()
+ const opened = await page.evaluate(() => window.__opened ?? [])
+ check('……点了打开的是来源查询节点的 args.sql,不是口径卡', JSON.stringify(opened) === JSON.stringify([['q_a', 'args.sql']]),
+ JSON.stringify(opened))
+ check('没有运行时报错', errors.length === 0, errors.join(' | '))
+ await page.close()
+})
+
await section('参考了哪些表:画布右栏和问数据页(context 操作)', async () => {
const c = await open('syn=copilot-context&dense=1', { w: 380, h: 800, name: 'copilot-context' })
const row = c.page.locator('[data-step-code="copilot_context"]')
@@ -1273,6 +1303,17 @@ await section('放弃、结构化的报错、恢复的轮次、出具横幅(RE
check('恢复的轮次:头部照样写几次查询', head.includes('3 次查询'), head.replace(/\n/g, ' '))
await r.page.close()
+ // 降档是因为查询没通过 SQL 检查(B5):横幅单列一块写是哪次查询、什么问题、影响了哪些引用;那一句不在其余缺口里重复
+ const q = await open('syn=issued-sql', { w: 1100, h: 900, name: 'issued-sql' })
+ const banner = q.page.locator('[data-issuance-banner=degraded]')
+ const sql = (await banner.locator('[data-issuance-sql-checks]').innerText().catch(() => '')).replace(/\s+/g, ' ')
+ check('SQL 检查导致的降档单列一块:写降档原因、哪次查询、问题、受影响的引用、下一步', sql.includes('降档原因:所依据的查询未通过 SQL 检查')
+ && sql.includes('查询「订单金额查询」(Q1)') && sql.includes('求和会重复计算') && sql.includes('受影响:指标「订单金额」、Q1 第 1 行「gmv」')
+ && sql.includes('修改来源查询的 SQL'), sql)
+ const rest = (await banner.locator('[data-issuance-gaps]').innerText().catch(() => '')).replace(/\s+/g, ' ')
+ check('……那一句不在「校验未全部完成」里重复,其余缺口照列', !rest.includes('未通过 SQL 检查') && rest.includes('协作团队'), rest)
+ await q.page.close()
+
const i = await open('syn=issued', { w: 1100, h: 900 })
check('出具横幅带 data-issuance-banner(画布印章据此滚过来)', await i.page.locator('[data-issuance-banner]').count() === 1)
const live = await i.page.locator('[data-turn] [role="status"][aria-live="polite"]').first().innerText()