From cd9bf0aedb04d4fa7e588b20a04f6f5cbfe138e9 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sat, 3 Oct 2026 10:22:46 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=88=E5=B9=B6=E6=9F=A5=E8=AF=A2=E8=8A=82?= =?UTF-8?q?=E7=82=B9=EF=BC=88=E5=9B=9B=EF=BC=89=EF=BC=9A=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E2=80=94=E2=80=94=E8=8A=82=E7=82=B9=E5=BA=93=E3=80=81=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E6=91=98=E8=A6=81=E5=92=8C=E7=B1=BB=E5=9E=8B=E8=89=B2?= =?UTF-8?q?=EF=BC=8C=E6=A3=80=E6=9F=A5=E5=99=A8=E4=BB=8E=E4=B8=8A=E6=B8=B8?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E8=8A=82=E7=82=B9=E9=87=8C=E9=80=89=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E5=B9=B6=E5=A1=AB=E5=88=AB=E5=90=8D=EF=BC=8C=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E8=AF=A6=E6=83=85=E5=B1=95=E7=A4=BA=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E3=80=81=E5=90=88=E5=B9=B6=20SQL=20=E5=92=8C=E8=AD=A6=E5=91=8A?= =?UTF-8?q?=EF=BC=8C=E8=AF=81=E6=8D=AE=E9=9D=A2=E6=9D=BF=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E7=9A=84=E6=9D=A5=E6=BA=90=E5=92=8C=E9=80=90?= =?UTF-8?q?=E6=A0=BC=E6=9D=A5=E5=8E=86=EF=BC=8C=E6=8E=A8=E6=96=AD=E6=9D=A5?= =?UTF-8?q?=E6=BA=90=E5=86=99=E6=98=8E=E7=BB=8F=E8=BF=87=E7=9A=84=E5=90=88?= =?UTF-8?q?=E5=B9=B6=EF=BC=9B=E8=A1=A5=E4=B8=8A=E8=A7=A3=E7=A0=81=E3=80=81?= =?UTF-8?q?=E5=9B=9E=E7=AD=94=E6=B5=81=E3=80=81=E8=AF=81=E6=8D=AE=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E3=80=81=E7=BC=96=E6=8E=92=E9=A1=B5=E7=9A=84=E6=A3=80?= =?UTF-8?q?=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/canvas/Inspector.tsx | 151 +++++++++++++++++++++++- frontend/src/canvas/InspectorSheet.tsx | 3 +- frontend/src/canvas/NodeCard.tsx | 7 ++ frontend/src/canvas/nodeDefs.ts | 23 +++- frontend/src/dev/preview.tsx | 6 +- frontend/src/index.css | 4 + frontend/src/lib/terms.ts | 40 +++++++ frontend/src/run/AssistantStream.tsx | 29 ++++- frontend/src/run/EvidencePanel.tsx | 85 ++++++++++++- frontend/src/run/EvidenceProvenance.tsx | 29 ++++- frontend/src/run/__tests__/synthetic.ts | 39 ++++++ frontend/src/run/decode.ts | 85 ++++++++++++- frontend/src/types.ts | 40 ++++++- scripts/check-decode.mjs | 27 +++++ scripts/check-evidence.mjs | 91 +++++++++++++- scripts/check-stream.mjs | 37 ++++++ scripts/check-studio.mjs | 81 +++++++++++++ 17 files changed, 753 insertions(+), 24 deletions(-) diff --git a/frontend/src/canvas/Inspector.tsx b/frontend/src/canvas/Inspector.tsx index bf5140e5..0aeaede1 100644 --- a/frontend/src/canvas/Inspector.tsx +++ b/frontend/src/canvas/Inspector.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useMemo, useState } from 'react' +import { useEffect, useId, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { Link } from 'react-router-dom' import { AlertTriangle, ChevronLeft, Copy, Lock, Maximize2, Plus, Trash2, X, XCircle } from 'lucide-react' @@ -12,7 +12,7 @@ import { datasourceTools, modelOptions, providerOfModel, useCatalog, useDatasour import { api } from '../api/client' import { IconButton, JsonInput, Modal, isComposing, useRadioGroup } from '../components/ui' import { formatShortcut } from '../lib/keys' -import { JUDGE_FIELD_LABEL, JUDGE_ON_UNSUPPORTED_HINT, JUDGE_ON_UNSUPPORTED_LABEL, JUDGE_SETTING_TEXT, SUBGRAPH_UPGRADE_HELP, judgeByModelText, upgradeNewerText } from '../lib/terms' +import { JUDGE_FIELD_LABEL, JUDGE_ON_UNSUPPORTED_HINT, JUDGE_ON_UNSUPPORTED_LABEL, JUDGE_SETTING_TEXT, MERGE_TEXT, NODE_TYPE_LABEL, SUBGRAPH_UPGRADE_HELP, judgeByModelText, upgradeNewerText } from '../lib/terms' import { TemplateText } from './TemplateText' import type { NodeType, ValidationIssue, WorkflowVersion } from '../types' @@ -150,6 +150,140 @@ function NodeRefsInput({ nodeId, type, value, onChange }: { ) } +interface MergeRow { alias: string; node: string } + +/** config.inputs({别名: 节点 id})→ 行。对象的键顺序就是行的顺序 */ +function mergeRowsOf(value: any): MergeRow[] { + if (!value || typeof value !== 'object' || Array.isArray(value)) return [] + return Object.entries(value).map(([alias, node]) => ({ alias, node: typeof node === 'string' ? node : '' })) +} + +/** 和后端 engine/merge_query.alias_problem 的写法规则一致;关键字、sqlite_ 前缀这类由后端校验报出来 */ +const MERGE_ALIAS = /^[A-Za-z_][A-Za-z0-9_]{0,31}$/ + +/** 新加一行时的别名:从节点 id 取(q_sales → q_sales),不合法的退成 t1、t2…,和已有的不重名 */ +function freshAlias(nodeId: string, taken: Set): string { + const base = nodeId.toLowerCase().replace(/[^a-z0-9_]/g, '_').replace(/^(?=[0-9])/, 't') + const head = MERGE_ALIAS.test(base) ? base : 't' + if (head !== 't' && !taken.has(head)) return head + for (let n = 1; ; n += 1) if (!taken.has(`${head}${n}`)) return `${head}${n}` +} + +/** + * 合并查询的输入:每一行一个别名(合并 SQL 里的表名)加一个上游查询节点。 + * + * 只列上游的查询节点:选了数据库查询工具的「调用工具」节点和别的合并查询。Agent 查过的库、模型写的文字都不能合并 + * (后端校验会报错),这里干脆不给选。已经选了、后来被删掉或挪到下游的节点照样显示成一行并说明,好让人看见并改掉。 + * + * 行先记在本地再写回:别名是对象的键,改到和另一行重名的那一刻写回去会吞掉一行。重名时先不写回,行内说明; + * 撤销、助手改图这类外部改动到达时按新的配置重来。后端按 inputs.<别名> 报的问题落在那一行下面 + */ +function MergeInputList({ nodeId, value, issues, onChange }: { + nodeId: string; value: any; issues: FieldIssue[]; onChange: (v: any) => void +}) { + const nodes = useStudio((s) => s.nodes) + const edges = useStudio((s) => s.edges) + const uid = useId() + const options = useMemo(() => { + const parents = new Map() + for (const e of edges) parents.set(e.target, [...(parents.get(e.target) ?? []), e.source]) + const seen = new Set() + const stack = [...(parents.get(nodeId) ?? [])] + while (stack.length) { + const id = stack.pop()! + if (seen.has(id) || id === nodeId) continue + seen.add(id) + stack.push(...(parents.get(id) ?? [])) + } + return nodes + .filter((n) => { + if (!seen.has(n.id)) return false + const tool = String((n.data?.config as Record | undefined)?.tool ?? '') + return n.data?.nodeType === 'merge' || (n.data?.nodeType === 'tool' && tool.startsWith('db_query__')) + }) + .map((n) => { + const label = String(n.data?.label || n.id) + const tool = String((n.data?.config as Record | undefined)?.tool ?? '') + return { + value: n.id, label: label === n.id ? label : `${label}(${n.id})`, + hint: n.data?.nodeType === 'merge' ? NODE_TYPE_LABEL.merge : tool, + } + }) + }, [nodes, edges, nodeId]) + + const external = useMemo(() => mergeRowsOf(value), [value]) + const [rows, setRows] = useState(external) + const wrote = useRef(JSON.stringify(external)) + useEffect(() => { + const sig = JSON.stringify(external) + if (sig !== wrote.current) { + wrote.current = sig + setRows(external) + } + }, [external]) + const keyOf = (alias: string) => alias.toLowerCase() + const clash = (list: MergeRow[], i: number) => + !!list[i].alias && list.some((r, j) => j !== i && keyOf(r.alias) === keyOf(list[i].alias)) + const commit = (next: MergeRow[]) => { + setRows(next) + if (next.some((_, i) => clash(next, i))) return + const obj = Object.fromEntries(next.map((r) => [r.alias, r.node])) + wrote.current = JSON.stringify(mergeRowsOf(obj)) + onChange(obj) + } + const add = () => { + const used = new Set(rows.map((r) => r.node)) + const pick = options.find((o) => !used.has(o.value)) ?? options[0] + if (!pick) return + commit([...rows, { alias: freshAlias(pick.value, new Set(rows.map((r) => keyOf(r.alias)))), node: pick.value }]) + } + + return ( +
+ {rows.map((row, i) => { + const own = issues.filter((x) => x.at?.sub != null && x.at.sub === row.alias) + const local = clash(rows, i) ? `别名「${row.alias}」与另一个输入重复(不区分大小写),请换一个` + : !row.alias ? '请填写别名' + : !MERGE_ALIAS.test(row.alias) ? '别名只能用英文字母、数字和下划线,不能以数字开头,最多 32 个字符' : '' + const known = options.find((o) => o.value === row.node) + const tone = own.some((x) => x.level === 'error') || local ? 'error' : own.length ? 'warning' : undefined + return ( + commit(rows.filter((_, j) => j !== i))}> +
+ + commit(rows.map((r, j) => (j === i ? { ...r, alias: e.target.value.trim() } : r)))} /> + + + + +
+ {known?.hint &&
{known.hint}
} + {local && !own.length && ( +
+ + {local} +
+ )} + {own.map((x, k) => )} +
+ ) + })} + + {!options.length &&
{MERGE_TEXT.noUpstream}
} +
+ ) +} + /** * 子工作流钉住版本后的升版处置:和口径卡的是同一个下拉——直接借口径卡的字段定义,选项、 * 文案一字不差,只换说明里「和谁同一套」那半句。上游在钉住的那一版之后又发了版时,正式运行 @@ -271,7 +405,10 @@ export function Inspector() { placeholder={def.label} />
- ID {node.id} · 下游用 {`{{ nodes.${node.id}.text }}`} 引用 + {/* 合并查询的产出是一张表、没有 text:口径卡按格取值,报告按查询编号引用 */} + ID {node.id} · {node.data.nodeType === 'merge' + ? <>口径卡用 {`cell(nodes.${node.id}, 0, '列名')`} 取值 + : <>下游用 {`{{ nodes.${node.id}.text }}`} 引用}
@@ -404,8 +541,11 @@ function Field({ field, nodeId, value, config, issues, onChange }: { const syntax = syntaxOf(field) const composite = field.type === 'cases' || field.type === 'agents' || field.type === 'ioFields' || field.type === 'metricsList' || field.type === 'caliberFrom' || field.type === 'judge' - // 复合字段自己把问题落到第几项;落不到具体某一项的,和普通字段一样挂在下面。结论句裁判按子键(judge.max_cost_usd)落 + || field.type === 'mergeInputs' + // 复合字段自己把问题落到第几项;落不到具体某一项的,和普通字段一样挂在下面。结论句裁判按子键(judge.max_cost_usd)落, + // 合并查询的输入按别名(inputs.<别名>)落到那一行 const own = field.type === 'judge' ? issues.filter((i) => !i.at?.sub || !JUDGE_SUB_KEYS.has(i.at.sub)) + : field.type === 'mergeInputs' ? issues.filter((i) => !i.at?.sub) : composite ? issues.filter((i) => i.at?.index == null) : issues const bad = own.some((i) => i.level === 'error') const errorId = own.length ? `${id}-issues` : undefined @@ -606,6 +746,9 @@ function FieldInput({ field, id, nodeId, syntax, value, config, invalid, describ case 'nodeRefs': return + case 'mergeInputs': + return + case 'caliberFrom': return diff --git a/frontend/src/canvas/InspectorSheet.tsx b/frontend/src/canvas/InspectorSheet.tsx index 41bfd53a..4dce4062 100644 --- a/frontend/src/canvas/InspectorSheet.tsx +++ b/frontend/src/canvas/InspectorSheet.tsx @@ -67,7 +67,8 @@ export function revealField(nodeId: string, field?: FieldRef | string | null, op const sheet = document.querySelector('[data-inspector-sheet]') ?? document const box = sheet.querySelector(`[data-field="${CSS.escape(at.key)}"]`) const item = at.index != null ? box?.querySelector(`[data-item="${at.index}"]`) : null - const part = at.sub ? item?.querySelector(`[data-sub="${CSS.escape(at.sub)}"]`) : null + // 只有子键没有序号的(合并查询的 inputs.<别名>):在整个字段里按子键找那一行 + const part = at.sub ? (item ?? box)?.querySelector(`[data-sub="${CSS.escape(at.sub)}"]`) : null const target = part ?? item ?? box if (!target) return const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches diff --git a/frontend/src/canvas/NodeCard.tsx b/frontend/src/canvas/NodeCard.tsx index 9526c5db..5b035188 100644 --- a/frontend/src/canvas/NodeCard.tsx +++ b/frontend/src/canvas/NodeCard.tsx @@ -52,6 +52,13 @@ function summarize(type: NodeType, config: Record): string { return config.tool ? String(config.tool) : '未选择工具' case 'code': return `${config.language ?? 'python'} · ${(config.code ?? '').split('\n')[0].slice(0, 48) || '空'}` + case 'merge': { + // 别名就是合并 SQL 里的表名:卡片上看得出合并的是哪几张 + const aliases = config.inputs && typeof config.inputs === 'object' && !Array.isArray(config.inputs) + ? Object.keys(config.inputs) : [] + if (!aliases.length) return '未配置输入' + return `合并 ${aliases.join('、')}${String(config.sql ?? '').trim() ? '' : ' · 未填写合并 SQL'}` + } case 'branch': return (config.cases ?? []).map((c: any) => c.key).filter(Boolean).join(' / ') || '未配置分支' case 'loop': diff --git a/frontend/src/canvas/nodeDefs.ts b/frontend/src/canvas/nodeDefs.ts index 9e8c8e4b..f9389894 100644 --- a/frontend/src/canvas/nodeDefs.ts +++ b/frontend/src/canvas/nodeDefs.ts @@ -1,14 +1,14 @@ import { - Bot, Braces, Brain, CheckCircle2, Code2, Database, FileInput, FileOutput, FileText, + Bot, Braces, Brain, CheckCircle2, Code2, Combine, Database, FileInput, FileOutput, FileText, Gauge, GitBranch, Hand, Repeat, Search, Shuffle, Users, Wrench, } from 'lucide-react' -import { APPROVAL_POLICY_LABEL, NODE_TYPE_LABEL, UPGRADE_POLICY_LABEL } from '../lib/terms' +import { APPROVAL_POLICY_LABEL, MERGE_TEXT, NODE_TYPE_LABEL, UPGRADE_POLICY_LABEL } from '../lib/terms' import type { NodeType } from '../types' export type FieldType = | 'text' | 'textarea' | 'prompt' | 'code' | 'number' | 'select' | 'switch' | 'json' | 'model' | 'tools' | 'skills' | 'collection' - | 'ioFields' | 'cases' | 'agents' | 'metricsList' | 'nodeRefs' | 'caliberFrom' | 'judge' + | 'ioFields' | 'cases' | 'agents' | 'metricsList' | 'nodeRefs' | 'caliberFrom' | 'judge' | 'mergeInputs' /** * 这个字段里写的是什么语法。 @@ -296,6 +296,23 @@ export const NODE_DEFS: Record = { ], defaults: { args: {} }, }, + merge: { + type: 'merge', label: NODE_TYPE_LABEL.merge, category: '执行', icon: Combine, + description: '把不同数据库的查询结果按合并键合并成一张表,报告和口径卡照常引用、可查看出处', + hasTarget: true, sources: [{ id: 'out', label: '' }], + fields: [ + // 后端 config.inputs 是 {别名: 节点 id}:别名就是内存库里的表名(engine/merge_query.alias_problem 校验) + { key: 'inputs', label: '输入', type: 'mergeInputs', help: MERGE_TEXT.inputsHint }, + { + key: 'sql', label: '合并 SQL', type: 'code', + placeholder: 'SELECT s.day, s.store, s.orders, v.visitors FROM s JOIN v ON s.day = v.day AND s.store = v.store', + help: '一条 SQLite 的 SELECT 或 WITH 查询,表名用上面的别名。同一个库的数据请直接写成一条查询;' + + '比率、增幅等派生计算请写在口径卡中。直接选取输入的列(可用 AS 改名)时,报告中的数字可以追到输入的那一格', + }, + ...COMMON_TAIL, + ], + defaults: { inputs: {}, sql: '' }, + }, code: { type: 'code', label: NODE_TYPE_LABEL.code, category: '执行', icon: Code2, description: '在 microVM 或系统沙箱中执行代码', diff --git a/frontend/src/dev/preview.tsx b/frontend/src/dev/preview.tsx index 2b484041..5743eb8c 100644 --- a/frontend/src/dev/preview.tsx +++ b/frontend/src/dev/preview.tsx @@ -14,7 +14,7 @@ import evidenceFixture from '../run/__tests__/evidence-doc.json' import evidenceJudge from '../run/__tests__/evidence-judge.json' import { CALIBER_ONE_OUTPUT, CALIBERS_OUTPUT, COPILOT_STUCK, COPILOT_TOOLS_DROPPED, MIXED_OUTPUT, abandonedRun, cancelledRun, exhaustedTeam, longLoop, - markupRun, mixedRun, pipelineRun, repairRun, teamRun, timeoutRun, + markupRun, mergeRun, mixedRun, pipelineRun, repairRun, teamRun, timeoutRun, } from '../run/__tests__/synthetic' import '../index.css' @@ -348,6 +348,10 @@ function synthetic(name: string): StreamTurn[] { columns: ['factory_code', 'line_name', 'attribute_group', 'output_qty'], rows: [['1063', '一号线', '001', 1520], ['1064', '二号线', '002', 1310], ['1065', '三号线', '001', 980]], row_count: 3 }) } }] + case 'merge': + // 合并查询:两个库各查一次、在库外按门店合并,带两道警告(键类型不一致、行数放大) + return [{ id: 'merge', question: '按门店合并销售和到店人数', phase: 'done', status: '已完成', + steps: decodeRun(mergeRun(), { status: 'succeeded' }) }] case 'schema': { const ev = mixedRun().slice(0, 10) return [{ id: 'schema', question: '看看有哪些人事表', phase: 'done', status: '已完成', diff --git a/frontend/src/index.css b/frontend/src/index.css index e81e5bc5..f1602470 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -72,6 +72,7 @@ --nt-supervisor: #bcadff; --nt-tool: #10c4c9; --nt-code: #57e3df; + --nt-merge: #96efe6; --nt-branch: #e389ca; --nt-loop: #ffaddd; --nt-subgraph: #dba9c8; @@ -130,6 +131,7 @@ --nt-supervisor: #63519e; --nt-tool: #026264; --nt-code: #057c79; + --nt-merge: #0a4d5a; --nt-branch: #7e376c; --nt-loop: #9b4c7d; --nt-subgraph: #7a516c; @@ -186,6 +188,7 @@ --nt-supervisor: #63519e; --nt-tool: #026264; --nt-code: #057c79; + --nt-merge: #0a4d5a; --nt-branch: #7e376c; --nt-loop: #9b4c7d; --nt-subgraph: #7a516c; @@ -393,6 +396,7 @@ .nt-supervisor { --nt: var(--nt-supervisor); } .nt-tool { --nt: var(--nt-tool); } .nt-code { --nt: var(--nt-code); } +.nt-merge { --nt: var(--nt-merge); } .nt-branch { --nt: var(--nt-branch); } .nt-loop { --nt: var(--nt-loop); } .nt-human { --nt: var(--nt-human); } diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 14065607..a1ddb70e 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -25,6 +25,10 @@ * - 报错、修复选项、说明里写界面上的中文标签(「单元格引用」「报告来自」),不在句子里裸写键名和 * 枚举值;配置界面里字段自身旁边可以附键名。 * - 导航里的历史页叫「记录」。 + * - 「合并查询」是节点名,也指它做的事:把几次查询的结果在库外按键合并成一张表。不写联表、联邦查询、join。 + * 合并时两边对齐的列(日期、门店)叫「合并键」;合并 SQL 里代表某个输入的表名叫「别名」;被合并的那几次 + * 查询叫「输入」。合并结果里的一格能指回输入的哪一格时叫「逐格来历」,指不回时只有「表级来历」(合并了 + * 哪几个输入、用的哪条合并 SQL)。 * * 语体: * - 书面、简洁、中性。用「无法 / 未能 / 失败 / 请」,不用「取不到 / 连不上 / 写坏了」这类口语补语; @@ -120,6 +124,7 @@ export const NODE_TYPE_LABEL: Record = { supervisor: '多 Agent 协作', tool: '调用工具', code: '沙箱代码', + merge: '合并查询', branch: '条件分支', loop: '循环', subgraph: '子工作流', @@ -415,6 +420,41 @@ export const EVIDENCE_TEXT = { }, } as const +/** + * 合并查询(merge 节点)在检查器、运行详情、证据面板和推断来源里的说法。用到的术语(输入、别名、合并键、 + * 逐格来历、表级来历)见文件开头。行号对人一律从 1 数,和证据面板查询表格最前面那一列「#」一致 + */ +export const MERGE_TEXT = { + // ---- 检查器 ---- + addInput: '添加输入', + alias: '别名(表名)', + node: '查询节点', + pick: '选择上游的查询节点', + removeInput: (alias: string) => `删除输入 ${alias || '(未命名)'}`, + noUpstream: '上游没有查询节点。请先在本节点之前连接选择了数据库查询工具的「调用工具」节点,或另一个「合并查询」节点', + missingNode: (id: string) => `节点「${id}」不在上游或已删除,请重新选择`, + inputsHint: '别名就是合并 SQL 中的表名。各输入请先在自己的库中聚合到相同的合并键和粒度(例如日期 + 门店),任何一个输入被截断,合并都会失败', + // ---- 运行详情 ---- + stepTitle: (aliases: string[]) => (aliases.length ? `合并 ${aliases.join('、')} 的查询结果` : '合并查询结果'), + sql: '合并 SQL', + copySql: '复制合并 SQL', + inputs: (n: number) => `合并自 ${formatNumber(n)} 个输入`, + rows: (n: number) => `${formatNumber(n)} 行`, + source: (name: string) => `数据源 ${name}`, + warnings: (n: number) => `${formatNumber(n)} 条警告,详见本节点下方`, + // ---- 证据面板 ---- + mergedInto: (alias: string) => `合并查询 ${alias} 的输入`, + traced: '被引用的格来自', + cellAt: (alias: string, row: number, column: string) => `${alias} 第 ${formatNumber(row + 1)} 行「${column}」`, + noLineage: '没有逐格来历,只有表级来历:见上方的输入和合并 SQL', + unsealed: '不在封存范围内', + notInCatalog: '不在本报告的证据目录中', + executionWarnings: '执行时的警告', + // ---- 推断来源 ---- + hop: (alias: string, to: string) => `经合并查询 ${alias} 追到 ${to}`, + hopMissing: (alias: string) => `合并查询 ${alias} 中的这一格没有逐格来历`, +} as const + /** 结论句按裁判结论分成的几堆(lib/evidence 的 claimCounts 数出来的) */ export interface ClaimTallyCounts { /** 结论句总数:模型判为「不是结论句」的不算 */ diff --git a/frontend/src/run/AssistantStream.tsx b/frontend/src/run/AssistantStream.tsx index f2a14620..cb6aa5f3 100644 --- a/frontend/src/run/AssistantStream.tsx +++ b/frontend/src/run/AssistantStream.tsx @@ -15,7 +15,7 @@ import { humanizeError } from '../lib/errors' import { explainRunError } from '../lib/explain' import { formatClock, formatDuration, formatNumber, NONE, shortId } from '../lib/format' import { statusLabel } from '../lib/status' -import { EVIDENCE_TEXT, JUDGE_TEXT, evidenceTally, issuanceLabel, nodeTypeLabel } from '../lib/terms' +import { EVIDENCE_TEXT, JUDGE_TEXT, MERGE_TEXT, evidenceTally, issuanceLabel, nodeTypeLabel } from '../lib/terms' import { ISSUANCE_HINT, childrenByExec, compactSteps, parseQueryResult, progressOf, spread, teamVerdictOf, type Exec, type FixKind, type ResultTable as Table, type Step, type StepKind, type TeamMemberEx, @@ -723,7 +723,10 @@ function findFirst(steps: Step[], pred: (s: Step) => boolean): Step | undefined function countKind(steps: Step[], kind: StepKind): number { let n = 0 - for (const s of steps) n += (s.kind === kind ? 1 : 0) + (s.children ? countKind(s.children, kind) : 0) + // 合并查询那一行也是 kind query(同样的展开区和下钻),但它没有查数据库:「N 次查询」和落库的查询次数对得上 + for (const s of steps) { + n += (s.kind === kind && !s.merge ? 1 : 0) + (s.children ? countKind(s.children, kind) : 0) + } return n } @@ -1471,14 +1474,32 @@ function StepDetail({ step, table, explained }: { )} )} + {step.merge && step.merge.inputs.length > 0 && ( + // 合并查询:先说合并的是哪几个输入(合并 SQL 里的表名 → 上游节点、行数、数据源),再看合并 SQL 和结果 +
+
{MERGE_TEXT.inputs(step.merge.inputs.length)}
+
    + {step.merge.inputs.map((i) => ( +
  • + {i.alias} + {i.label || i.nodeId || '—'} + + {[i.rows != null ? MERGE_TEXT.rows(i.rows) : '', i.source ? MERGE_TEXT.source(i.source) : ''] + .filter(Boolean).join(' · ')} + +
  • + ))} +
+
+ )} {detail && (
{step.kind === 'query' && (
- SQL + {step.merge ? MERGE_TEXT.sql : 'SQL'} {step.source && 数据源 {step.source}} - step.detail ?? ''} /> + step.detail ?? ''} />
)}
diff --git a/frontend/src/run/EvidencePanel.tsx b/frontend/src/run/EvidencePanel.tsx
index 0a71294f..254b8e90 100644
--- a/frontend/src/run/EvidencePanel.tsx
+++ b/frontend/src/run/EvidencePanel.tsx
@@ -18,12 +18,12 @@ import {
 } from '../lib/evidence'
 import { humanizeError } from '../lib/errors'
 import { formatDateTime, formatNumber, NONE, shortId } from '../lib/format'
-import { EVIDENCE_TEXT, JUDGE_TEXT, nodeTypeLabel } from '../lib/terms'
+import { EVIDENCE_TEXT, JUDGE_TEXT, MERGE_TEXT, NODE_TYPE_LABEL, nodeTypeLabel } from '../lib/terms'
 import { useCatalog } from '../store/catalog'
 import { askKey, segmentKey, useEvidence, useExplore, useVerdicts } from '../store/evidence'
 import type {
-  EvidenceBlock, EvidenceDocData, EvidenceInput, EvidenceOnDemand, EvidenceQuoteSource, EvidenceSeal, EvidenceSegment,
-  EvidenceStep, EvidenceUnit, EvidenceVerdict, EvidenceViolation,
+  EvidenceBlock, EvidenceDocData, EvidenceInput, EvidenceMerge, EvidenceOnDemand, EvidenceQuoteSource, EvidenceSeal,
+  EvidenceSegment, EvidenceStep, EvidenceUnit, EvidenceVerdict, EvidenceViolation,
 } from '../types'
 import { ArtifactViewer, ResultTable } from './AssistantStream'
 import { useEvidenceHost } from './evidenceHost'
@@ -1330,16 +1330,39 @@ function QueryPart({ id, step, seal, masked }: { id: string; step: EvidenceStep;
               className="min-w-0 flex-1 truncate rounded-sm text-2xs font-medium text-faint focus:outline-none focus-visible:outline focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[color:var(--accent)]">
           {EVIDENCE_TEXT.query(alias)}
           {step.tool && {step.tool}}
+          {step.merge && {NODE_TYPE_LABEL.merge}}
+          {step.merged_into && (
+            
+              · {MERGE_TEXT.mergedInto(step.merged_into)}
+            
+          )}
         
         {host.onNode && }
-        {step.sql &&  step.sql ?? ''} />}
+        {step.sql && (
+           step.sql ?? ''} />
+        )}
       
+ {step.merge && } + {step.sql && step.merge &&
{MERGE_TEXT.sql}
} {step.sql && (
           {step.sql}
         
)} + {!!step.merge?.warnings.length && ( +
+
{MERGE_TEXT.executionWarnings}
+
    + {step.merge.warnings.map((w, k) => ( +
  • + + {w.message} +
  • + ))} +
+
+ )} {bad.length > 0 && ( (k === 'hash' ? 'query-hash' : k)).join(' ')} items={bad.map((k) => (k === 'hash' ? EVIDENCE_TEXT.queryHash : EVIDENCE_TEXT.integrity[k]))} /> @@ -1355,6 +1378,7 @@ function QueryPart({ id, step, seal, masked }: { id: string; step: EvidenceStep; // 没有行:快照不在封存范围里、哈希对不上、取不回来。接口说了原因就照原话

{step.note || EVIDENCE_TEXT.queryMissing}

)} + {!!step.merge?.traced.length && }
{win.windowed && win.total != null && ( @@ -1389,6 +1413,59 @@ function QueryPart({ id, step, seal, masked }: { id: string; step: EvidenceStep; /** 窗口表格最前面那一列:快照里的行号,从 1 数 */ const ROW_NO = '#' +/** + * 合并查询的表级来历:合并了哪几个输入(合并 SQL 里的别名 → 报告目录里的编号、节点、行数、数据源)。 + * 输入不在这份报告的证据目录里时照实说:它的那次查询点不开 + */ +function MergeInputs({ merge }: { merge: EvidenceMerge }) { + const host = useEvidenceHost() + return ( +
+
{MERGE_TEXT.inputs(merge.inputs.length)}
+
    + {merge.inputs.map((i) => ( +
  • + {i.alias} + {i.query + ? {EVIDENCE_TEXT.query(i.query)} + : {MERGE_TEXT.notInCatalog}} + {i.label || i.node_id || NONE} + + {[typeof i.rows === 'number' ? MERGE_TEXT.rows(i.rows) : '', i.source ? MERGE_TEXT.source(i.source) : ''] + .filter(Boolean).join(' · ')} + + {host.onNode && i.node_id && } +
  • + ))} +
+
+ ) +} + +/** + * 被引用的格追到了哪个输入的哪一格(逐格来历)。追不到的照实说只有表级来历:宁可不指,也不指错。 + * 追到的那一格在下面那个输入的查询步骤里高亮 + */ +function MergeTraces({ alias, merge }: { alias: string; merge: EvidenceMerge }) { + return ( +
+
{MERGE_TEXT.traced}
+
    + {merge.traced.map((t, k) => ( +
  • + {MERGE_TEXT.cellAt(alias, t.cell[0], t.cell[1])} + ← + {t.input && t.row != null && t.column + ? {MERGE_TEXT.cellAt(t.query ?? t.input, t.row, t.column)} + : {MERGE_TEXT.noLineage}} +
  • + ))} +
+
+ ) +} + /** 接口没给查询步骤时:文档目录里记着的那次查询(编号、工具、行数),并照实说行没取到 */ function QueryFallback({ entry, alias, where, pending, failed }: { entry?: Record; alias?: string; where: string; pending: boolean; failed: string diff --git a/frontend/src/run/EvidenceProvenance.tsx b/frontend/src/run/EvidenceProvenance.tsx index b9de78e0..bbe49a1b 100644 --- a/frontend/src/run/EvidenceProvenance.tsx +++ b/frontend/src/run/EvidenceProvenance.tsx @@ -5,12 +5,12 @@ import clsx from 'clsx' import { ApiError, api } from '../api/client' import { ErrorState, Modal, Skeleton } from '../components/ui' import { NONE, formatDateTime } from '../lib/format' -import { EVIDENCE_TEXT, RAW_STATE_LABEL, VERSIONS_TEXT } from '../lib/terms' +import { EVIDENCE_TEXT, MERGE_TEXT, RAW_STATE_LABEL, VERSIONS_TEXT } from '../lib/terms' import { ManifestBody } from '../pages/import/ManifestBody' import { segmentKey, useEvidence } from '../store/evidence' import type { - EvidenceProvenance as Provenance, ManifestOut, ProvenanceCellSource, ProvenanceCheck, ProvenanceFromCell, ProvenancePart, - ProvenanceVersion, + EvidenceProvenance as Provenance, ManifestOut, ProvenanceCellSource, ProvenanceCheck, ProvenanceFromCell, ProvenanceMergeHop, + ProvenancePart, ProvenanceVersion, } from '../types' import { CopyChip } from './Markdown' @@ -106,7 +106,9 @@ function planOf(d: Provenance): Plan { const alert = d.alert?.code if (d.status === 'none') { if (!alert && (code === 'legacy_doc' || code === 'not_upload' || code === 'not_cell')) return NOTHING - if (!alert && (code === 'not_sealed' || code === 'simple_upload')) return { version: false, source: 'reason', checks: false } + if (!alert && (code === 'not_sealed' || code === 'simple_upload' || code === 'merge_no_lineage')) { + return { version: false, source: 'reason', checks: false } + } if (alert === 'manifest_unreadable' || alert === 'chain_mismatch') return { version: false, source: 'alert', checks: false } } else if (d.status === 'table_only') { if (alert === 'db_tampered' || alert === 'chain_mismatch') return { version: true, source: 'alert', checks: false } @@ -126,6 +128,7 @@ function Answer({ d }: { d: Provenance }) { <> {version && } + {Array.isArray(d.merge) && d.merge.length > 0 && } {plan.source === 'alert' && d.alert && ( // 标红:样式同 IntegrityList(失败色的框),放在这一节最前面;有它时不再画 reason.text

+ {hops.map((h, k) => ( +

  • + {h.input && h.row != null && h.column + ? MERGE_TEXT.hop(h.alias, MERGE_TEXT.cellAt(h.query ?? h.input, h.row, h.column)) + : MERGE_TEXT.hopMissing(h.alias)} +
  • + ))} + + ) +} + /** 「推断,不属于已封存的证据」:推断的来源、相关核对两节的标题旁都挂 */ function Badge() { return ( diff --git a/frontend/src/run/__tests__/synthetic.ts b/frontend/src/run/__tests__/synthetic.ts index b3231f76..ed777ba8 100644 --- a/frontend/src/run/__tests__/synthetic.ts +++ b/frontend/src/run/__tests__/synthetic.ts @@ -335,6 +335,45 @@ export function markupRun(): RunEvent[] { return out } +/** + * 合并查询:门店库、会员库各查一次,在库外按门店合并(engine/nodes/merge.py 的 merge.end 和随后的警告 log)。 + * 门店编号一边是文本、一边是数,又只按门店没按日期合并:两道警告都发 + */ +export const MERGE_SQL = 'SELECT s.门店, s.订单数, v.到店人数 FROM s JOIN v ON s.门店 = v.门店' +export function mergeRun(): RunEvent[] { + const { out, ev } = builder('syn-merge') + const query = (node: string, label: string, source: string, sql: string, columns: string[], rows: unknown[][]) => { + ev('node.started', node, { node_type: 'tool', label }) + ev('tool.start', node, { tool: `db_query__${source}`, args: { sql } }) + ev('tool.end', node, { tool: `db_query__${source}`, duration_ms: 40, artifact: `ts-${node}`, query_artifact: `qs-${node}`, + preview: JSON.stringify({ columns, rows, row_count: rows.length, truncated: false, sql, source }) }, 0.04) + ev('node.finished', node, { duration_ms: 45, attempt: 1 }) + } + ev('run.started', null, { nodes: 4, resumed: false }) + query('q_sales', '门店销售', 'stores', 'SELECT order_date AS 日期, substr(store_id, 2) AS 门店, COUNT(*) AS 订单数 FROM orders GROUP BY 1, 2', + ['日期', '门店', '订单数'], [['2026-05-01', '01', 3], ['2026-05-01', '02', 2], ['2026-05-02', '01', 2], ['2026-05-02', '02', 1]]) + query('q_visits', '到店人数', 'members', 'SELECT visit_date AS 日期, CAST(substr(store_code, 2) AS INTEGER) AS 门店, COUNT(*) AS 到店人数 FROM visits GROUP BY 1, 2', + ['日期', '门店', '到店人数'], [['2026-05-01', 1, 6], ['2026-05-01', 2, 4], ['2026-05-02', 1, 5], ['2026-05-02', 2, 4]]) + ev('node.started', 'merge', { node_type: 'merge', label: '按门店合并' }) + ev('merge.end', 'merge', { + inputs: [ + { alias: 's', node_id: 'q_sales', label: '门店销售', rows: 4, source: 'stores', artifact: 'qs-q_sales' }, + { alias: 'v', node_id: 'q_visits', label: '到店人数', rows: 4, source: 'members', artifact: 'qs-q_visits' }, + ], + sql: MERGE_SQL, rows: 8, columns: ['门店', '订单数', '到店人数'], + preview_rows: [['01', 3, 6], ['01', 3, 5], ['01', 2, 6], ['01', 2, 5], ['02', 2, 4]], + 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', + message: "合并键类型不一致:s.门店 是文本(例如 '01'),v.门店 是数值(例如 1)。SQLite 比较时会做隐式转换:文本形式的编号(如 '001'、'01')都会等于数值 1,也可能完全匹配不上。请在源查询中统一类型,或在合并 SQL 中用 CAST 明确转换" }) + ev('log', 'merge', { level: 'warn', code: 'merge_rows_grew', + 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 +} + /** 结构化校验:原文里没有数,修复两次都编出了 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 1c1569da..9349c9c1 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -1,7 +1,7 @@ import type { RunEvent, TeamMember, TeamRound, TeamRun, ToolChange } from '../types' import type { NodeState, RunPhase } from './trace' import { formatDuration, formatNumber } from '../lib/format' -import { JUDGE_TEXT, TYPE_LABEL, claimTally, evidenceTally, issuanceLabel, nodeTypeLabel } from '../lib/terms' +import { JUDGE_TEXT, MERGE_TEXT, TYPE_LABEL, claimTally, evidenceTally, issuanceLabel, nodeTypeLabel } from '../lib/terms' import { claimCountsOf, isJudged, statsTally } from '../lib/evidence' // 泳道数据画布也要用(supervisor 节点要展开成协作矩阵),所以类型放在 @@ -145,6 +145,25 @@ export interface Step { limitS?: number /** 出具那一行的档位(formal / degraded / withheld) */ tier?: string + /** 合并查询那一行(merge.end):合并了哪几个输入、几条警告。SQL 在 detail,预览在 result */ + merge?: MergeStep +} + +/** 合并查询的一个输入:合并 SQL 里的别名、上游节点、行数、数据源 */ +export interface MergeStepInput { + alias: string + nodeId?: string + label?: string + rows?: number + source?: string +} + +export interface MergeStep { + inputs: MergeStepInput[] + /** 执行时的警告条数。警告的原文由同一节点紧接着的 log(merge_key_type…)各占一行,不在这里重复 */ + warnings: number + rows?: number + truncated: boolean } // 这两个是流式增量,后端根本不落库(_EPHEMERAL)。单次运行的 delta 量级会 @@ -755,6 +774,38 @@ function explainLog(code: string | undefined, message: string): Pick x[1]) + return { + title: keys.length === 2 ? `合并键类型不一致:${keys[0]} 与 ${keys[1]}` : '合并键类型不一致', + sub: m?.[1], + 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] + return { + title: '合并结果行数多于输入,合并键可能不唯一', + sub: [head ? `合并结果 ${head[1]} 行,行数最多的输入「${head[2]}」${head[3]} 行` : '', dup ?? ''] + .filter(Boolean).join(';') || undefined, + next: '请检查合并条件是否覆盖了全部合并键(例如同时按日期和门店),或先在源库里聚合到相同粒度', + fix: 'canvas', + } + } + case 'merge_truncated': { + const rows = message.match(/只保留了前\s*([\d,]+)\s*行/)?.[1] + return { + title: `合并结果超过上限,${rows ? `只保留了前 ${rows} 行` : '已截断'}`, + next: '下游拿到的不是完整结果。请在合并 SQL 中聚合或加条件缩小范围', + fix: 'canvas', + } + } case 'judge_failed': { // 「结论句裁判没跑完:有 2 句结论没裁判:裁判调用失败(…)」 return { @@ -2074,6 +2125,38 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt break } + case 'merge.end': { + // 合并查询:几次查询的结果在库外按键合并。和查询同一类(kind query):detail 是合并 SQL,result 是预览, + // 工件是合并结果的查询快照。输入列在 merge 里,展开区单列一块;警告的原文由紧跟着的 log 各占一行 + const inputs: MergeStepInput[] = (Array.isArray(d.inputs) ? d.inputs : []) + .filter((i: any) => i && typeof i === 'object' && typeof i.alias === 'string') + .map((i: any) => ({ + alias: String(i.alias), + ...(typeof i.node_id === 'string' ? { nodeId: i.node_id } : {}), + ...(typeof i.label === 'string' && i.label ? { label: i.label } : {}), + ...(num(i.rows) != null ? { rows: num(i.rows) } : {}), + ...(typeof i.source === 'string' && i.source ? { source: i.source } : {}), + })) + const warnings = Array.isArray(d.warnings) ? d.warnings.filter((w: any) => w && w.message).length : 0 + const rows = num(d.rows) + const ms = num(d.duration_ms) + const preview = Array.isArray(d.preview_rows) ? d.preview_rows : [] + push({ + id: `mg-${seq}`, seq, kind: 'query', nodeId, status: 'done', code: 'merge', + ...(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, + result: Array.isArray(d.columns) + ? JSON.stringify({ columns: d.columns, rows: preview, truncated: rows != null && rows > preview.length }) + : undefined, + meta: [rows != null ? MERGE_TEXT.rows(rows) : '', dur(ms) ?? ''].filter(Boolean).join(' · ') || undefined, + ms, + ...(typeof d.query_artifact === 'string' && d.query_artifact ? { artifact: d.query_artifact } : {}), + merge: { inputs, warnings, ...(rows != null ? { rows } : {}), truncated: !!d.truncated }, + }, nodeId) + break + } + case 'retrieve.end': { // 知识检索。以前只发一条 info 日志,于是"这句结论依据的是哪份文档的 // 哪一段"在轨迹里查不到——而 SQL 取数那条路早就能下钻到工件 diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 6ce60836..c312947c 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,5 +1,5 @@ export type NodeType = - | 'input' | 'output' | 'llm' | 'agent' | 'supervisor' | 'tool' | 'code' + | 'input' | 'output' | 'llm' | 'agent' | 'supervisor' | 'tool' | 'code' | 'merge' | 'branch' | 'loop' | 'subgraph' | 'memory' | 'retrieve' | 'transform' | 'human' | 'validate' | 'metrics' | 'report' @@ -2063,6 +2063,40 @@ export interface EvidenceCaliberUpgrade { * run_input(报告直接引用的运行输入)、query(查询快照:被引用的行加前后各 2 行)。 * input 步骤同时带着 EvidenceInput 的字段(via、status、model_value、ref、locator、artifact) */ +/** 合并查询的一个输入(证据面板的 merge.inputs) */ +export interface EvidenceMergeInput { + /** 合并 SQL 里的表名 */ + alias: string + node_id?: string | null + /** 节点在画布上的名字 */ + label?: string | null + /** 这个输入在报告目录里的编号(Q1);不在这份报告的目录里时为空 */ + query?: string | null + rows?: number | null + /** 数据源名;输入本身是合并结果时是「合并查询」 */ + source?: string | null + artifact?: string | null + sealed?: boolean +} + +/** 被引用的一格追到了哪个输入的哪一格;追不到时 input 为空,note 说明只有表级来历 */ +export interface EvidenceMergeTrace { + /** 合并结果里的 [行, 列],行号从 0 数 */ + cell: [number, string] + input: string | null + query: string | null + row: number | null + column: string | null + note?: string +} + +export interface EvidenceMerge { + sql?: string | null + inputs: EvidenceMergeInput[] + warnings: { code?: string | null; message: string }[] + traced: EvidenceMergeTrace[] +} + export interface EvidenceStep extends Omit { step: string metric?: string @@ -2127,6 +2161,10 @@ export interface EvidenceStep extends Omit { * 查询快照来自上传的表格时才有,且恒为 true;其余一个键都不加。前端只在 seg.cite?.kind === 'cell' 且它为 true 时请求 */ provenance?: boolean + /** 合并查询的结果(快照的 source 是「合并查询」):合并了哪几个输入、合并 SQL、执行时的警告、被引用的格追到哪 */ + merge?: EvidenceMerge + /** 这一步是哪次合并查询的输入(合并结果在目录里的编号):排在那个合并步骤后面,高亮的是追到的格 */ + merged_into?: string // ---- entity:表或字段(三期)---- /** entity:table / column */ kind?: string diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index d13f188f..b81dda9b 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -112,6 +112,33 @@ await section('知识检索', async () => { check('未知事件的类型名不上标题和副标题', !unknown.some((x) => x.title.includes('brand.new') || x.sub?.includes('brand.new'))) }) +await section('合并查询', async () => { + // merge.end 是合并查询节点做完的那一条:哪几个输入、合并 SQL、几行。和查询同一类(kind query), + // 合并 SQL 进详情、预览进结果、工件可下钻;警告的原文由紧跟着的 log 各占一行,这一行只说有几条 + const steps = mod.decodeRun(synthetic.mergeRun(), { status: 'succeeded' }) + const all = flatten(steps) + const m = all.find((s) => s.code === 'merge') + check('合并查询是一行查询步骤,挂在合并节点下', !!m && m.kind === 'query' && m.nodeId === 'merge' + && steps.some((s) => s.kind === 'node' && s.nodeId === 'merge' && s.children?.includes(m)), m?.kind) + check('标题说合并的是哪几个输入', m?.title === '合并 s、v 的查询结果', m?.title) + check('合并 SQL 留在详情里', m?.detail === synthetic.MERGE_SQL) + check('预览按查询结果的形状给出,能画成表格', !!mod.parseQueryResult(m?.result ?? '')?.columns?.includes('到店人数')) + 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('键类型不一致给出下一步', !!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('警告原文各占一行,不在合并那一行里重复', 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') +}) + await section('长期记忆', async () => { // 写记忆以前只发 info 日志,而 info 在这一层是被丢弃的——系统往长期记忆里 // 存东西,界面上一点痕迹都没有。Copilot 会主动记之后,这条不可接受。 diff --git a/scripts/check-evidence.mjs b/scripts/check-evidence.mjs index 6e40bca0..d0370907 100644 --- a/scripts/check-evidence.mjs +++ b/scripts/check-evidence.mjs @@ -2963,7 +2963,96 @@ if (SHOTS) { await page.keyboard.press('Escape') } } -await section('copy-r2', '按后端原文匹配的地方:后端现在的原文、整改前的原文都认得(文案整改第二轮)', async () => { +// --------------------------------------------------------------------------- +// 合并查询:片段接口的查询步骤带 merge(表级来历、逐格来历),后面接着追到的输入的查询步骤(merged_into); +// 推断来源的答复带 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 } = {}) { + 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: '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 行):合并键可能不唯一,同一行被重复匹配' }], + traced, + }, + } + const input = { ...q, alias: 'Q1', merged_into: q.alias } + return { ...body, chain: body.chain.flatMap((st) => (st.step === 'query' ? (withInput ? [merged, input] : [merged]) : [st])) } +} + +await section('merge', '合并查询:表级来历(输入、合并 SQL、警告)、逐格来历、输入步骤标明是哪次合并的输入', async () => { + const traced = await probeQ((id, body) => (id === qseg('1,288') + ? asMerge(body, [{ cell: [5, 'amount'], input: 'o', query: 'Q1', row: 5, column: 'amount' }]) : body)) + await openQ(traced.page, '1,288') + if (SHOTS) { + for (const theme of ['dark', 'light']) { + await traced.page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + await traced.page.waitForTimeout(250) + await traced.page.screenshot({ path: `${SHOTS}/evidence-merge-${theme}.png`, fullPage: true }) + } + await traced.page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark')) + } + const p = traced.page.locator('[data-evidence-panel]') + const parts = p.locator('[data-ev-query]') + check('合并步骤和它追到的输入步骤各一块', await parts.count() === 2, String(await parts.count())) + const head = await parts.first().locator('[data-ev-query-head]').innerText().catch(() => '') + check('合并步骤的标题写「合并查询」,不写工具名', head.includes('Q3') && head.includes('合并查询') && !head.includes('db_query__'), head) + const inputs = p.locator('[data-ev-merge]') + check('表级来历:合并自 2 个输入', (await inputs.innerText().catch(() => '')).includes('合并自 2 个输入')) + const o = await p.locator('[data-ev-merge-input="o"]').innerText().catch(() => '') + check('……每个输入写别名、目录编号、节点名、行数、数据源', ['o', '查询 Q1', '订单明细', '12 行', '数据源 shop'].every((t) => o.includes(t)), o) + const t = await p.locator('[data-ev-merge-input="t"]').innerText().catch(() => '') + check('……不在本报告目录里的输入照实说', t.includes('不在本报告的证据目录中'), t) + check('合并 SQL 有标签、带复制', (await parts.first().innerText()).includes('合并 SQL') + && await parts.first().locator('button', { hasText: '复制合并 SQL' }).count() === 1) + const warn = await p.locator('[data-ev-merge-warnings]').innerText().catch(() => '') + check('执行时的警告写出来', warn.includes('执行时的警告') && warn.includes('不唯一'), warn) + const line = await p.locator('[data-ev-merge-trace="cell"]').innerText().catch(() => '') + check('逐格来历:被引用的格追到输入的哪一格(行号从 1 数)', line.includes('Q3 第 6 行「amount」') && line.includes('Q1 第 6 行「amount」'), line) + const into = await parts.nth(1).locator('[data-ev-merged-into]').innerText().catch(() => '') + 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) + 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)) + 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) + await flat.ctx.close() + + const wideId = pseg('wide') + const hop = { alias: 'Q3', node_id: 'merge', input: 'f', query: 'Q1', row: 5, column: '全日客流' } + const lost = { alias: 'Q3', node_id: 'merge', input: null, query: null, row: null, column: null } + const noLineage = { + ...fxp.provenance[wideId], status: 'none', version: null, cell_source: null, checks: [], merge: [lost], + reason: { code: 'merge_no_lineage', detail: '', text: '这一格来自合并查询,无法确定它对应哪个输入的哪一格' }, + } + const hops = await open('/ui-harness.html?evidence=1', { + prov: (run, sid, reply) => (sid === wideId ? { json: { ...reply.json, merge: [hop] } } + : sid === pseg('long') ? { json: noLineage } : reply), + }) + await openP(hops.page, 'wide') + const said = await textOf(pv(hops.page, '[data-ev-prov-merge]')) + check('推断的来源:先说经过的合并、追到的那一格,下面的原表格子照常', said === '经合并查询 Q3 追到 Q1 第 6 行「全日客流」' + && await pv(hops.page, '[data-ev-prov-cell]').count() === 1, said) + await openP(hops.page, 'long') + check('追不到逐格来历:写原因和没追到的那一跳,不画数据版本和格子', (await textOf(pv(hops.page, '[data-ev-prov-reason="merge_no_lineage"]'))) + .includes('合并查询') && (await textOf(pv(hops.page, '[data-ev-prov-merge-hop="none"]'))).includes('没有逐格来历') + && await pv(hops.page, '[data-ev-prov-version], [data-ev-prov-cell]').count() === 0) + await hops.ctx.close() +}) + +await section('copy-r2','按后端原文匹配的地方:后端现在的原文、整改前的原文都认得(文案整改第二轮)', async () => { // 画布问题落到哪一栏(canvas/issues.ts)、检查器的分支标识过滤(Inspector 用 issues.ts 的 isCaseKeyIssue)、 // 证据面板的裁判触顶(lib/evidence.ts)都按后端原文判断。开发库里历史运行、旧版本存下的文字是整改前的, // 新的是整改后的:两种各喂一遍。「新」样本和 backend/app 现在的写法一致(先核对源码片段), diff --git a/scripts/check-stream.mjs b/scripts/check-stream.mjs index 10075035..b7938867 100644 --- a/scripts/check-stream.mjs +++ b/scripts/check-stream.mjs @@ -694,6 +694,43 @@ await section('长表名、Copilot 结局、问数据的分段', async () => { await q.page.close() }) +await section('合并查询:输入、合并 SQL、结果预览,警告各占一行并给出下一步', async () => { + const { page, errors } = await open('syn=merge', { w: 1100, h: 900, name: 'merge' }) + const row = page.locator('[data-step-code="merge"]').first() + const line = (await row.innerText().catch(() => '')).replace(/\s+/g, ' ') + check('合并那一行说合并的是哪几个输入,行尾写行数', line.includes('合并 s、v 的查询结果') && line.includes('8 行'), line) + check('有警告:这一行标成警告、说有几条、指到下方', line.includes('2 条警告,详见本节点下方'), line) + await row.locator('button').first().click() + await page.waitForTimeout(200) + const merge = row.locator('[data-step-merge]') + const inputs = (await merge.innerText().catch(() => '')).replace(/\s+/g, ' ') + check('展开:合并自 2 个输入,每个写别名、节点名、行数、数据源', inputs.includes('合并自 2 个输入') + && ['s', '门店销售', 'v', '到店人数', '4 行', '数据源 stores', '数据源 members'].every((t) => inputs.includes(t)), inputs) + 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('到店人数'))) + check('输入写在合并 SQL 前面:先看合并的是什么,再看怎么合并', await row.evaluate((el) => { + const m = el.querySelector('[data-step-merge]') + const pre = el.querySelector('pre') + return !!m && !!pre && !!(m.compareDocumentPosition(pre) & Node.DOCUMENT_POSITION_FOLLOWING) + })) + const key = page.locator('[data-step-code="merge_key_type"]').first() + check('键类型不一致:标题点名两列,下一步不用展开就看得到', (await key.innerText().catch(() => '')).includes('合并键类型不一致:s.门店 与 v.门店') + && (await key.locator('[data-step-next]').innerText().catch(() => '')).includes('CAST')) + const grew = page.locator('[data-step-code="merge_rows_grew"]').first() + check('行数放大:说合并键可能不唯一,下一步说怎么改', (await grew.innerText().catch(() => '')).includes('合并键可能不唯一') + && (await grew.locator('[data-step-next]').innerText().catch(() => '')).includes('聚合到相同粒度')) + check('没有运行时报错', errors.length === 0, errors.join(' | ')) + await page.close() + + const narrow = await open('syn=merge&dense=1', { w: 380, h: 900 }) + await narrow.page.locator('[data-step-code="merge"] button').first().click() + await narrow.page.waitForTimeout(200) + const overflow = await narrow.page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + check('窄栏展开合并那一行:输入清单、合并 SQL、预览都不撑出横向滚动', overflow <= 0, `${overflow}px`) + await narrow.page.close() +}) + await section('步骤行和画布联动', async () => { const { page } = await open('syn=mixed&link=1', { w: 1100, h: 800 }) const row = page.locator('[data-node-id="agent"]').first() diff --git a/scripts/check-studio.mjs b/scripts/check-studio.mjs index f99f3caa..8177f7a6 100644 --- a/scripts/check-studio.mjs +++ b/scripts/check-studio.mjs @@ -114,6 +114,23 @@ const EV_GRAPH = { edges: [edge('start', 'fetch'), edge('start', 'manual'), edge('fetch', 'write'), edge('manual', 'write'), edge('write', 'done')], } +// 合并查询:门店库、会员库各查一次,按日期 + 门店合并。上游另有一个模型调用节点,检查器的输入下拉里不该有它 +const MERGE_GRAPH = { + nodes: [ + node('start', 'input', 0, 200, '输入', { fields: [{ name: 'day', required: true }] }), + node('q_sales', 'tool', 300, 80, '门店销售', { tool: 'db_query__shop', args: { sql: 'SELECT order_date AS 日期, store_id AS 门店, COUNT(*) AS 订单数 FROM orders GROUP BY 1, 2' } }), + node('q_visits', 'tool', 300, 240, '到店人数', { tool: 'db_query__members', args: { sql: 'SELECT visit_date AS 日期, store_code AS 门店, COUNT(*) AS 到店人数 FROM visits GROUP BY 1, 2' } }), + node('think', 'llm', 300, 400, '先想一想', { prompt: '{{ input.day }}' }), + node('merge', 'merge', 640, 200, '按门店合并', { + inputs: { s: 'q_sales', v: 'q_visits' }, + sql: 'SELECT s.日期, s.门店, s.订单数, v.到店人数 FROM s JOIN v ON s.日期 = v.日期 AND s.门店 = v.门店', + }), + node('done', 'output', 980, 200, '成果', { fields: [{ name: 'answer', value: '{{ nodes.merge.rows }}' }] }), + ], + edges: [edge('start', 'q_sales'), edge('start', 'q_visits'), edge('start', 'think'), edge('q_sales', 'merge'), + edge('q_visits', 'merge'), edge('think', 'merge'), edge('merge', 'done')], +} + const wf = (id, extra) => ({ id, name: extra.name, description: extra.description ?? '检查脚本伪造的工作流', graph: extra.graph ?? GRAPH, tags: extra.tags ?? [], version: extra.version ?? 3, is_template: !!extra.is_template, @@ -128,6 +145,7 @@ const FAKES = { 'st-team': wf('st-team', { name: '__studio_check_team__', graph: TEAM_BOUND, version: 2, published_version: 2, status: 'published' }), 'st-lint': wf('st-lint', { name: '__studio_check_lint__', graph: TEAM_GRAPH, version: 1 }), 'st-ev': wf('st-ev', { name: '__studio_check_ev__', graph: EV_GRAPH, version: 2 }), + 'st-merge': wf('st-merge', { name: '__studio_check_merge__', graph: MERGE_GRAPH, version: 1 }), } const VERSIONS = [ { id: 'v3', version: 3, note: '', created_at: '2026-09-26T02:00:00Z' }, @@ -2353,6 +2371,69 @@ await section('证据路径:点开报告片段时画布高亮、节点名能 await race.ctx.close() }) +// ================================================================ 合并查询 +await section('合并查询:节点库认得、卡片摘要、检查器从上游查询节点里选输入、别名重名先不写回、定位落到那一行', async () => { + const { ctx, page, errors } = await open({ path: '/studio/st-merge' }) + const entry = page.locator('[aria-label="节点库"] button[data-node-type="merge"]') + check('节点库「执行」一类里有「合并查询」', await entry.count() === 1 && (await entry.innerText()).includes('合并查询')) + const card = page.locator('.react-flow__node[data-id="merge"]') + check('卡片摘要写合并的是哪几张表(别名)', (await card.innerText().catch(() => '')).includes('合并 s、v'), + (await card.innerText().catch(() => '')).replace(/\s+/g, ' ').slice(0, 80)) + await S(page, () => window.__studio.getState().select('merge')) + await page.waitForSelector('[data-inspector-sheet] [data-field="inputs"]', { timeout: 4000 }).catch(() => {}) + // STUDIO_SHOTS=<目录>:亮暗两套各截一张合并查询的检查器,供人眼复核 + if (process.env.STUDIO_SHOTS) { + for (const theme of ['dark', 'light']) { + await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + await page.waitForTimeout(250) + await page.screenshot({ path: `${process.env.STUDIO_SHOTS}/studio-merge-${theme}.png` }) + } + await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark')) + } + const box = page.locator('[data-inspector-sheet] [data-field="inputs"]') + const aliases = await box.locator('input.mono').evaluateAll((els) => els.map((e) => e.value)) + check('每个输入一行:别名 s、v,各配一个查询节点', aliases.join(',') === 's,v', aliases.join(',')) + const options = await box.locator('select').first().locator('option:not([disabled])').evaluateAll((els) => els.map((e) => e.value)) + check('下拉只列上游的查询节点(调用数据库查询工具的节点),不列模型调用', options.join(',') === 'q_sales,q_visits' + || options.join(',') === 'q_visits,q_sales', options.join(',')) + check('选中的节点下面写着它调用的工具', (await box.innerText()).includes('db_query__shop')) + check('合并 SQL 用代码编辑框', await page.locator('[data-inspector-sheet] [data-field="sql"] textarea, [data-inspector-sheet] [data-field="sql"] [contenteditable]').count() >= 1) + const config = () => S(page, () => JSON.stringify(window.__studio.getState().nodes.find((n) => n.id === 'merge')?.data.config.inputs)) + + await box.locator('input.mono').nth(1).fill('S') + await page.waitForTimeout(150) + check('别名改成和另一行只差大小写:行内说明重复,先不写回(两行都还在)', + (await box.innerText()).includes('重复') && await config() === JSON.stringify({ s: 'q_sales', v: 'q_visits' }), await config()) + await box.locator('input.mono').nth(1).fill('visits') + await page.waitForTimeout(150) + check('改成不重名的别名:写回,顺序不变', await config() === JSON.stringify({ s: 'q_sales', visits: 'q_visits' }), await config()) + await box.locator('input.mono').nth(1).fill('9v') + await page.waitForTimeout(150) + check('不合法的别名当场说明写法', (await box.innerText()).includes('只能用英文字母、数字和下划线')) + await box.locator('input.mono').nth(1).fill('v') + await box.getByRole('button', { name: '添加输入' }).click() + await page.waitForTimeout(150) + const added = JSON.parse(await config()) + check('添加输入:取一个上游查询节点,别名从节点 id 起、不重名', Object.keys(added).length === 3 + && Object.keys(added)[2] === 'q_sales', JSON.stringify(added)) + await box.getByRole('button', { name: '删除输入 q_sales' }).click() + await page.waitForTimeout(150) + check('删除输入:那一行和配置里的那一项一起去掉', await config() === JSON.stringify({ s: 'q_sales', v: 'q_visits' }), await config()) + + await S(page, () => window.__studio.getState().select(null)) + await page.waitForTimeout(200) + const r = await page.evaluate(async () => { + const m = await window.__appImport('/src/canvas/InspectorSheet.tsx') + m.revealField('merge', 'inputs.v', { focus: true }) + await new Promise((res) => setTimeout(res, 600)) + const a = document.activeElement + return { inRow: !!a?.closest('[data-field="inputs"] [data-sub="v"]'), value: (a && 'value' in a) ? a.value : null } + }).catch((e) => ({ error: e.message.split('\n')[0] })) + check('问题落在 inputs.<别名> 上:定位到那一行,光标落进别名', r.inRow && r.value === 'v', JSON.stringify(r)) + check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | ')) + await ctx.close() +}) + check('整个检查没有弹出原生 confirm / prompt', dialogs === 0, `${dialogs} 次`) check('检查没有写库(兜底拦下的写请求)', writes.length === 0, writes.slice(0, 4).join('、'))