合并查询节点(四):前端——节点库、卡片摘要和类型色,检查器从上游查询节点里选输入并填别名,运行详情展示输入、合并 SQL 和警告,证据面板展示合并的来源和逐格来历,推断来源写明经过的合并;补上解码、回答流、证据面板、编排页的检查

This commit is contained in:
ThearchyHelios
2026-10-03 10:22:46 +08:00
parent 61cd2f7bd0
commit cd9bf0aedb
17 changed files with 753 additions and 24 deletions
+147 -4
View File
@@ -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>): 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<string, string[]>()
for (const e of edges) parents.set(e.target, [...(parents.get(e.target) ?? []), e.source])
const seen = new Set<string>()
const stack = [...(parents.get(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<string, any> | 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<string, any> | 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<MergeRow[]>(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 (
<div>
{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 (
<Row key={i} item={i} removeLabel={MERGE_TEXT.removeInput(row.alias)} tone={tone}
onRemove={() => commit(rows.filter((_, j) => j !== i))}>
<div className="flex gap-1.5" data-sub={row.alias || undefined}>
<Sub label={MERGE_TEXT.alias} htmlFor={`${uid}-${i}-alias`} className="w-32 shrink-0" sub="alias">
<input id={`${uid}-${i}-alias`} className="field mono text-xs" value={row.alias} placeholder="s"
spellCheck={false} aria-invalid={!!local || undefined}
onChange={(e) => commit(rows.map((r, j) => (j === i ? { ...r, alias: e.target.value.trim() } : r)))} />
</Sub>
<Sub label={MERGE_TEXT.node} htmlFor={`${uid}-${i}-node`} className="min-w-0 flex-1" sub="node">
<select id={`${uid}-${i}-node`} className="field text-xs" value={row.node}
onChange={(e) => commit(rows.map((r, j) => (j === i ? { ...r, node: e.target.value } : r)))}>
<option value="" disabled>{MERGE_TEXT.pick}</option>
{options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
{row.node && !known && <option value={row.node} disabled>{MERGE_TEXT.missingNode(row.node)}</option>}
</select>
</Sub>
</div>
{known?.hint && <div className="mono truncate text-2xs text-faint" title={known.hint}>{known.hint}</div>}
{local && !own.length && (
<div className="flex items-start gap-1 text-2xs leading-snug" style={{ color: 'var(--err)' }}>
<XCircle size={11} className="mt-px shrink-0" aria-hidden />
<span className="min-w-0 [overflow-wrap:anywhere]">{local}</span>
</div>
)}
{own.map((x, k) => <IssueLine key={k} issue={x} />)}
</Row>
)
})}
<button type="button" className="btn btn-sm w-full justify-center" disabled={!options.length} onClick={add}>
<Plus size={11} /> {MERGE_TEXT.addInput}
</button>
{!options.length && <div className="mt-1.5 text-2xs leading-snug text-faint">{MERGE_TEXT.noUpstream}</div>}
</div>
)
}
/**
* 子工作流钉住版本后的升版处置:和口径卡的是同一个下拉——直接借口径卡的字段定义,选项、
* 文案一字不差,只换说明里「和谁同一套」那半句。上游在钉住的那一版之后又发了版时,正式运行
@@ -271,7 +405,10 @@ export function Inspector() {
placeholder={def.label}
/>
<div className="mt-1 text-2xs text-faint">
ID <code className="mono">{node.id}</code> · 下游用 <code className="mono">{`{{ nodes.${node.id}.text }}`}</code> 引用
{/* 合并查询的产出是一张表、没有 text:口径卡按格取值,报告按查询编号引用 */}
ID <code className="mono">{node.id}</code> · {node.data.nodeType === 'merge'
? <>口径卡用 <code className="mono">{`cell(nodes.${node.id}, 0, '列名')`}</code> 取值</>
: <>下游用 <code className="mono">{`{{ nodes.${node.id}.text }}`}</code> 引用</>}
</div>
</div>
@@ -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 <NodeRefsInput nodeId={nodeId} type={field.refType} value={value} onChange={onChange} />
case 'mergeInputs':
return <MergeInputList nodeId={nodeId} value={value} issues={issues} onChange={onChange} />
case 'caliberFrom':
return <CaliberFromPicker id={id} value={value} invalid={invalid} describedBy={describedBy} />
+2 -1
View File
@@ -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<HTMLElement>(`[data-field="${CSS.escape(at.key)}"]`)
const item = at.index != null ? box?.querySelector<HTMLElement>(`[data-item="${at.index}"]`) : null
const part = at.sub ? item?.querySelector<HTMLElement>(`[data-sub="${CSS.escape(at.sub)}"]`) : null
// 只有子键没有序号的(合并查询的 inputs.<别名>):在整个字段里按子键找那一行
const part = at.sub ? (item ?? box)?.querySelector<HTMLElement>(`[data-sub="${CSS.escape(at.sub)}"]`) : null
const target = part ?? item ?? box
if (!target) return
const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches
+7
View File
@@ -52,6 +52,13 @@ function summarize(type: NodeType, config: Record<string, any>): 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':
+20 -3
View File
@@ -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<NodeType, NodeDef> = {
],
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 或系统沙箱中执行代码',
+5 -1
View File
@@ -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: '已完成',
+4
View File
@@ -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); }
+40
View File
@@ -25,6 +25,10 @@
* - 报错、修复选项、说明里写界面上的中文标签(「单元格引用」「报告来自」),不在句子里裸写键名和
* 枚举值;配置界面里字段自身旁边可以附键名。
* - 导航里的历史页叫「记录」。
* - 「合并查询」是节点名,也指它做的事:把几次查询的结果在库外按键合并成一张表。不写联表、联邦查询、join。
* 合并时两边对齐的列(日期、门店)叫「合并键」;合并 SQL 里代表某个输入的表名叫「别名」;被合并的那几次
* 查询叫「输入」。合并结果里的一格能指回输入的哪一格时叫「逐格来历」,指不回时只有「表级来历」(合并了
* 哪几个输入、用的哪条合并 SQL)。
*
* 语体:
* - 书面、简洁、中性。用「无法 / 未能 / 失败 / 请」,不用「取不到 / 连不上 / 写坏了」这类口语补语;
@@ -120,6 +124,7 @@ export const NODE_TYPE_LABEL: Record<NodeType, string> = {
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 {
/** 结论句总数:模型判为「不是结论句」的不算 */
+25 -4
View File
@@ -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 }: {
)}
</div>
)}
{step.merge && step.merge.inputs.length > 0 && (
// 合并查询:先说合并的是哪几个输入(合并 SQL 里的表名 → 上游节点、行数、数据源),再看合并 SQL 和结果
<div className="text-2xs text-dim" data-step-merge="">
<div className="mb-0.5">{MERGE_TEXT.inputs(step.merge.inputs.length)}</div>
<ul className="space-y-0.5">
{step.merge.inputs.map((i) => (
<li key={i.alias} className="flex min-w-0 items-baseline gap-1.5" data-step-merge-input={i.alias}>
<span className="mono shrink-0 rounded bg-bg px-1 text-fg">{i.alias}</span>
<span className="min-w-0 truncate" title={i.nodeId}>{i.label || i.nodeId || '—'}</span>
<span className="shrink-0 text-faint">
{[i.rows != null ? MERGE_TEXT.rows(i.rows) : '', i.source ? MERGE_TEXT.source(i.source) : '']
.filter(Boolean).join(' · ')}
</span>
</li>
))}
</ul>
</div>
)}
{detail && (
<div className="group/detail relative">
{step.kind === 'query' && (
<div className="mb-0.5 flex items-center gap-2 text-2xs text-dim">
<span className="mono">SQL</span>
<span className={step.merge ? undefined : 'mono'}>{step.merge ? MERGE_TEXT.sql : 'SQL'}</span>
{step.source && <span>数据源 {step.source}</span>}
<span className="flex-1" />
<CopyChip label="复制 SQL" text={() => step.detail ?? ''} />
<CopyChip label={step.merge ? MERGE_TEXT.copySql : '复制 SQL'} text={() => step.detail ?? ''} />
</div>
)}
<pre className={pre}>
+81 -4
View File
@@ -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 && <span className="mono ml-1.5 font-normal">{step.tool}</span>}
{step.merge && <span className="ml-1.5 font-normal">{NODE_TYPE_LABEL.merge}</span>}
{step.merged_into && (
<span className="ml-1.5 font-normal" data-ev-merged-into={step.merged_into}>
· {MERGE_TEXT.mergedInto(step.merged_into)}
</span>
)}
</span>
{host.onNode && <NodeChip id={step.node_id} />}
{step.sql && <CopyChip label={EVIDENCE_TEXT.copySql} text={() => step.sql ?? ''} />}
{step.sql && (
<CopyChip label={step.merge ? MERGE_TEXT.copySql : EVIDENCE_TEXT.copySql} text={() => step.sql ?? ''} />
)}
</div>
{step.merge && <MergeInputs merge={step.merge} />}
{step.sql && step.merge && <div className="mb-0.5 text-2xs text-faint">{MERGE_TEXT.sql}</div>}
{step.sql && (
<pre className="mono mb-1.5 max-h-28 overflow-auto whitespace-pre-wrap rounded bg-bg px-1.5 py-1 text-2xs leading-relaxed text-dim [overflow-wrap:anywhere]"
data-ev-sql="">
{step.sql}
</pre>
)}
{!!step.merge?.warnings.length && (
<div className="mb-1.5 text-2xs" data-ev-merge-warnings="">
<div className="mb-0.5 text-faint">{MERGE_TEXT.executionWarnings}</div>
<ul className="space-y-0.5">
{step.merge.warnings.map((w, k) => (
<li key={k} className="flex items-start gap-1 leading-snug" style={{ color: 'var(--st-waiting)' }}>
<AlertTriangle size={10} className="mt-[2px] shrink-0" aria-hidden />
<span className="min-w-0 [overflow-wrap:anywhere]">{w.message}</span>
</li>
))}
</ul>
</div>
)}
{bad.length > 0 && (
<IntegrityList code={bad.map((k) => (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;
// 没有行:快照不在封存范围里、哈希对不上、取不回来。接口说了原因就照原话
<p className="text-2xs text-dim" data-ev-query-note="">{step.note || EVIDENCE_TEXT.queryMissing}</p>
)}
{!!step.merge?.traced.length && <MergeTraces alias={alias} merge={step.merge} />}
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-2xs text-faint">
{win.windowed && win.total != null && (
<span data-ev-window="">
@@ -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 (
<div className="mb-1.5 text-2xs" data-ev-merge="">
<div className="mb-0.5 text-faint">{MERGE_TEXT.inputs(merge.inputs.length)}</div>
<ul className="space-y-0.5">
{merge.inputs.map((i) => (
<li key={i.alias} className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5" data-ev-merge-input={i.alias}>
<span className="mono rounded bg-bg px-1">{i.alias}</span>
{i.query
? <span className="mono text-dim">{EVIDENCE_TEXT.query(i.query)}</span>
: <span style={{ color: 'var(--st-waiting)' }}>{MERGE_TEXT.notInCatalog}</span>}
<span className="min-w-0 truncate text-dim" title={i.node_id ?? undefined}>{i.label || i.node_id || NONE}</span>
<span className="text-faint">
{[typeof i.rows === 'number' ? MERGE_TEXT.rows(i.rows) : '', i.source ? MERGE_TEXT.source(i.source) : '']
.filter(Boolean).join(' · ')}
</span>
{host.onNode && i.node_id && <NodeChip id={i.node_id} />}
</li>
))}
</ul>
</div>
)
}
/**
* 被引用的格追到了哪个输入的哪一格(逐格来历)。追不到的照实说只有表级来历:宁可不指,也不指错。
* 追到的那一格在下面那个输入的查询步骤里高亮
*/
function MergeTraces({ alias, merge }: { alias: string; merge: EvidenceMerge }) {
return (
<div className="mt-1.5 text-2xs" data-ev-merge-traced="">
<div className="mb-0.5 text-faint">{MERGE_TEXT.traced}</div>
<ul className="space-y-0.5">
{merge.traced.map((t, k) => (
<li key={k} className="flex min-w-0 flex-wrap items-baseline gap-x-1.5"
data-ev-merge-trace={t.input ? 'cell' : 'none'}>
<span className="mono text-dim">{MERGE_TEXT.cellAt(alias, t.cell[0], t.cell[1])}</span>
<span className="text-faint" aria-hidden>←</span>
{t.input && t.row != null && t.column
? <span className="mono">{MERGE_TEXT.cellAt(t.query ?? t.input, t.row, t.column)}</span>
: <span className="[overflow-wrap:anywhere]" style={{ color: 'var(--st-waiting)' }}>{MERGE_TEXT.noLineage}</span>}
</li>
))}
</ul>
</div>
)
}
/** 接口没给查询步骤时:文档目录里记着的那次查询(编号、工具、行数),并照实说行没取到 */
function QueryFallback({ entry, alias, where, pending, failed }: {
entry?: Record<string, any>; alias?: string; where: string; pending: boolean; failed: string
+25 -4
View File
@@ -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 && <VersionPart v={version} sealed={d.sealed !== false} onManifest={setManifest} />}
<SourceShell state={d.status}>
{Array.isArray(d.merge) && d.merge.length > 0 && <MergeHops hops={d.merge} />}
{plan.source === 'alert' && d.alert && (
// 标红:样式同 IntegrityList(失败色的框),放在这一节最前面;有它时不再画 reason.text
<p className="flex items-start gap-1.5 rounded border px-2 py-1 [overflow-wrap:anywhere]" data-ev-prov-alert={d.alert.code}
@@ -149,6 +152,24 @@ function Answer({ d }: { d: Provenance }) {
)
}
/**
* 被引用的格在合并查询的结果里:先说经过了哪几次合并、追到了哪个输入的哪一格(行号对人从 1 数),下面的数据版本、
* 原表格子说的都是追到的那一格。追不到的那一跳照实说没有逐格来历
*/
function MergeHops({ hops }: { hops: ProvenanceMergeHop[] }) {
return (
<ul className="mb-1 space-y-0.5 text-2xs text-dim" data-ev-prov-merge="">
{hops.map((h, k) => (
<li key={k} className="[overflow-wrap:anywhere]" data-ev-prov-merge-hop={h.input ? 'traced' : 'none'}>
{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)}
</li>
))}
</ul>
)
}
/** 「推断,不属于已封存的证据」:推断的来源、相关核对两节的标题旁都挂 */
function Badge() {
return (
+39
View File
@@ -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')
+84 -1
View File
@@ -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<Step, 'titl
fix: 'canvas',
}
}
case 'merge_key_type': {
// 「合并键类型不一致:s.门店 是文本(例如 '01'),v.门店 是数值(例如 1)。SQLite 比较时会做隐式转换…」
// (engine/merge_query._warnings)。标题说是哪两列,副标题说各是什么类型
const m = message.match(/^合并键类型不一致[::]\s*(.+?)。/)
const keys = [...(m?.[1] ?? '').matchAll(/([\w一-龥]+\.[^\s,,]+)\s*是/g)].map((x) => 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 取数那条路早就能下钻到工件
+39 -1
View File
@@ -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<EvidenceInput, 'status'> {
step: string
metric?: string
@@ -2127,6 +2161,10 @@ export interface EvidenceStep extends Omit<EvidenceInput, 'status'> {
* 查询快照来自上传的表格时才有,且恒为 true;其余一个键都不加。前端只在 seg.cite?.kind === 'cell' 且它为 true 时请求
*/
provenance?: boolean
/** 合并查询的结果(快照的 source 是「合并查询」):合并了哪几个输入、合并 SQL、执行时的警告、被引用的格追到哪 */
merge?: EvidenceMerge
/** 这一步是哪次合并查询的输入(合并结果在目录里的编号):排在那个合并步骤后面,高亮的是追到的格 */
merged_into?: string
// ---- entity:表或字段(三期)----
/** entity:table / column */
kind?: string
+27
View File
@@ -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 会主动记之后,这条不可接受。
+90 -1
View File
@@ -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 现在的写法一致(先核对源码片段),
+37
View File
@@ -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()
+81
View File
@@ -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('、'))