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 (
+
+ )
+}
+
+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)