mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:31:58 +00:00
合入期 4 整合 J3:出具横幅按「核查未通过 / 核查未完成 / 校验未全部完成」分组;审批卡把运行类别交给反方审稿概况;证据接口的核查类型契约挪进 types.ts;反方审稿加单句模式、证据面板不再谈出具;证据接口带上句子的反方判断映射,正文给被审过的句子挂句末标记;质询图标平时对比度达标
This commit is contained in:
+55
-18
@@ -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"}
|
||||
@@ -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 />,
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
}
|
||||
@@ -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: '反方审稿未启用,已跳过',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)'
|
||||
|
||||
@@ -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 : ''
|
||||
|
||||
@@ -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 ?? '' : ''
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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(';'))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user