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/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..88e69b3d --- /dev/null +++ b/frontend/src/dev/fixtures/inquiry.tsx @@ -0,0 +1,143 @@ +import { useState, type JSX } from 'react' +import { createPortal } from 'react-dom' +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:审批已经处理过(不是待处理的那张):只列问答,不给提问 + * - ?syn=inquiry-approval-nodoc:审的报告没有逐段证据(没有报告文档):只能就整份报告提问 + * - ?syn=inquiry-approval-twice:同一张待审批卡同时挂在两处,同编排页里画布的审批浮层(挂到页面末尾、role=dialog, + * React 树里排在前面)和右栏;「收起浮层」按钮开关浮层 + * - ?syn=inquiry-approval-steps:同一份报告先后两张审批(主管审批 → 总监审批),「处理掉这张」换下一张;卡按审批 id + * 重新挂(同右栏)。?syn=inquiry-approval-steps-same:同一个卡片实例换审批(同画布的审批浮层,不按 id 重新挂) + * + * 运行详情的「证据」页签(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', label = '主管审批'): 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: label, run_class: runClass, + } +} + +function Card({ approval, name, dense = false }: { approval: Approval; name: string; dense?: boolean }) { + return ( +
+
+ undefined} /> +
+
+ ) +} + +/** 同一张待审批卡挂两处:浮层挂到页面末尾(同 canvas/NodeCard 的审批浮层),右栏在页面里 */ +function TwoPlaces({ approval }: { approval: Approval }) { + const [pop, setPop] = useState(true) + return ( +
+ {pop && createPortal( +
+ +
, + document.body, + )} +
+ +
+ +
+ ) +} + +/** 同一份报告先后两张审批:「处理掉这张」之后换下一张(keyed:按审批 id 重新挂,同右栏;否则同一个卡片实例换审批) */ +function Steps({ name, keyed }: { name: string; keyed: boolean }) { + const list = [ + approvalOf(fx.approval_id, RUNS.approve, 'exploratory', reports.approve), + approvalOf('ap-inq-3', RUNS.approve, 'exploratory', reports.approve, 'pending', '总监审批'), + ] + const [at, setAt] = useState(0) + const approval = list[Math.min(at, list.length - 1)] + return ( +
+ +
+ {keyed + ? undefined} /> + : 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-approval-nodoc': () => ( + + ), + 'inquiry-approval-twice': () => ( + + ), + 'inquiry-approval-steps': () => , + 'inquiry-approval-steps-same': () => , + 'inquiry-run-explore': () => , + 'inquiry-run-formal': () => , +} diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 7b9e0da9..8ea7b9fb 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3368,9 +3368,129 @@ 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}`, + }, + + // ---- 报告上的入口(EvidenceDoc):句末的「质询」、划选原文时浮出的「质询」 ---- + ask: '质询', + askUnit: (text: string) => `质询这句:${text}`, + /** 句末「质询」图标的悬停说明 */ + askTitle: '质询这一句', + askSelection: '就选中的原文质询', + /** 读屏摘要里接在按键说明后面 */ + keysHint: '每句结论后有「质询」按钮:方向键走到这一句,再按 Tab 到达;没有可点内容的句子,方向键直接走到它的「质询」', + + // ---- 问答串(ReportInquiry) ---- + headCount: (n: number) => `${formatNumber(n)} 次`, + askReport: '就整份报告提问', + intro: '展开逐段证据后,点句末的「质询」图标或划选一段原文,即可就该处提问', + introRun: '点句末的「质询」图标或划选一段原文,即可就该处提问', + /** 报告没有逐段证据(没有报告文档):句子、原文上都没有入口 */ + introNoDoc: '这份报告没有逐段证据,可以就整份报告提问', + /** 设置里关了审批质询:不给入口,已有的问答照列 */ + disabled: '审批质询已在设置中关闭', + wholeReport: '整份报告', + /** 报告文档取不到,写不出问的是哪一句 */ + unitMissing: '报告中的一句', + /** 之前几轮审批里问的、报告后来改写过(句子编号对不上),或者对不上是哪一稿:单独一组 */ + otherDoc: '报告其他版本上的质询', + otherDocHint: '提问时的报告与当前显示的版本不同,或无法确认是否相同,所以不挂到当前报告的句子下面', + loadFailed: '无法读取之前的质询记录', + retry: '重试', + q: '问', + a: '答', + unsigned: '未署名', + entryLabel: (question: string) => `质询:${question}`, + quoteLine: (quote: string) => `原文「${quote}」`, + notRecorded: '这次问答没有写入运行记录', + notRecordedHint: '它只显示在当前页面,刷新后不再显示,也不会随运行封存', + /** 屏幕阅读器:回答回来了(带上问题的开头,连着几次回答各报一遍) */ + arrived: (question: string) => { + const chars = [...question.trim()] + return `已收到回答:${chars.length > 20 ? `${chars.slice(0, 20).join('')}…` : chars.join('')}` + }, + /** 同一张审批卡同时挂在两处(画布的审批浮层和右栏):提问框只在打开它的那一处 */ + elsewhere: '提问框已在另一处打开', + moveHere: '在这里继续', + /** 回答期间另开了提问框,正在回答的那一条却没问成:原来的问题和原因留在它那一句下面 */ + askFailed: (message: string) => `没有问成:${message}`, + reask: '放回提问框', + dismiss: '忽略', + + // ---- 提问框 ---- + 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},在报告中查看证据`, + /** 问的是报告的另一稿:引用的是那一稿里的出处,不在当前这一稿里找 */ + citedOther: (text: string, source: string) => `${text},出处:${source}(提问时那一版报告中的出处)`, + /** 引用的是报告已有的出处,报告里却找不到引用它的地方:照回答文档记下的出处说一句 */ + reportSource: (source: string) => `出处:${source}(报告已有的出处,报告中没有引用它的地方)`, + reportSourceOther: (source: string) => `出处:${source}(提问时那一版报告中的出处)`, + queriesHead: (n: number) => `核查模型执行的查询(${formatNumber(n)} 次)`, + /** 查询列表收着时也写:截断直接关系到回答靠不靠得住 */ + queriesTruncated: (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 a0b7b887..67c2a952 100644 --- a/frontend/src/pages/runs/EvidencePane.tsx +++ b/frontend/src/pages/runs/EvidencePane.tsx @@ -17,6 +17,7 @@ import { IssuanceBanner } from '../../run/AssistantStream' import { splitIssuanceGaps } from '../../run/decode' import { EvidenceDoc, type EvidenceDocHandle } from '../../run/EvidenceDoc' import { EvidenceGuessView } from '../../run/EvidenceGuess' +import { ReportInquiry, inquiryOpen, locateIn, useInquiryAccess } from '../../run/Inquiry' import { Markdown, inlineStyles } from '../../run/Markdown' import { useEvidence } from '../../store/evidence' import { useCatalog } from '../../store/catalog' @@ -125,6 +126,10 @@ 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) + const { access: inquiry, disabled: inquiryOff } = useInquiryAccess(asking, null) if (!graph && graphSlot?.status !== 'error') { return ( @@ -160,10 +165,16 @@ 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={inquiry} /> + 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/pages/runs/useRunDetail.ts b/frontend/src/pages/runs/useRunDetail.ts index 95800ef8..961143d1 100644 --- a/frontend/src/pages/runs/useRunDetail.ts +++ b/frontend/src/pages/runs/useRunDetail.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ApiError, api, streamRun } from '../../api/client' import { useCatalog, useOnReconnect } from '../../store/catalog' +import { useInquiryRecorded } from '../../run/Inquiry' import { emptyTrace, finalizeTrace, foldEvent, type Trace } from '../../run/trace' import type { Approval, GraphSpec, Run, RunEvent } from '../../types' import { isLiveRun } from './model' @@ -202,6 +203,16 @@ export function useRunDetail(runId: string, onChange: (run: Run) => void): RunDe } }, [streaming, runId, flush, resync, refreshPending, refreshApprovals]) + // 这一页里问的审批质询写进了运行记录(approval.inquiry):停在审批上、已经结束的运行这里不接流,补拉一次事件, + // 时间线才出现「审批人质询」那一行。在接流的照常从流里收到 + const inquired = useInquiryRecorded(runId) + const inquiredSeen = useRef(inquired) + useEffect(() => { + if (inquiredSeen.current === inquired) return + inquiredSeen.current = inquired + if (!streaming) void reload() + }, [inquired, streaming, reload]) + const follow = useCallback((next?: Run | null) => { if (next) adopt(next) setStreaming(true) diff --git a/frontend/src/run/ApprovalReport.tsx b/frontend/src/run/ApprovalReport.tsx index c1abb21d..31852da4 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, 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, useInquiryAccess, type InquiryAccess } from './Inquiry' type RunClass = 'formal' | 'exploratory' | null | undefined @@ -53,21 +54,52 @@ function claimsLine(report: ApprovalReport, runClass: RunClass): { text: string; * * 真实运行里报告有一句结论和证据相矛盾,审批人批准时完全不知道:卡上只有正文 */ -export function ApprovalReports({ reports, runId, runClass }: { +export function ApprovalReports({ reports, runId, runClass, approvalId, askable = false }: { reports: ApprovalReport[] runId: string runClass: RunClass + /** 这张审批卡的审批(处理过的也给):问答串据此认哪几条问的就是卡上这一稿报告 */ + approvalId?: string + /** + * 审批待处理:审批人可以就报告提出质询——报告上的句子、划选的原文挂「质询」,核对概况下面是问答串和提问框 + * (run/Inquiry.tsx)。设置里关了审批质询时照样不给 + */ + askable?: boolean }) { + const { access, disabled } = useInquiryAccess(askable && !!approvalId, approvalId ?? null) 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, disabled, approvalId }: { + report: ApprovalReport; runId: string; runClass: RunClass; access: InquiryAccess | null; disabled: boolean + approvalId: string | 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 +137,25 @@ 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 +164,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..e086f702 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,19 @@ 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', 'table'].includes(block.type) interface Badge { unit: EvidenceUnit; block: number; verdict: EvidenceVerdict; state: EvidenceStateCode } @@ -64,6 +78,8 @@ interface Model { seg: Map /** 句末徽标:键是 claim:,带着这一句眼下的判定 */ badge: Map + /** 进了键盘顺序的「质询」(审批质询):键是 ask: */ + ask: Map /** 每个有可点片段的句子里第一个可点片段,按正文顺序(↑/↓ 用) */ unitFirst: string[] unitOf: Map @@ -82,11 +98,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 +145,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 +243,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 +306,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 +409,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,11 +497,40 @@ export const EvidenceDoc = forwardRef ) + // 审批质询:划选一段原文,浮出「质询」,问的是选中的这段(带上它起头的那一句) + const pickAnchor = useRef(null) + const [pick, dropPick] = useSelectionPick(rootRef, pickAnchor, 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 (
+ {ask && ( + // 划选原文浮出的「质询」挂在文档里(高度为 0 的定位锚点下面,按锚点算坐标),不挂到页面末尾:画布上的审批浮层 + // 按下在它外面就收起,挂在外面的按钮一按,浮层连同报告一起收掉。也因此会被报告框裁切,位置按可见范围摆(见 + // useSelectionPick) +
+ {pick && ( + // 按下时不抢焦点、不清掉选区:点下去才读选区 + + )} +
+ )}

{summary}

{announce}
{tally && (counts.total > 0 || counts.other > 0 || !!counts.suspect || !!claims?.total) && ( @@ -473,7 +554,7 @@ export const EvidenceDoc = forwardRef + panelId={panelId} model={model} ask={ask} /> {mode === 'inline' && b === activeBlock && panelNode} ))} @@ -498,6 +579,103 @@ export const EvidenceDoc = forwardRef, anchor: 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 origin = anchor.current + const sel = document.getSelection() + if (!el || !origin || !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 view = visibleBox(el) + // 选区整个滚出了可见范围:按钮也不留(滚回来再量出来) + if (last.bottom <= view.top || first.top >= view.bottom) { setPick(null); return } + const doc = el.getBoundingClientRect() + const lo = Math.max(doc.left, view.left) + const hi = Math.min(doc.right, view.right) - PICK_W + const x = Math.max(lo, Math.min(last.right - PICK_W / 2, hi)) + const fitsBelow = last.bottom + PICK_GAP + PICK_H <= view.bottom + const fitsAbove = first.top - PICK_GAP - PICK_H >= view.top + const y = fitsBelow || !fitsAbove ? Math.min(last.bottom + PICK_GAP, Math.max(view.top, view.bottom - PICK_H)) + : first.top - PICK_GAP - PICK_H + const o = origin.getBoundingClientRect() + const next: SelectionPick = { + quote, unit: start?.closest('[data-ev-unit]')?.getAttribute('data-ev-unit') ?? null, + left: Math.round(x - o.left), top: Math.round(y - o.top), + } + // 滚动时每一帧都会量一次:没变就不换对象,整份报告不跟着重画 + 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) + const resized = typeof ResizeObserver === 'function' ? new ResizeObserver(schedule) : null + if (root.current) resized?.observe(root.current) + return () => { + cancelAnimationFrame(frame) + document.removeEventListener('selectionchange', schedule) + window.removeEventListener('scroll', schedule, true) + window.removeEventListener('resize', schedule) + resized?.disconnect() + } + }, [root, anchor, enabled]) + return [pick, useCallback(() => setPick(null), [])] +} + /** 定位到的片段描一下边(CSS 的 [data-flash]),同 AssistantStream 的 flash */ function flashOnce(el: HTMLElement) { el.dataset.flash = 'focus' @@ -590,12 +768,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 +794,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 +865,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 +913,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 +948,29 @@ function UnitView({ unit, doc, current, active, panelId, code, tag, badge }: { )} ) + if (!ask) return body + // 「质询」跟在句末,是一个固定宽度的小图标:平时淡淡的,悬停这一句、焦点落在这一句上时显出来(强调色、描边)。 + // 显没显出来只换颜色,宽度一直是那么宽——悬停时撑开的话,后面的字整体右移,鼠标底下的数字跟着挪走,点不到。 + // 触屏没有悬停,一直显出来,并写上「质询」两个字。 + // 跟随的那种只在焦点在这一句上时进 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..90f4ff41 --- /dev/null +++ b/frontend/src/run/Inquiry.tsx @@ -0,0 +1,1410 @@ +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, formatTime, parseServerTime } from '../lib/format' +import { COST_TEXT, EVIDENCE_STATE_LABEL, EVIDENCE_TEXT, INQUIRY_TEXT, runCostText } from '../lib/terms' +import { useEvidence } from '../store/evidence' +import { useStudio } from '../store/studio' +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 为假)醒目地说,它只留在这一页里。 + * + * 同一份报告可能同时挂在两处(编排页里画布的审批浮层和右栏是同一张卡),也可能先后挂上几张审批(两级审批审同一份 + * 报告、驳回改写后再审):提问框只画在打开它的那一处(InquiryAccess.host),只认打开它时的那张审批;问答串按每条 + * 问的是哪一稿报告分开——不是眼前这一稿的不按句子编号往句子下面挂(编号是位置序号,改写之后对不上) + */ + +/** 问题、原文的长度上限(同服务端,按字数) */ +export const QUESTION_MAX = 1000 +export const QUOTE_MAX = 500 + +/** + * 页面传给报告文档、问答串的「能不能问」:给了就是能问。approvalId 是运行停在的那张审批(封存后的探索运行为 + * null);host 标明是哪一处(useInquiryAccess 生成),同一份报告同时挂在两处时提问框只画在打开它的那一处 + */ +export interface InquiryAccess { + approvalId: string | null + host: string +} + +/** 要问的是哪儿:一句(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' } +} + +// ---------------------------------------------------------------- 设置里开没开 + +/** + * 设置里的审批质询开没开(verify.inquiry.enabled)。关了就不给入口——写完问题才收到 409 inquiry_disabled 太晚了。 + * 取不到(老服务端、断网)按开着算:服务端关了照样会拒绝。各处共用一次请求,半分钟内不重取 + */ +let enabledValue: boolean | null = null +let enabledAt = 0 +let enabledPending: Promise | null = null +const ENABLED_TTL = 30_000 + +function fetchEnabled(): Promise { + if (enabledPending) return enabledPending + if (enabledAt && Date.now() - enabledAt < ENABLED_TTL) return Promise.resolve(enabledValue) + enabledPending = api.settings.get().then( + (s) => { + const part = s?.verify?.inquiry + enabledValue = !(part && typeof part === 'object' && part.enabled === false) + enabledAt = Date.now() + return enabledValue + }, + () => enabledValue, + ).finally(() => { enabledPending = null }) + return enabledPending +} + +function useInquiryEnabled(active: boolean): boolean | null { + const [value, setValue] = useState(enabledValue) + useEffect(() => { + if (!active) return + let alive = true + void fetchEnabled().then((v) => { if (alive) setValue(v) }) + return () => { alive = false } + }, [active]) + return value +} + +// ---------------------------------------------------------------- store + +interface Problem { message: string; code: string; field?: string } + +/** 问答串里的一条:服务端列出来的都写进了运行记录;刚问的照答复的 recorded 记 */ +export interface InquiryEntry { + /** React 的键:有事件序号用序号,没写进运行记录的另编一个 */ + id: string + inquiry: Inquiry + recorded: boolean + note: string | null +} + +/** 回答期间另开了提问框、正在回答的那一条却没问成:问题和原因挂在它那一句下面,不覆盖新开的提问框 */ +interface Failure { + target: InquiryTarget + question: string + error: Problem + approvalId: string | null + /** 在哪一处问的:只有那一处用 role=alert 报出来,另一处同样显示、不重复播报 */ + host: string +} + +interface Thread { + status: 'loading' | 'ok' | 'error' + /** 服务端列出来的 */ + listed: Inquiry[] + /** 这一页里问过的(列表重新取回来之后,写进运行记录的按序号去重,没写进的一直留在这里) */ + local: InquiryEntry[] + error?: Problem + failed?: Failure +} + +interface Draft { + target: InquiryTarget + /** + * 打开它时停在的那张审批(封存了的探索运行为 null)。提问框只在同一张审批的地方出现:换了审批(同一份报告的下一级 + * 审批、驳回改写后再审),这一份就不再显示,下次点「质询」从头写 + */ + approvalId: string | null + /** + * 打开它的那一处(InquiryAccess.host):同一份报告同时挂在两处时,提问框只画在这里,焦点也只进这里。那一处卸掉了 + * (浮层关了)为 null,由还挂着的、同一张审批的另一处接过去 + */ + host: string | null + question: string + error: Problem | null + /** 每次点「质询」都换一个(全局递增,关掉再开也不重复):提问框据此把焦点送进输入框,换了一句也一样 */ + nonce: number +} + +interface Pending { + key: string + target: InquiryTarget + question: string + startedAt: number + /** 在哪一处问的:焦点交接、「已收到回答」的播报只在这一处 */ + host: string +} + +/** 一次运行里每张审批审的是哪一稿报告:审批 id → { 报告节点: 文档工件 } */ +interface ApprovalDocs { + status: 'loading' | 'ok' | 'error' + map: Record> + /** 为哪几张审批取过:取回来列表里也没有的(审批记录没了)不再为它重取 */ + asked: string[] +} + +interface InquiryState { + threads: Record + /** 开着的提问框,按(运行、报告)各一个 */ + drafts: Record + /** 正在回答的,按运行:一次运行同一时刻只有一条 */ + pending: Record + /** 按运行:问答串据此分辨之前几轮审批里问的是不是眼前这一稿 */ + approvalDocs: Record + /** 这一页里写进了运行记录的问答各几条(按运行):运行详情据此补拉事件,时间线才出现「审批人质询」 */ + recorded: Record + load: (runId: string, report: string) => Promise + loadApprovalDocs: (runId: string, ids: string[]) => Promise + compose: (runId: string, report: string, target: InquiryTarget, access: InquiryAccess) => void + edit: (key: string, question: string) => void + close: (key: string) => void + /** 接过没有主人的提问框(打开它的那一处卸掉了) */ + adopt: (key: string, access: InquiryAccess) => void + /** 这一处卸掉了:它开着的提问框交出去 */ + release: (key: string, host: string) => void + /** 提问框在另一处开着,审批人要在这里接着写 */ + move: (key: string, access: InquiryAccess) => void + submit: (runId: string, report: string, access: InquiryAccess) => Promise + /** 没问成的那一条重新放进提问框 */ + reask: (runId: string, report: string, access: InquiryAccess) => void + dismissFailed: (key: string) => void +} + +export const inquiryKey = (runId: string, report: string) => `${runId}@${report}` + +/** 打开提问框的那个元素(句末的「质询」、划选浮出的按钮已经没了时退回整份报告那个):Esc、取消之后焦点回到这里 */ +const openers = new Map() +/** 挂着的问答串(`报告键|那一处`):没问成、提问框要重新打开时,打开它的那一处已经卸掉了就不认它 */ +const liveHosts = new Set() +/** 提问框已经把焦点送进去过的 nonce:别处接过提问框(浮层关了、右栏接着显示)不算点了「质询」,不抢焦点 */ +const focusedNonce = new Map() +let nonceSeq = 0 +let localSeq = 0 + +const emptyThread = (): Thread => ({ status: 'ok', listed: [], local: [] }) + +export const useInquiry = create((set, get) => ({ + threads: {}, + drafts: {}, + pending: {}, + approvalDocs: {}, + recorded: {}, + + 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]: { ...cur, 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), + } } })) + } + }, + + loadApprovalDocs: async (runId, ids) => { + if (get().approvalDocs[runId]?.status === 'loading') return + set((s) => { + const cur = s.approvalDocs[runId] + return { approvalDocs: { ...s.approvalDocs, [runId]: { + status: 'loading', map: cur?.map ?? {}, asked: [...new Set([...(cur?.asked ?? []), ...ids])], + } } } + }) + try { + const list = await api.approvals.list({ run_id: runId, status: 'all', limit: 500 }) + const map: Record> = {} + for (const a of Array.isArray(list) ? list : []) { + if (!a || typeof a.id !== 'string' || a.run_id !== runId) continue + const docs: Record = {} + for (const r of Array.isArray(a.payload?.reports) ? a.payload.reports : []) { + if (r && typeof r.node_id === 'string' && typeof r.doc_artifact === 'string' && r.doc_artifact) docs[r.node_id] = r.doc_artifact + } + map[a.id] = docs + } + set((s) => ({ approvalDocs: { ...s.approvalDocs, [runId]: { ...s.approvalDocs[runId]!, status: 'ok', map } } })) + } catch { + set((s) => ({ approvalDocs: { ...s.approvalDocs, [runId]: { ...s.approvalDocs[runId]!, status: 'error' } } })) + } + }, + + compose: (runId, report, target, access) => { + const key = inquiryKey(runId, report) + set((s) => { + const cur = s.drafts[key] + // 换了一句:问题留着(多半是同一个问题挪个地方问),上一次的报错不留。换了审批的从头写:那一份是就上一张审批问的 + const question = cur && cur.approvalId === access.approvalId ? cur.question : '' + return { drafts: { ...s.drafts, [key]: { + target, approvalId: access.approvalId, host: access.host, question, error: null, nonce: ++nonceSeq, + } } } + }) + }, + + 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 } })), + + adopt: (key, access) => set((s) => { + const d = s.drafts[key] + return d && d.approvalId === access.approvalId && d.host == null + ? { drafts: { ...s.drafts, [key]: { ...d, host: access.host } } } : s + }), + + release: (key, host) => set((s) => { + const d = s.drafts[key] + return d?.host === host ? { drafts: { ...s.drafts, [key]: { ...d, host: null } } } : s + }), + + move: (key, access) => set((s) => { + const d = s.drafts[key] + return d && d.approvalId === access.approvalId + ? { drafts: { ...s.drafts, [key]: { ...d, host: access.host, nonce: ++nonceSeq } } } : s + }), + + submit: async (runId, report, access) => { + 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, + ...(access.approvalId ? { approval_id: access.approvalId } : {}), + } + set((s) => ({ + drafts: { ...s.drafts, [key]: undefined }, + pending: { ...s.pending, [runId]: { key, target: draft.target, question, startedAt: Date.now(), host: access.host } }, + })) + try { + const res = await api.inquiry.ask(runId, body) + const got = asInquiry(res?.inquiry) ?? asInquiry({ ...body, answer: {} })! + // 服务端没回 approval_id 的照请求补上:问答串按它分辨这一条问的是不是眼前这一稿报告 + const inquiry: Inquiry = { ...got, report: got.report || report, approval_id: got.approval_id ?? body.approval_id ?? null } + const entry: InquiryEntry = { + id: inquiry.seq != null ? `s${inquiry.seq}` : `l${++localSeq}`, inquiry, + recorded: res?.recorded !== false, note: str(res?.note) || null, + } + set((s) => { + const cur = s.threads[key] ?? emptyThread() + return { + threads: { ...s.threads, [key]: { ...cur, local: [...cur.local, entry] } }, + pending: { ...s.pending, [runId]: undefined }, + ...(entry.recorded ? { recorded: { ...s.recorded, [runId]: (s.recorded[runId] ?? 0) + 1 } } : {}), + } + }) + if (entry.recorded) followRun(runId) + } catch (e) { + const error = problemOf(e) + set((s) => { + const pending = { ...s.pending, [runId]: undefined } + // 回答期间另开了提问框(换了一句、写着下一个问题):那一份原样留着,没问成的这一条连同原因挂在它那一句下面 + if (s.drafts[key]) { + const cur = s.threads[key] ?? emptyThread() + return { pending, threads: { ...s.threads, [key]: { ...cur, failed: { + target: draft.target, question: draft.question, error, approvalId: access.approvalId, host: access.host, + } } } } + } + // 没另开:提问框带着原来的问题重新打开,照服务端的说明显示原因。问的那一处已经卸掉了(浮层关了)就交出去, + // 由还挂着的、同一张审批的那一处接过去 + return { pending, drafts: { ...s.drafts, [key]: { + ...draft, approvalId: access.approvalId, host: liveHosts.has(`${key}|${access.host}`) ? access.host : null, + error, nonce: ++nonceSeq, + } } } + }) + } + }, + + reask: (runId, report, access) => set((s) => { + const key = inquiryKey(runId, report) + const f = s.threads[key]?.failed + if (!f) return s + return { + drafts: { ...s.drafts, [key]: { + target: f.target, approvalId: access.approvalId, host: access.host, question: f.question, error: null, nonce: ++nonceSeq, + } }, + threads: { ...s.threads, [key]: { ...s.threads[key]!, failed: undefined } }, + } + }), + + dismissFailed: (key) => set((s) => (s.threads[key]?.failed + ? { threads: { ...s.threads, [key]: { ...s.threads[key]!, failed: undefined } } } : s)), +})) + +/** + * 写进了运行记录的问答(approval.inquiry):画布正显示这次运行、却没在接流(停在审批上、已经结束时,流在补完历史 + * 之后就收了尾)时,按手上已有的最大序号补接一次,时间线才出现「审批人质询」这一行。运行详情读 useInquiryRecorded + */ +function followRun(runId: string) { + const studio = useStudio.getState() + if (studio.run?.id === runId && !studio.streaming) void studio.attachRun(runId).catch(() => undefined) +} + +/** 这一页里有几条问答写进了这次运行的记录:变了就补拉事件(运行详情的时间线) */ +export const useInquiryRecorded = (runId: string): number => useInquiry((s) => s.recorded[runId] ?? 0) + +/** + * 句末的「质询」、划选浮出的「质询」点下去:打开这份报告的提问框。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) +} + +/** 这次运行是不是有一条质询正在回答:审批卡据此暂停批准 / 驳回(服务端这时也回 409) */ +export const useInquiryBusy = (runId: string | null | undefined): boolean => + useInquiry((s) => !!runId && !!s.pending[runId]) + +/** + * 页面给报告文档和问答串的「能不能问」。open 是按运行状态能不能问(审批卡:审批待处理;运行详情:封存了的探索运行), + * 设置里关了审批质询时 access 为 null、disabled 为真。host 每一处各一个:同一张审批卡同时挂在两处时靠它分开 + */ +export function useInquiryAccess(open: boolean, approvalId: string | null): { access: InquiryAccess | null; disabled: boolean } { + const host = useId() + const enabled = useInquiryEnabled(open) + const access = useMemo(() => (open && enabled !== false ? { approvalId, host } : null), + [open, enabled, approvalId, host]) + return { access, disabled: open && enabled === false } +} + +/** 问答串:服务端列出来的,加上这一页里问过、列表里还没有的(没写进运行记录的一直只在这里),按提问时间排 */ +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) => parseServerTime(e.inquiry.at)?.getTime() ?? 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' + +/** 问的不是眼前这一稿报告的那几条:单独一组,排在最后 */ +const OTHER = 'other' + +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, cardId = null, disabled = false, onLocate, postSeal = false }: { + runId: string + report: string + /** 报告文档的工件 id:问答串的句子取原文 */ + docArtifact?: string | null + access: InquiryAccess | null + /** + * 页面上这一稿报告是哪张审批审的(审批卡:这张卡的审批 id,处理过的卡也给)。运行详情不给:显示的是封存的那一稿, + * 封存之后问的(approval_id 为空)就是问它。别的审批里问的,按那张审批审的文档对上了才挂到句子下面 + */ + cardId?: string | null + /** 设置里关了审批质询:不给入口,已有的问答照列,写明关了 */ + disabled?: boolean + onLocate?: (seg: EvidenceSegment, doc: EvidenceDocData) => boolean + /** 运行已封存(运行详情):提问框写明问答追加在封存之后 */ + postSeal?: boolean +}) { + const key = inquiryKey(runId, report) + const host = access?.host ?? null + const thread = useInquiry((s) => s.threads[key]) + const draftAny = useInquiry((s) => s.drafts[key]) + const pending = useInquiry((s) => s.pending[runId]) + const load = useInquiry((s) => s.load) + const adopt = useInquiry((s) => s.adopt) + const release = useInquiry((s) => s.release) + const move = useInquiry((s) => s.move) + const reask = useInquiry((s) => s.reask) + const dismissFailed = useInquiry((s) => s.dismissFailed) + useEffect(() => { void load(runId, report) }, [runId, report, load]) + const entries = useMemo(() => entriesOf(thread), [thread]) + const mine = pending?.key === key ? pending : undefined + + // 提问框只认打开它时的那张审批:换了审批(上一张批掉了、驳回改写后再审)就不在这里出现;同一张审批的,只画在打开它 + // 的那一处,另一处写一句、给个「在这里继续」 + const draft = access && draftAny && draftAny.approvalId === access.approvalId ? draftAny : undefined + const ownDraft = draft && draft.host === host ? draft : undefined + const otherDraft = draft && draft.host && draft.host !== host ? draft : undefined + useEffect(() => { + if (!host) return + liveHosts.add(`${key}|${host}`) + return () => { + liveHosts.delete(`${key}|${host}`) + release(key, host) + } + }, [key, host, release]) + useEffect(() => { if (access && draft && draft.host == null) adopt(key, access) }, [access, draft, key, adopt]) + const failed = access && thread?.failed && thread.failed.approvalId === access.approvalId ? thread.failed : undefined + + // 句子的原文从报告文档取(审批卡上逐段证据没展开时也要取:问答串得写出问的是哪一句;回答引用了报告已有的出处时, + // 也要靠它在报告里找到那一处)。文档按工件 id 缓存,同一份只取一次 + const docSlot = useEvidence((s) => (docArtifact ? s.docs[docArtifact] : undefined)) + const loadDoc = useEvidence((s) => s.loadDoc) + const needDoc = !!docArtifact && (entries.length > 0 || !!draft || !!mine || !!failed) + 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 docsSlot = useInquiry((s) => s.approvalDocs[runId]) + const loadApprovalDocs = useInquiry((s) => s.loadApprovalDocs) + const foreign = useMemo(() => [...new Set(entries.map((e) => e.inquiry.approval_id) + .filter((a): a is string => !!a && a !== cardId))].sort(), [entries, cardId]) + // 还没为它取过审批列表的(列表里取回来也没有的不再重取:审批记录没了,再取也一样) + const unknown = foreign.filter((a) => !(docsSlot && (a in docsSlot.map || docsSlot.asked.includes(a)))).join(',') + useEffect(() => { if (unknown && docArtifact) void loadApprovalDocs(runId, unknown.split(',')) }, [unknown, docArtifact, runId, loadApprovalDocs]) + // 对不上之前先不画别的审批里问的那几条,免得先挂在一处、取回来再挪 + const settled = !foreign.length || !docArtifact || (!unknown && docsSlot?.status !== 'loading') + const sameDoc = useCallback((x: Inquiry) => { + const a = x.approval_id ?? null + if (a === (cardId ?? null)) return true + return !!a && !!docArtifact && docsSlot?.map[a]?.[report] === docArtifact + }, [cardId, docArtifact, docsSlot, report]) + + 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)! + } + const others: InquiryEntry[] = [] + for (const e of entries) { + if (sameDoc(e.inquiry)) add(e.inquiry.unit, e.inquiry.quote).entries.push(e) + else if (settled) others.push(e) + } + // 正在回答的、没问成的、开着的提问框挂在它们问的那一句下面(按先后):那句还没有问答时另起一组 + if (mine) add(mine.target.unit, mine.target.quote) + if (failed) add(failed.target.unit, failed.target.quote) + if (draft) add(draft.target.unit, draft.target.quote) + const list = [...out.values()] + if (others.length) list.push({ key: OTHER, unit: null, quote: null, entries: others }) + return list + }, [entries, sameDoc, settled, mine, draft, failed]) + + // 提交之后提问框拿掉了,焦点接到「正在回答」那一条上;回答回来那一条换掉了,再接到回答上。焦点已经去了别处的不抢; + // 没问成(提问框带着报错重新打开)时焦点由提问框自己接。都只在问的那一处:同一张卡挂在两处时另一处不动 + const pendingRef = useRef(null) + const lastRef = useRef(null) + const count = entries.length + const [heard, setHeard] = useState('') + const asking = useRef<{ count: number; held: boolean; ours: boolean; question: string } | null>(null) + useLayoutEffect(() => { + if (mine) { + if (!asking.current) { + const ours = !!host && mine.host === host + const held = ours && focusLost() + if (held) pendingRef.current?.focus({ preventScroll: true }) + asking.current = { count, held, ours, question: mine.question } + } + return + } + const was = asking.current + asking.current = null + if (!was?.ours || count <= was.count) return + // 先清空再写:连着两次回答说的是同一句时,读屏也得再报一遍 + setHeard('') + requestAnimationFrame(() => setHeard(INQUIRY_TEXT.arrived(was.question))) + if (was.held && focusLost()) lastRef.current?.focus({ preventScroll: true }) + }, [mine, count, host]) + + const reportBtn = useRef(null) + if (!access && !entries.length && thread?.status !== 'error') return null + const latest = entries[entries.length - 1]?.id + // 列表还没取回来时不说「还没有问答、可以这样问」:取回来有问答,这一句又得撤掉 + const fresh = !count && !mine && !draft && !failed && thread?.status === 'ok' + + return ( +
+
+ + {INQUIRY_TEXT.title} + {count > 0 && · {INQUIRY_TEXT.headCount(count)}} + + + {access ? ( + + ) : disabled && ( + {INQUIRY_TEXT.disabled} + )} +
+ {access && fresh && ( +

+ {!docArtifact ? INQUIRY_TEXT.introNoDoc : 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} /> + ))} + {g.key !== OTHER && ( + <> + {mine && groupKey(mine.target) === g.key && ( + { pendingRef.current = el }} /> + )} + {failed && groupKey(failed.target) === g.key && ( + { openers.delete(key); reask(runId, report, access!) }} + onDismiss={() => dismissFailed(key)} /> + )} + {ownDraft && access && groupKey(ownDraft.target) === g.key && ( + + )} + {otherDraft && access && groupKey(otherDraft.target) === g.key && ( +

+ {INQUIRY_TEXT.elsewhere} + +

+ )} + + )} +
+
+ ))} +
+ )} +
+ ) +} + +/** + * 一组的抬头:问的是哪一句(原文从报告文档取,取不到写「报告中的一句」)、哪段原文,或者整份报告。问的不是眼前这一稿的 + * 那一组只写明这一点:句子编号对不上,不去取原文 + */ +function GroupHead({ group, units }: { group: Group; units: Map }) { + if (group.key === OTHER) { + return ( +
+ {INQUIRY_TEXT.otherDoc} + {INQUIRY_TEXT.otherDocHint} +
+ ) + } + 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} +} + +/** 回答的花费:调用过模型才写;模型不在价格目录里的估不出金额,不写「$0」(同运行的金额,见 runCostText) */ +function costOf(usage: Inquiry['usage']): { text: string; title?: string } | null { + const calls = typeof usage?.calls === 'number' ? usage.calls : 0 + const unpriced = typeof usage?.unpriced_calls === 'number' ? usage.unpriced_calls : 0 + const usd = typeof usage?.cost_usd === 'number' ? usage.cost_usd : null + // 一次模型调用都没有(没配好模型、当日金额已到上限)就不写花费,写「$0」像是调用过 + if (!calls && !unpriced && !(usd != null && usd > 0)) return null + const text = runCostText(usd, unpriced) + if (!text) return null + return { text: text === COST_TEXT.unpriced ? text : INQUIRY_TEXT.cost(text), title: unpriced > 0 ? COST_TEXT.unpricedTitle(unpriced) : undefined } +} + +/** 一问一答 */ +function EntryView({ runId, entry, groupQuote, otherDoc, onLocate, focusRef }: { + runId: string + entry: InquiryEntry + /** 组抬头已经写着这段原文:问题下面不再写一遍 */ + groupQuote: string | null + /** 问的不是眼前这一稿报告:回答引用报告已有的出处时,不在眼前这一稿里找 */ + otherDoc: boolean + 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 + const cost = costOf(inquiry.usage) + 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 && ( +

+ {otherDoc ? INQUIRY_TEXT.reportSourceOther(elsewhere) : 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 && · {cost.text}} +

+ )} +
+ ) +} + +/** + * 正在回答的那一条:问题、转圈和等了多久。回答期间审批卡的批准 / 驳回暂停(审批卡读 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 && } +

+
+ ) +} + +/** + * 回答期间另开了提问框、正在回答的那一条却没问成:问题和原因照原样留着(提问框里新写的不动)。提问框关着时可以把它 + * 放回提问框重问 + */ +function FailedView({ failed, alert, canReask, onReask, onDismiss }: { + failed: Failure; alert: boolean; canReask: boolean; onReask: () => void; onDismiss: () => void +}) { + return ( +
+
+ {INQUIRY_TEXT.q} +

+ {failed.question} +

+
+

+ {INQUIRY_TEXT.askFailed(failed.error.message)} +

+
+ {canReask && } + +
+
+ ) +} + +/** + * 提问框:问的是哪一句(或哪段原文、整份报告)、问题、提交。Esc 关掉、焦点回到点开它的那个「质询」;Ctrl / ⌘ + 回车提交。 + * 字数超了、空着不让提交;这次运行另有一条在回答时也不让(服务端同样会拒绝) + */ +function Composer({ runId, report, access, draft, busy, postSeal, fallback }: { + runId: string + report: string + access: InquiryAccess + 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(() => { + if (focusedNonce.get(key) === draft.nonce) return + focusedNonce.set(key, draft.nonce) + area.current?.focus({ preventScroll: true }) + form.current?.scrollIntoView({ block: 'nearest' }) + }, [key, 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(() => { + const el = back?.isConnected ? back : fallback.current + if (!el) return + // 焦点回去的地方可能已经滚出视野(提问框滚进来时把它挤上去了,或者在限高的报告框里):滚回来,键盘用户看得见 + el.focus({ preventScroll: true }) + el.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + }) + } + const send = () => { if (!empty && !over && !busy) void submit(runId, report, access) } + 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() + send() + } + } + const problem = draft.error + return ( +
{ e.preventDefault(); send() }}> +
{INQUIRY_TEXT.target[target]}
+ {shown && ( +
+ {shown} +
+ )} + {cut &&

{INQUIRY_TEXT.quoteCut(QUOTE_MAX)}

} + +