diff --git a/backend/tests/test_ledger_match.py b/backend/tests/test_ledger_match.py index ab25b27b..3b7267ab 100644 --- a/backend/tests/test_ledger_match.py +++ b/backend/tests/test_ledger_match.py @@ -288,3 +288,13 @@ async def test_only_sealed_query_snapshots_are_compared(monkeypatch): await ledger_match.match_run(row.id, trigger="on_demand") recorded, _ = await matched(row.id) assert [q["status"] for q in recorded.content["queries"]] == ["proven"] + + +async def test_cells_inside_a_table_are_labelled_the_same_way(monkeypatch): + wf = await S.publish() + row = await S.explore(monkeypatch, S.EQUIVALENT, + text="本周销售额 [[v:Q1.r0.销售额]]。\n\n[[table:Q1 cols=销售额 rows=0-0]]") + recorded, _ = await matched(row.id) + [labels] = [r["labels"] for r in recorded.content["reports"].values()] + assert len(labels) >= 2 and all(label["kind"] == "caliber" and label["caliber"] == f"{wf}@1" + for label in labels.values()) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 079342e4..89fbacd9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -225,8 +225,14 @@ export const api = { workflows: { list: (opts?: RequestOptions) => get('/workflows', opts), get: (id: string) => get(`/workflows/${id}`), - create: (body: { name: string; description?: string; graph?: GraphSpec; tags?: string[] }) => - post('/workflows', body), + /** + * origin:新工作流出自哪次运行、哪段对话、哪一轮(问数据的「在画布里打开」带上,next-round 02 做法 9)。服务端核对运行 + * 读得到、对话用得了、这一轮属于这段对话,对不上回 422(workflow_origin_invalid) + */ + create: (body: { + name: string; description?: string; graph?: GraphSpec; tags?: string[] + origin?: { run_id: string; conversation_id?: string; turn_id?: string } + }) => post('/workflows', body), update: (id: string, body: Partial & { note?: string }) => patch(`/workflows/${id}`, body), remove: (id: string) => del(`/workflows/${id}`), @@ -582,6 +588,11 @@ export const api = { run_class?: 'formal' | 'exploratory'; version?: number /** 运行来源(X3):客户端只能声明 chat、canvas、launcher、api(不传按 api 记),其余由服务端写 */ origin?: 'chat' | 'canvas' | 'launcher' | 'api' + /** + * 问数据的运行属于哪段对话、哪一轮(只在 origin 是 chat 时收,next-round 02 做法 9):服务端核对后写进运行来源、在封存 + * 范围内。对话、轮次对不上回 422(origin_ref_invalid) + */ + origin_ref?: { conversation_id: string; turn_id: string } }) => post('/runs', body), artifacts: (id: string) => get(`/runs/${id}/artifacts`), /** 从失败的节点接着跑。graph 只能带改过配置的同一张图,结构必须一致 */ diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index d90db81e..0f3f7b1f 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -4349,6 +4349,34 @@ export const BUNDLE_TEXT = { fileName: (name: string) => `${name}-可验证报告.html`, } +/** + * 口径账本(docs/design/next-round/02-口径账本.md)。探索运行封存之后,服务端在后台拿这次执行的 SQL 和已发布口径的取数比对, + * 追加一条 ledger.matched(封存之后,不在封存范围内)。这一波只有时间线上那一行;逐个数的标签、证据面板的「口径」一步、对账在 + * 第 4 波接上界面(接口 GET /api/runs/{id}/ledger 已就绪)。叫法同总方案 X10:来自口径(受管 / 已发布)· 等价已证明 / 助手选用、 + * 相关口径、这次的推理 + */ +export const LEDGER_TEXT = { + /** 时间线那一行:只说几条查询各是什么情况(服务端的 counts),不带绑定和差异 */ + timeline: (counts: { proven?: unknown; composed?: unknown; related?: unknown; none?: unknown; skipped?: unknown }) => { + const n = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0) + const parts = [ + n(counts.proven) ? `${formatNumber(n(counts.proven))} 条查询等价已证明` : '', + n(counts.composed) ? `${formatNumber(n(counts.composed))} 条来自助手选用的口径` : '', + n(counts.related) ? `${formatNumber(n(counts.related))} 条有相关口径` : '', + n(counts.none) ? `${formatNumber(n(counts.none))} 条没有相关的已发布口径` : '', + n(counts.skipped) ? `${formatNumber(n(counts.skipped))} 条没有比完` : '', + ].filter(Boolean) + return parts.length ? `比对已发布口径:${parts.join(',')}` : '比对已发布口径' + }, + /** 副标题:比对在封存之后;事后补比、打开时按需比的另写一句 */ + sub: '封存后比对,不在封存范围内', + retro: '按现在的口径回看', + /** 标签的四种、认法的两种(第 4 波的证据面板、轮次摘要用同一套) */ + kind: { caliber: '来自口径', related: '相关口径', reasoning: '这次的推理', unknown: '没有比对' }, + how: { independent: '等价已证明', composed: '助手选用' }, + level: { governed: '受管', published: '已发布' }, +} + /** * 审批质询:审批人在批准前就待审的报告提问(选一句、划一段原文,或就整份报告),核查模型只读查库作答。问答写进运行 * 记录、随运行一并封存,不改动报告;封存之后只有探索运行还能问,追加在封存之后 diff --git a/frontend/src/pages/ChatPage.tsx b/frontend/src/pages/ChatPage.tsx index 36579bf9..5ea4aed0 100644 --- a/frontend/src/pages/ChatPage.tsx +++ b/frontend/src/pages/ChatPage.tsx @@ -304,7 +304,7 @@ export function ChatPage() { if (busy || blocked) pick(text) else void send(text) }} - onOpenGraph={async (graph, question) => { + onOpenGraph={async (graph, question, turnId) => { // 建成一张新工作流,走它自己的地址。以前是 setGraph(graph):只换掉了画布 // 上的节点,store 里的 workflow 还是上一次打开的那张图——于是 ⌘S 把那张图 // 整张覆盖了(旧版本还在版本表里,界面上却没有入口找回);没打开过图时 @@ -313,7 +313,19 @@ export function ChatPage() { if (!(await canLeave('/studio/'))) return try { const name = `问数据:${(question ?? '').trim().slice(0, 24) || '未命名'}` - const created = await api.workflows.create({ name, graph }) + // 新工作流记下出自哪次运行、哪段对话、哪一轮(服务端核对,同「提取成工作流」的来源):沉淀和召回要找得到它。 + // 这一轮还没运行过(只建了图)的没有来源可记 + const turn = turns?.find((t) => t.id === turnId) + const origin = turn?.run?.id + ? { run_id: turn.run.id, ...(currentId ? { conversation_id: currentId } : {}), + ...(currentId && turn.serverId ? { turn_id: turn.serverId } : {}) } + : undefined + // 来源核对不过(运行刚被删):来源只是留痕,不挡「在画布里打开」,不带来源照常新建 + const created = await (origin + ? api.workflows.create({ name, graph, origin }).catch((e) => ( + e instanceof ApiError && e.code === 'workflow_origin_invalid' + ? api.workflows.create({ name, graph }) : Promise.reject(e))) + : api.workflows.create({ name, graph })) void refreshCatalog() navigate(`/studio/${created.id}`, leavePass()) toast.ok('已在画布中新建工作流,原有工作流不受影响') diff --git a/frontend/src/run/AssistantStream.tsx b/frontend/src/run/AssistantStream.tsx index f17d62fc..05c32be0 100644 --- a/frontend/src/run/AssistantStream.tsx +++ b/frontend/src/run/AssistantStream.tsx @@ -191,7 +191,7 @@ export function AssistantStream({ empty?: ReactNode /** 这一轮要不要插审批卡——插槽而不是内建,因为不同页面接流的方式不同 */ approvalsFor?: (turn: StreamTurn) => ReactNode - onOpenGraph?: (graph: any, question?: string) => void + onOpenGraph?: (graph: any, question?: string, turnId?: string) => void footer?: ReactNode /** 每一轮结束之后的动作槽:重试、接着跑、让 Copilot 再修……由页面决定放什么 */ renderTurnActions?: (turn: StreamTurn, index: number) => ReactNode @@ -443,7 +443,7 @@ function flash(el: HTMLElement, kind: 'failed' | 'focus'): () => void { function TurnCard({ turn, last, approvals, onOpenGraph, onFollowUp }: { turn: StreamTurn; last: boolean; approvals?: ReactNode - onOpenGraph?: (graph: any, question?: string) => void + onOpenGraph?: (graph: any, question?: string, turnId?: string) => void onFollowUp?: (text: string) => void }) { const { dense } = useContext(Ctx) @@ -551,7 +551,7 @@ function TurnCard({ turn, last, approvals, onOpenGraph, onFollowUp }: { {!!turn.graph?.nodes?.length && ( onOpenGraph(g, turn.question))} /> + onOpen={onOpenGraph && ((g) => onOpenGraph(g, turn.question, turn.id))} /> )} diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index 185c3d52..8289f615 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -6,7 +6,7 @@ import { ABSENCE_CLAIM_TEXT, ACTION_CONFIRM_TEXT, ACTION_ENV_LABEL, ACTION_LOG_TEXT, CATALOG_DRIFT_TEXT, CATALOG_PATCH_TEXT, COPILOT_CONTEXT_TEXT, JUDGE_TEXT, LOOK_TEXT, MERGE_TEXT, ORIGIN_TEXT, RUN_SQL_CHECK_TEXT, SQL_CHECK_TEXT, TASK_KIND_LABEL, TASK_LOG_TEXT, TASK_STATUS_LABEL, TASK_TEXT, TYPE_LABEL, originLabel, CATALOG_TABLE_FIELD_LABEL, CHALLENGE_TEXT, DUAL_CHECK_TEXT, INQUIRY_TEXT, JUDGE_TRUNCATED_TEXT, MASKED_CELL_TEXT, NODE_TYPE_LABEL, - VERIFY_TRUNCATED_TEXT, BP_OP_TEXT, TEMPLATE_LABEL, bpPathText, FILE_TEXT, REJECTED_TEXT, + VERIFY_TRUNCATED_TEXT, BP_OP_TEXT, TEMPLATE_LABEL, bpPathText, FILE_TEXT, REJECTED_TEXT, LEDGER_TEXT, claimTally, evidenceTally, issuanceLabel, nodeTypeLabel, } from '../lib/terms' @@ -2402,6 +2402,20 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt break } + case 'ledger.matched': { + // 口径账本:探索运行封存之后,服务端在后台拿这次执行的 SQL 和已发布口径比对过了。追加在封存之后、不在封存范围内; + // 事件里只有几条查询各是什么情况,逐个数的标签走 GET /runs/{id}/ledger + const counts = d.counts && typeof d.counts === 'object' ? d.counts : {} + const retro = typeof d.trigger === 'string' && d.trigger !== 'sealed' + out.push({ + id: `lm-${seq}`, seq, kind: 'note', status: 'done', code: 'ledger_matched', + level: typeof counts.skipped === 'number' && counts.skipped > 0 ? 'warn' : 'info', + title: LEDGER_TEXT.timeline(counts), + sub: [LEDGER_TEXT.sub, retro ? LEDGER_TEXT.retro : ''].filter(Boolean).join(' · '), + }) + break + } + case 'approval.inquiry': // 审批人在批准前就待审的报告提问(审批质询):运行停在审批上时服务端追加这一条,node_id 为空,运行本身没动、 // 报告也不改(相位照旧,见 nextPhase)。封存之后探索运行也能问,带 post_seal。一条事件就是一问一答,挂在顶层 diff --git a/frontend/src/store/chat.ts b/frontend/src/store/chat.ts index 178356ff..d098ea01 100644 --- a/frontend/src/store/chat.ts +++ b/frontend/src/store/chat.ts @@ -1628,10 +1628,15 @@ async function launch( ) { const { patch } = flow try { - const run = await api.runs.start({ - graph, input: fillInput(graph, question, historyOf(flow.conversationId, flow.turnId)), - origin: 'chat', - }) + // 这一轮在服务端的 id(开轮请求可能还在路上,等它):运行记下属于哪段对话、哪一轮(服务端核对后封存进运行来源)。 + // 开轮没成的(serverId 为空)不带,运行照常发起 + const turnId = findTurn(flow.conversationId, flow.turnId)?.serverId ?? (await serverIds.get(flow.turnId)) ?? null + const body = { graph, input: fillInput(graph, question, historyOf(flow.conversationId, flow.turnId)), origin: 'chat' as const } + // 来源核对不过(对话刚被删、这一轮已不在):来源只是留痕,不挡这一轮的回答,不带来源照常发起 + const run = await (turnId + ? api.runs.start({ ...body, origin_ref: { conversation_id: flow.conversationId, turn_id: turnId } }) + .catch((e) => (e instanceof ApiError && e.code === 'origin_ref_invalid' ? api.runs.start(body) : Promise.reject(e))) + : api.runs.start(body)) // 启动请求在路上时用户点了停止或重试:运行已经起来了,得追一个取消过去 if (!flow.alive()) { void api.runs.cancel(run.id).catch(() => undefined) diff --git a/scripts/check-chat.mjs b/scripts/check-chat.mjs index 073b0baa..e6b073cf 100644 --- a/scripts/check-chat.mjs +++ b/scripts/check-chat.mjs @@ -628,7 +628,7 @@ async function fakeApi(route) { : conversation.includes('步数用满') ? `run-steps-${++runSeq}` : conversation.includes('照服务端的决定') ? `run-decide-${++runSeq}` : `run-busy-${++runSeq}` - log.runStarts.push({ id, graph: b.graph }) + log.runStarts.push({ id, graph: b.graph, origin_ref: b.origin_ref }) return json({ id, status: 'queued', run_class: 'exploratory', output: {}, usage: {} }) } m = path.match(/^\/runs\/([^/]+)\/(cancel|continue)$/) @@ -1455,6 +1455,10 @@ for (const theme of THEMES) { const started = log.runStarts.slice(startsBefore).at(-1) check('发起的图里 agent 步数是 24', started?.graph?.nodes?.find((n) => n.type === 'agent')?.data?.config?.max_steps === 24, JSON.stringify(started?.graph?.nodes?.map((n) => n.data?.config))) + // 口径账本的来源链(next-round 02 做法 9):运行记下属于哪段对话、哪一轮(服务端核对后封存进运行来源) + const server = await page.evaluate(() => window.__chat.getState().byConversation.c0histf.at(-1).serverId) + check('发起时带上这一轮属于哪段对话、哪一轮', started?.origin_ref?.conversation_id === 'c0histf' + && !!server && started?.origin_ref?.turn_id === server, JSON.stringify(started?.origin_ref)) check('上一次留档:第 1 次尝试 · 结论不可用', await shows(page, '第 1 次尝试')) await page.screenshot({ path: `${SHOTS}/retried-${theme}.png` }) check('没有运行时报错', errors.length === 0, errors[0] ?? '') @@ -2442,6 +2446,8 @@ for (const theme of THEMES) { const asks = await page.evaluate(() => window.__asks) check('说了放弃:建一个、跳过去,整条路只问这一遍', creates.length === 1 && asks === 2 && new URL(page.url()).pathname === '/studio/wf-chat-open', `建了 ${creates.length} 个 · 一共问了 ${asks} 遍(取消那次算一遍)`) + check('建的工作流带上来源:哪次运行、哪段对话、哪一轮(服务端核对)', JSON.stringify(creates[0]?.origin) + === JSON.stringify({ run_id: 'run-b1', conversation_id: 'c0idleb', turn_id: 'tb1' }), JSON.stringify(creates[0]?.origin)) check('没有运行时报错', errors.length === 0, errors[0] ?? '') await ctx.close() }) diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index f079d849..eef2faf3 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -1823,6 +1823,31 @@ await section('结论句裁判(可点击证据第四期):裁判调用单 odd.find((s) => s.code === 'report_checked')?.title) }) +await section('口径账本:封存后比对已发布口径(ledger.matched)', async () => { + // next-round 02 做法 2:探索运行封存之后,服务端在后台比对已发布口径,追加一条 ledger.matched(只有各种状态几条) + const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 1790000000 + seq, data }) + const run = (data) => flatten(mod.decodeRun([ + E(1, 'run.started', null, {}), E(2, 'node.started', 'q', { node_type: 'tool' }), E(3, 'node.finished', 'q', {}), + E(4, 'run.finished', null, { status: 'succeeded' }), + E(5, 'ledger.matched', null, { artifact: 'cm-1', manifest_hash: 'h', algo: '1', post_seal: true, ...data }), + ])) + const all = run({ counts: { proven: 1, composed: 0, related: 2, none: 0, skipped: 0 }, trigger: 'sealed' }) + check('认得它:不是「未识别的事件」', !all.some((s) => s.title === '未识别的事件')) + const lm = all.find((s) => s.code === 'ledger_matched') + check('一行:几条等价已证明、几条有相关口径,写明封存后比对、不在封存范围内', + lm?.title === '比对已发布口径:1 条查询等价已证明,2 条有相关口径' && lm?.sub === '封存后比对,不在封存范围内' + && lm?.level === 'info' && lm?.kind === 'note', `${lm?.title} / ${lm?.sub} / ${lm?.level}`) + const late = run({ counts: { proven: 0, related: 0, none: 1, skipped: 1 }, trigger: 'backfill' }).find((s) => s.code === 'ledger_matched') + check('事后补比的写明按现在的口径回看;没比完的是提醒', late?.title === '比对已发布口径:1 条没有相关的已发布口径,1 条没有比完' + && late?.sub === '封存后比对,不在封存范围内 · 按现在的口径回看' && late?.level === 'warn', `${late?.title} / ${late?.sub} / ${late?.level}`) + const odd = run({ counts: 'x' }).find((s) => s.code === 'ledger_matched') + check('缺字段:只说比对过,不出 undefined、不瞎填数', odd?.title === '比对已发布口径' && !/undefined|NaN/.test(`${odd?.title}${odd?.sub}`), + odd?.title) + const phase = [E(4, 'run.finished', null, {}), E(5, 'ledger.matched', null, {})] + .reduce((s, ev) => mod.nextPhase(s, ev), { phase: 'running', awaiting: false }) + check('不改相位:运行照旧是结束了', phase.phase === 'succeeded', phase.phase) +}) + await section('术语', async () => { // 没起名的节点退到类型名,类型名跟全站同一张表 const h = mod.decodeRun([{ seq: 1, type: 'node.started', node_id: 'h', ts: 1, data: { node_type: 'human', label: 'h' } }]) diff --git a/scripts/check-report-export.mjs b/scripts/check-report-export.mjs index ef486b7b..c80d3c71 100644 --- a/scripts/check-report-export.mjs +++ b/scripts/check-report-export.mjs @@ -238,6 +238,74 @@ await section('断网打开', async () => { } }) +// ---------------------------------------------------------------- 口径一步(口径账本,next-round 02 做法 3) + +await section('口径一步', async () => { + if (skipped()) return + const data = dataOf(html) + // 没比对过的运行:数据块里没有 ledger,面板里也没有「口径」一步 + if (!data.ledger) { + const plain = await openFile(writePage('report-plain.html', html)) + try { + const cited = plain.page.locator('#report button[data-seg][data-state="deterministic"]').first() + if (await cited.count()) { + await cited.click() + await plain.page.locator('#detail').waitFor({ state: 'visible', timeout: 5000 }) + check('没比对过的运行:面板里没有「口径」一步', await plain.page.locator('#detail [data-step="ledger"]').count() === 0) + } + } finally { + await plain.ctx.close() + } + } + // 服务端比对过的运行面板里是什么样:数据块里放一份口径账本给的标签(来自口径、相关口径各一个),断网打开点开看。 + // 只改数据块(脚本的哈希不变,内容安全策略照样放行) + const keys = Object.keys(data.refs ?? {}) + if (!keys.length) return note('报告里没有可点的片段,口径一步不查') + const labels = { + [keys[0]]: { kind: 'caliber', how: 'independent', caliber: 'wf-x@3', workflow_id: 'wf-x', version: 3, level: 'governed', + workflow_name: '周销售', level_text: '受管', column: 'gmv', + binding: [{ field: 'start', value: '2026-09-01' }, { field: 'phone', masked: true, value: '已遮罩' }] }, + ...(keys[1] ? { [keys[1]]: { kind: 'related', related: [{ caliber: 'wf-x@3', workflow_id: 'wf-x', version: 3, + workflow_name: '周销售', level_text: '受管', diff: { items: [{ aspect: 'filter', text: '口径只保留已支付的行,这次没有这个条件' }] } }] } } : {}), + } + const ledger = { labels, note: '封存后比对,不在封存范围内', retro: true, retro_note: '按现在的口径回看' } + const patched = html.replace(DATA_RE, (_, a, body, c) => a + JSON.stringify({ ...JSON.parse(body), ledger }) + .replace(/ { + await seg.click() + await page.locator('#detail').waitFor({ state: 'visible', timeout: 5000 }) + const part = page.locator('#detail [data-step="ledger"]') + const text = (await part.count()) ? await part.innerText() : '' + const order = await page.evaluate(() => [...document.querySelectorAll('#detail-body > section')].map((s) => s.dataset.step ?? '')) + await page.keyboard.press('Escape') + return { text, order } + } + const { text, order } = await ledgerText(first) + check('面板最上面多「口径」一步:来自口径「周销售」v3(受管)、等价已证明', /口径/.test(text) + && text.includes('来自口径「周销售」v3(受管)') && text.includes('等价已证明'), text.replace(/\n/g, ' ')) + check('参数照给,遮罩的写「已遮罩」', text.includes('start = 「2026-09-01」') && text.includes('phone = 已遮罩')) + check('写明封存后比对、不在封存范围内,事后补比的写按现在的口径回看', + text.includes('封存后比对,不在封存范围内') && text.includes('按现在的口径回看')) + check('「口径」一步排在出处链前面', order[1] === 'ledger', order.slice(0, 2).join(',')) + if (keys[1]) { + const second = page.locator(`#report button[data-seg="${keys[1]}"]`).first() + if (await second.count()) { + const other = (await ledgerText(second)).text + check('相关口径:写明是哪一份、差在哪', other.includes('相关口径') && other.includes('口径只保留已支付的行'), + other.replace(/\n/g, ' ')) + } + } + check('页面没有报错', errors.length === 0, errors.slice(0, 2).join(' | ')) + } finally { + await ctx.close() + } +}) + // ---------------------------------------------------------------- 不执行脚本 await section('不执行脚本', async () => {