合入期 4 整合 J3:出具横幅按「核查未通过 / 核查未完成 / 校验未全部完成」分组;审批卡把运行类别交给反方审稿概况;证据接口的核查类型契约挪进 types.ts;反方审稿加单句模式、证据面板不再谈出具;证据接口带上句子的反方判断映射,正文给被审过的句子挂句末标记;质询图标平时对比度达标

This commit is contained in:
ThearchyHelios
2026-10-06 16:23:33 +08:00
18 changed files with 1985 additions and 274 deletions
+55 -18
View File
@@ -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]:
"""四种判断各几句:取节点产出(或审完时的日志)里的计数;一个数都认不出来时按认得的句子数(审稿记录里的是全部,
记录没存上时只有产出里列的那几句)。"""
@@ -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"}
+20 -6
View File
@@ -31,11 +31,16 @@ 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)
*
* 证据面板的单句展示(scope="claim"):
* - ?syn=challenge-claim-quiet:被推翻的那一句,「不谈出具」(issuance={false})。给了正式运行、运行 id,服务端也说被推翻的
* 会降档(degrades)——审批卡上这几样凑齐就写「出具会降档」;这里一句和出具有关的话都不说,也不为此取运行类别
*
* 运行类别(GET /api/runs/{id})、工件(GET /api/artifacts/{id})都由检查脚本用 page.route 伪造;直接打开时取不到,
* 卡上照实不说降档、点开一句说无法获取。
*
@@ -68,9 +73,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<Challenge>): ApprovalReport => ({ ...report, challenge: { ...challenge, ...c } as ApprovalChallenge })
/** 期 4 起服务端另带的两项(unreviewed 没能反查的句数、degrades 被推翻的会不会让出具降档)类型里都有,按需盖上 */
const withChallenge = (c: Partial<ApprovalChallenge>): ApprovalReport => ({ ...report, challenge: { ...challenge, ...c } })
const many = fx.many as unknown as ApprovalChallenge
/** 没有被推翻的结论:同第一个用例去掉被推翻的那句,计数跟着改 */
const clear: Partial<ApprovalChallenge> = {
@@ -82,7 +86,7 @@ const clear: Partial<ApprovalChallenge> = {
* 记为无法判断,unreviewed 不会多于 inconclusive)。原来无法判断的 u11 是查过了、数据不够,不算未能反查
*/
const UNREVIEWED_UNITS = ['u5', 'u8']
const unreviewed: Partial<Challenge> = {
const unreviewed: Partial<ApprovalChallenge> = {
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,10 +249,20 @@ export const cases: Record<string, () => JSX.Element> = {
<ChallengeSummary reports={[withChallenge({ degrades: true })]} runId={RUNS.given} runClass="formal" />
</div>
),
'challenge-approval-given': () => (
<Card name="challenge-approval-given"
approval={approvalOf('ap-chal-17', RUNS.given, 'formal', withChallenge({ degrades: true }))} />
),
'challenge-approval-offpath': () => (
<Card name="challenge-approval-offpath"
approval={approvalOf('ap-chal-15', RUNS.offpath, 'formal', withChallenge({ on_refuted: 'degrade', degrades: false, unreviewed: 0 }))} />
),
'challenge-claim-quiet': () => (
<div className="mx-auto max-w-md p-4" data-fixture="challenge-claim-quiet">
<ChallengeSummary reports={[withChallenge({ degrades: true, items: challenge.items.filter((it) => it.verdict === 'refuted') })]}
runId={RUNS.formal} runClass="formal" scope="claim" issuance={false} />
</div>
),
'challenge-inspector': () => <InspectorCase name="challenge-inspector" workflow={WORKFLOW} />,
'challenge-inspector-chain': () => <InspectorCase name="challenge-inspector-chain" workflow={CHAIN} />,
}
@@ -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<string, () => JSX.Element> = {
'evidence-ask': () => (
<div className="flex max-w-3xl flex-col gap-3 p-4" data-fixture="evidence-ask">
<div className="rounded-lg border bg-bg p-3" data-ask-on="bg">
<EvidenceDoc doc={DOC} artifact={judge.plain.doc_artifact} runId={judge.plain.run_id} label="周报" inquiry={ACCESS} />
</div>
<div className="rounded-lg border bg-panel p-3" data-ask-on="panel">
<EvidenceDoc doc={DOC} artifact={judge.plain.doc_artifact} runId={judge.plain.run_id} label="周报" inquiry={ACCESS} />
</div>
</div>
),
}
@@ -0,0 +1,143 @@
import { useState, type JSX } from 'react'
import { EvidencePane } from '../../pages/runs/EvidencePane'
import { EvidenceDoc, EvidenceField } from '../../run/EvidenceDoc'
import { useEvidence } from '../../store/evidence'
import type { EvidenceDocData, Run } from '../../types'
import judge from '../../run/__tests__/evidence-judge.json'
/**
* 正文里被反方审过的句子(期 4,run/EvidenceDoc 的句末标记)的预览用例,scripts/check-evidence.mjs 按它们检查。报告文档
* 取自 run/__tests__/evidence-judge.json 的 plain(后端 compose_doc 真跑出来的周报,内容虚构);证据图、片段接口、审计
* 接口都由检查脚本伪造,这里不发任何写请求。
*
* - ?syn=evidence-marks-text:同一份周报,数字、取值都改成纯文字:整份报告一个可点的片段都没有,也没开裁判,挂上时键盘
* 顺序是空的。证据图(检查脚本晚一点答)带着被审过的句子,标记到了以后得进 Tab 顺序。前面摆一个按钮,从它按 Tab 进报告
* - ?syn=evidence-marks-stale:同一次运行先后在几个地方挂这份报告,看手里那份证据图过期了重不重取。按钮换场景(每按一次
* 都重新挂):审批卡里的初稿(old);报告改写后第二次审批的新稿(new);同一个实例从初稿换到新稿(switch,同画布的审批
* 浮层换审批);运行结束后成果区的新稿(output,EvidenceField);运行详情的证据页(pane-interrupted:运行停在第二次审批
* 上;pane-succeeded:运行已经结束、封存过)。两份文档预先放进证据 store:文档按工件 id 永久缓存,取文档不是这里要看的
*/
/** 同一次运行:初稿是夹具里那一份,新稿改了一句话和一个数(文档的 run_id 改成这次运行,成果区按它认是不是同一次运行) */
const RUN = 'run-evidence-marks-stale'
const OLD_ART = judge.plain.doc_artifact
const NEW_ART = 'b7'.repeat(32)
const BASE = judge.plain.doc as unknown as EvidenceDocData
const OLD: EvidenceDocData = { ...BASE, run_id: RUN }
const NEW: EvidenceDocData = {
...BASE,
run_id: RUN,
markdown: (BASE.markdown ?? '').replace('增长主要来自新客首单,老客复购持平。', '增长主要来自老客复购,新客首单持平。')
.replace('退款 29 单', '退款 31 单'),
blocks: BASE.blocks.map((b) => ({
...b,
units: b.units?.map((u) => ({
...u,
segments: u.segments?.map((s) => (s.id === 's11' ? { ...s, text: '增长主要来自老客复购,新客首单持平。' }
: s.id === 's14' && s.cite ? { ...s, text: '31', cite: { ...s.cite, value: 31, rendered: '31' } } : s)),
})),
})),
}
/** 一个可点的片段都没有的那一份:非结构片段一律改成普通文字,去掉出处 */
const TEXT_ONLY: EvidenceDocData = {
...BASE,
blocks: BASE.blocks.map((b) => ({
...b,
units: b.units?.map((u) => ({
...u,
segments: u.segments?.map((s) => (s.kind === 'structural' ? s : { id: s.id, kind: 'text', text: s.text, state: 'neutral' })),
})),
})),
}
function TextOnly() {
return (
<div className="mx-auto flex max-w-3xl flex-col gap-3 p-4" data-fixture="evidence-marks-text">
<button type="button" className="btn btn-xs self-start" data-marks-before="">报告前面的按钮</button>
<div className="rounded-lg border bg-panel p-3" data-marks-doc="">
<EvidenceDoc doc={TEXT_ONLY} artifact={judge.plain.doc_artifact} runId="run-evidence-marks-text" label="周报" />
</div>
</div>
)
}
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 (
<div className="max-h-96 w-[380px] max-w-full overflow-y-auto rounded border bg-bg p-2" data-marks-card={artifact}>
<EvidenceDoc doc={doc} artifact={artifact} runId={RUN} label="周报" dense tally runClass="formal" />
</div>
)
}
/** 同一个 EvidenceDoc 实例从初稿换到新稿 */
function Switch() {
const [fresh, setFresh] = useState(false)
return (
<div className="flex flex-col gap-2">
<button type="button" className="btn btn-xs self-start" data-marks-switch="" onClick={() => setFresh(true)}>换成新稿</button>
<div className="max-h-96 w-[380px] max-w-full overflow-y-auto rounded border bg-bg p-2" data-marks-card={fresh ? NEW_ART : OLD_ART}>
<EvidenceDoc doc={fresh ? NEW : OLD} artifact={fresh ? NEW_ART : OLD_ART} runId={RUN} label="周报" dense tally runClass="formal" />
</div>
</div>
)
}
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 (
<div className="flex h-screen min-w-0 flex-col gap-2 p-3" data-fixture="evidence-marks-stale">
<div className="flex flex-wrap gap-1.5">
{STAGES.map(([stage, label]) => (
<button key={stage} type="button" className="btn btn-xs" data-marks-stage={stage} onClick={() => go(stage)}>{label}</button>
))}
</div>
{/* 每按一次都按新的 key 重新挂:同一场景按两次,看手里那份图对上了还会不会重取 */}
<div key={at.n} className="min-h-0 min-w-0 flex-1" data-marks-at={at.stage}>
{at.stage === 'old' && <Card doc={OLD} artifact={OLD_ART} />}
{at.stage === 'new' && <Card doc={NEW} artifact={NEW_ART} />}
{at.stage === 'switch' && <Switch />}
{at.stage === 'output' && (
<div className="max-w-3xl rounded-lg border bg-panel p-3">
<EvidenceField artifact={NEW_ART} text={NEW.markdown ?? ''} runId={RUN} label="answer" tally runClass="formal" />
</div>
)}
{(at.stage === 'pane-interrupted' || at.stage === 'pane-succeeded') && (
<EvidencePane run={runOf(at.stage === 'pane-succeeded' ? 'succeeded' : 'interrupted')} output={null}
labelOf={(id) => (id === 'write' ? '写周报' : undefined)} refreshKey="0" />
)}
</div>
</div>
)
}
export const cases: Record<string, () => JSX.Element> = {
'evidence-marks-text': () => <TextOnly />,
'evidence-marks-stale': () => <Stale />,
}
+52
View File
@@ -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<string, () => JSX.Element> = {
'issued-verify': () => (
<div className="h-screen" data-fixture="issued-verify"><AssistantStream turns={[verifyTurn()]} /></div>
),
}
+28
View File
@@ -3954,6 +3954,34 @@ export const CHALLENGE_TEXT = {
/** 同上,没有可跳的查询时(双盲复算的理由)只念出处 */
citedPlain: (source: string) => `(出处:${source})`,
// ---- 证据面板的单句展示(ChallengeSummary 的 scope="claim")与正文的句末标记(run/EvidenceDoc)----
/** 证据面板里这一节的标题 */
claimTitle: '反方审稿',
/** 单句展示:展开反方对这一句的理由和它执行过的查询 */
claimDetail: '反方的理由与查询',
/** 这一句判为无法判断,而这份报告无法判断的句子全是没反查成的:这一句就是 */
claimUnreviewed: '这一句未能反查,已记为无法判断',
/** 同上,但无法判断的句子里还有查过了、数据不够的:说不准是不是这一句 */
claimMaybeUnreviewed: (n: number) => `这份报告有 ${formatNumber(n)} 句未能反查,已记为无法判断,这一句可能在其中`,
/**
* 单句展示的先行版:只知道判断(正文标记用的证据图),这一句的理由、查询和整份报告的计数要等片段接口答回来。
* 还在取、取不到(给重试)、答了却没有可用的内容
*/
claimPending: '正在获取反方的理由与查询…',
claimFailed: '未能获取反方的理由与查询',
claimNoDetail: '无法查看反方的理由与查询',
/**
* 无法判断的落款:未能反查的句子也记为无法判断(服务端按规则记的,反方模型没看过这一句),说不准是哪一种时不说成反方
* 模型的判断。证据面板先行版的落款、正文句末标记的悬停说明都用这一句
*/
judgedUnsure: '可能是反方模型的判断,也可能是未能反查时记下的,并非系统核对',
/** 正文句末标记的悬停说明:判断是反方模型给的 */
markTitle: (verdict: string) => `反方审稿:${verdict}(反方模型的判断,并非系统核对)`,
/** 同上,无法判断的那一种(括号里同 judgedUnsure) */
markTitleUnsure: (verdict: string) => `反方审稿:${verdict}(可能是反方模型的判断,也可能是未能反查时记下的,并非系统核对)`,
/** 句末标记读屏念的那一段:跟在句子后面(只有反方审稿时),或接在裁判的判定后面(两样都有时,并在一个标记里) */
markLabel: (verdict: string) => `反方审稿:${verdict}`,
// ---- 时间线(run/decode.ts explainLog):按日志的 code 和结构化字段说,不从原话里认
log: {
skippedDisabled: '反方审稿未启用,已跳过',
+50 -39
View File
@@ -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'
@@ -49,12 +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; void loadGraph(run.id); return }
if (first.current) {
first.current = false
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, 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<HTMLDivElement | null>(null)
const wide = useWide(root, 880)
@@ -150,8 +177,13 @@ export function EvidencePane({ run, output, labelOf, refreshKey }: {
reports={graph.reports ?? []} labelOf={labelOf} />
<UpgradeBanner run={run} mode={mode} />
{/* 出具档位和降档原因写在报告上方:降档时只在侧栏小标签里写「SQL 检查未通过」,报告上却是绿色的
「10 个数字都有出处」,读的人会以为一切正常。SQL 检查导致的降档在横幅里单列一块 */}
{!!output?._issuance?.tier && <IssuanceWithChecks issuance={output._issuance} runClass={run.run_class} />}
「10 个数字都有出处」,读的人会以为一切正常。SQL 检查导致的降档在横幅里单列一块,双盲复算、反方审稿报上来的
缺口分「核查未通过」「核查未完成」两组(和问数据、画布右栏同一个横幅) */}
{!!output?._issuance?.tier && (
<div data-evidence-issuance="">
<IssuanceBanner issuance={output._issuance} runClass={run.run_class} />
</div>
)}
{mode === 'cited' && reports.map((r) => {
const doc = docOf(r.node_id)
@@ -220,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 和版本记住答案:同一张图的几条运行不重复问;
@@ -322,38 +365,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<string, any>; 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 && (
<div data-evidence-verify-group={kind}>
<div className="font-medium">{DUAL_CHECK_TEXT.verifyGaps[kind]}</div>
<ul className="mt-0.5 space-y-0.5 pl-2 [overflow-wrap:anywhere]">
{gaps.map((g) => <li key={g}>· {g}</li>)}
</ul>
</div>
)
return (
<div className="space-y-1.5" data-evidence-issuance="">
<IssuanceBanner issuance={shown} runClass={runClass} />
{verify && (
<div className="space-y-1 rounded-lg border px-2 py-1.5 text-2xs leading-relaxed"
style={{ borderColor: 'var(--st-waiting)', color: 'var(--st-waiting)' }} data-evidence-verify-gaps="">
{group('failed', failed)}
{group('unfinished', unfinished)}
</div>
)}
</div>
)
}
/** 顶上一行:这次运行按哪种方式展示证据、封存核对的结果、每份报告文档的哈希 */
function SummaryBar({ mode, seal, message, reports, labelOf }: {
mode?: string
+29 -4
View File
@@ -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 (
<div className="mt-1.5 text-2xs leading-relaxed" style={{ color: 'var(--st-waiting)' }} data-issuance-verify-gaps={kind}>
<div className="font-medium">{title}:</div>
<ul className="mt-0.5 space-y-0.5 pl-2 [overflow-wrap:anywhere]">
{gaps.map((g) => <li key={g}>· {g}</li>)}
</ul>
</div>
)
}
const TIER_META: Record<string, { color: string; soft: string; hint: string }> = {
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<string, { color: string; soft: string; hint: string }> =
*
* 降档的原因必须写在这里:以前只列缺指标和回指不上的数字,而「叙述模板渲染
* 为空」「指标集为空」这类校验根本没跑起来的缺口(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 && <IssuanceSqlChecks tier={tier} checks={sqlChecks} />}
<IssuanceGaps kind="failed" title={DUAL_CHECK_TEXT.verifyGaps.failed} gaps={verifyFailed} />
<IssuanceGaps kind="unfinished" title={DUAL_CHECK_TEXT.verifyGaps.unfinished} gaps={verifyUnfinished} />
{!!gaps.length && (
<div className="mt-1.5 text-2xs leading-relaxed" style={{ color: 'var(--st-waiting)' }} data-issuance-gaps="">
<div className="font-medium">{RUN_SQL_CHECK_TEXT.otherGaps}</div>
+169 -47
View File
@@ -11,58 +11,74 @@ 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 取这一句的理由和查询。片段
* 接口还没答回来、答不了时,由 ClaimVerdictPending 先按正文标记(证据图)写出判断,说清在等什么、给重试。
*
* 判断是反方模型给的,不是系统核对:颜色照结论句裁判的那一套,「经受住」也不用确定性的绿。
* 「出具会降档」只在正式运行、有被推翻的结论、被推翻的真会让出具降档时才写:服务端给了 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 <ChallengeList reviewed={reviewed} runId={runId} given={runClassOf(runClass)} />
return <ChallengeList reviewed={reviewed} runId={runId} given={runClassOf(runClass)} scope={scope} issuance={issuance} />
}
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 }) => (
<ChallengeBlock key={report.node_id} report={report} challenge={challenge} run={run} />
))}
{reviewed.map(({ report, challenge }) => (scope === 'claim'
? <ClaimBlock key={report.node_id} report={report} challenge={challenge} run={run} />
: <ChallengeBlock key={report.node_id} report={report} challenge={challenge} run={run} />))}
</>
)
}
@@ -92,7 +108,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<string, unknown>
@@ -113,6 +129,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 +251,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<Set<string>>(() => new Set())
const [all, setAll] = useState(false)
@@ -231,11 +260,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 +309,14 @@ function ChallengeBlock({ report, challenge, run }: {
</span>
</Fragment>
))}
{refuted && !flagged && degrades && (
<span className="chip shrink-0 font-semibold" data-challenge-degrade=""
style={{ color: 'var(--st-failed)', borderColor: 'var(--st-failed)' }}>
<AlertTriangle size={11} aria-hidden /> {CHALLENGE_TEXT.degrade}
</span>
)}
{said === 'degrade' && <DegradeChip />}
</div>
) : (
<div className="text-2xs text-dim" data-challenge-empty="">{CHALLENGE_TEXT.none}</div>
)}
{/* 降档的原因常显,不放在悬停提示里:触屏、键盘用户也得看得到为什么、会怎样(只标注那一档同样常显一行)。
紧跟在概况那一行(「出具会降档」的标记)下面,中间不插别的话 */}
{refuted && !flagged && degrades && (
<div className="mt-0.5 text-2xs leading-relaxed text-dim" data-challenge-degrade-note="">{CHALLENGE_TEXT.degradeHint}</div>
)}
{refuted && !flagged && !degrades && (
<div className="mt-0.5 text-2xs leading-relaxed text-dim" data-challenge-degrade-off="">{CHALLENGE_TEXT.degradeOff}</div>
)}
{refuted && flagged && (
<div className="mt-0.5 text-2xs leading-relaxed text-dim" data-challenge-flag="">{CHALLENGE_TEXT.flagNote}</div>
)}
<IssuanceNote said={said} />
{/* 反查没完成的几句:「无法判断」里分不出查了数据不够和反查没做完,单独说一次 */}
{unreviewed > 0 && (
<p className="mt-0.5 text-2xs leading-relaxed" data-challenge-unreviewed={unreviewed}>
@@ -365,6 +377,116 @@ function ChallengeBlock({ report, challenge, run }: {
)
}
/** 「出具会降档」的醒目标记:跟在概况(单句时跟在判断)后面,原因由 IssuanceNote 常显在下一行 */
function DegradeChip() {
return (
<span className="chip shrink-0 font-semibold" data-challenge-degrade=""
style={{ color: 'var(--st-failed)', borderColor: 'var(--st-failed)' }}>
<AlertTriangle size={11} aria-hidden /> {CHALLENGE_TEXT.degrade}
</span>
)
}
/** 出具相关的那一行:会降档的原因、不在出具路径上为什么不影响、只标注不影响出具档位。不谈出具时什么都不画 */
function IssuanceNote({ said }: { said: Issuance }) {
const note = 'mt-0.5 text-2xs leading-relaxed text-dim'
if (said === 'degrade') return <div className={note} data-challenge-degrade-note="">{CHALLENGE_TEXT.degradeHint}</div>
if (said === 'off') return <div className={note} data-challenge-degrade-off="">{CHALLENGE_TEXT.degradeOff}</div>
if (said === 'flag') return <div className={note} data-challenge-flag="">{CHALLENGE_TEXT.flagNote}</div>
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 (
<div className="min-w-0 space-y-1 text-2xs leading-relaxed" data-challenge={report.node_id} data-challenge-scope="claim"
data-challenge-verdict={item.verdict}>
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1">
<VerdictTag verdict={item.verdict} />
{said === 'degrade' && <DegradeChip />}
{!noRecord && (
<button type="button" className="btn btn-xs" aria-expanded={open} aria-controls={open ? detailId : undefined}
data-challenge-toggle="" onClick={() => setOpen((v) => !v)}>
<ChevronRight size={11} aria-hidden style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }} />
{CHALLENGE_TEXT.claimDetail}
</button>
)}
</div>
<IssuanceNote said={said} />
{unreviewed > 0 && (
<p data-challenge-unreviewed={surely ? 'claim' : 'maybe'}>
<span className="font-medium" style={{ color: 'var(--st-waiting)' }}>
{surely ? CHALLENGE_TEXT.claimUnreviewed : CHALLENGE_TEXT.claimMaybeUnreviewed(unreviewed)}
</span>
<span className="text-dim">。{CHALLENGE_TEXT.unreviewedHint}</span>
</p>
)}
{open && (
<div id={detailId} className="min-w-0 space-y-2 rounded border p-2" data-challenge-detail={item.unit}>
<ClaimDetail item={item} detail={detail} />
</div>
)}
{noRecord && <p style={{ color: 'var(--st-waiting)' }} data-challenge-norecord="">{CHALLENGE_TEXT.noRecord}</p>}
{/* 判断标签看着像结论,得说清是谁判的:展开取到审稿记录之后带上模型名。这一句一定没反查成(surely)的不写:那是
服务端按规则记的无法判断,反方模型没看过这一句,上面一行已经说了。说不准的(maybe)照旧写 */}
{!surely && <p className="text-faint" data-challenge-judged="">{CHALLENGE_TEXT.judgedBy(detail.data?.model ?? null)}</p>}
</div>
)
}
/** 证据面板先行版在等什么:片段接口还在取、取不到、答了却没有可用的 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 (
<div className="min-w-0 space-y-1 text-2xs leading-relaxed" data-challenge-scope="claim" data-challenge-verdict={verdict}
data-challenge-pending={wait}>
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1">
<VerdictTag verdict={verdict} />
</div>
{wait === 'loading' ? (
<p className="text-faint" data-challenge-detail-state="loading">{CHALLENGE_TEXT.claimPending}</p>
) : (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1" data-challenge-detail-state={wait}>
<span role={wait === 'error' ? 'alert' : undefined} style={{ color: 'var(--st-waiting)' }}>
{wait === 'error' ? CHALLENGE_TEXT.claimFailed : CHALLENGE_TEXT.claimNoDetail}
</span>
{wait === 'error' && onRetry && (
<button type="button" className="btn btn-xs" data-challenge-retry="" onClick={onRetry}>{CHALLENGE_TEXT.retry}</button>
)}
</div>
)}
<p className="text-faint" data-challenge-judged="">
{verdict === 'inconclusive' ? CHALLENGE_TEXT.judgedUnsure : CHALLENGE_TEXT.judgedBy(null)}
</p>
</div>
)
}
/** 判断标签:文字加颜色(去掉颜色也读得出),不铺底色——浅色主题下底色会把小字的对比度压到 4.5 以下 */
function VerdictTag({ verdict }: { verdict: string }) {
const color = isVerdict(verdict) ? VERDICT_COLOR[verdict] : 'var(--text-dim)'
+202 -35
View File
@@ -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, ShieldEllipsis, ShieldMinus, ShieldX, type LucideIcon,
} from 'lucide-react'
import clsx from 'clsx'
import { rovingTarget } from '../components/ui'
import {
@@ -13,9 +15,15 @@ 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 { useEvidence, useVerdicts } from '../store/evidence'
import type { EvidenceBlock, EvidenceDocData, EvidenceSegment, EvidenceUnit, EvidenceVerdict } from '../types'
import {
CHALLENGE_TEXT, EVIDENCE_STATE_LABEL, EVIDENCE_TEXT, INQUIRY_TEXT, JUDGE_TEXT, claimTally, evidenceTally,
type ClaimTallyCounts,
} from '../lib/terms'
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'
@@ -30,6 +38,9 @@ import { BlockView, Markdown, StyledSlice, inlineStyles, numericColumns, type In
*
* 结论句的判定(四期)只在句末挂一枚小徽标(◆ ◇ ! ○ ?),整句不画线;证据相矛盾(和旧的证据不支持)的整句
* 铺一层浅底。徽标也是按钮:点开是这一句的「模型的解释」。判定是模型给的,颜色一律不用确定性的绿。
* 被反方审过的句子(期 4,证据图的 reports[].challenge_verdicts)同样在句末挂标记:被推翻、被削弱带字、醒目,经受住、
* 无法判断只有淡色图标;有判定的句子并在同一枚徽标里。点开都是整句面板,没有可点片段的句子也打得开。证据图有运行就取
* 一次;手里那份是别处早先取的、已经过期(报告改写过、运行收尾之前取的)时重取一次,见 useRunGraph。
*
* 键盘:整份报告只占一个 Tab 位(roving tabindex)。←/→ 在有状态的片段、句末徽标间走,↑/↓ 按
* 句子走(表格里按列走到上一行 / 下一行,出了表格接着按句子走),n / N 跳到下一处 /
@@ -69,7 +80,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 +117,8 @@ interface Model {
grid: Map<string, string>
}
function buildModel(blocks: EvidenceBlock[], overlay?: Record<string, EvidenceVerdict>, ask = false): Model {
function buildModel(blocks: EvidenceBlock[], overlay?: Record<string, EvidenceVerdict>, ask = false,
marks?: Record<string, string>): Model {
const order: string[] = []
const index = new Map<string, number>()
const seg = new Map<string, { seg: EvidenceSegment; unit: EvidenceUnit; block: number }>()
@@ -132,12 +152,15 @@ function buildModel(blocks: EvidenceBlock[], overlay?: Record<string, EvidenceVe
if (!grid.has(key)) grid.set(key, s.id)
}
}
// 句末徽标排在这一句的片段后面:←/→ 走到句尾就是它,证据相矛盾、部分有依据、证据不足的也进 n / N
// 句末徽标排在这一句的片段后面:←/→ 走到句尾就是它,证据相矛盾、部分有依据、证据不足的也进 n / N。被反方审过的
// 句子同样挂(和裁判的判定并在一个徽标里):没有可点的片段、也没有判定的句子靠它才打开得了整句面板。n / N 只按
// 裁判的判定走——横幅、证据条的「定位下一处」只数证据和判定的问题,反方的判断在面板和审批卡里看
const verdict = unitVerdict(unit, overlay)
const state = verdictState(verdict)
if (verdict && state) {
const challenge = marks?.[unit.id]
if ((verdict && state) || challenge) {
const key = badgeKey(unit.id)
badge.set(key, { unit, block: b, verdict, state })
badge.set(key, { unit, block: b, ...(verdict && state ? { verdict, state } : {}), ...(challenge ? { challenge } : {}) })
index.set(key, order.length)
order.push(key)
if (first) { unitFirst.push(key); first = false }
@@ -198,6 +221,84 @@ function verticalTarget(model: Model, id: string, down: boolean): string | null
return model.unitFirst[(u + (down ? 1 : -1) + n) % n] ?? null
}
type GraphReport = NonNullable<EvidenceGraph['reports']>[number]
/**
* 证据图里的这一项说的是不是正文这一份:同一个报告节点,封存的就是这一稿(工件 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<EvidenceGraph> | 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 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<string, unknown>)
.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) */
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]
@@ -243,6 +344,11 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
* 数字的出处都对得上,档位却因为别的原因(比如查询没通过 SQL 检查)降了
*/
tier?: string
/**
* 正文是运行的成果(成果区、问数据的回答,EvidenceField 给):运行已经结束。手里那份证据图要是没封存,就是收尾之前取的
* (比如审批卡里展开报告时),重取一次,见 useRunGraph
*/
settled?: boolean
/**
* 审批质询(期 4):给了就在每句结论后面挂「质询」(悬停、焦点在这一句上时出现),划选原文时浮出「质询」,点了打开
* 这份报告的提问框。能不能问由页面按运行状态定(审批卡:运行停在审批上;运行详情:封存了的探索运行),approvalId 是
@@ -250,21 +356,22 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
*/
inquiry?: InquiryAccess | null
}>(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)
const model = useMemo(() => buildModel(blocks, overlay, ask), [blocks, overlay, ask])
// 有运行就取这次运行的证据图(同一次运行只取一次,不逐句请求;过期了重取一次):它带着被反方审过的句子
// (reports[].challenge_verdicts,正式、探索运行都可能有,反方审稿在报告写完之后才审,文档里没有这份记录),探索运行的
// 报告还带着之前按需判过的句子(reports[].post_seal_verdicts,useVerdicts 读),重新打开页面时标记、徽标都还在
const graph = useRunGraph(runId, report, artifact, settled)
// 被反方审过的句子:句末挂标记(和裁判的判定并在一个徽标里),点开是整句面板
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[].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])
const rootRef = useRef<HTMLDivElement>(null)
const instance = useId()
const panelId = `${instance}-panel`
@@ -283,9 +390,10 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
setView(null)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [blocks])
// 拿着 Tab 位的徽标没了(判成「不是结论句」):Tab 位交回第一个,整份报告不能一个 Tab 位都没有
// 拿着 Tab 位的徽标没了(判成「不是结论句」),或者挂上时一个键盘目标都没有(没有可点片段、没开裁判、没给质询)、
// 反方标记随证据图后来才到:Tab 位交给第一个,整份报告不能一个 Tab 位都没有
useEffect(() => {
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])
// 同一时刻只开一个面板:另一份报告打开了自己的面板,这边的就收起
@@ -341,7 +449,8 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
if (badge) {
claim(instance)
setView({ kind: 'unit', id: badge.unit.id })
setAnnounce(`${JUDGE_TEXT.section}:${claimLabel(badge.unit, badge.verdict)}`)
// 只有反方标记的句子:读屏名本身就说了「反方审稿:被推翻」,前面不再加一个标题,免得「反方审稿」念两遍
setAnnounce(badge.verdict ? `${JUDGE_TEXT.section}:${badgeLabel(badge)}` : badgeLabel(badge))
return
}
const hit = model.seg.get(id)
@@ -490,6 +599,7 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
artifact={artifact}
runId={runId}
runClass={runClass}
marks={marks}
view={view}
onClose={() => close()}
onLocate={(id) => { focusSeg(id, { flash: true }); openSeg(id) }}
@@ -860,10 +970,51 @@ 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<ChallengeVerdict, MarkLook> = {
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: 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,汉字认不清
*/
function ChallengeMark({ verdict, look, after }: { verdict: string; look: MarkLook; after: boolean }) {
const Icon = look.icon
return (
<span aria-hidden="true" className={clsx('inline-flex items-center gap-px whitespace-nowrap', after && 'ml-0.5')}
style={{ color: look.color }} data-ev-challenge-look={look.loud ? 'loud' : 'quiet'}>
<Icon size={10} strokeWidth={2.25} className="shrink-0" />
{look.loud && <span className="text-[10px]">{challengeLabel(verdict)}</span>}
</span>
)
}
/**
* 一句话:文字按行内 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 +1062,21 @@ 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 ? ''
: 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 = (
<>
{conflicting(badge?.state)
@@ -924,32 +1088,34 @@ function UnitView({ unit, doc, current, active, panelId, code, tag, badge, ask }
{EVIDENCE_STATE[state].glyph}{TAG_TEXT[state]}
</span>
))}
{badge && meta && (
{badge && (meta || look) && (
<button
type="button"
className="ev-badge"
className={clsx('ev-badge', look?.loud && LOUD_RING)}
data-ev-claim={unit.id}
data-ev-verdict={badge.state}
data-ev-post-seal={badge.verdict.post_seal ? '' : undefined}
data-ev-post-seal={verdict?.post_seal ? '' : undefined}
data-ev-challenge-mark={badge.challenge}
tabIndex={current === key ? 0 : -1}
aria-label={claimLabel(unit, badge.verdict)}
aria-label={badgeLabel(badge)}
aria-expanded={open}
aria-controls={open ? panelId : undefined}
// 模型判过的写明谁判的、非确定(证据不足的接着写缺什么);模型没判过的(按需还没点、到上限、没跑成)只说
// 这是什么状态——到上限没判的也记着裁判模型,但不能写成「(模型 · 非确定)」,像是它判过一样
title={isJudged(badge.verdict)
? `${meta.label}${badge.verdict.post_seal ? ` · ${JUDGE_TEXT.postSeal}` : ''}(${badge.verdict.judge || JUDGE_TEXT.judgeModel} · 非确定)${
verdictMissing(badge.verdict) ? `。${JUDGE_TEXT.missing(verdictMissing(badge.verdict))}` : ''}`
: limitOf(badge.verdict) ? `${meta.label}:${JUDGE_TEXT.limitNotJudged}` : `${meta.label}:${meta.hint}`}
style={segStyle(badge.state)}
title={[judgedTitle, challengeTitle].filter(Boolean).join('。')}
// 字形的颜色照裁判的判定;只被反方审过的照反方的判断(悬停的浅底同理)。带着醒目的反方标记时不铺浅底(LOUD_RING)
style={meta ? { ...segStyle(badge.state!), ...(look?.loud ? { '--ev-soft': 'transparent' } : {}) } as CSSProperties
: { '--ev-glyph': look!.color, '--ev-soft': look!.soft } as CSSProperties}
>
<span aria-hidden="true">{meta.glyph}</span>
{meta && <span aria-hidden="true">{meta.glyph}</span>}
{look && <ChallengeMark verdict={badge.challenge!} look={look} after={!!meta} />}
</button>
)}
</>
)
if (!ask) return body
// 「质询」跟在句末,是一个固定宽度的小图标:平时淡淡的,悬停这一句、焦点落在这一句上时显出来(强调色、描边)。
// 平时也得看得清它是个可点的东西(WCAG 1.4.11 要求界面组件对背景 ≥ 3:1):最淡的字色再压到 60% 不透明,深色约 2.75、
// 浅色约 2.33,看不出来。改用次一级的暗字色压到 70%:深浅两套、报告摆在页面底色或面板底色上都在 3.3 以上,仍比正文
// 淡得多;不透明度留在 0.75 以下,和悬停、聚焦时显出来(不透明、强调色)分得开。
// 显没显出来只换颜色,宽度一直是那么宽——悬停时撑开的话,后面的字整体右移,鼠标底下的数字跟着挪走,点不到。
// 触屏没有悬停,一直显出来,并写上「质询」两个字。
// 跟随的那种只在焦点在这一句上时进 Tab 顺序,整份报告不因此多出一串 Tab 位
@@ -961,7 +1127,7 @@ function UnitView({ unit, doc, current, active, panelId, code, tag, badge, ask }
<button type="button" data-inquiry-ask={unit.id} tabIndex={here ? 0 : -1}
aria-label={INQUIRY_TEXT.askUnit(unitText(unit).trim())} title={INQUIRY_TEXT.askTitle}
className={'ml-1 inline-flex select-none items-center gap-0.5 whitespace-nowrap rounded border border-transparent px-[3px] '
+ 'align-middle text-[10px] font-medium leading-[1.4] text-faint opacity-60 outline-none '
+ 'align-middle text-[10px] font-medium leading-[1.4] text-dim opacity-70 outline-none '
+ 'hover:bg-hover group-hover/evu:border-[color:var(--border)] group-hover/evu:text-[color:var(--accent)] group-hover/evu:opacity-100 '
+ 'group-focus-within/evu:border-[color:var(--border)] group-focus-within/evu:text-[color:var(--accent)] group-focus-within/evu:opacity-100 '
+ 'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[color:var(--accent)] '
@@ -996,8 +1162,9 @@ export const EvidenceField = forwardRef<EvidenceDocHandle, {
const counted = useMemo(() => (usable ? withVerdicts(usable, overlay) : null), [usable, overlay])
useEffect(() => { onDoc?.(counted) }, [counted, onDoc])
if (usable) {
// 成果只在运行结束之后才有:手里那份证据图要是没封存,就是收尾之前取的(settled,见 useRunGraph)
return <EvidenceDoc ref={ref} doc={usable} artifact={artifact} runId={runId} dense={dense} label={label} tally={tally}
runClass={runClass} />
runClass={runClass} settled />
}
const note = slot?.status === 'error' ? EVIDENCE_TEXT.docMissing
: otherRun ? EVIDENCE_TEXT.docOtherRun : mismatch ? EVIDENCE_TEXT.docMismatch : ''
+108 -74
View File
@@ -19,17 +19,18 @@ 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, EvidenceChallengeVerdicts, 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'
import { ChallengeSummary, ClaimVerdictPending, type ClaimWait } from './ChallengeSummary'
import { useEvidenceHost } from './evidenceHost'
import { EvidenceProvenance } from './EvidenceProvenance'
import { CopyChip } from './Markdown'
@@ -76,10 +77,13 @@ import {
* 表格底下带着推演结果。
*
* 核查状态(期 4):指标步骤多一行双盲复算的结果(一致、不一致和审核人选了什么、未能完成、已跳过),选了盲算、两边都
* 不对、没选、未能完成的写明报告里的数没换、已记为缺口;句子被反方审过的(点开整句、点开句中的数字都一样)多一块反方
* 审稿,复用审批卡上的展示(run/ChallengeSummary),概况是整份报告真实的计数,点开这一句看反方的理由和查询,不说出具会
* 不会降档(运行已经结束,看报告的出具横幅)。取自遮罩列的输入,服务端已把值换成「已遮罩」,标签照样写「已遮罩」,输入
* 来源里只说核对结果、不写值;遮没遮核对不了的(快照读不出来)同样不显示值,写明原因。
* 不对、没选、未能完成的写明报告里的数没换、已记为缺口;句子被反方审过的(点开整句、点开句中的数字都一样)多一节反方
* 审稿,用审批卡那一块的单句展示(run/ChallengeSummary 的 scope="claim"):这一句的判断、它可能没反查成的说明,展开看
* 反方的理由和查询,不画报告级的标题和计数,不说出具会不会降档(运行已经结束,看报告的出具横幅)。正文里被审过的句子
* 挂句末标记(run/EvidenceDoc),点开就是整句面板;片段接口还没答回来、答不了时,这一节先按标记(marks,证据图)写出
* 判断,说清在等什么、给重试(ClaimVerdictPending)。取自遮罩列的输入,服务端已把值换成「已遮罩」,标签照样写「已遮罩」,
* 输入来源里只说核对结果、不写值;遮没遮核对不了的(快照读不出来)同样不显示值,写明原因。类型都在 types.ts
* (EvidenceDualCheck、EvidenceUnitChallenge、EvidenceInput 的 masked / mask_unverified)。
*
* 四种摆法:side 从右侧弹出(宽屏),drawer 从底部抽出(窄屏),inline 在 360px 的
* 画布右栏里直接栏内展开,dock 放进页面给的一块常驻位置(记录页的「证据」页签)。
@@ -87,51 +91,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,20 +117,20 @@ 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 : ''
}
export type PanelMode = 'side' | 'drawer' | 'inline' | 'dock'
export type PanelView = { kind: 'seg'; id: string } | { kind: 'unit'; id: string } | { kind: 'violations' }
export function EvidencePanel({ id, mode, doc, artifact, runId, runClass, view, onClose, onLocate, onViolations }: {
export function EvidencePanel({ id, mode, doc, artifact, runId, runClass, marks, view, onClose, onLocate, onViolations }: {
id: string
mode: PanelMode
doc: EvidenceDocData
@@ -162,6 +139,11 @@ export function EvidencePanel({ id, mode, doc, artifact, runId, runClass, view,
runId?: string
/** 运行类别:没开裁判的文档靠它认探索运行(「请模型判断这句」只在探索运行里有) */
runClass?: string
/**
* 正文句末标记用的反方审稿判断(证据图的 challenge_verdicts,正文已经按这一份对过工件 id):片段接口的 unit.challenge
* 还没答回来、答不了时,「反方审稿」一节先按它写出判断
*/
marks?: EvidenceChallengeVerdicts
view: PanelView
onClose: () => void
/** 跳到正文里的某个片段(违规清单里的「定位」) */
@@ -243,10 +225,11 @@ export function EvidencePanel({ id, mode, doc, artifact, runId, runClass, view,
? <ViolationList doc={doc} onLocate={onLocate} />
: found
? <SegmentBody panelId={id} doc={doc} artifact={artifact} runId={runId} runClass={runClass} seg={found.seg}
unit={found.unit} block={found.block} onViolations={onViolations} onLocate={onLocate} />
unit={found.unit} block={found.block} mark={marks?.[found.unit.id]} onViolations={onViolations}
onLocate={onLocate} />
: claimAt
? <ClaimBody doc={doc} artifact={artifact} runId={runId} runClass={runClass} unit={claimAt.unit}
block={claimAt.block} />
block={claimAt.block} mark={marks?.[claimAt.unit.id]} />
: <p className="text-dim">{NONE}</p>}
</div>
</section>
@@ -287,10 +270,12 @@ function flashOnce(el: HTMLElement) {
setTimeout(() => { if (el.dataset.flash === 'focus') delete el.dataset.flash }, 1600)
}
function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block, onViolations, onLocate }: {
function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block, mark, onViolations, onLocate }: {
panelId: string
doc: EvidenceDocData; artifact?: string; runId?: string; runClass?: string; seg: EvidenceSegment; unit: EvidenceUnit
block: EvidenceBlock
/** 正文标记上这一句的反方审稿判断(证据图):片段接口答回来之前先按它写 */
mark?: string
onViolations: () => void
/** 跳到正文里的某个片段(推演区「报告中会变的地方」的定位) */
onLocate: (segId: string) => void
@@ -418,6 +403,7 @@ function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block
: docIssue ? EVIDENCE_TEXT.chainForeign
: slot?.status === 'error' ? EVIDENCE_TEXT.chainMissing : ''
const segChallenge = docIssue ? null : challengeOf(detail?.unit)
const segWait = waitOf(slot?.status, !!runId)
return (
<>
@@ -534,9 +520,12 @@ function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block
<JudgePart doc={doc} unit={unit} block={block} runId={runId} runClass={runClass}
onDemand={detail?.unit?.on_demand} answered={detail?.unit?.verdict} pending={pending} />
{/* 反方审稿对这一句的判断:点句中的数字也看得到(没开结论句裁判的报告没有句末徽标,这里常是唯一的入口)。
正文不是封存的那一份时不用:服务端认的是封存的那份文档 */}
{segChallenge && <ChallengePart challenge={segChallenge} unit={unit} report={report} />}
{/* 反方审稿对这一句的判断:点句中的数字也看得到。片段接口还没答、答不了时先按正文标记写判断。正文不是封存的那一份
时都不用:服务端认的是封存的那份文档 */}
{!docIssue && (segChallenge || mark) && (
<ChallengePart challenge={segChallenge} mark={mark} wait={segWait} unit={unit} report={report}
onRetry={() => { if (runId) void loadSegment(runId, seg.id, report) }} />
)}
{host.onNode && (trace.producers.length > 0 || trace.consumers.length > 0) && (
// 画布右栏:这段证据经过的节点,点一下在画布上选中并对准
@@ -589,8 +578,13 @@ function useLateVerdict(runId: string | undefined, report: string | undefined,
* 点句末徽标打开的整句:句子、模型的解释、挂的依据、封存。封存状态和叠上的判定借这句第一个片段的
* 片段接口取(接口答的是另一份报告时一概不用)
*/
function ClaimBody({ doc, artifact, runId, runClass, unit, block }: {
function ClaimBody({ doc, artifact, runId, runClass, unit, block, mark }: {
doc: EvidenceDocData; artifact?: string; runId?: string; runClass?: string; unit: EvidenceUnit; block: EvidenceBlock
/**
* 正文标记上这一句的反方审稿判断(证据图):片段接口答回来之前、答不了时先按它写。没有可点片段、也没有裁判判定的句子
* 只能从这枚标记点进来
*/
mark?: string
}) {
const report = doc.node_id || undefined
const first = (unit.segments ?? []).find((s) => s.kind !== 'structural')
@@ -616,8 +610,10 @@ function ClaimBody({ doc, artifact, runId, runClass, unit, block }: {
const cites = (unit.cites ?? []).map((alias) => ({ alias, label: doc.catalog?.[alias]?.label as string | undefined }))
// 讲方法、讲结构的句子:SQL 和字段清单直接列在挂的依据下面。正文不是封存的那份时不列(链是另一份报告的)
const basis = useMemo(() => (foreign ? null : claimBasis(unit, doc)), [foreign, unit, doc])
// 反方审稿对这一句的判断:服务端只认审的就是封存的这一份文档;正文不是那一份时不用
// 反方审稿对这一句的判断:服务端只认审的就是封存的这一份文档;正文不是那一份时不用。片段接口还没答、答不了时先按正文
// 标记写(证据图,正文已经按工件 id 对过)
const challenge = foreign ? null : challengeOf(answered?.unit)
const wait = first ? waitOf(slot?.status, !!runId) : 'missing'
return (
<>
{foreign && (
@@ -631,7 +627,10 @@ function ClaimBody({ doc, artifact, runId, runClass, unit, block }: {
onDemand={foreign ? undefined : answered?.unit?.on_demand}
answered={foreign ? undefined : answered?.unit?.verdict}
pending={!!runId && !!first && (!slot || slot.status === 'loading')} />
{challenge && <ChallengePart challenge={challenge} unit={unit} report={report} />}
{!foreign && (challenge || mark) && (
<ChallengePart challenge={challenge} mark={mark} wait={wait} unit={unit} report={report}
onRetry={() => { if (runId && first) void loadSegment(runId, first.id, report) }} />
)}
<Part title={JUDGE_TEXT.cites} data-ev-cites="">
{cites.length ? (
<ul className="space-y-0.5">
@@ -660,31 +659,66 @@ function ClaimBody({ doc, artifact, runId, runClass, unit, block }: {
}
/**
* 反方审稿对这一句的判断(期 4),整句面板、片段面板共用。复用审批卡上的展示(run/ChallengeSummary,只读它的导出):
* 那一块是报告级的——标题写报告名,概况是整份报告四种判断各几句(服务端给的真实计数,不凭这一句拼),条目只列这一句,
* 点开看反方的理由和它执行的查询,「显示全部」补出这份报告别的句子。判断是反方模型给的,那一块自己写明。
* 反方审稿对这一句的判断(期 4),整句面板、片段面板共用。用审批卡那一块的单句展示(ChallengeSummary 的 scope="claim"):
* 只画这一句的判断、它可能没反查成的说明(透传 unreviewed,和整份报告的计数对一下),展开看反方的理由和它执行的查询;
* 不画报告级的标题、四种判断的计数和别的句子。判断是反方模型给的,那一块自己写明。
*
* 不给运行 id:ChallengeSummary 只拿它查运行类别,好在审批卡上预告「出具会降档」。证据面板看的是已经结束的运行,降没
* 降档、出没出具以报告的出具横幅(封存的出具判定)为准,这里再说一遍将来时的预告只会和横幅对不上(驳回了、不予出具、
* 反方审稿不在成果节点上游……)。ChallengeSummary 取不到运行类别时一句和出具有关的话都不说。等它有了单句的展示
* (只画这一句的判断和理由,不画报告级的标题、计数),换用那个
* 不谈出具(issuance={false},也不传 degrades):审批卡上预告「出具会降档」,是因为那时运行还没出具。证据面板看的是已经
* 结束的运行,降没降档、出没出具以报告的出具横幅(封存的出具判定)为准,再说一遍将来时的预告只会和横幅对不上(驳回了、
* 不予出具、反方审稿不在成果节点上游……)
*
* 判断有两个来源:片段接口答的 unit.challenge(带审稿记录和整份报告的计数,单句展示要它),和正文标记用的证据图(mark,
* 只有判断)。片段接口还在取、取不到、答了却没有可用的 unit.challenge 时,先按标记画出判断,说清在等什么,取不到给重试
* (ClaimVerdictPending):用户是点着句末醒目的「被推翻」进来的,没开裁判的报告里这几句也没有别的入口,这一节不能空着
*/
function ChallengePart({ challenge, unit, report }: { challenge: UnitChallenge; unit: EvidenceUnit; report?: string }) {
const reports = useMemo<ApprovalReport[]>(() => [{
function ChallengePart({ challenge, mark, wait, onRetry, unit, report }: {
challenge: EvidenceUnitChallenge | null
mark?: string
/** 片段接口那一头:还在取、取不到、答了却没有可用的 unit.challenge(只在没有 challenge 时用得上) */
wait: ClaimWait
/** 重取这一句的片段接口 */
onRetry: () => void
unit: EvidenceUnit; report?: string
}) {
const reports = useMemo<ApprovalReport[]>(() => (challenge ? [{
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])
},
}] : []), [challenge, unit, report])
// 按过「重试」:按钮一请求就拿掉了,焦点会掉到页面上。取到了落到完整展示里的「反方的理由与查询」(没有审稿记录、展开
// 不了时落到这一节上),又没取到落到新的「重试」上;这一会儿用户已经去了别处的不抢
const boxRef = useRef<HTMLDivElement>(null)
const retried = useRef(false)
const settledWait = challenge ? 'ok' : wait
useEffect(() => {
if (!retried.current || settledWait === 'loading') return
retried.current = false
if (document.activeElement && document.activeElement !== document.body) return
const box = boxRef.current
;(box?.querySelector<HTMLElement>('[data-challenge-toggle], [data-challenge-retry]') ?? box)?.focus()
}, [settledWait])
const verdict = challenge?.verdict ?? mark
if (!verdict) return null
return (
<div data-ev-challenge={challenge.verdict}>
<ChallengeSummary reports={reports} runId="" />
</div>
<Part title={CHALLENGE_TEXT.claimTitle} data-ev-challenge={verdict}>
<div ref={boxRef} tabIndex={-1} className="rounded-sm outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[color:var(--accent)]">
{challenge
? <ChallengeSummary reports={reports} scope="claim" issuance={false} />
: <ClaimVerdictPending verdict={verdict} wait={wait} onRetry={() => { retried.current = true; onRetry() }} />}
</div>
</Part>
)
}
/** 片段接口那一头现在的样子,给反方审稿的先行版用:没有运行时不会有答复,算答了却没有 */
function waitOf(status: 'loading' | 'ok' | 'error' | undefined, run: boolean): ClaimWait {
if (!run) return 'missing'
return !status || status === 'loading' ? 'loading' : status === 'error' ? 'error' : 'missing'
}
/**
* 模型的解释:只对结论句。判定、理由(斜体)、「模型判断 · 模型名 · 非确定」的徽标,封存之后按需追加的
* 另写「封存后追加」;到上限没判的写明哪个上限、到哪里调;改写过一次的写明原句。探索运行里还没判过的
@@ -1380,12 +1414,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 ?? '' : ''
+1 -1
View File
@@ -157,7 +157,7 @@ export function ApprovalCard({ approval, onResolved, showWorkflow = true }: {
<ApprovalReports reports={reports} runId={approval.run_id} runClass={approval.run_class}
approvalId={approval.id} askable={approval.status === 'pending'} />
<ChallengeSummary reports={reports} runId={approval.run_id} />
<ChallengeSummary reports={reports} runId={approval.run_id} runClass={approval.run_class} />
{payload.tool && (
<div className="mb-2 rounded border bg-bg p-2">
+77 -2
View File
@@ -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?: EvidenceChallengeVerdicts }[]
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,65 @@ 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
}
/**
* 一份报告里被反方审过的句子 → 判断(证据图 reports[].challenge_verdicts,_challenge_verdicts)。只有被审过的句子;判断和
* 点开这一句时片段接口答的 unit.challenge.verdict 一字不差。正文据此挂句末标记,证据面板在片段接口答回来之前先按它写判断
*/
export type EvidenceChallengeVerdicts = Record<string, ChallengeVerdict | (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 +2277,8 @@ export interface EvidenceStep extends Omit<EvidenceInput, 'status'> {
sql_check_failed?: boolean
/** metric:没通过的原因(人话,「所依据的查询未通过 SQL 检查(…),结果不可靠」) */
sql_check_reason?: string
/** metric(期 4):这个指标的双盲复算状态。没有双盲复算复核过这份指标集时没有这个键 */
dual_check?: EvidenceDualCheck
/** query:这次查询对照数据目录查出的问题。没查出问题时没有这个键 */
checks?: SqlCheckItem[]
/** query:查询当时冻结的表结构快照(工件 id)。合并查询、老快照没有 */
@@ -2366,7 +2432,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 +3734,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
}
/**
+48 -8
View File
@@ -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,41 @@ 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 () => {
// 证据面板看的是已经结束的运行,降没降档以报告的出具横幅为准,单句那一块传 issuance={false}。以前证据面板既不传运行 id
// 也不传运行类别,这个开关在哪个用例里都观察不到:改成忽略它,「出具会降档」悄悄回到单句面板也没有检查会发现。用例
// challenge-claim-quiet 把审批卡上写降档要的几样(正式运行、运行 id、被推翻、degrades)都给齐了;那次运行的类别要是
// 去取,答的是正式运行
const { ctx, page, hits, errors } = await open('challenge-claim-quiet')
const block = page.locator('[data-challenge="write"][data-challenge-scope="claim"]')
await block.waitFor()
await page.waitForTimeout(500)
const all = await text(block)
check('单句:只画这一句的判断(被推翻),不画报告级的标题和计数', (await text(block.locator('[data-challenge-tag]'))) === '被推翻'
&& await page.locator('[data-challenge-counts], [data-challenge-items]').count() === 0, all)
check('……不谈出具:不写降档、不在出具路径上、只标注', await page.locator('[data-challenge-degrade], [data-challenge-degrade-note], '
+ '[data-challenge-degrade-off], [data-challenge-flag]').count() === 0 && !all.includes('降档') && !all.includes('出具'), all)
check('……也不为此取这次运行的类别', !hits.runs.includes(RUNS.formal), hits.runs.join('、') || '一次都没取')
check('……页面没有报错', errors.length === 0, errors.join(';'))
await ctx.close()
})
await section('有句子反查没完成:单独说一行,不混在「无法判断」里,不插在降档标记和它的说明中间', async () => {
// 服务端的 unreviewed:核查模型不可用、触顶没给出结论、数据源不在了……这几句记为无法判断,可反查没有完成。「无法判断
// 3 句」分不出是查了、数据不够,还是没查成,审批卡单独说。未能反查的每一句都记为无法判断,unreviewed 不会多于它
+567 -23
View File
@@ -87,8 +87,11 @@ function judgeReply(set, body) {
* 三节都不画,只看 DOM 的断言不论前端发没发请求都会过。推断来源夹具的运行按 provenance[<片段>] 作答,其余一律回
* 可识别的哨兵(418,harness_unexpected_provenance),界面会画出「未能获取推断的来源」。prov 给了就由它改答复
* (prov(运行, 片段, 默认答复) → {status?, json} 或它的 Promise:延迟、出错、老服务端)
*
* 期 4:graph 给了就由它改四期夹具(结论句裁判那几份)的证据图答复(graph(夹具, 证据图拷贝) → 证据图),并把每次请求
* 记成 graph:<运行>(数得出同一次运行取了几回);不给时照夹具答、不记
*/
async function routed(page, { judge = null, segPatch = null, prov = null } = {}) {
async function routed(page, { judge = null, segPatch = null, prov = null, graph = null } = {}) {
const hits = []
const judged = new Set()
await page.route((u) => new URL(u).pathname.startsWith('/api/'), async (r) => {
@@ -152,7 +155,11 @@ async function routed(page, { judge = null, segPatch = null, prov = null } = {})
return body ? r.fulfill({ json: body })
: r.fulfill({ status: 404, json: { detail: '报告中没有这个片段', code: 'evidence_segment_not_found' } })
}
if (/\/evidence$/.test(url.pathname)) return r.fulfill({ json: jset.graph })
if (/\/evidence$/.test(url.pathname)) {
if (!graph) return r.fulfill({ json: jset.graph })
hits.push(`graph:${jset.run_id}`)
return r.fulfill({ json: graph(jset, structuredClone(jset.graph)) })
}
if (url.pathname.endsWith(`/runs/${jset.run_id}`)) {
hits.push(`run:${jset.run_id}`)
return r.fulfill({ json: { id: jset.run_id, run_class: jset.run_class, status: 'succeeded' } })
@@ -182,14 +189,17 @@ async function routed(page, { judge = null, segPatch = null, prov = null } = {})
return hits
}
async function open(url, { w = 1280, h = 1000, reduced = false, patchQuery = null, judge = null, segPatch = null, prov = null } = {}) {
async function open(url, { w = 1280, h = 1000, reduced = false, patchQuery = null, judge = null, segPatch = null, prov = null,
graph = null, before = null } = {}) {
const ctx = await browser.newContext({ viewport: { width: w, height: h }, ...(reduced ? { reducedMotion: 'reduce' } : {}) })
const page = await ctx.newPage()
// 找不到元素时 8 秒就报,不等默认的 30 秒:改坏验证时一节里十几处都找不到
page.setDefaultTimeout(8000)
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
const hits = await routed(page, { judge, segPatch, prov })
const hits = await routed(page, { judge, segPatch, prov, graph })
// 打开页面之前再挂的路由(排在 routed 前面,认不出的交回 routed):报告一挂上就取这次运行的证据图,换答复得赶在那之前
if (before) await before(page)
// 预览页直接 import 第二期夹具(vite 把 JSON 当模块给):要一份改过的文档时把那个模块换掉
if (patchQuery) {
await page.route((u) => new URL(u).pathname.endsWith('/run/__tests__/evidence-query.json'), (r) =>
@@ -580,16 +590,15 @@ await section('integrity', '证据接口复核出问题:醒目写出来,不
})
/**
* 组件预览页,片段接口按 answer(segId, 夹具里那一份的拷贝) 回:返回对象就当 200,返回数字
* 就当那个状态码。证据图按 graph(夹具拷贝) 回
* 组件预览页,一期夹具那份报告(#evidence-wide)的片段接口按 answer(segId, 夹具里那一份的拷贝) 回:返回对象就当 200,
* 返回数字就当那个状态码。它的证据图按 graph(夹具拷贝) 回。在打开页面之前挂上:报告一挂上就会取证据图(期 4 起正文
* 要它带的反方审稿标记),换答复晚了,取回来缓存住的就是换之前那一份。别的请求照 routed 答
*/
async function probe(answer, { graph = (g) => g } = {}) {
const r = await open('/ui-harness.html?evidence=1')
await r.page.unroute((u) => new URL(u).pathname.startsWith('/api/'))
await r.page.route((u) => new URL(u).pathname.startsWith('/api/'), (route) => {
return open('/ui-harness.html?evidence=1', { before: (page) => page.route((u) => new URL(u).pathname.startsWith('/api/'), (route) => {
const req = route.request()
if (req.method() !== 'GET') return route.abort()
const path = new URL(req.url()).pathname
if (req.method() !== 'GET' || !path.startsWith(`/api/runs/${fx.run_id}/`)) return route.fallback()
const m = path.match(/\/evidence\/segments\/([^/]+)$/)
if (m) {
const body = answer(m[1], structuredClone(fx.segments[m[1]] ?? {}))
@@ -598,9 +607,8 @@ async function probe(answer, { graph = (g) => g } = {}) {
: route.fulfill({ json: body })
}
if (/\/evidence$/.test(path)) return route.fulfill({ json: graph(structuredClone(fx.graph)) })
return route.continue()
})
return r
return route.fallback()
}) })
}
const sealOf = async (p) => ({
state: await p.locator('[data-evidence-panel] [data-ev-seal-state]').getAttribute('data-ev-seal-state').catch(() => null),
@@ -3489,7 +3497,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 })
@@ -3525,13 +3534,17 @@ await section('verify', '期 4:指标步骤的双盲复算、取自遮罩列
const block = cp.locator('[data-ev-challenge]')
const blockText = async () => (await block.innerText().catch(() => '')).replace(/\s+/g, ' ')
await badge(c.page, J, u.bad).click()
await c.page.waitForSelector('[data-evidence-panel] [data-ev-challenge] [data-challenge-item]', { timeout: 4000 }).catch(() => {})
// 单句展示没有逐句的条目(data-challenge-item):等的是片段接口答回来之后的单句那一块和它的判断标签
await c.page.waitForSelector('[data-evidence-panel] [data-ev-challenge] [data-challenge-scope="claim"]:not([data-challenge-pending]) '
+ '[data-challenge-tag]', { 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 +3552,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 +3580,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]')
@@ -3581,6 +3594,537 @@ await section('verify', '期 4:指标步骤的双盲复算、取自遮罩列
}
})
// ---------------------------------------------------------------- 期 4:正文里被反方审过的句子
/**
* 证据图带的被反方审过的句子(服务端 _challenge_verdicts,{句子: 判断}):没开裁判的那份挂在纯文字的结论句(u2、u4、u6
* 一个可点的片段都没有)和带数字的句子(u3)上;正式运行那份和裁判的徽标并在一起;探索运行那份的证据图说的是另一稿
* (工件 id 对不上),一个都不该挂
*/
const MARKS = {
plain: { u2: 'refuted', u3: 'weakened', u4: 'inconclusive', u6: 'stands' },
formal: { [fxj.formal.units.bad]: 'refuted', [fxj.formal.units.ok]: 'stands', [fxj.formal.units.limit]: 'inconclusive' },
explore: { [fxj.explore.units.ok]: 'refuted' },
}
/**
* 片段接口答的 unit.challenge 里整份报告的计数:没开裁判的那份无法判断 1 句、未能反查 1 句(无法判断的就是没反查成的);
* 正式运行那份无法判断 2 句、未能反查 1 句(说不准是哪一句)
*/
const MARK_COUNTS = {
plain: { counts: { stands: 1, weakened: 1, refuted: 1, inconclusive: 1 }, unreviewed: 1 },
formal: { counts: { stands: 1, weakened: 0, refuted: 1, inconclusive: 2 }, unreviewed: 1 },
}
/**
* 片段接口答回来之后的完整单句展示(面板里的「反方审稿」一节)。答回来之前那一节先按标记写判断(data-challenge-pending),
* 等的是之后这一块
*/
const FULL_CLAIM = '[data-evidence-panel] [data-ev-challenge] [data-challenge-scope="claim"]:not([data-challenge-pending])'
const markSet = (jset) => (jset === fxj.plain ? 'plain' : jset === fxj.formal ? 'formal' : jset === fxj.explore ? 'explore' : '')
const markGraph = (jset, g) => {
const key = markSet(jset)
if (!key) return g
g.reports[0].challenge_verdicts = MARKS[key]
if (key === 'explore') g.reports[0].doc_artifact = 'e0'.repeat(32)
return g
}
const markSeg = (jset, body) => {
const key = markSet(jset)
const verdict = MARKS[key]?.[body.unit?.id]
if (verdict && MARK_COUNTS[key]) {
body.unit.challenge = { verdict, node_id: 'chal', artifact: CH_ART, on_refuted: 'degrade', report_label: '写周报', ...MARK_COUNTS[key] }
}
return body
}
/** 半透明的前景叠在底色上之后的颜色(rgb 字符串),量对比度用 */
function blendOver(fg, bg, alpha) {
const nums = (c) => (c.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number)
const [f, b] = [nums(fg), nums(bg)]
return `rgb(${f.map((v, i) => Math.round(v * alpha + b[i] * (1 - alpha))).join(', ')})`
}
/** getComputedStyle 给的颜色(rgb / rgba / color(srgb …))→ [r, g, b, a],r g b 取 0–255 */
function rgbaOf(c) {
const m = c.match(/color\(srgb ([\d.]+) ([\d.]+) ([\d.]+)(?: \/ ([\d.]+))?\)/)
if (m) return [m[1] * 255, m[2] * 255, m[3] * 255, m[4] == null ? 1 : Number(m[4])]
const n = c.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/)
return n ? [Number(n[1]), Number(n[2]), Number(n[3]), n[4] == null ? 1 : Number(n[4])] : [0, 0, 0, 0]
}
/**
* 句末标记里那两个字(醒目的标记)的对比度:标记自己的底色(悬停、展开时铺的浅底)一层层叠到最近的不透明底色上再算。
* 返回比值、各层底色(从标记往外),以及量的时候它是不是展开态、是不是悬停着
*/
async function loudStack(pg, sel) {
const got = await pg.evaluate((s) => {
const btn = document.querySelector(s)
const word = btn?.querySelector('[data-ev-challenge-look="loud"] span')
if (!btn || !word) return null
const layers = []
for (let n = btn; n; n = n.parentElement) {
const c = getComputedStyle(n).backgroundColor
if (c && !/rgba\(0, 0, 0, 0\)|transparent/.test(c)) layers.push(c)
if (/^rgb\(/.test(c) || (c.startsWith('color(srgb') && !c.includes('/'))) break
}
layers.push(getComputedStyle(document.body).backgroundColor)
return { fg: getComputedStyle(word).color, layers, expanded: btn.getAttribute('aria-expanded') === 'true', hover: btn.matches(':hover') }
}, sel)
if (!got) return { ratio: 0, layers: [], expanded: false, hover: false }
let under = [255, 255, 255]
for (const c of [...got.layers].reverse()) {
const [r, g, b, a] = rgbaOf(c)
under = [r, g, b].map((v, i) => v * a + under[i] * (1 - a))
}
return { ratio: contrastOf(got.fg, `rgb(${under.map((v) => Math.round(v)).join(', ')})`), layers: got.layers,
expanded: got.expanded, hover: got.hover }
}
await section('marks', '期 4:被反方审过的句子在正文里挂句末标记——和裁判徽标并在一起、点开是整句面板、不逐句请求', async () => {
const r = await open('/ui-harness.html?evidence=1', { graph: markGraph, segPatch: markSeg })
const pg = r.page
await pg.waitForSelector(`${JP} [data-ev-challenge-mark]`, { timeout: 5000 }).catch(() => {})
// 正文一挂上、一处都没点:不请求片段接口;证据图每次运行只取一回(正式运行那份宽窄各摆一份也一样)
const segHits = () => r.hits.filter((h) => h.startsWith('j:'))
const graphs = (run) => r.hits.filter((h) => h === `graph:${run}`).length
check('一处都没点:没有逐句请求片段接口,证据图每次运行只取一回', segHits().length === 0
&& graphs(fxj.plain.run_id) === 1 && graphs(fxj.formal.run_id) === 1,
`${segHits().join(',')} | ${r.hits.filter((h) => h.startsWith('graph:')).join(',')}`)
const look = await pg.evaluate((sel) => [...document.querySelectorAll(`${sel} [data-ev-claim]`)].map((el) => {
const mark = el.querySelector('[data-ev-challenge-look]')
return { unit: el.getAttribute('data-ev-claim'), verdict: el.getAttribute('data-ev-verdict'),
mark: el.getAttribute('data-ev-challenge-mark'), look: mark?.getAttribute('data-ev-challenge-look') ?? null,
text: el.textContent ?? '', color: mark ? getComputedStyle(mark).color : '', icon: !!mark?.querySelector('svg'),
label: el.getAttribute('aria-label'), title: el.getAttribute('title'), tag: el.tagName, type: el.getAttribute('type') }
}), JP)
const by = Object.fromEntries(look.map((x) => [x.unit, x]))
check('没开裁判的报告:被审过的四句都挂了标记(纯文字的结论句、带数字的句子都有),没被审的不挂',
JSON.stringify(Object.keys(by).sort()) === JSON.stringify(['u2', 'u3', 'u4', 'u6'])
&& look.every((x) => x.mark === MARKS.plain[x.unit] && x.verdict === null && x.tag === 'BUTTON' && x.type === 'button'),
JSON.stringify(look.map((x) => [x.unit, x.mark, x.verdict])))
const [failedC, waitingC, faintC] = await Promise.all(['--st-failed', '--st-waiting', '--text-faint'].map((t) => colorOf(pg, t)))
check('被推翻、被削弱醒目:盾牌图标加判断两个字,失败色、提醒色', by.u2?.look === 'loud' && by.u2.text.includes('被推翻')
&& by.u2.color === failedC && by.u2.icon && by.u3?.look === 'loud' && by.u3.text.includes('被削弱') && by.u3.color === waitingC
&& by.u3.icon, JSON.stringify([by.u2, by.u3]))
check('经受住、无法判断低调:只有淡色的盾牌图标,不写字', ['u4', 'u6'].every((k) => by[k]?.look === 'quiet' && by[k].icon
&& !by[k].text.trim() && by[k].color === faintC), JSON.stringify([by.u4, by.u6]))
// 低调的两种图标形状不同(经受住是勾、无法判断是省略号):句末已经有裁判的「?」和「质询」的问号,不再添一个问号
const shapes = await pg.evaluate((sel) => ['u4', 'u6'].map((u) => document.querySelector(`${sel} [data-ev-claim="${u}"] svg`)
?.getAttribute('class') ?? ''), JP)
check('……经受住、无法判断的图标形状不同,无法判断不用问号', shapes[0].includes('lucide-shield-ellipsis')
&& shapes[1].includes('lucide-shield-check') && !shapes.some((s) => s.includes('question')), shapes.join(' | '))
check('读屏名写明句子和反方审稿的判断,悬停写明是反方模型的判断',
by.u2?.label === '结论句「增长主要来自新客首单,老客复购持平。」,反方审稿:被推翻'
&& by.u2?.title === '反方审稿:被推翻(反方模型的判断,并非系统核对)', `${by.u2?.label} | ${by.u2?.title}`)
// 未能反查的句子也记为无法判断,正文里分不出是哪一种:无法判断的悬停说明不说成反方模型的判断
check('无法判断的悬停说明:不说成反方模型的判断(可能是未能反查时记下的)',
by.u4?.title === '反方审稿:无法判断(可能是反方模型的判断,也可能是未能反查时记下的,并非系统核对)', by.u4?.title)
const both = await pg.evaluate(({ sel, unit }) => {
const els = document.querySelectorAll(`${sel} [data-ev-claim="${unit}"]`)
const el = els[0]
return { n: els.length, verdict: el?.getAttribute('data-ev-verdict'), mark: el?.getAttribute('data-ev-challenge-mark'),
text: el?.textContent ?? '', label: el?.getAttribute('aria-label'), title: el?.getAttribute('title') ?? '' }
}, { sel: J, unit: fxj.formal.units.bad })
check('正式运行:有裁判徽标的句子并在一个徽标里,不挂两个', both.n === 1 && both.verdict === 'unsupported' && both.mark === 'refuted'
&& both.text.startsWith('!') && both.text.includes('被推翻'), JSON.stringify(both))
check('……读屏名、悬停说明两样都说', both.label === '结论句「增长主要来自新客首单,老客复购持平。」,模型判断:证据不支持,反方审稿:被推翻'
&& both.title.startsWith('模型判断:证据不支持') && both.title.endsWith('反方审稿:被推翻(反方模型的判断,并非系统核对)'),
`${both.label} | ${both.title}`)
check('……两份报告仍各只占一个 Tab 位', await pg.locator(`${JP} [data-seg][tabindex="0"], ${JP} [data-ev-claim][tabindex="0"]`).count() === 1
&& await pg.locator(`${J} [data-seg][tabindex="0"], ${J} [data-ev-claim][tabindex="0"]`).count() === 1)
check('证据图说的是另一稿(工件 id 对不上):一个标记都不挂', await pg.locator(`${JX} [data-ev-challenge-mark]`).count() === 0)
// 键盘:没有可点片段的那句,标记就是它在键盘顺序里的位置;回车打开整句面板,Esc 回到标记
await pg.locator(`${JP} [data-seg="s8"]`).focus()
await pg.keyboard.press('ArrowDown')
check('↓ 从上一句走到只有标记的那一句', await activeKey(pg) === 'claim:u2', await activeKey(pg))
await pg.keyboard.press('Enter')
await pg.waitForSelector(FULL_CLAIM, { timeout: 4000 }).catch(() => {})
const p = panel(pg)
const block = p.locator('[data-ev-challenge]')
const blockText = (await block.innerText().catch(() => '')).replace(/\s+/g, ' ')
check('回车打开整句面板:标题是这一句,「反方审稿」一节写被推翻(单句展示)',
(await p.locator('h3').innerText().catch(() => '')).trim() === '增长主要来自新客首单,老客复购持平。'
&& await block.getAttribute('data-ev-challenge').catch(() => null) === 'refuted'
&& await block.locator('[data-challenge-scope="claim"]').count() === 1, blockText)
check('……只请求了这一句的一个片段', JSON.stringify(segHits()) === JSON.stringify(['j:s11']), segHits().join(','))
check('……标记标着展开了面板', await pg.locator(`${JP} [data-ev-claim="u2"]`).getAttribute('aria-expanded') === 'true')
// 只有反方标记的句子:读屏名本身已经说了「反方审稿:被推翻」,前面不再加一个「反方审稿:」
const said = await pg.locator(`${JP} [aria-live="polite"]`).first().innerText()
check('……读屏从 live region 听到这一句和反方审稿的判断,「反方审稿」只说一次',
said === '结论句「增长主要来自新客首单,老客复购持平。」,反方审稿:被推翻', said)
check('……判断不是无法判断:不说未能反查', await p.locator('[data-challenge-unreviewed]').count() === 0)
// 面板开着时这枚标记一直是展开态(悬停同理):带字的醒目标记不铺浅底——10px 的失败色、提醒色字叠在 14% 的浅底上,
// 浅色主题里不到 4.5。按标记自己的底色叠在外面的底色上算,深浅两套都量;悬停的是被削弱那一枚,和正式运行里和裁判徽标
// 并在一起的那一枚(浅底是裁判判定的颜色,字是反方的失败色)
for (const theme of ['light', 'dark']) {
await pg.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await pg.waitForTimeout(100)
const open = await loudStack(pg, `${JP} [data-ev-claim="u2"]`)
await pg.locator(`${JP} [data-ev-claim="u3"]`).hover()
const hover = await loudStack(pg, `${JP} [data-ev-claim="u3"]`)
await pg.locator(`${J} [data-ev-claim="${fxj.formal.units.bad}"]`).hover()
const both = await loudStack(pg, `${J} [data-ev-claim="${fxj.formal.units.bad}"]`)
check(`${theme}:展开态、悬停时带字的标记对比度 ≥ 4.5`, open.expanded && hover.hover && both.hover
&& Math.min(open.ratio, hover.ratio, both.ratio) >= 4.5,
`展开 ${open.ratio.toFixed(2)}(${open.layers.join(' / ')})· 悬停 ${hover.ratio.toFixed(2)} · 并在一起的 ${both.ratio.toFixed(2)}`)
}
await pg.mouse.move(1, 1)
await pg.evaluate(() => document.documentElement.removeAttribute('data-theme'))
await pg.locator(`${JP} [data-ev-claim="u2"]`).focus()
await pg.keyboard.press('Escape')
await pg.waitForTimeout(150)
check('Esc 关掉,焦点回到标记', await panel(pg).count() === 0 && await activeKey(pg) === 'claim:u2', await activeKey(pg))
// 判为无法判断的句子:这份报告无法判断的全是没反查成的,就说这一句未能反查;还有查过了、数据不够的,说可能在其中
await pg.locator(`${JP} [data-ev-claim="u4"]`).click()
await pg.waitForSelector('[data-evidence-panel] [data-challenge-unreviewed]', { timeout: 4000 }).catch(() => {})
const sure = p.locator('[data-challenge-unreviewed]')
const sureText = (await sure.innerText().catch(() => '')).replace(/\s+/g, ' ')
check('无法判断、而无法判断的全是没反查成的:说这一句未能反查', await sure.getAttribute('data-challenge-unreviewed').catch(() => null) === 'claim'
&& sureText.startsWith('这一句未能反查,已记为无法判断。反查没有完成'), sureText)
// 没反查成的句子是服务端按规则记的无法判断,反方模型根本没看过这一句:不能再写「判断由反方模型给出」
const sureBlock = (await p.locator('[data-ev-challenge]').innerText().catch(() => '')).replace(/\s+/g, ' ')
check('……这一句根本没反查:不写「判断由反方模型给出」', await p.locator('[data-ev-challenge] [data-challenge-judged]').count() === 0
&& !sureBlock.includes('反方模型'), sureBlock)
await badge(pg, J, fxj.formal.units.limit).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-challenge="inconclusive"] [data-challenge-unreviewed]', { timeout: 4000 })
.catch(() => {})
const maybe = p.locator('[data-challenge-unreviewed]')
const maybeText = (await maybe.innerText().catch(() => '')).replace(/\s+/g, ' ')
check('……无法判断的还有查过了、数据不够的:说这份报告几句未能反查、这一句可能在其中',
await maybe.getAttribute('data-challenge-unreviewed').catch(() => null) === 'maybe'
&& maybeText.startsWith('这份报告有 1 句未能反查,已记为无法判断,这一句可能在其中'), maybeText)
check('……说不准的照旧写明判断出自反方模型、并非系统核对', await p.locator('[data-ev-challenge] [data-challenge-judged]').count() === 1)
await pg.keyboard.press('Escape')
await pg.waitForTimeout(120)
for (const theme of ['light', 'dark']) {
await pg.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await pg.waitForTimeout(100)
const pairs = await pg.evaluate((sel) => [...document.querySelectorAll(`${sel} [data-ev-challenge-look]`)].map((el) => {
const back = (node) => {
for (let n = node; n; n = n.parentElement) {
const c = getComputedStyle(n).backgroundColor
if (c && !/rgba\(0, 0, 0, 0\)|transparent/.test(c)) return c
}
return getComputedStyle(document.body).backgroundColor
}
return { loud: el.getAttribute('data-ev-challenge-look') === 'loud', fg: getComputedStyle(el).color, bg: back(el) }
}), JP)
const worst = (loud) => Math.min(...pairs.filter((x) => x.loud === loud).map((x) => contrastOf(x.fg, x.bg)))
check(`${theme}:醒目的标记(带字)对比度 ≥ 4.5,低调的图标 ≥ 3`, pairs.length === 4 && worst(true) >= 4.5 && worst(false) >= 3,
`${worst(true).toFixed(2)} / ${worst(false).toFixed(2)}`)
}
await pg.evaluate(() => document.documentElement.removeAttribute('data-theme'))
const narrow = await pg.evaluate(() => {
const el = document.querySelector('#evidence-judge-narrow')
return el ? el.scrollWidth - el.clientWidth : 99
})
check('360px 画布右栏那份(并在一起的徽标)不撑出横向滚动', narrow <= 0, `${narrow}px`)
check('没有运行时报错', r.errors.length === 0, r.errors.join(' | '))
await r.ctx.close()
const n = await open('/ui-harness.html?evidence=1', { graph: markGraph, segPatch: markSeg, w: 390, h: 844 })
await n.page.waitForSelector(`${JP} [data-ev-challenge-mark]`, { timeout: 5000 }).catch(() => {})
await n.page.locator(`${JP} [data-ev-claim="u2"]`).click()
await n.page.waitForSelector(FULL_CLAIM, { timeout: 4000 }).catch(() => {})
const wide390 = await n.page.evaluate(() => {
const doc = document.querySelector('#evidence-judge-plain')
const el = document.querySelector('[data-evidence-panel]')
return { doc: doc ? doc.scrollWidth - doc.clientWidth : 99, panel: el ? el.scrollWidth - el.clientWidth : 99 }
})
check('390px:带标记的报告、点开的整句面板都不横向溢出', wide390.doc <= 0 && wide390.panel <= 1, JSON.stringify(wide390))
check('390px:没有运行时报错', n.errors.length === 0, n.errors.join(' | '))
await n.ctx.close()
})
await section('mark-panel', '期 4:从只有反方标记的句子打开整句面板——片段接口还没答、答不了时,「反方审稿」一节照样写出判断,取不到给重试', async () => {
// 正文的标记来自证据图(challenge_verdicts),面板里的理由、整份报告的计数要等这一句第一个片段的片段接口(unit.challenge)。
// 只认片段接口的话:还在取时这一节什么都不写,答 500 时整节消失、也不说为什么——用户点的可是醒目的「被推翻」,没开裁判的
// 报告里这几句也没有别的入口。u2 的第一个片段 s11 先压着不答、放行时答 500,重试时照常答;u4(无法判断)的 s17 一律 500
const plainSeg = (id) => `/api/runs/${fxj.plain.run_id}/evidence/segments/${id}`
let release = () => {}
const gate = new Promise((res) => { release = res })
const st = { s11: 0, mode: 'hold' }
const r = await open('/ui-harness.html?evidence=1', {
graph: markGraph, segPatch: markSeg,
before: (page) => page.route((u) => [plainSeg('s11'), plainSeg('s17')].includes(new URL(u).pathname), async (route) => {
if (new URL(route.request().url()).pathname === plainSeg('s17')) {
return route.fulfill({ status: 500, json: { detail: '服务暂时不可用' } })
}
st.s11++
if (st.mode === 'hold') {
await gate
return route.fulfill({ status: 500, json: { detail: '服务暂时不可用' } })
}
return route.fallback()
}),
})
const pg = r.page
await pg.waitForSelector(`${JP} [data-ev-claim="u2"][data-ev-challenge-mark="refuted"]`, { timeout: 5000 }).catch(() => {})
await pg.locator(`${JP} [data-ev-claim="u2"]`).click()
const p = panel(pg)
const block = p.locator('[data-ev-challenge]')
const text = async () => (await block.innerText().catch(() => '')).replace(/\s+/g, ' ')
await pg.waitForSelector('[data-evidence-panel] [data-ev-challenge]', { timeout: 3000 }).catch(() => {})
check('片段接口还在取:「反方审稿」一节已经按标记写出判断(被推翻),说正在获取理由与查询',
await block.getAttribute('data-ev-challenge').catch(() => null) === 'refuted'
&& (await block.locator('[data-challenge-tag="refuted"]').innerText().catch(() => '')).trim() === '被推翻'
&& await block.locator('[data-challenge-pending="loading"]').count() === 1 && (await text()).includes('正在获取反方的理由与查询'),
await text())
check('……判断出自反方模型、并非系统核对,同样写明', (await text()).includes('判断由反方模型给出,并非系统核对'), await text())
release()
await pg.waitForSelector('[data-evidence-panel] [data-challenge-pending="error"]', { timeout: 4000 }).catch(() => {})
const failedText = await text()
check('片段接口答 500:判断还在,写明未能获取理由与查询,给重试', await block.locator('[data-challenge-tag="refuted"]').count() === 1
&& failedText.includes('未能获取反方的理由与查询') && await block.locator('button', { hasText: '重试' }).count() === 1, failedText)
// 重试:这回照常答(带 unit.challenge),换成完整的单句展示;按过的「重试」没了,焦点不掉到页面上
st.mode = 'ok'
await block.locator('button', { hasText: '重试' }).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-challenge] [data-challenge-scope="claim"]:not([data-challenge-pending]) '
+ '[data-challenge-toggle]', { timeout: 4000 }).catch(() => {})
check('重试:片段接口再请求一次,答回来换成完整的单句展示(能展开理由与查询)', st.s11 === 2
&& await block.locator('[data-challenge-pending]').count() === 0 && await block.locator('[data-challenge-toggle]').count() === 1
&& await block.locator('[data-challenge-tag="refuted"]').count() === 1, `${st.s11} 次 | ${await text()}`)
check('……焦点留在面板里', await pg.evaluate(() => !!document.activeElement?.closest('[data-evidence-panel]')),
await pg.evaluate(() => document.activeElement?.outerHTML.slice(0, 80) ?? ''))
// 无法判断的那一句:片段接口答不了,说不准它是不是根本没反查成,不说成反方模型的判断
await pg.keyboard.press('Escape')
await pg.locator(`${JP} [data-ev-claim="u4"]`).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-challenge="inconclusive"] [data-challenge-pending="error"]', { timeout: 4000 })
.catch(() => {})
const unsure = await text()
check('无法判断的那一句、片段接口答不了:写出判断和未能获取,不说成反方模型的判断', unsure.includes('无法判断')
&& unsure.includes('未能获取反方的理由与查询') && !unsure.includes('判断由反方模型') && unsure.includes('也可能是未能反查时记下的'), unsure)
check('没有运行时报错', r.errors.length === 0, r.errors.join(' | '))
await r.ctx.close()
// 390px(底部抽屉):取不到时那一节(判断、未能获取、重试)不横向溢出
const n = await open('/ui-harness.html?evidence=1', {
graph: markGraph, segPatch: markSeg, w: 390, h: 844,
before: (page) => page.route((u) => new URL(u).pathname === plainSeg('s11'),
(route) => route.fulfill({ status: 500, json: { detail: '服务暂时不可用' } })),
})
await n.page.waitForSelector(`${JP} [data-ev-claim="u2"][data-ev-challenge-mark]`, { timeout: 5000 }).catch(() => {})
await n.page.locator(`${JP} [data-ev-claim="u2"]`).click()
await n.page.waitForSelector('[data-evidence-panel] [data-challenge-pending="error"]', { timeout: 4000 }).catch(() => {})
const over = await n.page.evaluate(() => {
const el = document.querySelector('[data-evidence-panel]')
return { pending: !!el?.querySelector('[data-challenge-pending="error"]'),
page: document.documentElement.scrollWidth - document.documentElement.clientWidth, panel: el ? el.scrollWidth - el.clientWidth : 99 }
})
check('390px:取不到时那一节(判断、未能获取、重试)不横向溢出', over.pending && over.page <= 0 && over.panel <= 1, JSON.stringify(over))
check('390px:没有运行时报错', n.errors.length === 0, n.errors.join(' | '))
await n.ctx.close()
})
await section('mark-tab', '期 4:一个可点的片段都没有的报告,标记随证据图晚到:标记进 Tab 顺序', async () => {
// 用例 evidence-marks-text(dev/fixtures/evidence_marks.tsx):挂上时整份报告没有键盘目标(没有可点片段、没开裁判、没给
// 质询),拿 Tab 位的是空的;证据图晚 0.7 秒到,带着被审过的三句。只在「拿着 Tab 位的那个没了」时补的话,空的从来不补:
// 三枚标记都是 tabindex=-1,Tab 直接跳过整份报告
const RUN_T = 'run-evidence-marks-text'
const r = await open('/preview.html?syn=evidence-marks-text', {
before: (page) => page.route((u) => new URL(u).pathname.startsWith(`/api/runs/${RUN_T}/`), async (route) => {
if (new URL(route.request().url()).pathname !== `/api/runs/${RUN_T}/evidence`) {
return route.fulfill({ status: 404, json: { detail: 'Not Found' } })
}
await new Promise((res) => setTimeout(res, 700))
const g = structuredClone(fxj.plain.graph)
g.run_id = RUN_T
g.reports[0].challenge_verdicts = { u2: 'refuted', u4: 'weakened', u6: 'stands' }
return route.fulfill({ json: g })
}),
})
const pg = r.page
await pg.waitForSelector('[data-marks-doc] [data-ev-challenge-mark]', { timeout: 5000 }).catch(() => {})
const tabs = await pg.evaluate(() => [...document.querySelectorAll('[data-marks-doc] [data-ev-claim]')]
.map((el) => `${el.getAttribute('data-ev-claim')}:${el.tabIndex}`))
check('标记到了:三枚都挂上,第一枚拿着这份报告唯一的 Tab 位', JSON.stringify(tabs) === JSON.stringify(['u2:0', 'u4:-1', 'u6:-1'])
&& await pg.locator('[data-marks-doc] [tabindex="0"]').count() === 1, tabs.join(', '))
await pg.locator('[data-marks-before]').focus()
await pg.keyboard.press('Tab')
check('从报告前面的按钮按 Tab:落到第一枚标记上', await activeKey(pg) === 'claim:u2', await activeKey(pg))
await pg.keyboard.press('ArrowRight')
check('……→ 走到下一枚标记', await activeKey(pg) === 'claim:u4', await activeKey(pg))
check('没有运行时报错', r.errors.length === 0, r.errors.join(' | '))
await r.ctx.close()
})
await section('stale', '期 4:手里那份证据图过期了(报告改写过、运行收尾之前取的):正文、证据页重取一次,对上了就不再取', async () => {
// 正文有运行就取证据图,同一次运行只取一回、之后一直用缓存。审批卡里展开过报告的话,缓存的是运行停在审批上那一刻的图:
// 之后报告改写了(第二次审批、同一张卡换稿),新稿在那份图里找不到,一个标记都不挂;审批之后才审完的反方审稿,成果区
// 也看不到;证据页挂上时只在「运行已结束、图没封存」时重取,停在第二次审批上的运行、别处正在取的那份都不管,画的是
// 旧稿,审计表却是新取的。用例 evidence-marks-stale(dev/fixtures/evidence_marks.tsx)按场景重新挂,证据图在这里按
// st.answer 答(请求进来那一刻的答复和延迟),数得出取了几回
const RUN_S = 'run-evidence-marks-stale'
const OLD = fxj.plain.doc_artifact
const NEW = 'b7'.repeat(32)
const SEALED = { sealed: true, ok: true, manifest_seq: 64, legacy: false }
const OPEN = { sealed: false, ok: null, manifest_seq: null, legacy: false }
const graphOf = (art, marks, seal) => ({
...structuredClone(fxj.plain.graph), run_id: RUN_S, seal,
reports: [{ ...structuredClone(fxj.plain.graph.reports[0]), doc_artifact: art, doc_sealed: seal.sealed, challenge_verdicts: marks }],
})
// 第一次审批时:初稿,没封存;改写后第二次审批:新稿,没封存;运行结束:审批之后的反方审稿也审完了,封存了
const G1 = graphOf(OLD, { u2: 'refuted' }, OPEN)
const G2 = graphOf(NEW, { u4: 'weakened' }, OPEN)
const G3 = graphOf(NEW, { u2: 'refuted', u4: 'weakened' }, SEALED)
const start = async () => {
const st = { answer: G1, delay: 0, n: 0 }
const r = await open('/preview.html?syn=evidence-marks-stale', {
before: (page) => page.route((u) => new URL(u).pathname.startsWith(`/api/runs/${RUN_S}/`), async (route) => {
if (new URL(route.request().url()).pathname !== `/api/runs/${RUN_S}/evidence`) {
// 审计接口答「老服务端」(证据页按页面上的文档拼审计表),片段、问答串一律没有
return route.fulfill({ status: 404, json: { detail: 'Not Found' } })
}
st.n++
const { answer, delay } = st
if (delay) await new Promise((res) => setTimeout(res, delay))
return route.fulfill({ json: structuredClone(answer) })
}),
})
return { ...r, st }
}
const stage = async (pg, name) => {
await pg.locator(`[data-marks-stage="${name}"]`).click()
await pg.waitForSelector(`[data-marks-at="${name}"]`)
}
const marksIn = (pg) => pg.evaluate(() => JSON.stringify(Object.fromEntries([...document.querySelectorAll('[data-marks-at] [data-ev-challenge-mark]')]
.map((el) => [el.getAttribute('data-ev-claim'), el.getAttribute('data-ev-challenge-mark')]).sort())))
// 审批卡 → 第二次审批 → 成果区:每处重新挂上
const a = await start()
await a.page.waitForSelector('[data-marks-at="old"] [data-ev-challenge-mark="refuted"]', { timeout: 5000 }).catch(() => {})
check('审批卡里展开初稿:证据图取一回,标记照它挂', a.st.n === 1 && await marksIn(a.page) === '{"u2":"refuted"}',
`${a.st.n} 回 | ${await marksIn(a.page)}`)
a.st.answer = G2
await stage(a.page, 'new')
await a.page.waitForSelector('[data-marks-at="new"] [data-ev-claim="u4"][data-ev-challenge-mark="weakened"]', { timeout: 3000 })
.catch(() => {})
check('改写后第二次审批的新稿:手里那份图里没有这一稿,重取一次,标记按新图挂', a.st.n === 2
&& await marksIn(a.page) === '{"u4":"weakened"}', `${a.st.n} 回 | ${await marksIn(a.page)}`)
await stage(a.page, 'new')
await a.page.waitForTimeout(500)
check('……再挂一次:手里那份对上了,不再取', a.st.n === 2 && await marksIn(a.page) === '{"u4":"weakened"}', `${a.st.n} 回`)
a.st.answer = G3
await stage(a.page, 'output')
await a.page.waitForSelector('[data-marks-at="output"] [data-ev-claim="u2"][data-ev-challenge-mark="refuted"]', { timeout: 3000 })
.catch(() => {})
check('运行结束后的成果区:手里那份是收尾之前取的(没封存),重取一次,审批之后才审完的标记也挂上', a.st.n === 3
&& await marksIn(a.page) === '{"u2":"refuted","u4":"weakened"}', `${a.st.n} 回 | ${await marksIn(a.page)}`)
await stage(a.page, 'output')
await a.page.waitForTimeout(500)
check('……再挂一次:封存了的图不再取', a.st.n === 3, `${a.st.n} 回`)
check('没有运行时报错', a.errors.length === 0, a.errors.join(' | '))
await a.ctx.close()
// 同一个实例从初稿换到新稿(画布的审批浮层换审批,不重新挂)
const b = await start()
await b.page.waitForSelector('[data-marks-at="old"] [data-ev-challenge-mark]', { timeout: 5000 }).catch(() => {})
await stage(b.page, 'switch')
await b.page.waitForTimeout(400)
check('同一张卡:初稿重新挂上,手里那份对得上,不重取', b.st.n === 1, `${b.st.n} 回`)
b.st.answer = G2
await b.page.locator('[data-marks-switch]').click()
await b.page.waitForSelector(`[data-marks-card="${NEW}"] [data-ev-claim="u4"][data-ev-challenge-mark="weakened"]`, { timeout: 3000 })
.catch(() => {})
check('……同一个实例换成新稿:重取一次,标记按新图挂', b.st.n === 2 && await marksIn(b.page) === '{"u4":"weakened"}',
`${b.st.n} 回 | ${await marksIn(b.page)}`)
await b.ctx.close()
// 证据页,运行停在第二次审批上:手里那份是第一次审批时的初稿
const c = await start()
await c.page.waitForSelector('[data-marks-at="old"] [data-ev-challenge-mark]', { timeout: 5000 }).catch(() => {})
c.st.answer = G2
// 证据页挂上之后画没画过旧稿,哪怕只画了一帧又收起来:证据页里加进来、拿下去的节点带着旧稿的句子就记下来(画的是旧稿、
// 审计表却是新取的,点一行会打开旧稿里同号的片段)。按变动记录里的节点认,不按回调时的页面认——先画旧稿、紧接着换成
// 占位的话,回调时页面上已经没有了
await c.page.evaluate(() => {
window.__sawOld = false
new MutationObserver((muts) => {
for (const m of muts) {
const at = m.target.nodeType === 1 ? m.target : m.target.parentElement
const nodes = [...m.addedNodes, ...m.removedNodes]
// 证据页里面的变动看全部节点;外面的(换场景时拿下审批卡那一层)只看证据页那一层自己
const suspects = at?.closest?.('[data-marks-at^="pane"]') ? nodes
: nodes.filter((n) => n.nodeType === 1 && n.matches('[data-marks-at^="pane"]'))
if (suspects.some((n) => n.textContent?.includes('增长主要来自新客首单'))) window.__sawOld = true
}
}).observe(document.body, { subtree: true, childList: true, characterData: true })
})
await stage(c.page, 'pane-interrupted')
await c.page.waitForSelector('[data-marks-at="pane-interrupted"] [data-evidence-report="write"] [data-ev-claim="u4"]', { timeout: 4000 })
.catch(() => {})
const report = (await c.page.locator('[data-marks-at] [data-evidence-report="write"]').innerText().catch(() => '')).replace(/\s+/g, ' ')
check('证据页(运行停在第二次审批上):手里那份没封存,重取一次,画的是新稿,标记按新图挂', c.st.n === 2
&& report.includes('增长主要来自老客复购') && !report.includes('增长主要来自新客首单') && await marksIn(c.page) === '{"u4":"weakened"}',
`${c.st.n} 回 | ${report.slice(0, 80)}`)
check('……重取回来之前先占位,旧稿一眼都没画', !(await c.page.evaluate(() => window.__sawOld)))
const table = (await c.page.locator('[data-marks-at] [data-evidence-audit]').innerText().catch(() => '')).replace(/\s+/g, ' ')
check('……审计表也是新稿的(退款 31 单)', table.includes('退款 31 单') && !table.includes('退款 29 单'), table.slice(0, 160))
await c.ctx.close()
// 证据页,运行已经结束:挂上时别处正在取证据图(新稿重新挂上触发的那一次,慢),取回来的那份没封存,再取一次
const d = await start()
await d.page.waitForSelector('[data-marks-at="old"] [data-ev-challenge-mark]', { timeout: 5000 }).catch(() => {})
Object.assign(d.st, { answer: G2, delay: 1200 })
await stage(d.page, 'new')
for (let i = 0; i < 30 && d.st.n < 2; i++) await d.page.waitForTimeout(50)
Object.assign(d.st, { answer: G3, delay: 0 })
await stage(d.page, 'pane-succeeded')
await d.page.waitForSelector('[data-marks-at="pane-succeeded"] [data-evidence-seal="done"]', { timeout: 5000 }).catch(() => {})
const seal = (await d.page.locator('[data-marks-at] [data-evidence-seal]').innerText().catch(() => '')).replace(/\s+/g, ' ')
check('证据页(运行已结束):挂上时别处正在取,取回来的那份没封存,再取一次,顶上写已封存', d.st.n === 3
&& seal.includes('已封存'), `${d.st.n} 回 | ${seal}`)
await d.page.waitForTimeout(500)
check('……之后不再取', d.st.n === 3, `${d.st.n} 回`)
check('没有运行时报错', d.errors.length === 0, d.errors.join(' | '))
await d.ctx.close()
})
await section('ask', '期 4:句末「质询」图标平时的对比度——深浅两套、页面底色和面板底色上都 ≥ 3:1,显出来时不撑开排版', async () => {
// 平时是淡淡的一个小图标(字色压一层不透明度),可 WCAG 1.4.11 要求界面组件对背景 ≥ 3:1(以前最淡的字色压到 60%,深色
// 约 2.75、浅色约 2.33)。用例 evidence-ask(dev/fixtures/evidence_ask.tsx):同一份报告摆在页面底色(同审批卡)和面板
// 底色(同证据页)上,深浅两套各量一遍,按叠上不透明度之后的颜色算
const r = await open('/preview.html?syn=evidence-ask')
const pg = r.page
await pg.waitForSelector('[data-ask-on="panel"] [data-inquiry-ask]')
await pg.mouse.move(1, 1)
await pg.evaluate(() => document.activeElement?.blur?.())
for (const theme of ['dark', 'light']) {
await pg.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await pg.waitForTimeout(120)
for (const [on, name] of [['bg', '页面底色'], ['panel', '面板底色']]) {
const m = await pg.evaluate((box) => [...document.querySelectorAll(`[data-ask-on="${box}"] [data-inquiry-ask]`)].map((b) => {
const back = (node) => {
for (let n = node; n; n = n.parentElement) {
const c = getComputedStyle(n).backgroundColor
if (c && !/rgba\(0, 0, 0, 0\)|transparent/.test(c)) return c
}
return getComputedStyle(document.body).backgroundColor
}
const cs = getComputedStyle(b)
return { fg: cs.color, opacity: Number(cs.opacity), bg: back(b) }
}), on)
const ratios = m.map((x) => contrastOf(blendOver(x.fg, x.bg, x.opacity), x.bg))
const worst = Math.min(...ratios)
check(`${theme} · ${name}:平时的「质询」图标对比度 ≥ 3:1`, m.length >= 3 && worst >= 3 && m.every((x) => x.opacity < 1),
`${worst.toFixed(2)}(${m[0]?.fg} × ${m[0]?.opacity} on ${m[0]?.bg})`)
}
}
await pg.evaluate(() => document.documentElement.removeAttribute('data-theme'))
// 悬停一句时显出来:只换颜色、描边和不透明度,宽度、位置不变
const btn = pg.locator('[data-ask-on="panel"] [data-inquiry-ask]').first()
const rest = await btn.boundingBox()
await pg.locator('[data-ask-on="panel"] [data-ev-unit]').first().hover()
await pg.waitForTimeout(150)
const shown = await btn.boundingBox()
const opacity = await btn.evaluate((el) => getComputedStyle(el).opacity)
check('悬停这一句:图标显出来(不透明),宽度、位置不变,不撑开排版', opacity === '1' && !!rest && !!shown
&& Math.abs(rest.width - shown.width) < 0.5 && Math.abs(rest.x - shown.x) < 0.5, JSON.stringify({ rest, shown, opacity }))
check('没有运行时报错', r.errors.length === 0, r.errors.join(' | '))
await r.ctx.close()
})
check('没有运行时报错(组件预览)', harness.errors.length === 0, harness.errors.join(' | '))
await harness.ctx.close()
await browser.close()
+41
View File
@@ -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, '窄栏']]) {
+25 -17
View File
@@ -20,8 +20,8 @@
// 真写一遍的结果);没有合并预告时按其余指标当前的选择推,被目录挡下的同项指标也算进来(说法不同就说都不记,说不准的
// 只说可能),结尾换成以时间线上的记录为准;盲算的理由按核查 agent 组装的理由文档画,有出处的数字画实线、裸数字醒目
// 标出,老工件照旧纯文本;
// - 出具缺口:证据页(真的 EvidencePane)和成果节点印章的悬停说明把双盲复算、反方审稿报上来的缺口另起「核查未通过」
// 「核查未完成」两组,不放在「校验未全部完成」下;
// - 出具缺口:证据页的出具横幅(真的 EvidencePane 里那一个 IssuanceBanner,和问数据、画布右栏同一个)和成果节点印章的
// 悬停说明把双盲复算、反方审稿报上来的缺口另起「核查未通过」「核查未完成」两组,不放在「校验未全部完成」下;
// - 记录页的工件列表:双盲复算对照、反方审稿记录两种工件有名字和说明,不露键名;
// - 检查器:口径卡、指标、口径说明、盲算模型、相对误差、未采用原流程时、启用,每一项都真的写进节点配置;字段的
// 叫法和服务端校验报错里的(engine/labels.py 的 DUAL_CHECK_FIELD_LABEL)一字不差;选口径卡、盲算模型、启用三态用的是
@@ -1129,11 +1129,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 = ['双盲复算「复核周报口径」:指标「订单数」无法复算:输入来自合并查询,无法单独复算']
@@ -1149,17 +1150,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()
@@ -1171,8 +1179,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
@@ -1181,9 +1188,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(';'))
}