From b58321f6b8dfc9d15ed8d9587ca4b7037bbf4411 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 14:09:45 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9C=9F=203=20=E5=8F=8D=E6=96=B9=E5=AE=A1?= =?UTF-8?q?=E7=A8=BF=EF=BC=88=E5=89=8D=E7=AB=AF=EF=BC=89=EF=BC=9A=E5=85=88?= =?UTF-8?q?=E5=86=99=E6=A3=80=E6=9F=A5=E2=80=94=E2=80=94=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E3=80=81=E9=A2=84=E8=A7=88=E7=94=A8=E4=BE=8B=E4=B8=8E=20check-?= =?UTF-8?q?challenge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types.ts 末尾加反方审稿的类型(约定里的审批概况 payload.reports[i].challenge、challenge_artifact 工件), ApprovalReport 用声明合并补上 challenge 一项,不动前面的定义 - 预览用例 dev/fixtures/challenge.tsx:审批卡七种情形(正式运行默认降档、只标注、探索运行、没有反方审稿、 超过十句、工件取不到、360px 右栏)和检查器(两个上游报告、一个下游报告的虚构图,旁边是节点卡片和实时配置) - 夹具 challenge.json 与检查脚本共用;理由文档是后端 run_verify_agent + compose_doc 真跑出来的,库、表名、 地名、数字都是虚构的 - check-challenge.mjs:概况计数、被推翻时的降档提示(只标注、探索运行不写)、逐句标签、点开后取工件并显示 理由(数字的出处、裸数字)与查询、工件取不到可重试、超过十句显示全部、检查器字段写进节点配置、卡片摘要、 390px 不横向溢出、深浅色对比度;登记进 check-all 此时还没有实现,检查全部不通过(7 段都超时),下两个提交补上 --- frontend/src/dev/fixtures/challenge.json | 1074 ++++++++++++++++++++++ frontend/src/dev/fixtures/challenge.tsx | 160 ++++ frontend/src/types.ts | 72 ++ scripts/check-all.mjs | 2 + scripts/check-challenge.mjs | 497 ++++++++++ 5 files changed, 1805 insertions(+) create mode 100644 frontend/src/dev/fixtures/challenge.json create mode 100644 frontend/src/dev/fixtures/challenge.tsx create mode 100644 scripts/check-challenge.mjs diff --git a/frontend/src/dev/fixtures/challenge.json b/frontend/src/dev/fixtures/challenge.json new file mode 100644 index 00000000..09159425 --- /dev/null +++ b/frontend/src/dev/fixtures/challenge.json @@ -0,0 +1,1074 @@ +{ + "//": "反方审稿预览用例与 check-challenge 共用的夹具。理由文档由后端 run_verify_agent + compose_doc 真跑出来(虚构的 SQLite 库),表名、地名、数字都是虚构的", + "runs": { + "formal": "run-chal-formal", + "flag": "run-chal-flag", + "explore": "run-chal-explore", + "none": "run-chal-none", + "many": "run-chal-many", + "broken": "run-chal-broken" + }, + "report": { + "node_id": "write", + "label": "写月报", + "claims": "off", + "stats": { + "numbers": 6, + "numbers_cited": 6, + "uncited_numbers": 0, + "unresolved": 0 + } + }, + "challenge": { + "counts": { + "stands": 1, + "weakened": 1, + "refuted": 1, + "inconclusive": 1 + }, + "items": [ + { + "unit": "u3", + "text": "9 月销售额环比增长 12.4%,增长主要来自新客。", + "verdict": "refuted" + }, + { + "unit": "u5", + "text": "青岚区贡献了一半以上的增长。", + "verdict": "weakened" + }, + { + "unit": "u8", + "text": "9 月退款率保持在 2% 以下。", + "verdict": "stands" + }, + { + "unit": "u11", + "text": "线上渠道的客单价高于门店。", + "verdict": "inconclusive" + } + ], + "artifact": "43381f302615492cab9a2d28acfbc251afb2c2a6a4f35f6e8b6543827b5f7845" + }, + "many": { + "counts": { + "stands": 7, + "weakened": 2, + "refuted": 2, + "inconclusive": 1 + }, + "items": [ + { + "unit": "u20", + "text": "白汀区 9 月订单量高于 8 月。", + "verdict": "stands" + }, + { + "unit": "u21", + "text": "云屿区仍是销售额最高的区。", + "verdict": "stands" + }, + { + "unit": "u22", + "text": "老客复购率明显上升。", + "verdict": "weakened" + }, + { + "unit": "u23", + "text": "退款集中在云屿区。", + "verdict": "stands" + }, + { + "unit": "u24", + "text": "青岚区新客数量翻了一番。", + "verdict": "refuted" + }, + { + "unit": "u25", + "text": "三个区的销售额都在增长。", + "verdict": "stands" + }, + { + "unit": "u26", + "text": "周末订单占比有所提高。", + "verdict": "inconclusive" + }, + { + "unit": "u27", + "text": "大额订单数量与上月持平。", + "verdict": "stands" + }, + { + "unit": "u28", + "text": "客单价的提升主要来自青岚区。", + "verdict": "weakened" + }, + { + "unit": "u29", + "text": "9 月没有出现单日销售额为零的门店。", + "verdict": "stands" + } + ], + "artifact": "dad9224ddaed549de56eac6e96902cf448291e8aaabb1699d09f25b87fc3541d", + "on_refuted": "degrade" + }, + "doc_u8": "8da1c00e7a24b0eabe6f33efc13ec5d2e6578fddd8aaf67de5fb9d303cb12b90", + "artifacts": { + "43381f302615492cab9a2d28acfbc251afb2c2a6a4f35f6e8b6543827b5f7845": { + "report": "write", + "doc_artifact": "1b03b7ae4cdda13ae128a29e85ac985a3b1d66041318f4a53166c970e99fdf81", + "claims": [ + { + "unit": "u3", + "text": "9 月销售额环比增长 12.4%,增长主要来自新客。", + "verdict": "refuted", + "rationale": "按客户类型拆分:9 月新客销售额 151,000 元,低于 8 月的 182,000 元;老客从 962,000 元增至 1,135,000 元。增长来自老客,新客反而下降。", + "rationale_doc": { + "schema": "agentlab.report/1", + "entity_syntax": 2, + "provenance": 1, + "run_id": "run-chal-formal", + "node_id": "critic", + "markdown": "按客户类型拆分:9 月新客销售额 151,000 元,低于 8 月的 182,000 元;老客从 962,000 元增至 1,135,000 元。增长来自老客,新客反而下降。", + "source": "按客户类型拆分:9 月新客销售额 [[v:Q1.r0.amount]] 元,低于 8 月的 [[v:Q2.r0.amount]] 元;老客从 [[v:Q2.r1.amount]] 元增至 [[v:Q1.r1.amount]] 元。增长来自老客,新客反而下降。", + "catalog": { + "Q1": { + "alias": "Q1", + "kind": "query", + "eid": "ev:query:1db67c3eb22cd18c", + "locator": {}, + "node_id": "critic", + "exec": 1, + "artifact": "4a1da399f511fabb37b962da2749e34406f67c56014550a7630f5bb4d98fc163", + "call_id": "u3-c0", + "tool": "db_query__lanshan_retail", + "source": "lanshan_retail", + "columns": [ + "customer_type", + "amount" + ], + "rows": 2, + "truncated": false, + "label": "db_query__lanshan_retail · 2 行" + }, + "Q2": { + "alias": "Q2", + "kind": "query", + "eid": "ev:query:79f93ddf166ebee9", + "locator": {}, + "node_id": "critic", + "exec": 1, + "artifact": "b13138a32f067d0d5b330fe15955029a4ed953c64ccc9636dfa9f443f67f7a0d", + "call_id": "u3-c1", + "tool": "db_query__lanshan_retail", + "source": "lanshan_retail", + "columns": [ + "customer_type", + "amount" + ], + "rows": 2, + "truncated": false, + "label": "db_query__lanshan_retail · 2 行" + } + }, + "blocks": [ + { + "type": "paragraph", + "units": [ + { + "id": "u0", + "kind": "claim", + "span": [ + 0, + 73 + ], + "cites": [ + "Q1", + "Q2" + ], + "see": [], + "segments": [ + { + "kind": "text", + "text": "按客户类型拆分:9 月新客销售额 ", + "state": "neutral", + "span": [ + 0, + 17 + ], + "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:0a0f13bc23a46da7", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 151000.0, + "rendered": "151,000" + }, + "state": "deterministic", + "span": [ + 17, + 24 + ], + "id": "s1" + }, + { + "kind": "text", + "text": " 元,低于 8 月的 ", + "state": "neutral", + "span": [ + 24, + 35 + ], + "id": "s2" + }, + { + "kind": "number", + "text": "182,000", + "ref": "v:Q2.r0.amount", + "cite": { + "ref": "Q2.r0.amount", + "alias": "Q2", + "locator": { + "row": 0, + "column": "amount" + }, + "eid": "ev:cell:ba5847ac3ea0a7a4", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 182000.0, + "rendered": "182,000" + }, + "state": "deterministic", + "span": [ + 35, + 42 + ], + "id": "s3" + }, + { + "kind": "text", + "text": " 元;老客从 ", + "state": "neutral", + "span": [ + 42, + 49 + ], + "id": "s4" + }, + { + "kind": "number", + "text": "962,000", + "ref": "v:Q2.r1.amount", + "cite": { + "ref": "Q2.r1.amount", + "alias": "Q2", + "locator": { + "row": 1, + "column": "amount" + }, + "eid": "ev:cell:d71b3885da80963a", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 962000.0, + "rendered": "962,000" + }, + "state": "deterministic", + "span": [ + 49, + 56 + ], + "id": "s5" + }, + { + "kind": "text", + "text": " 元增至 ", + "state": "neutral", + "span": [ + 56, + 61 + ], + "id": "s6" + }, + { + "kind": "number", + "text": "1,135,000", + "ref": "v:Q1.r1.amount", + "cite": { + "ref": "Q1.r1.amount", + "alias": "Q1", + "locator": { + "row": 1, + "column": "amount" + }, + "eid": "ev:cell:dc6888990b89154a", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 1135000.0, + "rendered": "1,135,000" + }, + "state": "deterministic", + "span": [ + 61, + 70 + ], + "id": "s7" + }, + { + "kind": "text", + "text": " 元。", + "state": "neutral", + "span": [ + 70, + 73 + ], + "id": "s8" + } + ] + }, + { + "id": "u1", + "kind": "claim", + "span": [ + 73, + 87 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "text", + "text": "增长来自老客,新客反而下降。", + "state": "neutral", + "span": [ + 73, + 87 + ], + "id": "s9" + } + ] + } + ], + "id": "b0" + } + ], + "stats": { + "units": 2, + "segments": 10, + "claims": 2, + "connective": 0, + "headings": 0, + "numbers": 4, + "numbers_cited": 4, + "values": 0, + "entities": 0, + "quotes": 0, + "uncited_numbers": 0, + "unresolved": 0, + "unknown_entities": 0, + "unverified_entities": 0, + "see": 0, + "uncited_claims": 1, + "violations": 0 + }, + "violations": [] + }, + "queries": [ + { + "alias": "Q1", + "artifact": "4a1da399f511fabb37b962da2749e34406f67c56014550a7630f5bb4d98fc163", + "sql": "SELECT customer_type, SUM(amount) AS amount FROM orders WHERE is_refund = 0 AND month = '2026-09' GROUP BY customer_type ORDER BY customer_type", + "row_count": 2, + "truncated": false, + "source": "lanshan_retail" + }, + { + "alias": "Q2", + "artifact": "b13138a32f067d0d5b330fe15955029a4ed953c64ccc9636dfa9f443f67f7a0d", + "sql": "SELECT customer_type, SUM(amount) AS amount FROM orders WHERE is_refund = 0 AND month = '2026-08' GROUP BY customer_type ORDER BY customer_type", + "row_count": 2, + "truncated": false, + "source": "lanshan_retail" + } + ], + "usage": { + "input_tokens": 581, + "output_tokens": 3, + "total_tokens": 584, + "cost_usd": 0.0, + "calls": 4 + } + }, + { + "unit": "u5", + "text": "青岚区贡献了一半以上的增长。", + "verdict": "weakened", + "rationale": "青岚区增量 72,000 元,是三个区中最多的;但白汀区、云屿区合计增量也有 70000 元,青岚区的占比不到一半。", + "rationale_doc": { + "schema": "agentlab.report/1", + "entity_syntax": 2, + "provenance": 1, + "run_id": "run-chal-formal", + "node_id": "critic", + "markdown": "青岚区增量 72,000 元,是三个区中最多的;但白汀区、云屿区合计增量也有 70000 元,青岚区的占比不到一半。", + "source": "青岚区增量 [[v:Q1.r0.delta]] 元,是三个区中最多的;但白汀区、云屿区合计增量也有 70000 元,青岚区的占比不到一半。", + "catalog": { + "Q1": { + "alias": "Q1", + "kind": "query", + "eid": "ev:query:b1668f44771fbb87", + "locator": {}, + "node_id": "critic", + "exec": 1, + "artifact": "5aae93c426f3710e06d98f0ada1afcc84387a422cf5739802ae6f6ce9069d4bb", + "call_id": "u5-c0", + "tool": "db_query__lanshan_retail", + "source": "lanshan_retail", + "columns": [ + "district", + "delta" + ], + "rows": 3, + "truncated": false, + "label": "db_query__lanshan_retail · 3 行" + } + }, + "blocks": [ + { + "type": "paragraph", + "units": [ + { + "id": "u0", + "kind": "claim", + "span": [ + 0, + 58 + ], + "cites": [ + "Q1" + ], + "see": [], + "segments": [ + { + "kind": "text", + "text": "青岚区增量 ", + "state": "neutral", + "span": [ + 0, + 6 + ], + "id": "s0" + }, + { + "kind": "number", + "text": "72,000", + "ref": "v:Q1.r0.delta", + "cite": { + "ref": "Q1.r0.delta", + "alias": "Q1", + "locator": { + "row": 0, + "column": "delta" + }, + "eid": "ev:cell:e6298c09825e45b9", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 72000.0, + "rendered": "72,000" + }, + "state": "deterministic", + "span": [ + 6, + 12 + ], + "id": "s1" + }, + { + "kind": "text", + "text": " 元,是", + "span": [ + 12, + 16 + ], + "state": "neutral", + "id": "s2" + }, + { + "kind": "number", + "text": "三", + "span": [ + 16, + 17 + ], + "state": "none", + "issue": "uncited_number", + "id": "s3" + }, + { + "kind": "text", + "text": "个区中最多的;但白汀区、云屿区合计增量也有 ", + "span": [ + 17, + 39 + ], + "state": "neutral", + "id": "s4" + }, + { + "kind": "number", + "text": "70000", + "span": [ + 39, + 44 + ], + "state": "none", + "issue": "uncited_number", + "id": "s5" + }, + { + "kind": "text", + "text": " 元,青岚区的占比不到", + "span": [ + 44, + 55 + ], + "state": "neutral", + "id": "s6" + }, + { + "kind": "number", + "text": "一半", + "span": [ + 55, + 57 + ], + "state": "none", + "issue": "uncited_number", + "id": "s7" + }, + { + "kind": "text", + "text": "。", + "span": [ + 57, + 58 + ], + "state": "neutral", + "id": "s8" + } + ] + } + ], + "id": "b0" + } + ], + "stats": { + "units": 1, + "segments": 9, + "claims": 1, + "connective": 0, + "headings": 0, + "numbers": 4, + "numbers_cited": 1, + "values": 0, + "entities": 0, + "quotes": 0, + "uncited_numbers": 3, + "unresolved": 0, + "unknown_entities": 0, + "unverified_entities": 0, + "see": 0, + "uncited_claims": 0, + "violations": 3 + }, + "violations": [ + { + "code": "uncited_number", + "message": "数字「三」没有出处:报告中直接写出了数字,系统无法核对", + "for_model": "数字「三」没有出处:要写成引用标记(比如 [[m:指标id]]),不能直接写数字", + "span": [ + 16, + 17 + ], + "text": "三", + "context": "青岚区增量 72,000 元,是三个区中最多的;但白汀区、云屿区合计增", + "segment": "s3", + "unit": "u0" + }, + { + "code": "uncited_number", + "message": "数字「70000」没有出处:报告中直接写出了数字,系统无法核对", + "for_model": "数字「70000」没有出处:要写成引用标记(比如 [[m:指标id]]),不能直接写数字", + "span": [ + 39, + 44 + ], + "text": "70000", + "context": "多的;但白汀区、云屿区合计增量也有 70000 元,青岚区的占比不到一半。", + "segment": "s5", + "unit": "u0" + }, + { + "code": "uncited_number", + "message": "数字「一半」没有出处:报告中直接写出了数字,系统无法核对", + "for_model": "数字「一半」没有出处:要写成引用标记(比如 [[m:指标id]]),不能直接写数字", + "span": [ + 55, + 57 + ], + "text": "一半", + "context": "有 70000 元,青岚区的占比不到一半。", + "segment": "s7", + "unit": "u0" + } + ] + }, + "queries": [ + { + "alias": "Q1", + "artifact": "5aae93c426f3710e06d98f0ada1afcc84387a422cf5739802ae6f6ce9069d4bb", + "sql": "SELECT district, SUM(CASE WHEN month = '2026-09' THEN amount ELSE 0 END) - SUM(CASE WHEN month = '2026-08' THEN amount ELSE 0 END) AS delta FROM orders WHERE is_refund = 0 GROUP BY district ORDER BY delta DESC", + "row_count": 3, + "truncated": false, + "source": "lanshan_retail" + } + ], + "usage": { + "input_tokens": 264, + "output_tokens": 2, + "total_tokens": 266, + "cost_usd": 0.0, + "calls": 3 + } + }, + { + "unit": "u8", + "text": "9 月退款率保持在 2% 以下。", + "verdict": "stands", + "rationale": "9 月退款金额 21,500 元,占当月订单金额的比例为 0.0164,结论成立。", + "rationale_doc": "8da1c00e7a24b0eabe6f33efc13ec5d2e6578fddd8aaf67de5fb9d303cb12b90", + "queries": [ + { + "alias": "Q1", + "artifact": "8a0a49da574589f1108757ac8c7bc72eba7d48bb905627a201134764c55beef9", + "sql": "SELECT SUM(CASE WHEN is_refund = 1 THEN amount ELSE 0 END) AS refund, ROUND(SUM(CASE WHEN is_refund = 1 THEN amount ELSE 0 END) * 1.0 / SUM(amount), 4) AS rate FROM orders WHERE month = '2026-09'", + "row_count": 1, + "truncated": false, + "source": "lanshan_retail" + } + ], + "usage": { + "input_tokens": 251, + "output_tokens": 2, + "total_tokens": 253, + "cost_usd": 0.0, + "calls": 3 + } + }, + { + "unit": "u11", + "text": "线上渠道的客单价高于门店。", + "verdict": "inconclusive", + "rationale": "数据源中没有区分线上与门店的字段,无法判断这句结论。", + "rationale_doc": null, + "queries": [ + { + "alias": "Q1", + "artifact": "62217061d25cfac1db2b8dd81892066d9b4971a29e4637f1ef14f26eeee3d386", + "sql": "SELECT * FROM orders LIMIT 50", + "row_count": 50, + "truncated": true, + "source": "lanshan_retail" + } + ], + "usage": { + "input_tokens": 233, + "output_tokens": 41, + "total_tokens": 274, + "cost_usd": 0.0012, + "calls": 2 + } + } + ], + "model": "critic-large" + }, + "8da1c00e7a24b0eabe6f33efc13ec5d2e6578fddd8aaf67de5fb9d303cb12b90": { + "schema": "agentlab.report/1", + "entity_syntax": 2, + "provenance": 1, + "run_id": "run-chal-formal", + "node_id": "critic", + "markdown": "9 月退款金额 21,500 元,占当月订单金额的比例为 0.0164,结论成立。", + "source": "9 月退款金额 [[v:Q1.r0.refund]] 元,占当月订单金额的比例为 [[v:Q1.r0.rate]],结论成立。", + "catalog": { + "Q1": { + "alias": "Q1", + "kind": "query", + "eid": "ev:query:054504d0cb205b77", + "locator": {}, + "node_id": "critic", + "exec": 1, + "artifact": "8a0a49da574589f1108757ac8c7bc72eba7d48bb905627a201134764c55beef9", + "call_id": "u8-c0", + "tool": "db_query__lanshan_retail", + "source": "lanshan_retail", + "columns": [ + "refund", + "rate" + ], + "rows": 1, + "truncated": false, + "label": "db_query__lanshan_retail · 1 行" + } + }, + "blocks": [ + { + "type": "paragraph", + "units": [ + { + "id": "u0", + "kind": "claim", + "span": [ + 0, + 41 + ], + "cites": [ + "Q1" + ], + "see": [], + "segments": [ + { + "kind": "text", + "text": "9 月退款金额 ", + "state": "neutral", + "span": [ + 0, + 8 + ], + "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:d1776657622dc7a2", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 21500.0, + "rendered": "21,500" + }, + "state": "deterministic", + "span": [ + 8, + 14 + ], + "id": "s1" + }, + { + "kind": "text", + "text": " 元,占当月订单金额的比例为 ", + "state": "neutral", + "span": [ + 14, + 29 + ], + "id": "s2" + }, + { + "kind": "number", + "text": "0.0164", + "ref": "v:Q1.r0.rate", + "cite": { + "ref": "Q1.r0.rate", + "alias": "Q1", + "locator": { + "row": 0, + "column": "rate" + }, + "eid": "ev:cell:0a01eeda467fe3ce", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 0.0164, + "rendered": "0.0164" + }, + "state": "deterministic", + "span": [ + 29, + 35 + ], + "id": "s3" + }, + { + "kind": "text", + "text": ",结论成立。", + "state": "neutral", + "span": [ + 35, + 41 + ], + "id": "s4" + } + ] + } + ], + "id": "b0" + } + ], + "stats": { + "units": 1, + "segments": 5, + "claims": 1, + "connective": 0, + "headings": 0, + "numbers": 2, + "numbers_cited": 2, + "values": 0, + "entities": 0, + "quotes": 0, + "uncited_numbers": 0, + "unresolved": 0, + "unknown_entities": 0, + "unverified_entities": 0, + "see": 0, + "uncited_claims": 0, + "violations": 0 + }, + "violations": [] + }, + "dad9224ddaed549de56eac6e96902cf448291e8aaabb1699d09f25b87fc3541d": { + "report": "write", + "doc_artifact": "1b03b7ae4cdda13ae128a29e85ac985a3b1d66041318f4a53166c970e99fdf81", + "claims": [ + { + "unit": "u20", + "text": "白汀区 9 月订单量高于 8 月。", + "verdict": "stands", + "rationale": "按 orders 表复核:白汀区 9 月订单量高于 8 月这一说法成立。", + "rationale_doc": null, + "queries": [ + { + "alias": "Q1", + "artifact": "cc2aa1d637a2ac89c00a5307661e7ff4f3bac699db375a703bfdcf38201d06dd", + "sql": "SELECT district, SUM(amount) AS amount FROM orders WHERE month = '2026-09' GROUP BY district", + "row_count": 3, + "truncated": false, + "source": "lanshan_retail" + } + ] + }, + { + "unit": "u21", + "text": "云屿区仍是销售额最高的区。", + "verdict": "stands", + "rationale": "按 orders 表复核:云屿区仍是销售额最高的区这一说法成立。", + "rationale_doc": null, + "queries": [ + { + "alias": "Q1", + "artifact": "edf596cc32996af36b5e78fbcc6e60946841e8d6704f494b83a7e9606475a68d", + "sql": "SELECT district, SUM(amount) AS amount FROM orders WHERE month = '2026-09' GROUP BY district", + "row_count": 3, + "truncated": false, + "source": "lanshan_retail" + } + ] + }, + { + "unit": "u22", + "text": "老客复购率明显上升。", + "verdict": "weakened", + "rationale": "按 orders 表复核:老客复购率明显上升这一说法只部分成立。", + "rationale_doc": null, + "queries": [ + { + "alias": "Q1", + "artifact": "8a66366737b3140d307236b8fce3f0297a3c093b60529e0d3d15a50e13382004", + "sql": "SELECT district, SUM(amount) AS amount FROM orders WHERE month = '2026-09' GROUP BY district", + "row_count": 3, + "truncated": false, + "source": "lanshan_retail" + } + ] + }, + { + "unit": "u23", + "text": "退款集中在云屿区。", + "verdict": "stands", + "rationale": "按 orders 表复核:退款集中在云屿区这一说法成立。", + "rationale_doc": null, + "queries": [ + { + "alias": "Q1", + "artifact": "b6c81578dfbab675d02554e5a5524da25174e636344cfed90131367b0d5e193b", + "sql": "SELECT district, SUM(amount) AS amount FROM orders WHERE month = '2026-09' GROUP BY district", + "row_count": 3, + "truncated": false, + "source": "lanshan_retail" + } + ] + }, + { + "unit": "u24", + "text": "青岚区新客数量翻了一番。", + "verdict": "refuted", + "rationale": "按 orders 表复核:青岚区新客数量翻了一番这一说法与查询结果不符。", + "rationale_doc": null, + "queries": [ + { + "alias": "Q1", + "artifact": "752364b7b25e4de0bee322582d7a205ecab5476ba1f62290b842486883fcbf53", + "sql": "SELECT district, SUM(amount) AS amount FROM orders WHERE month = '2026-09' GROUP BY district", + "row_count": 3, + "truncated": false, + "source": "lanshan_retail" + } + ] + }, + { + "unit": "u25", + "text": "三个区的销售额都在增长。", + "verdict": "stands", + "rationale": "按 orders 表复核:三个区的销售额都在增长这一说法成立。", + "rationale_doc": null, + "queries": [ + { + "alias": "Q1", + "artifact": "fa96ed5ded14063346879e23109aa7200e87ce7ecd0631643ef6512a024f0ea2", + "sql": "SELECT district, SUM(amount) AS amount FROM orders WHERE month = '2026-09' GROUP BY district", + "row_count": 3, + "truncated": false, + "source": "lanshan_retail" + } + ] + }, + { + "unit": "u26", + "text": "周末订单占比有所提高。", + "verdict": "inconclusive", + "rationale": "按 orders 表复核:周末订单占比有所提高这一说法无法从现有数据判断。", + "rationale_doc": null, + "queries": [ + { + "alias": "Q1", + "artifact": "c890124d6e3357b415f1e7a51235ce76657b4519e6013d440855d12a4e9ff6aa", + "sql": "SELECT district, SUM(amount) AS amount FROM orders WHERE month = '2026-09' GROUP BY district", + "row_count": 3, + "truncated": false, + "source": "lanshan_retail" + } + ] + }, + { + "unit": "u27", + "text": "大额订单数量与上月持平。", + "verdict": "stands", + "rationale": "按 orders 表复核:大额订单数量与上月持平这一说法成立。", + "rationale_doc": null, + "queries": [ + { + "alias": "Q1", + "artifact": "2ca8f43235a32539d4f65cacd6d3de6d5f28c618a2ffde74fa2eae0f110d6d5e", + "sql": "SELECT district, SUM(amount) AS amount FROM orders WHERE month = '2026-09' GROUP BY district", + "row_count": 3, + "truncated": false, + "source": "lanshan_retail" + } + ] + }, + { + "unit": "u28", + "text": "客单价的提升主要来自青岚区。", + "verdict": "weakened", + "rationale": "按 orders 表复核:客单价的提升主要来自青岚区这一说法只部分成立。", + "rationale_doc": null, + "queries": [ + { + "alias": "Q1", + "artifact": "bf09144566e4263917ea89ae2d52f5e23ebab1889c516088c3cd5d0eb4e194bd", + "sql": "SELECT district, SUM(amount) AS amount FROM orders WHERE month = '2026-09' GROUP BY district", + "row_count": 3, + "truncated": false, + "source": "lanshan_retail" + } + ] + }, + { + "unit": "u29", + "text": "9 月没有出现单日销售额为零的门店。", + "verdict": "stands", + "rationale": "按 orders 表复核:9 月没有出现单日销售额为零的门店这一说法成立。", + "rationale_doc": null, + "queries": [ + { + "alias": "Q1", + "artifact": "4ecf3bf8b99e3770a4fbe68dacae228d8b4a71e87e5659a3da2c0bc6c24b9212", + "sql": "SELECT district, SUM(amount) AS amount FROM orders WHERE month = '2026-09' GROUP BY district", + "row_count": 3, + "truncated": false, + "source": "lanshan_retail" + } + ] + }, + { + "unit": "u30", + "text": "白汀区的退款金额高于云屿区。", + "verdict": "refuted", + "rationale": "按 orders 表复核:白汀区的退款金额高于云屿区这一说法与查询结果不符。", + "rationale_doc": null, + "queries": [ + { + "alias": "Q1", + "artifact": "a5c007cfee1b26ede1cb1361d33e007c49f563dc2c8d584660a316ee66089d75", + "sql": "SELECT district, SUM(amount) AS amount FROM orders WHERE month = '2026-09' GROUP BY district", + "row_count": 3, + "truncated": false, + "source": "lanshan_retail" + } + ] + }, + { + "unit": "u31", + "text": "9 月销售额创年内新高。", + "verdict": "stands", + "rationale": "按 orders 表复核:9 月销售额创年内新高这一说法成立。", + "rationale_doc": null, + "queries": [ + { + "alias": "Q1", + "artifact": "f5619f980c194fbe53fc1beedc18bbd8448e3352b19440a4a435000527d85ad3", + "sql": "SELECT district, SUM(amount) AS amount FROM orders WHERE month = '2026-09' GROUP BY district", + "row_count": 3, + "truncated": false, + "source": "lanshan_retail" + } + ] + } + ], + "model": "critic-large" + } + } +} \ No newline at end of file diff --git a/frontend/src/dev/fixtures/challenge.tsx b/frontend/src/dev/fixtures/challenge.tsx new file mode 100644 index 00000000..0e917ceb --- /dev/null +++ b/frontend/src/dev/fixtures/challenge.tsx @@ -0,0 +1,160 @@ +import { useState, type JSX } from 'react' +import { ReactFlow, ReactFlowProvider } from '@xyflow/react' +import { Inspector } from '../../canvas/Inspector' +import { NodeCard } from '../../canvas/NodeCard' +import { ApprovalCard } from '../../run/RunPanel' +import { useCatalog } from '../../store/catalog' +import { useStudio } from '../../store/studio' +import type { Approval, ApprovalChallenge, ApprovalReport, Provider, Workflow } from '../../types' +import fx from './challenge.json' + +/** + * 反方审稿的预览用例,scripts/check-challenge.mjs 按它们检查。夹具在 challenge.json(检查脚本读同一份):理由文档是后端 + * run_verify_agent + compose_doc 真跑出来的,库、表名、地名、数字都是虚构的。 + * + * 审批卡(payload.reports[0].challenge): + * - ?syn=challenge-approval:正式运行,没写 on_refuted(按降档),四种判断各一句,有被推翻的 →「出具会降档」 + * - ?syn=challenge-approval-flag:正式运行,只标注,不写降档 + * - ?syn=challenge-approval-explore:探索运行,不说任何和出具有关的话 + * - ?syn=challenge-approval-none:审的报告没有经过反方审稿,什么都不画 + * - ?syn=challenge-approval-many:十二句,条目只列前十句,「显示全部」从工件里补齐 + * - ?syn=challenge-approval-broken:工件取不到(检查脚本先答 404,再改成能取到,测重试) + * - ?syn=challenge-approval-dense:同第一个,摆在 360px 的右栏里 + * + * 运行类别(GET /api/runs/{id})、工件(GET /api/artifacts/{id})都由检查脚本用 page.route 伪造;直接打开时取不到, + * 卡上照实不说降档、点开一句说无法获取。 + * + * 检查器:?syn=challenge-inspector:一张虚构的图(两个上游报告、一个下游报告),选中反方审稿节点,旁边是同一张图的 + * 节点卡片和节点配置的实时内容。 + */ + +const report = fx.report as unknown as ApprovalReport +const challenge = fx.challenge as unknown as ApprovalChallenge + +function approvalOf(id: string, runId: string, runClass: 'formal' | 'exploratory', reviewed: ApprovalReport): Approval { + return { + id, run_id: runId, node_id: 'review', mode: 'approve', title: '本月月报可以发出吗?', status: 'pending', response: {}, + payload: { kind: 'human_node', message: '请审核本月月报。', reports: [reviewed] }, + created_at: new Date(Date.now() - 300_000).toISOString().replace('Z', ''), + workflow_name: '月报', node_label: '主管审批', run_class: runClass, + } +} + +function Card({ approval, name, dense = false }: { approval: Approval; name: string; dense?: boolean }) { + return ( +
+
+ undefined} /> +
+
+ ) +} + +const RUNS = fx.runs +const withChallenge = (c: Partial): ApprovalReport => ({ ...report, challenge: { ...challenge, ...c } }) + +// ---------------------------------------------------------------- 检查器 + +const PROVIDERS = [ + { + id: 'pv-main', name: '主力接入', kind: 'anthropic', base_url: null, default_model: 'writer-large', + models: [{ id: 'writer-large' }, { id: 'critic-large' }], enabled: true, extra: {}, api_key_masked: '', has_key: true, + }, + { + id: 'pv-spare', name: '备用接入', kind: 'openai', base_url: null, default_model: 'spare-mini', + models: [{ id: 'spare-mini' }], enabled: false, extra: {}, api_key_masked: '', has_key: true, + }, +] as Provider[] + +const at = (x: number, y: number) => ({ x, y }) +const WORKFLOW = { + id: 'wf-challenge-preview', name: '月报(预览)', description: '', tags: [], version: 1, is_template: false, status: 'draft', + graph: { + nodes: [ + { id: 'input', type: 'input', position: at(0, 120), data: { label: '输入', config: { fields: [{ name: 'month', required: true }] } } }, + { id: 'write', type: 'report', position: at(260, 0), data: { label: '写月报', config: { instructions: '写本月月报', model: 'writer-large' } } }, + { id: 'write2', type: 'report', position: at(260, 240), data: { label: '写周报', config: { instructions: '写本周周报' } } }, + { id: 'critic', type: 'challenge', position: at(540, 120), data: { label: '反方审稿', config: {} } }, + { id: 'later', type: 'report', position: at(820, 120), data: { label: '写摘要', config: { instructions: '写一段摘要' } } }, + { id: 'out', type: 'output', position: at(1100, 120), data: { label: '输出', config: {} } }, + ], + edges: [ + { source: 'input', target: 'write' }, { source: 'input', target: 'write2' }, + { source: 'write', target: 'critic' }, { source: 'write2', target: 'critic' }, + { source: 'critic', target: 'later' }, { source: 'later', target: 'out' }, + ], + }, +} as unknown as Workflow + +const nodeTypes = { card: NodeCard } + +/** 同一张图的节点卡片:看反方审稿节点的摘要跟着配置变 */ +function MiniCanvas() { + const nodes = useStudio((s) => s.nodes) + const edges = useStudio((s) => s.edges) + return ( + + + + ) +} + +function ConfigView() { + const config = useStudio((s) => s.nodes.find((n) => n.id === 'critic')?.data.config) + return ( +
+      {JSON.stringify(config ?? null, null, 1)}
+    
+ ) +} + +function InspectorCase() { + // 首次渲染前装好图和接入:检查器、卡片一挂上就是选中反方审稿节点的样子 + useState(() => { + useCatalog.setState({ providers: PROVIDERS }) + useStudio.getState().load(WORKFLOW) + useStudio.getState().select('critic') + return true + }) + return ( +
+
+ +
+
+
+ +
+
+ ) +} + +export const cases: Record JSX.Element> = { + 'challenge-approval': () => ( + + ), + 'challenge-approval-flag': () => ( + + ), + 'challenge-approval-explore': () => ( + + ), + 'challenge-approval-none': () => ( + + ), + 'challenge-approval-many': () => ( + + ), + 'challenge-approval-broken': () => ( + + ), + 'challenge-approval-dense': () => ( + + ), + 'challenge-inspector': () => , +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index bd414929..12f94ca0 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -3480,3 +3480,75 @@ export interface RunComparison { /** 运行接口回的运行带着发起它的定时任务(RunOut.schedule_id);Run 上没写这一项,对比页的运行卡要用 */ export type RunWithSchedule = Run & { schedule_id?: string | null } + +// --------------------------------------------------------------------------- +// 反方审稿(challenge 节点):审批卡上的概况、challenge_artifact 工件的内容。形状照前后端共用的接口约定,字段名一个不改; +// 判断由反方模型给出,界面只排版,理由正文、行数这些都是服务端给的 +// --------------------------------------------------------------------------- + +/** 一句结论经受住反方的复核没有:经受住 / 被削弱 / 被推翻 / 无法判断 */ +export type ChallengeVerdict = 'stands' | 'weakened' | 'refuted' | 'inconclusive' + +/** 各种判断的句数 */ +export type ChallengeCounts = Record + +/** 审批卡、节点产出里列的一句:unit 是报告文档里这一句的 id,text 是结论原文 */ +export interface ChallengeItem { + unit: string + text: string + verdict: ChallengeVerdict | (string & {}) +} + +/** + * 审批卡上的反方审稿概况(human 节点 payload.reports[i].challenge)。items 最多 10 条,完整内容在 artifact 指的工件里。 + * on_refuted 是实际生效的处理方式:degrade 被推翻的结论计入出具缺口;flag 只标注。报错(fail)时节点已经失败,到不了审批。 + * 老服务端不给时按 degrade + */ +export interface ApprovalChallenge { + counts: ChallengeCounts + items: ChallengeItem[] + artifact: string + on_refuted?: 'degrade' | 'flag' | (string & {}) +} + +/** + * 审批审的报告经过反方审稿时多一个 challenge。和上面的 ApprovalReport 是同一个接口(声明合并):反方审稿的类型集中在 + * 这一段,这一项也就近写在这里 + */ +export interface ApprovalReport { + challenge?: ApprovalChallenge +} + +/** 反方为复核一句结论执行的一次只读查询:编号是它自己的(Q1、Q2…),和报告里的查询编号无关 */ +export interface ChallengeQuery { + alias: string + /** 查询快照工件 */ + artifact: string + sql: string + row_count: number | null + truncated: boolean + /** 数据源名 */ + source: string | null +} + +/** 工件里的一句:判断、理由、查询 */ +export interface ChallengeClaim extends ChallengeItem { + /** 理由渲染后的正文(数字已换成查询结果里的值) */ + rationale: string + /** + * 理由组装成的文档(和报告文档同一种形状,数字带出处、裸数字被标出)。约定没说是工件 id 还是文档本身,两种都认; + * 没有时只显示 rationale + */ + rationale_doc?: string | EvidenceDocData | null + queries: ChallengeQuery[] + usage?: { input_tokens?: number; output_tokens?: number; total_tokens?: number; cost_usd?: number | null; calls?: number } +} + +/** challenge_artifact 工件的内容:审的哪份报告、每句的复核,以及反方用的模型 */ +export interface ChallengeArtifact { + /** 被审的报告撰写节点 */ + report: string + doc_artifact: string + claims: ChallengeClaim[] + model: string | null +} diff --git a/scripts/check-all.mjs b/scripts/check-all.mjs index 6ac0cc67..b75def11 100644 --- a/scripts/check-all.mjs +++ b/scripts/check-all.mjs @@ -86,6 +86,7 @@ const ORDER = [ 'compare', // 运行对比 'verify', // 双盲复算、反方审稿 'inquiry', // 审批质询 + 'challenge', // 反方审稿:审批卡上的概况与逐句展开、检查器 'bundle', // 可验证报告 'whatif', // 数字推演 'ui', // 各页面端到端,连真数据 @@ -93,6 +94,7 @@ const ORDER = [ // 各项的耗时(秒)。只决定排队顺序:长的先开跑,短的填空,结果不受影响。新加的检查不在表里就按 60 秒算; // 拆开的两项看 SPLIT 里各份的 est。2026-09-29 八道并行、关浏览器不干等之后的实测 const ESTIMATE = { + challenge: 40, stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87, evidence: 91, versions: 60, catalog: 45, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1, // 占位,换成真检查时按实测改 diff --git a/scripts/check-challenge.mjs b/scripts/check-challenge.mjs new file mode 100644 index 00000000..5942019b --- /dev/null +++ b/scripts/check-challenge.mjs @@ -0,0 +1,497 @@ +// 反方审稿的回归检查:审批卡上的反方审稿(run/ChallengeSummary.tsx)和检查器里反方审稿节点的配置(canvas/nodeDefs.ts 的 +// challenge、canvas/verifyFields.tsx)。 +// +// 守的是这些「看着没坏、其实用不了」的地方:概况的句数和条目对不上、有被推翻的结论却不提醒降档、只标注时也写降档、 +// 探索运行里说出具、没经过反方审稿的报告也冒出一块空框(或者白发一次请求)、点开一句不取工件(或者每点一句取一次)、 +// 理由里的数字看不出引用了哪次查询、裸数字没被标出来、理由文档是另一个工件时不去取、查询的 SQL / 行数 / 截断不显示、 +// 工件取不到时卡死在加载中、条目只列前十句时看不到其余的;检查器里的字段写不进节点配置(或者写进去的不是约定的形状: +// 跟随设置要把这一项拿掉,不能写成空字符串)、下拉里列出了下游的报告、选中的报告被删了还看着像「未选择」、反方模型和 +// 撰写报告的模型相同不提醒、节点卡片的摘要还写着功能开发中;窄屏(390px)被撑出横向滚动、浅色主题下标签看不清。 +// +// 跑在预览页上:/preview.html?syn=challenge-*(用例见 frontend/src/dev/fixtures/challenge.tsx)。夹具在 +// frontend/src/dev/fixtures/challenge.json,理由文档是后端 run_verify_agent + compose_doc 真跑出来的(虚构的库和数字)。 +// 运行类别(GET /api/runs/{id})、工件(GET /api/artifacts/{id})、画布校验(两个纯计算的 POST)在这里伪造,别的非 GET +// 一律拦掉并记下来,别的 GET 放行。 +// +// node scripts/check-challenge.mjs +// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-challenge.mjs +// CHALLENGE_SHOTS=<目录> 时亮暗两套各截几张,供人眼复核。 +import { mkdirSync, readFileSync } from 'node:fs' +import { chromium } from '../frontend/node_modules/playwright-core/index.mjs' + +const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273' +const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' +const SHOTS = process.env.CHALLENGE_SHOTS ?? '' +const root = new URL('..', import.meta.url).pathname +const fx = JSON.parse(readFileSync(`${root}frontend/src/dev/fixtures/challenge.json`, 'utf8')) +const ART = fx.challenge.artifact +const DOC = fx.doc_u8 +const MANY = fx.many.artifact +const BROKEN = 'e'.repeat(64) +const RUNS = fx.runs +const RUN_CLASS = { + [RUNS.formal]: 'formal', [RUNS.flag]: 'formal', [RUNS.explore]: 'exploratory', [RUNS.none]: 'formal', + [RUNS.many]: 'formal', [RUNS.broken]: 'formal', +} +const LABEL = { stands: '经受住', weakened: '被削弱', refuted: '被推翻', inconclusive: '无法判断' } +const FLAG_NOTE = '按节点设置,被推翻的结论只做标注,不影响出具档位' +const FAILED = '无法获取反方审稿的详细内容' + +let failed = 0 +const check = (name, cond, detail = '') => { + console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`) + if (!cond) failed++ +} +async function section(name, fn) { + console.log(`\n=== ${name} ===`) + try { + await fn() + } catch (e) { + check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0]) + } +} + +const browser = await chromium.launch({ executablePath: CHROME }) + +/** + * 开一个预览用例。工件按夹具答(BROKEN 先答 404,state.broken 置 false 后答正常内容),运行类别按 RUN_CLASS 答; + * 记下取了哪些工件、问了哪些运行、拦下了哪些写请求 + */ +async function open(syn, { width = 1100, height = 900, theme = 'dark' } = {}) { + const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: theme, timezoneId: 'Asia/Shanghai' }) + ctx.setDefaultTimeout(6000) + const page = await ctx.newPage() + const hits = { artifacts: [], runs: [], writes: [] } + const state = { broken: true } + const errors = [] + page.on('pageerror', (e) => errors.push(e.message)) + page.on('dialog', (d) => { errors.push(`原生对话框:${d.message()}`); void d.dismiss() }) + await page.route((u) => new URL(u).pathname.startsWith('/api/'), async (route) => { + const req = route.request() + const path = new URL(req.url()).pathname + if (req.method() === 'POST' && path === '/api/workflows/validate') return route.fulfill({ json: { ok: true, issues: [] } }) + if (req.method() === 'POST' && path === '/api/workflows/variables') return route.fulfill({ json: { variables: [], issues: [] } }) + // 画布载入一张图时会找(没有就建)它的助手会话:答一份假的,不碰真实会话 + if (path === '/api/conversations') { + return route.fulfill({ json: req.method() === 'GET' ? [] : { id: 'conv-challenge', kind: 'canvas', title: '', turns: [] } }) + } + if (req.method() !== 'GET') { + hits.writes.push(`${req.method()} ${path}`) + return route.abort() + } + const art = path.match(/^\/api\/artifacts\/([^/]+)$/) + if (art) { + const id = decodeURIComponent(art[1]) + hits.artifacts.push(id) + if (id === BROKEN && !state.broken) return route.fulfill({ json: { id, content: fx.artifacts[ART] } }) + const content = fx.artifacts[id] + return content ? route.fulfill({ json: { id, content } }) + : route.fulfill({ status: 404, json: { detail: '工件不存在,可能已被删除' } }) + } + const run = path.match(/^\/api\/runs\/([^/]+)$/) + if (run) { + const id = decodeURIComponent(run[1]) + hits.runs.push(id) + return RUN_CLASS[id] ? route.fulfill({ json: { id, run_class: RUN_CLASS[id], status: 'interrupted' } }) + : route.fulfill({ status: 404, json: { detail: '运行不存在' } }) + } + return route.continue() + }) + await page.goto(`${WEB}/preview.html?syn=${syn}&theme=${theme}`, { waitUntil: 'networkidle' }) + return { ctx, page, hits, state, errors } +} + +const text = async (loc) => (await loc.innerText()).replace(/\s+/g, ' ').trim() +const shot = async (page, name) => { + if (!SHOTS) return + mkdirSync(SHOTS, { recursive: true }) + await page.screenshot({ path: `${SHOTS}/${name}.png`, fullPage: true }) +} +/** 等运行类别答完、界面画上:否定的断言(不写降档)得在这之后做 */ +async function runClassSettled(page, hits, runId) { + for (let i = 0; i < 50 && !hits.runs.includes(runId); i++) await page.waitForTimeout(100) + await page.waitForTimeout(400) +} +const item = (page, unit) => page.locator(`[data-challenge-item="${unit}"]`) +const toggle = (page, unit) => item(page, unit).locator('[data-challenge-toggle]') +const detail = (page, unit) => page.locator(`[data-challenge-detail="${unit}"]`) +const config = (page) => page.evaluate(() => window.__studio.getState().nodes.find((n) => n.id === 'critic')?.data.config ?? null) + +await section('审批卡:概况与降档提示', async () => { + const { ctx, page, hits, errors } = await open('challenge-approval') + const block = page.locator('[data-challenge="write"]') + await block.waitFor() + check('审批卡里有这份报告的反方审稿', (await text(block.locator('div').first())).includes('报告「写月报」的反方审稿')) + const counts = {} + for (const v of Object.keys(LABEL)) counts[v] = await text(block.locator(`[data-challenge-count="${v}"]`)) + check('一行概况:四种判断各几句', counts.stands === '经受住 1 句' && counts.weakened === '被削弱 1 句' + && counts.refuted === '被推翻 1 句' && counts.inconclusive === '无法判断 1 句', JSON.stringify(counts)) + await page.waitForSelector('[data-challenge-degrade]') + check('正式运行、没写处理方式(按降档)、有被推翻的:醒目提示「出具会降档」', + (await text(page.locator('[data-challenge-degrade]'))).startsWith('出具会降档')) + check('不是只标注:没有「只做标注」那句', await page.locator('[data-challenge-flag]').count() === 0) + const rows = await block.locator('[data-challenge-item]').evaluateAll((els) => els.map((el) => ({ + unit: el.getAttribute('data-challenge-item'), + tag: el.querySelector('[data-challenge-tag]')?.textContent?.trim(), + text: el.querySelector('[data-challenge-text]')?.textContent?.trim(), + }))) + const expected = fx.challenge.items.map((it) => ({ unit: it.unit, tag: LABEL[it.verdict], text: it.text })) + check('逐句列出结论原文和判断标签,顺序照审批的条目', JSON.stringify(rows) === JSON.stringify(expected), JSON.stringify(rows)) + check('每句是一个收起的按钮', await block.locator('[data-challenge-toggle][aria-expanded="false"]').count() === 4) + check('没点开之前也说清判断出自反方模型', (await text(block.locator('[data-challenge-judged]'))) === '判断由反方模型给出,并非系统核对') + check('没点开之前不取工件', hits.artifacts.length === 0, hits.artifacts.join('、')) + check('运行类别按运行 id 取了一次', hits.runs.filter((r) => r === RUNS.formal).length === 1, hits.runs.join('、')) + await shot(page, 'approval-dark') + check('页面没有报错', errors.length === 0, errors.join(';')) + await ctx.close() +}) + +await section('点开一句:取工件,显示理由与查询', async () => { + const { ctx, page, hits, errors } = await open('challenge-approval') + await toggle(page, 'u3').click() + await detail(page, 'u3').locator('[data-challenge-doc]').waitFor() + check('点开后标为展开、指向详情', (await toggle(page, 'u3').getAttribute('aria-expanded')) === 'true' + && await page.locator(`#${CSS_ESC(await toggle(page, 'u3').getAttribute('aria-controls'))}[data-challenge-detail="u3"]`).count() === 1) + check('按 challenge_artifact 取了一次工件', JSON.stringify(hits.artifacts) === JSON.stringify([ART]), hits.artifacts.join('、')) + const u3 = detail(page, 'u3') + const rationale = await text(u3.locator('[data-challenge-doc]')) + check('显示理由正文(数字是查询结果里的值)', rationale.includes('9 月新客销售额 151,000') && rationale.includes('1,135,000'), rationale) + const cites = await u3.locator('[data-challenge-cite]').evaluateAll((els) => els.map((el) => [el.getAttribute('data-challenge-cite'), + el.querySelector('sup')?.textContent, el.getAttribute('aria-label'), el.getAttribute('data-ev-line')])) + check('有出处的数字照证据的样子画实线,后面挂查询编号', cites.length === 4 + && cites.every(([alias, sup, , line]) => alias === sup && line === 'solid') + && JSON.stringify(cites.map((c) => c[0])) === JSON.stringify(['Q1', 'Q2', 'Q2', 'Q1']), JSON.stringify(cites.map((c) => c.slice(0, 2)))) + check('读屏听得到引用的是哪次查询、哪一格', (cites[0]?.[2] ?? '').includes('出处:查询 Q1 · 第 1 行 · amount'), cites[0]?.[2]) + const queries = await u3.locator('[data-challenge-query]').evaluateAll((els) => els.map((el) => ({ + alias: el.getAttribute('data-challenge-query'), sql: el.querySelector('[data-challenge-sql]')?.textContent ?? '', + rows: el.querySelector('[data-challenge-rows]')?.textContent ?? '', cut: !!el.querySelector('[data-challenge-truncated]'), + }))) + check('列出反方执行的查询:SQL、行数', queries.length === 2 && queries[0].alias === 'Q1' && queries[1].alias === 'Q2' + && queries.every((q) => q.sql.includes('GROUP BY customer_type') && q.rows === '2 行' && !q.cut), JSON.stringify(queries.map((q) => [q.alias, q.rows]))) + check('查询一节写明共几次', (await text(u3.locator('[data-challenge-queries] > div').first())) === '反方执行的查询(2 次)') + check('写明判断出自反方模型、不是系统核对(取到工件后带上模型名,整块只说一次)', + (await text(page.locator('[data-challenge="write"] [data-challenge-judged]'))) === '判断由反方模型「critic-large」给出,并非系统核对' + && await page.locator('[data-challenge-judged]').count() === 1) + + // 点理由里的数字:焦点落到那次查询上,描一下边 + await u3.locator('[data-challenge-cite="Q2"]').first().click() + await page.waitForTimeout(100) + const jumped = await page.evaluate(() => { + const head = document.activeElement + const card = head?.closest('[data-challenge-query]') + return { head: head?.hasAttribute('data-challenge-query-head'), alias: card?.getAttribute('data-challenge-query'), flash: card?.getAttribute('data-flash') } + }) + check('点有出处的数字:跳到它引用的那次查询并描边', jumped.head && jumped.alias === 'Q2' && jumped.flash === 'focus', JSON.stringify(jumped)) + + // 裸数字被标出来 + await toggle(page, 'u5').click() + const u5 = detail(page, 'u5') + await u5.locator('[data-challenge-doc]').waitFor() + const bare = await u5.locator('.ev-mark[data-ev-state="none"]').allInnerTexts() + check('理由里的裸数字画点状线,句末挂「无证据」', JSON.stringify(bare.map((t) => t.replace(/\s*(无证据)$/, ''))) === JSON.stringify(['三', '70000', '一半']) + && (await u5.locator('[data-ev-tag="none"]').innerText()).includes('无证据'), JSON.stringify(bare)) + check('有出处的那个照样挂编号', await u5.locator('[data-challenge-cite="Q1"]').count() === 1) + check('同一张卡点第二句不再取工件', hits.artifacts.filter((a) => a === ART).length === 1, hits.artifacts.join('、')) + + // 理由文档是另一个工件 + await toggle(page, 'u8').click() + const u8 = detail(page, 'u8') + await u8.locator('[data-challenge-doc]').waitFor() + const u8Text = await text(u8.locator('[data-challenge-doc]')) + check('理由文档是工件 id 时去取它,按文档显示', hits.artifacts.includes(DOC) && u8Text.includes('9 月退款金额 21,500') + && await u8.locator('[data-challenge-cite]').count() === 2, `${hits.artifacts.length} 次取工件 · ${u8Text}`) + + // 没有理由文档:按渲染后的正文显示;查询截断照实说 + await toggle(page, 'u11').click() + const u11 = detail(page, 'u11') + await u11.locator('[data-challenge-rationale-text]').waitFor() + check('没有理由文档时显示渲染后的正文', (await text(u11.locator('[data-challenge-rationale-text]'))) === '数据源中没有区分线上与门店的字段,无法判断这句结论。') + check('查询结果截断时写明', (await text(u11.locator('[data-challenge-rows]'))) === '50 行' + && (await text(u11.locator('[data-challenge-truncated]'))) === '结果已截断,数据库中还有更多数据') + await shot(page, 'approval-open-dark') + + // 收起;键盘开合 + await toggle(page, 'u3').click() + check('再点一下收起', await detail(page, 'u3').count() === 0 && (await toggle(page, 'u3').getAttribute('aria-expanded')) === 'false') + await toggle(page, 'u3').focus() + await page.keyboard.press('Enter') + await detail(page, 'u3').locator('[data-challenge-doc]').waitFor() + check('键盘:回车展开', (await toggle(page, 'u3').getAttribute('aria-expanded')) === 'true') + await page.keyboard.press('Space') + await page.waitForTimeout(100) + check('键盘:空格收起', await detail(page, 'u3').count() === 0) + check('没有发出任何写请求', hits.writes.length === 0, hits.writes.join('、')) + check('页面没有报错', errors.length === 0, errors.join(';')) + await ctx.close() +}) + +await section('只标注、探索运行、没有反方审稿', async () => { + { + const { ctx, page, hits, errors } = await open('challenge-approval-flag') + await page.locator('[data-challenge="write"]').waitFor() + await runClassSettled(page, hits, RUNS.flag) + const all = await text(page.locator('[data-challenge="write"]')) + check('只标注:不写「出具会降档」', await page.locator('[data-challenge-degrade]').count() === 0 && !all.includes('降档'), all) + check('只标注:写明被推翻的结论只做标注', (await text(page.locator('[data-challenge-flag]'))) === FLAG_NOTE) + check('只标注:页面没有报错', errors.length === 0, errors.join(';')) + await ctx.close() + } + { + const { ctx, page, hits, errors } = await open('challenge-approval-explore') + await page.locator('[data-challenge="write"]').waitFor() + await runClassSettled(page, hits, RUNS.explore) + const all = await text(page.locator('[data-challenge="write"]')) + check('探索运行:反方审稿里不说任何和出具有关的话', !all.includes('出具') && await page.locator('[data-challenge-degrade], [data-challenge-flag]').count() === 0, all) + check('探索运行:概况和条目照常显示', (await text(page.locator('[data-challenge-count="refuted"]'))) === '被推翻 1 句' + && await page.locator('[data-challenge-item]').count() === 4) + check('探索运行:页面没有报错', errors.length === 0, errors.join(';')) + await ctx.close() + } + { + const { ctx, page, hits, errors } = await open('challenge-approval-none') + await page.locator('[data-approval-report="write"]').waitFor() + await page.waitForTimeout(500) + check('报告没有经过反方审稿:什么都不画', await page.locator('[data-challenge]').count() === 0) + check('报告没有经过反方审稿:不问运行类别、不取工件', !hits.runs.includes(RUNS.none) && hits.artifacts.length === 0, + `${hits.runs.join('、')} / ${hits.artifacts.join('、')}`) + check('报告核对概况照常显示', (await text(page.locator('[data-approval-report="write"]'))).includes('报告「写月报」的核对')) + check('没有反方审稿:页面没有报错', errors.length === 0, errors.join(';')) + await ctx.close() + } +}) + +await section('工件取不到:说明并可重试', async () => { + const { ctx, page, state, hits, errors } = await open('challenge-approval-broken') + await toggle(page, 'u3').click() + await page.waitForSelector('[data-challenge-detail="u3"] [data-challenge-detail-state="error"]') + check('取不到:写明无法获取,给重试', (await text(detail(page, 'u3'))).includes(FAILED) + && await detail(page, 'u3').locator('button', { hasText: '重试' }).count() === 1) + check('取不到:不停在「正在加载」', await page.locator('[data-challenge-detail-state="loading"]').count() === 0) + state.broken = false + await detail(page, 'u3').locator('button', { hasText: '重试' }).click() + await detail(page, 'u3').locator('[data-challenge-doc]').waitFor() + check('重试:再取一次,取到后显示理由', hits.artifacts.filter((a) => a === BROKEN).length === 2 + && (await text(detail(page, 'u3').locator('[data-challenge-doc]'))).includes('151,000')) + check('取不到:页面没有报错', errors.length === 0, errors.join(';')) + await ctx.close() +}) + +await section('超过十句:显示全部', async () => { + const { ctx, page, hits, errors } = await open('challenge-approval-many') + await page.locator('[data-challenge="write"]').waitFor() + check('条目只列前十句', await page.locator('[data-challenge-item]').count() === 10) + check('计数是全部十二句', (await text(page.locator('[data-challenge-count="stands"]'))) === '经受住 7 句' + && (await text(page.locator('[data-challenge-count="refuted"]'))) === '被推翻 2 句') + check('写明另有几句未列出', (await text(page.locator('[data-challenge-more]'))).startsWith('另有 2 句未列出')) + check('没要之前不取工件', hits.artifacts.length === 0) + await page.locator('[data-challenge-show-all]').click() + await page.waitForFunction(() => document.querySelectorAll('[data-challenge-item]').length === 12) + check('显示全部:从工件里补齐其余两句,排在后面', JSON.stringify(await page.locator('[data-challenge-item]').evaluateAll((els) => + els.slice(10).map((el) => el.getAttribute('data-challenge-item')))) === JSON.stringify(['u30', 'u31']) && hits.artifacts.includes(MANY)) + check('显示全部之后不再写「另有」', await page.locator('[data-challenge-more]').count() === 0) + check('补出来的一句带着自己的判断标签', (await text(item(page, 'u30').locator('[data-challenge-tag]'))) === '被推翻') + await toggle(page, 'u30').click() + await detail(page, 'u30').locator('[data-challenge-rationale-text]').waitFor() + const u30 = await text(detail(page, 'u30')) + check('补出来的那几句也能点开', u30.includes('白汀区的退款金额高于云屿区这一说法与查询结果不符'), u30) + check('超过十句:页面没有报错', errors.length === 0, errors.join(';')) + await ctx.close() +}) + +await section('检查器:字段写进节点配置', async () => { + const { ctx, page, hits, errors } = await open('challenge-inspector', { width: 1280, height: 1000 }) + await page.locator('[data-field="report"]').waitFor() + const panel = page.locator('[data-challenge-inspector]') + check('节点说明不再写功能开发中', !(await text(panel)).includes('功能开发中')) + const card = page.locator('.react-flow__node[data-id="critic"]') + await card.waitFor() + check('节点卡片摘要:没配时写审上游报告、句数跟随设置', (await text(card)).includes('审上游报告 · 句数跟随设置'), await text(card)) + + // 要审的报告:只列上游的报告撰写节点 + const report = page.locator('[data-field="report"] select') + const options = await report.locator('option').evaluateAll((els) => els.map((o) => [o.value, o.textContent])) + check('要审的报告:只列上游的报告撰写节点,下游的不列', JSON.stringify(options) === JSON.stringify([ + ['', '未选择:审上游的报告撰写节点'], ['write', '写月报(write)'], ['write2', '写周报(write2)']]), JSON.stringify(options)) + check('上游有两个、又没选:提醒选一个', (await text(page.locator('[data-node-pick-hint="many"]'))) === '上游有 2 个报告撰写节点,请选择要审的那一个') + await report.selectOption('write') + check('选了报告:写进 report', (await config(page))?.report === 'write') + check('选了之后不再提醒', await page.locator('[data-node-pick-hint]').count() === 0) + + await page.locator('[data-field="max_claims"] input').fill('3') + check('最多审几句:写进 max_claims(数)', (await config(page))?.max_claims === 3) + await page.waitForTimeout(150) + check('节点卡片摘要写清审哪份报告、最多几句', (await text(card)).includes('审报告节点 write · 最多审 3 句结论'), await text(card)) + + // 反方模型 + const provider = page.locator('[data-field="provider"] select') + const providers = await provider.locator('option').evaluateAll((els) => els.map((o) => o.textContent)) + check('反方模型 · 接入:跟随设置在第一项,停用的接入标出来', JSON.stringify(providers) === JSON.stringify(['跟随设置', '主力接入', '备用接入(已停用)']), + JSON.stringify(providers)) + await provider.selectOption('主力接入') + const model = page.locator('[data-field="model"] input') + await model.fill('writer-large') + let c = await config(page) + check('接入、模型写进 provider、model', c?.provider === '主力接入' && c?.model === 'writer-large', JSON.stringify(c)) + check('和撰写报告的模型相同:提醒', (await text(page.locator('[data-verify-same-model]'))).includes('「writer-large」')) + await model.fill('critic-large') + check('换一个模型:不再提醒', await page.locator('[data-verify-same-model]').count() === 0) + check('模型框给出所选接入的模型', JSON.stringify(await page.locator('[data-field="model"] datalist option').evaluateAll((els) => els.map((o) => o.value))) + === JSON.stringify(['writer-large', 'critic-large'])) + await provider.selectOption('') + c = await config(page) + check('接入改回留空:配置里拿掉 provider', !('provider' in (c ?? {})) || c.provider === undefined, JSON.stringify(c)) + check('只填了模型:接入一项写「按模型名找接入」,并说明会用哪个接入', + (await provider.locator('option').first().textContent()) === '按模型名找接入' + && (await text(page.locator('[data-verify-by-model="found"]'))).includes('「主力接入」')) + + // 被推翻时怎么办 + const onRefuted = page.locator('[data-field="on_refuted"] select') + check('结论被推翻时:没写时落在「出具降档(默认)」', (await onRefuted.evaluate((s) => s.options[s.selectedIndex]?.textContent)) === '出具降档(默认)') + await onRefuted.selectOption('flag') + check('选只标注:写进 on_refuted', (await config(page))?.on_refuted === 'flag') + await onRefuted.selectOption('fail') + check('选报错:写进 on_refuted', (await config(page))?.on_refuted === 'fail') + + // 启用:三态 + const group = page.locator('[data-field="enabled"] [role="radiogroup"]') + check('启用:一组单选,组名是「启用」,默认跟随设置', (await group.getAttribute('aria-label')) === '启用' + && (await group.getAttribute('data-tri-state')) === 'follow' && !('enabled' in (await config(page)))) + check('跟随设置时说明跟的是哪一项', (await text(page.locator('[data-tri-hint]'))) === '使用「设置 → 核查」中「反方审稿 · 启用」的值') + await group.locator('[data-tri="off"]').click() + check('点关闭:写进 enabled: false', (await config(page))?.enabled === false && (await group.getAttribute('data-tri-state')) === 'off') + await page.keyboard.press('ArrowLeft') + check('键盘:方向键在组内移动并选中(关闭 → 开启)', (await config(page))?.enabled === true + && await page.evaluate(() => document.activeElement?.getAttribute('data-tri')) === 'on') + await group.locator('[data-tri="follow"]').click() + check('点跟随设置:配置里拿掉 enabled,不写 null 也不写空字符串', !('enabled' in (await config(page))) || (await config(page)).enabled === undefined, + JSON.stringify(await config(page))) + await group.locator('[data-tri="off"]').click() + await page.waitForTimeout(150) + check('关掉的节点:卡片摘要写已关闭', (await text(card)).includes('已关闭 · 审报告节点 write'), await text(card)) + + // 高级选项里的两项上限 + await panel.locator('button', { hasText: '高级选项' }).click() + await page.locator('[data-field="max_queries"] input').fill('2') + await page.locator('[data-field="timeout_s"] input').fill('60') + c = await config(page) + check('每句最多查询次数、每句时长上限写进 max_queries、timeout_s', c?.max_queries === 2 && c?.timeout_s === 60, JSON.stringify(c)) + await page.locator('[data-field="max_queries"] input').fill('') + check('清空上限:配置里拿掉这一项(跟随设置)', (await config(page))?.max_queries === undefined) + + // 选中的报告挪到下游、被删掉:照实列出来,不像「未选择」 + await page.evaluate(() => { + const s = window.__studio.getState() + const n = s.nodes.find((x) => x.id === 'critic') + s.updateNode('critic', { config: { ...n.data.config, report: 'later' } }) + }) + await page.waitForTimeout(100) + check('选中的报告在下游:下拉里照实标「不在上游」,并提醒', (await report.evaluate((s) => s.options[s.selectedIndex]?.textContent)) === '写摘要(later)(不在上游)' + && await page.locator('[data-node-pick-hint="notUpstream"]').count() === 1) + await page.evaluate(() => { + const s = window.__studio.getState() + const n = s.nodes.find((x) => x.id === 'critic') + s.updateNode('critic', { config: { ...n.data.config, report: 'ghost' } }) + }) + await page.waitForTimeout(100) + check('选中的报告已不在画布上:照实标出,并提醒', (await report.evaluate((s) => s.options[s.selectedIndex]?.textContent)) === 'ghost(已不在画布上)' + && await page.locator('[data-node-pick-hint="missing"]').count() === 1) + await shot(page, 'inspector-dark') + check('检查器:没有发出任何写请求', hits.writes.length === 0, hits.writes.join('、')) + check('检查器:页面没有报错', errors.length === 0, errors.join(';')) + await ctx.close() +}) + +/** 页面和某个容器都没有横向滚动(SQL 框自己的滚动条不算) */ +async function overflow(page, sel) { + return page.evaluate((s) => { + const doc = document.documentElement + const box = document.querySelector(s) + return { page: doc.scrollWidth - doc.clientWidth, box: box ? box.scrollWidth - box.clientWidth : -1 } + }, sel) +} + +/** 两个颜色的对比度(WCAG):颜色是 getComputedStyle 给的 rgb / rgba / color(srgb …),半透明的叠在底色上 */ +function contrast(fg, bg, base) { + const parse = (c) => { + const m = c.match(/color\(srgb ([\d.]+) ([\d.]+) ([\d.]+)(?: \/ ([\d.]+))?\)/) + if (m) return [m[1] * 255, m[2] * 255, m[3] * 255, m[4] == null ? 1 : Number(m[4])] + const n = c.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/) + return n ? [Number(n[1]), Number(n[2]), Number(n[3]), n[4] == null ? 1 : Number(n[4])] : null + } + const over = (top, under) => top.slice(0, 3).map((v, i) => v * top[3] + under[i] * (1 - top[3])) + const lum = ([r, g, b]) => { + const f = (v) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 } + return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b) + } + const b0 = parse(base); const b1 = parse(bg); const f0 = parse(fg) + if (!b0 || !b1 || !f0) return 0 + const under = over(b1, b0.slice(0, 3)) + const a = lum(over(f0, under)); const b = lum(under) + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) +} + +await section('窄屏与深浅色', async () => { + for (const theme of ['dark', 'light']) { + const { ctx, page, errors } = await open('challenge-approval', { width: 390, height: 844, theme }) + await page.waitForSelector('[data-challenge-degrade]') + for (const unit of ['u3', 'u5', 'u11']) { + await toggle(page, unit).click() + await detail(page, unit).locator('[data-challenge-queries]').waitFor() + } + const o = await overflow(page, '[data-challenge="write"]') + check(`390px(${theme}):展开三句也没有横向溢出`, o.page <= 0 && o.box <= 1, JSON.stringify(o)) + const colors = await page.evaluate(() => { + const block = document.querySelector('[data-challenge="write"]') + const base = getComputedStyle(document.body).backgroundColor + const bg = getComputedStyle(block).backgroundColor + const pick = (el) => ({ fg: getComputedStyle(el).color, weight: Number(getComputedStyle(el).fontWeight) }) + return { + base, bg, + degrade: pick(document.querySelector('[data-challenge-degrade]')), + tags: [...document.querySelectorAll('[data-challenge-tag]')].slice(0, 4).map((el) => ({ v: el.getAttribute('data-challenge-tag'), ...pick(el) })), + counts: [...document.querySelectorAll('[data-challenge-count]')].map((el) => ({ v: el.getAttribute('data-challenge-count'), ...pick(el) })), + sup: pick(document.querySelector('[data-challenge-cite] sup')), + } + }) + const ratio = (c) => contrast(c.fg, colors.bg, colors.base) + check(`${theme}:「出具会降档」对比度 ≥ 4.5、加粗`, ratio(colors.degrade) >= 4.5 && colors.degrade.weight >= 600, + `${ratio(colors.degrade).toFixed(2)} · ${colors.degrade.weight}`) + const tagRatios = colors.tags.map((t) => [t.v, ratio(t).toFixed(2)]) + check(`${theme}:四种判断标签对比度都 ≥ 4.5`, colors.tags.length === 4 && colors.tags.every((t) => ratio(t) >= 4.5), JSON.stringify(tagRatios)) + check(`${theme}:概况里的句数对比度都 ≥ 4.5`, colors.counts.every((t) => ratio(t) >= 4.5), + JSON.stringify(colors.counts.map((t) => [t.v, ratio(t).toFixed(2)]))) + check(`${theme}:数字后面的查询编号对比度 ≥ 4.5`, ratio(colors.sup) >= 4.5, ratio(colors.sup).toFixed(2)) + await shot(page, `narrow-${theme}`) + check(`390px(${theme}):页面没有报错`, errors.length === 0, errors.join(';')) + await ctx.close() + } + { + const { ctx, page } = await open('challenge-approval-dense', { width: 1100, height: 900 }) + await page.locator('[data-challenge="write"]').waitFor() + await toggle(page, 'u3').click() + await detail(page, 'u3').locator('[data-challenge-queries]').waitFor() + const o = await overflow(page, '[data-fixture="challenge-approval-dense"]') + check('360px 右栏:展开后不撑宽右栏', o.page <= 0 && o.box <= 0, JSON.stringify(o)) + await shot(page, 'dense-dark') + await ctx.close() + } + for (const theme of ['dark', 'light']) { + const { ctx, page, errors } = await open('challenge-inspector', { width: 390, height: 844, theme }) + await page.locator('[data-field="report"]').waitFor() + await page.locator('[data-field="model"] input').fill('writer-large') + await page.waitForSelector('[data-verify-by-model]') + const o = await overflow(page, '[data-challenge-inspector]') + check(`390px(${theme}):检查器不横向溢出`, o.page <= 0 && o.box <= 0, JSON.stringify(o)) + await shot(page, `inspector-narrow-${theme}`) + check(`390px 检查器(${theme}):页面没有报错`, errors.length === 0, errors.join(';')) + await ctx.close() + } +}) + +function CSS_ESC(id) { + return String(id ?? '').replace(/([^a-zA-Z0-9_-])/g, '\\$1') +} + +await browser.close() +console.log(failed ? `\n✗ 反方审稿:${failed} 项未通过` : '\n✓ 反方审稿:全部通过') +process.exit(failed ? 1 : 0)