审批卡标明运行类别、附上审的报告的核对概况:真实运行里审批说明写着「批准后才会正式出具」,那却是一次探索运行;报告里一句结论和证据相矛盾,审批人批准时完全不知道。人工审批的卡上加一行运行类别和批了会怎样(探索运行写「批准后输出,这是探索运行,不是正式出具」,正式运行写从已发布版本发起);human 节点挂起时,上游直接连着的、或标题和说明里引用了正文(nodes.<id> 或报告的 assign_to)的报告撰写节点各带一份核对概况(payload.reports:同 report.checked 的 stats、结论句策略、裁判摘要、报告文档工件 id),卡上写「N 个数字都有出处」和结论句的核对状态(节点里判过的写判了几句、几句证据相矛盾;没开裁判或探索运行写「结论句尚未核对」),并给查看逐段证据的入口,点数字打开证据面板。check-stream 补断言

This commit is contained in:
ThearchyHelios
2026-10-04 11:29:04 +08:00
parent 546d0e9326
commit 4e1adec205
8 changed files with 472 additions and 6 deletions
+48
View File
@@ -33,6 +33,8 @@ async def run_human(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
}
if mode == "edit":
payload["draft"] = ctx.render(ctx.cfg("draft", ""), state)
if reports := _reviewed_reports(state, ctx):
payload["reports"] = reports
response = await ask(ctx, payload, **payload)
# 谁批的跟着事件走:时间线要写得出"张工驳回了",没署名就是 None,不再默认写"你"
@@ -71,6 +73,52 @@ async def run_human(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
return updates
#: 模板里对某个节点产出的引用:{{ nodes.write.text }}、{{ vars.weekly }}
_NODE_REF = re.compile(r"\bnodes\s*\.\s*([\w-]+)")
_VAR_REF = re.compile(r"\bvars\s*\.\s*([\w-]+)")
#: 审批卡要的裁判摘要:判了几句、各判成什么、有没有判完。其余(上限、费用、模型名)留在证据面板里看
_JUDGE_KEYS = ("mode", "counts", "complete", "unjudged", "limits_hit")
def _reviewed_reports(state: GraphState, ctx: NodeContext) -> list[dict[str, Any]]:
"""这次审批审的报告各自的核对概况:上游直接连着的报告撰写节点,加上标题、说明里引用了正文的
({{ nodes.<id>.text }},或者报告的 assign_to 变量)。
真实运行里报告有一句结论和证据相矛盾,审批人批准时完全不知道:审批卡只有正文。这里带上报告节点产出里的
stats(同 report.checked)、结论句策略、裁判摘要和报告文档的工件 id,审批卡据此写核对概况、给打开证据的入口。
只认已经跑完、带着核对统计的报告;重放时 state 一样,算出来的也一样。"""
from app.engine.schema import NodeType
spec = ctx.run.spec
nodes = spec.node_map()
text = " ".join(json.dumps(ctx.config.get(key), ensure_ascii=False, default=str)
for key in ("title", "message", "context", "draft") if ctx.config.get(key))
# 报告节点的 assign_to 也能把正文交给下游:{{ vars.<assign_to> }} 和 {{ nodes.<id>.text }} 一样算引用
by_var = {str(n.config.get("assign_to")): n.id for n in spec.nodes
if n.type == NodeType.REPORT and n.config.get("assign_to")}
wanted = ([e.source for e in spec.edges if e.target == ctx.node.id] + _NODE_REF.findall(text)
+ [by_var[v] for v in _VAR_REF.findall(text) if v in by_var])
outputs = state.get("nodes") or {}
out: list[dict[str, Any]] = []
for rid in dict.fromkeys(wanted):
node = nodes.get(rid)
result = outputs.get(rid)
if node is None or node.type != NodeType.REPORT or not isinstance(result, dict) \
or not isinstance(result.get("stats"), dict):
continue
item: dict[str, Any] = {
"node_id": rid, "label": node.title,
"stats": {k: v for k, v in result["stats"].items() if isinstance(v, int) and not isinstance(v, bool)},
"claims": result.get("claims") or "off",
}
if isinstance(result.get("doc_artifact"), str) and result["doc_artifact"]:
item["doc_artifact"] = result["doc_artifact"]
if isinstance(result.get("judge"), dict):
item["judge"] = {k: result["judge"][k] for k in _JUDGE_KEYS if k in result["judge"]}
out.append(item)
return out
# --------------------------------------------------------------------------
# 结构化校验
# --------------------------------------------------------------------------
+154
View File
@@ -0,0 +1,154 @@
"""人工审批审的是报告时,审批卡附上这份报告的核对概况(human 节点的请求里带 reports)。
真实运行里:报告里有一句结论和证据相矛盾,审批人批准时完全不知道——报告节点只把正文交给了审批卡,几个数字有出处、
结论句判没判过,一个字都没有。用户事后在证据面板点开那句,裁判才判它相矛盾。
现在 human 节点挂起时,上游的报告节点(直接连着的,或者标题、说明里引用了它正文的)各带一份核对概况:
数字核对的统计(report.checked 的 stats)、结论句策略、裁判摘要(几句判了、几句相矛盾),以及报告文档的工件 id,
审批卡据此写「N 个数字都有出处」「结论句已裁判 X 句,证据相矛盾 Y 句」或「结论句尚未核对」,并给出打开证据的入口。
表名、指标、数字都是虚构的。
"""
from __future__ import annotations
import asyncio
import json
import re
import pytest
from langchain_core.messages import AIMessage
from sqlalchemy import delete, select
from app.db.base import SessionLocal
from app.db.models import Approval, Run, RunEvent, Setting
from app.engine import judge as judge_mod
from app.engine.judge import JUDGE_ROLE, SPEND_KEY
from app.engine.runner import run_manager
from app.providers.mock_model import MockChatModel
PRICED = "claude-sonnet-5"
METRICS = [{"id": "gmv", "name": "销售额", "unit": "元", "decimals": 1, "expression": "vars.kpi.gmv"}]
TEXT = "本周销售额 [[m:gmv]]。[[see:m:gmv]]增长主要来自新客首单。"
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
async with SessionLocal() as session:
await session.execute(delete(Setting).where(Setting.key.in_([SPEND_KEY, "judge", "copilot"])))
await session.commit()
yield
await run_manager.shutdown()
@pytest.fixture(autouse=True)
def models(monkeypatch):
"""写作者回 TEXT;裁判把「新客」那句判成证据相矛盾,其余有依据。"""
def decide(model, messages):
if JUDGE_ROLE in str(messages[0].content):
request = str(messages[-1].content)
items = [{"unit": uid, "verdict": "contradicted" if "新客" in line else "supported",
"rationale": "虚构的理由", "used": []}
for uid, line in re.findall(r"^\[(u\d+)\]([^\n]*)$", request, re.M)]
return AIMessage(content=json.dumps({"items": items}, ensure_ascii=False))
return AIMessage(content=TEXT)
monkeypatch.setattr(MockChatModel, "_decide", decide)
async def judge_model_of(session, spec):
return MockChatModel(model_name=PRICED), PRICED
monkeypatch.setattr(judge_mod, "get_chat_model", judge_model_of)
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 graph(*, gate: dict, wire_report_to_gate: bool = True, **report):
nodes = [
node("start", "input"),
node("fetch", "transform", mode="expression", expression="{'gmv': 45678.5}", assign_to="kpi"),
node("caliber", "metrics", caliber="周报口径", caliber_version="v1", metrics=METRICS),
node("write", "report", "写周报", instructions="写周报", **report),
node("note", "transform", mode="expression", expression="'ok'", assign_to="note"),
node("gate", "human", "主管审批", mode="approve", **gate),
node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]),
]
chain = ["start", "fetch", "caliber", "write"] + (["gate"] if wire_report_to_gate else ["note", "gate"])
edges = [{"source": a, "target": b} for a, b in zip(chain, chain[1:])]
edges.append({"source": "gate", "target": "out", "sourceHandle": "approved"})
return {"nodes": nodes, "edges": edges}
async def paused(g, *, run_class: str) -> tuple[Run, Approval]:
started = await run_manager.start(graph=g, input_payload={}, run_class=run_class)
for _ in range(400):
await asyncio.sleep(0.05)
async with SessionLocal() as session:
row = await session.get(Run, started.id)
if row.status in ("interrupted", "failed", "succeeded"):
approval = (await session.execute(select(Approval).where(Approval.run_id == row.id))).scalar_one_or_none()
if row.status != "interrupted" or approval is not None:
break
assert row.status == "interrupted", row.error
assert approval is not None
return row, approval
async def test_formal_run_carries_the_judged_claims_to_the_approval():
row, approval = await paused(graph(gate={"title": "审核周报", "message": "{{ nodes.write.text }}"},
claims="judge"), run_class="formal")
[report] = approval.payload["reports"]
assert report["node_id"] == "write" and report["label"] == "写周报"
assert isinstance(report["doc_artifact"], str) and report["doc_artifact"]
assert report["stats"]["numbers"] == report["stats"]["numbers_cited"] == 1
assert report["claims"] == "judge"
judge = report["judge"]
assert judge["mode"] == "inline"
assert judge["counts"]["contradicted"] == 1 and judge["counts"]["supported"] == 1
assert judge["counts"]["unjudged"] == 0
# 和 report.checked 是同一份:审批卡写的和时间线那一行对得上
async with SessionLocal() as session:
[checked] = list((await session.execute(select(RunEvent).where(
RunEvent.run_id == row.id, RunEvent.type == "report.checked"))).scalars())
assert checked.data["doc_artifact"] == report["doc_artifact"]
assert {k: checked.data["stats"][k] for k in report["stats"]} == report["stats"]
assert checked.data["judge"]["counts"] == judge["counts"]
# human.requested 事件里也带着,回放时界面一样读得到
async with SessionLocal() as session:
[requested] = list((await session.execute(select(RunEvent).where(
RunEvent.run_id == row.id, RunEvent.type == "human.requested"))).scalars())
assert requested.data["reports"] == approval.payload["reports"]
async def test_exploratory_run_says_claims_are_judged_on_demand():
_, approval = await paused(graph(gate={"title": "审核周报"}, claims="judge"), run_class="exploratory")
[report] = approval.payload["reports"]
assert report["claims"] == "judge" and report["judge"]["mode"] == "on_demand"
assert report["judge"]["counts"]["contradicted"] == 0, "探索运行不在节点里判"
async def test_report_without_claims_policy_still_reports_numbers():
_, approval = await paused(graph(gate={"title": "审核周报"}), run_class="exploratory")
[report] = approval.payload["reports"]
assert report["claims"] == "off" and "judge" not in report
assert report["stats"]["numbers"] == 1
async def test_a_report_quoted_in_the_message_counts_even_when_not_wired_directly():
_, approval = await paused(graph(gate={"title": "审核", "message": "请看:{{ nodes.write.text }}"},
wire_report_to_gate=False), run_class="exploratory")
assert [r["node_id"] for r in approval.payload["reports"]] == ["write"]
async def test_a_report_quoted_through_its_variable_counts_too():
_, approval = await paused(graph(gate={"title": "审核", "message": "{{ vars.weekly }}"}, wire_report_to_gate=False,
assign_to="weekly"), run_class="exploratory")
assert [r["node_id"] for r in approval.payload["reports"]] == ["write"]
async def test_approvals_unrelated_to_a_report_carry_nothing():
_, approval = await paused(graph(gate={"title": "确认", "message": "继续吗"}, wire_report_to_gate=False),
run_class="exploratory")
assert "reports" not in approval.payload
+30 -3
View File
@@ -488,11 +488,36 @@ function Expanding({ dense, n }: { dense: boolean; n: number }) {
const APPROVAL = {
id: 'ap-1', run_id: 'syn-mixed', node_id: 'review', mode: 'approve' as const,
title: '出勤结论可以发出吗?', status: 'pending', response: {},
payload: { message: '早班出勤率 **94.23%**,晚班 **73.08%**。' },
payload: { kind: 'human_node', message: '早班出勤率 **94.23%**,晚班 **73.08%**。' },
created_at: new Date(Date.now() - 8 * 86_400_000 - 3_600_000).toISOString().replace('Z', ''),
workflow_name: '出勤日报', node_label: '班长复核', run_class: 'formal' as const,
}
/**
* ?approval=report:探索运行里审一份报告。核对概况取 evidence-doc 夹具(7/12 数字有出处、1 处引用解析不了),
* 结论句没在节点里判;?approval=report-formal:正式运行,节点里判过,1 句证据相矛盾
*/
const APPROVAL_REPORT = {
id: 'ap-2', run_id: evidenceFixture.run_id, node_id: 'review', mode: 'approve' as const,
title: '本周周报可以发出吗?', status: 'pending', response: {},
payload: {
kind: 'human_node', message: '请审核本周周报。',
reports: [{ node_id: 'write', label: '写周报', stats: evidenceFixture.doc.stats, claims: 'judge',
doc_artifact: evidenceFixture.doc_artifact,
judge: { mode: 'on_demand', counts: { unjudged: 4 }, complete: false } }],
},
created_at: new Date(Date.now() - 120_000).toISOString().replace('Z', ''),
workflow_name: '周报', node_label: '主管审批', run_class: 'exploratory' as const,
}
const APPROVAL_REPORT_FORMAL = {
...APPROVAL_REPORT, id: 'ap-3', run_class: 'formal' as const,
payload: {
...APPROVAL_REPORT.payload,
reports: [{ ...APPROVAL_REPORT.payload.reports[0], stats: { ...evidenceFixture.doc.stats, numbers_cited: 12, uncited_numbers: 0, unresolved: 0 },
judge: { mode: 'inline', counts: { supported: 5, contradicted: 1, unjudged: 0 }, complete: true } }],
},
}
function Preview() {
// ?case=db 只看一个用例,?theme=light 直接出浅色——都是为了截图可复现,
// 不用靠点按钮
@@ -525,11 +550,13 @@ function Preview() {
if (md) return <div className="h-full overflow-y-auto"><MarkdownCases /></div>
if (params.get('grow') === '1') return <Growing dense={dense} />
if (params.get('expand')) return <Expanding dense={dense} n={Number(params.get('expand')) || 1} />
if (params.get('approval') === '1') {
const approvalCase = params.get('approval')
if (approvalCase === '1' || approvalCase === 'report' || approvalCase === 'report-formal') {
const approval = approvalCase === 'report' ? APPROVAL_REPORT : approvalCase === 'report-formal' ? APPROVAL_REPORT_FORMAL : APPROVAL
return (
<div className={dense ? 'w-[360px] border-r' : 'mx-auto max-w-3xl p-4'}>
<div className="overflow-hidden rounded-lg border" style={{ borderColor: 'var(--warn)' }}>
<ApprovalCard approval={APPROVAL} onResolved={() => undefined} />
<ApprovalCard approval={approval} onResolved={() => undefined} />
</div>
</div>
)
+27
View File
@@ -87,6 +87,33 @@ export function runClassLabel(runClass: 'formal' | 'exploratory' | null | undefi
return '探索运行'
}
/**
* 审批卡上人工审批的运行类别和审的报告的核对概况。出具只发生在成果节点配了出具契约、从已发布版本发起的正式运行里:
* 探索运行批准后只是输出,卡上要说破,不能让审批人以为自己在签发正式的东西
*/
export const APPROVAL_TEXT = {
/** 人工审批批了会怎样,按运行类别说 */
outcome: (runClass: 'formal' | 'exploratory' | null | undefined, approve: boolean) => (runClass === 'formal'
? `从已发布版本发起,${approve ? '批准' : '提交'}后继续运行`
: `${approve ? '批准后输出' : '提交后继续运行'},这是探索运行,不是正式出具`),
reportHead: (label: string) => `报告「${label}」的核对`,
noNumbers: '报告中没有需要核对的数字',
/** 节点里当场裁判过(正式运行开了结论句裁判):判了几句、几句证据相矛盾、几句没判成 */
claimsJudged: (judged: number, contradicted: number, unjudged: number) =>
`结论句已裁判 ${formatNumber(judged)} 句,证据相矛盾 ${formatNumber(contradicted)} 句`
+ (unjudged ? `,未裁判 ${formatNumber(unjudged)} 句` : ''),
claimsNone: '报告中没有需要裁判的结论句',
claimsPending: '结论句尚未核对',
/** 探索运行停在审批上时还没封存,按需裁判要等运行结束 */
claimsLater: '探索运行不在节点内裁判,运行结束后可在证据中逐句请模型判断',
/** 正式运行、报告没开结论句裁判 */
claimsOff: '这份报告没有开启结论句裁判',
openEvidence: '查看逐段证据',
closeEvidence: '收起证据',
docLoading: '正在加载报告文档…',
docMissing: '无法获取报告文档',
}
/**
* 没挂在工作流上的运行:画布或问数据当场搭的,从没存成工作流。后端给它的名字老数据是
* 「临时图」、新的是「未保存的工作流」,也可能是空的;界面上一律叫「未保存的工作流」。
+128
View File
@@ -0,0 +1,128 @@
import { useEffect, useId, useState } from 'react'
import { ChevronRight, ShieldCheck } from 'lucide-react'
import { EVIDENCE_STATE, claimCountsOf, claimProblems, statsTally } from '../lib/evidence'
import { formatNumber } from '../lib/format'
import { APPROVAL_TEXT, EVIDENCE_TEXT, JUDGE_TEXT, evidenceTally, runClassLabel } from '../lib/terms'
import { useEvidence } from '../store/evidence'
import type { ApprovalReport } from '../types'
import { EvidenceDoc } from './EvidenceDoc'
type RunClass = 'formal' | 'exploratory' | null | undefined
/**
* 人工审批这次是探索运行还是正式运行,批了会怎样。
*
* 真实运行里审批说明写着「批准后才会正式出具」,那却是一次探索运行:出具只发生在成果节点配了出具契约、从已发布
* 版本发起的正式运行里。审批卡自己标明运行类别,不靠模型写的说明
*/
export function ApprovalRunClass({ runClass, approve }: { runClass: RunClass; approve: boolean }) {
const formal = runClass === 'formal'
return (
<div className="mb-2 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-2xs leading-relaxed"
data-approval-run-class={formal ? 'formal' : 'exploratory'}>
<span className="chip shrink-0" style={formal ? { color: 'var(--accent)' } : undefined}>{runClassLabel(runClass)}</span>
<span className="text-dim">{APPROVAL_TEXT.outcome(runClass, approve)}</span>
</div>
)
}
/** 结论句那一句:节点里判过的说判了几句、几句相矛盾;没判过的说尚未核对,并说什么时候能核对 */
function claimsLine(report: ApprovalReport, runClass: RunClass): { text: string; sub?: string; warn: boolean } {
const judge = report.judge
if (judge?.mode === 'inline' && judge.counts && typeof judge.counts === 'object') {
const c = claimCountsOf(judge.counts)
if (!c.total) return { text: APPROVAL_TEXT.claimsNone, warn: false }
const contradicted = c.contradicted + c.unsupported
return {
text: APPROVAL_TEXT.claimsJudged(c.total - c.unjudged, contradicted, c.unjudged),
warn: claimProblems(c) > 0 || c.unjudged > 0,
}
}
return {
text: APPROVAL_TEXT.claimsPending,
sub: runClass === 'formal' ? (report.claims === 'judge' ? undefined : APPROVAL_TEXT.claimsOff) : APPROVAL_TEXT.claimsLater,
warn: true,
}
}
/**
* 审批审的报告的核对概况:几个数字有出处(同证据条的说法),结论句判没判过、几句证据相矛盾,再给一个查看逐段证据
* 的入口——点开是和成果里同一份可逐段点开的报告文档,点数字、句末徽标打开证据面板。
*
* 真实运行里报告有一句结论和证据相矛盾,审批人批准时完全不知道:卡上只有正文
*/
export function ApprovalReports({ reports, runId, runClass }: {
reports: ApprovalReport[]
runId: string
runClass: RunClass
}) {
if (!reports.length) return null
return (
<>
{reports.map((r) => <ReportCheck key={r.node_id} report={r} runId={runId} runClass={runClass} />)}
</>
)
}
function ReportCheck({ report, runId, runClass }: { report: ApprovalReport; runId: string; runClass: RunClass }) {
const [open, setOpen] = useState(false)
const docId = useId()
const counts = statsTally(report.stats)
const suspect = typeof report.stats?.unknown_entities === 'number' ? report.stats.unknown_entities : 0
const numbers = counts ? evidenceTally(counts.cited, counts.total, counts.other) : ''
const claims = claimsLine(report, runClass)
const gaps = !!counts && (counts.none > 0 || counts.other > 0)
return (
<div className="mb-2 rounded border bg-bg p-2" data-approval-report={report.node_id}>
<div className="mb-1 flex items-center gap-1 text-2xs font-medium text-faint">
<ShieldCheck size={11} aria-hidden /> {APPROVAL_TEXT.reportHead(report.label || report.node_id)}
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-2xs">
<span className="tnum" data-approval-report-numbers=""
style={{ color: !counts ? 'var(--text-dim)' : gaps ? 'var(--st-waiting)' : 'var(--st-done)' }}>
{numbers || APPROVAL_TEXT.noNumbers}
</span>
{suspect > 0 && (
<span className="tnum" style={{ color: 'var(--st-waiting)' }}>
· {EVIDENCE_STATE.suspect.glyph} {EVIDENCE_TEXT.suspectTag} {formatNumber(suspect)}
</span>
)}
{/* 判定是模型给的:全都有依据也不用确定性的绿,有问题、没核对的用提醒色 */}
<span className="tnum" data-approval-report-claims="" title={report.judge?.mode === 'inline' ? JUDGE_TEXT.badge(null) : undefined}
style={{ color: claims.warn ? 'var(--st-waiting)' : 'var(--text-dim)' }}>
· {claims.text}
</span>
</div>
{claims.sub && <div className="mt-0.5 text-2xs leading-relaxed text-dim">{claims.sub}</div>}
{report.doc_artifact && (
<>
<button type="button" className="btn btn-xs mt-1.5" data-approval-evidence="" aria-expanded={open}
aria-controls={docId} onClick={() => setOpen((v) => !v)}>
<ChevronRight size={11} aria-hidden style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }} />
{open ? APPROVAL_TEXT.closeEvidence : APPROVAL_TEXT.openEvidence}
</button>
{open && (
<div id={docId} className="mt-1.5">
<ReportDoc artifact={report.doc_artifact} runId={runId} runClass={runClass} />
</div>
)}
</>
)}
</div>
)
}
/** 报告文档:和成果里同一份可逐段点开的文档,自带证据条(定位下一处、违规清单),点开的证据面板在卡内展开 */
function ReportDoc({ artifact, runId, runClass }: { artifact: string; runId: string; runClass: RunClass }) {
const slot = useEvidence((s) => s.docs[artifact])
const loadDoc = useEvidence((s) => s.loadDoc)
useEffect(() => { void loadDoc(artifact) }, [artifact, loadDoc])
if (slot?.status === 'error') return <div className="text-2xs text-faint">{APPROVAL_TEXT.docMissing}</div>
const doc = slot?.status === 'ok' ? slot.data : undefined
if (!doc) return <div className="text-2xs text-faint">{APPROVAL_TEXT.docLoading}</div>
return (
<div className="max-h-96 overflow-y-auto rounded border p-2" data-approval-evidence-doc="">
<EvidenceDoc doc={doc} artifact={artifact} runId={runId} dense tally runClass={runClass ?? undefined} />
</div>
)
}
+14 -2
View File
@@ -11,7 +11,8 @@ import { formatDateTime, formatSpan, formatTime, parseServerTime } from '../lib/
import { runClassLabel, runName, TOOL_TRUST_TEXT } from '../lib/terms'
import { useRunClock } from './useRunClock'
import { Markdown } from './Markdown'
import type { Approval } from '../types'
import { ApprovalReports, ApprovalRunClass } from './ApprovalReport'
import type { Approval, ApprovalReport } from '../types'
// 发起运行的表单搬去了 run/RunControl.tsx 的工具栏控件里。它原先常驻在
// 助手栏顶部,和 Copilot 输入框两个"主要动作"互相压着——而它们是两种不同的
@@ -99,6 +100,12 @@ export function ApprovalCard({ approval, onResolved, showWorkflow = true }: {
}
const where = approval.node_label || approval.node_id
// 人工审批节点挂起的审批(工具、代码的审批另有 kind):卡上标明运行类别、批了会怎样,审的是报告时附上核对概况
const humanNode = payload.kind === 'human_node'
const reports: ApprovalReport[] = Array.isArray(payload.reports)
? payload.reports.filter((r: unknown): r is ApprovalReport =>
!!r && typeof r === 'object' && typeof (r as ApprovalReport).node_id === 'string')
: []
return (
<div className="fade-up border-b p-3 last:border-b-0" data-approval={approval.id}
style={{ background: 'color-mix(in srgb, var(--warn) 7%, transparent)' }}>
@@ -109,7 +116,7 @@ export function ApprovalCard({ approval, onResolved, showWorkflow = true }: {
<span>人工审批</span>
{showWorkflow && approval.workflow_name && <><span aria-hidden>·</span><span>{runName(approval)}</span></>}
{where && <><span aria-hidden>·</span><span>节点「{where}」</span></>}
{approval.run_class && <><span aria-hidden>·</span><span>{runClassLabel(approval.run_class)}</span></>}
{approval.run_class && !humanNode && <><span aria-hidden>·</span><span>{runClassLabel(approval.run_class)}</span></>}
{created && (
<>
<span aria-hidden>·</span>
@@ -126,6 +133,9 @@ export function ApprovalCard({ approval, onResolved, showWorkflow = true }: {
)}
</div>
{/* 老后端的审批没有运行类别:不猜,不画这一行 */}
{humanNode && approval.run_class && <ApprovalRunClass runClass={approval.run_class} approve={approveMode} />}
{payload.message && (
// 待审的往往是一整段模型写的文案,同样是 Markdown。要人家判断"能不能
// 发",却让他对着一堆星号看,是在给审批这件事添难度
@@ -134,6 +144,8 @@ export function ApprovalCard({ approval, onResolved, showWorkflow = true }: {
</div>
)}
<ApprovalReports reports={reports} runId={approval.run_id} runClass={approval.run_class} />
{payload.tool && (
<div className="mb-2 rounded border bg-bg p-2">
<div className="mb-1 flex items-center gap-1.5 text-2xs">
+15
View File
@@ -173,6 +173,21 @@ export interface Approval {
resolved_at?: string | null
}
/**
* 人工审批审的报告的核对概况(human 节点请求里的 payload.reports,服务端 nodes/human._reviewed_reports):
* 上游直接连着的、或者标题和说明里引用了正文的报告撰写节点,各一项。stats 同 report.checked,judge 是裁判摘要
* 的几项(mode:inline 节点里当场判的 / on_demand 探索运行按需判)。老后端、审的不是报告时没有
*/
export interface ApprovalReport {
node_id: string
label?: string
stats?: EvidenceStats
/** 结论句策略:off / require_citation / judge */
claims?: string
doc_artifact?: string
judge?: { mode?: string; counts?: Record<string, unknown>; complete?: boolean; unjudged?: unknown; limits_hit?: unknown }
}
/**
* 后端记着的最近一次测连接(模型接入、数据源、MCP 的列表接口都平铺这四项)。
* 没测过、或者连接配置改过之后全是 null。用 lib/health 的 healthFromServer 转成
+56 -1
View File
@@ -923,10 +923,65 @@ await section('审批卡的上下文', async () => {
check('没署名时提醒并给去处', body.includes('未署名') && await page.locator('a[href="/settings/prefs"]').count() === 1)
await page.close()
const s = await open('approval=1&actor=张工', { w: 820, h: 500, name: 'approval-signed' })
check('署了名就写明以谁的名义', (await s.page.locator('body').innerText()).includes('将以「张工」签批'))
const signed = await s.page.locator('body').innerText()
check('署了名就写明以谁的名义', signed.includes('将以「张工」签批'))
// 人工审批自己标明运行类别:正式运行从已发布版本发起
check('人工审批:正式运行标明类别和批了会怎样', await s.page.locator('[data-approval-run-class="formal"]').count() === 1
&& signed.includes('从已发布版本发起,批准后继续运行'), signed.replace(/\n/g, ' ').slice(0, 200))
await s.page.close()
})
await section('审批卡:审的报告的核对概况', async () => {
// 真实运行里审批说明写着「批准后才会正式出具」,那却是一次探索运行;报告里一句结论和证据相矛盾,审批人批准时完全
// 不知道。审批卡自己标明运行类别,附上报告的核对概况和查看逐段证据的入口。文档、片段、证据图都用夹具伪造,非 GET 拦掉
const { readFileSync } = await import('node:fs')
const fx = JSON.parse(readFileSync(new URL('../frontend/src/run/__tests__/evidence-doc.json', import.meta.url), 'utf8'))
const page = await browser.newPage({ viewport: { width: 420, height: 900 } })
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
await page.route((u) => new URL(u).pathname.startsWith('/api/'), (r) => {
const url = new URL(r.request().url())
if (r.request().method() !== 'GET') return r.abort()
const seg = url.pathname.match(/\/evidence\/segments\/([^/]+)$/)
if (seg) return r.fulfill({ json: fx.segments[seg[1]] ?? {} })
if (url.pathname === `/api/runs/${fx.run_id}/evidence`) return r.fulfill({ json: fx.graph })
if (url.pathname === `/api/artifacts/${fx.doc_artifact}`) return r.fulfill({ json: { id: fx.doc_artifact, content: fx.doc } })
return r.continue()
})
await page.goto(`${WEB}/preview.html?approval=report&dense=1&actor=张工`, { waitUntil: 'networkidle' })
await page.waitForSelector('[data-approval]', { timeout: 6000 })
const card = page.locator('[data-approval]')
const text = await card.innerText()
check('探索运行:标明类别,写明批准后输出、不是正式出具', await card.locator('[data-approval-run-class="exploratory"]').count() === 1
&& text.includes('批准后输出,这是探索运行,不是正式出具'), text.replace(/\n/g, ' ').slice(0, 160))
const report = card.locator('[data-approval-report="write"]')
check('报告的核对概况:标题写哪份报告', (await report.innerText()).includes('报告「写周报」的核对'))
check('数字和证据条同一种说法', (await report.locator('[data-approval-report-numbers]').innerText())
.includes('7/12 数字有出处 · 无证据 5 · 另有 1 处引用无法解析'), await report.locator('[data-approval-report-numbers]').innerText())
check('探索运行:结论句尚未核对,并说运行结束后才能逐句判断', (await report.locator('[data-approval-report-claims]').innerText())
.includes('结论句尚未核对') && (await report.innerText()).includes('运行结束后可在证据中逐句请模型判断'))
await report.locator('[data-approval-evidence]').click()
await page.waitForSelector('[data-approval-evidence-doc] [data-evidence-doc]', { timeout: 6000 })
check('查看逐段证据:卡里展开可点的报告文档,自带证据条', await report.locator('[data-evidence-doc] [data-seg]').count() > 0
&& await report.locator('[data-evidence-tally]').count() === 1)
await report.locator('[data-seg="s6"]').click()
await page.waitForSelector('[data-approval] [data-evidence-panel]', { timeout: 6000 })
check('点数字打开证据面板,在卡内展开', await page.locator('[data-approval] [data-evidence-panel]').getAttribute('data-evidence-panel') === 'inline')
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
check('展开后不撑破窄栏', overflow <= 0, `${overflow}px`)
await report.locator('[data-approval-evidence]').click()
check('再点一下收起', await report.locator('[data-approval-evidence-doc]').count() === 0)
check('没有运行时报错', errors.length === 0, errors.join(' | '))
await page.close()
const f = await open('approval=report-formal&dense=1&actor=张工', { w: 420, h: 700, name: 'approval-report-formal' })
const formal = await f.page.locator('[data-approval-report="write"]').innerText()
check('正式运行:节点里判过的写判了几句、几句证据相矛盾', formal.includes('结论句已裁判 6 句,证据相矛盾 1 句'), formal.replace(/\n/g, ' '))
check('正式运行:数字都有出处', formal.includes('12 个数字都有出处'))
check('正式运行:卡上标明正式运行', await f.page.locator('[data-approval-run-class="formal"]').count() === 1)
await f.page.close()
})
await section('画布右栏:运行视图', async () => {
// 真页面、真 store:往 window.__studio 灌事件(同 check-canvas-fx)。工作流、会话、审批、
// 运行全用 page.route 伪造,写操作一律回 409——检查脚本不写库