mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:01:58 +00:00
审批质询(前端)第一步:质询的类型与接口、时间线解读 approval.inquiry
- types.ts 末尾按前后端约定加 Inquiry、InquiryRequest/Response、查询与快照的类型,字段名一个不改 - api/inquiry.ts:提问(POST,不设超时,服务端自有时长上限)、列出问答、取质询引用过的查询快照(外层包不包一层都交给界面认) - decode.ts:approval.inquiry 不再落进「未识别的事件」,解读成顶层一行「审批人质询」:标题带问题前 40 字,副标题说回答完成 / 不完整(写明哪个上限)/ 未能回答、查了几次,封存之后问的写明封存后追加;缺字段、类型不对的照实少说,不拿 0 顶查询次数 - check-decode 断言上述说法与缺字段的兜底;check-trace 加「运行停在审批时来了一条质询,相位仍是等待」:等待段不被收掉、执行时长不涨、恢复后照常跑完、每个前缀上 trace 与 decodePhase 一致
This commit is contained in:
@@ -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<InquiryResponse>(`${run(runId)}/inquiries`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
/** 这次运行的问答(封存前后的都算,按时间排)。report、unit 可选,只要某份报告、某一句的 */
|
||||
list: (runId: string, filter?: { report?: string; unit?: string }, opts?: RequestOptions) =>
|
||||
get<InquiryList>(`${run(runId)}/inquiries${qs(filter)}`, opts),
|
||||
/**
|
||||
* 质询引用过的查询快照(遮罩列已遮掉)。不是这次运行的质询引用过的工件回 404。约定没定死外面包不包一层
|
||||
* {id, content},原样交回,由界面认(Inquiry.tsx 的 snapshotOf)
|
||||
*/
|
||||
snapshot: (runId: string, artifact: string, opts?: RequestOptions) =>
|
||||
get<unknown>(`${run(runId)}/inquiries/artifacts/${encodeURIComponent(artifact)}`, opts),
|
||||
}
|
||||
|
||||
@@ -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<string, string>,
|
||||
limitOther: '已达上限',
|
||||
detail: {
|
||||
question: (q: string) => `问题:${q}`,
|
||||
quote: (q: string) => `原文:「${q}」`,
|
||||
answer: (t: string) => `回答:${t}`,
|
||||
error: (e: string) => `原因:${e}`,
|
||||
model: (m: string) => `核查模型:${m}`,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, any>, 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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user