diff --git a/backend/app/engine/nodes/human.py b/backend/app/engine/nodes/human.py index 83e83d95..29bd3fc1 100644 --- a/backend/app/engine/nodes/human.py +++ b/backend/app/engine/nodes/human.py @@ -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..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. }} 和 {{ nodes..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 + + # -------------------------------------------------------------------------- # 结构化校验 # -------------------------------------------------------------------------- diff --git a/backend/tests/test_approval_report_check.py b/backend/tests/test_approval_report_check.py new file mode 100644 index 00000000..cd13c261 --- /dev/null +++ b/backend/tests/test_approval_report_check.py @@ -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 diff --git a/frontend/src/dev/preview.tsx b/frontend/src/dev/preview.tsx index 15d215d9..89dc111c 100644 --- a/frontend/src/dev/preview.tsx +++ b/frontend/src/dev/preview.tsx @@ -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
if (params.get('grow') === '1') return if (params.get('expand')) return - 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 (
- undefined} /> + undefined} />
) diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 6b017e2f..bbcfea34 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -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: '无法获取报告文档', +} + /** * 没挂在工作流上的运行:画布或问数据当场搭的,从没存成工作流。后端给它的名字老数据是 * 「临时图」、新的是「未保存的工作流」,也可能是空的;界面上一律叫「未保存的工作流」。 diff --git a/frontend/src/run/ApprovalReport.tsx b/frontend/src/run/ApprovalReport.tsx new file mode 100644 index 00000000..40fb718f --- /dev/null +++ b/frontend/src/run/ApprovalReport.tsx @@ -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 ( +
+ {runClassLabel(runClass)} + {APPROVAL_TEXT.outcome(runClass, approve)} +
+ ) +} + +/** 结论句那一句:节点里判过的说判了几句、几句相矛盾;没判过的说尚未核对,并说什么时候能核对 */ +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) => )} + + ) +} + +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 ( +
+
+ {APPROVAL_TEXT.reportHead(report.label || report.node_id)} +
+
+ + {numbers || APPROVAL_TEXT.noNumbers} + + {suspect > 0 && ( + + · {EVIDENCE_STATE.suspect.glyph} {EVIDENCE_TEXT.suspectTag} {formatNumber(suspect)} + + )} + {/* 判定是模型给的:全都有依据也不用确定性的绿,有问题、没核对的用提醒色 */} + + · {claims.text} + +
+ {claims.sub &&
{claims.sub}
} + {report.doc_artifact && ( + <> + + {open && ( +
+ +
+ )} + + )} +
+ ) +} + +/** 报告文档:和成果里同一份可逐段点开的文档,自带证据条(定位下一处、违规清单),点开的证据面板在卡内展开 */ +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
{APPROVAL_TEXT.docMissing}
+ const doc = slot?.status === 'ok' ? slot.data : undefined + if (!doc) return
{APPROVAL_TEXT.docLoading}
+ return ( +
+ +
+ ) +} diff --git a/frontend/src/run/RunPanel.tsx b/frontend/src/run/RunPanel.tsx index 325d4886..fe83abac 100644 --- a/frontend/src/run/RunPanel.tsx +++ b/frontend/src/run/RunPanel.tsx @@ -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 (
@@ -109,7 +116,7 @@ export function ApprovalCard({ approval, onResolved, showWorkflow = true }: { 人工审批 {showWorkflow && approval.workflow_name && <>·{runName(approval)}} {where && <>·节点「{where}」} - {approval.run_class && <>·{runClassLabel(approval.run_class)}} + {approval.run_class && !humanNode && <>·{runClassLabel(approval.run_class)}} {created && ( <> · @@ -126,6 +133,9 @@ export function ApprovalCard({ approval, onResolved, showWorkflow = true }: { )}
+ {/* 老后端的审批没有运行类别:不猜,不画这一行 */} + {humanNode && approval.run_class && } + {payload.message && ( // 待审的往往是一整段模型写的文案,同样是 Markdown。要人家判断"能不能 // 发",却让他对着一堆星号看,是在给审批这件事添难度 @@ -134,6 +144,8 @@ export function ApprovalCard({ approval, onResolved, showWorkflow = true }: { )} + + {payload.tool && (
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index dc8a1c4c..3d176275 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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; complete?: boolean; unjudged?: unknown; limits_hit?: unknown } +} + /** * 后端记着的最近一次测连接(模型接入、数据源、MCP 的列表接口都平铺这四项)。 * 没测过、或者连接配置改过之后全是 null。用 lib/health 的 healthFromServer 转成 diff --git a/scripts/check-stream.mjs b/scripts/check-stream.mjs index db54b27e..c95b0319 100644 --- a/scripts/check-stream.mjs +++ b/scripts/check-stream.mjs @@ -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——检查脚本不写库