mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:31:58 +00:00
合并查询节点(四):前端——节点库、卡片摘要和类型色,检查器从上游查询节点里选输入并填别名,运行详情展示输入、合并 SQL 和警告,证据面板展示合并的来源和逐格来历,推断来源写明经过的合并;补上解码、回答流、证据面板、编排页的检查
This commit is contained in:
@@ -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} />
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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 或系统沙箱中执行代码',
|
||||
|
||||
@@ -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: '已完成',
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 {
|
||||
/** 结论句总数:模型判为「不是结论句」的不算 */
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 会主动记之后,这条不可接受。
|
||||
|
||||
@@ -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 现在的写法一致(先核对源码片段),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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('、'))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user