合入数据目录阶段 3:合并查询节点——跨库按键合并,截断输入拒绝,类型不一致与行数放大告警,证据可下钻到两边原始查询

This commit is contained in:
ThearchyHelios
2026-10-03 10:27:16 +08:00
39 changed files with 3249 additions and 77 deletions
+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
@@ -2989,7 +2989,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('、'))