From 23cdc986fc68348844d96840d4e709af68dec20a Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 18:46:03 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=A1=E6=89=B9=E8=B4=A8=E8=AF=A2=EF=BC=88?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=89=E7=AC=AC=E4=B8=80=E6=AD=A5=EF=BC=9A?= =?UTF-8?q?=E8=B4=A8=E8=AF=A2=E7=9A=84=E7=B1=BB=E5=9E=8B=E4=B8=8E=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E3=80=81=E6=97=B6=E9=97=B4=E7=BA=BF=E8=A7=A3=E8=AF=BB?= =?UTF-8?q?=20approval.inquiry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types.ts 末尾按前后端约定加 Inquiry、InquiryRequest/Response、查询与快照的类型,字段名一个不改 - api/inquiry.ts:提问(POST,不设超时,服务端自有时长上限)、列出问答、取质询引用过的查询快照(外层包不包一层都交给界面认) - decode.ts:approval.inquiry 不再落进「未识别的事件」,解读成顶层一行「审批人质询」:标题带问题前 40 字,副标题说回答完成 / 不完整(写明哪个上限)/ 未能回答、查了几次,封存之后问的写明封存后追加;缺字段、类型不对的照实少说,不拿 0 顶查询次数 - check-decode 断言上述说法与缺字段的兜底;check-trace 加「运行停在审批时来了一条质询,相位仍是等待」:等待段不被收掉、执行时长不涨、恢复后照常跑完、每个前缀上 trace 与 decodePhase 一致 --- frontend/src/api/inquiry.ts | 26 ++++++++- frontend/src/lib/terms.ts | 30 ++++++++++- frontend/src/run/decode.ts | 54 ++++++++++++++++++- frontend/src/types.ts | 105 ++++++++++++++++++++++++++++++++++++ scripts/check-decode.mjs | 77 ++++++++++++++++++++++++++ scripts/check-trace.mjs | 53 ++++++++++++++++++ 6 files changed, 341 insertions(+), 4 deletions(-) diff --git a/frontend/src/api/inquiry.ts b/frontend/src/api/inquiry.ts index 9ec48775..35ac11fa 100644 --- a/frontend/src/api/inquiry.ts +++ b/frontend/src/api/inquiry.ts @@ -1,7 +1,29 @@ /** - * 审批质询的接口(/api/runs/{run_id}/…)。现在是空的,审批质询的工作包往里加,挂在 api.inquiry 上。 + * 审批质询的接口(/api/runs/{run_id}/inquiries…),挂在 api.inquiry 上。 * * 请求函数(get / post / del……)和 ApiError 从 ./http 导入,不要从 ./client 导入:client.ts 要导入 * 这个文件把它挂到 api 上,反过来导入就成了循环(见 http.ts 开头的说明)。 */ -export const inquiry = {} +import type { InquiryList, InquiryRequest, InquiryResponse } from '../types' +import { get, qs, request, type RequestOptions } from './http' + +const run = (id: string) => `/runs/${encodeURIComponent(id)}` + +export const inquiry = { + /** + * 就报告提问。核查模型只读查库作答,通常要几十秒,不设超时:服务端自己有时长上限,到点按查到的结果收尾。 + * 拒绝时 409 带 code(inquiry_not_open:运行没停在这张审批上、正式运行已封存、运行还在执行;inquiry_busy:这次运行 + * 另有一条质询正在回答;inquiry_disabled:设置里关了审批质询),输入不合法 422 带 field,detail 都是一句中文 + */ + ask: (runId: string, body: InquiryRequest) => + request(`${run(runId)}/inquiries`, { method: 'POST', body: JSON.stringify(body) }), + /** 这次运行的问答(封存前后的都算,按时间排)。report、unit 可选,只要某份报告、某一句的 */ + list: (runId: string, filter?: { report?: string; unit?: string }, opts?: RequestOptions) => + get(`${run(runId)}/inquiries${qs(filter)}`, opts), + /** + * 质询引用过的查询快照(遮罩列已遮掉)。不是这次运行的质询引用过的工件回 404。约定没定死外面包不包一层 + * {id, content},原样交回,由界面认(Inquiry.tsx 的 snapshotOf) + */ + snapshot: (runId: string, artifact: string, opts?: RequestOptions) => + get(`${run(runId)}/inquiries/artifacts/${encodeURIComponent(artifact)}`, opts), +} diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index af39ded1..6d2d4fa1 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3274,9 +3274,37 @@ export const BUNDLE_TEXT = { fileName: (name: string) => `${name}-可验证报告.html`, } -/** 审批质询:审批人在批准前就待审的报告提出质询 */ +/** + * 审批质询:审批人在批准前就待审的报告提问(选一句、划一段原文,或就整份报告),核查模型只读查库作答。问答写进运行 + * 记录、随运行一并封存,不改动报告;封存之后只有探索运行还能问,追加在封存之后 + */ export const INQUIRY_TEXT = { title: '审批质询', + + // ---- 时间线(decode.ts 解读 approval.inquiry):标题带问题的前 40 字,副标题说回答是否完成、查了几次 ---- + timeline: (question: string) => (question ? `审批人质询:${question}` : '审批人质询'), + complete: '回答完成', + /** 按已查到的结果收了尾:回答可能缺了没来得及查的部分 */ + incomplete: (limit: string) => `回答不完整(${limit})`, + failed: (limit: string | null) => (limit ? `未能回答(${limit})` : '未能回答'), + /** 服务端没说成没成(缺字段)、却有回答正文:只说答了,不说完成 */ + answered: '已回答', + queries: (n: number) => (n > 0 ? `查询 ${formatNumber(n)} 次` : '未执行查询'), + postSeal: '封存后追加', + limit: { + max_queries: '已达查询次数上限', + timeout_s: '已达时长上限', + max_cost_usd: '已达单次金额上限', + daily_max_usd: '已达核查每日金额上限', + } as Record, + limitOther: '已达上限', + detail: { + question: (q: string) => `问题:${q}`, + quote: (q: string) => `原文:「${q}」`, + answer: (t: string) => `回答:${t}`, + error: (e: string) => `原因:${e}`, + model: (m: string) => `核查模型:${m}`, + }, } /** diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index 33dd7161..91caacbb 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -3,7 +3,7 @@ import type { NodeState, RunPhase } from './trace' import { formatDuration, formatNumber } from '../lib/format' import { ABSENCE_CLAIM_TEXT, CATALOG_DRIFT_TEXT, CATALOG_PATCH_TEXT, COPILOT_CONTEXT_TEXT, JUDGE_TEXT, MERGE_TEXT, RUN_SQL_CHECK_TEXT, SCHEMA_PARTIAL_TEXT, SQL_CHECK_TEXT, TYPE_LABEL, - CATALOG_TABLE_FIELD_LABEL, DUAL_CHECK_TEXT, + CATALOG_TABLE_FIELD_LABEL, DUAL_CHECK_TEXT, INQUIRY_TEXT, claimTally, evidenceTally, issuanceLabel, nodeTypeLabel, } from '../lib/terms' @@ -704,6 +704,52 @@ const JUDGE_FAILED_TITLE = '结论句裁判未能执行' /** 审批行末尾:批的时候点了「始终允许」,这个工具之后不再问人 */ const ALWAYS_NOTE = ',并设为「始终允许 · 门控把关」' +/** 「审批人质询」那一行的标题里,问题最多带几个字(按字数,不按 UTF-16 码元) */ +const INQUIRY_TITLE = 40 + +/** + * approval.inquiry → 一行「审批人质询」:标题带问题的前 40 字,副标题说回答是否完成、查了几次(封存之后问的另写一句)。 + * + * 只认服务端给的:ok 为真才说「回答完成」,带着触顶原因的说不完整、写明是哪个上限;没说成没成(缺字段)又有回答正文的 + * 只说「已回答」。查了几次认 queries,没有再认 inquiry_artifacts,两样都没有就不说次数——不拿 0 去顶。字段类型不对的 + * 当没给:问题不是字符串就只写「审批人质询」 + */ +function inquiryStep(d: Record, seq: number): Step { + const T = INQUIRY_TEXT + const str = (v: unknown) => (typeof v === 'string' ? v.trim() : '') + const question = str(d.question) + const chars = [...question] + const head = chars.length > INQUIRY_TITLE ? `${chars.slice(0, INQUIRY_TITLE).join('')}…` : question + const limit = str(d.limit) + const limitText = limit ? T.limit[limit] ?? T.limitOther : '' + const answer = str(d.answer_text) + const error = str(d.error) + const state = d.ok === true ? (limit ? T.incomplete(limitText) : T.complete) + : d.ok === false || !answer ? T.failed(limitText || null) : T.answered + const runs = Array.isArray(d.queries) ? d.queries.length + : Array.isArray(d.inquiry_artifacts) ? d.inquiry_artifacts.length : null + const fine = !limit && !error && (d.ok === true || (d.ok !== false && !!answer)) + const quote = str(d.quote) + const model = str(d.model) + const doc = str(d.answer_doc) + const detail = [ + question ? T.detail.question(question) : '', + quote ? T.detail.quote(quote) : '', + answer ? T.detail.answer(answer) : '', + error ? T.detail.error(error) : '', + model ? T.detail.model(model) : '', + ].filter(Boolean).join('\n') + return { + id: `iq-${seq}`, seq, kind: 'human', status: 'done', code: 'approval_inquiry', + level: fine ? 'info' : 'warn', + title: T.timeline(head), + sub: [state, runs != null ? T.queries(runs) : '', d.post_seal === true ? T.postSeal : ''].filter(Boolean).join(' · '), + ...(detail ? { detail } : {}), + // 回答组装成的文档(数字带出处):展开区可以下钻 + ...(doc ? { artifact: doc } : {}), + } +} + /** * 门控模型(工具信任档「始终允许 · 门控把关」)对一次调用的结论。team:协作团队里拦下的, * 成员停不下来等人,这次调用不执行。没有工具名(老数据、字段缺了)就说「一次工具调用」 @@ -2112,6 +2158,12 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt break } + case 'approval.inquiry': + // 审批人在批准前就待审的报告提问(审批质询):运行停在审批上时服务端追加这一条,node_id 为空,运行本身没动、 + // 报告也不改(相位照旧,见 nextPhase)。封存之后探索运行也能问,带 post_seal。一条事件就是一问一答,挂在顶层 + out.push(inquiryStep(d, seq)) + break + case 'catalog.drift': { // 从发布版本发起的正式运行:发布之后数据目录有变化。只提醒、运行照常,所以是一条警告,不是失败 const drift = catalogDrift(d) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index cd09e0ba..7edcab4e 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -3690,3 +3690,108 @@ export interface ChallengeArtifact { claims: ChallengeClaim[] model: string | null } + +// --------------------------------------------------------------------------- +// 审批质询:审批人就待审的报告提问,核查模型只读查库作答。形状照前后端共用的接口约定(F5a / F5b 同一份),字段名一个 +// 不改;回答正文、行数、触顶原因都是服务端给的,界面只排版 +// --------------------------------------------------------------------------- + +/** 回答停在哪个上限上:每次最多查询次数、时长、这一次的金额、核查的每日金额 */ +export type InquiryLimit = 'max_queries' | 'timeout_s' | 'max_cost_usd' | 'daily_max_usd' + +/** 质询的回答 */ +export interface InquiryAnswer { + /** 渲染后的正文(数字已按出处换好) */ + text: string + /** compose_doc 组装的文档工件:有它,正文里的数字可以点开看出处;没有(组装失败)只显示 text */ + doc_artifact: string | null + ok: boolean + limit: InquiryLimit | (string & {}) | null + /** 没答成时的中文说明 */ + error: string | null +} + +/** 核查模型为回答执行的一次只读查询。编号是这次质询自己的,快照按质询工件接口取(遮罩列已遮掉) */ +export interface InquiryQuery { + alias: string + artifact: string + sql: string + row_count: number | null + truncated: boolean + /** 数据源名 */ + source: string | null +} + +export interface InquiryUsage { + input_tokens?: number + output_tokens?: number + cost_usd?: number | null + calls?: number + unpriced_calls?: number +} + +/** 一次质询:问的是哪份报告的哪一句(或划选的哪段原文)、问了什么、怎么答的、查了什么 */ +export interface Inquiry { + /** 运行记录里那条 approval.inquiry 事件的序号;没写进运行记录时为 null */ + seq: number | null + /** 什么时候问的(ISO 时间或秒级时间戳) */ + at: string | number | null + /** 提问人的署名;没署名时为 null */ + actor: string | null + /** 报告撰写节点 id */ + report: string + /** 句子单元 id(如 u12);就整份报告提问时为 null */ + unit: string | null + /** 划选的原文 */ + quote: string | null + question: string + answer: InquiryAnswer + queries: InquiryQuery[] + /** 作答的核查模型 */ + model: string | null + usage: InquiryUsage + /** 运行封存之后才问的(只有探索运行可以) */ + post_seal: boolean + /** 提问时停在的那张审批;封存之后问的为 null */ + approval_id: string | null +} + +/** POST /runs/{id}/inquiries 的请求体 */ +export interface InquiryRequest { + report: string + unit: string | null + quote?: string | null + question: string + /** 运行停在审批上时必填:指明是哪一张 */ + approval_id?: string +} + +/** POST /runs/{id}/inquiries 的答复 */ +export interface InquiryResponse { + inquiry: Inquiry + /** 是否写进了运行记录 */ + recorded: boolean + /** 没写进运行记录时的中文说明,否则为 null */ + note: string | null +} + +/** GET /runs/{id}/inquiries 的答复 */ +export interface InquiryList { + items: Inquiry[] +} + +/** + * GET /runs/{id}/inquiries/artifacts/{artifact}:质询引用过的查询快照,遮罩列照证据面板的规则遮掉(值写「已遮罩」)。 + * 约定只说了是快照:可能是快照本身,也可能包在 {id, content} 里(同工件接口),界面两种都认 + */ +export interface InquirySnapshot { + columns: string[] + rows: unknown[][] + sql: string | null + truncated: boolean + /** 结果一共几行(快照的 row_count / total_rows),不给时按 rows 的长度 */ + total: number | null + /** 遮掉的列 */ + masked: string[] + source: string | null +} diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index 779dee2a..5ad21607 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -2181,5 +2181,82 @@ await section('双盲复算与反方审稿的日志(期 3):按 code 和结 check('……缺字段的工件 id 不当成可下钻的工件', !rows.some((s) => s?.artifact != null)) }) +await section('审批质询 approval.inquiry(期 4):一行「审批人质询」,标题带问题前 40 字,副标题说回答是否完成、查了几次', async () => { + // 前后端共用的约定:运行停在审批上时服务端追加 approval.inquiry,node_id 为空;data 里有问题、划选的原文、回答正文、 + // 回答文档、成没成、触顶原因、查询(queries 和 inquiry_artifacts 两份)。以前这类事件落进「未识别的事件」 + const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 1790000000 + seq, data }) + const DOC = 'd0c0'.repeat(16) + const art = (i) => String(i).repeat(64).slice(0, 64) + const q = (i) => ({ alias: `Q${i}`, artifact: art(i), sql: `SELECT ${i}`, row_count: 1, truncated: false, source: 'shop' }) + const LONG = '本月华东区销售额环比增长 12%,其中新客贡献了多少?请按门店拆开说明,并说明是否扣除了退款与优惠券抵扣的金额' + const base = { report: 'write', unit: 'u3', approval_id: 'ap-1', answer_doc: DOC, model: 'verify-mini', usage: { calls: 3 } } + const events = [ + E(1, 'run.started', null, { nodes: 3 }), + E(2, 'node.started', 'h', { node_type: 'human', label: '主管审批' }), + E(3, 'human.requested', 'h', { kind: 'human_node', node_id: 'h', mode: 'approve', title: '月报可以发出吗?' }), + E(4, 'run.interrupted', 'h', { payload: { kind: 'human_node', node_id: 'h' } }), + E(5, 'approval.inquiry', null, { ...base, question: '销售额是否扣除了退款?', quote: null, answer_text: '已扣除退款,退款合计 21,500 元。', + ok: true, limit: null, error: null, inquiry_artifacts: [art(1), art(2)], queries: [q(1), q(2)] }), + E(6, 'approval.inquiry', null, { ...base, question: LONG, quote: '华东区增长最快', answer_text: '按已查到的结果:新客贡献约一半。', + ok: true, limit: 'max_queries', error: null, inquiry_artifacts: [1, 2, 3, 4, 5, 6].map(art), queries: [1, 2, 3, 4, 5, 6].map(q) }), + E(7, 'approval.inquiry', null, { ...base, unit: null, question: '新客数是多少?', quote: null, answer_text: '', answer_doc: null, + ok: false, limit: null, error: '核查模型未配置,无法作答', inquiry_artifacts: [], queries: [] }), + ] + const steps = mod.decodeRun(events) + const all = flatten(steps) + const rows = steps.filter((s) => s.code === 'approval_inquiry') + check('三条质询各一行,挂在顶层(node_id 为空),不再是「未识别的事件」', rows.length === 3 + && !all.some((s) => s.title === '未识别的事件'), steps.map((s) => s.title).join(' | ')) + check('……审批那一行仍在等待,相位仍是 waiting', all.some((s) => s.kind === 'human' && s.nodeId === 'h' && s.status === 'waiting') + && mod.isAwaitingHuman(steps) && mod.decodePhase(events) === 'waiting', mod.decodePhase(events)) + const [a, b, c] = rows + check('标题:「审批人质询:」加问题', a?.title === '审批人质询:销售额是否扣除了退款?', a?.title) + check('长问题只取前 40 字,后面加省略号', b?.title === `审批人质询:${[...LONG].slice(0, 40).join('')}…` && [...LONG].length > 40, b?.title) + check('答完了:副标题写回答完成、查询 2 次,不用提醒色', a?.sub === '回答完成 · 查询 2 次' && a.level === 'info', `${a?.sub} / ${a?.level}`) + check('触顶收尾:回答不完整、写明是哪个上限,查询 6 次,提醒色', b?.sub === '回答不完整(已达查询次数上限) · 查询 6 次' + && b.level === 'warn', `${b?.sub} / ${b?.level}`) + check('没答成:未能回答、未执行查询,提醒色', c?.sub === '未能回答 · 未执行查询' && c.level === 'warn', `${c?.sub} / ${c?.level}`) + check('展开区:完整的问题、划选的原文、回答(没答成的写原因)、核查模型', !!b?.detail?.includes(LONG) && b.detail.includes('原文:「华东区增长最快」') + && b.detail.includes('按已查到的结果') && !!c?.detail?.includes('核查模型未配置,无法作答') && !!a?.detail?.includes('verify-mini'), + `${b?.detail} // ${c?.detail}`) + check('回答文档可下钻,没有文档的不挂工件', a?.artifact === DOC && c?.artifact === undefined, `${a?.artifact} / ${c?.artifact}`) + check('图标同人工审批(kind human),状态完成,不在转圈', rows.every((s) => s.kind === 'human' && s.status === 'done')) + check('标题、副标题里没有内部代码', !rows.some((s) => /approval\.inquiry|approval_inquiry|max_queries|post_seal/.test(`${s.title}${s.sub ?? ''}`))) + + // 其余三个上限、封存之后追加(只有探索运行可以) + const more = mod.decodeRun([ + E(1, 'run.started', null, { nodes: 2 }), E(2, 'run.finished', null, { output: {} }), + E(3, 'approval.inquiry', null, { ...base, approval_id: null, post_seal: true, question: '退款率怎么算的?', answer_text: '退款金额除以订单金额。', + ok: true, limit: null, error: null, queries: [q(1)], inquiry_artifacts: [art(1)] }), + E(4, 'approval.inquiry', null, { ...base, question: '甲', answer_text: '乙', ok: true, limit: 'timeout_s', queries: [q(1)] }), + E(5, 'approval.inquiry', null, { ...base, question: '丙', answer_text: '', ok: false, limit: 'daily_max_usd', queries: [] }), + E(6, 'approval.inquiry', null, { ...base, question: '丁', answer_text: '戊', ok: true, limit: 'max_cost_usd', queries: [q(1), q(2)] }), + ]).filter((s) => s.code === 'approval_inquiry') + check('封存之后问的:副标题写明封存后追加', more[0]?.sub === '回答完成 · 查询 1 次 · 封存后追加', more[0]?.sub) + check('时长上限、每日金额上限、单次金额上限各有说法', more[1]?.sub === '回答不完整(已达时长上限) · 查询 1 次' + && more[2]?.sub === '未能回答(已达核查每日金额上限) · 未执行查询' && more[3]?.sub === '回答不完整(已达单次金额上限) · 查询 2 次', + more.slice(1).map((s) => s.sub).join(' / ')) + check('运行跑完之后来的质询不把相位带回等待', mod.decodePhase([E(1, 'run.started', null, {}), E(2, 'run.finished', null, {}), + E(3, 'approval.inquiry', null, { question: 'x', ok: true })]) === 'succeeded') + + // 缺字段、类型不对:不崩,不出 undefined、NaN、[object Object],不瞎填数 + const odd = [{}, { question: 7, queries: 'x', ok: 'yes' }, { question: ' ', answer_text: 3, inquiry_artifacts: {} }, + { question: '只给了工件', inquiry_artifacts: [art(1), art(2), art(3)] }, { question: '有回答没写成没成', answer_text: '有' }] + let broken = null + let oddRows = [] + try { + oddRows = mod.decodeRun(odd.map((d, i) => E(i + 1, 'approval.inquiry', null, d))).filter((s) => s.code === 'approval_inquiry') + } catch (e) { + broken = String(e?.message ?? e) + } + check('缺字段的质询事件:不抛异常,每条照样一行', broken === null && oddRows.length === odd.length, broken ?? `${oddRows.length} 行`) + check('……问题不是字符串、是空白:标题只写「审批人质询」', oddRows[0]?.title === '审批人质询' && oddRows[1]?.title === '审批人质询' + && oddRows[2]?.title === '审批人质询', oddRows.slice(0, 3).map((s) => s.title).join(' / ')) + check('……前三条不出 undefined、NaN、[object Object],也不瞎填数', !oddRows.slice(0, 3).some((s) => /undefined|NaN|object Object|\d/.test(`${s.title}${s.sub ?? ''}`)), + oddRows.slice(0, 3).map((s) => `${s.title}|${s.sub}`).join(' ; ')) + check('……只给了质询工件:按工件数说查了几次', oddRows[3]?.sub === '未能回答 · 查询 3 次', oddRows[3]?.sub) + check('……有回答、没写成没成:只说已回答,不说完成', oddRows[4]?.sub === '已回答' && oddRows[4].level === 'info', `${oddRows[4]?.sub} / ${oddRows[4]?.level}`) +}) + console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 解码器全部通过') process.exit(failed ? 1 : 0) diff --git a/scripts/check-trace.mjs b/scripts/check-trace.mjs index 57ee3da2..cd601f90 100644 --- a/scripts/check-trace.mjs +++ b/scripts/check-trace.mjs @@ -195,6 +195,59 @@ await section('墙钟 / 执行 / 等人', async () => { project(t, ts('run.interrupted') + 1).nodes.h?.state === 'waiting') }) +await section('等待审批时来了一条质询(approval.inquiry)', async () => { + // 审批人在批准前就报告提问:服务端在运行停在审批上时追加一条 approval.inquiry(node_id 为空),运行本身没动。 + // 它不能把相位从等待里带出来(标题变成「正在执行」、工具栏挂出必然 409 的「停止」),也不能把等待的节点、 + // 等待的那段时长收掉;恢复之后照常接着走 + const e = fixtures.human + const before = upto(e, 6) + const last = before[before.length - 1] + const inquiry = { + seq: last.seq + 1, type: 'approval.inquiry', node_id: null, ts: last.ts + 0.5, + data: { question: '本月销售额是否扣除了退款?', quote: null, report: 'write', unit: 'u3', approval_id: 'ap-1', + answer_text: '已扣除退款。', answer_doc: null, ok: true, limit: null, error: null, + inquiry_artifacts: ['a'.repeat(64)], queries: [{ alias: 'Q1', artifact: 'a'.repeat(64), sql: 'SELECT 1', row_count: 1, + truncated: false, source: 'shop' }], model: 'verify-mini', usage: { calls: 2 } }, + } + const asked = [...before, inquiry] + const t = run(asked, G.human) + check('相位仍是 waiting,等的还是 h', t.phase === 'waiting' && t.waitingNodeId === 'h' && t.nodes.h?.state === 'waiting', + `${t.phase} · ${t.waitingNodeId} · ${t.nodes.h?.state}`) + check('decodePhase 同样是 waiting', decode.decodePhase(asked) === 'waiting', decode.decodePhase(asked)) + check('等待那一段没有被收掉', t.nodes.h?.segments.map((s) => `${s.kind}:${s.end == null ? 'open' : 'closed'}`).join(',') === 'run:closed,wait:open', + t.nodes.h?.segments.map((s) => `${s.kind}:${s.status}`).join(',')) + check('等待节点的入边仍是闸门、没有光点', activeEdgesOf(t, G.human).length === 0 && heldEdgesOf(t, G.human).join(' ') === 'in|h|', + `流动 ${activeEdgesOf(t, G.human).join(' ')} · 闸门 ${heldEdgesOf(t, G.human).join(' ')}`) + const later = project(t, inquiry.ts * 1000 + 5000) + check('质询不算执行:之后墙钟和等待一起涨,执行时长不涨', + later.waitMs >= 5000 && later.elapsedMs - later.waitMs < 50, + `墙钟 ${ms(later.elapsedMs)} · 等待 ${ms(later.waitMs)} · 执行 ${ms(later.activeMs)}`) + const steps = decode.decodeRun(asked) + check('右栏:审批那一行仍在等待', decode.isAwaitingHuman(steps) + && flat(steps).some((s) => s.kind === 'human' && s.nodeId === 'h' && s.status === 'waiting'), + flat(steps).map((s) => `${s.kind}:${s.status}`).join(',')) + check('右栏:质询单独成一行,挂在顶层,不当成未识别的事件', steps.some((s) => s.code === 'approval_inquiry' && s.status === 'done') + && !flat(steps).some((s) => s.title === '未识别的事件'), steps.map((s) => s.title).join(' | ')) + // 恢复之后:后面的事件序号往后挪一位,接在质询后面 + const resumed = [...asked, ...e.filter((x) => x.seq > 6).map((x) => ({ ...x, seq: x.seq + 1 }))] + const done = run(resumed, G.human) + check('恢复后照常跑完:h 只执行一次,一行三段(跑 → 等 → 接着跑)', done.phase === 'succeeded' && done.nodes.h?.count === 1 + && done.nodes.h.segments.map((s) => s.kind).join(',') === 'run,wait,run', + `${done.phase} · ×${done.nodes.h?.count} · ${done.nodes.h?.segments.map((s) => s.kind).join(',')}`) + const p = project(done, LATER) + const wait = (e.find((x) => x.type === 'run.resumed').ts - e.find((x) => x.type === 'run.interrupted').ts) * 1000 + check('等待时长照旧是中断到恢复(质询那一刻不切断等待)', Math.abs(p.waitMs - wait) < 1, `${ms(p.waitMs)} vs ${ms(wait)}`) + let same = true + let where = '' + let tt = emptyTrace() + for (let i = 0; i < resumed.length; i++) { + tt = foldEvent(tt, resumed[i]) + const expect = decode.decodePhase(resumed.slice(0, i + 1)) + if (tt.phase !== expect) { same = false; where = `#${resumed[i].seq}: ${tt.phase} vs ${expect}` } + } + check('每个前缀上 trace.phase === decodePhase(含质询那一条)', same, where) +}) + await section('循环轮次', async () => { const e = fixtures.think const t = run(e, G.think)