From 69b70eceb3947af4dd10706e3082e38124cba411 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 12:02:03 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E8=AF=81=E6=8D=AE=E5=9B=BE=E7=9A=84?= =?UTF-8?q?=E6=AF=8F=E4=BB=BD=E6=8A=A5=E5=91=8A=E5=B8=A6=E4=B8=8A=E8=A2=AB?= =?UTF-8?q?=E5=8F=8D=E6=96=B9=E5=AE=A1=E8=BF=87=E7=9A=84=E5=8F=A5=E5=AD=90?= =?UTF-8?q?=20=E2=86=92=20=E5=88=A4=E6=96=AD=EF=BC=88challenge=5Fverdicts?= =?UTF-8?q?=EF=BC=89=EF=BC=9A=E6=AD=A3=E6=96=87=E6=8D=AE=E6=AD=A4=E7=BB=99?= =?UTF-8?q?=E8=A2=AB=E5=AE=A1=E5=8F=A5=E5=AD=90=E6=8C=82=E5=8F=A5=E6=9C=AB?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=EF=BC=8C=E4=B8=8D=E9=80=90=E5=8F=A5=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E7=89=87=E6=AE=B5=E6=8E=A5=E5=8F=A3=EF=BC=9B=E5=92=8C?= =?UTF-8?q?=E7=89=87=E6=AE=B5=E6=8E=A5=E5=8F=A3=E7=9A=84=20unit.challenge?= =?UTF-8?q?=20=E5=85=B1=E7=94=A8=E5=90=8C=E4=B8=80=E4=B8=AA=E8=AF=BB?= =?UTF-8?q?=E5=AE=A1=E7=A8=BF=E7=9A=84=E8=BF=AD=E4=BB=A3=EF=BC=88=5Freview?= =?UTF-8?q?s=EF=BC=89=EF=BC=8C=E6=AF=8F=E5=8F=A5=E5=8F=96=E6=9C=80?= =?UTF-8?q?=E5=90=8E=E8=B7=91=E5=AE=8C=E3=80=81=E5=AE=A1=E5=88=B0=E4=BA=86?= =?UTF-8?q?=E5=AE=83=E7=9A=84=E9=82=A3=E6=AC=A1=E5=AE=A1=E7=A8=BF=EF=BC=8C?= =?UTF-8?q?=E5=AE=A1=E7=A8=BF=E8=AE=B0=E5=BD=95=E4=B8=A2=E4=BA=86=E6=8C=89?= =?UTF-8?q?=E4=BA=A7=E5=87=BA=E9=87=8C=E5=88=97=E7=9A=84=E9=82=A3=E5=87=A0?= =?UTF-8?q?=E5=8F=A5=EF=BC=8C=E8=8A=82=E7=82=B9=E6=8A=A5=E9=94=99=E6=8C=89?= =?UTF-8?q?=E5=AE=A1=E5=AE=8C=E6=97=B6=E7=9A=84=E6=97=A5=E5=BF=97=EF=BC=9B?= =?UTF-8?q?=E4=B8=80=E5=8F=A5=E9=83=BD=E6=B2=A1=E5=AE=A1=E7=9A=84=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E6=B2=A1=E6=9C=89=E8=BF=99=E4=B8=AA=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/evidence.py | 73 +++- .../tests/test_evidence_challenge_marks.py | 343 ++++++++++++++++++ 2 files changed, 398 insertions(+), 18 deletions(-) create mode 100644 backend/tests/test_evidence_challenge_marks.py diff --git a/backend/app/api/evidence.py b/backend/app/api/evidence.py index 1ae739d8..a67d4e20 100644 --- a/backend/app/api/evidence.py +++ b/backend/app/api/evidence.py @@ -23,6 +23,8 @@ options.mask_columns 里的列换成「已遮罩」——在有身份体系之 期 4 的核查状态同样只读封存范围内的东西:指标步骤带双盲复算的结果(_dual_check_of,读双盲复算节点的产出和台账 条目),句子带反方审稿的判断(_challenge_of,读反方审稿节点的产出和审稿记录)。它们是核查,不是证据:没有就不带这两个键。 +证据图的每份报告另带一张被反方审过的句子 → 判断的表(_challenge_verdicts,和 _challenge_of 同一个读法),正文据此挂 +句末标记,不用逐句请求片段接口。 结论句的判定(四期)是模型给的,不是证据:正式运行的判定在报告文档里、随文档封存;探索运行 按需裁判(POST …/evidence/judge),判定作为 evidence.judged 事件追加在封存之后,只填补没判过的 @@ -38,7 +40,7 @@ import logging import re import sqlite3 from collections import OrderedDict -from collections.abc import Callable +from collections.abc import Callable, Iterator from dataclasses import asdict, dataclass, field, replace from pathlib import Path from typing import Any, get_args @@ -362,11 +364,14 @@ async def evidence_graph(run_id: str) -> dict[str, Any]: inputs = _input_payloads(sealed) for report in reports: + # 期 4:被反方审过的句子 → 判断,正文据此给它们挂句末标记。文档读不出来的不给;一句都没审的没有这个键 + marks = _challenge_verdicts(sealed, report) if report.doc is not None else {} body["reports"].append({ "node_id": report.node_id, "doc_artifact": report.doc_artifact, "doc_sealed": sealed.trusted, "hash_ok": report.hash_ok, "ok": report.ok, "repairs": report.repairs, "fields": report.fields, "stats": report.stats, **_judgement_of(report, sealed), + **({"challenge_verdicts": marks} if marks else {}), }) if report.doc is not None: evidence, edges = _graph_of(report, sealed, inputs) @@ -1151,32 +1156,29 @@ def _dual_status(node_id: str, output: dict[str, Any], entry: dict[str, Any] | N _CHALLENGE_VERDICTS = ("stands", "weakened", "refuted", "inconclusive") -def _challenge_of(sealed: _Sealed, report: _Report, unit_id: str) -> dict[str, Any] | None: - """这一句的反方审稿判断:{verdict, node_id, artifact, on_refuted, report_label, counts, unreviewed?}。没审到这一句的 - 是 None。 +def _reviews(sealed: _Sealed, report: _Report + ) -> Iterator[tuple[str, dict[str, Any], str | None, list[dict[str, Any]]]]: + """审的就是正文这一份报告文档的每一次反方审稿,按跑完的先后:(节点, 产出, 审稿记录工件, 逐句的判断)。逐句判断的 + 每一项至少是个 dict,unit、verdict 认不认得由调用方看。片段接口的 unit.challenge(_challenge_of)和证据图的 + challenge_verdicts(_challenge_verdicts)都从这里读,两边说的一定是同一次审稿。 - 只认审的就是正文这一份报告文档的:审稿记录(challenge_artifact)里记着它审的报告节点和文档工件,和这份对上才算—— + 只认审的就是这一份文档的:审稿记录(challenge_artifact)里记着它审的报告节点和文档工件,和这份对上才算—— 报告在审稿之后又重写了(循环、接着跑),同一个句子编号在新的一稿里说的可能是另一句话。记录没存上(产出里的 challenge_artifact 为空)或读不出来时,按画布上它审的报告、和它跑完之前那份报告最后一次自查记下的文档认,判断取 - 产出里列的那几句(最多 10 句,被推翻、被削弱的排在前面),artifact 照产出写(没存上的为空)。几个反方审稿审了同一 - 份文档时取最后跑完、审到了这一句的那个。on_refuted 是实际生效的处置,report_label 是报告节点的名字。 - - counts 是这次审稿四种判断各几句——整份报告的,取节点产出里的那一份;unreviewed 是没能反查的句数,产出里有才带。 - 证据面板借审批卡上的展示画这一句,那一块的概况得是这份报告真实的数,不能凭这一句拼出一个「被推翻 0 句」。 + 产出里列的那几句(最多 10 句,被推翻、被削弱的排在前面),审稿记录工件照产出写(没存上的为空)。 节点设为「报错并停止运行」、又有句子被推翻时,节点报错、没有 node.finished:审完那一刻的 challenge_done 日志记着 - 审稿记录和计数,按它认(只认审稿记录存上了的),on_refuted 为 fail。运行因此失败了,被推翻的是哪几句、反方凭什么, - 证据面板里照样看得到。 + 审稿记录和计数,按它认(只认审稿记录存上了的),产出拼成 {challenge_artifact, counts, on_refuted: fail}。运行因此 + 失败了,被推翻的是哪几句、反方凭什么,证据面板里照样看得到。 """ reviewers = set(sealed.nodes_of("challenge")) - if not reviewers or not unit_id: - return None + if not reviewers: + return spec: GraphSpec | None = None #: 报告节点 → 到这一刻为止它最后一次自查记下的文档 drafts: dict[str, str] = {} #: 反方审稿节点 → 这一次执行里审完时的日志:节点报错时它就是审稿结果 done: dict[str, dict[str, Any]] = {} - found: dict[str, Any] | None = None for _, etype, node_id, data in sealed.events: if not node_id or not isinstance(data, dict): continue @@ -1218,9 +1220,30 @@ def _challenge_of(sealed: _Sealed, report: _Report, unit_id: str) -> dict[str, A if target != report.node_id or drafts.get(target) != report.doc_artifact: continue claims = output.get("items") - claims = [c for c in claims or [] if isinstance(c, dict)] - verdict = next((c.get("verdict") for c in claims if c.get("unit") == unit_id), None) - if not isinstance(verdict, str) or not verdict: + yield node_id, output, artifact, [c for c in claims or [] if isinstance(c, dict)] + + +def _verdict_in(claims: list[dict[str, Any]], unit_id: str) -> str | None: + """一次审稿里这一句的判断:取列着这一句的第一条,判断不是一个词(缺了、空的、不是字符串)时这次审稿不算审到它。""" + verdict = next((c.get("verdict") for c in claims if c.get("unit") == unit_id), None) + return verdict if isinstance(verdict, str) and verdict else None + + +def _challenge_of(sealed: _Sealed, report: _Report, unit_id: str) -> dict[str, Any] | None: + """这一句的反方审稿判断:{verdict, node_id, artifact, on_refuted, report_label, counts, unreviewed?}。没审到这一句的 + 是 None。认哪一次审稿见 _reviews;几个反方审稿审了同一份文档时取最后跑完、审到了这一句的那个。artifact 是审稿记录 + (没存上的为空),on_refuted 是实际生效的处置(节点报错停止运行的是 fail),report_label 是报告节点的名字。 + + counts 是这次审稿四种判断各几句——整份报告的,取节点产出里的那一份;unreviewed 是没能反查的句数,产出里有才带。 + 证据面板单句展示时不画报告级的计数,可「无法判断」是不是因为反查没完成,得拿这两个数对一下。 + """ + if not unit_id: + return None + found: dict[str, Any] | None = None + spec: GraphSpec | None = None + for node_id, output, artifact, claims in _reviews(sealed, report): + verdict = _verdict_in(claims, unit_id) + if verdict is None: continue spec = spec or _spec_of(sealed) writer = spec.node_map().get(report.node_id) if spec else None @@ -1234,6 +1257,20 @@ def _challenge_of(sealed: _Sealed, report: _Report, unit_id: str) -> dict[str, A return found +def _challenge_verdicts(sealed: _Sealed, report: _Report) -> dict[str, str]: + """这份报告里被反方审过的句子 → 判断(证据图 reports[] 的 challenge_verdicts)。正文据此给被审过的句子挂句末标记、 + 点开整句面板:有的被审句子既没有可点的片段、也没有裁判判定,不靠这张表就打不开;也不用为了认出它们逐句请求片段接口。 + + 读法和 _challenge_of 一字不差(同一个 _reviews、同一个 _verdict_in):每一句取最后跑完、审到了它的那次审稿,所以表里 + 每一句的判断就是点开它时片段接口答的 unit.challenge.verdict。只有被审过的句子;一句都没审的是空表。 + """ + out: dict[str, str] = {} + for _, _, _, claims in _reviews(sealed, report): + units = dict.fromkeys(c.get("unit") for c in claims if isinstance(c.get("unit"), str) and c.get("unit")) + out.update({uid: verdict for uid in units if (verdict := _verdict_in(claims, uid)) is not None}) + return out + + def _challenge_counts(raw: Any, claims: list[dict[str, Any]]) -> dict[str, int]: """四种判断各几句:取节点产出(或审完时的日志)里的计数;一个数都认不出来时按认得的句子数(审稿记录里的是全部, 记录没存上时只有产出里列的那几句)。""" diff --git a/backend/tests/test_evidence_challenge_marks.py b/backend/tests/test_evidence_challenge_marks.py new file mode 100644 index 00000000..79bf33ff --- /dev/null +++ b/backend/tests/test_evidence_challenge_marks.py @@ -0,0 +1,343 @@ +"""证据图上的反方审稿标记(期 4 整合 J3):GET /api/runs/{id}/evidence 的 reports[] 带 challenge_verdicts({句子: 判断})。 + +正文(前端 EvidenceDoc)据此给被反方审过的句子挂句末标记、点开整句面板,不逐句请求片段接口。有的被审句子既没有可点的 +片段、也没有裁判判定(没开裁判的报告里一句纯文字的结论),不靠这张表,正文里就打不开它的整句面板。 + +- 只含被反方审过的句子;读法和片段接口的 unit.challenge(_challenge_of)是同一个来源:审稿记录里记着审的哪份报告文档, + 和这一份对上才算;记录没存上时按画布上审的报告、和它跑完之前那份报告最后一次自查的文档认,判断取产出里列的那几句; + 节点「报错并停止运行」时按审完那一刻的日志认。几个反方审稿审了同一份文档时,每一句取最后跑完、审到了它的那个 +- 每一句的判断和点开它时片段接口答的 unit.challenge.verdict 一字不差 +- 没有反方审稿(或者它直通了、一句都没审)的报告没有这个键,老运行和以前一字不差 + +反方审稿真跑一遍图(虚构的 SQLite 库,模型是 MockChatModel 剧本);认「哪一次」的边界情形用手写的封存事件。 +""" +from __future__ import annotations + +import asyncio +import json +import sqlite3 +import uuid + +import pytest +from httpx import ASGITransport, AsyncClient +from langchain_core.messages import AIMessage, ToolMessage +from sqlalchemy import delete + +from app.api import evidence +from app.core import artifact_store +from app.core.artifact_store import put_json +from app.data import introspect +from app.data.engine import engines +from app.db.base import SessionLocal +from app.db.models import DataSource, Run, Setting +from app.engine import spend, verify_agent +from app.engine.evidence import iter_units +from app.engine.judge import SPEND_KEY as JUDGE_SPEND_KEY +from app.engine.nodes import challenge as challenge_mod +from app.engine.runner import run_manager +from app.main import app +from app.providers.mock_model import MockChatModel + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + + async def clear() -> None: + async with SessionLocal() as session: + await session.execute(delete(Setting).where(Setting.key.in_( + ["verify", "judge", spend.SPEND_KEY, JUDGE_SPEND_KEY]))) + await session.commit() + + await clear() + yield + await clear() + await run_manager.shutdown() + + +@pytest.fixture +async def client(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +def node(nid, ntype, label=None, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}} + + +def chain(*nodes): + return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +async def start(spec: dict) -> Run: + run_id = (await run_manager.start(graph=spec, input_payload={}, run_class="formal")).id + for _ in range(600): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run_id) + if row.status in ("succeeded", "failed") and row.manifest_seq is not None: + await run_manager.wait_idle(run_id) + return row + raise AssertionError(f"运行 {run_id} 停在 {row.status}:{row.error}") + + +# -------------------------------------------------------------------------- +# 真跑:虚构的区域月报 +# -------------------------------------------------------------------------- + +PRICED = "claude-sonnet-5" +#: 各区销售额(含退款):北区 1600、南区 700、西区 300 +QUERY = "SELECT region AS 区域, SUM(amount) AS 销售额 FROM sales GROUP BY region ORDER BY 销售额 DESC" +NET = "SELECT region AS 区域, SUM(amount) AS 净销售额 FROM sales WHERE refunded = 0 GROUP BY region ORDER BY 净销售额 DESC" +REFUNDS = "SELECT COUNT(*) AS 退款笔数 FROM sales WHERE refunded = 1" +#: 标题、一句有依据的结论、一句预埋的错误结论(南区其实低于北区)。后一句只有文字:没有可点的片段 +TEXT = "## 区域月报\n\n北区销售额 [[v:Q1.r0.销售额]],居各区之首。[[see:Q1]]南区销售额超过北区。[[see:Q1]]" +NORTH, SOUTH = "北区销售额 1,600,居各区之首。", "南区销售额超过北区。" +PLAYS = { + "南区销售额超过北区": ([NET], "refuted", "扣除退款后南区为 [[v:Q1.r1.净销售额]],仍低于北区的 [[v:Q1.r0.净销售额]]。[[see:Q1]]"), + "居各区之首": ([REFUNDS], "weakened", "北区含 [[v:Q1.r0.退款笔数]] 笔退款,扣除后仍居首,但销售额被高估。"), +} + + +@pytest.fixture +async def sales(tmp_path): + path = tmp_path / f"{uuid.uuid4().hex[:8]}.db" + db = sqlite3.connect(path) + db.executescript( + "CREATE TABLE sales (id INTEGER PRIMARY KEY, region TEXT, amount INTEGER, refunded INTEGER);" + "INSERT INTO sales VALUES (1, '北区', 1250, 0), (2, '北区', 350, 1), (3, '南区', 700, 0), (4, '西区', 300, 0);") + db.commit() + db.close() + name = f"months_{uuid.uuid4().hex[:8]}" + async with SessionLocal() as session: + row = DataSource(name=name, kind="sqlite", database=str(path), readonly=True, enabled=True, options={}) + session.add(row) + await session.flush() + row.schema_cache = await introspect.introspect(row) + await session.commit() + source_id = row.id + yield name + await engines.invalidate(source_id) + + +def reviewer(monkeypatch, source: str) -> None: + """写作者交 TEXT;反方 agent 按任务里的结论认出是哪一句,照 PLAYS 查询、交结论和理由。""" + + def decide(model, messages): + if challenge_mod.CHALLENGE_SYSTEM not in str(messages[0].content): + return AIMessage(content=TEXT) + task = str(messages[1].content) if len(messages) > 1 else "" + queries, verdict, rationale = next((p for k, p in PLAYS.items() if k in task), ([], "stands", "没有找到反例。")) + if model.response_format: + return AIMessage(content=json.dumps({"verdict": verdict, "rationale": rationale}, ensure_ascii=False)) + done = sum(1 for m in messages if isinstance(m, ToolMessage)) + if done < len(queries): + call = {"name": f"db_query__{source}", "args": {"sql": queries[done]}, + "id": f"q{done}-{uuid.uuid4().hex[:6]}"} + return AIMessage(content="查一下。", tool_calls=[call]) + return AIMessage(content="查完了。") + + monkeypatch.setattr(MockChatModel, "_decide", decide) + + async def verify_model(session, spec): + return MockChatModel(model_name=PRICED, latency=0.0), PRICED + + monkeypatch.setattr(verify_agent, "get_chat_model", verify_model) + + +def challenge_graph(source: str, *, challenge: bool = True, **config) -> dict: + return chain( + node("start", "input"), + node("fetch", "tool", tool=f"db_query__{source}", args={"sql": QUERY}), + node("write", "report", "写月报", instructions="写月报"), + *([node("chal", "challenge", "审稿", report="write", **config)] if challenge else []), + node("out", "output", fields=[{"name": "月报", "value": "{{ nodes.write.text }}"}], + contract={"report_from": "write"}), + ) + + +async def graph_report(client, run_id: str) -> dict: + res = await client.get(f"/api/runs/{run_id}/evidence") + assert res.status_code == 200, res.text + [report] = res.json()["reports"] + return report + + +def units_of(doc: dict) -> dict[str, dict]: + """句子编号 → (原文, 第一个不是结构性的片段):点开整句时前端请求的就是这个片段。""" + md = doc["markdown"] + out = {} + for _, unit in iter_units(doc): + first = next((s for s in unit.get("segments") or [] if s.get("kind") != "structural"), None) + start, end = unit["span"] + out[unit["id"]] = {"text": md[start:end].strip(), "kind": unit.get("kind"), "first": first} + return out + + +async def same_as_segments(client, run_id: str, report: dict) -> dict[str, str]: + """每一句的标记和片段接口答的 unit.challenge.verdict 一字不差(没审到的两边都没有)。返回 原文 → 判断。""" + doc = artifact_store.load(report["doc_artifact"]) + marks = report.get("challenge_verdicts", {}) + said = {} + for uid, unit in units_of(doc).items(): + if unit["first"] is None: + continue + res = await client.get(f"/api/runs/{run_id}/evidence/segments/{unit['first']['id']}") + assert res.status_code == 200, res.text + verdict = (res.json()["unit"].get("challenge") or {}).get("verdict") + assert marks.get(uid) == verdict, f"{unit['text']}:证据图 {marks.get(uid)!r} ≠ 片段接口 {verdict!r}" + if verdict: + said[unit["text"]] = verdict + assert set(marks) <= set(units_of(doc)), "证据图标了文档里没有的句子" + return said + + +async def test_the_graph_marks_every_challenged_sentence(client, monkeypatch, sales): + reviewer(monkeypatch, sales) + run = await start(challenge_graph(sales)) + assert run.status == "succeeded", run.error + report = await graph_report(client, run.id) + doc = artifact_store.load(report["doc_artifact"]) + units = units_of(doc) + by_text = {u["text"]: uid for uid, u in units.items()} + # 只含被审过的两句:标题、没被挑来审的句子不在里面 + assert report["challenge_verdicts"] == {by_text[NORTH]: "weakened", by_text[SOUTH]: "refuted"} + heading = next(uid for uid, u in units.items() if u["kind"] == "heading") + assert heading not in report["challenge_verdicts"] + # 被推翻的那句只有文字:没有一个可点的片段,正文要靠这张表才挂得出入口 + south = next(u for b in doc["blocks"] for u in b["units"] if u["id"] == by_text[SOUTH]) + assert all(seg["kind"] in ("text", "structural") for seg in south["segments"]) + assert await same_as_segments(client, run.id, report) == {NORTH: "weakened", SOUTH: "refuted"} + + +async def test_a_lost_review_record_marks_the_listed_sentences(client, monkeypatch, sales): + """审稿记录没存上:按产出里列的那几句标,和片段接口同一个来源。""" + async def lost(self, record): + return None + + monkeypatch.setattr(challenge_mod._Review, "_store", lost) + reviewer(monkeypatch, sales) + run = await start(challenge_graph(sales)) + assert run.status == "succeeded", run.error + report = await graph_report(client, run.id) + assert sorted(report["challenge_verdicts"].values()) == ["refuted", "weakened"] + assert await same_as_segments(client, run.id, report) == {NORTH: "weakened", SOUTH: "refuted"} + + +async def test_a_review_that_stopped_the_run_still_marks(client, monkeypatch, sales): + """「结论被推翻时」设为报错并停止运行:节点报错、运行失败,按审完那一刻的日志认审稿记录,被审的句子照样有标记。""" + reviewer(monkeypatch, sales) + run = await start(challenge_graph(sales, on_refuted="fail")) + assert run.status == "failed" and "推翻" in (run.error or ""), run.error + report = await graph_report(client, run.id) + assert await same_as_segments(client, run.id, report) == {NORTH: "weakened", SOUTH: "refuted"} + + +@pytest.mark.parametrize("challenge", [False, True]) +async def test_reports_nobody_reviewed_have_no_marks(client, monkeypatch, sales, challenge): + """没有反方审稿、或者反方审稿关着(直通,一句都没审):没有这个键,和以前一字不差。""" + reviewer(monkeypatch, sales) + run = await start(challenge_graph(sales, challenge=challenge, **({"enabled": False} if challenge else {}))) + assert run.status == "succeeded", run.error + assert "challenge_verdicts" not in await graph_report(client, run.id) + + +# -------------------------------------------------------------------------- +# 认「哪一次」:手写的封存事件 +# -------------------------------------------------------------------------- + + +def sealed_of(graph: dict, events: list[tuple]) -> evidence._Sealed: + return evidence._Sealed(run_id="syn-run", graph=graph, events=events, + seal={"sealed": True, "ok": True, "manifest_seq": events[-1][0], "legacy": False}) + + +def report(doc_artifact: str) -> evidence._Report: + return evidence._Report(node_id="write", doc_artifact=doc_artifact, ok=True, repairs=0, stats=None, doc=None, + hash_ok=True, fields=[]) + + +def claim(unit, verdict, text="一句结论。"): + return {"unit": unit, "text": text, "verdict": verdict, "rationale": "", "rationale_doc": None, "queries": []} + + +async def review(doc: str, claims: list[dict], *, saved: bool = True, on_refuted: str = "degrade") -> str: + """一次反方审稿的产出工件(审稿记录存上了的带 challenge_artifact)。""" + record = await put_json({"report": "write", "doc_artifact": doc, "model": "mock", "claims": claims}, + kind="challenge") if saved else None + counts = {v: sum(1 for c in claims if c["verdict"] == v) for v in ("stands", "weakened", "refuted", "inconclusive")} + items = [{k: c[k] for k in ("unit", "text", "verdict")} for c in claims] + return await put_json({"challenge_artifact": record, "counts": counts, "on_refuted": on_refuted, "items": items}, + kind="node_output") + + +def consistent(sealed: evidence._Sealed, doc: str, units: list[str]) -> dict[str, str]: + """标记表和逐句的 _challenge_of 一致:每一句的判断相同,没审到的两边都没有。""" + marks = evidence._challenge_verdicts(sealed, report(doc)) + for uid in units: + one = evidence._challenge_of(sealed, report(doc), uid) + assert marks.get(uid) == (one or {}).get("verdict"), f"{uid}:{marks.get(uid)!r} ≠ {one!r}" + return marks + + +@pytest.mark.parametrize("saved", [True, False]) +async def test_a_review_of_an_earlier_draft_does_not_mark_the_rewritten_report(saved): + """报告在反方审稿之后又重写了一次:审的是前一稿,新的一稿上同一个句子编号说的可能是另一句话,不标。""" + draft = await put_json({"markdown": "第一稿", "n": 11}, kind="report_doc") + final = await put_json({"markdown": "第二稿", "n": 12}, kind="report_doc") + out = await review(draft, [claim("u1", "refuted"), claim("u2", "stands")], saved=saved) + graph = chain(node("write", "report", "写月报"), node("chal", "challenge", "审稿", report="write")) + events = [ + (1, "report.checked", "write", {"doc_artifact": draft, "ok": True}), + (2, "node.finished", "write", {}), + (3, "node.finished", "chal", {"artifact": out}), + (4, "report.checked", "write", {"doc_artifact": final, "ok": True}), + ] + sealed = sealed_of(graph, events) + assert consistent(sealed, draft, ["u1", "u2", "u3"]) == {"u1": "refuted", "u2": "stands"} + assert consistent(sealed, final, ["u1", "u2"]) == {} + + +async def test_each_sentence_takes_the_last_review_that_reached_it(): + """两个反方审稿审了同一份文档:每一句取最后跑完、审到了它的那个(同片段接口);只有前一个审到的句子照样标。""" + doc = await put_json({"markdown": "月报", "n": 13}, kind="report_doc") + first = await review(doc, [claim("u1", "refuted"), claim("u2", "stands")]) + second = await review(doc, [claim("u2", "weakened"), claim("u3", "inconclusive")]) + graph = chain(node("write", "report", "写月报"), node("chal1", "challenge", "审稿一", report="write"), + node("chal2", "challenge", "审稿二", report="write")) + events = [ + (1, "report.checked", "write", {"doc_artifact": doc, "ok": True}), + (2, "node.finished", "chal1", {"artifact": first}), + (3, "node.finished", "chal2", {"artifact": second}), + ] + marks = consistent(sealed_of(graph, events), doc, ["u1", "u2", "u3", "u4"]) + assert marks == {"u1": "refuted", "u2": "weakened", "u3": "inconclusive"} + + +async def test_a_review_that_failed_the_node_marks_from_its_record(): + """节点报错:按审完那一刻的日志认审稿记录;重跑之后没再审完的不算,日志里没有审稿记录的认不出审的是哪一稿,也不算。""" + doc = await put_json({"markdown": "月报", "n": 14}, kind="report_doc") + record = await put_json({"report": "write", "doc_artifact": doc, "model": "mock", + "claims": [claim("u1", "refuted"), claim("u2", "weakened")]}, kind="challenge") + graph = chain(node("write", "report", "写月报"), node("chal", "challenge", "审稿", report="write", + on_refuted="fail")) + counts = {"stands": 0, "weakened": 1, "refuted": 1, "inconclusive": 0} + head = [(1, "report.checked", "write", {"doc_artifact": doc, "ok": True}), (2, "node.started", "chal", {})] + done = (3, "log", "chal", {"code": "challenge_done", "counts": counts, "artifact": record}) + failed = (4, "node.failed", "chal", {"error": "反方审稿推翻了 1 句结论"}) + assert consistent(sealed_of(graph, [*head, done, failed]), doc, ["u1", "u2"]) == {"u1": "refuted", "u2": "weakened"} + retried = [*head, done, (4, "node.started", "chal", {}), (5, "node.failed", "chal", {"error": "模型不可用"})] + assert consistent(sealed_of(graph, retried), doc, ["u1", "u2"]) == {} + lost = [*head, (3, "log", "chal", {"code": "challenge_done", "counts": counts, "artifact": None}), failed] + assert consistent(sealed_of(graph, lost), doc, ["u1", "u2"]) == {} + + +async def test_unreadable_verdicts_are_left_out(): + """判断不是一个词的(缺了、空的、不是字符串)不标;同一次审稿里一句列了两遍时取前一条(同片段接口)。""" + doc = await put_json({"markdown": "月报", "n": 15}, kind="report_doc") + out = await review(doc, [claim("u1", None), claim("u2", ""), claim("u3", 3), claim("u4", "stands"), + claim("u4", "refuted"), claim("", "refuted"), claim(None, "refuted")]) + graph = chain(node("write", "report", "写月报"), node("chal", "challenge", "审稿", report="write")) + events = [(1, "report.checked", "write", {"doc_artifact": doc, "ok": True}), + (2, "node.finished", "chal", {"artifact": out})] + assert consistent(sealed_of(graph, events), doc, ["u1", "u2", "u3", "u4", ""]) == {"u4": "stands"} From 573313cbc20f2353cfd2103d7dbb7a8bb564e27a Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 13:01:17 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E8=AF=81=E6=8D=AE=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E7=9A=84=E6=A0=B8=E6=9F=A5=E7=8A=B6=E6=80=81=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E6=8C=AA=E8=BF=9B=20types.ts=EF=BC=8C=E8=AF=81=E6=8D=AE?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E7=9A=84=E5=8F=8D=E6=96=B9=E5=AE=A1=E7=A8=BF?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E5=8D=95=E5=8F=A5=E5=B1=95=E7=A4=BA=EF=BC=9A?= =?UTF-8?q?EvidenceDualCheck=E3=80=81EvidenceUnitChallenge=E3=80=81?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E6=AD=A5=E9=AA=A4=E7=9A=84=20masked=20/=20ma?= =?UTF-8?q?sk=5Funverified=E3=80=81=E8=AF=81=E6=8D=AE=E5=9B=BE=E7=9A=84=20?= =?UTF-8?q?challenge=5Fverdicts=20=E7=85=A7=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E9=80=90=E5=AD=97=E6=A0=B8=E5=AF=B9=EF=BC=9B=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E5=8D=A1=E4=B8=8A=E7=9A=84=E6=A6=82=E5=86=B5=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E8=A1=A5=E4=B8=8A=20unreviewed=E3=80=81degrades=E3=80=82Challe?= =?UTF-8?q?ngeSummary=20=E5=8A=A0=E5=8D=95=E5=8F=A5=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=EF=BC=88scope=3Dclaim=EF=BC=8C=E5=8F=AA=E7=94=BB=E8=BF=99?= =?UTF-8?q?=E4=B8=80=E5=8F=A5=E7=9A=84=E5=88=A4=E6=96=AD=E3=80=81=E5=AE=83?= =?UTF-8?q?=E5=8F=AF=E8=83=BD=E6=B2=A1=E5=8F=8D=E6=9F=A5=E6=88=90=E7=9A=84?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E3=80=81=E5=B1=95=E5=BC=80=E7=9C=8B=E7=90=86?= =?UTF-8?q?=E7=94=B1=E5=92=8C=E6=9F=A5=E8=AF=A2=EF=BC=8C=E4=B8=8D=E7=94=BB?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E7=BA=A7=E7=9A=84=E6=A0=87=E9=A2=98=E5=92=8C?= =?UTF-8?q?=E8=AE=A1=E6=95=B0=EF=BC=89=E5=92=8C=E6=98=BE=E5=BC=8F=E7=9A=84?= =?UTF-8?q?=E3=80=8C=E4=B8=8D=E8=B0=88=E5=87=BA=E5=85=B7=E3=80=8D=E5=BC=80?= =?UTF-8?q?=E5=85=B3=EF=BC=88issuance=3Dfalse=EF=BC=8C=E4=B8=8D=E9=A2=84?= =?UTF-8?q?=E5=91=8A=E9=99=8D=E6=A1=A3=E3=80=81=E4=B9=9F=E4=B8=8D=E4=B8=BA?= =?UTF-8?q?=E6=AD=A4=E5=8F=96=E8=BF=90=E8=A1=8C=E7=B1=BB=E5=88=AB=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E8=AF=81=E6=8D=AE=E9=9D=A2=E6=9D=BF=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E8=BF=99=E4=B8=A4=E6=A0=B7=EF=BC=8C=E5=8E=BB=E6=8E=89=E4=BC=A0?= =?UTF-8?q?=20runId=20=E4=B8=BA=E7=A9=BA=E7=9A=84=E5=81=9A=E6=B3=95?= =?UTF-8?q?=EF=BC=9Bcheck-evidence=20=E7=9A=84=E6=A0=B8=E6=9F=A5=E4=B8=80?= =?UTF-8?q?=E8=8A=82=E8=B7=9F=E7=9D=80=E6=94=B9=E6=88=90=E5=8D=95=E5=8F=A5?= =?UTF-8?q?=E7=9A=84=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/lib/terms.ts | 14 ++ frontend/src/run/ChallengeSummary.tsx | 178 +++++++++++++++++++------- frontend/src/run/EvidencePanel.tsx | 97 ++++++-------- frontend/src/types.ts | 73 ++++++++++- scripts/check-evidence.mjs | 21 +-- 5 files changed, 264 insertions(+), 119 deletions(-) diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 8ea7b9fb..e8a4013a 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3954,6 +3954,20 @@ export const CHALLENGE_TEXT = { /** 同上,没有可跳的查询时(双盲复算的理由)只念出处 */ citedPlain: (source: string) => `(出处:${source})`, + // ---- 证据面板的单句展示(ChallengeSummary 的 scope="claim")与正文的句末标记(run/EvidenceDoc)---- + /** 证据面板里这一节的标题 */ + claimTitle: '反方审稿', + /** 单句展示:展开反方对这一句的理由和它执行过的查询 */ + claimDetail: '反方的理由与查询', + /** 这一句判为无法判断,而这份报告无法判断的句子全是没反查成的:这一句就是 */ + claimUnreviewed: '这一句未能反查,已记为无法判断', + /** 同上,但无法判断的句子里还有查过了、数据不够的:说不准是不是这一句 */ + claimMaybeUnreviewed: (n: number) => `这份报告有 ${formatNumber(n)} 句未能反查,已记为无法判断,这一句可能在其中`, + /** 正文句末标记的悬停说明:判断是反方模型给的 */ + markTitle: (verdict: string) => `反方审稿:${verdict}(反方模型的判断,并非系统核对)`, + /** 句末标记读屏念的那一段:跟在句子后面(只有反方审稿时),或接在裁判的判定后面(两样都有时,并在一个标记里) */ + markLabel: (verdict: string) => `反方审稿:${verdict}`, + // ---- 时间线(run/decode.ts explainLog):按日志的 code 和结构化字段说,不从原话里认 log: { skippedDisabled: '反方审稿未启用,已跳过', diff --git a/frontend/src/run/ChallengeSummary.tsx b/frontend/src/run/ChallengeSummary.tsx index 1e850522..a0a0db4d 100644 --- a/frontend/src/run/ChallengeSummary.tsx +++ b/frontend/src/run/ChallengeSummary.tsx @@ -11,58 +11,73 @@ import type { import { BlockView, CopyChip, Markdown, StyledSlice, inlineStyles, numericColumns } from './Markdown' export interface ChallengeSummaryProps { - /** 这张审批卡审的报告(payload.reports,和 ApprovalReports 拿到的是同一份) */ + /** + * 审的报告:审批卡是 payload.reports(和 ApprovalReports 拿到的是同一份);证据面板是按片段接口的 unit.challenge 拼的 + * 一份,条目只有这一句 + */ reports: ApprovalReport[] - runId: string + /** 运行 id:要谈出具、又没给 runClass 时按它取一次运行类别 */ + runId?: string /** * 这次运行的类别(审批自带的 approval.run_class,同 ApprovalReports 的 runClass)。给了就照它说、不再另取;没给 * (老服务端的审批没有这一项)才按运行 id 取一次 */ runClass?: string | null + /** + * report(默认):报告级的一块,审批卡用——标题、四种判断各几句、逐句列出、点开一句看理由和查询。 + * claim:单句,证据面板用——只有这一句的判断、它可能没反查成的说明、展开看理由和查询,不画报告级的标题、计数和别的句子 + * (面板里这一句就在正文上方;计数说的是整份报告,放在一句话的面板里读着像这一句的结果)。只取条目里的第一条 + */ + scope?: 'report' | 'claim' + /** + * 谈不谈出具(「出具会降档」、只标注、不在出具路径上)。默认谈:审批卡上运行还没出具,预告有用。证据面板看的是已经结束 + * 的运行,降没降档、出没出具以报告的出具横幅(封存的出具判定)为准,再说一遍将来时的预告只会和横幅对不上(驳回了、 + * 不予出具、反方审稿不在成果节点上游……):传 false,一句和出具有关的话都不说,也不为此去取运行类别 + */ + issuance?: boolean } /** - * 审批卡里反方审稿的摘要,紧跟在报告核对概况(ApprovalReports)后面:一行概况(四种判断各几句),出具相关的那一句, - * 有句子反查没完成时再说一行「N 句未能反查」,逐句列出结论原文和判断,点开一句再按 challenge_artifact 取完整内容,看反方 - * 的理由和它执行过的查询。审稿记录没存上(没有工件)时只列判断、不能点开,整块说一次。 + * 反方审稿的摘要。两种摆法: + * - 审批卡(scope 为 report),紧跟在报告核对概况(ApprovalReports)后面:一行概况(四种判断各几句),出具相关的那一句, + * 有句子反查没完成时再说一行「N 句未能反查」,逐句列出结论原文和判断,点开一句再按 challenge_artifact 取完整内容,看 + * 反方的理由和它执行过的查询。审稿记录没存上(没有工件)时只列判断、不能点开,整块说一次。 + * - 证据面板(scope 为 claim、issuance 为 false):一句话的判断,展开同样按 challenge_artifact 取这一句的理由和查询。 * * 判断是反方模型给的,不是系统核对:颜色照结论句裁判的那一套,「经受住」也不用确定性的绿。 - * 「出具会降档」只在正式运行、有被推翻的结论、被推翻的真会让出具降档时才写:服务端给了 degrades 就照它(按降档处置、又接 - * 在带出具契约的成果节点上游才为真),老服务端没给时按「没写只标注」算。只标注的写一句不影响出具,按降档处置却不在出具 - * 路径上的写一句为什么不影响;探索运行不出具,和出具有关的话一句不说。运行类别照审批卡给的(runClass,审批自带的那一项, - * 卡顶写的也是它);没给时按运行 id 从证据 store 取(loadRunClass),取到之前、取不到时都不说和出具有关的话(窗口重新 - * 拿到焦点时再取)。 + * 「出具会降档」只在谈出具(issuance)、正式运行、有被推翻的结论、被推翻的真会让出具降档时才写:服务端给了 degrades 就照它 + * (按降档处置、又接在带出具契约的成果节点上游才为真),老服务端没给时按「没写只标注」算。只标注的写一句不影响出具,按降档 + * 处置却不在出具路径上的写一句为什么不影响;探索运行不出具,和出具有关的话一句不说。运行类别照审批卡给的(runClass,审批 + * 自带的那一项,卡顶写的也是它);没给时按运行 id 从证据 store 取(loadRunClass),取到之前、取不到时都不说和出具有关的话 + * (窗口重新拿到焦点时再取)。 * * 审的报告都没有经过反方审稿时什么都不画,也不发任何请求,卡片和以前一样。 */ -/** - * 审批卡上的反方审稿概况,约定之外服务端另带两项(期 4):unreviewed 未能反查的句数,degrades 被推翻的会不会让出具降档。 - * 老服务端都不给 - */ -type Challenge = ApprovalChallenge & { unreviewed?: number; degrades?: boolean } type RunClass = 'formal' | 'exploratory' const runClassOf = (v: unknown): RunClass | null => (v === 'formal' || v === 'exploratory' ? v : null) -export function ChallengeSummary({ reports, runId, runClass }: ChallengeSummaryProps) { +export function ChallengeSummary({ reports, runId = '', runClass, scope = 'report', issuance = true }: ChallengeSummaryProps) { const reviewed = useMemo(() => reports.flatMap((r) => { const challenge = challengeOf(r) return challenge ? [{ report: r, challenge }] : [] }), [reports]) if (!reviewed.length) return null - return + return } -function ChallengeList({ reviewed, runId, given }: { - reviewed: { report: ApprovalReport; challenge: Challenge }[]; runId: string; given: RunClass | null +function ChallengeList({ reviewed, runId, given, scope, issuance }: { + reviewed: { report: ApprovalReport; challenge: ApprovalChallenge }[]; runId: string; given: RunClass | null + scope: 'report' | 'claim'; issuance: boolean }) { - // 审批卡给了运行类别就照它,不另取:另取的那一次失败了,正式运行里的「出具会降档」会无端消失,而卡顶照样写着正式运行 - const fetched = useRunClass(given ? '' : runId) - const run = given ?? fetched + // 审批卡给了运行类别就照它,不另取:另取的那一次失败了,正式运行里的「出具会降档」会无端消失,而卡顶照样写着正式运行。 + // 不谈出具的连取都不取:运行类别只用来决定说不说出具 + const fetched = useRunClass(given || !issuance ? '' : runId) + const run = issuance ? given ?? fetched : null return ( <> - {reviewed.map(({ report, challenge }) => ( - - ))} + {reviewed.map(({ report, challenge }) => (scope === 'claim' + ? + : ))} ) } @@ -92,7 +107,7 @@ const count = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v * payload.reports[i].challenge 认不认得:要么有计数、要么有条目。计数缺了按条目数(条目最多 10 条,计数才是全部); * 条目缺了 unit 的留空(行的键带序号,区分得开),不补假的:补出来的键会被当成真的 unit 去和工件里的句子比 */ -function challengeOf(report: ApprovalReport): Challenge | null { +function challengeOf(report: ApprovalReport): ApprovalChallenge | null { const raw = report.challenge as unknown if (!raw || typeof raw !== 'object') return null const c = raw as Record @@ -113,6 +128,19 @@ function challengeOf(report: ApprovalReport): Challenge | null { } } +/** + * 出具相关的那一句说什么(run 为 null——不谈出具、不是正式运行、类别还不知道——时一句都不说): + * degrade 会降档(写「出具会降档」和原因);off 按降档处置却不在出具路径上(写为什么不影响);flag 只标注(写不影响出具档位) + */ +type Issuance = 'degrade' | 'off' | 'flag' | null +function issuanceOf(challenge: ApprovalChallenge, run: RunClass | null): Issuance { + if (run !== 'formal' || challenge.counts.refuted <= 0) return null + // 没给 on_refuted(老服务端)按降档;报错的节点到不了审批,不会是 fail + if (challenge.on_refuted === 'flag') return 'flag' + // 被推翻的真会让出具降档:服务端给了就照它,老服务端没给时按「不是只标注」算 + return (challenge.degrades ?? true) ? 'degrade' : 'off' +} + /** * 这次运行的类别(审批卡没给时才取,runId 为空就不取):取到之前、取不到时为 null——不知道是不是正式运行,就一句和出具 * 有关的话都不说。取失败了不就此作罢:窗口重新拿到焦点时再取一次(store 对出错的槽会重发),取到了正式运行,降档的提示 @@ -222,7 +250,7 @@ const focusLost = () => !document.activeElement || document.activeElement === do // ---------------------------------------------------------------- 卡片 function ChallengeBlock({ report, challenge, run }: { - report: ApprovalReport; challenge: Challenge; run: RunClass | null + report: ApprovalReport; challenge: ApprovalChallenge; run: RunClass | null }) { const [open, setOpen] = useState>(() => new Set()) const [all, setAll] = useState(false) @@ -231,11 +259,7 @@ function ChallengeBlock({ report, challenge, run }: { const total = VERDICTS.reduce((n, v) => n + counts[v], 0) // 审批里没带工件:服务端保存审稿记录失败了。每句都看不了理由和查询,不画成能点开的样子,整块说一次 const noRecord = !challenge.artifact - const refuted = counts.refuted > 0 && run === 'formal' - // 没给 on_refuted(老服务端)按降档;报错的节点到不了审批,不会是 fail - const flagged = challenge.on_refuted === 'flag' - // 被推翻的真会让出具降档:服务端给了就照它,老服务端没给时按「不是只标注」算 - const degrades = challenge.degrades ?? !flagged + const said = issuanceOf(challenge, run) // 未能反查的每一句都记为无法判断(服务端的约定),不会多于概况里的「无法判断」:对不上时按概况的数说,两个数不打架 const unreviewed = Math.min(challenge.unreviewed ?? 0, counts.inconclusive) // 条目之外的几句(条目最多 10 条):要看全部时从工件里补上,排在后面,顺序照工件 @@ -284,27 +308,14 @@ function ChallengeBlock({ report, challenge, run }: { ))} - {refuted && !flagged && degrades && ( - - {CHALLENGE_TEXT.degrade} - - )} + {said === 'degrade' && } ) : (
{CHALLENGE_TEXT.none}
)} {/* 降档的原因常显,不放在悬停提示里:触屏、键盘用户也得看得到为什么、会怎样(只标注那一档同样常显一行)。 紧跟在概况那一行(「出具会降档」的标记)下面,中间不插别的话 */} - {refuted && !flagged && degrades && ( -
{CHALLENGE_TEXT.degradeHint}
- )} - {refuted && !flagged && !degrades && ( -
{CHALLENGE_TEXT.degradeOff}
- )} - {refuted && flagged && ( -
{CHALLENGE_TEXT.flagNote}
- )} + {/* 反查没完成的几句:「无法判断」里分不出查了数据不够和反查没做完,单独说一次 */} {unreviewed > 0 && (

@@ -365,6 +376,79 @@ function ChallengeBlock({ report, challenge, run }: { ) } +/** 「出具会降档」的醒目标记:跟在概况(单句时跟在判断)后面,原因由 IssuanceNote 常显在下一行 */ +function DegradeChip() { + return ( + + {CHALLENGE_TEXT.degrade} + + ) +} + +/** 出具相关的那一行:会降档的原因、不在出具路径上为什么不影响、只标注不影响出具档位。不谈出具时什么都不画 */ +function IssuanceNote({ said }: { said: Issuance }) { + const note = 'mt-0.5 text-2xs leading-relaxed text-dim' + if (said === 'degrade') return

{CHALLENGE_TEXT.degradeHint}
+ if (said === 'off') return
{CHALLENGE_TEXT.degradeOff}
+ if (said === 'flag') return
{CHALLENGE_TEXT.flagNote}
+ return null +} + +/** + * 单句(证据面板):这一句的判断;判为无法判断、这份报告又有句子未能反查时说一句它(可能)没反查成;展开看反方的理由和 + * 它执行过的查询(按 challenge_artifact 取,和审批卡共用一份缓存);最后写明判断出自反方模型。不画报告级的标题、四种 + * 判断的计数和别的句子,也不列这一句的原文(面板上方就是这一句)。审稿记录没存上时不给展开,说一次为什么 + */ +function ClaimBlock({ report, challenge, run }: { + report: ApprovalReport; challenge: ApprovalChallenge; run: RunClass | null +}) { + const [open, setOpen] = useState(false) + const detail = useChallengeArtifact(challenge.artifact ?? '', open) + const detailId = useId() + const item = challenge.items[0] + if (!item) return null + const noRecord = !challenge.artifact + const said = issuanceOf(challenge, run) + // 未能反查的每一句都记为无法判断(服务端的约定):这一句判为无法判断、报告里又有没反查成的,才可能是它。无法判断的 + // 句子全是没反查成的(未能反查的不少于无法判断的),那就一定是它;还有查过了、数据不够的,说不准,照实说「可能」 + const unreviewed = item.verdict === 'inconclusive' ? Math.min(challenge.unreviewed ?? 0, challenge.counts.inconclusive) : 0 + const surely = unreviewed > 0 && unreviewed >= challenge.counts.inconclusive + return ( +
+
+ + {said === 'degrade' && } + {!noRecord && ( + + )} +
+ + {unreviewed > 0 && ( +

+ + {surely ? CHALLENGE_TEXT.claimUnreviewed : CHALLENGE_TEXT.claimMaybeUnreviewed(unreviewed)} + + 。{CHALLENGE_TEXT.unreviewedHint} +

+ )} + {open && ( +
+ +
+ )} + {noRecord &&

{CHALLENGE_TEXT.noRecord}

} + {/* 判断标签看着像结论,得说清是谁判的:展开取到审稿记录之后带上模型名 */} +

{CHALLENGE_TEXT.judgedBy(detail.data?.model ?? null)}

+
+ ) +} + /** 判断标签:文字加颜色(去掉颜色也读得出),不铺底色——浅色主题下底色会把小字的对比度压到 4.5 以下 */ function VerdictTag({ verdict }: { verdict: string }) { const color = isVerdict(verdict) ? VERDICT_COLOR[verdict] : 'var(--text-dim)' diff --git a/frontend/src/run/EvidencePanel.tsx b/frontend/src/run/EvidencePanel.tsx index 2bc5574a..3afaf40b 100644 --- a/frontend/src/run/EvidencePanel.tsx +++ b/frontend/src/run/EvidencePanel.tsx @@ -19,14 +19,15 @@ import { import { humanizeError } from '../lib/errors' import { formatDateTime, formatNumber, NONE, shortId } from '../lib/format' import { - ABSENCE_CLAIM_TEXT, DUAL_CHECK_TEXT, EVIDENCE_TEXT, JUDGE_TEXT, MERGE_TEXT, NODE_TYPE_LABEL, RUN_SQL_CHECK_TEXT, SQL_CHECK_TEXT, - nodeTypeLabel, + ABSENCE_CLAIM_TEXT, CHALLENGE_TEXT, DUAL_CHECK_TEXT, EVIDENCE_TEXT, JUDGE_TEXT, MERGE_TEXT, NODE_TYPE_LABEL, RUN_SQL_CHECK_TEXT, + SQL_CHECK_TEXT, nodeTypeLabel, } from '../lib/terms' import { useCatalog } from '../store/catalog' import { askKey, segmentKey, useEvidence, useExplore, useVerdicts } from '../store/evidence' import type { - ApprovalReport, ChallengeCounts, EvidenceBlock, EvidenceDocData, EvidenceInput, EvidenceMerge, EvidenceOnDemand, - EvidenceQuoteSource, EvidenceSeal, EvidenceSegment, EvidenceStep, EvidenceUnit, EvidenceVerdict, EvidenceViolation, + ApprovalReport, EvidenceBlock, EvidenceDocData, EvidenceDualCheck, EvidenceInput, EvidenceMerge, EvidenceOnDemand, + EvidenceQuoteSource, EvidenceSeal, EvidenceSegment, EvidenceSegmentDetail, EvidenceStep, EvidenceUnit, EvidenceUnitChallenge, + EvidenceVerdict, EvidenceViolation, } from '../types' import { ArtifactViewer, ResultTable } from './AssistantStream' import { ChallengeSummary } from './ChallengeSummary' @@ -76,10 +77,12 @@ import { * 表格底下带着推演结果。 * * 核查状态(期 4):指标步骤多一行双盲复算的结果(一致、不一致和审核人选了什么、未能完成、已跳过),选了盲算、两边都 - * 不对、没选、未能完成的写明报告里的数没换、已记为缺口;句子被反方审过的(点开整句、点开句中的数字都一样)多一块反方 - * 审稿,复用审批卡上的展示(run/ChallengeSummary),概况是整份报告真实的计数,点开这一句看反方的理由和查询,不说出具会 - * 不会降档(运行已经结束,看报告的出具横幅)。取自遮罩列的输入,服务端已把值换成「已遮罩」,标签照样写「已遮罩」,输入 - * 来源里只说核对结果、不写值;遮没遮核对不了的(快照读不出来)同样不显示值,写明原因。 + * 不对、没选、未能完成的写明报告里的数没换、已记为缺口;句子被反方审过的(点开整句、点开句中的数字都一样)多一节反方 + * 审稿,用审批卡那一块的单句展示(run/ChallengeSummary 的 scope="claim"):这一句的判断、它可能没反查成的说明,展开看 + * 反方的理由和查询,不画报告级的标题和计数,不说出具会不会降档(运行已经结束,看报告的出具横幅)。正文里被审过的句子 + * 挂句末标记(run/EvidenceDoc),点开就是整句面板。取自遮罩列的输入,服务端已把值换成「已遮罩」,标签照样写「已遮罩」, + * 输入来源里只说核对结果、不写值;遮没遮核对不了的(快照读不出来)同样不显示值,写明原因。类型都在 types.ts + * (EvidenceDualCheck、EvidenceUnitChallenge、EvidenceInput 的 masked / mask_unverified)。 * * 四种摆法:side 从右侧弹出(宽屏),drawer 从底部抽出(窄屏),inline 在 360px 的 * 画布右栏里直接栏内展开,dock 放进页面给的一块常驻位置(记录页的「证据」页签)。 @@ -87,51 +90,24 @@ import { */ /** - * 期 4:证据接口在指标步骤上给的双盲复算状态(api/evidence.py 的 _dual_check_of)。没有双盲复算复核过这份指标集时 - * 没有这个键。只有证据面板读它,类型就近写在这里 + * 期 4:证据接口在指标步骤上给的双盲复算状态(类型 EvidenceDualCheck,照 api/evidence.py 的 _dual_check_of)。没有双盲 + * 复算复核过这份指标集时没有这个键;状态不是一个词的不认 */ -interface DualCheckState { - status: 'agreed' | 'disagreed' | 'unchecked' | 'skipped' | (string & {}) - node_id?: string - /** 不一致时审核人的选择:a 采用原流程、b 采用盲算、neither 两边都不对。没选的没有 */ - choice?: string - /** 一致、不一致时的盲算值,服务端按口径卡的格式渲染好的 */ - blind_rendered?: string - /** 未能完成(unchecked):原因(人话);已跳过:disabled / no_inputs / not_targeted(按这个指标说),认不出的代码不显示 */ - reason?: string -} - -/** 期 4:证据接口在句子上给的反方审稿判断(api/evidence.py 的 _challenge_of)。没审到这一句时没有这个键 */ -interface UnitChallenge { - verdict: string - node_id?: string - /** 审稿记录(challenge_artifact)。服务端保存失败时为 null:判断照样有,理由和查询看不了 */ - artifact?: string | null - /** 实际生效的处置:degrade 被推翻的计入出具缺口;flag 只标注;fail 节点报错、运行停了 */ - on_refuted?: string - /** 报告撰写节点的名字 */ - report_label?: string - /** 整份报告的审稿计数(节点产出里的那一份),不是这一句的 */ - counts: ChallengeCounts - /** 没能反查的句数(期 4 起的产出才有) */ - unreviewed?: number -} - -function dualCheckOf(step?: EvidenceStep): DualCheckState | null { - const raw = (step as (EvidenceStep & { dual_check?: unknown }) | undefined)?.dual_check - return raw && typeof raw === 'object' && typeof (raw as DualCheckState).status === 'string' ? raw as DualCheckState : null +function dualCheckOf(step?: EvidenceStep): EvidenceDualCheck | null { + const raw: unknown = step?.dual_check + return raw && typeof raw === 'object' && typeof (raw as EvidenceDualCheck).status === 'string' ? raw as EvidenceDualCheck : null } const CHALLENGE_VERDICTS = ['stands', 'weakened', 'refuted', 'inconclusive'] as const /** - * 句子上的反方审稿判断认不认得:判断是一个词,计数四项都是数。没有计数的不画——审批卡的展示缺了计数会按列出的这一句 - * 拼一个「被推翻 1 句、其余 0 句」,读着像整份报告的结果 + * 句子上的反方审稿判断(类型 EvidenceUnitChallenge,照 _challenge_of)认不认得:判断是一个词,计数四项都是数。单句展示 + * 不画计数,可要拿它和 unreviewed 对一下「无法判断」是不是没反查成;缺了计数不是服务端的形状,宁可不画也不半截地画 */ -function challengeOf(unit: unknown): UnitChallenge | null { - const raw = unit && typeof unit === 'object' ? (unit as { challenge?: unknown }).challenge : undefined +function challengeOf(unit: EvidenceSegmentDetail['unit']): EvidenceUnitChallenge | null { + const raw: unknown = unit?.challenge if (!raw || typeof raw !== 'object') return null - const c = raw as UnitChallenge + const c = raw as EvidenceUnitChallenge const counts = c.counts && typeof c.counts === 'object' ? c.counts : null if (typeof c.verdict !== 'string' || !c.verdict || !counts || !CHALLENGE_VERDICTS.every((v) => typeof counts[v] === 'number' && Number.isFinite(counts[v]))) return null @@ -140,13 +116,13 @@ function challengeOf(unit: unknown): UnitChallenge | null { /** 取自遮罩列的输入:服务端把值换成「已遮罩」,masked 写明是哪几列 */ function maskedOf(input: EvidenceInput): string[] { - const raw = (input as EvidenceInput & { masked?: unknown }).masked + const raw: unknown = input.masked return Array.isArray(raw) ? raw.filter((c): c is string => typeof c === 'string' && !!c) : [] } /** 遮没遮核对不了的输入(快照、取值链上的产出读不出来):值同样换成「已遮罩」,mask_unverified 是原因 */ function unverifiedOf(input: EvidenceInput): string { - const raw = (input as EvidenceInput & { mask_unverified?: unknown }).mask_unverified + const raw: unknown = input.mask_unverified return typeof raw === 'string' ? raw : '' } @@ -660,28 +636,27 @@ function ClaimBody({ doc, artifact, runId, runClass, unit, block }: { } /** - * 反方审稿对这一句的判断(期 4),整句面板、片段面板共用。复用审批卡上的展示(run/ChallengeSummary,只读它的导出): - * 那一块是报告级的——标题写报告名,概况是整份报告四种判断各几句(服务端给的真实计数,不凭这一句拼),条目只列这一句, - * 点开看反方的理由和它执行的查询,「显示全部」补出这份报告别的句子。判断是反方模型给的,那一块自己写明。 + * 反方审稿对这一句的判断(期 4),整句面板、片段面板共用。用审批卡那一块的单句展示(ChallengeSummary 的 scope="claim"): + * 只画这一句的判断、它可能没反查成的说明(透传 unreviewed,和整份报告的计数对一下),展开看反方的理由和它执行的查询; + * 不画报告级的标题、四种判断的计数和别的句子。判断是反方模型给的,那一块自己写明。 * - * 不给运行 id:ChallengeSummary 只拿它查运行类别,好在审批卡上预告「出具会降档」。证据面板看的是已经结束的运行,降没 - * 降档、出没出具以报告的出具横幅(封存的出具判定)为准,这里再说一遍将来时的预告只会和横幅对不上(驳回了、不予出具、 - * 反方审稿不在成果节点上游……)。ChallengeSummary 取不到运行类别时一句和出具有关的话都不说。等它有了单句的展示 - * (只画这一句的判断和理由,不画报告级的标题、计数),换用那个 + * 不谈出具(issuance={false},也不传 degrades):审批卡上预告「出具会降档」,是因为那时运行还没出具。证据面板看的是已经 + * 结束的运行,降没降档、出没出具以报告的出具横幅(封存的出具判定)为准,再说一遍将来时的预告只会和横幅对不上(驳回了、 + * 不予出具、反方审稿不在成果节点上游……) */ -function ChallengePart({ challenge, unit, report }: { challenge: UnitChallenge; unit: EvidenceUnit; report?: string }) { +function ChallengePart({ challenge, unit, report }: { challenge: EvidenceUnitChallenge; unit: EvidenceUnit; report?: string }) { const reports = useMemo(() => [{ node_id: report ?? '', label: challenge.report_label || report || '', challenge: { counts: challenge.counts, items: [{ unit: unit.id, text: unitText(unit).trim(), verdict: challenge.verdict }], artifact: challenge.artifact ?? null, on_refuted: challenge.on_refuted, ...(typeof challenge.unreviewed === 'number' ? { unreviewed: challenge.unreviewed } : {}), - } as ApprovalReport['challenge'], + }, }], [challenge, unit, report]) return ( -
- -
+ + + ) } @@ -1380,12 +1355,12 @@ function MetricPart({ step, entry, inputs, runId, runClass, seal, pending, faile * 不一致而审核人采用了原流程用提醒色,采用盲算、两边都不对、没有选的用失败色(记了缺口);未能完成用提醒色(同样记了 * 缺口);跳过的中性色。认不出的跳过原因不写原因代码 */ -function DualCheckLine({ check, formal }: { check: DualCheckState; formal: boolean }) { +function DualCheckLine({ check, formal }: { check: EvidenceDualCheck; formal: boolean }) { const T = EVIDENCE_TEXT.dualCheck const host = useEvidenceHost() const { status } = check const blind = (status === 'agreed' || status === 'disagreed') && check.blind_rendered ? T.blind(check.blind_rendered) : '' - const picked = check.choice ? DUAL_CHECK_TEXT.approval.choices[check.choice as 'a' | 'b' | 'neither'] : undefined + const picked = check.choice ? DUAL_CHECK_TEXT.approval.choices[check.choice] : undefined const detail = status === 'disagreed' ? (picked ? T.choice(picked) : T.noChoice) : status === 'skipped' ? (check.reason ? T.skip[check.reason] ?? '' : '') : status === 'unchecked' ? check.reason ?? '' : '' diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 318d2f4a..982b88a7 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -2037,7 +2037,12 @@ export interface EvidenceGraph { /** 封存的裁判摘要(文档的 judge) */ judge?: EvidenceJudgeSummary | null /** 最近一次按需裁判用的模型:和写作模型相同没有、在不在价格表里 */ - judge_meta?: EvidenceJudgeMeta | null }[] + judge_meta?: EvidenceJudgeMeta | null + /** + * 期 4:被反方审过的句子 → 判断(_challenge_verdicts,和片段接口的 unit.challenge 同一个读法,每句的判断 + * 一字不差)。正文据此给被审过的句子挂句末标记、点开整句面板。一句都没审、文档读不出来、老服务端都没有这个键 + */ + challenge_verdicts?: Record }[] evidence?: { alias?: string; eid?: string; kind?: string; label?: string; node_id?: string; artifact?: string sealed?: boolean; cited_by?: string[]; report?: string }[] edges?: { from: string; to: string; rel: string }[] @@ -2090,6 +2095,59 @@ export interface EvidenceInput { cell?: string /** agent 的数组字段:引用的行段到了截断查询结果的末行,后面还有没取回的行(query 步骤上是快照本身截断了) */ truncated?: boolean | null + /** + * 期 4:取自数据源设置了遮罩的列(_withheld):服务端已把值换成「已遮罩」、拿掉 model_value / snapshot_value,这里写明 + * 是哪几列。没遮的没有这个键 + */ + masked?: string[] + /** + * 期 4:遮没遮核对不了(查询快照、取值链上的产出读不出来):值同样换成「已遮罩」,这是原因(人话,服务端写好的一句)。 + * 不进 masked:面板底下「这些列设置了遮罩」不把它们算进去 + */ + mask_unverified?: string +} + +// --------------------------------------------------------------------------- +// 期 4:证据接口里的核查状态(api/evidence.py)。是核查,不是证据:没核查过的指标、句子不带这些键。形状照服务端的 +// 真实输出逐字核对过,前端认的时候仍按「认不出就不画」写(run/EvidencePanel.tsx 的 dualCheckOf / challengeOf) +// --------------------------------------------------------------------------- + +/** 双盲复算跳过一个指标的原因(_not_checked):关闭了;这个指标没有来自查询的输入;不在复算范围内(或值不是数) */ +export type EvidenceDualCheckSkip = 'disabled' | 'no_inputs' | 'not_targeted' + +/** + * 指标步骤上的双盲复算状态(_dual_check_of / _dual_status)。没有双盲复算复核过这份指标集时指标步骤没有 dual_check。 + * 产出读不出来、退回台账条目时只有 status、node_id 和 choice + */ +export interface EvidenceDualCheck { + /** agreed 与盲算一致;disagreed 不一致;unchecked 未能完成(盲算触顶、出错,或输入没法单独复算);skipped 没复算这个指标 */ + status: 'agreed' | 'disagreed' | 'unchecked' | 'skipped' + /** 双盲复算节点 */ + node_id: string + /** 只有 disagreed:审核人的选择(a 采用原流程、b 采用盲算、neither 两边都不对)。没选的没有 */ + choice?: DualCheckChoice + /** 只有 agreed / disagreed:盲算值,服务端按口径卡的格式渲染好的 */ + blind_rendered?: string + /** unchecked:给人看的原因;skipped:EvidenceDualCheckSkip 里的代码(界面认不出的代码不显示) */ + reason?: string +} + +/** 句子上的反方审稿判断(_challenge_of):片段接口答的 unit.challenge。没被反方审过的句子没有这个键 */ +export interface EvidenceUnitChallenge { + /** 这一句的判断(反方模型给的,不是系统核对) */ + verdict: ChallengeVerdict | (string & {}) + /** 反方审稿节点(几个都审了这一句时取最后跑完的那个) */ + node_id: string + /** 审稿记录(challenge_artifact)。服务端保存失败时为 null:判断照样有,理由和查询看不了 */ + artifact: string | null + /** 实际生效的处置:degrade 被推翻的计入出具缺口;flag 只标注;fail 节点报错、运行停了 */ + on_refuted: 'degrade' | 'flag' | 'fail' + /** 报告撰写节点的名字(画布上的标题,取不到时是节点 id) */ + report_label: string + /** 这次审稿四种判断各几句:整份报告的(节点产出里的那一份),不是这一句的 */ + counts: ChallengeCounts + /** 没能反查的句数(都记为无法判断,不会多于 counts.inconclusive)。节点产出里有才带 */ + unreviewed?: number } /** 口径卡钉在另一个已发布工作流某个版本里的口径卡上 */ @@ -2213,6 +2271,8 @@ export interface EvidenceStep extends Omit { sql_check_failed?: boolean /** metric:没通过的原因(人话,「所依据的查询未通过 SQL 检查(…),结果不可靠」) */ sql_check_reason?: string + /** metric(期 4):这个指标的双盲复算状态。没有双盲复算复核过这份指标集时没有这个键 */ + dual_check?: EvidenceDualCheck /** query:这次查询对照数据目录查出的问题。没查出问题时没有这个键 */ checks?: SqlCheckItem[] /** query:查询当时冻结的表结构快照(工件 id)。合并查询、老快照没有 */ @@ -2366,7 +2426,9 @@ export interface EvidenceSegmentDetail { /** 这一句的判定:封存的文档里的,或者按最新的 evidence.judged 叠上的(post_seal) */ verdict?: EvidenceVerdict /** 这一句能不能请模型判断、不能的话为什么 */ - on_demand?: EvidenceOnDemand } + on_demand?: EvidenceOnDemand + /** 期 4:反方审稿对这一句的判断。没审到这一句的没有这个键 */ + challenge?: EvidenceUnitChallenge } block?: { id?: string; type?: string } chain?: EvidenceStep[] /** 片段为什么是现在这个状态,一句人话 */ @@ -3666,6 +3728,13 @@ export interface ApprovalChallenge { /** challenge_artifact 工件 id。服务端保存审稿记录失败时为 null:判断照样有,理由和查询看不了 */ artifact: string | null on_refuted?: 'degrade' | 'flag' | (string & {}) + /** 期 4(nodes/challenge.challenge_summary):没能反查的句数,都记为无法判断。升级前的产出、老服务端没有 */ + unreviewed?: number + /** + * 期 4:被推翻的会不会让出具降档(按降档处置、又接在带出具契约的成果节点上游才为真)。升级前的产出、老服务端没有, + * 审批卡按「没写只标注就降档」的旧规则说 + */ + degrades?: boolean } /** diff --git a/scripts/check-evidence.mjs b/scripts/check-evidence.mjs index f38bee61..a75ccd38 100644 --- a/scripts/check-evidence.mjs +++ b/scripts/check-evidence.mjs @@ -3489,7 +3489,8 @@ await section('verify', '期 4:指标步骤的双盲复算、取自遮罩列 check('输入来源:没遮罩的字段照旧', verified.includes('与快照一致') && !verified.includes('遮罩'), verified.replace(/\s+/g, ' ')) await q.ctx.close() - // ---- 反方审稿:整句面板、片段面板都看得到这一句的判断。复用审批卡的展示,概况是整份报告的真实计数,不预告出具 + // ---- 反方审稿:整句面板、片段面板都看得到这一句的判断。用审批卡那一块的单句展示:只画这一句的判断,不画报告级的 + // 标题和计数,不预告出具 const u = fxj.formal.units const challenge = (verdict) => ({ verdict, node_id: 'chal', artifact: CH_ART, on_refuted: 'degrade', report_label: '写周报', counts: CH_COUNTS }) @@ -3528,10 +3529,12 @@ await section('verify', '期 4:指标步骤的双盲复算、取自遮罩列 await c.page.waitForSelector('[data-evidence-panel] [data-ev-challenge] [data-challenge-item]', { timeout: 4000 }).catch(() => {}) const text = await blockText() if (w === 1280) { - check('整句面板:反方审稿的判断(被推翻)挂在这一句下,写明是哪份报告的审稿', await block.getAttribute('data-ev-challenge', { timeout: 3000 }).catch(() => null) === 'refuted' - && text.includes('报告「写周报」的反方审稿') && text.includes('被推翻') && text.includes('新客首单'), text) - check('……概况是整份报告的真实计数(服务端给的),不凭这一句拼', text.includes('经受住 3 句') && text.includes('被削弱 1 句') - && text.includes('被推翻 2 句') && text.includes('无法判断 0 句') && await block.locator('[data-challenge-more="5"]').count() === 1, text) + check('整句面板:「反方审稿」一节写这一句的判断(被推翻),用单句展示', await block.getAttribute('data-ev-challenge', { timeout: 3000 }).catch(() => null) === 'refuted' + && text.startsWith('反方审稿') && await block.locator('[data-challenge-scope="claim"]').count() === 1 + && (await block.locator('[data-challenge-tag="refuted"]').innerText().catch(() => '')).trim() === '被推翻', text) + check('……单句只画这一句:不画报告级的标题、四种判断的计数、别的句子,也不再抄一遍这一句的原文', + await block.locator('[data-challenge-counts], [data-challenge-items], [data-challenge-more], [data-challenge-show-all]').count() === 0 + && !text.includes('报告「写周报」的反方审稿') && !/\d+ 句/.test(text) && !text.includes('增长主要来自新客首单'), text) check('……运行已经结束:不预告「出具会降档」,降没降档看报告的出具横幅', await block.locator(ISSUANCE_LINES).count() === 0 && !text.includes('降档'), text) check('……判断由反方模型给出,并非系统核对', text.includes('并非系统核对'), text) @@ -3539,12 +3542,12 @@ await section('verify', '期 4:指标步骤的双盲复算、取自遮罩列 await c.page.waitForSelector('[data-evidence-panel] [data-challenge-detail-state="ok"]', { timeout: 4000 }).catch(() => {}) const why = await block.locator('[data-challenge-rationale]').innerText().catch(() => '') check('……点开看得到反方的理由(按审稿记录取)', why.includes(CH_RATIONALE) && artifactHits.length === 1, why) - // 被削弱的那一句:概况照样是整份报告的数,不出现凭这一句拼的「被推翻 0 句」 + // 被削弱的那一句:只写它自己的判断,不写整份报告的计数(以前借报告级的展示,读着像这一句「被推翻 0 句」) await badge(c.page, J, u.ok).click() await c.page.waitForSelector('[data-evidence-panel] [data-ev-challenge="weakened"]', { timeout: 4000 }).catch(() => {}) const weak = await blockText() - check('被削弱的那一句:概况写整份报告的计数(被推翻 2 句),不写凭这一句拼的「被推翻 0 句」', weak.includes('被推翻 2 句') - && !weak.includes('被推翻 0 句') && !weak.includes('被削弱 1 句 · 被推翻 0 句'), weak) + check('被削弱的那一句:只写这一句的判断(被削弱),不写整份报告的计数', weak.includes('被削弱') && !weak.includes('被推翻') + && !/\d+ 句/.test(weak), weak) // 片段面板:点这一句里的数字,同样看得到判断;正式运行里双盲复算记的是出具缺口 await c.page.locator(`${J} [data-seg="s6"]`).click() await c.page.waitForSelector('[data-evidence-panel] [data-ev-challenge="weakened"]', { timeout: 4000 }).catch(() => {}) @@ -3567,7 +3570,7 @@ await section('verify', '期 4:指标步骤的双盲复算、取自遮罩列 await c.page.locator(`${JP} [data-seg="s14"]`).click() await c.page.waitForSelector('[data-evidence-panel] [data-ev-challenge]', { timeout: 4000 }).catch(() => {}) check('没开结论句裁判的报告:点句中的数字看得到反方审稿判断', await block.getAttribute('data-ev-challenge', { timeout: 3000 }).catch(() => null) === 'refuted' - && (await blockText()).includes('退款 29 单'), await blockText()) + && (await block.locator('[data-challenge-tag="refuted"]').count()) === 1, await blockText()) } else { const wide = await c.page.evaluate(() => { const el = document.querySelector('[data-evidence-panel]') From a25fed002e81778c925de85c34af5284be8d7527 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 13:02:04 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E5=87=BA=E5=85=B7=E6=A8=AA=E5=B9=85?= =?UTF-8?q?=E6=8A=8A=E6=A0=B8=E6=9F=A5=E7=BC=BA=E5=8F=A3=E5=88=86=E7=BB=84?= =?UTF-8?q?=EF=BC=8C=E5=AE=A1=E6=89=B9=E5=8D=A1=E6=8A=8A=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E7=B1=BB=E5=88=AB=E4=BA=A4=E7=BB=99=E5=8F=8D=E6=96=B9=E5=AE=A1?= =?UTF-8?q?=E7=A8=BF=EF=BC=9AIssuanceBanner=EF=BC=88=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E6=88=90=E6=9E=9C=E5=8C=BA=E3=80=81=E9=97=AE?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=9B=9E=E7=AD=94=E3=80=81=E8=AF=81=E6=8D=AE?= =?UTF-8?q?=E9=A1=B5=E5=90=8C=E4=B8=80=E4=B8=AA=EF=BC=89=E6=8A=8A=E5=8F=8C?= =?UTF-8?q?=E7=9B=B2=E5=A4=8D=E7=AE=97=E3=80=81=E5=8F=8D=E6=96=B9=E5=AE=A1?= =?UTF-8?q?=E7=A8=BF=E6=8A=A5=E4=B8=8A=E6=9D=A5=E7=9A=84=E7=BC=BA=E5=8F=A3?= =?UTF-8?q?=E5=8F=A6=E8=B5=B7=E3=80=8C=E6=A0=B8=E6=9F=A5=E6=9C=AA=E9=80=9A?= =?UTF-8?q?=E8=BF=87=E3=80=8D=E3=80=8C=E6=A0=B8=E6=9F=A5=E6=9C=AA=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E3=80=8D=E4=B8=A4=E7=BB=84=EF=BC=8C=E6=8E=92=E5=9C=A8?= =?UTF-8?q?=E3=80=8C=E6=A0=A1=E9=AA=8C=E6=9C=AA=E5=85=A8=E9=83=A8=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E3=80=8D=E4=B8=8A=E9=9D=A2=EF=BC=8C=E5=85=88=E5=90=8E?= =?UTF-8?q?=E5=90=8C=E6=97=B6=E9=97=B4=E7=BA=BF=E5=92=8C=E6=88=90=E6=9E=9C?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E5=8D=B0=E7=AB=A0=EF=BC=9B=E8=AF=81=E6=8D=AE?= =?UTF-8?q?=E9=A1=B5=E5=8E=BB=E6=8E=89=E5=8F=A6=E5=88=97=E4=B8=80=E9=81=8D?= =?UTF-8?q?=E7=9A=84=20IssuanceWithChecks=20=E5=8C=85=E8=A3=85=EF=BC=8C?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E7=94=A8=E6=A8=AA=E5=B9=85=E3=80=82RunPanel?= =?UTF-8?q?=20=E7=9A=84=E5=AE=A1=E6=89=B9=E5=8D=A1=E6=8A=8A=20approval.run?= =?UTF-8?q?=5Fclass=20=E4=BC=A0=E7=BB=99=20ChallengeSummary=EF=BC=8C?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E7=9A=84=20GET=20=E5=A4=B1=E8=B4=A5=E4=B9=9F?= =?UTF-8?q?=E7=85=A7=E6=A0=B7=E5=86=99=E3=80=8C=E5=87=BA=E5=85=B7=E4=BC=9A?= =?UTF-8?q?=E9=99=8D=E6=A1=A3=E3=80=8D=E3=80=81=E4=B8=8D=E5=8F=A6=E5=8F=96?= =?UTF-8?q?=E3=80=82check-stream=20=E8=A1=A5=E6=A8=AA=E5=B9=85=E5=88=86?= =?UTF-8?q?=E7=BB=84=E7=9A=84=E6=96=AD=E8=A8=80=EF=BC=88=E6=96=B0=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E7=94=A8=E4=BE=8B=20issued-verify=EF=BC=89=EF=BC=8Cch?= =?UTF-8?q?eck-verify=20=E7=9A=84=20verify-gaps=20=E6=94=B9=E8=AF=BB?= =?UTF-8?q?=E6=A8=AA=E5=B9=85=E9=87=8C=E7=9A=84=E6=96=B0=E7=BB=84=EF=BC=8C?= =?UTF-8?q?check-challenge=20=E8=A1=A5=E6=95=B4=E5=BC=A0=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E5=8D=A1=E5=B8=A6=E8=BF=90=E8=A1=8C=E7=B1=BB=E5=88=AB=E7=9A=84?= =?UTF-8?q?=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/dev/fixtures/challenge.tsx | 16 +++++--- frontend/src/dev/fixtures/issuance.tsx | 52 ++++++++++++++++++++++++ frontend/src/pages/runs/EvidencePane.tsx | 44 ++++---------------- frontend/src/run/AssistantStream.tsx | 33 +++++++++++++-- frontend/src/run/RunPanel.tsx | 2 +- scripts/check-challenge.mjs | 37 +++++++++++++---- scripts/check-stream.mjs | 41 +++++++++++++++++++ scripts/check-verify.mjs | 42 +++++++++++-------- 8 files changed, 195 insertions(+), 72 deletions(-) create mode 100644 frontend/src/dev/fixtures/issuance.tsx diff --git a/frontend/src/dev/fixtures/challenge.tsx b/frontend/src/dev/fixtures/challenge.tsx index 5e8a4ffe..eb00d9d2 100644 --- a/frontend/src/dev/fixtures/challenge.tsx +++ b/frontend/src/dev/fixtures/challenge.tsx @@ -31,8 +31,9 @@ import fx from './challenge.json' * 卡上按「无法判断」的句数说,两个数不打架 * - ?syn=challenge-approval-offpath:正式运行,按降档处置,但反方审稿不在带出具契约的成果节点上游(degrades 为假) * - ?syn=challenge-summary-given:反方审稿那一块单独画,审批自带的运行类别(正式运行)由调用方传进来;检查脚本让这次运行的 - * GET 失败,卡上照样写「出具会降档」,也不另发这次请求。审批卡(RunPanel 的 ApprovalCard)把 approval.run_class - * 传给它之后,卡上走的就是这条路 + * GET 失败(500),卡上照样写「出具会降档」,也不另发这次请求 + * - ?syn=challenge-approval-given:同一次运行(GET 同样 500),整张审批卡(RunPanel 的 ApprovalCard):审批带着 + * run_class,卡把它传给反方审稿那一块,照它说出具、不另取 * - ?syn=challenge-approval-dup:同 many,工件里补出来的一句和条目里的一句原文相同、unit 不同(工件由检查脚本合成) * - ?syn=challenge-approval-norecord:同 many,审稿记录没存上(artifact 为 null) * @@ -68,9 +69,8 @@ function Card({ approval, name, dense = false }: { approval: Approval; name: str } const RUNS = fx.runs -/** 期 4 起服务端另带的两项:unreviewed(没能反查的句数)、degrades(被推翻的会不会让出具降档) */ -type Challenge = ApprovalChallenge & { unreviewed?: number; degrades?: boolean } -const withChallenge = (c: Partial): ApprovalReport => ({ ...report, challenge: { ...challenge, ...c } as ApprovalChallenge }) +/** 期 4 起服务端另带的两项(unreviewed 没能反查的句数、degrades 被推翻的会不会让出具降档)类型里都有,按需盖上 */ +const withChallenge = (c: Partial): ApprovalReport => ({ ...report, challenge: { ...challenge, ...c } }) const many = fx.many as unknown as ApprovalChallenge /** 没有被推翻的结论:同第一个用例去掉被推翻的那句,计数跟着改 */ const clear: Partial = { @@ -82,7 +82,7 @@ const clear: Partial = { * 记为无法判断,unreviewed 不会多于 inconclusive)。原来无法判断的 u11 是查过了、数据不够,不算未能反查 */ const UNREVIEWED_UNITS = ['u5', 'u8'] -const unreviewed: Partial = { +const unreviewed: Partial = { counts: { stands: 0, weakened: 0, refuted: 1, inconclusive: 3 }, items: challenge.items.map((it) => (UNREVIEWED_UNITS.includes(it.unit) ? { ...it, verdict: 'inconclusive' } : it)), artifact: 'f'.repeat(64), @@ -245,6 +245,10 @@ export const cases: Record JSX.Element> = { ), + 'challenge-approval-given': () => ( + + ), 'challenge-approval-offpath': () => ( diff --git a/frontend/src/dev/fixtures/issuance.tsx b/frontend/src/dev/fixtures/issuance.tsx new file mode 100644 index 00000000..522e1145 --- /dev/null +++ b/frontend/src/dev/fixtures/issuance.tsx @@ -0,0 +1,52 @@ +import type { JSX } from 'react' +import { AssistantStream, type StreamTurn } from '../../run/AssistantStream' +import { decodeRun } from '../../run/decode' +import type { RunEvent } from '../../types' + +/** + * 出具横幅里的核查缺口(运行面板成果区、问数据回答用的 run/AssistantStream 的 IssuanceBanner),scripts/check-stream.mjs + * 按它检查: + * - ?syn=issued-verify:正式运行降档出具。缺口里有一条没通过 SQL 检查的(横幅单列)、双盲复算和反方审稿报上来的几条 + * (两路结果不一致、结论被推翻、指标无法复算),和一条校验没做完的;先后照服务端(出具契约按上游节点的先后收),核查的和 + * 别的混在一起。横幅按「核查未通过」「核查未完成」「校验未全部完成」分组,先后同时间线上出具那一行 + * + * 门店、指标、数字都是虚构的。缺口原话照服务端的写法(出具契约给上游节点的缺口加「节点类型「节点名」:」的前缀) + */ +export const ISSUED_VERIFY = { + run: 'syn-issued-verify', + sql: '查询「取门店流水」(Q1)未通过 SQL 检查(「门店」关联「流水明细」是一对多,对「门店」的「面积」求和会重复计算),结果不可靠;受影响的引用:指标「坪效」', + failed: ['双盲复算「复核月报口径」:指标「客单价」两路结果不一致,审核人选择了盲算结果', '反方审稿:推翻了 1 句结论:「西区客流回升主要来自会员日」'], + unfinished: ['双盲复算「复核月报口径」:指标「复购率」无法复算:输入来自合并查询,无法单独复算'], + other: ['叙述模板渲染为空(路径可能有误)'], +} + +const V = ISSUED_VERIFY +const ISSUANCE = { + tier: 'degraded', matched_numbers: 4, metrics_checked: 3, missing_required: [], missing_expected: [], unmatched_numbers: [], + calibers: [], + gaps: [V.failed[0], V.sql, V.other[0], V.unfinished[0], V.failed[1]], + sql_checks: [{ query: '查询「取门店流水」(Q1)', node_id: 'fetch', problems: ['「门店」关联「流水明细」是一对多,对「门店」的「面积」求和会重复计算'], + refs: ['指标「坪效」'], gap: V.sql }], +} +const OUTPUT = { 月报: '上月各门店客单价 86.4 元,坪效 1,240 元。', _issuance: ISSUANCE } + +function verifyTurn(): StreamTurn { + const at = Date.now() / 1000 - 30 + const events: RunEvent[] = [ + { seq: 1, type: 'run.started', node_id: null, ts: at, data: { nodes: 1 } }, + { seq: 2, type: 'node.started', node_id: 'done', ts: at + 1, data: {} }, + { seq: 3, type: 'issuance', node_id: 'done', ts: at + 1.2, data: { ...ISSUANCE, unmatched: 0 } }, + { seq: 4, type: 'node.finished', node_id: 'done', ts: at + 1.3, data: { duration_ms: 300 } }, + { seq: 5, type: 'run.finished', node_id: null, ts: at + 1.4, data: { output: OUTPUT, duration_ms: 1400 } }, + ] + return { + id: 'issued-verify', question: '上月门店经营月报', phase: 'done', status: '已完成', + steps: decodeRun(events, { status: 'succeeded' }), output: OUTPUT, runId: V.run, runClass: 'formal', + } +} + +export const cases: Record JSX.Element> = { + 'issued-verify': () => ( +
+ ), +} diff --git a/frontend/src/pages/runs/EvidencePane.tsx b/frontend/src/pages/runs/EvidencePane.tsx index 67c2a952..ac3dffee 100644 --- a/frontend/src/pages/runs/EvidencePane.tsx +++ b/frontend/src/pages/runs/EvidencePane.tsx @@ -12,9 +12,8 @@ import { issuanceMarks, segmentState, unitVerdict, verdictState, type AuditFilter, type AuditGroup, type AuditRow, } from '../../lib/evidence' import { formatNumber } from '../../lib/format' -import { DUAL_CHECK_TEXT, EVIDENCE_AUDIT_TEXT as T, UPGRADE_TEXT } from '../../lib/terms' +import { EVIDENCE_AUDIT_TEXT as T, UPGRADE_TEXT } from '../../lib/terms' import { IssuanceBanner } from '../../run/AssistantStream' -import { splitIssuanceGaps } from '../../run/decode' import { EvidenceDoc, type EvidenceDocHandle } from '../../run/EvidenceDoc' import { EvidenceGuessView } from '../../run/EvidenceGuess' import { ReportInquiry, inquiryOpen, locateIn, useInquiryAccess } from '../../run/Inquiry' @@ -150,8 +149,13 @@ export function EvidencePane({ run, output, labelOf, refreshKey }: { reports={graph.reports ?? []} labelOf={labelOf} /> {/* 出具档位和降档原因写在报告上方:降档时只在侧栏小标签里写「SQL 检查未通过」,报告上却是绿色的 - 「10 个数字都有出处」,读的人会以为一切正常。SQL 检查导致的降档在横幅里单列一块 */} - {!!output?._issuance?.tier && } + 「10 个数字都有出处」,读的人会以为一切正常。SQL 检查导致的降档在横幅里单列一块,双盲复算、反方审稿报上来的 + 缺口分「核查未通过」「核查未完成」两组(和问数据、画布右栏同一个横幅) */} + {!!output?._issuance?.tier && ( +
+ +
+ )} {mode === 'cited' && reports.map((r) => { const doc = docOf(r.node_id) @@ -322,38 +326,6 @@ function useAudit(runId: string, key: string): AuditState { return state } -/** - * 出具横幅,外加双盲复算、反方审稿报上来的缺口另起的两组:「核查未通过」(两路结果不一致、结论被推翻)、「核查未完成」 - * (指标无法复算)。横幅把缺口都列在「校验未全部完成」下,读起来像是校验没做完:这里从横幅里拿出来,紧跟在它下面单列, - * 颜色和横幅里的缺口一样(分组见 run/decode.ts 的 splitIssuanceGaps)。导出给预览用例(dev/fixtures/dual_check.tsx) - */ -export function IssuanceWithChecks({ issuance, runClass }: { issuance: Record; runClass?: string }) { - const { failed, unfinished, other } = useMemo( - () => splitIssuanceGaps(Array.isArray(issuance.gaps) ? issuance.gaps.map(String) : []), [issuance]) - const verify = failed.length + unfinished.length > 0 - const shown = useMemo(() => (verify ? { ...issuance, gaps: other } : issuance), [issuance, verify, other]) - const group = (kind: 'failed' | 'unfinished', gaps: string[]) => gaps.length > 0 && ( -
-
{DUAL_CHECK_TEXT.verifyGaps[kind]}
-
    - {gaps.map((g) =>
  • · {g}
  • )} -
-
- ) - return ( -
- - {verify && ( -
- {group('failed', failed)} - {group('unfinished', unfinished)} -
- )} -
- ) -} - /** 顶上一行:这次运行按哪种方式展示证据、封存核对的结果、每份报告文档的哈希 */ function SummaryBar({ mode, seal, message, reports, labelOf }: { mode?: string diff --git a/frontend/src/run/AssistantStream.tsx b/frontend/src/run/AssistantStream.tsx index ecdc7eb0..e229f9a6 100644 --- a/frontend/src/run/AssistantStream.tsx +++ b/frontend/src/run/AssistantStream.tsx @@ -16,11 +16,12 @@ import { explainRunError } from '../lib/explain' import { formatClock, formatDuration, formatNumber, NONE, shortId } from '../lib/format' import { statusLabel } from '../lib/status' import { - COPILOT_CONTEXT_TEXT, EVIDENCE_TEXT, JUDGE_TEXT, MERGE_TEXT, RUN_SQL_CHECK_TEXT, evidenceTally, issuanceLabel, nodeTypeLabel, + COPILOT_CONTEXT_TEXT, DUAL_CHECK_TEXT, EVIDENCE_TEXT, JUDGE_TEXT, MERGE_TEXT, RUN_SQL_CHECK_TEXT, evidenceTally, issuanceLabel, + nodeTypeLabel, } from '../lib/terms' import { SqlCheckList } from './SqlChecks' import { - ISSUANCE_HINT, childrenByExec, compactSteps, parseQueryResult, progressOf, spread, teamVerdictOf, + ISSUANCE_HINT, childrenByExec, compactSteps, parseQueryResult, progressOf, splitIssuanceGaps, spread, teamVerdictOf, type Exec, type FixKind, type ResultTable as Table, type Step, type StepKind, type TeamMemberEx, type TeamRun, } from './decode' @@ -2296,6 +2297,22 @@ export function IssuanceSqlChecks({ tier, checks }: { tier: string; checks: Issu ) } +/** + * 出具横幅里双盲复算、反方审稿报上来的一组缺口:failed「核查未通过」、unfinished「核查未完成」。排在「校验未全部完成」 + * 上面,样子和它一样(同一种提醒色、小标题加圆点列表),一条都没有时不画 + */ +function IssuanceGaps({ kind, title, gaps }: { kind: 'failed' | 'unfinished'; title: string; gaps: string[] }) { + if (!gaps.length) return null + return ( +
+
{title}:
+
    + {gaps.map((g) =>
  • · {g}
  • )} +
+
+ ) +} + const TIER_META: Record = { formal: { color: 'var(--st-done)', soft: 'var(--st-done-soft)', hint: ISSUANCE_HINT.formal }, degraded: { color: 'var(--st-waiting)', soft: 'var(--st-waiting-soft)', hint: ISSUANCE_HINT.degraded }, @@ -2312,7 +2329,9 @@ const TIER_META: Record = * * 降档的原因必须写在这里:以前只列缺指标和回指不上的数字,而「叙述模板渲染 * 为空」「指标集为空」这类校验根本没跑起来的缺口(gaps)一条都不显示——横幅 - * 说「请对照下方声明」,下方什么都没有。 + * 说「请对照下方声明」,下方什么都没有。缺口分几块:没通过 SQL 检查的单列在最前, + * 双盲复算、反方审稿报上来的分「核查未通过」「核查未完成」,其余的归「校验未全部完成」。 + * 运行详情的证据页、问数据的回答、画布右栏用的都是这一个横幅,三处说法一样。 */ export function IssuanceBanner({ issuance, runClass, evidence }: { issuance: any; runClass?: string @@ -2331,7 +2350,11 @@ export function IssuanceBanner({ issuance, runClass, evidence }: { const sqlChecks: IssuanceSqlCheck[] = (Array.isArray(issuance?.sql_checks) ? issuance.sql_checks : []) .filter((x: unknown): x is IssuanceSqlCheck => !!x && typeof x === 'object') const sqlGaps = new Set(sqlChecks.map((x) => x.gap).filter(Boolean)) - const gaps: string[] = (Array.isArray(issuance?.gaps) ? issuance.gaps.map(String) : []).filter((g: string) => !sqlGaps.has(g)) + // 其余缺口里双盲复算、反方审稿报上来的另起两组:「核查未通过」(两路结果不一致、结论被推翻)、「核查未完成」(指标无法 + // 复算),排在「校验未全部完成」上面,先后同时间线、证据页、成果节点的印章(分组见 decode.ts 的 splitIssuanceGaps)。 + // 不分的话核查查出的问题读着像校验没做完,没核成的数又像被查出了错 + const { failed: verifyFailed, unfinished: verifyUnfinished, other: gaps } = splitIssuanceGaps( + (Array.isArray(issuance?.gaps) ? issuance.gaps.map(String) : []).filter((g: string) => !sqlGaps.has(g))) const calibers: any[] = issuance?.calibers ?? [] const matched = Number(issuance?.matched_numbers ?? 0) // 逐个数字的出处(matched[].caliber 形如「口径名 @ v2」):有它就能按卡数清楚, @@ -2404,6 +2427,8 @@ export function IssuanceBanner({ issuance, runClass, evidence }: { )} {!!sqlChecks.length && } + + {!!gaps.length && (
{RUN_SQL_CHECK_TEXT.otherGaps}
diff --git a/frontend/src/run/RunPanel.tsx b/frontend/src/run/RunPanel.tsx index 67239c38..96d0e51e 100644 --- a/frontend/src/run/RunPanel.tsx +++ b/frontend/src/run/RunPanel.tsx @@ -157,7 +157,7 @@ export function ApprovalCard({ approval, onResolved, showWorkflow = true }: { - + {payload.tool && (
diff --git a/scripts/check-challenge.mjs b/scripts/check-challenge.mjs index ac34e417..01ad15b3 100644 --- a/scripts/check-challenge.mjs +++ b/scripts/check-challenge.mjs @@ -3,7 +3,8 @@ // // 守的是这些「看着没坏、其实用不了」的地方:概况的句数和条目对不上、有被推翻的结论却不提醒降档(或者原因只藏在悬停里)、 // 没有被推翻的结论也写降档、只标注时也写降档、探索运行里说出具、运行类别取不到时说起出具(取不到就不知道是不是正式 -// 运行,一句和出具有关的话都不说,窗口重新拿到焦点时再取)、审批带着运行类别时还另取一次(取失败了降档提示就没了)、 +// 运行,一句和出具有关的话都不说,窗口重新拿到焦点时再取)、审批带着运行类别时还另取一次(取失败了降档提示就没了; +// 整张审批卡得把 approval.run_class 交给反方审稿那一块)、 // 反方审稿不在出具路径上(服务端的 degrades 为假)还写降档、说明里用「本节点」(画在审批卡上,读着像审批节点)、 // 反查没完成的几句混在「无法判断」里看不出来(或者比「无法判断」还多、插在降档标记和它的说明中间)、 // 没经过反方审稿的报告也冒出一块空框(或者白发一次请求)、点开一句不取工件(或者每点一句取一次)、理由里的数字看不出引用了哪次查询、 @@ -43,7 +44,7 @@ const DUP_CONTENT = { claims: fx.artifacts[MANY].claims.map((c) => (c.unit === 'u30' ? { ...c, text: fx.many.items.find((it) => it.unit === 'u20').text } : c)), } const RUNS = fx.runs -// RUNS.given 不在这里:那次运行的 GET 一律答 404,审批带着的运行类别得照样管用 +// RUNS.given 不在这里:那次运行的 GET 一律答 500,审批带着的运行类别得照样管用 const RUN_CLASS = { [RUNS.formal]: 'formal', [RUNS.flag]: 'formal', [RUNS.explore]: 'exploratory', [RUNS.none]: 'formal', [RUNS.many]: 'formal', [RUNS.broken]: 'formal', [RUNS.clear]: 'formal', [RUNS.clearFlag]: 'formal', @@ -178,6 +179,7 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark' } = {}) { const id = decodeURIComponent(run[1]) hits.runs.push(id) if (id === RUNS.runfail && state.runFail) return route.fulfill({ status: 500, json: { detail: '服务暂时不可用' } }) + if (id === RUNS.given) return route.fulfill({ status: 500, json: { detail: '服务暂时不可用' } }) return RUN_CLASS[id] ? route.fulfill({ json: { id, run_class: RUN_CLASS[id], status: 'interrupted' } }) : route.fulfill({ status: 404, json: { detail: '运行不存在' } }) } @@ -193,9 +195,12 @@ const shot = async (page, name) => { 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) +/** + * 等界面画定:否定的断言(不写降档)得在这之后做。审批带着运行类别时卡上照它说、不另取,画上就是定了;老服务端的审批 + * 不带运行类别(fetches 为真),要等卡上按运行 id 取的那一次答完 + */ +async function runClassSettled(page, hits, runId, { fetches = false } = {}) { + if (fetches) 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}"]`) @@ -232,7 +237,7 @@ await section('审批卡:概况与降档提示', async () => { 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('、')) + check('审批带着运行类别:照它说,不另取', !hits.runs.includes(RUNS.formal), hits.runs.join('、')) await shot(page, 'approval-dark') check('页面没有报错', errors.length === 0, errors.join(';')) await ctx.close() @@ -377,7 +382,7 @@ await section('运行类别取不到:不说任何和出具有关的话,重 const { ctx, page, hits, state, errors } = await open('challenge-approval-runfail') const block = page.locator('[data-challenge="write"]') await block.waitFor() - await runClassSettled(page, hits, RUNS.runfail) + await runClassSettled(page, hits, RUNS.runfail, { fetches: true }) const quiet = async () => { const all = await text(block) return await page.locator('[data-challenge-degrade], [data-challenge-degrade-note], [data-challenge-degrade-off], ' @@ -406,7 +411,7 @@ await section('运行类别取不到:不说任何和出具有关的话,重 await section('审批带着运行类别:照它说出具,不另取(另取失败也不影响)', async () => { // 审批自带 approval.run_class(卡顶写的就是它)。以前反方审稿那一块只拿运行 id 另取一次,这一次失败了,正式运行里 // 「出具会降档」整块消失,卡顶却照样写着正式运行。用例把反方审稿那一块单独画,运行类别由调用方传进来,这次运行的 - // GET 一律答 404 + // GET 一律答 500 const { ctx, page, hits, errors } = await open('challenge-summary-given') const block = page.locator('[data-challenge="write"]') await block.waitFor() @@ -418,6 +423,22 @@ await section('审批带着运行类别:照它说出具,不另取(另取 await ctx.close() }) +await section('整张审批卡:把审批带着的运行类别交给反方审稿那一块,运行的 GET 失败也照样写降档、不另取', async () => { + // RunPanel 的 ApprovalCard 得把 approval.run_class 传给 ChallengeSummary(同 ApprovalReports)。不传的话那一块按运行 id + // 另取一次:这里那次 GET 一律答 500,正式运行里「出具会降档」整块消失,卡顶却照样写着正式运行 + const { ctx, page, hits, errors } = await open('challenge-approval-given') + const block = page.locator('[data-challenge="write"]') + await block.waitFor() + await page.waitForTimeout(500) + check('卡顶照审批写正式运行', await page.locator('[data-approval-run-class="formal"]').count() === 1) + const degrade = page.locator('[data-challenge-degrade]') + check('……反方审稿那一块照样写「出具会降档」和原因', await degrade.count() === 1 && (await text(degrade)) === '出具会降档' + && (await text(page.locator('[data-challenge-degrade-note]'))) === DEGRADE_NOTE, await text(block)) + check('……不另取这次运行的类别(那次 GET 答 500)', !hits.runs.includes(RUNS.given), hits.runs.join('、') || '一次都没取') + check('……页面没有报错', errors.length === 0, errors.join(';')) + await ctx.close() +}) + await section('有句子反查没完成:单独说一行,不混在「无法判断」里,不插在降档标记和它的说明中间', async () => { // 服务端的 unreviewed:核查模型不可用、触顶没给出结论、数据源不在了……这几句记为无法判断,可反查没有完成。「无法判断 // 3 句」分不出是查了、数据不够,还是没查成,审批卡单独说。未能反查的每一句都记为无法判断,unreviewed 不会多于它 diff --git a/scripts/check-stream.mjs b/scripts/check-stream.mjs index 651db9af..aae71d98 100644 --- a/scripts/check-stream.mjs +++ b/scripts/check-stream.mjs @@ -1411,6 +1411,47 @@ await section('放弃、结构化的报错、恢复的轮次、出具横幅(RE await one.page.close() }) +await section('出具横幅:核查报上来的缺口另起「核查未通过」「核查未完成」,不归在「校验未全部完成」下,先后同时间线', async () => { + // 双盲复算、反方审稿报上来的缺口(出具契约加了「双盲复算「节点名」:」「反方审稿:」的前缀)以前全列在「校验未全部 + // 完成」下:两路结果不一致、结论被推翻读着像校验没做完。时间线、证据页、成果节点的印章早就分成「核查未通过」「核查未 + // 完成」「校验未全部完成」三组(印章的悬停说明最后一行还指向成果区),运行面板成果区、问数据回答的横幅得同样分、同样的 + // 先后。用例 issued-verify(dev/fixtures/issuance.tsx):缺口的先后照服务端,核查的和别的混在一起,另有一条没通过 + // SQL 检查的(横幅单列) + for (const [w, label] of [[1100, '宽栏'], [390, '390px']]) { + const { page, errors } = await open('syn=issued-verify', { w, h: 900, name: w === 1100 ? 'issued-verify' : undefined }) + const V = await page.evaluate(async () => (await import('/src/dev/fixtures/issuance.tsx')).ISSUED_VERIFY) + const banner = page.locator('[data-issuance-banner=degraded]') + await banner.waitFor() + const blocks = await banner.evaluate((el) => [...el.querySelectorAll('[data-issuance-verify-gaps], [data-issuance-gaps]')] + .map((g) => ({ + kind: g.getAttribute('data-issuance-verify-gaps') ?? 'other', head: g.firstElementChild?.textContent.trim(), + items: [...g.querySelectorAll('li')].map((li) => li.textContent.replace(/^·\s*/, '').trim()), + }))) + const want = [{ kind: 'failed', head: '核查未通过:', items: V.failed }, { kind: 'unfinished', head: '核查未完成:', items: V.unfinished }, + { kind: 'other', head: '校验未全部完成:', items: V.other }] + check(`${label}:横幅里先「核查未通过」、再「核查未完成」、最后「校验未全部完成」,各列各的`, + JSON.stringify(blocks) === JSON.stringify(want), JSON.stringify(blocks)) + const rest = (await banner.locator('[data-issuance-gaps]').innerText().catch(() => '')).replace(/\s+/g, ' ') + check(`${label}:……核查报上来的缺口一条都不在「校验未全部完成」下`, + ![...V.failed, ...V.unfinished].some((g) => rest.includes(g)) && !/双盲复算|反方审稿/.test(rest), rest) + const sql = (await banner.locator('[data-issuance-sql-checks]').innerText().catch(() => '')).replace(/\s+/g, ' ') + check(`${label}:……没通过 SQL 检查的那条照旧单列在最前,不在三组里重复`, sql.includes('查询「取门店流水」(Q1)') + && !blocks.some((b) => b.items.includes(V.sql)), sql) + // 时间线上出具那一行:展开后按同样的先后列三组(时间线把「校验未全部完成」那一组叫「校验不完整」) + const row = page.locator('[data-turn] [data-step-status]').filter({ hasText: '降档出具' }).first() + await row.locator('button').first().click() + await page.waitForTimeout(150) + const line = (await row.innerText()).replace(/\s+/g, ' ') + const at = (s) => line.indexOf(s) + check(`${label}:……三组的先后和时间线上出具那一行一致`, at('核查未通过') >= 0 && at('核查未通过') < at('核查未完成') + && at('核查未完成') < at('校验不完整'), line) + const over = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + check(`${label}:页面不横向溢出`, over <= 0, `${over}px`) + check(`${label}:没有运行时报错`, errors.length === 0, errors.join(' | ')) + await page.close() + } +}) + await section('吸顶的轮次头贴着顶边(REQ-8)', async () => { for (const [q, w, h, label] of [['syn=mixed', 1024, 768, '宽栏 1024'], ['syn=mixed', 1180, 800, '宽栏 1180'], ['syn=mixed&dense=1', 380, 700, '窄栏']]) { diff --git a/scripts/check-verify.mjs b/scripts/check-verify.mjs index 7cb95b43..53fd0c73 100644 --- a/scripts/check-verify.mjs +++ b/scripts/check-verify.mjs @@ -20,8 +20,8 @@ // 真写一遍的结果);没有合并预告时按其余指标当前的选择推,被目录挡下的同项指标也算进来(说法不同就说都不记,说不准的 // 只说可能),结尾换成以时间线上的记录为准;盲算的理由按核查 agent 组装的理由文档画,有出处的数字画实线、裸数字醒目 // 标出,老工件照旧纯文本; -// - 出具缺口:证据页(真的 EvidencePane)和成果节点印章的悬停说明把双盲复算、反方审稿报上来的缺口另起「核查未通过」 -// 「核查未完成」两组,不放在「校验未全部完成」下; +// - 出具缺口:证据页的出具横幅(真的 EvidencePane 里那一个 IssuanceBanner,和问数据、画布右栏同一个)和成果节点印章的 +// 悬停说明把双盲复算、反方审稿报上来的缺口另起「核查未通过」「核查未完成」两组,不放在「校验未全部完成」下; // - 记录页的工件列表:双盲复算对照、反方审稿记录两种工件有名字和说明,不露键名; // - 检查器:口径卡、指标、口径说明、盲算模型、相对误差、未采用原流程时、启用,每一项都真的写进节点配置;字段的 // 叫法和服务端校验报错里的(engine/labels.py 的 DUAL_CHECK_FIELD_LABEL)一字不差;选口径卡、盲算模型、启用三态用的是 @@ -1098,11 +1098,12 @@ await section('审批卡:390px 不横向溢出、窄栏里上下排、深浅 await shot(rail.page, 'approval-rail-dark') }) -await section('出具缺口:证据页、成果节点的印章把核查报上来的缺口另起两组,不放在「校验未全部完成」下', async () => { +await section('出具缺口:证据页的出具横幅、成果节点的印章把核查报上来的缺口另起两组,不放在「校验未全部完成」下', async () => { // 双盲复算、反方审稿报上来的缺口(出具契约加了「双盲复算「节点名」:」「反方审稿:」的前缀)以前和「叙述模板渲染为空」 - // 一起列在「校验未全部完成」下。证据页(真的 EvidencePane)在出具横幅下面另起「核查未通过」(两路不一致、结论被推翻) - // 「核查未完成」(无法复算)两组,横幅里只留校验没做完的;成果节点印章的悬停说明同样分组、同样的先后。用例 - // verify-gaps(dev/fixtures/dual_check.tsx):缺口的先后照服务端,核查的和别的混在一起;证据图、审计接口在这里伪造 + // 一起列在「校验未全部完成」下。证据页(真的 EvidencePane)的出具横幅里另起「核查未通过」(两路不一致、结论被推翻) + // 「核查未完成」(无法复算)两组,排在「校验未全部完成」上面,那一组只留校验没做完的;横幅外面不再另列一遍。成果节点 + // 印章的悬停说明同样分组、同样的先后。用例 verify-gaps(dev/fixtures/dual_check.tsx):缺口的先后照服务端,核查的和 + // 别的混在一起;证据图、审计接口在这里伪造 const RUN = 'run-verify-gaps' const FAILED = ['双盲复算「复核周报口径」:指标「销售额」两路结果不一致,审核人选择了盲算结果', '反方审稿:推翻了 1 句结论:「东区增长主要来自新客」'] const UNFINISHED = ['双盲复算「复核周报口径」:指标「订单数」无法复算:输入来自合并查询,无法单独复算'] @@ -1118,17 +1119,24 @@ await section('出具缺口:证据页、成果节点的印章把核查报上 for (const [width, theme] of [[1280, 'dark'], [390, 'dark'], [390, 'light']]) { const what = `${width}px(${theme})` const { page, errors } = await preview('verify-gaps', { extra, width, height: 900, theme }) - await page.waitForSelector('[data-evidence-verify-gaps]') - const groups = await page.evaluate(() => [...document.querySelectorAll('[data-evidence-verify-group]')].map((g) => ({ - kind: g.dataset.evidenceVerifyGroup, head: g.firstElementChild?.textContent.trim(), + await page.waitForSelector('[data-evidence-issuance] [data-issuance-banner] [data-issuance-verify-gaps]') + const groups = await page.evaluate(() => [...document.querySelectorAll('[data-evidence-issuance] [data-issuance-banner] ' + + ':is([data-issuance-verify-gaps], [data-issuance-gaps])')].map((g) => ({ + kind: g.dataset.issuanceVerifyGaps ?? 'other', head: g.firstElementChild?.textContent.trim(), items: [...g.querySelectorAll('li')].map((li) => li.textContent.replace(/^·\s*/, '').trim()), }))) - check(`${what} 证据页:横幅下面另起两组,先「核查未通过」(两路不一致、被推翻),再「核查未完成」(无法复算)`, - JSON.stringify(groups) === JSON.stringify([{ kind: 'failed', head: '核查未通过', items: FAILED }, - { kind: 'unfinished', head: '核查未完成', items: UNFINISHED }]), JSON.stringify(groups)) + check(`${what} 证据页:出具横幅里先「核查未通过」(两路不一致、被推翻),再「核查未完成」(无法复算),最后「校验未全部完成」`, + JSON.stringify(groups) === JSON.stringify([{ kind: 'failed', head: '核查未通过:', items: FAILED }, + { kind: 'unfinished', head: '核查未完成:', items: UNFINISHED }, { kind: 'other', head: '校验未全部完成:', items: OTHER }]), + JSON.stringify(groups)) const banner = await page.locator('[data-evidence-issuance] [data-issuance-gaps] li').evaluateAll(items) check(`${what} ……横幅的「校验未全部完成」里只剩校验没做完的那条`, JSON.stringify(banner) === JSON.stringify(OTHER) && (await text(page.locator('[data-evidence-issuance] [data-issuance-gaps]'))).startsWith('校验未全部完成'), JSON.stringify(banner)) + const outside = await page.evaluate((all) => [...document.querySelectorAll('[data-evidence-issuance] li')] + .filter((li) => !li.closest('[data-issuance-banner]') && all.some((g) => li.textContent.includes(g))).length, + [...FAILED, ...UNFINISHED]) + check(`${what} ……横幅外面不再另列一遍核查的缺口`, outside === 0 && await page.locator('[data-evidence-verify-gaps]').count() === 0, + `${outside} 条`) // 成果节点的印章:悬停说明里核查的两组排在「校验未全部完成」前面,各组只列自己的 const stamp = page.locator('.react-flow__node[data-id="done"] .nc-stamp') await stamp.waitFor() @@ -1140,8 +1148,7 @@ await section('出具缺口:证据页、成果节点的印章把核查报上 && at('核查未通过:') < at('核查未完成:') && at('核查未完成:') < at('校验未全部完成:'), lines.join(' / ')) const w = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) check(`${what}:证据页不横向溢出`, w <= 0, `${w}px`) - const c = await page.evaluate(() => { - const box = document.querySelector('[data-evidence-verify-gaps]') + const cs = await page.evaluate(() => [...document.querySelectorAll('[data-evidence-issuance] [data-issuance-verify-gaps]')].map((box) => { const bgOf = (el) => { for (let n = el; n && n !== document.documentElement; n = n.parentElement) { const v = getComputedStyle(n).backgroundColor @@ -1150,9 +1157,10 @@ await section('出具缺口:证据页、成果节点的印章把核查报上 return 'rgba(0, 0, 0, 0)' } return { fg: getComputedStyle(box).color, bg: bgOf(box), base: getComputedStyle(document.body).backgroundColor } - }) - const ratio = contrast(c.fg, c.bg, c.base) - check(`${what}:核查那两组的字对比度 ≥ 4.5`, ratio >= 4.5, `${ratio.toFixed(2)}(${c.fg} on ${c.bg})`) + })) + const ratios = cs.map((c) => contrast(c.fg, c.bg, c.base)) + check(`${what}:核查那两组的字对比度 ≥ 4.5`, ratios.length === 2 && ratios.every((r) => r >= 4.5), + cs.map((c, i) => `${ratios[i].toFixed(2)}(${c.fg} on ${c.bg})`).join(';')) await shot(page, `verify-gaps-${width}-${theme}`) check(`${what}:页面没有报错`, errors.length === 0, errors.join(';')) } From 4b5b3ecbf7df4509dd9294f843a6c1f323b198a8 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 13:02:30 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E6=AD=A3=E6=96=87=E7=BB=99=E8=A2=AB?= =?UTF-8?q?=E5=8F=8D=E6=96=B9=E5=AE=A1=E8=BF=87=E7=9A=84=E5=8F=A5=E5=AD=90?= =?UTF-8?q?=E6=8C=82=E5=8F=A5=E6=9C=AB=E6=A0=87=E8=AE=B0=EF=BC=8C=E3=80=8C?= =?UTF-8?q?=E8=B4=A8=E8=AF=A2=E3=80=8D=E5=9B=BE=E6=A0=87=E5=B9=B3=E6=97=B6?= =?UTF-8?q?=E7=9A=84=E5=AF=B9=E6=AF=94=E5=BA=A6=E6=8F=90=E5=88=B0=203:1=20?= =?UTF-8?q?=E4=BB=A5=E4=B8=8A=EF=BC=9AEvidenceDoc=20=E6=8C=89=E8=AF=81?= =?UTF-8?q?=E6=8D=AE=E5=9B=BE=20reports[].challenge=5Fverdicts=20=E7=BB=99?= =?UTF-8?q?=E8=A2=AB=E5=AE=A1=E5=8F=A5=E5=AD=90=E6=8C=82=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=EF=BC=88=E8=A2=AB=E6=8E=A8=E7=BF=BB=E3=80=81=E8=A2=AB=E5=89=8A?= =?UTF-8?q?=E5=BC=B1=E5=B8=A6=E5=AD=97=E9=86=92=E7=9B=AE=EF=BC=8C=E7=BB=8F?= =?UTF-8?q?=E5=8F=97=E4=BD=8F=E3=80=81=E6=97=A0=E6=B3=95=E5=88=A4=E6=96=AD?= =?UTF-8?q?=E5=8F=AA=E6=9C=89=E6=B7=A1=E8=89=B2=E5=9B=BE=E6=A0=87=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E5=B7=B2=E6=9C=89=E8=A3=81=E5=88=A4=E5=BE=BD=E6=A0=87?= =?UTF-8?q?=E7=9A=84=E5=B9=B6=E5=9C=A8=E5=90=8C=E4=B8=80=E6=9E=9A=E5=BE=BD?= =?UTF-8?q?=E6=A0=87=E9=87=8C=EF=BC=8C=E7=82=B9=E5=BC=80=E6=98=AF=E6=95=B4?= =?UTF-8?q?=E5=8F=A5=E9=9D=A2=E6=9D=BF=EF=BC=8C=E6=B2=A1=E6=9C=89=E5=8F=AF?= =?UTF-8?q?=E7=82=B9=E7=89=87=E6=AE=B5=E3=80=81=E6=B2=A1=E5=BC=80=E8=A3=81?= =?UTF-8?q?=E5=88=A4=E7=9A=84=E5=8F=A5=E5=AD=90=E4=B9=9F=E6=89=93=E5=BE=97?= =?UTF-8?q?=E5=BC=80=EF=BC=9B=E6=9C=89=E8=BF=90=E8=A1=8C=E5=B0=B1=E5=8F=96?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E8=AF=81=E6=8D=AE=E5=9B=BE=EF=BC=88=E5=90=8C?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E8=BF=90=E8=A1=8C=E5=8F=AA=E5=8F=96=E4=B8=80?= =?UTF-8?q?=E5=9B=9E=EF=BC=8C=E4=B8=8D=E9=80=90=E5=8F=A5=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E5=8F=AA=E8=AE=A4=E5=B7=A5=E4=BB=B6=20id=20?= =?UTF-8?q?=E5=AF=B9=E5=BE=97=E4=B8=8A=E7=9A=84=E9=82=A3=E4=BB=BD=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=EF=BC=9B=E8=AF=81=E6=8D=AE=E9=A1=B5=E6=8C=82=E8=BD=BD?= =?UTF-8?q?=E6=97=B6=E6=89=8B=E9=87=8C=E9=82=A3=E4=BB=BD=E8=AF=81=E6=8D=AE?= =?UTF-8?q?=E5=9B=BE=E6=98=AF=E8=BF=90=E8=A1=8C=E6=94=B6=E5=B0=BE=E5=89=8D?= =?UTF-8?q?=E5=8F=96=E7=9A=84=EF=BC=88=E6=B2=A1=E5=B0=81=E5=AD=98=EF=BC=89?= =?UTF-8?q?=E5=B0=B1=E9=87=8D=E5=8F=96=E3=80=82=E5=8F=A5=E6=9C=AB=E3=80=8C?= =?UTF-8?q?=E8=B4=A8=E8=AF=A2=E3=80=8D=E5=9B=BE=E6=A0=87=E5=B9=B3=E6=97=B6?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E6=9A=97=E5=AD=97=E8=89=B2=E5=8E=8B=E5=88=B0?= =?UTF-8?q?=2070%=EF=BC=8C=E6=B7=B1=E6=B5=85=E4=B8=A4=E5=A5=97=E3=80=81?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E5=BA=95=E8=89=B2=E5=92=8C=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E5=BA=95=E8=89=B2=E4=B8=8A=E9=83=BD=E5=9C=A8=203.3=20=E4=BB=A5?= =?UTF-8?q?=E4=B8=8A=EF=BC=8C=E4=B8=8D=E6=92=91=E5=BC=80=E6=8E=92=E7=89=88?= =?UTF-8?q?=E3=80=82check-evidence=20=E8=A1=A5=E6=A0=87=E8=AE=B0=E3=80=81?= =?UTF-8?q?=E6=95=B4=E5=8F=A5=E9=9D=A2=E6=9D=BF=E3=80=81=E6=9C=AA=E8=83=BD?= =?UTF-8?q?=E5=8F=8D=E6=9F=A5=E7=9A=84=E8=AF=B4=E6=B3=95=E5=92=8C=E5=B9=B3?= =?UTF-8?q?=E6=97=B6=E5=AF=B9=E6=AF=94=E5=BA=A6=E7=9A=84=E6=96=AD=E8=A8=80?= =?UTF-8?q?=EF=BC=8Cprobe=20=E6=94=B9=E5=9C=A8=E6=89=93=E5=BC=80=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E4=B9=8B=E5=89=8D=E6=8C=82=E8=B7=AF=E7=94=B1=EF=BC=88?= =?UTF-8?q?=E8=AF=81=E6=8D=AE=E5=9B=BE=E4=B8=80=E6=8C=82=E4=B8=8A=E5=B0=B1?= =?UTF-8?q?=E5=8F=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/dev/fixtures/evidence_ask.tsx | 27 +++ frontend/src/pages/runs/EvidencePane.tsx | 12 +- frontend/src/run/EvidenceDoc.tsx | 160 ++++++++++--- scripts/check-evidence.mjs | 249 +++++++++++++++++++-- 4 files changed, 405 insertions(+), 43 deletions(-) create mode 100644 frontend/src/dev/fixtures/evidence_ask.tsx diff --git a/frontend/src/dev/fixtures/evidence_ask.tsx b/frontend/src/dev/fixtures/evidence_ask.tsx new file mode 100644 index 00000000..08523d05 --- /dev/null +++ b/frontend/src/dev/fixtures/evidence_ask.tsx @@ -0,0 +1,27 @@ +import type { JSX } from 'react' +import { EvidenceDoc } from '../../run/EvidenceDoc' +import type { EvidenceDocData } from '../../types' +import judge from '../../run/__tests__/evidence-judge.json' + +/** + * 报告正文句末「质询」图标(审批质询)平时的样子,scripts/check-evidence.mjs 按它量平时状态的对比度: + * - ?syn=evidence-ask:同一份没开裁判的周报(run/__tests__/evidence-judge.json 的 plain,后端 compose_doc 真跑出来的, + * 内容虚构)摆两份,都给了质询入口:一份在页面底色上(同审批卡里展开的报告),一份在面板底色上(同运行详情的证据页) + * + * 证据图、片段接口由检查脚本伪造;不点「质询」,提问框不挂 + */ +const DOC = judge.plain.doc as unknown as EvidenceDocData +const ACCESS = { approvalId: 'ap-evidence-ask', host: 'evidence-ask' } + +export const cases: Record JSX.Element> = { + 'evidence-ask': () => ( +
+
+ +
+
+ +
+
+ ), +} diff --git a/frontend/src/pages/runs/EvidencePane.tsx b/frontend/src/pages/runs/EvidencePane.tsx index ac3dffee..812d814b 100644 --- a/frontend/src/pages/runs/EvidencePane.tsx +++ b/frontend/src/pages/runs/EvidencePane.tsx @@ -49,9 +49,17 @@ export function EvidencePane({ run, output, labelOf, refreshKey }: { const reloadGraph = useEvidence((s) => s.reloadGraph) const first = useRef(true) useEffect(() => { - if (first.current) { first.current = false; void loadGraph(run.id); return } + if (first.current) { + first.current = false + // 报告正文(成果区、审批卡里展开的那份)有运行就先取过证据图,取的时候运行可能还停在审批上、刚收尾还没封存: + // 运行已经结束、手里那份却说没封存时重取一次,不然这一页顶上一直写着尚未封存(之后没有新事件,refreshKey 不再变) + const held = useEvidence.getState().graphs[run.id] + if (held?.status === 'ok' && SETTLED.has(run.status) && !held.data?.seal?.sealed) void reloadGraph(run.id) + else void loadGraph(run.id) + return + } void reloadGraph(run.id) - }, [run.id, refreshKey, loadGraph, reloadGraph]) + }, [run.id, run.status, refreshKey, loadGraph, reloadGraph]) const audit = useAudit(run.id, refreshKey) const graph = graphSlot?.data diff --git a/frontend/src/run/EvidenceDoc.tsx b/frontend/src/run/EvidenceDoc.tsx index e086f702..7f8bd26f 100644 --- a/frontend/src/run/EvidenceDoc.tsx +++ b/frontend/src/run/EvidenceDoc.tsx @@ -4,7 +4,9 @@ import { type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type RefObject, } from 'react' import { createPortal } from 'react-dom' -import { ListChecks, MessageCircleQuestion } from 'lucide-react' +import { + ListChecks, MessageCircleQuestion, ShieldAlert, ShieldCheck, ShieldMinus, ShieldQuestion, ShieldX, type LucideIcon, +} from 'lucide-react' import clsx from 'clsx' import { rovingTarget } from '../components/ui' import { @@ -13,9 +15,12 @@ import { type EvidenceStateCode, type EvidenceTally as Tally, } from '../lib/evidence' import { formatNumber } from '../lib/format' -import { EVIDENCE_STATE_LABEL, EVIDENCE_TEXT, INQUIRY_TEXT, JUDGE_TEXT, claimTally, evidenceTally, type ClaimTallyCounts } from '../lib/terms' +import { + CHALLENGE_TEXT, EVIDENCE_STATE_LABEL, EVIDENCE_TEXT, INQUIRY_TEXT, JUDGE_TEXT, claimTally, evidenceTally, + type ClaimTallyCounts, +} from '../lib/terms' import { useEvidence, useVerdicts } from '../store/evidence' -import type { EvidenceBlock, EvidenceDocData, EvidenceSegment, EvidenceUnit, EvidenceVerdict } from '../types' +import type { ChallengeVerdict, EvidenceBlock, EvidenceDocData, EvidenceSegment, EvidenceUnit, EvidenceVerdict } from '../types' import { EvidencePanel, type PanelMode, type PanelView } from './EvidencePanel' import { askAbout, type InquiryAccess } from './Inquiry' import { BlockView, Markdown, StyledSlice, inlineStyles, numericColumns, type InlineStyles } from './Markdown' @@ -30,6 +35,8 @@ import { BlockView, Markdown, StyledSlice, inlineStyles, numericColumns, type In * * 结论句的判定(四期)只在句末挂一枚小徽标(◆ ◇ ! ○ ?),整句不画线;证据相矛盾(和旧的证据不支持)的整句 * 铺一层浅底。徽标也是按钮:点开是这一句的「模型的解释」。判定是模型给的,颜色一律不用确定性的绿。 + * 被反方审过的句子(期 4,证据图的 reports[].challenge_verdicts)同样在句末挂标记:被推翻、被削弱带字、醒目,经受住、 + * 无法判断只有淡色图标;有判定的句子并在同一枚徽标里。点开都是整句面板,没有可点片段的句子也打得开。 * * 键盘:整份报告只占一个 Tab 位(roving tabindex)。←/→ 在有状态的片段、句末徽标间走,↑/↓ 按 * 句子走(表格里按列走到上一行 / 下一行,出了表格接着按句子走),n / N 跳到下一处 / @@ -69,7 +76,15 @@ const unitOfAsk = (key: string) => (key.startsWith('ask:') ? key.slice(4) : null const askable = (unit: EvidenceUnit, block: EvidenceBlock) => (unit.kind ? unit.kind === 'claim' : !!unitText(unit).trim()) && !['heading', 'code', 'hr', 'table'].includes(block.type) -interface Badge { unit: EvidenceUnit; block: number; verdict: EvidenceVerdict; state: EvidenceStateCode } +/** + * 句末徽标:结论句裁判的判定(verdict、state),和 / 或反方审稿的判断(challenge)。两样都有的并在一个徽标里,不挂两个; + * 只被反方审过的句子(没开裁判的报告、裁判没送的句子)只有 challenge + */ +interface Badge { + unit: EvidenceUnit; block: number + verdict?: EvidenceVerdict; state?: EvidenceStateCode + challenge?: string +} interface Model { /** 可点的片段和句末徽标,按正文顺序 */ @@ -98,7 +113,8 @@ interface Model { grid: Map } -function buildModel(blocks: EvidenceBlock[], overlay?: Record, ask = false): Model { +function buildModel(blocks: EvidenceBlock[], overlay?: Record, ask = false, + marks?: Record): Model { const order: string[] = [] const index = new Map() const seg = new Map() @@ -132,12 +148,15 @@ function buildModel(blocks: EvidenceBlock[], overlay?: Record | undefined { + const graph = useEvidence((s) => (runId ? s.graphs[runId]?.data : undefined)) + return useMemo(() => { + const hit = (graph?.reports ?? []).find((r) => (!report || !r?.node_id || r.node_id === report) + && (!artifact || r?.doc_artifact === artifact)) + const raw: unknown = hit?.challenge_verdicts + if (!raw || typeof raw !== 'object') return undefined + const marks = Object.fromEntries(Object.entries(raw as Record) + .filter((e): e is [string, string] => !!e[0] && typeof e[1] === 'string' && !!e[1])) + return Object.keys(marks).length ? marks : undefined + }, [graph, report, artifact]) +} + +/** 句末标记读屏念的那一句:截到 40 字(同 lib/evidence 的 claimLabel) */ +const markSentence = (unit: EvidenceUnit) => { + const text = unitText(unit).trim() + return text.length > 40 ? `${text.slice(0, 39)}…` : text +} + +/** 反方审稿判断的叫法:认不出的照写原值(同审批卡) */ +const challengeLabel = (v: string) => (Object.hasOwn(CHALLENGE_TEXT.verdict, v) + ? CHALLENGE_TEXT.verdict[v as keyof typeof CHALLENGE_TEXT.verdict] : CHALLENGE_TEXT.verdictUnknown(v)) + +/** + * 句末徽标的读屏名:有判定的照裁判的说法(claimLabel),被反方审过的接着说反方审稿的判断;只被反方审过的 + * 说「结论句「…」,反方审稿:被推翻」 + */ +function badgeLabel(badge: Badge): string { + const said = badge.challenge ? CHALLENGE_TEXT.markLabel(challengeLabel(badge.challenge)) : '' + if (!badge.verdict) return JUDGE_TEXT.badgeLabel(markSentence(badge.unit), said, false) + const judged = claimLabel(badge.unit, badge.verdict) + return said ? `${judged},${said}` : judged +} + /** 片段的外观:线型走 data 属性(CSS 里只管线型),颜色写成变量交给 index.css(引文前的引号用 --ev-glyph) */ function segStyle(state: EvidenceStateCode): CSSProperties { const meta = EVIDENCE_STATE[state] @@ -257,14 +316,16 @@ export const EvidenceDoc = forwardRef buildModel(blocks, overlay, ask), [blocks, overlay, ask]) + // 被反方审过的句子:句末挂标记(和裁判的判定并在一个徽标里),点开是整句面板 + const marks = useChallengeMarks(runId, report, artifact) + const model = useMemo(() => buildModel(blocks, overlay, ask, marks), [blocks, overlay, ask, marks]) const counts = useMemo(() => docTally(doc), [doc]) const claims = useMemo(() => claimCounts(doc, overlay), [doc, overlay]) - // 探索运行的报告(开了裁判的按需文档,或调用方说了是探索运行):证据图带着这次运行里之前按需判过的句子 - // (reports[].post_seal_verdicts),取一次,重新打开页面时徽标还在。别的报告不多发这个请求 + // 有运行就取一次这次运行的证据图(同一次运行只取一次,不逐句请求):它带着被反方审过的句子(reports[].challenge_verdicts, + // 正式、探索运行都可能有,反方审稿在报告写完之后才审,文档里没有这份记录),探索运行的报告还带着之前按需判过的句子 + // (reports[].post_seal_verdicts),重新打开页面时标记、徽标都还在 const loadGraph = useEvidence((s) => s.loadGraph) - const lateOnes = doc.judge?.mode === 'on_demand' || (runClass === 'exploratory' && doc.judge?.mode !== 'inline') - useEffect(() => { if (lateOnes && runId) void loadGraph(runId) }, [lateOnes, runId, loadGraph]) + useEffect(() => { if (runId) void loadGraph(runId) }, [runId, loadGraph]) const rootRef = useRef(null) const instance = useId() const panelId = `${instance}-panel` @@ -341,7 +402,7 @@ export const EvidenceDoc = forwardRef = { + refuted: { icon: ShieldX, color: 'var(--st-failed)', soft: 'var(--st-failed-soft)', loud: true }, + weakened: { icon: ShieldMinus, color: 'var(--st-waiting)', soft: 'var(--st-waiting-soft)', loud: true }, + stands: { icon: ShieldCheck, color: 'var(--text-faint)', soft: 'var(--st-idle-soft)', loud: false }, + inconclusive: { icon: ShieldQuestion, color: 'var(--text-faint)', soft: 'var(--st-idle-soft)', loud: false }, +} +const markLook = (verdict: string): MarkLook => (Object.hasOwn(MARK_LOOK, verdict) ? MARK_LOOK[verdict as ChallengeVerdict] + : { icon: ShieldAlert, color: 'var(--text-faint)', soft: 'var(--st-idle-soft)', loud: false }) + +/** + * 句末徽标里反方审稿的那一截。和裁判的字形并在一个徽标里时跟在字形后面(after)。醒目的那两种连字一起写:字号定成 + * 10px(同句末的「无证据」小标签),窄栏里 0.72em 的上标只有 8px,汉字认不清 + */ +function ChallengeMark({ verdict, look, after }: { verdict: string; look: MarkLook; after: boolean }) { + const Icon = look.icon + return ( + + ) +} + /** * 一句话:文字按行内 Markdown 渲染(整句算一次样式,跨片段的 `code`、**粗体** 不会 * 被切坏),有状态的片段包成按钮。含无证据片段的句子末尾挂「? 无证据」;有判定的结论句末尾挂 - * 一枚徽标(也是按钮),证据相矛盾(和旧的证据不支持)的整句铺浅底——字本身不画线 + * 一枚徽标(也是按钮),证据相矛盾(和旧的证据不支持)的整句铺浅底——字本身不画线。被反方审过的 + * 句子同样挂徽标(和判定并在一起),点开都是整句面板 */ function UnitView({ unit, doc, current, active, panelId, code, tag, badge, ask }: { unit: EvidenceUnit; doc: EvidenceDocData; current: string | null; active: string | null; panelId: string @@ -911,8 +1004,18 @@ function UnitView({ unit, doc, current, active, panelId, code, tag, badge, ask } ) }) const key = badge ? badgeKey(unit.id) : '' - const meta = badge ? EVIDENCE_STATE[badge.state] : null + const meta = badge?.state ? EVIDENCE_STATE[badge.state] : null const open = !!badge && active === key + const verdict = badge?.verdict + // 模型判过的写明谁判的、非确定(证据不足的接着写缺什么);模型没判过的(按需还没点、到上限、没跑成)只说 + // 这是什么状态——到上限没判的也记着裁判模型,但不能写成「(模型 · 非确定)」,像是它判过一样 + const judgedTitle = !verdict || !meta ? '' + : isJudged(verdict) + ? `${meta.label}${verdict.post_seal ? ` · ${JUDGE_TEXT.postSeal}` : ''}(${verdict.judge || JUDGE_TEXT.judgeModel} · 非确定)${ + verdictMissing(verdict) ? `。${JUDGE_TEXT.missing(verdictMissing(verdict))}` : ''}` + : limitOf(verdict) ? `${meta.label}:${JUDGE_TEXT.limitNotJudged}` : `${meta.label}:${meta.hint}` + const challengeTitle = badge?.challenge ? CHALLENGE_TEXT.markTitle(challengeLabel(badge.challenge)) : '' + const look = badge?.challenge ? markLook(badge.challenge) : null const body = ( <> {conflicting(badge?.state) @@ -924,32 +1027,33 @@ function UnitView({ unit, doc, current, active, panelId, code, tag, badge, ask } {EVIDENCE_STATE[state].glyph}{TAG_TEXT[state]} ))} - {badge && meta && ( + {badge && (meta || look) && ( )} ) if (!ask) return body // 「质询」跟在句末,是一个固定宽度的小图标:平时淡淡的,悬停这一句、焦点落在这一句上时显出来(强调色、描边)。 + // 平时也得看得清它是个可点的东西(WCAG 1.4.11 要求界面组件对背景 ≥ 3:1):最淡的字色再压到 60% 不透明,深色约 2.75、 + // 浅色约 2.33,看不出来。改用次一级的暗字色压到 70%:深浅两套、报告摆在页面底色或面板底色上都在 3.3 以上,仍比正文 + // 淡得多;不透明度留在 0.75 以下,和悬停、聚焦时显出来(不透明、强调色)分得开。 // 显没显出来只换颜色,宽度一直是那么宽——悬停时撑开的话,后面的字整体右移,鼠标底下的数字跟着挪走,点不到。 // 触屏没有悬停,一直显出来,并写上「质询」两个字。 // 跟随的那种只在焦点在这一句上时进 Tab 顺序,整份报告不因此多出一串 Tab 位 @@ -961,7 +1065,7 @@ function UnitView({ unit, doc, current, active, panelId, code, tag, badge, ask } +
+ +
+
+ ) +} + +type Stage = 'old' | 'new' | 'switch' | 'output' | 'pane-interrupted' | 'pane-succeeded' +const STAGES: [Stage, string][] = [ + ['old', '审批卡 · 初稿'], ['new', '第二次审批 · 新稿'], ['switch', '同一张卡换稿'], ['output', '成果区 · 新稿'], + ['pane-interrupted', '证据页 · 停在审批上'], ['pane-succeeded', '证据页 · 运行已结束'], +] + +function runOf(status: 'interrupted' | 'succeeded'): Run { + const done = status === 'succeeded' + return { + id: RUN, workflow_id: null, workflow_name: '周报', status, input: {}, output: {}, error: null, + usage: { input_tokens: 0, output_tokens: 0, cost_usd: 0 } as Run['usage'], run_class: 'formal', version: 2, + manifest_hash: done ? 'c'.repeat(64) : null, manifest_seq: done ? 64 : null, + created_at: '2026-10-05T06:00:00', started_at: '2026-10-05T06:00:00', + } +} + +/** 审批卡里展开的报告(同 run/ApprovalReport 的 ReportDoc:限高、窄栏、自带证据条) */ +function Card({ doc, artifact }: { doc: EvidenceDocData; artifact: string }) { + return ( +
+ +
+ ) +} + +/** 同一个 EvidenceDoc 实例从初稿换到新稿 */ +function Switch() { + const [fresh, setFresh] = useState(false) + return ( +
+ +
+ +
+
+ ) +} + +function Stale() { + // 首次渲染前把两份文档放进 store:文档按工件 id 永久缓存,证据页、成果区按工件 id 取的就是它们 + useState(() => { + useEvidence.setState((s) => ({ + docs: { ...s.docs, [OLD_ART]: { status: 'ok', data: OLD }, [NEW_ART]: { status: 'ok', data: NEW } }, + })) + return true + }) + const [at, setAt] = useState<{ stage: Stage; n: number }>({ stage: 'old', n: 0 }) + const go = (stage: Stage) => setAt((cur) => ({ stage, n: cur.n + 1 })) + return ( +
+
+ {STAGES.map(([stage, label]) => ( + + ))} +
+ {/* 每按一次都按新的 key 重新挂:同一场景按两次,看手里那份图对上了还会不会重取 */} +
+ {at.stage === 'old' && } + {at.stage === 'new' && } + {at.stage === 'switch' && } + {at.stage === 'output' && ( +
+ +
+ )} + {(at.stage === 'pane-interrupted' || at.stage === 'pane-succeeded') && ( + (id === 'write' ? '写周报' : undefined)} refreshKey="0" /> + )} +
+
+ ) +} + +export const cases: Record JSX.Element> = { + 'evidence-marks-text': () => , + 'evidence-marks-stale': () => , +} diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index e8a4013a..aedba907 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3963,8 +3963,22 @@ export const CHALLENGE_TEXT = { claimUnreviewed: '这一句未能反查,已记为无法判断', /** 同上,但无法判断的句子里还有查过了、数据不够的:说不准是不是这一句 */ claimMaybeUnreviewed: (n: number) => `这份报告有 ${formatNumber(n)} 句未能反查,已记为无法判断,这一句可能在其中`, + /** + * 单句展示的先行版:只知道判断(正文标记用的证据图),这一句的理由、查询和整份报告的计数要等片段接口答回来。 + * 还在取、取不到(给重试)、答了却没有可用的内容 + */ + claimPending: '正在获取反方的理由与查询…', + claimFailed: '未能获取反方的理由与查询', + claimNoDetail: '无法查看反方的理由与查询', + /** + * 无法判断的落款:未能反查的句子也记为无法判断(服务端按规则记的,反方模型没看过这一句),说不准是哪一种时不说成反方 + * 模型的判断。证据面板先行版的落款、正文句末标记的悬停说明都用这一句 + */ + judgedUnsure: '可能是反方模型的判断,也可能是未能反查时记下的,并非系统核对', /** 正文句末标记的悬停说明:判断是反方模型给的 */ markTitle: (verdict: string) => `反方审稿:${verdict}(反方模型的判断,并非系统核对)`, + /** 同上,无法判断的那一种(括号里同 judgedUnsure) */ + markTitleUnsure: (verdict: string) => `反方审稿:${verdict}(可能是反方模型的判断,也可能是未能反查时记下的,并非系统核对)`, /** 句末标记读屏念的那一段:跟在句子后面(只有反方审稿时),或接在裁判的判定后面(两样都有时,并在一个标记里) */ markLabel: (verdict: string) => `反方审稿:${verdict}`, diff --git a/frontend/src/pages/runs/EvidencePane.tsx b/frontend/src/pages/runs/EvidencePane.tsx index 812d814b..531d895c 100644 --- a/frontend/src/pages/runs/EvidencePane.tsx +++ b/frontend/src/pages/runs/EvidencePane.tsx @@ -48,20 +48,40 @@ export function EvidencePane({ run, output, labelOf, refreshKey }: { const loadGraph = useEvidence((s) => s.loadGraph) const reloadGraph = useEvidence((s) => s.reloadGraph) const first = useRef(true) + // 报告正文(审批卡里展开的、成果区的)有运行就先取过证据图,取的时候运行可能还停在审批上(之后报告又改写过)、刚收尾还没 + // 封存,也可能是接着跑之前封存的那一版。挂上时手里那份不是「封存了、封存的就是这一版」就重取一次,不看运行停在哪:之后 + // 没有新事件,refreshKey 不再变,过期的那份会一直留着。别处正在取的那份取回来再看一眼(recheck)。 + // 重取回来之前先占位(renewing),不画手里那份:画出来的是旧稿,审计表却是挂上时新取的,点一行会按片段 id 打开旧稿里同号 + // 的片段。第一次渲染就按挂上那一刻手里的那份定,不先画一帧旧稿再收起来;换下来的判据是 store 里那一格不再是它 + const [held] = useState(() => useEvidence.getState().graphs[run.id]) + const [renewing, setRenewing] = useState(() => staleHeld(held, run.manifest_seq)) + const recheck = useRef(false) useEffect(() => { if (first.current) { first.current = false - // 报告正文(成果区、审批卡里展开的那份)有运行就先取过证据图,取的时候运行可能还停在审批上、刚收尾还没封存: - // 运行已经结束、手里那份却说没封存时重取一次,不然这一页顶上一直写着尚未封存(之后没有新事件,refreshKey 不再变) - const held = useEvidence.getState().graphs[run.id] - if (held?.status === 'ok' && SETTLED.has(run.status) && !held.data?.seal?.sealed) void reloadGraph(run.id) - else void loadGraph(run.id) + if (!staleHeld(held, run.manifest_seq)) void loadGraph(run.id) + else if (held?.status === 'loading') recheck.current = true + else void reloadGraph(run.id) return } void reloadGraph(run.id) - }, [run.id, run.status, refreshKey, loadGraph, reloadGraph]) + // held 是挂上那一刻的,之后不变 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [run.id, run.status, run.manifest_seq, refreshKey, loadGraph, reloadGraph]) + useEffect(() => { + // 挂上时那一份还没换下来(重取还没发出、还没回来),或者正在取:接着等 + if (!renewing || graphSlot === held || graphSlot?.status === 'loading') return + if (recheck.current) { + recheck.current = false + if (graphSlot?.status === 'ok' && !sealedNow(graphSlot.data, run.manifest_seq)) { + void reloadGraph(run.id) + return + } + } + setRenewing(false) + }, [renewing, graphSlot, held, run.id, run.manifest_seq, reloadGraph]) const audit = useAudit(run.id, refreshKey) - const graph = graphSlot?.data + const graph = renewing ? undefined : graphSlot?.data const [root, setRoot] = useState(null) const wide = useWide(root, 880) @@ -232,6 +252,17 @@ const openKey = (row: AuditRow): string | undefined => /** 已经收尾的运行:还在跑、停在审批上的,报告可能还没写,说「这次的报告没有逐段证据」太早 */ const SETTLED = new Set(['succeeded', 'failed', 'cancelled']) +/** + * 手里这份证据图能不能直接用:封存了,而且封存的就是运行现在这一版(失败后接着跑的运行会重新封存,manifest_seq 跟着变)。 + * 运行记录上没有 manifest_seq(老运行)、老服务端的证据图没给时只看封没封存 + */ +const sealedNow = (graph: EvidenceGraph | undefined, seq: number | null | undefined) => + !!graph?.seal?.sealed && (seq == null || graph.seal.manifest_seq == null || graph.seal.manifest_seq === seq) + +/** 证据页挂上时手里那份(别处取的)要不要重取:正在取的取回来再看;取到了的不是封存的这一版就重取。没有、取失败的照常取 */ +const staleHeld = (held: { status: string; data?: EvidenceGraph } | undefined, seq: number | null | undefined) => + held?.status === 'loading' || (held?.status === 'ok' && !sealedNow(held.data, seq)) + /** * 工作流现在的图能不能一键升级:拿它去校验,看有没有 evidence.upgrade_available 这条建议。老后端不给 * 这条建议,横幅也就不出现(它也没有升级接口)。按工作流 id 和版本记住答案:同一张图的几条运行不重复问; diff --git a/frontend/src/run/ChallengeSummary.tsx b/frontend/src/run/ChallengeSummary.tsx index a0a0db4d..5a277302 100644 --- a/frontend/src/run/ChallengeSummary.tsx +++ b/frontend/src/run/ChallengeSummary.tsx @@ -42,7 +42,8 @@ export interface ChallengeSummaryProps { * - 审批卡(scope 为 report),紧跟在报告核对概况(ApprovalReports)后面:一行概况(四种判断各几句),出具相关的那一句, * 有句子反查没完成时再说一行「N 句未能反查」,逐句列出结论原文和判断,点开一句再按 challenge_artifact 取完整内容,看 * 反方的理由和它执行过的查询。审稿记录没存上(没有工件)时只列判断、不能点开,整块说一次。 - * - 证据面板(scope 为 claim、issuance 为 false):一句话的判断,展开同样按 challenge_artifact 取这一句的理由和查询。 + * - 证据面板(scope 为 claim、issuance 为 false):一句话的判断,展开同样按 challenge_artifact 取这一句的理由和查询。片段 + * 接口还没答回来、答不了时,由 ClaimVerdictPending 先按正文标记(证据图)写出判断,说清在等什么、给重试。 * * 判断是反方模型给的,不是系统核对:颜色照结论句裁判的那一套,「经受住」也不用确定性的绿。 * 「出具会降档」只在谈出具(issuance)、正式运行、有被推翻的结论、被推翻的真会让出具降档时才写:服务端给了 degrades 就照它 @@ -443,8 +444,45 @@ function ClaimBlock({ report, challenge, run }: {
)} {noRecord &&

{CHALLENGE_TEXT.noRecord}

} - {/* 判断标签看着像结论,得说清是谁判的:展开取到审稿记录之后带上模型名 */} -

{CHALLENGE_TEXT.judgedBy(detail.data?.model ?? null)}

+ {/* 判断标签看着像结论,得说清是谁判的:展开取到审稿记录之后带上模型名。这一句一定没反查成(surely)的不写:那是 + 服务端按规则记的无法判断,反方模型没看过这一句,上面一行已经说了。说不准的(maybe)照旧写 */} + {!surely &&

{CHALLENGE_TEXT.judgedBy(detail.data?.model ?? null)}

} + + ) +} + +/** 证据面板先行版在等什么:片段接口还在取、取不到、答了却没有可用的 unit.challenge */ +export type ClaimWait = 'loading' | 'error' | 'missing' + +/** + * 单句展示的先行版(证据面板):只知道这一句的判断(正文标记用的证据图 challenge_verdicts),审稿记录、整份报告的计数要等 + * 片段接口的 unit.challenge。判断标签先画出来——用户是点着句末醒目的「被推翻」进来的,这一节不能空着;还在取时说正在获取, + * 取不到给重试(onRetry),答了却没有可用的内容时照实说看不了。拿到 unit.challenge 之后调用方换成完整的单句展示 + * (ChallengeSummary 的 scope="claim")。无法判断的说不准是不是根本没反查成(要整份报告的计数才分得出),落款不说成反方模型 + * 的判断。不谈出具,同证据面板里的完整展示 + */ +export function ClaimVerdictPending({ verdict, wait, onRetry }: { verdict: string; wait: ClaimWait; onRetry?: () => void }) { + return ( +
+
+ +
+ {wait === 'loading' ? ( +

{CHALLENGE_TEXT.claimPending}

+ ) : ( +
+ + {wait === 'error' ? CHALLENGE_TEXT.claimFailed : CHALLENGE_TEXT.claimNoDetail} + + {wait === 'error' && onRetry && ( + + )} +
+ )} +

+ {verdict === 'inconclusive' ? CHALLENGE_TEXT.judgedUnsure : CHALLENGE_TEXT.judgedBy(null)} +

) } diff --git a/frontend/src/run/EvidenceDoc.tsx b/frontend/src/run/EvidenceDoc.tsx index 7f8bd26f..454e2e35 100644 --- a/frontend/src/run/EvidenceDoc.tsx +++ b/frontend/src/run/EvidenceDoc.tsx @@ -5,7 +5,7 @@ import { } from 'react' import { createPortal } from 'react-dom' import { - ListChecks, MessageCircleQuestion, ShieldAlert, ShieldCheck, ShieldMinus, ShieldQuestion, ShieldX, type LucideIcon, + ListChecks, MessageCircleQuestion, ShieldAlert, ShieldCheck, ShieldEllipsis, ShieldMinus, ShieldX, type LucideIcon, } from 'lucide-react' import clsx from 'clsx' import { rovingTarget } from '../components/ui' @@ -19,8 +19,11 @@ import { CHALLENGE_TEXT, EVIDENCE_STATE_LABEL, EVIDENCE_TEXT, INQUIRY_TEXT, JUDGE_TEXT, claimTally, evidenceTally, type ClaimTallyCounts, } from '../lib/terms' -import { useEvidence, useVerdicts } from '../store/evidence' -import type { ChallengeVerdict, EvidenceBlock, EvidenceDocData, EvidenceSegment, EvidenceUnit, EvidenceVerdict } from '../types' +import { useEvidence, useVerdicts, type Slot } from '../store/evidence' +import type { + ChallengeVerdict, EvidenceBlock, EvidenceChallengeVerdicts, EvidenceDocData, EvidenceGraph, EvidenceSegment, EvidenceUnit, + EvidenceVerdict, +} from '../types' import { EvidencePanel, type PanelMode, type PanelView } from './EvidencePanel' import { askAbout, type InquiryAccess } from './Inquiry' import { BlockView, Markdown, StyledSlice, inlineStyles, numericColumns, type InlineStyles } from './Markdown' @@ -36,7 +39,8 @@ import { BlockView, Markdown, StyledSlice, inlineStyles, numericColumns, type In * 结论句的判定(四期)只在句末挂一枚小徽标(◆ ◇ ! ○ ?),整句不画线;证据相矛盾(和旧的证据不支持)的整句 * 铺一层浅底。徽标也是按钮:点开是这一句的「模型的解释」。判定是模型给的,颜色一律不用确定性的绿。 * 被反方审过的句子(期 4,证据图的 reports[].challenge_verdicts)同样在句末挂标记:被推翻、被削弱带字、醒目,经受住、 - * 无法判断只有淡色图标;有判定的句子并在同一枚徽标里。点开都是整句面板,没有可点片段的句子也打得开。 + * 无法判断只有淡色图标;有判定的句子并在同一枚徽标里。点开都是整句面板,没有可点片段的句子也打得开。证据图有运行就取 + * 一次;手里那份是别处早先取的、已经过期(报告改写过、运行收尾之前取的)时重取一次,见 useRunGraph。 * * 键盘:整份报告只占一个 Tab 位(roving tabindex)。←/→ 在有状态的片段、句末徽标间走,↑/↓ 按 * 句子走(表格里按列走到上一行 / 下一行,出了表格接着按句子走),n / N 跳到下一处 / @@ -217,23 +221,61 @@ function verticalTarget(model: Model, id: string, down: boolean): string | null return model.unitFirst[(u + (down ? 1 : -1) + n) % n] ?? null } +type GraphReport = NonNullable[number] + /** - * 这份报告里被反方审过的句子 → 判断(证据图 reports[].challenge_verdicts)。只认证据图里这份报告、而且封存的就是正文 - * 这一份(工件 id 对得上)的那一项:证据图说的是这个报告节点最后写的那一稿,正文是另一稿时同一个句子编号说的可能是别的话。 + * 证据图里的这一项说的是不是正文这一份:同一个报告节点,封存的就是这一稿(工件 id 对得上)。证据图说的是这个报告节点最后 + * 写的那一稿,正文是另一稿时同一个句子编号说的可能是别的话。正文没记报告节点、没给工件 id 的(预览、老文档)不按它比 + */ +const sameReport = (r: GraphReport | undefined, report: string | undefined, artifact: string | undefined) => + (!report || !r?.node_id || r.node_id === report) && (!artifact || r?.doc_artifact === artifact) + +/** + * 这次运行的证据图(正文的反方标记、按需判过的句子都从这里来):有运行就取一次,同一次运行只取一回,不逐句请求。 + * + * 挂上(或换了一稿)那一刻手里已经有的那一份是别处早先取的,可能过期了。审批卡里展开过报告,取的就是运行停在审批上那一刻 + * 的图:之后报告改写了(第二次审批、同一张卡换稿),正文这一稿在那份图里找不到,一个标记都挂不上;正文是运行的成果 + * (settled)而那份图没封存,审批之后才审完的反方审稿不在里面。这两种按(运行、报告、工件)重取一次,重取回来还是这样 + * 就作罢(正文是改写前的原件时本来就对不上),不反复请求。挂上时还没有、正在取的那一份是这时候取的,不算过期。证据页自己 + * 管重取(pages/runs/EvidencePane) + */ +function useRunGraph(runId: string | undefined, report: string | undefined, artifact: string | undefined, settled: boolean): + EvidenceGraph | undefined { + const slot = useEvidence((s) => (runId ? s.graphs[runId] : undefined)) + const loadGraph = useEvidence((s) => s.loadGraph) + const reloadGraph = useEvidence((s) => s.reloadGraph) + const key = runId ? `${runId}\u0000${report ?? ''}\u0000${artifact ?? ''}` : '' + // 挂上、换了一稿那一刻手里已经取到的那一份:只有它可能过期,看过一次就放下 + const held = useRef<{ key: string; slot: Slot | null }>({ key: '', slot: null }) + useEffect(() => { + if (!runId || held.current.key === key) return + const cur = useEvidence.getState().graphs[runId] + held.current = { key, slot: cur?.status === 'ok' ? cur : null } + void loadGraph(runId) + }, [runId, key, loadGraph]) + useEffect(() => { + const h = held.current + if (!runId || h.key !== key || !h.slot || slot !== h.slot) return + h.slot = null + const graph = slot.data + const mine = (graph?.reports ?? []).some((r) => sameReport(r, report, artifact)) + if (!mine || (settled && !graph?.seal?.sealed)) void reloadGraph(runId) + }, [runId, key, slot, report, artifact, settled, reloadGraph]) + return slot?.data +} + +/** + * 这份报告里被反方审过的句子 → 判断(证据图 reports[].challenge_verdicts),只认说的就是正文这一份的那一项(sameReport)。 * 没有运行、证据图还没取到(或取不到)、一句都没审、老服务端都是 undefined */ -function useChallengeMarks(runId: string | undefined, report: string | undefined, artifact: string | undefined): - Record | undefined { - const graph = useEvidence((s) => (runId ? s.graphs[runId]?.data : undefined)) - return useMemo(() => { - const hit = (graph?.reports ?? []).find((r) => (!report || !r?.node_id || r.node_id === report) - && (!artifact || r?.doc_artifact === artifact)) - const raw: unknown = hit?.challenge_verdicts - if (!raw || typeof raw !== 'object') return undefined - const marks = Object.fromEntries(Object.entries(raw as Record) - .filter((e): e is [string, string] => !!e[0] && typeof e[1] === 'string' && !!e[1])) - return Object.keys(marks).length ? marks : undefined - }, [graph, report, artifact]) +function challengeMarks(graph: EvidenceGraph | undefined, report: string | undefined, artifact: string | undefined): + EvidenceChallengeVerdicts | undefined { + const hit = (graph?.reports ?? []).find((r) => sameReport(r, report, artifact)) + const raw: unknown = hit?.challenge_verdicts + if (!raw || typeof raw !== 'object') return undefined + const marks = Object.fromEntries(Object.entries(raw as Record) + .filter((e): e is [string, string] => !!e[0] && typeof e[1] === 'string' && !!e[1])) + return Object.keys(marks).length ? marks : undefined } /** 句末标记读屏念的那一句:截到 40 字(同 lib/evidence 的 claimLabel) */ @@ -302,6 +344,11 @@ export const EvidenceDoc = forwardRef(function EvidenceDoc({ doc, artifact, runId, dense = false, label, panel = 'auto', dock, onView, tally = false, runClass, - tier, inquiry }, ref) { + tier, settled = false, inquiry }, ref) { const blocks = useMemo(() => doc.blocks ?? [], [doc]) const report = doc.node_id || undefined // 质询要知道是哪次运行的哪份报告:两样缺一样就不挂入口 const ask = !!inquiry && !!runId && !!report // 封存之后按需追加的判定:盖过文档里的「未裁判 · 按需」。只换句末徽标,封存的文档一个字不改 const overlay = useVerdicts(runId, report) + // 有运行就取这次运行的证据图(同一次运行只取一次,不逐句请求;过期了重取一次):它带着被反方审过的句子 + // (reports[].challenge_verdicts,正式、探索运行都可能有,反方审稿在报告写完之后才审,文档里没有这份记录),探索运行的 + // 报告还带着之前按需判过的句子(reports[].post_seal_verdicts,useVerdicts 读),重新打开页面时标记、徽标都还在 + const graph = useRunGraph(runId, report, artifact, settled) // 被反方审过的句子:句末挂标记(和裁判的判定并在一个徽标里),点开是整句面板 - const marks = useChallengeMarks(runId, report, artifact) + const marks = useMemo(() => challengeMarks(graph, report, artifact), [graph, report, artifact]) const model = useMemo(() => buildModel(blocks, overlay, ask, marks), [blocks, overlay, ask, marks]) const counts = useMemo(() => docTally(doc), [doc]) const claims = useMemo(() => claimCounts(doc, overlay), [doc, overlay]) - // 有运行就取一次这次运行的证据图(同一次运行只取一次,不逐句请求):它带着被反方审过的句子(reports[].challenge_verdicts, - // 正式、探索运行都可能有,反方审稿在报告写完之后才审,文档里没有这份记录),探索运行的报告还带着之前按需判过的句子 - // (reports[].post_seal_verdicts),重新打开页面时标记、徽标都还在 - const loadGraph = useEvidence((s) => s.loadGraph) - useEffect(() => { if (runId) void loadGraph(runId) }, [runId, loadGraph]) const rootRef = useRef(null) const instance = useId() const panelId = `${instance}-panel` @@ -344,9 +390,10 @@ export const EvidenceDoc = forwardRef { - if (current && !model.index.has(current)) setCurrent(model.order[0] ?? null) + if (current ? !model.index.has(current) : model.order.length > 0) setCurrent(model.order[0] ?? null) }, [model, current]) // 同一时刻只开一个面板:另一份报告打开了自己的面板,这边的就收起 @@ -402,7 +449,8 @@ export const EvidenceDoc = forwardRef close()} onLocate={(id) => { focusSeg(id, { flash: true }); openSeg(id) }} @@ -921,22 +970,31 @@ const DocBlock = memo(function DocBlock({ block, dense, doc, visual, current, ac } }) -/** 反方审稿的判断在句末徽标里的样子 */ +/** 反方审稿的判断在句末徽标里的样子。soft 是悬停、展开时铺的浅底,醒目的那两种不铺(见 LOUD_RING) */ interface MarkLook { icon: LucideIcon; color: string; soft: string; loud: boolean } /** * 被推翻、被削弱醒目:盾牌图标加判断两个字,用失败色、提醒色;经受住、无法判断低调:只有淡色的盾牌图标(悬停、读屏照样 - * 说得出是哪一种)。四种图标各不相同,去掉颜色也分得开;判断是反方模型给的,经受住也不用确定性的绿。认不出的判断按低调画 + * 说得出是哪一种)。四种图标各不相同,去掉颜色也分得开:无法判断用省略号不用问号——句末常常已经有裁判的「?」(未裁判、 + * 证据不足)和「质询」的问号,再添一个问号就分不清了,和经受住的勾也更好分。判断是反方模型给的,经受住也不用确定性的绿。 + * 认不出的判断按低调画 */ const MARK_LOOK: Record = { - refuted: { icon: ShieldX, color: 'var(--st-failed)', soft: 'var(--st-failed-soft)', loud: true }, - weakened: { icon: ShieldMinus, color: 'var(--st-waiting)', soft: 'var(--st-waiting-soft)', loud: true }, + refuted: { icon: ShieldX, color: 'var(--st-failed)', soft: 'transparent', loud: true }, + weakened: { icon: ShieldMinus, color: 'var(--st-waiting)', soft: 'transparent', loud: true }, stands: { icon: ShieldCheck, color: 'var(--text-faint)', soft: 'var(--st-idle-soft)', loud: false }, - inconclusive: { icon: ShieldQuestion, color: 'var(--text-faint)', soft: 'var(--st-idle-soft)', loud: false }, + inconclusive: { icon: ShieldEllipsis, color: 'var(--text-faint)', soft: 'var(--st-idle-soft)', loud: false }, } const markLook = (verdict: string): MarkLook => (Object.hasOwn(MARK_LOOK, verdict) ? MARK_LOOK[verdict as ChallengeVerdict] : { icon: ShieldAlert, color: 'var(--text-faint)', soft: 'var(--st-idle-soft)', loud: false }) +/** + * 带字的醒目标记悬停、展开时不铺浅底(index.css 的 .ev-badge 按 --ev-soft 铺),改描一圈细边:10px 的失败色、提醒色字叠在 + * 14% 的浅底上,浅色主题里对比度只有 4.0 左右(页面底色上),面板开着时这枚标记一直是展开态。和裁判徽标并在一起的同样 + * 不铺(浅底是裁判判定的颜色,字是反方的颜色)。细边用徽标的字色,和焦点环(2px 强调色、外扩)分得开 + */ +const LOUD_RING = 'hover:shadow-[inset_0_0_0_1px_currentColor] aria-expanded:shadow-[inset_0_0_0_1px_currentColor]' + /** * 句末徽标里反方审稿的那一截。和裁判的字形并在一个徽标里时跟在字形后面(after)。醒目的那两种连字一起写:字号定成 * 10px(同句末的「无证据」小标签),窄栏里 0.72em 的上标只有 8px,汉字认不清 @@ -1014,7 +1072,10 @@ function UnitView({ unit, doc, current, active, panelId, code, tag, badge, ask } ? `${meta.label}${verdict.post_seal ? ` · ${JUDGE_TEXT.postSeal}` : ''}(${verdict.judge || JUDGE_TEXT.judgeModel} · 非确定)${ verdictMissing(verdict) ? `。${JUDGE_TEXT.missing(verdictMissing(verdict))}` : ''}` : limitOf(verdict) ? `${meta.label}:${JUDGE_TEXT.limitNotJudged}` : `${meta.label}:${meta.hint}` - const challengeTitle = badge?.challenge ? CHALLENGE_TEXT.markTitle(challengeLabel(badge.challenge)) : '' + // 无法判断的句子可能根本没反查成(服务端按规则记的无法判断,反方模型没看过),正文里分不出是哪一种:不说成反方模型的判断 + const challengeTitle = !badge?.challenge ? '' + : badge.challenge === 'inconclusive' ? CHALLENGE_TEXT.markTitleUnsure(challengeLabel(badge.challenge)) + : CHALLENGE_TEXT.markTitle(challengeLabel(badge.challenge)) const look = badge?.challenge ? markLook(badge.challenge) : null const body = ( <> @@ -1030,7 +1091,7 @@ function UnitView({ unit, doc, current, active, panelId, code, tag, badge, ask } {badge && (meta || look) && (