From 23cdc986fc68348844d96840d4e709af68dec20a Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 18:46:03 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E5=AE=A1=E6=89=B9=E8=B4=A8=E8=AF=A2?= =?UTF-8?q?=EF=BC=88=E5=89=8D=E7=AB=AF=EF=BC=89=E7=AC=AC=E4=B8=80=E6=AD=A5?= =?UTF-8?q?=EF=BC=9A=E8=B4=A8=E8=AF=A2=E7=9A=84=E7=B1=BB=E5=9E=8B=E4=B8=8E?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E3=80=81=E6=97=B6=E9=97=B4=E7=BA=BF=E8=A7=A3?= =?UTF-8?q?=E8=AF=BB=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) From 3dd5216af29899f9819e4c76d4ed610288376430 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 19:13:07 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E5=AE=A1=E6=89=B9=E8=B4=A8=E8=AF=A2?= =?UTF-8?q?=EF=BC=88=E5=89=8D=E7=AB=AF=EF=BC=89=EF=BC=9A=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=E4=B8=8A=E7=9A=84=E5=85=A5=E5=8F=A3=E3=80=81=E9=97=AE=E7=AD=94?= =?UTF-8?q?=E4=B8=B2=E4=B8=8E=E6=8F=90=E9=97=AE=E6=A1=86=E3=80=81=E5=9B=9E?= =?UTF-8?q?=E7=AD=94=E6=B8=B2=E6=9F=93=EF=BC=8C=E5=AE=A1=E6=89=B9=E5=8D=A1?= =?UTF-8?q?=E4=B8=8E=E8=BF=90=E8=A1=8C=E8=AF=A6=E6=83=85=E7=9A=84=E6=8C=82?= =?UTF-8?q?=E6=B3=95=EF=BC=8Ccheck-inquiry=20=E6=8D=A2=E6=88=90=E7=9C=9F?= =?UTF-8?q?=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - run/Inquiry.tsx:质询的 store(列表按运行与报告缓存、一次运行同一时刻只有一条在回答、请求不随组件卸载作废)、ReportInquiry(问答按句子归拢、同一句下按时间排,进入时按列表接口恢复;就整份报告提问;正在回答的那一条)、提问框(显示被问的那句或划选的原文,Esc 关掉、焦点回到点开它的「质询」,Ctrl+回车提交,字数上限 1000、原文 500,409/422 照服务端的中文说明显示、问题留着)、回答渲染(数字的出处同报告:质询自己的查询点开按质询工件接口取快照、标出那一格;引用报告已有出处的交给页面在报告里打开证据面板;裸数字点状线)、查询列表(SQL、行数、截断、遮罩)、recorded 为假时醒目说明 - EvidenceDoc.tsx 只加入口:父组件传入 inquiry(是否可问与 approval_id)。每句结论后的「质询」平时宽 0、透明,悬停或焦点在这一句上时出现,只在焦点在这一句上时进 Tab 顺序,整份报告不多出一串 Tab 位;没有可点内容的句子,「质询」本身进方向键的顺序;触屏一直显示。划选原文浮出「质询」,原文按选区克隆、去掉徽标和小标签,证据面板里的字不算 - ApprovalReport.tsx:报告核对概况下面挂问答串,逐段证据带上入口;回答里引用报告已有出处时展开逐段证据、在那一处打开证据面板 - 文件所有权之外的两处最小改动(交付说明里写明):RunPanel.tsx 把待处理审批的 id 传给 ApprovalReports,回答期间批准 / 驳回停用并说明原因;pages/runs/EvidencePane.tsx 对封存了的探索运行给入口并挂问答串(判断用 Inquiry.tsx 导出的 inquiryOpen),正式运行不给 - 预览用例 dev/fixtures/inquiry.tsx 与夹具 inquiry.json(报告文档、回答文档由后端 build_catalog + compose_doc 真实组装,虚构数据);check-inquiry.mjs 用 page.route 假接口逐项断言入口、请求体、回答期间停用、问答串恢复、数字取质询工件、报告出处、recorded 为假、409/422、已处理审批与正式运行封存后无入口、390px 与深浅色 --- frontend/src/dev/fixtures/inquiry.json | 4282 ++++++++++++++++++++++ frontend/src/dev/fixtures/inquiry.tsx | 84 + frontend/src/lib/terms.ts | 68 + frontend/src/pages/runs/EvidencePane.tsx | 18 +- frontend/src/run/ApprovalReport.tsx | 52 +- frontend/src/run/EvidenceDoc.tsx | 195 +- frontend/src/run/Inquiry.tsx | 1053 ++++++ frontend/src/run/RunPanel.tsx | 20 +- scripts/check-inquiry.mjs | 741 +++- 9 files changed, 6471 insertions(+), 42 deletions(-) create mode 100644 frontend/src/dev/fixtures/inquiry.json create mode 100644 frontend/src/dev/fixtures/inquiry.tsx create mode 100644 frontend/src/run/Inquiry.tsx diff --git a/frontend/src/dev/fixtures/inquiry.json b/frontend/src/dev/fixtures/inquiry.json new file mode 100644 index 00000000..dcf1c971 --- /dev/null +++ b/frontend/src/dev/fixtures/inquiry.json @@ -0,0 +1,4282 @@ +{ + "//": "审批质询预览用例与 check-inquiry 共用的夹具(frontend/src/dev/fixtures/inquiry.tsx)。报告文档和回答文档由后端 evidence.build_catalog + compose_doc 真实组装(虚构的超市、片区、金额),查询快照照质询工件接口的样子给:遮罩列的值写「已遮罩」,另带 masked。生成脚本不在仓库里:改了文档的形状,照这个思路按后端的输出重新生成", + "runs": { + "approve": "run-inq-approve", + "formal": "run-inq-formal", + "explore": "run-inq-explore", + "sealed": "run-inq-sealed" + }, + "approval_id": "ap-inq-1", + "formal_approval_id": "ap-inq-2", + "reports": { + "approve": { + "node_id": "write", + "label": "写月报", + "doc_artifact": "a89df85369c394d3a4bf65968c59e2c3295af490621edb0cfa4c27b54f30f149", + "stats": { + "units": 8, + "segments": 30, + "claims": 7, + "connective": 0, + "headings": 1, + "numbers": 8, + "numbers_cited": 7, + "values": 0, + "uncited_numbers": 1, + "unresolved": 0, + "see": 0, + "uncited_claims": 3, + "violations": 1 + }, + "claims": "off" + }, + "formal": { + "node_id": "write", + "label": "写月报", + "doc_artifact": "59b8f3417d7466fceef466a0ff23570e329b11fbaa239eaeef3c459d26b83d0d", + "stats": { + "units": 8, + "segments": 30, + "claims": 7, + "connective": 0, + "headings": 1, + "numbers": 8, + "numbers_cited": 7, + "values": 0, + "uncited_numbers": 1, + "unresolved": 0, + "see": 0, + "uncited_claims": 3, + "violations": 1 + }, + "claims": "off" + }, + "explore": { + "node_id": "write", + "label": "写月报", + "doc_artifact": "8cb677489bbaafcafd3b0f0df1939c1ca57cccaa3c065e14c4efe2991a3dd9a9", + "stats": { + "units": 8, + "segments": 30, + "claims": 7, + "connective": 0, + "headings": 1, + "numbers": 8, + "numbers_cited": 7, + "values": 0, + "uncited_numbers": 1, + "unresolved": 0, + "see": 0, + "uncited_claims": 3, + "violations": 1 + }, + "claims": "off" + }, + "sealed": { + "node_id": "write", + "label": "写月报", + "doc_artifact": "2d18aed9086913a6552d335331a70c9805106aa7511e5676ba8e524ee15ebe97", + "stats": { + "units": 8, + "segments": 30, + "claims": 7, + "connective": 0, + "headings": 1, + "numbers": 8, + "numbers_cited": 7, + "values": 0, + "uncited_numbers": 1, + "unresolved": 0, + "see": 0, + "uncited_claims": 3, + "violations": 1 + }, + "claims": "off" + } + }, + "items": [ + { + "seq": 31, + "at": "2026-10-05T06:12:40Z", + "actor": "张工", + "report": "write", + "unit": "u2", + "quote": null, + "question": "退款率的分母是含税销售额还是不含税销售额?", + "answer": { + "text": "退款率按退款金额除以含税销售额计算:9 月退款金额 21,500 元,含税销售额 1,135,000 元,两者之比为 0.0189。含税销售额与报告中的销售额 1,135,000元 一致,口径相同。", + "doc_artifact": "1c66d590eac155dec35e2215e20c6e7497e96d0b2d73894eff1dea3fb641bb51", + "ok": true, + "limit": null, + "error": null + }, + "queries": [ + { + "alias": "Q1", + "artifact": "02ca8aeabca59d785d982b321d22ff889cb2da5a037cd4a080407e9044356434", + "sql": "SELECT SUM(r.amount) AS refund, SUM(o.amount) AS gross, ROUND(SUM(r.amount) * 1.0 / SUM(o.amount), 4) AS rate FROM orders o LEFT JOIN refunds r ON r.order_id = o.id WHERE o.paid_at >= '2026-09-01' AND o.paid_at < '2026-10-01'", + "row_count": 1, + "truncated": false, + "source": "qingshan_mart" + } + ], + "model": "verify-mini", + "usage": { + "input_tokens": 5210, + "output_tokens": 640, + "cost_usd": 0.0042, + "calls": 3, + "unpriced_calls": 0 + }, + "post_seal": false, + "approval_id": "ap-inq-1" + }, + { + "seq": 34, + "at": "2026-10-05T06:20:05Z", + "actor": null, + "report": "write", + "unit": "u3", + "quote": null, + "question": "新客贡献了多少增长?请给出新客与老客各自的销售额。", + "answer": { + "text": "9 月新客销售额 151,000 元,老客 984,000 元,新客约占 13%,单凭这些不足以说明增长主要来自新客。新客中消费最高的会员消费 9,000 元。", + "doc_artifact": "08aadcdf2338b5be5addd826bdc96fa9ec3f410edd629557d5f91e790d6bbb34", + "ok": true, + "limit": "max_queries", + "error": null + }, + "queries": [ + { + "alias": "Q1", + "artifact": "36cf830847abf4b41ae28ab17cd375bebba829662454cf066588cd774c21a399", + "sql": "SELECT customer_type, SUM(amount) AS amount FROM orders WHERE paid_at >= '2026-09-01' AND paid_at < '2026-10-01' GROUP BY customer_type ORDER BY customer_type", + "row_count": 2, + "truncated": false, + "source": "qingshan_mart" + }, + { + "alias": "Q2", + "artifact": "57b0c8fe914a2bf78c2bd6ecbecdbb24758914657962d4b8f62de6303079f892", + "sql": "SELECT member_id, phone, first_order_at, amount FROM orders WHERE customer_type = '新客' ORDER BY amount DESC", + "row_count": 50, + "truncated": true, + "source": "qingshan_mart" + } + ], + "model": "verify-mini", + "usage": { + "input_tokens": 5210, + "output_tokens": 640, + "cost_usd": 0.0042, + "calls": 3, + "unpriced_calls": 0 + }, + "post_seal": false, + "approval_id": "ap-inq-1" + }, + { + "seq": 36, + "at": "2026-10-05T06:25:51Z", + "actor": "张工", + "report": "write", + "unit": "u4", + "quote": "枫岭区销售额最高", + "question": "枫岭区的销售额是否包含线上订单?", + "answer": { + "text": "", + "doc_artifact": null, + "ok": false, + "limit": "daily_max_usd", + "error": "今日核查费用已达上限,未能作答。可在「设置 → 偏好设置 → 核查」中调整每日金额上限" + }, + "queries": [], + "model": "verify-mini", + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "cost_usd": 0, + "calls": 0 + }, + "post_seal": false, + "approval_id": "ap-inq-1" + } + ], + "answer": { + "seq": 40, + "at": "2026-10-05T06:31:12Z", + "actor": "张工", + "report": "write", + "unit": "u1", + "quote": null, + "question": "这里的销售额包含线上订单吗?", + "answer": { + "text": "枫岭区 9 月线上订单销售额 118,000 元,门店 334,000 元,报告中的片区销售额包含线上订单。", + "doc_artifact": "4ed3d62d2af13bbea40195283f3caf63462ca76bb5d4f7ddf1216d36dd5e0463", + "ok": true, + "limit": null, + "error": null + }, + "queries": [ + { + "alias": "Q1", + "artifact": "ca81d2368e8597720aad6fd4eb6a0c33227770aa4a5b3b3219df6ccaa0d23be9", + "sql": "SELECT channel, SUM(amount) AS amount FROM orders WHERE region = '枫岭区' AND paid_at >= '2026-09-01' AND paid_at < '2026-10-01' GROUP BY channel ORDER BY channel", + "row_count": 2, + "truncated": false, + "source": "qingshan_mart" + } + ], + "model": "verify-mini", + "usage": { + "input_tokens": 5210, + "output_tokens": 640, + "cost_usd": 0.0042, + "calls": 3, + "unpriced_calls": 0 + }, + "post_seal": false, + "approval_id": "ap-inq-1" + }, + "explore_items": [ + { + "seq": 58, + "at": "2026-10-05T08:02:33Z", + "actor": "李工", + "report": "write", + "unit": "u2", + "quote": null, + "question": "退款率怎么算的?", + "answer": { + "text": "退款率按退款金额除以含税销售额计算,9 月为 0.0189。", + "doc_artifact": "911780ed990217a2a2e7e3424a9408d23b53a524ba5d1812ec677b7d6b5b80aa", + "ok": true, + "limit": null, + "error": null + }, + "queries": [ + { + "alias": "Q1", + "artifact": "02ca8aeabca59d785d982b321d22ff889cb2da5a037cd4a080407e9044356434", + "sql": "SELECT SUM(r.amount) AS refund, SUM(o.amount) AS gross, ROUND(SUM(r.amount) * 1.0 / SUM(o.amount), 4) AS rate FROM orders o LEFT JOIN refunds r ON r.order_id = o.id WHERE o.paid_at >= '2026-09-01' AND o.paid_at < '2026-10-01'", + "row_count": 1, + "truncated": false, + "source": "qingshan_mart" + } + ], + "model": "verify-mini", + "usage": { + "input_tokens": 5210, + "output_tokens": 640, + "cost_usd": 0.0042, + "calls": 3, + "unpriced_calls": 0 + }, + "post_seal": true, + "approval_id": null + } + ], + "docs": { + "a89df85369c394d3a4bf65968c59e2c3295af490621edb0cfa4c27b54f30f149": { + "schema": "agentlab.report/1", + "entity_syntax": 2, + "provenance": 1, + "run_id": "run-inq-approve", + "node_id": "write", + "markdown": "## 9 月经营概况\n\n9 月销售额 1,135,000元,订单 8,420单。退款金额 21,500元,退款率 1.89%。\n\n增长主要来自新客。\n\n分片区看,枫岭区销售额最高,为 452,000 元,松溪区次之(398,500 元)。临潭区 284,500 元,环比下降 3.2%。\n\n- 枫岭区的客单价高于其余两区\n- 松溪区的订单数最多", + "source": "## 9 月经营概况\n\n9 月销售额 [[m:gmv]],订单 [[m:orders]]。退款金额 [[m:refund]],退款率 [[m:refund_rate]]。\n\n增长主要来自新客。\n\n分片区看,枫岭区销售额最高,为 [[v:Q1.r0.amount]] 元,松溪区次之([[v:Q1.r1.amount]] 元)。临潭区 [[v:Q1.r2.amount]] 元,环比下降 3.2%。\n\n- 枫岭区的客单价高于其余两区\n- 松溪区的订单数最多", + "catalog": { + "m:gmv": { + "alias": "m:gmv", + "kind": "metric", + "eid": "ev:metric:a867f352bc111166", + "node_id": "card", + "artifact": "757e03414694c3b3480236718d32082ba449e7712d9ce99f4aee39ffdc5c3f4e", + "locator": { + "metric": "gmv" + }, + "caliber": "月报口径", + "version": "v2", + "name": "销售额", + "unit": "元", + "decimals": 0, + "format": "thousands", + "value": 1135000, + "status": "ok", + "rendered": "1,135,000元", + "label": "销售额 = 1,135,000元" + }, + "m:orders": { + "alias": "m:orders", + "kind": "metric", + "eid": "ev:metric:bb840f0fb915d4b1", + "node_id": "card", + "artifact": "757e03414694c3b3480236718d32082ba449e7712d9ce99f4aee39ffdc5c3f4e", + "locator": { + "metric": "orders" + }, + "caliber": "月报口径", + "version": "v2", + "name": "订单数", + "unit": "单", + "decimals": 0, + "format": "thousands", + "value": 8420, + "status": "ok", + "rendered": "8,420单", + "label": "订单数 = 8,420单" + }, + "m:refund": { + "alias": "m:refund", + "kind": "metric", + "eid": "ev:metric:82118fae68692f09", + "node_id": "card", + "artifact": "757e03414694c3b3480236718d32082ba449e7712d9ce99f4aee39ffdc5c3f4e", + "locator": { + "metric": "refund" + }, + "caliber": "月报口径", + "version": "v2", + "name": "退款金额", + "unit": "元", + "decimals": 0, + "format": "thousands", + "value": 21500, + "status": "ok", + "rendered": "21,500元", + "label": "退款金额 = 21,500元" + }, + "m:refund_rate": { + "alias": "m:refund_rate", + "kind": "metric", + "eid": "ev:metric:80676887dbac8214", + "node_id": "card", + "artifact": "757e03414694c3b3480236718d32082ba449e7712d9ce99f4aee39ffdc5c3f4e", + "locator": { + "metric": "refund_rate" + }, + "caliber": "月报口径", + "version": "v2", + "name": "退款率", + "unit": "", + "decimals": 4, + "format": "percent_of_ratio", + "value": 0.0189, + "status": "ok", + "rendered": "1.89%", + "label": "退款率 = 1.89%" + }, + "Q1": { + "alias": "Q1", + "kind": "query", + "eid": "ev:query:4658b8445675a26b", + "locator": {}, + "node_id": "regions", + "exec": 1, + "artifact": "67b5757e2f07807b9eb47cc04ac6c34556773ae3224aca042bb91664857a2e65", + "tool": "db_query__qingshan_mart", + "source": "qingshan_mart", + "columns": [ + "region", + "amount" + ], + "rows": 3, + "truncated": false, + "label": "db_query__qingshan_mart · 3 行" + } + }, + "blocks": [ + { + "type": "heading", + "level": 2, + "units": [ + { + "id": "u0", + "kind": "heading", + "span": [ + 3, + 10 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "## ", + "state": "neutral", + "span": [ + 0, + 3 + ], + "id": "s0" + }, + { + "kind": "text", + "text": "9 月经营概况", + "state": "neutral", + "span": [ + 3, + 10 + ], + "id": "s1" + } + ] + } + ], + "id": "b0" + }, + { + "type": "paragraph", + "units": [ + { + "id": "u1", + "kind": "claim", + "span": [ + 12, + 40 + ], + "cites": [ + "m:gmv", + "m:orders" + ], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n\n", + "state": "neutral", + "span": [ + 10, + 12 + ], + "id": "s2" + }, + { + "kind": "text", + "text": "9 月销售额 ", + "state": "neutral", + "span": [ + 12, + 19 + ], + "id": "s3" + }, + { + "kind": "number", + "text": "1,135,000元", + "ref": "m:gmv", + "cite": { + "ref": "gmv", + "alias": "m:gmv", + "locator": { + "metric": "gmv" + }, + "eid": "ev:metric:a867f352bc111166", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 1135000, + "rendered": "1,135,000元" + }, + "state": "deterministic", + "span": [ + 19, + 29 + ], + "id": "s4" + }, + { + "kind": "text", + "text": ",订单 ", + "state": "neutral", + "span": [ + 29, + 33 + ], + "id": "s5" + }, + { + "kind": "number", + "text": "8,420单", + "ref": "m:orders", + "cite": { + "ref": "orders", + "alias": "m:orders", + "locator": { + "metric": "orders" + }, + "eid": "ev:metric:bb840f0fb915d4b1", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 8420, + "rendered": "8,420单" + }, + "state": "deterministic", + "span": [ + 33, + 39 + ], + "id": "s6" + }, + { + "kind": "text", + "text": "。", + "state": "neutral", + "span": [ + 39, + 40 + ], + "id": "s7" + } + ] + }, + { + "id": "u2", + "kind": "claim", + "span": [ + 40, + 63 + ], + "cites": [ + "m:refund", + "m:refund_rate" + ], + "see": [], + "segments": [ + { + "kind": "text", + "text": "退款金额 ", + "state": "neutral", + "span": [ + 40, + 45 + ], + "id": "s8" + }, + { + "kind": "number", + "text": "21,500元", + "ref": "m:refund", + "cite": { + "ref": "refund", + "alias": "m:refund", + "locator": { + "metric": "refund" + }, + "eid": "ev:metric:82118fae68692f09", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 21500, + "rendered": "21,500元" + }, + "state": "deterministic", + "span": [ + 45, + 52 + ], + "id": "s9" + }, + { + "kind": "text", + "text": ",退款率 ", + "state": "neutral", + "span": [ + 52, + 57 + ], + "id": "s10" + }, + { + "kind": "number", + "text": "1.89%", + "ref": "m:refund_rate", + "cite": { + "ref": "refund_rate", + "alias": "m:refund_rate", + "locator": { + "metric": "refund_rate" + }, + "eid": "ev:metric:80676887dbac8214", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 0.0189, + "rendered": "1.89%" + }, + "state": "deterministic", + "span": [ + 57, + 62 + ], + "id": "s11" + }, + { + "kind": "text", + "text": "。", + "state": "neutral", + "span": [ + 62, + 63 + ], + "id": "s12" + } + ] + } + ], + "id": "b1" + }, + { + "type": "paragraph", + "units": [ + { + "id": "u3", + "kind": "claim", + "span": [ + 65, + 74 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n\n", + "state": "neutral", + "span": [ + 63, + 65 + ], + "id": "s13" + }, + { + "kind": "text", + "text": "增长主要来自新客。", + "state": "neutral", + "span": [ + 65, + 74 + ], + "id": "s14" + } + ] + } + ], + "id": "b2" + }, + { + "type": "paragraph", + "units": [ + { + "id": "u4", + "kind": "claim", + "span": [ + 76, + 119 + ], + "cites": [ + "Q1" + ], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n\n", + "state": "neutral", + "span": [ + 74, + 76 + ], + "id": "s15" + }, + { + "kind": "text", + "text": "分片区看,枫岭区销售额最高,为 ", + "state": "neutral", + "span": [ + 76, + 92 + ], + "id": "s16" + }, + { + "kind": "number", + "text": "452,000", + "ref": "v:Q1.r0.amount", + "cite": { + "ref": "Q1.r0.amount", + "alias": "Q1", + "locator": { + "row": 0, + "column": "amount" + }, + "eid": "ev:cell:0a1e7f806f2eb24a", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 452000, + "rendered": "452,000" + }, + "state": "deterministic", + "span": [ + 92, + 99 + ], + "id": "s17" + }, + { + "kind": "text", + "text": " 元,松溪区次之(", + "state": "neutral", + "span": [ + 99, + 108 + ], + "id": "s18" + }, + { + "kind": "number", + "text": "398,500", + "ref": "v:Q1.r1.amount", + "cite": { + "ref": "Q1.r1.amount", + "alias": "Q1", + "locator": { + "row": 1, + "column": "amount" + }, + "eid": "ev:cell:9df40facfe235170", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 398500, + "rendered": "398,500" + }, + "state": "deterministic", + "span": [ + 108, + 115 + ], + "id": "s19" + }, + { + "kind": "text", + "text": " 元)。", + "state": "neutral", + "span": [ + 115, + 119 + ], + "id": "s20" + } + ] + }, + { + "id": "u5", + "kind": "claim", + "span": [ + 119, + 143 + ], + "cites": [ + "Q1" + ], + "see": [], + "segments": [ + { + "kind": "text", + "text": "临潭区 ", + "state": "neutral", + "span": [ + 119, + 123 + ], + "id": "s21" + }, + { + "kind": "number", + "text": "284,500", + "ref": "v:Q1.r2.amount", + "cite": { + "ref": "Q1.r2.amount", + "alias": "Q1", + "locator": { + "row": 2, + "column": "amount" + }, + "eid": "ev:cell:7b9793466ab9ba7b", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 284500, + "rendered": "284,500" + }, + "state": "deterministic", + "span": [ + 123, + 130 + ], + "id": "s22" + }, + { + "kind": "text", + "text": " 元,环比下降 ", + "span": [ + 130, + 138 + ], + "state": "neutral", + "id": "s23" + }, + { + "kind": "number", + "text": "3.2%", + "span": [ + 138, + 142 + ], + "state": "none", + "issue": "uncited_number", + "id": "s24" + }, + { + "kind": "text", + "text": "。", + "span": [ + 142, + 143 + ], + "state": "neutral", + "id": "s25" + } + ] + } + ], + "id": "b3" + }, + { + "type": "list", + "ordered": false, + "units": [ + { + "id": "u6", + "kind": "claim", + "span": [ + 147, + 160 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n\n- ", + "state": "neutral", + "span": [ + 143, + 147 + ], + "id": "s26" + }, + { + "kind": "text", + "text": "枫岭区的客单价高于其余两区", + "state": "neutral", + "span": [ + 147, + 160 + ], + "id": "s27" + } + ], + "depth": 0 + }, + { + "id": "u7", + "kind": "claim", + "span": [ + 163, + 172 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n- ", + "state": "neutral", + "span": [ + 160, + 163 + ], + "id": "s28" + }, + { + "kind": "text", + "text": "松溪区的订单数最多", + "state": "neutral", + "span": [ + 163, + 172 + ], + "id": "s29" + } + ], + "depth": 0 + } + ], + "id": "b4" + } + ], + "stats": { + "units": 8, + "segments": 30, + "claims": 7, + "connective": 0, + "headings": 1, + "numbers": 8, + "numbers_cited": 7, + "values": 0, + "uncited_numbers": 1, + "unresolved": 0, + "see": 0, + "uncited_claims": 3, + "violations": 1 + }, + "violations": [ + { + "code": "uncited_number", + "message": "数字「3.2%」没有出处:报告中直接写出了数字,系统无法核对", + "for_model": "数字「3.2%」没有出处:要写成引用标记(比如 [[m:指标id]]),不能直接写数字", + "span": [ + 138, + 142 + ], + "text": "3.2%", + "context": "潭区 284,500 元,环比下降 3.2%。 - 枫岭区的客单价高于其余两区", + "segment": "s24", + "unit": "u5" + } + ] + }, + "59b8f3417d7466fceef466a0ff23570e329b11fbaa239eaeef3c459d26b83d0d": { + "schema": "agentlab.report/1", + "entity_syntax": 2, + "provenance": 1, + "run_id": "run-inq-formal", + "node_id": "write", + "markdown": "## 9 月经营概况\n\n9 月销售额 1,135,000元,订单 8,420单。退款金额 21,500元,退款率 1.89%。\n\n增长主要来自新客。\n\n分片区看,枫岭区销售额最高,为 452,000 元,松溪区次之(398,500 元)。临潭区 284,500 元,环比下降 3.2%。\n\n- 枫岭区的客单价高于其余两区\n- 松溪区的订单数最多", + "source": "## 9 月经营概况\n\n9 月销售额 [[m:gmv]],订单 [[m:orders]]。退款金额 [[m:refund]],退款率 [[m:refund_rate]]。\n\n增长主要来自新客。\n\n分片区看,枫岭区销售额最高,为 [[v:Q1.r0.amount]] 元,松溪区次之([[v:Q1.r1.amount]] 元)。临潭区 [[v:Q1.r2.amount]] 元,环比下降 3.2%。\n\n- 枫岭区的客单价高于其余两区\n- 松溪区的订单数最多", + "catalog": { + "m:gmv": { + "alias": "m:gmv", + "kind": "metric", + "eid": "ev:metric:a867f352bc111166", + "node_id": "card", + "artifact": "757e03414694c3b3480236718d32082ba449e7712d9ce99f4aee39ffdc5c3f4e", + "locator": { + "metric": "gmv" + }, + "caliber": "月报口径", + "version": "v2", + "name": "销售额", + "unit": "元", + "decimals": 0, + "format": "thousands", + "value": 1135000, + "status": "ok", + "rendered": "1,135,000元", + "label": "销售额 = 1,135,000元" + }, + "m:orders": { + "alias": "m:orders", + "kind": "metric", + "eid": "ev:metric:bb840f0fb915d4b1", + "node_id": "card", + "artifact": "757e03414694c3b3480236718d32082ba449e7712d9ce99f4aee39ffdc5c3f4e", + "locator": { + "metric": "orders" + }, + "caliber": "月报口径", + "version": "v2", + "name": "订单数", + "unit": "单", + "decimals": 0, + "format": "thousands", + "value": 8420, + "status": "ok", + "rendered": "8,420单", + "label": "订单数 = 8,420单" + }, + "m:refund": { + "alias": "m:refund", + "kind": "metric", + "eid": "ev:metric:82118fae68692f09", + "node_id": "card", + "artifact": "757e03414694c3b3480236718d32082ba449e7712d9ce99f4aee39ffdc5c3f4e", + "locator": { + "metric": "refund" + }, + "caliber": "月报口径", + "version": "v2", + "name": "退款金额", + "unit": "元", + "decimals": 0, + "format": "thousands", + "value": 21500, + "status": "ok", + "rendered": "21,500元", + "label": "退款金额 = 21,500元" + }, + "m:refund_rate": { + "alias": "m:refund_rate", + "kind": "metric", + "eid": "ev:metric:80676887dbac8214", + "node_id": "card", + "artifact": "757e03414694c3b3480236718d32082ba449e7712d9ce99f4aee39ffdc5c3f4e", + "locator": { + "metric": "refund_rate" + }, + "caliber": "月报口径", + "version": "v2", + "name": "退款率", + "unit": "", + "decimals": 4, + "format": "percent_of_ratio", + "value": 0.0189, + "status": "ok", + "rendered": "1.89%", + "label": "退款率 = 1.89%" + }, + "Q1": { + "alias": "Q1", + "kind": "query", + "eid": "ev:query:4658b8445675a26b", + "locator": {}, + "node_id": "regions", + "exec": 1, + "artifact": "67b5757e2f07807b9eb47cc04ac6c34556773ae3224aca042bb91664857a2e65", + "tool": "db_query__qingshan_mart", + "source": "qingshan_mart", + "columns": [ + "region", + "amount" + ], + "rows": 3, + "truncated": false, + "label": "db_query__qingshan_mart · 3 行" + } + }, + "blocks": [ + { + "type": "heading", + "level": 2, + "units": [ + { + "id": "u0", + "kind": "heading", + "span": [ + 3, + 10 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "## ", + "state": "neutral", + "span": [ + 0, + 3 + ], + "id": "s0" + }, + { + "kind": "text", + "text": "9 月经营概况", + "state": "neutral", + "span": [ + 3, + 10 + ], + "id": "s1" + } + ] + } + ], + "id": "b0" + }, + { + "type": "paragraph", + "units": [ + { + "id": "u1", + "kind": "claim", + "span": [ + 12, + 40 + ], + "cites": [ + "m:gmv", + "m:orders" + ], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n\n", + "state": "neutral", + "span": [ + 10, + 12 + ], + "id": "s2" + }, + { + "kind": "text", + "text": "9 月销售额 ", + "state": "neutral", + "span": [ + 12, + 19 + ], + "id": "s3" + }, + { + "kind": "number", + "text": "1,135,000元", + "ref": "m:gmv", + "cite": { + "ref": "gmv", + "alias": "m:gmv", + "locator": { + "metric": "gmv" + }, + "eid": "ev:metric:a867f352bc111166", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 1135000, + "rendered": "1,135,000元" + }, + "state": "deterministic", + "span": [ + 19, + 29 + ], + "id": "s4" + }, + { + "kind": "text", + "text": ",订单 ", + "state": "neutral", + "span": [ + 29, + 33 + ], + "id": "s5" + }, + { + "kind": "number", + "text": "8,420单", + "ref": "m:orders", + "cite": { + "ref": "orders", + "alias": "m:orders", + "locator": { + "metric": "orders" + }, + "eid": "ev:metric:bb840f0fb915d4b1", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 8420, + "rendered": "8,420单" + }, + "state": "deterministic", + "span": [ + 33, + 39 + ], + "id": "s6" + }, + { + "kind": "text", + "text": "。", + "state": "neutral", + "span": [ + 39, + 40 + ], + "id": "s7" + } + ] + }, + { + "id": "u2", + "kind": "claim", + "span": [ + 40, + 63 + ], + "cites": [ + "m:refund", + "m:refund_rate" + ], + "see": [], + "segments": [ + { + "kind": "text", + "text": "退款金额 ", + "state": "neutral", + "span": [ + 40, + 45 + ], + "id": "s8" + }, + { + "kind": "number", + "text": "21,500元", + "ref": "m:refund", + "cite": { + "ref": "refund", + "alias": "m:refund", + "locator": { + "metric": "refund" + }, + "eid": "ev:metric:82118fae68692f09", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 21500, + "rendered": "21,500元" + }, + "state": "deterministic", + "span": [ + 45, + 52 + ], + "id": "s9" + }, + { + "kind": "text", + "text": ",退款率 ", + "state": "neutral", + "span": [ + 52, + 57 + ], + "id": "s10" + }, + { + "kind": "number", + "text": "1.89%", + "ref": "m:refund_rate", + "cite": { + "ref": "refund_rate", + "alias": "m:refund_rate", + "locator": { + "metric": "refund_rate" + }, + "eid": "ev:metric:80676887dbac8214", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 0.0189, + "rendered": "1.89%" + }, + "state": "deterministic", + "span": [ + 57, + 62 + ], + "id": "s11" + }, + { + "kind": "text", + "text": "。", + "state": "neutral", + "span": [ + 62, + 63 + ], + "id": "s12" + } + ] + } + ], + "id": "b1" + }, + { + "type": "paragraph", + "units": [ + { + "id": "u3", + "kind": "claim", + "span": [ + 65, + 74 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n\n", + "state": "neutral", + "span": [ + 63, + 65 + ], + "id": "s13" + }, + { + "kind": "text", + "text": "增长主要来自新客。", + "state": "neutral", + "span": [ + 65, + 74 + ], + "id": "s14" + } + ] + } + ], + "id": "b2" + }, + { + "type": "paragraph", + "units": [ + { + "id": "u4", + "kind": "claim", + "span": [ + 76, + 119 + ], + "cites": [ + "Q1" + ], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n\n", + "state": "neutral", + "span": [ + 74, + 76 + ], + "id": "s15" + }, + { + "kind": "text", + "text": "分片区看,枫岭区销售额最高,为 ", + "state": "neutral", + "span": [ + 76, + 92 + ], + "id": "s16" + }, + { + "kind": "number", + "text": "452,000", + "ref": "v:Q1.r0.amount", + "cite": { + "ref": "Q1.r0.amount", + "alias": "Q1", + "locator": { + "row": 0, + "column": "amount" + }, + "eid": "ev:cell:0a1e7f806f2eb24a", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 452000, + "rendered": "452,000" + }, + "state": "deterministic", + "span": [ + 92, + 99 + ], + "id": "s17" + }, + { + "kind": "text", + "text": " 元,松溪区次之(", + "state": "neutral", + "span": [ + 99, + 108 + ], + "id": "s18" + }, + { + "kind": "number", + "text": "398,500", + "ref": "v:Q1.r1.amount", + "cite": { + "ref": "Q1.r1.amount", + "alias": "Q1", + "locator": { + "row": 1, + "column": "amount" + }, + "eid": "ev:cell:9df40facfe235170", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 398500, + "rendered": "398,500" + }, + "state": "deterministic", + "span": [ + 108, + 115 + ], + "id": "s19" + }, + { + "kind": "text", + "text": " 元)。", + "state": "neutral", + "span": [ + 115, + 119 + ], + "id": "s20" + } + ] + }, + { + "id": "u5", + "kind": "claim", + "span": [ + 119, + 143 + ], + "cites": [ + "Q1" + ], + "see": [], + "segments": [ + { + "kind": "text", + "text": "临潭区 ", + "state": "neutral", + "span": [ + 119, + 123 + ], + "id": "s21" + }, + { + "kind": "number", + "text": "284,500", + "ref": "v:Q1.r2.amount", + "cite": { + "ref": "Q1.r2.amount", + "alias": "Q1", + "locator": { + "row": 2, + "column": "amount" + }, + "eid": "ev:cell:7b9793466ab9ba7b", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 284500, + "rendered": "284,500" + }, + "state": "deterministic", + "span": [ + 123, + 130 + ], + "id": "s22" + }, + { + "kind": "text", + "text": " 元,环比下降 ", + "span": [ + 130, + 138 + ], + "state": "neutral", + "id": "s23" + }, + { + "kind": "number", + "text": "3.2%", + "span": [ + 138, + 142 + ], + "state": "none", + "issue": "uncited_number", + "id": "s24" + }, + { + "kind": "text", + "text": "。", + "span": [ + 142, + 143 + ], + "state": "neutral", + "id": "s25" + } + ] + } + ], + "id": "b3" + }, + { + "type": "list", + "ordered": false, + "units": [ + { + "id": "u6", + "kind": "claim", + "span": [ + 147, + 160 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n\n- ", + "state": "neutral", + "span": [ + 143, + 147 + ], + "id": "s26" + }, + { + "kind": "text", + "text": "枫岭区的客单价高于其余两区", + "state": "neutral", + "span": [ + 147, + 160 + ], + "id": "s27" + } + ], + "depth": 0 + }, + { + "id": "u7", + "kind": "claim", + "span": [ + 163, + 172 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n- ", + "state": "neutral", + "span": [ + 160, + 163 + ], + "id": "s28" + }, + { + "kind": "text", + "text": "松溪区的订单数最多", + "state": "neutral", + "span": [ + 163, + 172 + ], + "id": "s29" + } + ], + "depth": 0 + } + ], + "id": "b4" + } + ], + "stats": { + "units": 8, + "segments": 30, + "claims": 7, + "connective": 0, + "headings": 1, + "numbers": 8, + "numbers_cited": 7, + "values": 0, + "uncited_numbers": 1, + "unresolved": 0, + "see": 0, + "uncited_claims": 3, + "violations": 1 + }, + "violations": [ + { + "code": "uncited_number", + "message": "数字「3.2%」没有出处:报告中直接写出了数字,系统无法核对", + "for_model": "数字「3.2%」没有出处:要写成引用标记(比如 [[m:指标id]]),不能直接写数字", + "span": [ + 138, + 142 + ], + "text": "3.2%", + "context": "潭区 284,500 元,环比下降 3.2%。 - 枫岭区的客单价高于其余两区", + "segment": "s24", + "unit": "u5" + } + ] + }, + "8cb677489bbaafcafd3b0f0df1939c1ca57cccaa3c065e14c4efe2991a3dd9a9": { + "schema": "agentlab.report/1", + "entity_syntax": 2, + "provenance": 1, + "run_id": "run-inq-explore", + "node_id": "write", + "markdown": "## 9 月经营概况\n\n9 月销售额 1,135,000元,订单 8,420单。退款金额 21,500元,退款率 1.89%。\n\n增长主要来自新客。\n\n分片区看,枫岭区销售额最高,为 452,000 元,松溪区次之(398,500 元)。临潭区 284,500 元,环比下降 3.2%。\n\n- 枫岭区的客单价高于其余两区\n- 松溪区的订单数最多", + "source": "## 9 月经营概况\n\n9 月销售额 [[m:gmv]],订单 [[m:orders]]。退款金额 [[m:refund]],退款率 [[m:refund_rate]]。\n\n增长主要来自新客。\n\n分片区看,枫岭区销售额最高,为 [[v:Q1.r0.amount]] 元,松溪区次之([[v:Q1.r1.amount]] 元)。临潭区 [[v:Q1.r2.amount]] 元,环比下降 3.2%。\n\n- 枫岭区的客单价高于其余两区\n- 松溪区的订单数最多", + "catalog": { + "m:gmv": { + "alias": "m:gmv", + "kind": "metric", + "eid": "ev:metric:a867f352bc111166", + "node_id": "card", + "artifact": "757e03414694c3b3480236718d32082ba449e7712d9ce99f4aee39ffdc5c3f4e", + "locator": { + "metric": "gmv" + }, + "caliber": "月报口径", + "version": "v2", + "name": "销售额", + "unit": "元", + "decimals": 0, + "format": "thousands", + "value": 1135000, + "status": "ok", + "rendered": "1,135,000元", + "label": "销售额 = 1,135,000元" + }, + "m:orders": { + "alias": "m:orders", + "kind": "metric", + "eid": "ev:metric:bb840f0fb915d4b1", + "node_id": "card", + "artifact": "757e03414694c3b3480236718d32082ba449e7712d9ce99f4aee39ffdc5c3f4e", + "locator": { + "metric": "orders" + }, + "caliber": "月报口径", + "version": "v2", + "name": "订单数", + "unit": "单", + "decimals": 0, + "format": "thousands", + "value": 8420, + "status": "ok", + "rendered": "8,420单", + "label": "订单数 = 8,420单" + }, + "m:refund": { + "alias": "m:refund", + "kind": "metric", + "eid": "ev:metric:82118fae68692f09", + "node_id": "card", + "artifact": "757e03414694c3b3480236718d32082ba449e7712d9ce99f4aee39ffdc5c3f4e", + "locator": { + "metric": "refund" + }, + "caliber": "月报口径", + "version": "v2", + "name": "退款金额", + "unit": "元", + "decimals": 0, + "format": "thousands", + "value": 21500, + "status": "ok", + "rendered": "21,500元", + "label": "退款金额 = 21,500元" + }, + "m:refund_rate": { + "alias": "m:refund_rate", + "kind": "metric", + "eid": "ev:metric:80676887dbac8214", + "node_id": "card", + "artifact": "757e03414694c3b3480236718d32082ba449e7712d9ce99f4aee39ffdc5c3f4e", + "locator": { + "metric": "refund_rate" + }, + "caliber": "月报口径", + "version": "v2", + "name": "退款率", + "unit": "", + "decimals": 4, + "format": "percent_of_ratio", + "value": 0.0189, + "status": "ok", + "rendered": "1.89%", + "label": "退款率 = 1.89%" + }, + "Q1": { + "alias": "Q1", + "kind": "query", + "eid": "ev:query:4658b8445675a26b", + "locator": {}, + "node_id": "regions", + "exec": 1, + "artifact": "67b5757e2f07807b9eb47cc04ac6c34556773ae3224aca042bb91664857a2e65", + "tool": "db_query__qingshan_mart", + "source": "qingshan_mart", + "columns": [ + "region", + "amount" + ], + "rows": 3, + "truncated": false, + "label": "db_query__qingshan_mart · 3 行" + } + }, + "blocks": [ + { + "type": "heading", + "level": 2, + "units": [ + { + "id": "u0", + "kind": "heading", + "span": [ + 3, + 10 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "## ", + "state": "neutral", + "span": [ + 0, + 3 + ], + "id": "s0" + }, + { + "kind": "text", + "text": "9 月经营概况", + "state": "neutral", + "span": [ + 3, + 10 + ], + "id": "s1" + } + ] + } + ], + "id": "b0" + }, + { + "type": "paragraph", + "units": [ + { + "id": "u1", + "kind": "claim", + "span": [ + 12, + 40 + ], + "cites": [ + "m:gmv", + "m:orders" + ], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n\n", + "state": "neutral", + "span": [ + 10, + 12 + ], + "id": "s2" + }, + { + "kind": "text", + "text": "9 月销售额 ", + "state": "neutral", + "span": [ + 12, + 19 + ], + "id": "s3" + }, + { + "kind": "number", + "text": "1,135,000元", + "ref": "m:gmv", + "cite": { + "ref": "gmv", + "alias": "m:gmv", + "locator": { + "metric": "gmv" + }, + "eid": "ev:metric:a867f352bc111166", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 1135000, + "rendered": "1,135,000元" + }, + "state": "deterministic", + "span": [ + 19, + 29 + ], + "id": "s4" + }, + { + "kind": "text", + "text": ",订单 ", + "state": "neutral", + "span": [ + 29, + 33 + ], + "id": "s5" + }, + { + "kind": "number", + "text": "8,420单", + "ref": "m:orders", + "cite": { + "ref": "orders", + "alias": "m:orders", + "locator": { + "metric": "orders" + }, + "eid": "ev:metric:bb840f0fb915d4b1", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 8420, + "rendered": "8,420单" + }, + "state": "deterministic", + "span": [ + 33, + 39 + ], + "id": "s6" + }, + { + "kind": "text", + "text": "。", + "state": "neutral", + "span": [ + 39, + 40 + ], + "id": "s7" + } + ] + }, + { + "id": "u2", + "kind": "claim", + "span": [ + 40, + 63 + ], + "cites": [ + "m:refund", + "m:refund_rate" + ], + "see": [], + "segments": [ + { + "kind": "text", + "text": "退款金额 ", + "state": "neutral", + "span": [ + 40, + 45 + ], + "id": "s8" + }, + { + "kind": "number", + "text": "21,500元", + "ref": "m:refund", + "cite": { + "ref": "refund", + "alias": "m:refund", + "locator": { + "metric": "refund" + }, + "eid": "ev:metric:82118fae68692f09", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 21500, + "rendered": "21,500元" + }, + "state": "deterministic", + "span": [ + 45, + 52 + ], + "id": "s9" + }, + { + "kind": "text", + "text": ",退款率 ", + "state": "neutral", + "span": [ + 52, + 57 + ], + "id": "s10" + }, + { + "kind": "number", + "text": "1.89%", + "ref": "m:refund_rate", + "cite": { + "ref": "refund_rate", + "alias": "m:refund_rate", + "locator": { + "metric": "refund_rate" + }, + "eid": "ev:metric:80676887dbac8214", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 0.0189, + "rendered": "1.89%" + }, + "state": "deterministic", + "span": [ + 57, + 62 + ], + "id": "s11" + }, + { + "kind": "text", + "text": "。", + "state": "neutral", + "span": [ + 62, + 63 + ], + "id": "s12" + } + ] + } + ], + "id": "b1" + }, + { + "type": "paragraph", + "units": [ + { + "id": "u3", + "kind": "claim", + "span": [ + 65, + 74 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n\n", + "state": "neutral", + "span": [ + 63, + 65 + ], + "id": "s13" + }, + { + "kind": "text", + "text": "增长主要来自新客。", + "state": "neutral", + "span": [ + 65, + 74 + ], + "id": "s14" + } + ] + } + ], + "id": "b2" + }, + { + "type": "paragraph", + "units": [ + { + "id": "u4", + "kind": "claim", + "span": [ + 76, + 119 + ], + "cites": [ + "Q1" + ], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n\n", + "state": "neutral", + "span": [ + 74, + 76 + ], + "id": "s15" + }, + { + "kind": "text", + "text": "分片区看,枫岭区销售额最高,为 ", + "state": "neutral", + "span": [ + 76, + 92 + ], + "id": "s16" + }, + { + "kind": "number", + "text": "452,000", + "ref": "v:Q1.r0.amount", + "cite": { + "ref": "Q1.r0.amount", + "alias": "Q1", + "locator": { + "row": 0, + "column": "amount" + }, + "eid": "ev:cell:0a1e7f806f2eb24a", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 452000, + "rendered": "452,000" + }, + "state": "deterministic", + "span": [ + 92, + 99 + ], + "id": "s17" + }, + { + "kind": "text", + "text": " 元,松溪区次之(", + "state": "neutral", + "span": [ + 99, + 108 + ], + "id": "s18" + }, + { + "kind": "number", + "text": "398,500", + "ref": "v:Q1.r1.amount", + "cite": { + "ref": "Q1.r1.amount", + "alias": "Q1", + "locator": { + "row": 1, + "column": "amount" + }, + "eid": "ev:cell:9df40facfe235170", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 398500, + "rendered": "398,500" + }, + "state": "deterministic", + "span": [ + 108, + 115 + ], + "id": "s19" + }, + { + "kind": "text", + "text": " 元)。", + "state": "neutral", + "span": [ + 115, + 119 + ], + "id": "s20" + } + ] + }, + { + "id": "u5", + "kind": "claim", + "span": [ + 119, + 143 + ], + "cites": [ + "Q1" + ], + "see": [], + "segments": [ + { + "kind": "text", + "text": "临潭区 ", + "state": "neutral", + "span": [ + 119, + 123 + ], + "id": "s21" + }, + { + "kind": "number", + "text": "284,500", + "ref": "v:Q1.r2.amount", + "cite": { + "ref": "Q1.r2.amount", + "alias": "Q1", + "locator": { + "row": 2, + "column": "amount" + }, + "eid": "ev:cell:7b9793466ab9ba7b", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 284500, + "rendered": "284,500" + }, + "state": "deterministic", + "span": [ + 123, + 130 + ], + "id": "s22" + }, + { + "kind": "text", + "text": " 元,环比下降 ", + "span": [ + 130, + 138 + ], + "state": "neutral", + "id": "s23" + }, + { + "kind": "number", + "text": "3.2%", + "span": [ + 138, + 142 + ], + "state": "none", + "issue": "uncited_number", + "id": "s24" + }, + { + "kind": "text", + "text": "。", + "span": [ + 142, + 143 + ], + "state": "neutral", + "id": "s25" + } + ] + } + ], + "id": "b3" + }, + { + "type": "list", + "ordered": false, + "units": [ + { + "id": "u6", + "kind": "claim", + "span": [ + 147, + 160 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n\n- ", + "state": "neutral", + "span": [ + 143, + 147 + ], + "id": "s26" + }, + { + "kind": "text", + "text": "枫岭区的客单价高于其余两区", + "state": "neutral", + "span": [ + 147, + 160 + ], + "id": "s27" + } + ], + "depth": 0 + }, + { + "id": "u7", + "kind": "claim", + "span": [ + 163, + 172 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n- ", + "state": "neutral", + "span": [ + 160, + 163 + ], + "id": "s28" + }, + { + "kind": "text", + "text": "松溪区的订单数最多", + "state": "neutral", + "span": [ + 163, + 172 + ], + "id": "s29" + } + ], + "depth": 0 + } + ], + "id": "b4" + } + ], + "stats": { + "units": 8, + "segments": 30, + "claims": 7, + "connective": 0, + "headings": 1, + "numbers": 8, + "numbers_cited": 7, + "values": 0, + "uncited_numbers": 1, + "unresolved": 0, + "see": 0, + "uncited_claims": 3, + "violations": 1 + }, + "violations": [ + { + "code": "uncited_number", + "message": "数字「3.2%」没有出处:报告中直接写出了数字,系统无法核对", + "for_model": "数字「3.2%」没有出处:要写成引用标记(比如 [[m:指标id]]),不能直接写数字", + "span": [ + 138, + 142 + ], + "text": "3.2%", + "context": "潭区 284,500 元,环比下降 3.2%。 - 枫岭区的客单价高于其余两区", + "segment": "s24", + "unit": "u5" + } + ] + }, + "2d18aed9086913a6552d335331a70c9805106aa7511e5676ba8e524ee15ebe97": { + "schema": "agentlab.report/1", + "entity_syntax": 2, + "provenance": 1, + "run_id": "run-inq-sealed", + "node_id": "write", + "markdown": "## 9 月经营概况\n\n9 月销售额 1,135,000元,订单 8,420单。退款金额 21,500元,退款率 1.89%。\n\n增长主要来自新客。\n\n分片区看,枫岭区销售额最高,为 452,000 元,松溪区次之(398,500 元)。临潭区 284,500 元,环比下降 3.2%。\n\n- 枫岭区的客单价高于其余两区\n- 松溪区的订单数最多", + "source": "## 9 月经营概况\n\n9 月销售额 [[m:gmv]],订单 [[m:orders]]。退款金额 [[m:refund]],退款率 [[m:refund_rate]]。\n\n增长主要来自新客。\n\n分片区看,枫岭区销售额最高,为 [[v:Q1.r0.amount]] 元,松溪区次之([[v:Q1.r1.amount]] 元)。临潭区 [[v:Q1.r2.amount]] 元,环比下降 3.2%。\n\n- 枫岭区的客单价高于其余两区\n- 松溪区的订单数最多", + "catalog": { + "m:gmv": { + "alias": "m:gmv", + "kind": "metric", + "eid": "ev:metric:a867f352bc111166", + "node_id": "card", + "artifact": "757e03414694c3b3480236718d32082ba449e7712d9ce99f4aee39ffdc5c3f4e", + "locator": { + "metric": "gmv" + }, + "caliber": "月报口径", + "version": "v2", + "name": "销售额", + "unit": "元", + "decimals": 0, + "format": "thousands", + "value": 1135000, + "status": "ok", + "rendered": "1,135,000元", + "label": "销售额 = 1,135,000元" + }, + "m:orders": { + "alias": "m:orders", + "kind": "metric", + "eid": "ev:metric:bb840f0fb915d4b1", + "node_id": "card", + "artifact": "757e03414694c3b3480236718d32082ba449e7712d9ce99f4aee39ffdc5c3f4e", + "locator": { + "metric": "orders" + }, + "caliber": "月报口径", + "version": "v2", + "name": "订单数", + "unit": "单", + "decimals": 0, + "format": "thousands", + "value": 8420, + "status": "ok", + "rendered": "8,420单", + "label": "订单数 = 8,420单" + }, + "m:refund": { + "alias": "m:refund", + "kind": "metric", + "eid": "ev:metric:82118fae68692f09", + "node_id": "card", + "artifact": "757e03414694c3b3480236718d32082ba449e7712d9ce99f4aee39ffdc5c3f4e", + "locator": { + "metric": "refund" + }, + "caliber": "月报口径", + "version": "v2", + "name": "退款金额", + "unit": "元", + "decimals": 0, + "format": "thousands", + "value": 21500, + "status": "ok", + "rendered": "21,500元", + "label": "退款金额 = 21,500元" + }, + "m:refund_rate": { + "alias": "m:refund_rate", + "kind": "metric", + "eid": "ev:metric:80676887dbac8214", + "node_id": "card", + "artifact": "757e03414694c3b3480236718d32082ba449e7712d9ce99f4aee39ffdc5c3f4e", + "locator": { + "metric": "refund_rate" + }, + "caliber": "月报口径", + "version": "v2", + "name": "退款率", + "unit": "", + "decimals": 4, + "format": "percent_of_ratio", + "value": 0.0189, + "status": "ok", + "rendered": "1.89%", + "label": "退款率 = 1.89%" + }, + "Q1": { + "alias": "Q1", + "kind": "query", + "eid": "ev:query:4658b8445675a26b", + "locator": {}, + "node_id": "regions", + "exec": 1, + "artifact": "67b5757e2f07807b9eb47cc04ac6c34556773ae3224aca042bb91664857a2e65", + "tool": "db_query__qingshan_mart", + "source": "qingshan_mart", + "columns": [ + "region", + "amount" + ], + "rows": 3, + "truncated": false, + "label": "db_query__qingshan_mart · 3 行" + } + }, + "blocks": [ + { + "type": "heading", + "level": 2, + "units": [ + { + "id": "u0", + "kind": "heading", + "span": [ + 3, + 10 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "## ", + "state": "neutral", + "span": [ + 0, + 3 + ], + "id": "s0" + }, + { + "kind": "text", + "text": "9 月经营概况", + "state": "neutral", + "span": [ + 3, + 10 + ], + "id": "s1" + } + ] + } + ], + "id": "b0" + }, + { + "type": "paragraph", + "units": [ + { + "id": "u1", + "kind": "claim", + "span": [ + 12, + 40 + ], + "cites": [ + "m:gmv", + "m:orders" + ], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n\n", + "state": "neutral", + "span": [ + 10, + 12 + ], + "id": "s2" + }, + { + "kind": "text", + "text": "9 月销售额 ", + "state": "neutral", + "span": [ + 12, + 19 + ], + "id": "s3" + }, + { + "kind": "number", + "text": "1,135,000元", + "ref": "m:gmv", + "cite": { + "ref": "gmv", + "alias": "m:gmv", + "locator": { + "metric": "gmv" + }, + "eid": "ev:metric:a867f352bc111166", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 1135000, + "rendered": "1,135,000元" + }, + "state": "deterministic", + "span": [ + 19, + 29 + ], + "id": "s4" + }, + { + "kind": "text", + "text": ",订单 ", + "state": "neutral", + "span": [ + 29, + 33 + ], + "id": "s5" + }, + { + "kind": "number", + "text": "8,420单", + "ref": "m:orders", + "cite": { + "ref": "orders", + "alias": "m:orders", + "locator": { + "metric": "orders" + }, + "eid": "ev:metric:bb840f0fb915d4b1", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 8420, + "rendered": "8,420单" + }, + "state": "deterministic", + "span": [ + 33, + 39 + ], + "id": "s6" + }, + { + "kind": "text", + "text": "。", + "state": "neutral", + "span": [ + 39, + 40 + ], + "id": "s7" + } + ] + }, + { + "id": "u2", + "kind": "claim", + "span": [ + 40, + 63 + ], + "cites": [ + "m:refund", + "m:refund_rate" + ], + "see": [], + "segments": [ + { + "kind": "text", + "text": "退款金额 ", + "state": "neutral", + "span": [ + 40, + 45 + ], + "id": "s8" + }, + { + "kind": "number", + "text": "21,500元", + "ref": "m:refund", + "cite": { + "ref": "refund", + "alias": "m:refund", + "locator": { + "metric": "refund" + }, + "eid": "ev:metric:82118fae68692f09", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 21500, + "rendered": "21,500元" + }, + "state": "deterministic", + "span": [ + 45, + 52 + ], + "id": "s9" + }, + { + "kind": "text", + "text": ",退款率 ", + "state": "neutral", + "span": [ + 52, + 57 + ], + "id": "s10" + }, + { + "kind": "number", + "text": "1.89%", + "ref": "m:refund_rate", + "cite": { + "ref": "refund_rate", + "alias": "m:refund_rate", + "locator": { + "metric": "refund_rate" + }, + "eid": "ev:metric:80676887dbac8214", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 0.0189, + "rendered": "1.89%" + }, + "state": "deterministic", + "span": [ + 57, + 62 + ], + "id": "s11" + }, + { + "kind": "text", + "text": "。", + "state": "neutral", + "span": [ + 62, + 63 + ], + "id": "s12" + } + ] + } + ], + "id": "b1" + }, + { + "type": "paragraph", + "units": [ + { + "id": "u3", + "kind": "claim", + "span": [ + 65, + 74 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n\n", + "state": "neutral", + "span": [ + 63, + 65 + ], + "id": "s13" + }, + { + "kind": "text", + "text": "增长主要来自新客。", + "state": "neutral", + "span": [ + 65, + 74 + ], + "id": "s14" + } + ] + } + ], + "id": "b2" + }, + { + "type": "paragraph", + "units": [ + { + "id": "u4", + "kind": "claim", + "span": [ + 76, + 119 + ], + "cites": [ + "Q1" + ], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n\n", + "state": "neutral", + "span": [ + 74, + 76 + ], + "id": "s15" + }, + { + "kind": "text", + "text": "分片区看,枫岭区销售额最高,为 ", + "state": "neutral", + "span": [ + 76, + 92 + ], + "id": "s16" + }, + { + "kind": "number", + "text": "452,000", + "ref": "v:Q1.r0.amount", + "cite": { + "ref": "Q1.r0.amount", + "alias": "Q1", + "locator": { + "row": 0, + "column": "amount" + }, + "eid": "ev:cell:0a1e7f806f2eb24a", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 452000, + "rendered": "452,000" + }, + "state": "deterministic", + "span": [ + 92, + 99 + ], + "id": "s17" + }, + { + "kind": "text", + "text": " 元,松溪区次之(", + "state": "neutral", + "span": [ + 99, + 108 + ], + "id": "s18" + }, + { + "kind": "number", + "text": "398,500", + "ref": "v:Q1.r1.amount", + "cite": { + "ref": "Q1.r1.amount", + "alias": "Q1", + "locator": { + "row": 1, + "column": "amount" + }, + "eid": "ev:cell:9df40facfe235170", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 398500, + "rendered": "398,500" + }, + "state": "deterministic", + "span": [ + 108, + 115 + ], + "id": "s19" + }, + { + "kind": "text", + "text": " 元)。", + "state": "neutral", + "span": [ + 115, + 119 + ], + "id": "s20" + } + ] + }, + { + "id": "u5", + "kind": "claim", + "span": [ + 119, + 143 + ], + "cites": [ + "Q1" + ], + "see": [], + "segments": [ + { + "kind": "text", + "text": "临潭区 ", + "state": "neutral", + "span": [ + 119, + 123 + ], + "id": "s21" + }, + { + "kind": "number", + "text": "284,500", + "ref": "v:Q1.r2.amount", + "cite": { + "ref": "Q1.r2.amount", + "alias": "Q1", + "locator": { + "row": 2, + "column": "amount" + }, + "eid": "ev:cell:7b9793466ab9ba7b", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 284500, + "rendered": "284,500" + }, + "state": "deterministic", + "span": [ + 123, + 130 + ], + "id": "s22" + }, + { + "kind": "text", + "text": " 元,环比下降 ", + "span": [ + 130, + 138 + ], + "state": "neutral", + "id": "s23" + }, + { + "kind": "number", + "text": "3.2%", + "span": [ + 138, + 142 + ], + "state": "none", + "issue": "uncited_number", + "id": "s24" + }, + { + "kind": "text", + "text": "。", + "span": [ + 142, + 143 + ], + "state": "neutral", + "id": "s25" + } + ] + } + ], + "id": "b3" + }, + { + "type": "list", + "ordered": false, + "units": [ + { + "id": "u6", + "kind": "claim", + "span": [ + 147, + 160 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n\n- ", + "state": "neutral", + "span": [ + 143, + 147 + ], + "id": "s26" + }, + { + "kind": "text", + "text": "枫岭区的客单价高于其余两区", + "state": "neutral", + "span": [ + 147, + 160 + ], + "id": "s27" + } + ], + "depth": 0 + }, + { + "id": "u7", + "kind": "claim", + "span": [ + 163, + 172 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n- ", + "state": "neutral", + "span": [ + 160, + 163 + ], + "id": "s28" + }, + { + "kind": "text", + "text": "松溪区的订单数最多", + "state": "neutral", + "span": [ + 163, + 172 + ], + "id": "s29" + } + ], + "depth": 0 + } + ], + "id": "b4" + } + ], + "stats": { + "units": 8, + "segments": 30, + "claims": 7, + "connective": 0, + "headings": 1, + "numbers": 8, + "numbers_cited": 7, + "values": 0, + "uncited_numbers": 1, + "unresolved": 0, + "see": 0, + "uncited_claims": 3, + "violations": 1 + }, + "violations": [ + { + "code": "uncited_number", + "message": "数字「3.2%」没有出处:报告中直接写出了数字,系统无法核对", + "for_model": "数字「3.2%」没有出处:要写成引用标记(比如 [[m:指标id]]),不能直接写数字", + "span": [ + 138, + 142 + ], + "text": "3.2%", + "context": "潭区 284,500 元,环比下降 3.2%。 - 枫岭区的客单价高于其余两区", + "segment": "s24", + "unit": "u5" + } + ] + }, + "1c66d590eac155dec35e2215e20c6e7497e96d0b2d73894eff1dea3fb641bb51": { + "schema": "agentlab.report/1", + "entity_syntax": 2, + "provenance": 1, + "run_id": "run-inq-approve", + "node_id": "verify", + "markdown": "退款率按退款金额除以含税销售额计算:9 月退款金额 21,500 元,含税销售额 1,135,000 元,两者之比为 0.0189。含税销售额与报告中的销售额 1,135,000元 一致,口径相同。", + "source": "退款率按退款金额除以含税销售额计算:9 月退款金额 [[v:Q1.r0.refund]] 元,含税销售额 [[v:Q1.r0.gross]] 元,两者之比为 [[v:Q1.r0.rate]]。含税销售额与报告中的销售额 [[m:gmv]] 一致,口径相同。[[see:Q1]]", + "catalog": { + "m:gmv": { + "alias": "m:gmv", + "kind": "metric", + "eid": "ev:metric:a867f352bc111166", + "node_id": "card", + "artifact": "757e03414694c3b3480236718d32082ba449e7712d9ce99f4aee39ffdc5c3f4e", + "locator": { + "metric": "gmv" + }, + "caliber": "月报口径", + "version": "v2", + "name": "销售额", + "unit": "元", + "decimals": 0, + "format": "thousands", + "value": 1135000, + "status": "ok", + "rendered": "1,135,000元", + "label": "销售额 = 1,135,000元" + }, + "m:orders": { + "alias": "m:orders", + "kind": "metric", + "eid": "ev:metric:bb840f0fb915d4b1", + "node_id": "card", + "artifact": "757e03414694c3b3480236718d32082ba449e7712d9ce99f4aee39ffdc5c3f4e", + "locator": { + "metric": "orders" + }, + "caliber": "月报口径", + "version": "v2", + "name": "订单数", + "unit": "单", + "decimals": 0, + "format": "thousands", + "value": 8420, + "status": "ok", + "rendered": "8,420单", + "label": "订单数 = 8,420单" + }, + "m:refund": { + "alias": "m:refund", + "kind": "metric", + "eid": "ev:metric:82118fae68692f09", + "node_id": "card", + "artifact": "757e03414694c3b3480236718d32082ba449e7712d9ce99f4aee39ffdc5c3f4e", + "locator": { + "metric": "refund" + }, + "caliber": "月报口径", + "version": "v2", + "name": "退款金额", + "unit": "元", + "decimals": 0, + "format": "thousands", + "value": 21500, + "status": "ok", + "rendered": "21,500元", + "label": "退款金额 = 21,500元" + }, + "m:refund_rate": { + "alias": "m:refund_rate", + "kind": "metric", + "eid": "ev:metric:80676887dbac8214", + "node_id": "card", + "artifact": "757e03414694c3b3480236718d32082ba449e7712d9ce99f4aee39ffdc5c3f4e", + "locator": { + "metric": "refund_rate" + }, + "caliber": "月报口径", + "version": "v2", + "name": "退款率", + "unit": "", + "decimals": 4, + "format": "percent_of_ratio", + "value": 0.0189, + "status": "ok", + "rendered": "1.89%", + "label": "退款率 = 1.89%" + }, + "Q1": { + "alias": "Q1", + "kind": "query", + "eid": "ev:query:7d73ed945072dc9b", + "locator": {}, + "node_id": "verify", + "exec": 1, + "artifact": "02ca8aeabca59d785d982b321d22ff889cb2da5a037cd4a080407e9044356434", + "tool": "db_query__qingshan_mart", + "source": "qingshan_mart", + "columns": [ + "refund", + "gross", + "rate" + ], + "rows": 1, + "truncated": false, + "label": "db_query__qingshan_mart · 1 行" + } + }, + "blocks": [ + { + "type": "paragraph", + "units": [ + { + "id": "u0", + "kind": "claim", + "span": [ + 0, + 66 + ], + "cites": [ + "Q1" + ], + "see": [], + "segments": [ + { + "kind": "text", + "text": "退款率按退款金额除以含税销售额计算:9 月退款金额 ", + "state": "neutral", + "span": [ + 0, + 26 + ], + "id": "s0" + }, + { + "kind": "number", + "text": "21,500", + "ref": "v:Q1.r0.refund", + "cite": { + "ref": "Q1.r0.refund", + "alias": "Q1", + "locator": { + "row": 0, + "column": "refund" + }, + "eid": "ev:cell:0f068f8ee1656d4b", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 21500, + "rendered": "21,500" + }, + "state": "deterministic", + "span": [ + 26, + 32 + ], + "id": "s1" + }, + { + "kind": "text", + "text": " 元,含税销售额 ", + "state": "neutral", + "span": [ + 32, + 41 + ], + "id": "s2" + }, + { + "kind": "number", + "text": "1,135,000", + "ref": "v:Q1.r0.gross", + "cite": { + "ref": "Q1.r0.gross", + "alias": "Q1", + "locator": { + "row": 0, + "column": "gross" + }, + "eid": "ev:cell:de4f4c63e35aea73", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 1135000, + "rendered": "1,135,000" + }, + "state": "deterministic", + "span": [ + 41, + 50 + ], + "id": "s3" + }, + { + "kind": "text", + "text": " 元,两者之比为 ", + "state": "neutral", + "span": [ + 50, + 59 + ], + "id": "s4" + }, + { + "kind": "number", + "text": "0.0189", + "ref": "v:Q1.r0.rate", + "cite": { + "ref": "Q1.r0.rate", + "alias": "Q1", + "locator": { + "row": 0, + "column": "rate" + }, + "eid": "ev:cell:e651bc70e437e2bc", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 0.0189, + "rendered": "0.0189" + }, + "state": "deterministic", + "span": [ + 59, + 65 + ], + "id": "s5" + }, + { + "kind": "text", + "text": "。", + "state": "neutral", + "span": [ + 65, + 66 + ], + "id": "s6" + } + ] + }, + { + "id": "u1", + "kind": "claim", + "span": [ + 66, + 99 + ], + "cites": [ + "m:gmv", + "Q1" + ], + "see": [ + { + "ref": "Q1", + "role": "support", + "alias": "Q1", + "kind": "query", + "status": "resolved", + "eid": "ev:query:7d73ed945072dc9b", + "locator": {} + } + ], + "segments": [ + { + "kind": "text", + "text": "含税销售额与报告中的销售额 ", + "state": "neutral", + "span": [ + 66, + 80 + ], + "id": "s7" + }, + { + "kind": "number", + "text": "1,135,000元", + "ref": "m:gmv", + "cite": { + "ref": "gmv", + "alias": "m:gmv", + "locator": { + "metric": "gmv" + }, + "eid": "ev:metric:a867f352bc111166", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 1135000, + "rendered": "1,135,000元" + }, + "state": "deterministic", + "span": [ + 80, + 90 + ], + "id": "s8" + }, + { + "kind": "text", + "text": " 一致,口径相同。", + "state": "neutral", + "span": [ + 90, + 99 + ], + "id": "s9" + } + ] + } + ], + "id": "b0" + } + ], + "stats": { + "units": 2, + "segments": 10, + "claims": 2, + "connective": 0, + "headings": 0, + "numbers": 4, + "numbers_cited": 4, + "values": 0, + "uncited_numbers": 0, + "unresolved": 0, + "see": 1, + "uncited_claims": 0, + "violations": 0 + }, + "violations": [] + }, + "08aadcdf2338b5be5addd826bdc96fa9ec3f410edd629557d5f91e790d6bbb34": { + "schema": "agentlab.report/1", + "entity_syntax": 2, + "provenance": 1, + "run_id": "run-inq-approve", + "node_id": "verify", + "markdown": "9 月新客销售额 151,000 元,老客 984,000 元,新客约占 13%,单凭这些不足以说明增长主要来自新客。新客中消费最高的会员消费 9,000 元。", + "source": "9 月新客销售额 [[v:Q1.r0.amount]] 元,老客 [[v:Q1.r1.amount]] 元,新客约占 13%,单凭这些不足以说明增长主要来自新客。新客中消费最高的会员消费 [[v:Q2.r0.amount]] 元。[[see:Q1]]", + "catalog": { + "Q1": { + "alias": "Q1", + "kind": "query", + "eid": "ev:query:3c4aa132de11b466", + "locator": {}, + "node_id": "verify", + "exec": 1, + "artifact": "36cf830847abf4b41ae28ab17cd375bebba829662454cf066588cd774c21a399", + "tool": "db_query__qingshan_mart", + "source": "qingshan_mart", + "columns": [ + "customer_type", + "amount" + ], + "rows": 2, + "truncated": false, + "label": "db_query__qingshan_mart · 2 行" + }, + "Q2": { + "alias": "Q2", + "kind": "query", + "eid": "ev:query:97e281218b0b9496", + "locator": {}, + "node_id": "verify", + "exec": 1, + "artifact": "57b0c8fe914a2bf78c2bd6ecbecdbb24758914657962d4b8f62de6303079f892", + "tool": "db_query__qingshan_mart", + "source": "qingshan_mart", + "columns": [ + "member_id", + "phone", + "first_order_at", + "amount" + ], + "rows": 50, + "truncated": true, + "label": "db_query__qingshan_mart · 50 行" + } + }, + "blocks": [ + { + "type": "paragraph", + "units": [ + { + "id": "u0", + "kind": "claim", + "span": [ + 0, + 59 + ], + "cites": [ + "Q1" + ], + "see": [], + "segments": [ + { + "kind": "text", + "text": "9 月新客销售额 ", + "state": "neutral", + "span": [ + 0, + 9 + ], + "id": "s0" + }, + { + "kind": "number", + "text": "151,000", + "ref": "v:Q1.r0.amount", + "cite": { + "ref": "Q1.r0.amount", + "alias": "Q1", + "locator": { + "row": 0, + "column": "amount" + }, + "eid": "ev:cell:de32bdd67974bf18", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 151000, + "rendered": "151,000" + }, + "state": "deterministic", + "span": [ + 9, + 16 + ], + "id": "s1" + }, + { + "kind": "text", + "text": " 元,老客 ", + "state": "neutral", + "span": [ + 16, + 22 + ], + "id": "s2" + }, + { + "kind": "number", + "text": "984,000", + "ref": "v:Q1.r1.amount", + "cite": { + "ref": "Q1.r1.amount", + "alias": "Q1", + "locator": { + "row": 1, + "column": "amount" + }, + "eid": "ev:cell:ceb74003e12744e8", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 984000, + "rendered": "984,000" + }, + "state": "deterministic", + "span": [ + 22, + 29 + ], + "id": "s3" + }, + { + "kind": "text", + "text": " 元,新客约占 ", + "span": [ + 29, + 37 + ], + "state": "neutral", + "id": "s4" + }, + { + "kind": "number", + "text": "13%", + "span": [ + 37, + 40 + ], + "state": "none", + "issue": "uncited_number", + "id": "s5" + }, + { + "kind": "text", + "text": ",单凭这些不足以说明增长主要来自新客。", + "span": [ + 40, + 59 + ], + "state": "neutral", + "id": "s6" + } + ] + }, + { + "id": "u1", + "kind": "claim", + "span": [ + 59, + 80 + ], + "cites": [ + "Q2", + "Q1" + ], + "see": [ + { + "ref": "Q1", + "role": "support", + "alias": "Q1", + "kind": "query", + "status": "resolved", + "eid": "ev:query:3c4aa132de11b466", + "locator": {} + } + ], + "segments": [ + { + "kind": "text", + "text": "新客中消费最高的会员消费 ", + "state": "neutral", + "span": [ + 59, + 72 + ], + "id": "s7" + }, + { + "kind": "number", + "text": "9,000", + "ref": "v:Q2.r0.amount", + "cite": { + "ref": "Q2.r0.amount", + "alias": "Q2", + "locator": { + "row": 0, + "column": "amount" + }, + "eid": "ev:cell:07282a98c9fafe2e", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 9000, + "rendered": "9,000" + }, + "state": "deterministic", + "span": [ + 72, + 77 + ], + "id": "s8" + }, + { + "kind": "text", + "text": " 元。", + "state": "neutral", + "span": [ + 77, + 80 + ], + "id": "s9" + } + ] + } + ], + "id": "b0" + } + ], + "stats": { + "units": 2, + "segments": 10, + "claims": 2, + "connective": 0, + "headings": 0, + "numbers": 4, + "numbers_cited": 3, + "values": 0, + "uncited_numbers": 1, + "unresolved": 0, + "see": 1, + "uncited_claims": 0, + "violations": 1 + }, + "violations": [ + { + "code": "uncited_number", + "message": "数字「13%」没有出处:报告中直接写出了数字,系统无法核对", + "for_model": "数字「13%」没有出处:要写成引用标记(比如 [[m:指标id]]),不能直接写数字", + "span": [ + 37, + 40 + ], + "text": "13%", + "context": "老客 984,000 元,新客约占 13%,单凭这些不足以说明增长主要来自新客", + "segment": "s5", + "unit": "u0" + } + ] + }, + "4ed3d62d2af13bbea40195283f3caf63462ca76bb5d4f7ddf1216d36dd5e0463": { + "schema": "agentlab.report/1", + "entity_syntax": 2, + "provenance": 1, + "run_id": "run-inq-approve", + "node_id": "verify", + "markdown": "枫岭区 9 月线上订单销售额 118,000 元,门店 334,000 元,报告中的片区销售额包含线上订单。", + "source": "枫岭区 9 月线上订单销售额 [[v:Q1.r0.amount]] 元,门店 [[v:Q1.r1.amount]] 元,报告中的片区销售额包含线上订单。[[see:Q1]]", + "catalog": { + "Q1": { + "alias": "Q1", + "kind": "query", + "eid": "ev:query:d4bea3bc648535a9", + "locator": {}, + "node_id": "verify", + "exec": 1, + "artifact": "ca81d2368e8597720aad6fd4eb6a0c33227770aa4a5b3b3219df6ccaa0d23be9", + "tool": "db_query__qingshan_mart", + "source": "qingshan_mart", + "columns": [ + "channel", + "amount" + ], + "rows": 2, + "truncated": false, + "label": "db_query__qingshan_mart · 2 行" + } + }, + "blocks": [ + { + "type": "paragraph", + "units": [ + { + "id": "u0", + "kind": "claim", + "span": [ + 0, + 54 + ], + "cites": [ + "Q1" + ], + "see": [ + { + "ref": "Q1", + "role": "support", + "alias": "Q1", + "kind": "query", + "status": "resolved", + "eid": "ev:query:d4bea3bc648535a9", + "locator": {} + } + ], + "segments": [ + { + "kind": "text", + "text": "枫岭区 9 月线上订单销售额 ", + "state": "neutral", + "span": [ + 0, + 15 + ], + "id": "s0" + }, + { + "kind": "number", + "text": "118,000", + "ref": "v:Q1.r0.amount", + "cite": { + "ref": "Q1.r0.amount", + "alias": "Q1", + "locator": { + "row": 0, + "column": "amount" + }, + "eid": "ev:cell:2d8a4ebd33347c2e", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 118000, + "rendered": "118,000" + }, + "state": "deterministic", + "span": [ + 15, + 22 + ], + "id": "s1" + }, + { + "kind": "text", + "text": " 元,门店 ", + "state": "neutral", + "span": [ + 22, + 28 + ], + "id": "s2" + }, + { + "kind": "number", + "text": "334,000", + "ref": "v:Q1.r1.amount", + "cite": { + "ref": "Q1.r1.amount", + "alias": "Q1", + "locator": { + "row": 1, + "column": "amount" + }, + "eid": "ev:cell:6eca03c8d280f692", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 334000, + "rendered": "334,000" + }, + "state": "deterministic", + "span": [ + 28, + 35 + ], + "id": "s3" + }, + { + "kind": "text", + "text": " 元,报告中的片区销售额包含线上订单。", + "state": "neutral", + "span": [ + 35, + 54 + ], + "id": "s4" + } + ] + } + ], + "id": "b0" + } + ], + "stats": { + "units": 1, + "segments": 5, + "claims": 1, + "connective": 0, + "headings": 0, + "numbers": 2, + "numbers_cited": 2, + "values": 0, + "uncited_numbers": 0, + "unresolved": 0, + "see": 1, + "uncited_claims": 0, + "violations": 0 + }, + "violations": [] + }, + "911780ed990217a2a2e7e3424a9408d23b53a524ba5d1812ec677b7d6b5b80aa": { + "schema": "agentlab.report/1", + "entity_syntax": 2, + "provenance": 1, + "run_id": "run-inq-explore", + "node_id": "verify", + "markdown": "退款率按退款金额除以含税销售额计算,9 月为 0.0189。", + "source": "退款率按退款金额除以含税销售额计算,9 月为 [[v:Q1.r0.rate]]。[[see:Q1]]", + "catalog": { + "Q1": { + "alias": "Q1", + "kind": "query", + "eid": "ev:query:7d73ed945072dc9b", + "locator": {}, + "node_id": "verify", + "exec": 1, + "artifact": "02ca8aeabca59d785d982b321d22ff889cb2da5a037cd4a080407e9044356434", + "tool": "db_query__qingshan_mart", + "source": "qingshan_mart", + "columns": [ + "refund", + "gross", + "rate" + ], + "rows": 1, + "truncated": false, + "label": "db_query__qingshan_mart · 1 行" + } + }, + "blocks": [ + { + "type": "paragraph", + "units": [ + { + "id": "u0", + "kind": "claim", + "span": [ + 0, + 30 + ], + "cites": [ + "Q1" + ], + "see": [ + { + "ref": "Q1", + "role": "support", + "alias": "Q1", + "kind": "query", + "status": "resolved", + "eid": "ev:query:7d73ed945072dc9b", + "locator": {} + } + ], + "segments": [ + { + "kind": "text", + "text": "退款率按退款金额除以含税销售额计算,9 月为 ", + "state": "neutral", + "span": [ + 0, + 23 + ], + "id": "s0" + }, + { + "kind": "number", + "text": "0.0189", + "ref": "v:Q1.r0.rate", + "cite": { + "ref": "Q1.r0.rate", + "alias": "Q1", + "locator": { + "row": 0, + "column": "rate" + }, + "eid": "ev:cell:e651bc70e437e2bc", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 0.0189, + "rendered": "0.0189" + }, + "state": "deterministic", + "span": [ + 23, + 29 + ], + "id": "s1" + }, + { + "kind": "text", + "text": "。", + "state": "neutral", + "span": [ + 29, + 30 + ], + "id": "s2" + } + ] + } + ], + "id": "b0" + } + ], + "stats": { + "units": 1, + "segments": 3, + "claims": 1, + "connective": 0, + "headings": 0, + "numbers": 1, + "numbers_cited": 1, + "values": 0, + "uncited_numbers": 0, + "unresolved": 0, + "see": 1, + "uncited_claims": 0, + "violations": 0 + }, + "violations": [] + } + }, + "snapshots": { + "02ca8aeabca59d785d982b321d22ff889cb2da5a037cd4a080407e9044356434": { + "artifact": "02ca8aeabca59d785d982b321d22ff889cb2da5a037cd4a080407e9044356434", + "sql": "SELECT SUM(r.amount) AS refund, SUM(o.amount) AS gross, ROUND(SUM(r.amount) * 1.0 / SUM(o.amount), 4) AS rate FROM orders o LEFT JOIN refunds r ON r.order_id = o.id WHERE o.paid_at >= '2026-09-01' AND o.paid_at < '2026-10-01'", + "source": "qingshan_mart", + "columns": [ + "refund", + "gross", + "rate" + ], + "rows": [ + [ + 21500, + 1135000, + 0.0189 + ] + ], + "row_count": 1, + "truncated": false, + "masked": [] + }, + "36cf830847abf4b41ae28ab17cd375bebba829662454cf066588cd774c21a399": { + "artifact": "36cf830847abf4b41ae28ab17cd375bebba829662454cf066588cd774c21a399", + "sql": "SELECT customer_type, SUM(amount) AS amount FROM orders WHERE paid_at >= '2026-09-01' AND paid_at < '2026-10-01' GROUP BY customer_type ORDER BY customer_type", + "source": "qingshan_mart", + "columns": [ + "customer_type", + "amount" + ], + "rows": [ + [ + "新客", + 151000 + ], + [ + "老客", + 984000 + ] + ], + "row_count": 2, + "truncated": false, + "masked": [] + }, + "57b0c8fe914a2bf78c2bd6ecbecdbb24758914657962d4b8f62de6303079f892": { + "artifact": "57b0c8fe914a2bf78c2bd6ecbecdbb24758914657962d4b8f62de6303079f892", + "sql": "SELECT member_id, phone, first_order_at, amount FROM orders WHERE customer_type = '新客' ORDER BY amount DESC", + "source": "qingshan_mart", + "columns": [ + "member_id", + "phone", + "first_order_at", + "amount" + ], + "rows": [ + [ + "M1000", + "已遮罩", + "2026-09-01", + 9000 + ], + [ + "M1001", + "已遮罩", + "2026-09-02", + 8880 + ], + [ + "M1002", + "已遮罩", + "2026-09-03", + 8760 + ], + [ + "M1003", + "已遮罩", + "2026-09-04", + 8640 + ], + [ + "M1004", + "已遮罩", + "2026-09-05", + 8520 + ], + [ + "M1005", + "已遮罩", + "2026-09-06", + 8400 + ], + [ + "M1006", + "已遮罩", + "2026-09-07", + 8280 + ], + [ + "M1007", + "已遮罩", + "2026-09-08", + 8160 + ], + [ + "M1008", + "已遮罩", + "2026-09-09", + 8040 + ], + [ + "M1009", + "已遮罩", + "2026-09-10", + 7920 + ], + [ + "M1010", + "已遮罩", + "2026-09-11", + 7800 + ], + [ + "M1011", + "已遮罩", + "2026-09-12", + 7680 + ], + [ + "M1012", + "已遮罩", + "2026-09-13", + 7560 + ], + [ + "M1013", + "已遮罩", + "2026-09-14", + 7440 + ], + [ + "M1014", + "已遮罩", + "2026-09-15", + 7320 + ], + [ + "M1015", + "已遮罩", + "2026-09-16", + 7200 + ], + [ + "M1016", + "已遮罩", + "2026-09-17", + 7080 + ], + [ + "M1017", + "已遮罩", + "2026-09-18", + 6960 + ], + [ + "M1018", + "已遮罩", + "2026-09-19", + 6840 + ], + [ + "M1019", + "已遮罩", + "2026-09-20", + 6720 + ], + [ + "M1020", + "已遮罩", + "2026-09-21", + 6600 + ], + [ + "M1021", + "已遮罩", + "2026-09-22", + 6480 + ], + [ + "M1022", + "已遮罩", + "2026-09-23", + 6360 + ], + [ + "M1023", + "已遮罩", + "2026-09-24", + 6240 + ], + [ + "M1024", + "已遮罩", + "2026-09-25", + 6120 + ], + [ + "M1025", + "已遮罩", + "2026-09-26", + 6000 + ], + [ + "M1026", + "已遮罩", + "2026-09-27", + 5880 + ], + [ + "M1027", + "已遮罩", + "2026-09-28", + 5760 + ], + [ + "M1028", + "已遮罩", + "2026-09-01", + 5640 + ], + [ + "M1029", + "已遮罩", + "2026-09-02", + 5520 + ], + [ + "M1030", + "已遮罩", + "2026-09-03", + 5400 + ], + [ + "M1031", + "已遮罩", + "2026-09-04", + 5280 + ], + [ + "M1032", + "已遮罩", + "2026-09-05", + 5160 + ], + [ + "M1033", + "已遮罩", + "2026-09-06", + 5040 + ], + [ + "M1034", + "已遮罩", + "2026-09-07", + 4920 + ], + [ + "M1035", + "已遮罩", + "2026-09-08", + 4800 + ], + [ + "M1036", + "已遮罩", + "2026-09-09", + 4680 + ], + [ + "M1037", + "已遮罩", + "2026-09-10", + 4560 + ], + [ + "M1038", + "已遮罩", + "2026-09-11", + 4440 + ], + [ + "M1039", + "已遮罩", + "2026-09-12", + 4320 + ], + [ + "M1040", + "已遮罩", + "2026-09-13", + 4200 + ], + [ + "M1041", + "已遮罩", + "2026-09-14", + 4080 + ], + [ + "M1042", + "已遮罩", + "2026-09-15", + 3960 + ], + [ + "M1043", + "已遮罩", + "2026-09-16", + 3840 + ], + [ + "M1044", + "已遮罩", + "2026-09-17", + 3720 + ], + [ + "M1045", + "已遮罩", + "2026-09-18", + 3600 + ], + [ + "M1046", + "已遮罩", + "2026-09-19", + 3480 + ], + [ + "M1047", + "已遮罩", + "2026-09-20", + 3360 + ], + [ + "M1048", + "已遮罩", + "2026-09-21", + 3240 + ], + [ + "M1049", + "已遮罩", + "2026-09-22", + 3120 + ] + ], + "row_count": 50, + "truncated": true, + "masked": [ + "phone" + ] + }, + "ca81d2368e8597720aad6fd4eb6a0c33227770aa4a5b3b3219df6ccaa0d23be9": { + "artifact": "ca81d2368e8597720aad6fd4eb6a0c33227770aa4a5b3b3219df6ccaa0d23be9", + "sql": "SELECT channel, SUM(amount) AS amount FROM orders WHERE region = '枫岭区' AND paid_at >= '2026-09-01' AND paid_at < '2026-10-01' GROUP BY channel ORDER BY channel", + "source": "qingshan_mart", + "columns": [ + "channel", + "amount" + ], + "rows": [ + [ + "线上", + 118000 + ], + [ + "门店", + 334000 + ] + ], + "row_count": 2, + "truncated": false, + "masked": [] + } + }, + "card_artifact": "757e03414694c3b3480236718d32082ba449e7712d9ce99f4aee39ffdc5c3f4e" +} diff --git a/frontend/src/dev/fixtures/inquiry.tsx b/frontend/src/dev/fixtures/inquiry.tsx new file mode 100644 index 00000000..3ad1ac5c --- /dev/null +++ b/frontend/src/dev/fixtures/inquiry.tsx @@ -0,0 +1,84 @@ +import type { JSX } from 'react' +import { EvidencePane } from '../../pages/runs/EvidencePane' +import { ApprovalCard } from '../../run/RunPanel' +import type { Approval, ApprovalReport, Run } from '../../types' +import fx from './inquiry.json' + +/** + * 审批质询的预览用例,scripts/check-inquiry.mjs 按它们检查。夹具在 inquiry.json(检查脚本读同一份):报告文档和回答文档 + * 是后端 build_catalog + compose_doc 真实组装的,超市、片区、金额都是虚构的。 + * + * 审批卡(运行停在人工审批上,审的是报告「写月报」): + * - ?syn=inquiry-approval:探索运行。核对概况下面是问答串(进入时按列表接口恢复)和「就整份报告提问」;展开逐段证据后, + * 每句结论悬停 / 焦点在它上面时出现「质询」,划选原文时浮出「质询」 + * - ?syn=inquiry-approval-formal:正式运行的审批,同样能问(审批阶段还没封存) + * - ?syn=inquiry-approval-dense:同第一个,摆在 360px 的右栏里 + * - ?syn=inquiry-approval-done:审批已经处理过(不是待处理的那张):只列问答,不给提问 + * + * 运行详情的「证据」页签(pages/runs/EvidencePane,是否可问由它按运行状态判断): + * - ?syn=inquiry-run-explore:封存了的探索运行,报告上给入口,问答追加在封存之后 + * - ?syn=inquiry-run-formal:封存了的正式运行,不给入口 + * + * 列表、提问、质询工件、报告文档、证据图都由检查脚本用 page.route 伪造;直接打开时问答串照实说无法读取。 + */ + +const RUNS = fx.runs +const reports = fx.reports as unknown as Record + +function approvalOf(id: string, runId: string, runClass: 'formal' | 'exploratory', report: ApprovalReport, + status = 'pending'): Approval { + return { + id, run_id: runId, node_id: 'review', mode: 'approve', title: '9 月月报可以发出吗?', status, response: {}, + payload: { kind: 'human_node', message: '请审核 9 月月报。', reports: [report] }, + created_at: new Date(Date.now() - 300_000).toISOString().replace('Z', ''), + workflow_name: '月报', node_label: '主管审批', run_class: runClass, + } +} + +function Card({ approval, name, dense = false }: { approval: Approval; name: string; dense?: boolean }) { + return ( +
+
+ undefined} /> +
+
+ ) +} + +/** 运行详情:一次已经结束、封存了的运行 */ +function runOf(id: string, runClass: 'formal' | 'exploratory'): Run { + return { + id, workflow_id: null, workflow_name: '月报', status: 'succeeded', input: { month: '2026-09' }, output: {}, error: null, + usage: { input_tokens: 0, output_tokens: 0, cost_usd: 0 } as Run['usage'], run_class: runClass, version: runClass === 'formal' ? 3 : null, + manifest_hash: 'f'.repeat(64), manifest_seq: 30, created_at: '2026-10-05T06:00:00', started_at: '2026-10-05T06:00:00', + finished_at: '2026-10-05T06:40:00', + } +} + +const LABELS: Record = { write: '写月报', review: '主管审批' } + +function RunEvidence({ run, name }: { run: Run; name: string }) { + return ( +
+ (id ? LABELS[id] : undefined)} refreshKey="0" /> +
+ ) +} + +export const cases: Record JSX.Element> = { + 'inquiry-approval': () => ( + + ), + 'inquiry-approval-formal': () => ( + + ), + 'inquiry-approval-dense': () => ( + + ), + 'inquiry-approval-done': () => ( + + ), + 'inquiry-run-explore': () => , + 'inquiry-run-formal': () => , +} diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 6d2d4fa1..4f772874 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3305,6 +3305,74 @@ export const INQUIRY_TEXT = { error: (e: string) => `原因:${e}`, model: (m: string) => `核查模型:${m}`, }, + + // ---- 报告上的入口(EvidenceDoc):句末的「质询」、划选原文时浮出的「质询」 ---- + ask: '质询', + askUnit: (text: string) => `质询这句:${text}`, + askSelection: '就选中的原文质询', + /** 读屏摘要里接在按键说明后面 */ + keysHint: '每句结论后有「质询」按钮:方向键走到这一句,再按 Tab 到达;没有可点内容的句子,方向键直接走到它的「质询」', + + // ---- 问答串(ReportInquiry) ---- + headCount: (n: number) => `${formatNumber(n)} 次`, + askReport: '就整份报告提问', + intro: '展开逐段证据后,悬停一句结论或划选一段原文,点「质询」即可就该处提问', + introRun: '悬停一句结论或划选一段原文,点「质询」即可就该处提问', + wholeReport: '整份报告', + /** 报告文档取不到,写不出问的是哪一句 */ + unitMissing: '报告中的一句', + loadFailed: '无法读取之前的质询记录', + retry: '重试', + q: '问', + a: '答', + unsigned: '未署名', + entryLabel: (question: string) => `质询:${question}`, + quoteLine: (quote: string) => `原文「${quote}」`, + notRecorded: '这次问答没有写入运行记录', + notRecordedHint: '它只显示在当前页面,刷新后不再显示,也不会随运行封存', + /** 屏幕阅读器:回答回来了 */ + arrived: '已收到回答', + + // ---- 提问框 ---- + target: { unit: '就这句提问', quote: '就选中的原文提问', report: '就整份报告提问' } as Record<'unit' | 'quote' | 'report', string>, + questionLabel: '问题', + placeholder: '例如:这个数是否扣除了退款?', + submit: '提问', + cancel: '取消', + chars: (n: number, max: number) => `${formatNumber(n)}/${formatNumber(max)}`, + keys: 'Ctrl + 回车提交 · Esc 关闭', + empty: '请填写问题', + tooLong: (max: number) => `问题最多 ${formatNumber(max)} 字,请删减后再提交`, + quoteCut: (max: number) => `选中的原文超过 ${formatNumber(max)} 字,只提交前 ${formatNumber(max)} 字`, + hint: '由核查模型只读查询数据作答,问答写入运行记录,不改动报告', + hintPostSeal: '运行已封存:问答追加在封存之后,不改动报告', + busyOther: '这次运行另有一条质询正在回答,回答完再提问', + submitFailed: '提问失败', + + // ---- 回答中:审批卡的批准 / 驳回暂停(服务端这时也会拒绝) ---- + pending: '正在查询与回答,通常需要几十秒', + waited: (seconds: number) => `已等待 ${formatNumber(seconds)} 秒`, + decideBlocked: '正在回答质询,回答完再批准或驳回', + + // ---- 回答 ---- + noAnswer: '没有给出回答', + answerIncomplete: (limit: string) => `${limit}:回答依据已查到的结果,可能不完整`, + answeredBy: (model: string | null) => `回答由核查模型${model ? `「${model}」` : ''}给出,并非系统核对`, + cost: (usd: string) => `花费 ${usd}`, + /** 回答里有出处的数字:读屏听到「数字,出处:查询 Q1 · 第 1 行 · amount」,点一下展开那次查询的结果 */ + cited: (text: string, source: string) => `${text},出处:${source},查看这次查询的结果`, + citedReport: (text: string, source: string) => `${text},出处:${source},在报告中查看证据`, + /** 引用的是报告已有的出处,报告里却找不到引用它的地方:照回答文档记下的出处说一句 */ + reportSource: (source: string) => `出处:${source}(报告已有的出处,报告中没有引用它的地方)`, + queriesHead: (n: number) => `核查模型执行的查询(${formatNumber(n)} 次)`, + noQueries: '核查模型没有执行查询', + rows: (n: number) => `${formatNumber(n)} 行`, + truncated: '结果已截断,数据库中还有更多数据', + showResult: '查看结果', + hideResult: '收起结果', + snapshotLoading: '正在读取查询结果…', + snapshotFailed: '无法读取这次查询的结果', + masked: (columns: string) => `${columns} 在数据源中设置了遮罩,不显示原值`, } /** diff --git a/frontend/src/pages/runs/EvidencePane.tsx b/frontend/src/pages/runs/EvidencePane.tsx index f04e3298..ba97238d 100644 --- a/frontend/src/pages/runs/EvidencePane.tsx +++ b/frontend/src/pages/runs/EvidencePane.tsx @@ -16,6 +16,7 @@ import { EVIDENCE_AUDIT_TEXT as T, UPGRADE_TEXT } from '../../lib/terms' import { IssuanceBanner } from '../../run/AssistantStream' import { EvidenceDoc, type EvidenceDocHandle } from '../../run/EvidenceDoc' import { EvidenceGuessView } from '../../run/EvidenceGuess' +import { NO_APPROVAL, ReportInquiry, inquiryOpen, locateIn } from '../../run/Inquiry' import { Markdown, inlineStyles } from '../../run/Markdown' import { useEvidence } from '../../store/evidence' import { useCatalog } from '../../store/catalog' @@ -124,6 +125,8 @@ export function EvidencePane({ run, output, labelOf, refreshKey }: { const mode = graph?.mode ?? audit.data?.mode const seal = audit.data?.seal ?? graph?.seal + // 审批质询:封存了的探索运行可以接着就报告提问(追加在封存之后);正式运行封存后不再开放 + const asking = inquiryOpen(run) if (!graph && graphSlot?.status !== 'error') { return ( @@ -163,10 +166,17 @@ export function EvidencePane({ run, output, labelOf, refreshKey }: { {doc ? ( - { handles.current.set(r.node_id!, h) }} doc={doc} artifact={r.doc_artifact} - runId={run.id} runClass={run.run_class} label={r.fields?.join('、') || r.node_id} tally - tier={typeof output?._issuance?.tier === 'string' ? output._issuance.tier : undefined} - panel={wide ? 'dock' : 'auto'} dock={dock} onView={(o) => onView(r.node_id!, o)} /> + <> + { handles.current.set(r.node_id!, h) }} doc={doc} artifact={r.doc_artifact} + runId={run.id} runClass={run.run_class} label={r.fields?.join('、') || r.node_id} tally + tier={typeof output?._issuance?.tier === 'string' ? output._issuance.tier : undefined} + panel={wide ? 'dock' : 'auto'} dock={dock} onView={(o) => onView(r.node_id!, o)} + inquiry={asking ? NO_APPROVAL : null} /> + {asking && ( + locateIn(handles.current.get(r.node_id!), doc, seg)} /> + )} + ) : slot?.status === 'error' ? (

{T.docGone(labelOf(r.node_id) ?? r.node_id ?? '')}

) : } diff --git a/frontend/src/run/ApprovalReport.tsx b/frontend/src/run/ApprovalReport.tsx index c1abb21d..e95b71f9 100644 --- a/frontend/src/run/ApprovalReport.tsx +++ b/frontend/src/run/ApprovalReport.tsx @@ -1,11 +1,12 @@ -import { useEffect, useId, useState } from 'react' +import { useCallback, useEffect, useId, useMemo, useRef, useState, type Ref } from 'react' import { ChevronRight, ShieldCheck } from 'lucide-react' import { EVIDENCE_STATE, claimCountsOf, claimProblems, statsTally } from '../lib/evidence' import { formatNumber } from '../lib/format' import { APPROVAL_TEXT, EVIDENCE_TEXT, JUDGE_TEXT, evidenceTally, runClassLabel } from '../lib/terms' import { useEvidence } from '../store/evidence' -import type { ApprovalReport } from '../types' -import { EvidenceDoc } from './EvidenceDoc' +import type { ApprovalReport, EvidenceSegment } from '../types' +import { EvidenceDoc, type EvidenceDocHandle } from './EvidenceDoc' +import { ReportInquiry, reportSegmentFor, type InquiryAccess } from './Inquiry' type RunClass = 'formal' | 'exploratory' | null | undefined @@ -53,21 +54,46 @@ function claimsLine(report: ApprovalReport, runClass: RunClass): { text: string; * * 真实运行里报告有一句结论和证据相矛盾,审批人批准时完全不知道:卡上只有正文 */ -export function ApprovalReports({ reports, runId, runClass }: { +export function ApprovalReports({ reports, runId, runClass, approvalId }: { reports: ApprovalReport[] runId: string runClass: RunClass + /** + * 运行停在的这张审批(待处理的才给)。有它,审批人可以就报告提出质询:报告上的句子、划选的原文挂「质询」, + * 核对概况下面是问答串和提问框(run/Inquiry.tsx) + */ + approvalId?: string }) { + const access = useMemo(() => (approvalId ? { approvalId } : null), [approvalId]) if (!reports.length) return null return ( <> - {reports.map((r) => )} + {reports.map((r) => )} ) } -function ReportCheck({ report, runId, runClass }: { report: ApprovalReport; runId: string; runClass: RunClass }) { +function ReportCheck({ report, runId, runClass, access }: { + report: ApprovalReport; runId: string; runClass: RunClass; access: InquiryAccess | null +}) { const [open, setOpen] = useState(false) + // 回答里引用了报告已有的出处:在逐段证据里打开那一处的证据面板。逐段证据收着的先展开,挂上之后再打开 + const docRef = useRef(null) + const opening = useRef(null) + const docSlot = useEvidence((s) => (report.doc_artifact ? s.docs[report.doc_artifact] : undefined)) + const locate = useCallback((seg: EvidenceSegment) => { + const id = reportSegmentFor(docSlot?.status === 'ok' ? docSlot.data : null, seg) + if (!id) return false + if (open && docRef.current) return docRef.current.open(id, { focus: true }) + opening.current = id + setOpen(true) + return true + }, [docSlot, open]) + useEffect(() => { + if (!open || !opening.current || !docRef.current) return + docRef.current.open(opening.current, { focus: true }) + opening.current = null + }) const docId = useId() const counts = statsTally(report.stats) const suspect = typeof report.stats?.unknown_entities === 'number' ? report.stats.unknown_entities : 0 @@ -105,17 +131,24 @@ function ReportCheck({ report, runId, runClass }: { report: ApprovalReport; runI {open && (
- +
)} )} + {/* 审批质询:问答串(进入审批卡时按列表接口恢复)、提问框和「就整份报告提问」 */} + ) } /** 报告文档:和成果里同一份可逐段点开的文档,自带证据条(定位下一处、违规清单),点开的证据面板在卡内展开 */ -function ReportDoc({ artifact, runId, runClass }: { artifact: string; runId: string; runClass: RunClass }) { +function ReportDoc({ artifact, runId, runClass, inquiry, handle }: { + artifact: string; runId: string; runClass: RunClass + /** 审批质询:句子、划选的原文上挂「质询」 */ + inquiry: InquiryAccess | null + handle: Ref +}) { const slot = useEvidence((s) => s.docs[artifact]) const loadDoc = useEvidence((s) => s.loadDoc) useEffect(() => { void loadDoc(artifact) }, [artifact, loadDoc]) @@ -124,7 +157,8 @@ function ReportDoc({ artifact, runId, runClass }: { artifact: string; runId: str if (!doc) return
{APPROVAL_TEXT.docLoading}
return (
- +
) } diff --git a/frontend/src/run/EvidenceDoc.tsx b/frontend/src/run/EvidenceDoc.tsx index 7cac5672..2afd561d 100644 --- a/frontend/src/run/EvidenceDoc.tsx +++ b/frontend/src/run/EvidenceDoc.tsx @@ -1,10 +1,10 @@ import { Fragment, forwardRef, memo, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type FocusEvent as ReactFocusEvent, - type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, + type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type RefObject, } from 'react' import { createPortal } from 'react-dom' -import { ListChecks } from 'lucide-react' +import { ListChecks, MessageCircleQuestion } from 'lucide-react' import clsx from 'clsx' import { rovingTarget } from '../components/ui' import { @@ -13,10 +13,11 @@ import { type EvidenceStateCode, type EvidenceTally as Tally, } from '../lib/evidence' import { formatNumber } from '../lib/format' -import { EVIDENCE_STATE_LABEL, EVIDENCE_TEXT, JUDGE_TEXT, claimTally, evidenceTally, type ClaimTallyCounts } from '../lib/terms' +import { EVIDENCE_STATE_LABEL, EVIDENCE_TEXT, INQUIRY_TEXT, JUDGE_TEXT, claimTally, evidenceTally, type ClaimTallyCounts } from '../lib/terms' import { useEvidence, useVerdicts } from '../store/evidence' import type { EvidenceBlock, EvidenceDocData, EvidenceSegment, EvidenceUnit, EvidenceVerdict } from '../types' import { EvidencePanel, type PanelMode, type PanelView } from './EvidencePanel' +import { askAbout, type InquiryAccess } from './Inquiry' import { BlockView, Markdown, StyledSlice, inlineStyles, numericColumns, type InlineStyles } from './Markdown' /** @@ -54,6 +55,16 @@ const TEXT_CAP = 3000 /** 句末徽标在键盘顺序里的键:片段 id 是 s0…,不会撞 */ const badgeKey = (unit: string) => `claim:${unit}` const unitOfKey = (key: string) => (key.startsWith('claim:') ? key.slice(6) : null) +/** + * 审批质询的「质询」在键盘顺序里的键。只有没有可点片段、也没有徽标的句子,「质询」本身进键盘顺序(方向键走得到这一句); + * 别的句子的「质询」跟在这一句后面,焦点在这一句上时按 Tab 到达,不多占键盘顺序 + */ +const askKey = (unit: string) => `ask:${unit}` +const unitOfAsk = (key: string) => (key.startsWith('ask:') ? key.slice(4) : null) + +/** 哪些句子挂「质询」:结论句(老文档没有 kind 的按有字的句子),标题、代码块里的不挂 */ +const askable = (unit: EvidenceUnit, block: EvidenceBlock) => + (unit.kind ? unit.kind === 'claim' : !!unitText(unit).trim()) && !['heading', 'code', 'hr'].includes(block.type) interface Badge { unit: EvidenceUnit; block: number; verdict: EvidenceVerdict; state: EvidenceStateCode } @@ -64,6 +75,8 @@ interface Model { seg: Map /** 句末徽标:键是 claim:,带着这一句眼下的判定 */ badge: Map + /** 进了键盘顺序的「质询」(审批质询):键是 ask: */ + ask: Map /** 每个有可点片段的句子里第一个可点片段,按正文顺序(↑/↓ 用) */ unitFirst: string[] unitOf: Map @@ -82,11 +95,12 @@ interface Model { grid: Map } -function buildModel(blocks: EvidenceBlock[], overlay?: Record): Model { +function buildModel(blocks: EvidenceBlock[], overlay?: Record, ask = false): Model { const order: string[] = [] const index = new Map() const seg = new Map() const badge = new Map() + const asks = new Map() const unitFirst: string[] = [] const unitOf = new Map() const tiered: [string, 1 | 2][] = [] @@ -128,18 +142,27 @@ function buildModel(blocks: EvidenceBlock[], overlay?: Record a[1] - b[1]) - return { order, index, seg, badge, unitFirst, unitOf, alerts: sorted.map(([k]) => k), tier: new Map(sorted), chars, cell, - grid } + return { order, index, seg, badge, ask: asks, unitFirst, unitOf, alerts: sorted.map(([k]) => k), tier: new Map(sorted), chars, + cell, grid } } /** 片段或徽标在第几块 */ const blockOf = (model: Model, key: string | null | undefined): number => - (key ? model.seg.get(key)?.block ?? model.badge.get(key)?.block : undefined) ?? -1 + (key ? model.seg.get(key)?.block ?? model.badge.get(key)?.block ?? model.ask.get(key)?.block : undefined) ?? -1 /** * ↑/↓ 该去哪。表格里按列走:下一行同一列(那一格空着就找这一行离它最近的一格); @@ -217,13 +240,21 @@ export const EvidenceDoc = forwardRef(function EvidenceDoc({ doc, artifact, runId, dense = false, label, panel = 'auto', dock, onView, tally = false, runClass, - tier }, ref) { + tier, inquiry }, ref) { const blocks = useMemo(() => doc.blocks ?? [], [doc]) const report = doc.node_id || undefined + // 质询要知道是哪次运行的哪份报告:两样缺一样就不挂入口 + const ask = !!inquiry && !!runId && !!report // 封存之后按需追加的判定:盖过文档里的「未裁判 · 按需」。只换句末徽标,封存的文档一个字不改 const overlay = useVerdicts(runId, report) - const model = useMemo(() => buildModel(blocks, overlay), [blocks, overlay]) + const model = useMemo(() => buildModel(blocks, overlay, ask), [blocks, overlay, ask]) const counts = useMemo(() => docTally(doc), [doc]) const claims = useMemo(() => claimCounts(doc, overlay), [doc, overlay]) // 探索运行的报告(开了裁判的按需文档,或调用方说了是探索运行):证据图带着这次运行里之前按需判过的句子 @@ -272,7 +303,9 @@ export const EvidenceDoc = forwardRef { const unit = unitOfKey(id) - const sel = unit ? `[data-ev-claim="${CSS.escape(unit)}"]` : `[data-seg="${CSS.escape(id)}"]` + const asked = unitOfAsk(id) + const sel = unit ? `[data-ev-claim="${CSS.escape(unit)}"]` + : asked ? `[data-inquiry-ask="${CSS.escape(asked)}"]` : `[data-seg="${CSS.escape(id)}"]` return rootRef.current?.querySelector(sel) ?? null }, []) @@ -373,13 +406,29 @@ export const EvidenceDoc = forwardRef { - const el = (target as HTMLElement | null)?.closest?.('[data-seg], [data-ev-claim]') + const el = (target as HTMLElement | null)?.closest?.('[data-seg], [data-ev-claim], [data-inquiry-ask]') if (!el || !rootRef.current?.contains(el)) return null + if (el.dataset.inquiryAsk) return model.index.has(askKey(el.dataset.inquiryAsk)) ? askKey(el.dataset.inquiryAsk) : null return el.dataset.seg ?? (el.dataset.evClaim ? badgeKey(el.dataset.evClaim) : null) } + /** 点了句末的「质询」:打开这份报告的提问框,问的是这一句 */ + const askUnit = (el: HTMLElement) => { + const id = el.dataset.inquiryAsk + if (!id || !inquiry || !runId || !report) return + const unit = blocks.flatMap((b) => b.units ?? []).find((u) => u.id === id) + if (model.index.has(askKey(id))) setCurrent(askKey(id)) + askAbout(runId, report, inquiry, { unit: id, quote: null, text: unit ? unitText(unit).trim() : '' }, el) + } const onClick = (e: ReactMouseEvent) => { + const asking = (e.target as HTMLElement | null)?.closest?.('[data-inquiry-ask]') + if (asking && rootRef.current?.contains(asking)) { + e.preventDefault() + askUnit(asking) + return + } const id = segOf(e.target) if (!id) return e.preventDefault() @@ -445,8 +494,20 @@ export const EvidenceDoc = forwardRef ) + // 审批质询:划选一段原文,浮出「质询」,问的是选中的这段(带上它起头的那一句) + const [pick, dropPick] = useSelectionPick(rootRef, ask) + const askPick = () => { + if (!pick || !inquiry || !runId || !report) return + // 浮出的按钮点完就没了:Esc 之后焦点回到那一句的「质询」 + const back = pick.unit ? rootRef.current?.querySelector(`[data-inquiry-ask="${CSS.escape(pick.unit)}"]`) : null + document.getSelection()?.removeAllRanges() + dropPick() + askAbout(runId, report, inquiry, { unit: pick.unit, quote: pick.quote, text: pick.quote }, back) + } + // 画不了线的两种分开说:列表序号、代码块标签里的数字,和正文里根本没有对应字的(句末依据里的引用) - const summary = tallySummary({ ...counts, claims }, model.order.length > 0) + const summary = [tallySummary({ ...counts, claims }, model.order.length > 0), ask ? INQUIRY_TEXT.keysHint : ''] + .filter(Boolean).join('。') return (
@@ -473,7 +534,7 @@ export const EvidenceDoc = forwardRef + panelId={panelId} model={model} ask={ask} /> {mode === 'inline' && b === activeBlock && panelNode} ))} @@ -494,10 +555,78 @@ export const EvidenceDoc = forwardRef e.preventDefault()} + onClick={(e) => { e.stopPropagation(); askPick() }}> + {INQUIRY_TEXT.ask} + , + document.body, + )}
) }) +/** 划选的一段原文:要问的字、它起头的那一句、浮出按钮的位置(视口坐标) */ +interface SelectionPick { quote: string; unit: string | null; left: number; top: number } +/** 浮出按钮的大致尺寸:放在选区末尾下方,贴边时收进视口,下方放不下时放到选区上方 */ +const PICK_W = 64 +const PICK_H = 26 + +/** + * 报告正文里划选了一段原文:返回要问的字和浮出按钮的位置,选区收起、移出正文时为 null。只认报告正文里的字——栏内展开 + * 的证据面板也在这个容器里,那里的字不算。要问的字按选区克隆出来算,去掉句末徽标、「无证据」小标签、读屏文字和 + * 「质询」本身(它们在 DOM 里,却不是报告的原文) + */ +function useSelectionPick(root: RefObject, enabled: boolean): [SelectionPick | null, () => void] { + const [pick, setPick] = useState(null) + useEffect(() => { + if (!enabled) { setPick(null); return } + let frame = 0 + const read = () => { + frame = 0 + const el = root.current + const sel = document.getSelection() + if (!el || !sel || sel.isCollapsed || !sel.rangeCount) { setPick(null); return } + const range = sel.getRangeAt(0) + const at = (n: Node) => (n instanceof Element ? n : n.parentElement) + const inside = (x: Element | null) => !!x && el.contains(x) && !!x.closest('[data-block]') && !x.closest('[data-evidence-panel]') + const start = at(range.startContainer) + if (!inside(start) || !inside(at(range.endContainer))) { setPick(null); return } + const frag = range.cloneContents() + frag.querySelectorAll('[data-inquiry-ask], .ev-tag, .ev-badge, .sr-only').forEach((n) => n.remove()) + const quote = (frag.textContent ?? '').replace(/\s+/g, ' ').trim() + if ([...quote].length < 2) { setPick(null); return } + const rects = range.getClientRects() + const last = rects[rects.length - 1] ?? range.getBoundingClientRect() + const first = rects[0] ?? last + const below = last.bottom + 6 + PICK_H <= window.innerHeight + const next: SelectionPick = { + quote, unit: start?.closest('[data-ev-unit]')?.getAttribute('data-ev-unit') ?? null, + left: Math.max(8, Math.min(last.right - PICK_W / 2, window.innerWidth - PICK_W - 8)), + top: below ? last.bottom + 6 : Math.max(8, first.top - PICK_H - 6), + } + // 滚动时每一帧都会量一次:没变就不换对象,整份报告不跟着重画 + setPick((cur) => (cur && cur.quote === next.quote && cur.unit === next.unit && cur.left === next.left && cur.top === next.top + ? cur : next)) + } + // 选区一变、页面一滚(报告在可滚动的卡片里)都重新量:按钮跟着选区走 + const schedule = () => { if (!frame) frame = requestAnimationFrame(read) } + document.addEventListener('selectionchange', schedule) + window.addEventListener('scroll', schedule, true) + window.addEventListener('resize', schedule) + return () => { + cancelAnimationFrame(frame) + document.removeEventListener('selectionchange', schedule) + window.removeEventListener('scroll', schedule, true) + window.removeEventListener('resize', schedule) + } + }, [root, enabled]) + return [pick, useCallback(() => setPick(null), [])] +} + /** 定位到的片段描一下边(CSS 的 [data-flash]),同 AssistantStream 的 flash */ function flashOnce(el: HTMLElement) { el.dataset.flash = 'focus' @@ -590,12 +719,14 @@ export function ClaimTally({ claims, lead }: { claims: ClaimTallyCounts; lead: b /** 列表项的开头:结构片段里带着行首的 - / 1. 。列表项被分成几句时,后面几句接在同一项里 */ const ITEM_START = /(^|\n)[ \t]*([-*+]|\d+[.)])[ \t]+$/ -const DocBlock = memo(function DocBlock({ block, dense, doc, visual, current, active, panelId, model }: { +const DocBlock = memo(function DocBlock({ block, dense, doc, visual, current, active, panelId, model, ask }: { block: EvidenceBlock dense: boolean doc: EvidenceDocData /** 句末徽标从这里取:这一句眼下的判定(封存后追加的盖过文档里的) */ model: Model + /** 审批质询:结论句后面挂「质询」 */ + ask: boolean /** * 长文档:块上加 content-visibility,屏幕外的块不排版不绘制。短文档不加:它自带 paint * containment,贴着块边的片段的焦点描边会被裁掉一截,而短文档本来就不慢 @@ -614,7 +745,8 @@ const DocBlock = memo(function DocBlock({ block, dense, doc, visual, current, ac } const unit = (u: EvidenceUnit, opts?: { code?: boolean; tag?: boolean }) => ( + code={opts?.code} tag={opts?.tag ?? true} badge={model.badge.get(badgeKey(u.id))} + ask={ask && askable(u, block) ? (model.ask.has(askKey(u.id)) ? 'roving' : 'follow') : undefined} /> ) switch (block.type) { @@ -684,9 +816,14 @@ const DocBlock = memo(function DocBlock({ block, dense, doc, visual, current, ac * 被切坏),有状态的片段包成按钮。含无证据片段的句子末尾挂「? 无证据」;有判定的结论句末尾挂 * 一枚徽标(也是按钮),证据相矛盾(和旧的证据不支持)的整句铺浅底——字本身不画线 */ -function UnitView({ unit, doc, current, active, panelId, code, tag, badge }: { +function UnitView({ unit, doc, current, active, panelId, code, tag, badge, ask }: { unit: EvidenceUnit; doc: EvidenceDocData; current: string | null; active: string | null; panelId: string code?: boolean; tag: boolean; badge?: Badge + /** + * 审批质询的「质询」:roving 是这一句一个可点的都没有,「质询」本身进键盘顺序;follow 跟在这一句后面,焦点在这一句上时 + * 按 Tab 到达 + */ + ask?: 'roving' | 'follow' }) { const segs = (unit.segments ?? []).filter((s) => s.kind !== 'structural') const text = segs.map((s) => s.text).join('') @@ -727,7 +864,7 @@ function UnitView({ unit, doc, current, active, panelId, code, tag, badge }: { const key = badge ? badgeKey(unit.id) : '' const meta = badge ? EVIDENCE_STATE[badge.state] : null const open = !!badge && active === key - return ( + const body = ( <> {conflicting(badge?.state) // 证据相矛盾(和旧的证据不支持):整句铺一层浅底(折行时每行各自带圆角),字色、字重都不动,也不画线 @@ -762,6 +899,28 @@ function UnitView({ unit, doc, current, active, panelId, code, tag, badge }: { )} ) + if (!ask) return body + // 「质询」跟在句末:平时不占地方(宽度为 0、透明),悬停这一句、焦点落在这一句上时出现;触屏没有悬停,一直显示。 + // 跟随的那种只在焦点在这一句上时进 Tab 顺序,整份报告不因此多出一串 Tab 位 + const here = current != null && (current === key || current === askKey(unit.id) + || (unit.segments ?? []).some((sg) => sg.id === current)) + return ( + + {body} + + + ) } /** diff --git a/frontend/src/run/Inquiry.tsx b/frontend/src/run/Inquiry.tsx new file mode 100644 index 00000000..4d1cae3f --- /dev/null +++ b/frontend/src/run/Inquiry.tsx @@ -0,0 +1,1053 @@ +import { + useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, + type KeyboardEvent as ReactKeyboardEvent, type ReactNode, type RefObject, +} from 'react' +import { create } from 'zustand' +import { AlertTriangle, ChevronRight, MessageCircleQuestion } from 'lucide-react' +import { ApiError, api } from '../api/client' +import { Spinner, isComposing, useTicker } from '../components/ui' +import { EVIDENCE_STATE, segmentLabel, segmentState, sourceOf, unitText, type EvidenceStateCode } from '../lib/evidence' +import { humanizeError } from '../lib/errors' +import { formatCost, formatDateTime, formatNumber, formatTime } from '../lib/format' +import { EVIDENCE_STATE_LABEL, EVIDENCE_TEXT, INQUIRY_TEXT } from '../lib/terms' +import { useEvidence } from '../store/evidence' +import type { + EvidenceBlock, EvidenceDocData, EvidenceSegment, EvidenceUnit, Inquiry, InquiryQuery, InquiryRequest, InquirySnapshot, Run, +} from '../types' +import { ResultTable } from './AssistantStream' +import type { EvidenceDocHandle } from './EvidenceDoc' +import { BlockView, CopyChip, Markdown, StyledSlice, inlineStyles, numericColumns } from './Markdown' + +/** + * 审批质询:审批人就待审的报告提问——选一句(句末的「质询」)、划一段原文(浮出的「质询」),或者就整份报告—— + * 核查模型只读查库作答。问答写进运行记录、随运行一并封存,不改动报告;回答里的数字照报告的样子标出处,点开看它查了 + * 什么。 + * + * 分三处: + * - 入口在报告文档上(EvidenceDoc 的 inquiry 属性,askAbout 把要问的那句交到这里的 store); + * - 问答串和提问框在 ReportInquiry 里,由页面挂在报告下面(审批卡:报告核对概况下面;运行详情:报告下面), + * 问答按句子归拢,同一句下按时间排;进入时按列表接口恢复; + * - 回答期间(这一次运行有一条在问)审批卡的批准 / 驳回暂停(useInquiryBusy):服务端在回答时也会拒绝审批回复。 + * + * 一次运行同一时刻只有一条在回答(服务端回 409 inquiry_busy):请求挂在 store 上,不随组件卸载作废,回答回来时 + * 卡片收起了也照样记进问答串。没写进运行记录的(recorded 为假)醒目地说,它只留在这一页里。 + */ + +/** 问题、原文的长度上限(同服务端,按字数) */ +export const QUESTION_MAX = 1000 +export const QUOTE_MAX = 500 + +/** 页面传给报告文档、问答串的「能不能问」:给了就是能问;approvalId 是运行停在的那张审批(封存后的探索运行为 null) */ +export interface InquiryAccess { + approvalId: string | null +} + +/** 封存了的探索运行:能问,不带审批(同一个对象,报告文档不因此重画) */ +export const NO_APPROVAL: InquiryAccess = Object.freeze({ approvalId: null }) + +/** 要问的是哪儿:一句(unit)、划选的原文(quote,同时带上它起头的那一句),或者整份报告(两个都是 null) */ +export interface InquiryTarget { + unit: string | null + quote: string | null + /** 提问框里给人看的:那句话或那段原文 */ + text: string +} + +/** + * 运行详情的报告上给不给「质询」:只有封存了的探索运行。正式运行封存之后不再开放(同封存后的按需裁判); + * 还在执行、停在审批上的运行在审批卡上问(问的时候带着那张审批) + */ +export function inquiryOpen(run: Pick | null | undefined): boolean { + return !!run && run.run_class === 'exploratory' && !!run.manifest_hash + && (run.status === 'succeeded' || run.status === 'failed' || run.status === 'cancelled') +} + +// ---------------------------------------------------------------- 认服务端给的 + +const str = (v: unknown) => (typeof v === 'string' ? v.trim() : '') +const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : null) + +function asQuery(x: unknown): InquiryQuery | null { + if (!x || typeof x !== 'object') return null + const q = x as Record + return { + alias: str(q.alias), artifact: str(q.artifact), sql: typeof q.sql === 'string' ? q.sql : '', + row_count: num(q.row_count), truncated: q.truncated === true, source: str(q.source) || null, + } +} + +/** + * 一条问答认不认得:至少得有问题。别的字段缺了、类型不对的按没有算——服务端由另一个工作包按同一份约定实现, + * 字段名对得上,取值照样可能缺 + */ +export function asInquiry(x: unknown): Inquiry | null { + if (!x || typeof x !== 'object') return null + const r = x as Record + const question = str(r.question) + if (!question) return null + const a = r.answer && typeof r.answer === 'object' ? r.answer as Record : {} + return { + seq: num(r.seq), at: typeof r.at === 'string' || typeof r.at === 'number' ? r.at : null, actor: str(r.actor) || null, + report: str(r.report), unit: str(r.unit) || null, quote: str(r.quote) || null, question, + answer: { + text: str(a.text), doc_artifact: str(a.doc_artifact) || null, ok: a.ok === true, limit: str(a.limit) || null, + error: str(a.error) || null, + }, + queries: (Array.isArray(r.queries) ? r.queries : []).map(asQuery).filter((q): q is InquiryQuery => !!q), + model: str(r.model) || null, usage: r.usage && typeof r.usage === 'object' ? r.usage : {}, + post_seal: r.post_seal === true, approval_id: str(r.approval_id) || null, + } +} + +/** + * 质询工件接口给的查询快照。约定只说是快照:可能是快照本身,也可能包在 {id, content} 里,两种都认。遮掉的列以接口 + * 给的 masked 为准,快照里记着的 mask_columns 也算进去(接口万一给了原值,界面照样不显示) + */ +export function snapshotOf(body: unknown): InquirySnapshot | null { + if (!body || typeof body !== 'object') return null + const outer = body as Record + const c = (outer.content && typeof outer.content === 'object' ? outer.content : outer) as Record + if (!Array.isArray(c.columns) || !Array.isArray(c.rows)) return null + const columns = c.columns.map(String) + const rows = c.rows.map((r) => (Array.isArray(r) ? r + : columns.map((col) => (r && typeof r === 'object' ? (r as Record)[col] ?? null : null)))) + const list = (v: unknown) => (Array.isArray(v) ? v.map(String) : []) + const masked = [...new Set([...list(c.masked), ...list(outer.masked), ...list(c.mask_columns)])] + return { + columns, rows, sql: typeof c.sql === 'string' ? c.sql : null, truncated: c.truncated === true, + total: num(c.total_rows) ?? num(c.row_count), masked, source: str(c.source) || null, + } +} + +/** 出错时给人看的一句:服务端的 409 / 422 照它的中文说明;连不上照通用的说法 */ +function problemOf(e: unknown): Problem { + if (e instanceof ApiError) { + return { message: e.kind === 'network' ? humanizeError(e).title : e.message, code: e.code ?? (e.status ? String(e.status) : 'network'), + field: e.field } + } + return { message: humanizeError(e).title || INQUIRY_TEXT.submitFailed, code: 'client' } +} + +// ---------------------------------------------------------------- store + +interface Problem { message: string; code: string; field?: string } + +/** 问答串里的一条:服务端列出来的都写进了运行记录;刚问的照答复的 recorded 记 */ +export interface InquiryEntry { + /** React 的键:有事件序号用序号,没写进运行记录的另编一个 */ + id: string + inquiry: Inquiry + recorded: boolean + note: string | null +} + +interface Thread { + status: 'loading' | 'ok' | 'error' + /** 服务端列出来的 */ + listed: Inquiry[] + /** 这一页里问过的(列表重新取回来之后,写进运行记录的按序号去重,没写进的一直留在这里) */ + local: InquiryEntry[] + error?: Problem +} + +interface Draft { + target: InquiryTarget + approvalId: string | null + question: string + error: Problem | null + /** 每次点「质询」加一:提问框据此把焦点送进输入框,换了一句也一样 */ + nonce: number +} + +interface Pending { + key: string + target: InquiryTarget + question: string + startedAt: number +} + +interface InquiryState { + threads: Record + /** 开着的提问框,按(运行、报告)各一个 */ + drafts: Record + /** 正在回答的,按运行:一次运行同一时刻只有一条 */ + pending: Record + load: (runId: string, report: string) => Promise + compose: (runId: string, report: string, target: InquiryTarget, approvalId: string | null) => void + edit: (key: string, question: string) => void + close: (key: string) => void + submit: (runId: string, report: string) => Promise +} + +export const inquiryKey = (runId: string, report: string) => `${runId}@${report}` + +/** 打开提问框的那个元素(句末的「质询」、划选浮出的按钮已经没了时退回整份报告那个):Esc、取消之后焦点回到这里 */ +const openers = new Map() +let localSeq = 0 + +export const useInquiry = create((set, get) => ({ + threads: {}, + drafts: {}, + pending: {}, + + load: async (runId, report) => { + const key = inquiryKey(runId, report) + if (get().threads[key]?.status === 'loading') return + set((s) => { + const cur = s.threads[key] + return { threads: { ...s.threads, [key]: { listed: cur?.listed ?? [], local: cur?.local ?? [], status: 'loading' } } } + }) + try { + const res = await api.inquiry.list(runId, { report }) + const listed = (Array.isArray(res?.items) ? res.items : []).map(asInquiry) + .filter((x): x is Inquiry => !!x && (!x.report || x.report === report)) + set((s) => ({ threads: { ...s.threads, [key]: { ...s.threads[key], listed, status: 'ok' } } })) + } catch (e) { + // 老服务端没有这个接口(404):当作还没有问答,不报错 + const missing = e instanceof ApiError && e.status === 404 + set((s) => ({ threads: { ...s.threads, [key]: { + ...s.threads[key], status: missing ? 'ok' : 'error', error: missing ? undefined : problemOf(e), + } } })) + } + }, + + compose: (runId, report, target, approvalId) => { + const key = inquiryKey(runId, report) + set((s) => { + const cur = s.drafts[key] + // 换了一句:问题留着(多半是同一个问题挪个地方问),上一次的报错不留 + return { drafts: { ...s.drafts, [key]: { + target, approvalId, question: cur?.question ?? '', error: null, nonce: (cur?.nonce ?? 0) + 1, + } } } + }) + }, + + edit: (key, question) => set((s) => { + const cur = s.drafts[key] + return cur ? { drafts: { ...s.drafts, [key]: { ...cur, question, error: cur.error?.field === 'quote' ? cur.error : null } } } : s + }), + + close: (key) => set((s) => ({ drafts: { ...s.drafts, [key]: undefined } })), + + submit: async (runId, report) => { + const key = inquiryKey(runId, report) + const draft = get().drafts[key] + if (!draft) return + const question = draft.question.trim() + const fail = (error: Problem) => set((s) => ({ drafts: { ...s.drafts, [key]: { ...draft, error } } })) + if (!question) return fail({ message: INQUIRY_TEXT.empty, code: 'local', field: 'question' }) + if ([...question].length > QUESTION_MAX) return fail({ message: INQUIRY_TEXT.tooLong(QUESTION_MAX), code: 'local', field: 'question' }) + if (get().pending[runId]) return fail({ message: INQUIRY_TEXT.busyOther, code: 'local' }) + const quote = draft.target.quote ? [...draft.target.quote].slice(0, QUOTE_MAX).join('') : '' + // 约定:unit 可为 null;quote 可选,没划选就不带;approval_id 只在运行停在审批上时带 + const body: InquiryRequest = { + report, unit: draft.target.unit, ...(quote ? { quote } : {}), question, + ...(draft.approvalId ? { approval_id: draft.approvalId } : {}), + } + set((s) => ({ + drafts: { ...s.drafts, [key]: undefined }, + pending: { ...s.pending, [runId]: { key, target: draft.target, question, startedAt: Date.now() } }, + })) + try { + const res = await api.inquiry.ask(runId, body) + const inquiry = asInquiry(res?.inquiry) ?? asInquiry({ ...body, answer: {} })! + const entry: InquiryEntry = { + id: inquiry.seq != null ? `s${inquiry.seq}` : `l${++localSeq}`, inquiry: { ...inquiry, report: inquiry.report || report }, + recorded: res?.recorded !== false, note: str(res?.note) || null, + } + set((s) => { + const cur = s.threads[key] ?? { status: 'ok' as const, listed: [], local: [] } + return { + threads: { ...s.threads, [key]: { ...cur, local: [...cur.local, entry] } }, + pending: { ...s.pending, [runId]: undefined }, + } + }) + } catch (e) { + // 没问成(409 / 422 / 连不上):提问框带着原来的问题重新打开,照服务端的说明显示原因 + set((s) => ({ + pending: { ...s.pending, [runId]: undefined }, + drafts: { ...s.drafts, [key]: { ...draft, question: draft.question, error: problemOf(e), nonce: draft.nonce + 1 } }, + })) + } + }, +})) + +/** + * 句末的「质询」、划选浮出的「质询」点下去:打开这份报告的提问框。opener 是点下去的那个元素,Esc、取消之后焦点回到它 + */ +export function askAbout(runId: string, report: string, access: InquiryAccess, target: InquiryTarget, opener?: HTMLElement | null) { + const key = inquiryKey(runId, report) + if (opener) openers.set(key, opener) + else openers.delete(key) + useInquiry.getState().compose(runId, report, target, access.approvalId) +} + +/** 这次运行是不是有一条质询正在回答:审批卡据此暂停批准 / 驳回(服务端这时也回 409) */ +export const useInquiryBusy = (runId: string | null | undefined): boolean => + useInquiry((s) => !!runId && !!s.pending[runId]) + +/** 问答串:服务端列出来的,加上这一页里问过、列表里还没有的(没写进运行记录的一直只在这里),按提问时间排 */ +function entriesOf(thread: Thread | undefined): InquiryEntry[] { + if (!thread) return [] + const listed: InquiryEntry[] = thread.listed.map((inquiry, i) => ({ + id: inquiry.seq != null ? `s${inquiry.seq}` : `g${i}`, inquiry, recorded: true, note: null, + })) + const seen = new Set(thread.listed.map((x) => x.seq).filter((s) => s != null)) + const local = thread.local.filter((e) => e.inquiry.seq == null || !seen.has(e.inquiry.seq)) + const at = (e: InquiryEntry) => { + const t = typeof e.inquiry.at === 'number' ? e.inquiry.at * (e.inquiry.at < 1e12 ? 1000 : 1) + : e.inquiry.at ? Date.parse(/[zZ]|[+-]\d\d:?\d\d$/.test(e.inquiry.at) ? e.inquiry.at : `${e.inquiry.at}Z`) : NaN + return Number.isFinite(t) ? t : Infinity + } + // 稳定排序:时间相同、没有时间的保持原来的先后(列表在前,刚问的在后) + return [...listed, ...local].map((e, i) => ({ e, i, t: at(e) })) + .sort((a, b) => (a.t === b.t ? a.i - b.i : a.t - b.t)).map((x) => x.e) +} + +// ---------------------------------------------------------------- 问答串 + +/** 问答按句子归拢:有 unit 的按句子,只有原文的按原文,都没有的是整份报告 */ +const groupKey = (t: { unit: string | null; quote: string | null }) => + t.unit ? `u:${t.unit}` : t.quote ? `q:${t.quote}` : 'report' + +interface Group { + key: string + unit: string | null + quote: string | null + entries: InquiryEntry[] +} + +/** 焦点丢了(按过的按钮被拿掉,落回 body)才替它接住;用户已经去了别处的不抢 */ +const focusLost = () => !document.activeElement || document.activeElement === document.body + +/** 接住焦点的几处本身不在 Tab 顺序里(tabIndex -1),键盘用户得看得见落在哪 */ +const FOCUS_RING = 'rounded-sm 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)]' + +/** + * 一份报告的审批质询:问答串(按句子归拢,同一句下按时间排)、正在回答的那一条、提问框,以及「就整份报告提问」。 + * access 为空时只读:只列已有的问答,不给提问(没有问答时什么都不画)。onLocate:回答里引用了报告已有出处的数字, + * 交给页面在报告里打开那一处的证据面板,找不到时返回 false,这里照文档记下的出处说一句 + */ +export function ReportInquiry({ runId, report, docArtifact, access, onLocate, postSeal = false }: { + runId: string + report: string + /** 报告文档的工件 id:问答串的句子取原文 */ + docArtifact?: string | null + access: InquiryAccess | null + onLocate?: (seg: EvidenceSegment, doc: EvidenceDocData) => boolean + /** 运行已封存(运行详情):提问框写明问答追加在封存之后 */ + postSeal?: boolean +}) { + const key = inquiryKey(runId, report) + const thread = useInquiry((s) => s.threads[key]) + const draft = useInquiry((s) => s.drafts[key]) + const pending = useInquiry((s) => s.pending[runId]) + const load = useInquiry((s) => s.load) + useEffect(() => { void load(runId, report) }, [runId, report, load]) + const entries = useMemo(() => entriesOf(thread), [thread]) + const mine = pending?.key === key ? pending : undefined + + // 句子的原文从报告文档取(审批卡上逐段证据没展开时也要取:问答串得写出问的是哪一句) + const docSlot = useEvidence((s) => (docArtifact ? s.docs[docArtifact] : undefined)) + const loadDoc = useEvidence((s) => s.loadDoc) + const needDoc = !!docArtifact && (entries.some((e) => e.inquiry.unit) || !!draft?.target.unit || !!mine?.target.unit) + useEffect(() => { if (needDoc && docArtifact) void loadDoc(docArtifact) }, [needDoc, docArtifact, loadDoc]) + const units = useMemo(() => { + const map = new Map() + const doc = docSlot?.status === 'ok' ? docSlot.data : undefined + for (const b of doc?.blocks ?? []) for (const u of b.units ?? []) map.set(u.id, unitText(u).trim()) + return map + }, [docSlot]) + + const groups = useMemo(() => { + const out = new Map() + const add = (unit: string | null, quote: string | null) => { + const k = groupKey({ unit, quote }) + if (!out.has(k)) out.set(k, { key: k, unit, quote, entries: [] }) + return out.get(k)! + } + for (const e of entries) add(e.inquiry.unit, e.inquiry.quote).entries.push(e) + // 正在回答的、开着的提问框挂在它们问的那一句下面:那句还没有问答时另起一组,排在最后 + if (mine) add(mine.target.unit, mine.target.quote) + if (draft) add(draft.target.unit, draft.target.quote) + return [...out.values()] + }, [entries, mine, draft]) + + // 提交之后提问框拿掉了,焦点接到「正在回答」那一条上;回答回来那一条换掉了,再接到回答上 + const pendingRef = useRef(null) + const lastRef = useRef(null) + const held = useRef(false) + useLayoutEffect(() => { + if (mine && focusLost()) { pendingRef.current?.focus({ preventScroll: true }); held.current = true } + }, [mine]) + const count = entries.length + const [heard, setHeard] = useState('') + const seen = useRef(count) + useLayoutEffect(() => { + if (count > seen.current) { + setHeard(INQUIRY_TEXT.arrived) + if (held.current && focusLost()) lastRef.current?.focus({ preventScroll: true }) + } + held.current = false + seen.current = count + }, [count]) + + const reportBtn = useRef(null) + if (!access && !entries.length && thread?.status !== 'error') return null + const latest = entries[entries.length - 1]?.id + + return ( +
+
+ + {INQUIRY_TEXT.title} + {count > 0 && · {INQUIRY_TEXT.headCount(count)}} + + + {access && ( + + )} +
+ {access && !count && !mine && !draft && ( +

+ {postSeal ? INQUIRY_TEXT.introRun : INQUIRY_TEXT.intro} +

+ )} + {thread?.status === 'error' && ( +
+ {INQUIRY_TEXT.loadFailed} + +
+ )} +
{heard}
+ {groups.length > 0 && ( +
+ {groups.map((g) => ( +
+ +
+ {g.entries.map((e) => ( + { lastRef.current = el } : undefined} /> + ))} + {mine && groupKey(mine.target) === g.key && ( + { pendingRef.current = el }} /> + )} + {draft && groupKey(draft.target) === g.key && ( + + )} +
+
+ ))} +
+ )} +
+ ) +} + +/** 一组的抬头:问的是哪一句(原文从报告文档取,取不到写「报告中的一句」)、哪段原文,或者整份报告 */ +function GroupHead({ group, units }: { group: Group; units: Map }) { + const text = group.unit ? units.get(group.unit) || group.entries.find((e) => e.inquiry.quote)?.inquiry.quote || '' + : group.quote ?? '' + return ( +
+ {text ? ( + + 「」 + + ) : ( + {group.unit ? INQUIRY_TEXT.unitMissing : INQUIRY_TEXT.wholeReport} + )} +
+ ) +} + +/** 问、答前面的小标签 */ +function Tag({ children }: { children: ReactNode }) { + return {children} +} + +/** 一问一答 */ +function EntryView({ runId, entry, groupQuote, onLocate, focusRef }: { + runId: string + entry: InquiryEntry + /** 组抬头已经写着这段原文:问题下面不再写一遍 */ + groupQuote: string | null + onLocate?: (seg: EvidenceSegment, doc: EvidenceDocData) => boolean + focusRef?: (el: HTMLElement | null) => void +}) { + const { inquiry, recorded, note } = entry + const baseId = useId() + /** 展开了结果的查询:别名 → 要高亮的那一格(从回答里的数字点过来的)或 null */ + const [shown, setShown] = useState>({}) + /** 查询列表展开没有:从回答里的数字点过来时一并展开(同一次渲染,焦点才跳得过去) */ + const [listOpen, setListOpen] = useState(false) + const [elsewhere, setElsewhere] = useState(null) + const jumpTo = useRef(null) + useEffect(() => { + const alias = jumpTo.current + if (!alias) return + jumpTo.current = null + const part = document.getElementById(`${baseId}-q-${alias}`) + const head = part?.querySelector('[data-inquiry-query-head]') + if (!part || !head) return + head.focus({ preventScroll: true }) + part.scrollIntoView({ block: 'nearest' }) + part.dataset.flash = 'focus' + setTimeout(() => { if (part.dataset.flash === 'focus') delete part.dataset.flash }, 1600) + }, [shown, baseId]) + const onCite = useCallback((cite: Cite) => { + if (cite.kind === 'query') { + jumpTo.current = cite.alias + setElsewhere(null) + setListOpen(true) + setShown((s) => ({ ...s, [cite.alias]: cite.cell })) + return + } + const found = onLocate?.(cite.seg, cite.doc) ?? false + setElsewhere(found ? null : sourceOf(cite.seg, cite.doc) || cite.seg.text) + }, [onLocate]) + const who = inquiry.actor || INQUIRY_TEXT.unsigned + const when = inquiry.at != null ? formatTime(inquiry.at) : '' + const answer = inquiry.answer + const limit = answer.limit ? INQUIRY_TEXT.limit[answer.limit] ?? INQUIRY_TEXT.limitOther : '' + const quote = inquiry.quote && inquiry.quote !== groupQuote ? inquiry.quote : null + // 一次模型调用都没有(没配好模型、当日金额已到上限)就不写花费,写「$0」像是调用过 + const calls = typeof inquiry.usage?.calls === 'number' ? inquiry.usage.calls : null + const cost = typeof inquiry.usage?.cost_usd === 'number' && (inquiry.usage.cost_usd > 0 || !!calls) ? inquiry.usage.cost_usd : null + const answered = !!answer.text || !!answer.doc_artifact + return ( +
+
+ {INQUIRY_TEXT.q} +
+

{inquiry.question}

+

+ {[who, when].filter(Boolean).join(' · ')} + {inquiry.post_seal && <> · {INQUIRY_TEXT.postSeal}} +

+ {quote && ( +

{INQUIRY_TEXT.quoteLine(quote)}

+ )} +
+
+ {/* 没写进运行记录:醒目地说,它只留在这一页,不随运行封存 */} + {!recorded && ( +

+ + + {INQUIRY_TEXT.notRecorded}{note ? `:${note}` : ''} + {INQUIRY_TEXT.notRecordedHint} + +

+ )} +
+ {INQUIRY_TEXT.a} +
+ + {elsewhere != null && ( +

{INQUIRY_TEXT.reportSource(elsewhere)}

+ )} + {answer.ok && limit && ( +

{INQUIRY_TEXT.answerIncomplete(limit)}

+ )} + {!answer.ok && ( +

+ {[INQUIRY_TEXT.failed(limit || null), answer.error].filter(Boolean).join(':')} +

+ )} +
+
+ setShown((s) => { + const next = { ...s } + if (alias in next) delete next[alias] + else next[alias] = null + return next + })} /> + {/* 回答是核查模型给的、不是系统核对:有回答才说 */} + {answered && ( +

+ {INQUIRY_TEXT.answeredBy(inquiry.model)} + {cost != null && · {INQUIRY_TEXT.cost(formatCost(cost))}} +

+ )} +
+ ) +} + +/** 正在回答的那一条:问题、转圈和等了多久。回答期间审批卡的批准 / 驳回暂停(审批卡读 useInquiryBusy) */ +function PendingView({ pending, focusRef }: { pending: Pending; focusRef: (el: HTMLElement | null) => void }) { + const now = useTicker(1000) + const waited = Math.max(0, Math.floor((now - pending.startedAt) / 1000)) + return ( +
+
+ {INQUIRY_TEXT.q} +

{pending.question}

+
+

+ + {INQUIRY_TEXT.pending} + {waited >= 3 && · {INQUIRY_TEXT.waited(waited)}} +

+
+ ) +} + +/** + * 提问框:问的是哪一句(或哪段原文、整份报告)、问题、提交。Esc 关掉、焦点回到点开它的那个「质询」;Ctrl / ⌘ + 回车提交。 + * 字数超了、空着不让提交;这次运行另有一条在回答时也不让(服务端同样会拒绝) + */ +function Composer({ runId, report, draft, busy, postSeal, fallback }: { + runId: string + report: string + draft: Draft + busy: boolean + postSeal: boolean + fallback: RefObject +}) { + const key = inquiryKey(runId, report) + const edit = useInquiry((s) => s.edit) + const close = useInquiry((s) => s.close) + const submit = useInquiry((s) => s.submit) + const id = useId() + const area = useRef(null) + const form = useRef(null) + // 每点一次「质询」(换了一句也算)都把焦点送进输入框,并把提问框滚进视野 + useEffect(() => { + area.current?.focus({ preventScroll: true }) + form.current?.scrollIntoView({ block: 'nearest' }) + }, [draft.nonce]) + const chars = [...draft.question].length + const over = chars > QUESTION_MAX + const empty = !draft.question.trim() + const cut = !!draft.target.quote && [...draft.target.quote].length > QUOTE_MAX + const target = draft.target.quote ? 'quote' : draft.target.unit ? 'unit' : 'report' + const shown = draft.target.quote ?? draft.target.text + const dismiss = () => { + close(key) + const back = openers.get(key) + openers.delete(key) + requestAnimationFrame(() => { + if (back?.isConnected) back.focus({ preventScroll: true }) + else fallback.current?.focus({ preventScroll: true }) + }) + } + const onKeyDown = (e: ReactKeyboardEvent) => { + if (isComposing(e)) return + if (e.key === 'Escape') { + // 截住:画布上的审批浮层、证据面板也听 Esc,关提问框不该连带把它们收起 + e.preventDefault() + e.stopPropagation() + dismiss() + } else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + e.preventDefault() + if (!empty && !over && !busy) void submit(runId, report) + } + } + const problem = draft.error + return ( +
{ e.preventDefault(); if (!empty && !over && !busy) void submit(runId, report) }}> +
{INQUIRY_TEXT.target[target]}
+ {shown && ( +
+ {shown} +
+ )} + {cut &&

{INQUIRY_TEXT.quoteCut(QUOTE_MAX)}

} + +