合入期 4 审批质询(前端):报告句末与划选原文浮出「质询」,问答串与提问框挂在审批卡和运行详情,回答按片段与出处渲染;同一张卡挂两处、换了审批、报告改写前的问答各归其位;时间线解读 approval.inquiry,等待中来质询阶段仍是等待

This commit is contained in:
ThearchyHelios
2026-10-06 11:32:11 +08:00
16 changed files with 7961 additions and 48 deletions
+24 -2
View File
@@ -1,7 +1,29 @@
/**
* 审批质询的接口(/api/runs/{run_id}/…)。现在是空的,审批质询的工作包往里加,挂在 api.inquiry 上。
* 审批质询的接口(/api/runs/{run_id}/inquiries…),挂在 api.inquiry 上。
*
* 请求函数(get / post / del……)和 ApiError 从 ./http 导入,不要从 ./client 导入:client.ts 要导入
* 这个文件把它挂到 api 上,反过来导入就成了循环(见 http.ts 开头的说明)。
*/
export const inquiry = {}
import type { InquiryList, InquiryRequest, InquiryResponse } from '../types'
import { get, qs, request, type RequestOptions } from './http'
const run = (id: string) => `/runs/${encodeURIComponent(id)}`
export const inquiry = {
/**
* 就报告提问。核查模型只读查库作答,通常要几十秒,不设超时:服务端自己有时长上限,到点按查到的结果收尾。
* 拒绝时 409 带 code(inquiry_not_open:运行没停在这张审批上、正式运行已封存、运行还在执行;inquiry_busy:这次运行
* 另有一条质询正在回答;inquiry_disabled:设置里关了审批质询),输入不合法 422 带 field,detail 都是一句中文
*/
ask: (runId: string, body: InquiryRequest) =>
request<InquiryResponse>(`${run(runId)}/inquiries`, { method: 'POST', body: JSON.stringify(body) }),
/** 这次运行的问答(封存前后的都算,按时间排)。report、unit 可选,只要某份报告、某一句的 */
list: (runId: string, filter?: { report?: string; unit?: string }, opts?: RequestOptions) =>
get<InquiryList>(`${run(runId)}/inquiries${qs(filter)}`, opts),
/**
* 质询引用过的查询快照(遮罩列已遮掉)。不是这次运行的质询引用过的工件回 404。约定没定死外面包不包一层
* {id, content},原样交回,由界面认(Inquiry.tsx 的 snapshotOf)
*/
snapshot: (runId: string, artifact: string, opts?: RequestOptions) =>
get<unknown>(`${run(runId)}/inquiries/artifacts/${encodeURIComponent(artifact)}`, opts),
}
File diff suppressed because it is too large Load Diff
+143
View File
@@ -0,0 +1,143 @@
import { useState, type JSX } from 'react'
import { createPortal } from 'react-dom'
import { EvidencePane } from '../../pages/runs/EvidencePane'
import { ApprovalCard } from '../../run/RunPanel'
import type { Approval, ApprovalReport, Run } from '../../types'
import fx from './inquiry.json'
/**
* 审批质询的预览用例,scripts/check-inquiry.mjs 按它们检查。夹具在 inquiry.json(检查脚本读同一份):报告文档和回答文档
* 是后端 build_catalog + compose_doc 真实组装的,超市、片区、金额都是虚构的。
*
* 审批卡(运行停在人工审批上,审的是报告「写月报」):
* - ?syn=inquiry-approval:探索运行。核对概况下面是问答串(进入时按列表接口恢复)和「就整份报告提问」;展开逐段证据后,
* 每句结论后面有「质询」图标(悬停 / 焦点在这一句上时显出来),划选原文时浮出「质询」
* - ?syn=inquiry-approval-formal:正式运行的审批,同样能问(审批阶段还没封存)
* - ?syn=inquiry-approval-dense:同第一个,摆在 360px 的右栏里
* - ?syn=inquiry-approval-done:审批已经处理过(不是待处理的那张):只列问答,不给提问
* - ?syn=inquiry-approval-nodoc:审的报告没有逐段证据(没有报告文档):只能就整份报告提问
* - ?syn=inquiry-approval-twice:同一张待审批卡同时挂在两处,同编排页里画布的审批浮层(挂到页面末尾、role=dialog,
* React 树里排在前面)和右栏;「收起浮层」按钮开关浮层
* - ?syn=inquiry-approval-steps:同一份报告先后两张审批(主管审批 → 总监审批),「处理掉这张」换下一张;卡按审批 id
* 重新挂(同右栏)。?syn=inquiry-approval-steps-same:同一个卡片实例换审批(同画布的审批浮层,不按 id 重新挂)
*
* 运行详情的「证据」页签(pages/runs/EvidencePane,是否可问由它按运行状态判断):
* - ?syn=inquiry-run-explore:封存了的探索运行,报告上给入口,问答追加在封存之后
* - ?syn=inquiry-run-formal:封存了的正式运行,不给入口,审批时问过的照列
*
* 列表、提问、质询工件、报告文档、证据图、审批列表、设置都由检查脚本用 page.route 伪造;直接打开时问答串照实说无法读取。
*/
const RUNS = fx.runs
const reports = fx.reports as unknown as Record<keyof typeof RUNS, ApprovalReport>
function approvalOf(id: string, runId: string, runClass: 'formal' | 'exploratory', report: ApprovalReport,
status = 'pending', label = '主管审批'): Approval {
return {
id, run_id: runId, node_id: 'review', mode: 'approve', title: '9 月月报可以发出吗?', status, response: {},
payload: { kind: 'human_node', message: '请审核 9 月月报。', reports: [report] },
created_at: new Date(Date.now() - 300_000).toISOString().replace('Z', ''),
workflow_name: '月报', node_label: label, run_class: runClass,
}
}
function Card({ approval, name, dense = false }: { approval: Approval; name: string; dense?: boolean }) {
return (
<div className={dense ? 'w-[360px] max-w-full border-r bg-panel' : 'mx-auto max-w-3xl p-4'} data-fixture={name}>
<div className="overflow-hidden rounded-lg border" style={{ borderColor: 'var(--warn)' }}>
<ApprovalCard approval={approval} onResolved={() => undefined} />
</div>
</div>
)
}
/** 同一张待审批卡挂两处:浮层挂到页面末尾(同 canvas/NodeCard 的审批浮层),右栏在页面里 */
function TwoPlaces({ approval }: { approval: Approval }) {
const [pop, setPop] = useState(true)
return (
<div className="flex min-h-screen" data-fixture="inquiry-approval-twice">
{pop && createPortal(
<div role="dialog" aria-label="审批卡" data-fixture-pop=""
className="fixed left-4 top-4 z-50 max-h-[90vh] w-[380px] overflow-y-auto rounded-lg border bg-panel shadow-elev-3">
<ApprovalCard approval={approval} showWorkflow={false} />
</div>,
document.body,
)}
<div className="flex-1 p-4">
<button type="button" className="btn btn-xs ml-[400px]" data-fixture-toggle="" onClick={() => setPop((v) => !v)}>
{pop ? '收起浮层' : '打开浮层'}
</button>
</div>
<aside className="w-[380px] shrink-0 border-l bg-panel" data-fixture-panel="">
<ApprovalCard approval={approval} showWorkflow={false} />
</aside>
</div>
)
}
/** 同一份报告先后两张审批:「处理掉这张」之后换下一张(keyed:按审批 id 重新挂,同右栏;否则同一个卡片实例换审批) */
function Steps({ name, keyed }: { name: string; keyed: boolean }) {
const list = [
approvalOf(fx.approval_id, RUNS.approve, 'exploratory', reports.approve),
approvalOf('ap-inq-3', RUNS.approve, 'exploratory', reports.approve, 'pending', '总监审批'),
]
const [at, setAt] = useState(0)
const approval = list[Math.min(at, list.length - 1)]
return (
<div className="mx-auto max-w-3xl p-4" data-fixture={name}>
<button type="button" className="btn btn-xs mb-2" data-fixture-next="" onClick={() => setAt((n) => n + 1)}>处理掉这张</button>
<div className="overflow-hidden rounded-lg border" style={{ borderColor: 'var(--warn)' }}>
{keyed
? <ApprovalCard key={approval.id} approval={approval} onResolved={() => undefined} />
: <ApprovalCard approval={approval} onResolved={() => undefined} />}
</div>
</div>
)
}
/** 运行详情:一次已经结束、封存了的运行 */
function runOf(id: string, runClass: 'formal' | 'exploratory'): Run {
return {
id, workflow_id: null, workflow_name: '月报', status: 'succeeded', input: { month: '2026-09' }, output: {}, error: null,
usage: { input_tokens: 0, output_tokens: 0, cost_usd: 0 } as Run['usage'], run_class: runClass, version: runClass === 'formal' ? 3 : null,
manifest_hash: 'f'.repeat(64), manifest_seq: 30, created_at: '2026-10-05T06:00:00', started_at: '2026-10-05T06:00:00',
finished_at: '2026-10-05T06:40:00',
}
}
const LABELS: Record<string, string> = { write: '写月报', review: '主管审批' }
function RunEvidence({ run, name }: { run: Run; name: string }) {
return (
<div className="h-screen min-w-0" data-fixture={name}>
<EvidencePane run={run} output={null} labelOf={(id) => (id ? LABELS[id] : undefined)} refreshKey="0" />
</div>
)
}
export const cases: Record<string, () => JSX.Element> = {
'inquiry-approval': () => (
<Card name="inquiry-approval" approval={approvalOf(fx.approval_id, RUNS.approve, 'exploratory', reports.approve)} />
),
'inquiry-approval-formal': () => (
<Card name="inquiry-approval-formal" approval={approvalOf(fx.formal_approval_id, RUNS.formal, 'formal', reports.formal)} />
),
'inquiry-approval-dense': () => (
<Card name="inquiry-approval-dense" dense approval={approvalOf(fx.approval_id, RUNS.approve, 'exploratory', reports.approve)} />
),
'inquiry-approval-done': () => (
<Card name="inquiry-approval-done"
approval={approvalOf(fx.approval_id, RUNS.approve, 'exploratory', reports.approve, 'approved')} />
),
'inquiry-approval-nodoc': () => (
<Card name="inquiry-approval-nodoc" approval={approvalOf(fx.approval_id, RUNS.approve, 'exploratory',
{ ...reports.approve, doc_artifact: undefined } as ApprovalReport)} />
),
'inquiry-approval-twice': () => (
<TwoPlaces approval={approvalOf(fx.approval_id, RUNS.approve, 'exploratory', reports.approve)} />
),
'inquiry-approval-steps': () => <Steps name="inquiry-approval-steps" keyed />,
'inquiry-approval-steps-same': () => <Steps name="inquiry-approval-steps-same" keyed={false} />,
'inquiry-run-explore': () => <RunEvidence name="inquiry-run-explore" run={runOf(RUNS.explore, 'exploratory')} />,
'inquiry-run-formal': () => <RunEvidence name="inquiry-run-formal" run={runOf(RUNS.sealed, 'formal')} />,
}
+121 -1
View File
@@ -3368,9 +3368,129 @@ export const BUNDLE_TEXT = {
fileName: (name: string) => `${name}-可验证报告.html`,
}
/** 审批质询:审批人在批准前就待审的报告提出质询 */
/**
* 审批质询:审批人在批准前就待审的报告提问(选一句、划一段原文,或就整份报告),核查模型只读查库作答。问答写进运行
* 记录、随运行一并封存,不改动报告;封存之后只有探索运行还能问,追加在封存之后
*/
export const INQUIRY_TEXT = {
title: '审批质询',
// ---- 时间线(decode.ts 解读 approval.inquiry):标题带问题的前 40 字,副标题说回答是否完成、查了几次 ----
timeline: (question: string) => (question ? `审批人质询:${question}` : '审批人质询'),
complete: '回答完成',
/** 按已查到的结果收了尾:回答可能缺了没来得及查的部分 */
incomplete: (limit: string) => `回答不完整(${limit})`,
failed: (limit: string | null) => (limit ? `未能回答(${limit})` : '未能回答'),
/** 服务端没说成没成(缺字段)、却有回答正文:只说答了,不说完成 */
answered: '已回答',
queries: (n: number) => (n > 0 ? `查询 ${formatNumber(n)} 次` : '未执行查询'),
postSeal: '封存后追加',
limit: {
max_queries: '已达查询次数上限',
timeout_s: '已达时长上限',
max_cost_usd: '已达单次金额上限',
daily_max_usd: '已达核查每日金额上限',
} as Record<string, string>,
limitOther: '已达上限',
detail: {
question: (q: string) => `问题:${q}`,
quote: (q: string) => `原文:「${q}」`,
answer: (t: string) => `回答:${t}`,
error: (e: string) => `原因:${e}`,
model: (m: string) => `核查模型:${m}`,
},
// ---- 报告上的入口(EvidenceDoc):句末的「质询」、划选原文时浮出的「质询」 ----
ask: '质询',
askUnit: (text: string) => `质询这句:${text}`,
/** 句末「质询」图标的悬停说明 */
askTitle: '质询这一句',
askSelection: '就选中的原文质询',
/** 读屏摘要里接在按键说明后面 */
keysHint: '每句结论后有「质询」按钮:方向键走到这一句,再按 Tab 到达;没有可点内容的句子,方向键直接走到它的「质询」',
// ---- 问答串(ReportInquiry) ----
headCount: (n: number) => `${formatNumber(n)} 次`,
askReport: '就整份报告提问',
intro: '展开逐段证据后,点句末的「质询」图标或划选一段原文,即可就该处提问',
introRun: '点句末的「质询」图标或划选一段原文,即可就该处提问',
/** 报告没有逐段证据(没有报告文档):句子、原文上都没有入口 */
introNoDoc: '这份报告没有逐段证据,可以就整份报告提问',
/** 设置里关了审批质询:不给入口,已有的问答照列 */
disabled: '审批质询已在设置中关闭',
wholeReport: '整份报告',
/** 报告文档取不到,写不出问的是哪一句 */
unitMissing: '报告中的一句',
/** 之前几轮审批里问的、报告后来改写过(句子编号对不上),或者对不上是哪一稿:单独一组 */
otherDoc: '报告其他版本上的质询',
otherDocHint: '提问时的报告与当前显示的版本不同,或无法确认是否相同,所以不挂到当前报告的句子下面',
loadFailed: '无法读取之前的质询记录',
retry: '重试',
q: '问',
a: '答',
unsigned: '未署名',
entryLabel: (question: string) => `质询:${question}`,
quoteLine: (quote: string) => `原文「${quote}」`,
notRecorded: '这次问答没有写入运行记录',
notRecordedHint: '它只显示在当前页面,刷新后不再显示,也不会随运行封存',
/** 屏幕阅读器:回答回来了(带上问题的开头,连着几次回答各报一遍) */
arrived: (question: string) => {
const chars = [...question.trim()]
return `已收到回答:${chars.length > 20 ? `${chars.slice(0, 20).join('')}…` : chars.join('')}`
},
/** 同一张审批卡同时挂在两处(画布的审批浮层和右栏):提问框只在打开它的那一处 */
elsewhere: '提问框已在另一处打开',
moveHere: '在这里继续',
/** 回答期间另开了提问框,正在回答的那一条却没问成:原来的问题和原因留在它那一句下面 */
askFailed: (message: string) => `没有问成:${message}`,
reask: '放回提问框',
dismiss: '忽略',
// ---- 提问框 ----
target: { unit: '就这句提问', quote: '就选中的原文提问', report: '就整份报告提问' } as Record<'unit' | 'quote' | 'report', string>,
questionLabel: '问题',
placeholder: '例如:这个数是否扣除了退款?',
submit: '提问',
cancel: '取消',
chars: (n: number, max: number) => `${formatNumber(n)}/${formatNumber(max)}`,
keys: 'Ctrl + 回车提交 · Esc 关闭',
empty: '请填写问题',
tooLong: (max: number) => `问题最多 ${formatNumber(max)} 字,请删减后再提交`,
quoteCut: (max: number) => `选中的原文超过 ${formatNumber(max)} 字,只提交前 ${formatNumber(max)} 字`,
hint: '由核查模型只读查询数据作答,问答写入运行记录,不改动报告',
hintPostSeal: '运行已封存:问答追加在封存之后,不改动报告',
busyOther: '这次运行另有一条质询正在回答,回答完再提问',
submitFailed: '提问失败',
// ---- 回答中:审批卡的批准 / 驳回暂停(服务端这时也会拒绝) ----
pending: '正在查询与回答,通常需要几十秒',
waited: (seconds: number) => `已等待 ${formatNumber(seconds)} 秒`,
decideBlocked: '正在回答质询,回答完再批准或驳回',
// ---- 回答 ----
noAnswer: '没有给出回答',
answerIncomplete: (limit: string) => `${limit}:回答依据已查到的结果,可能不完整`,
answeredBy: (model: string | null) => `回答由核查模型${model ? `「${model}」` : ''}给出,并非系统核对`,
cost: (usd: string) => `花费 ${usd}`,
/** 回答里有出处的数字:读屏听到「数字,出处:查询 Q1 · 第 1 行 · amount」,点一下展开那次查询的结果 */
cited: (text: string, source: string) => `${text},出处:${source},查看这次查询的结果`,
citedReport: (text: string, source: string) => `${text},出处:${source},在报告中查看证据`,
/** 问的是报告的另一稿:引用的是那一稿里的出处,不在当前这一稿里找 */
citedOther: (text: string, source: string) => `${text},出处:${source}(提问时那一版报告中的出处)`,
/** 引用的是报告已有的出处,报告里却找不到引用它的地方:照回答文档记下的出处说一句 */
reportSource: (source: string) => `出处:${source}(报告已有的出处,报告中没有引用它的地方)`,
reportSourceOther: (source: string) => `出处:${source}(提问时那一版报告中的出处)`,
queriesHead: (n: number) => `核查模型执行的查询(${formatNumber(n)} 次)`,
/** 查询列表收着时也写:截断直接关系到回答靠不靠得住 */
queriesTruncated: (n: number) => `其中 ${formatNumber(n)} 次结果已截断`,
noQueries: '核查模型没有执行查询',
rows: (n: number) => `${formatNumber(n)} 行`,
truncated: '结果已截断,数据库中还有更多数据',
showResult: '查看结果',
hideResult: '收起结果',
snapshotLoading: '正在读取查询结果…',
snapshotFailed: '无法读取这次查询的结果',
masked: (columns: string) => `${columns} 在数据源中设置了遮罩,不显示原值`,
}
/**
+15 -4
View File
@@ -17,6 +17,7 @@ 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'
import { Markdown, inlineStyles } from '../../run/Markdown'
import { useEvidence } from '../../store/evidence'
import { useCatalog } from '../../store/catalog'
@@ -125,6 +126,10 @@ export function EvidencePane({ run, output, labelOf, refreshKey }: {
const mode = graph?.mode ?? audit.data?.mode
const seal = audit.data?.seal ?? graph?.seal
// 审批质询:封存了的探索运行可以接着就报告提问(追加在封存之后);正式运行封存后不再开放。问答串每次运行都挂(只读):
// 审批时问过什么、怎么答的、查了什么随运行封存,审批处理掉以后也得在报告旁边看得到
const asking = inquiryOpen(run)
const { access: inquiry, disabled: inquiryOff } = useInquiryAccess(asking, null)
if (!graph && graphSlot?.status !== 'error') {
return (
@@ -160,10 +165,16 @@ export function EvidencePane({ run, output, labelOf, refreshKey }: {
</span>
</header>
{doc ? (
<EvidenceDoc ref={(h) => { handles.current.set(r.node_id!, h) }} doc={doc} artifact={r.doc_artifact}
runId={run.id} runClass={run.run_class} label={r.fields?.join('、') || r.node_id} tally
tier={typeof output?._issuance?.tier === 'string' ? output._issuance.tier : undefined}
panel={wide ? 'dock' : 'auto'} dock={dock} onView={(o) => onView(r.node_id!, o)} />
<>
<EvidenceDoc ref={(h) => { handles.current.set(r.node_id!, h) }} doc={doc} artifact={r.doc_artifact}
runId={run.id} runClass={run.run_class} label={r.fields?.join('、') || r.node_id} tally
tier={typeof output?._issuance?.tier === 'string' ? output._issuance.tier : undefined}
panel={wide ? 'dock' : 'auto'} dock={dock} onView={(o) => onView(r.node_id!, o)}
inquiry={inquiry} />
<ReportInquiry runId={run.id} report={r.node_id!} docArtifact={r.doc_artifact} access={inquiry}
disabled={inquiryOff} postSeal={asking}
onLocate={(seg) => locateIn(handles.current.get(r.node_id!), doc, seg)} />
</>
) : slot?.status === 'error' ? (
<p className="text-2xs" style={{ color: 'var(--st-failed)' }}>{T.docGone(labelOf(r.node_id) ?? r.node_id ?? '')}</p>
) : <Skeleton rows={4} height={14} />}
+11
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ApiError, api, streamRun } from '../../api/client'
import { useCatalog, useOnReconnect } from '../../store/catalog'
import { useInquiryRecorded } from '../../run/Inquiry'
import { emptyTrace, finalizeTrace, foldEvent, type Trace } from '../../run/trace'
import type { Approval, GraphSpec, Run, RunEvent } from '../../types'
import { isLiveRun } from './model'
@@ -202,6 +203,16 @@ export function useRunDetail(runId: string, onChange: (run: Run) => void): RunDe
}
}, [streaming, runId, flush, resync, refreshPending, refreshApprovals])
// 这一页里问的审批质询写进了运行记录(approval.inquiry):停在审批上、已经结束的运行这里不接流,补拉一次事件,
// 时间线才出现「审批人质询」那一行。在接流的照常从流里收到
const inquired = useInquiryRecorded(runId)
const inquiredSeen = useRef(inquired)
useEffect(() => {
if (inquiredSeen.current === inquired) return
inquiredSeen.current = inquired
if (!streaming) void reload()
}, [inquired, streaming, reload])
const follow = useCallback((next?: Run | null) => {
if (next) adopt(next)
setStreaming(true)
+50 -9
View File
@@ -1,11 +1,12 @@
import { useEffect, useId, useState } from 'react'
import { useCallback, useEffect, useId, useRef, useState, type Ref } 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'
import type { ApprovalReport, EvidenceSegment } from '../types'
import { EvidenceDoc, type EvidenceDocHandle } from './EvidenceDoc'
import { ReportInquiry, reportSegmentFor, useInquiryAccess, type InquiryAccess } from './Inquiry'
type RunClass = 'formal' | 'exploratory' | null | undefined
@@ -53,21 +54,52 @@ function claimsLine(report: ApprovalReport, runClass: RunClass): { text: string;
*
* 真实运行里报告有一句结论和证据相矛盾,审批人批准时完全不知道:卡上只有正文
*/
export function ApprovalReports({ reports, runId, runClass }: {
export function ApprovalReports({ reports, runId, runClass, approvalId, askable = false }: {
reports: ApprovalReport[]
runId: string
runClass: RunClass
/** 这张审批卡的审批(处理过的也给):问答串据此认哪几条问的就是卡上这一稿报告 */
approvalId?: string
/**
* 审批待处理:审批人可以就报告提出质询——报告上的句子、划选的原文挂「质询」,核对概况下面是问答串和提问框
* (run/Inquiry.tsx)。设置里关了审批质询时照样不给
*/
askable?: boolean
}) {
const { access, disabled } = useInquiryAccess(askable && !!approvalId, approvalId ?? null)
if (!reports.length) return null
return (
<>
{reports.map((r) => <ReportCheck key={r.node_id} report={r} runId={runId} runClass={runClass} />)}
{reports.map((r) => (
<ReportCheck key={r.node_id} report={r} runId={runId} runClass={runClass} access={access} disabled={disabled}
approvalId={approvalId ?? null} />
))}
</>
)
}
function ReportCheck({ report, runId, runClass }: { report: ApprovalReport; runId: string; runClass: RunClass }) {
function ReportCheck({ report, runId, runClass, access, disabled, approvalId }: {
report: ApprovalReport; runId: string; runClass: RunClass; access: InquiryAccess | null; disabled: boolean
approvalId: string | null
}) {
const [open, setOpen] = useState(false)
// 回答里引用了报告已有的出处:在逐段证据里打开那一处的证据面板。逐段证据收着的先展开,挂上之后再打开
const docRef = useRef<EvidenceDocHandle>(null)
const opening = useRef<string | null>(null)
const docSlot = useEvidence((s) => (report.doc_artifact ? s.docs[report.doc_artifact] : undefined))
const locate = useCallback((seg: EvidenceSegment) => {
const id = reportSegmentFor(docSlot?.status === 'ok' ? docSlot.data : null, seg)
if (!id) return false
if (open && docRef.current) return docRef.current.open(id, { focus: true })
opening.current = id
setOpen(true)
return true
}, [docSlot, open])
useEffect(() => {
if (!open || !opening.current || !docRef.current) return
docRef.current.open(opening.current, { focus: true })
opening.current = null
})
const docId = useId()
const counts = statsTally(report.stats)
const suspect = typeof report.stats?.unknown_entities === 'number' ? report.stats.unknown_entities : 0
@@ -105,17 +137,25 @@ function ReportCheck({ report, runId, runClass }: { report: ApprovalReport; runI
</button>
{open && (
<div id={docId} className="mt-1.5">
<ReportDoc artifact={report.doc_artifact} runId={runId} runClass={runClass} />
<ReportDoc artifact={report.doc_artifact} runId={runId} runClass={runClass} inquiry={access} handle={docRef} />
</div>
)}
</>
)}
{/* 审批质询:问答串(进入审批卡时按列表接口恢复)、提问框和「就整份报告提问」 */}
<ReportInquiry runId={runId} report={report.node_id} docArtifact={report.doc_artifact} access={access} disabled={disabled}
cardId={approvalId} onLocate={locate} />
</div>
)
}
/** 报告文档:和成果里同一份可逐段点开的文档,自带证据条(定位下一处、违规清单),点开的证据面板在卡内展开 */
function ReportDoc({ artifact, runId, runClass }: { artifact: string; runId: string; runClass: RunClass }) {
function ReportDoc({ artifact, runId, runClass, inquiry, handle }: {
artifact: string; runId: string; runClass: RunClass
/** 审批质询:句子、划选的原文上挂「质询」 */
inquiry: InquiryAccess | null
handle: Ref<EvidenceDocHandle>
}) {
const slot = useEvidence((s) => s.docs[artifact])
const loadDoc = useEvidence((s) => s.loadDoc)
useEffect(() => { void loadDoc(artifact) }, [artifact, loadDoc])
@@ -124,7 +164,8 @@ function ReportDoc({ artifact, runId, runClass }: { artifact: string; runId: str
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} />
<EvidenceDoc ref={handle} doc={doc} artifact={artifact} runId={runId} dense tally runClass={runClass ?? undefined}
inquiry={inquiry} />
</div>
)
}
+227 -18
View File
@@ -1,10 +1,10 @@
import {
Fragment, forwardRef, memo, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo,
useRef, useState, type CSSProperties, type FocusEvent as ReactFocusEvent,
type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent,
type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type RefObject,
} from 'react'
import { createPortal } from 'react-dom'
import { ListChecks } from 'lucide-react'
import { ListChecks, MessageCircleQuestion } from 'lucide-react'
import clsx from 'clsx'
import { rovingTarget } from '../components/ui'
import {
@@ -13,10 +13,11 @@ import {
type EvidenceStateCode, type EvidenceTally as Tally,
} from '../lib/evidence'
import { formatNumber } from '../lib/format'
import { EVIDENCE_STATE_LABEL, EVIDENCE_TEXT, JUDGE_TEXT, claimTally, evidenceTally, type ClaimTallyCounts } from '../lib/terms'
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 { EvidencePanel, type PanelMode, type PanelView } from './EvidencePanel'
import { askAbout, type InquiryAccess } from './Inquiry'
import { BlockView, Markdown, StyledSlice, inlineStyles, numericColumns, type InlineStyles } from './Markdown'
/**
@@ -54,6 +55,19 @@ const TEXT_CAP = 3000
/** 句末徽标在键盘顺序里的键:片段 id 是 s0…,不会撞 */
const badgeKey = (unit: string) => `claim:${unit}`
const unitOfKey = (key: string) => (key.startsWith('claim:') ? key.slice(6) : null)
/**
* 审批质询的「质询」在键盘顺序里的键。只有没有可点片段、也没有徽标的句子,「质询」本身进键盘顺序(方向键走得到这一句);
* 别的句子的「质询」跟在这一句后面,焦点在这一句上时按 Tab 到达,不多占键盘顺序
*/
const askKey = (unit: string) => `ask:${unit}`
const unitOfAsk = (key: string) => (key.startsWith('ask:') ? key.slice(4) : null)
/**
* 哪些句子挂「质询」:结论句(老文档没有 kind 的按有字的句子),标题、代码块里的不挂。表格里的格子也不挂:每格一个
* 图标会把列撑宽、满表都是图标;问表里的数可以划选那一格,或者就整份报告提问
*/
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 }
@@ -64,6 +78,8 @@ interface Model {
seg: Map<string, { seg: EvidenceSegment; unit: EvidenceUnit; block: number }>
/** 句末徽标:键是 claim:<unit>,带着这一句眼下的判定 */
badge: Map<string, Badge>
/** 进了键盘顺序的「质询」(审批质询):键是 ask:<unit> */
ask: Map<string, { unit: EvidenceUnit; block: number }>
/** 每个有可点片段的句子里第一个可点片段,按正文顺序(↑/↓ 用) */
unitFirst: string[]
unitOf: Map<string, number>
@@ -82,11 +98,12 @@ interface Model {
grid: Map<string, string>
}
function buildModel(blocks: EvidenceBlock[], overlay?: Record<string, EvidenceVerdict>): Model {
function buildModel(blocks: EvidenceBlock[], overlay?: Record<string, EvidenceVerdict>, ask = false): Model {
const order: string[] = []
const index = new Map<string, number>()
const seg = new Map<string, { seg: EvidenceSegment; unit: EvidenceUnit; block: number }>()
const badge = new Map<string, Badge>()
const asks = new Map<string, { unit: EvidenceUnit; block: number }>()
const unitFirst: string[] = []
const unitOf = new Map<string, number>()
const tiered: [string, 1 | 2][] = []
@@ -128,18 +145,27 @@ function buildModel(blocks: EvidenceBlock[], overlay?: Record<string, EvidenceVe
const t = alertTier(state)
if (t) tiered.push([key, t])
}
// 审批质询:一个可点的都没有的结论句,「质询」本身进键盘顺序,不然键盘用户问不了这一句
if (ask && first && askable(unit, block)) {
const key = askKey(unit.id)
asks.set(key, { unit, block: b })
index.set(key, order.length)
order.push(key)
unitFirst.push(key)
unitOf.set(key, unitFirst.length - 1)
}
}
chars.push(total)
})
// 按(轮次、正文顺序)排:tiered 本来就是正文顺序,稳定排序只按轮次挪
const sorted = [...tiered].sort((a, b) => a[1] - b[1])
return { order, index, seg, badge, unitFirst, unitOf, alerts: sorted.map(([k]) => k), tier: new Map(sorted), chars, cell,
grid }
return { order, index, seg, badge, ask: asks, unitFirst, unitOf, alerts: sorted.map(([k]) => k), tier: new Map(sorted), chars,
cell, grid }
}
/** 片段或徽标在第几块 */
const blockOf = (model: Model, key: string | null | undefined): number =>
(key ? model.seg.get(key)?.block ?? model.badge.get(key)?.block : undefined) ?? -1
(key ? model.seg.get(key)?.block ?? model.badge.get(key)?.block ?? model.ask.get(key)?.block : undefined) ?? -1
/**
* ↑/↓ 该去哪。表格里按列走:下一行同一列(那一格空着就找这一行离它最近的一格);
@@ -217,13 +243,21 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
* 数字的出处都对得上,档位却因为别的原因(比如查询没通过 SQL 检查)降了
*/
tier?: string
/**
* 审批质询(期 4):给了就在每句结论后面挂「质询」(悬停、焦点在这一句上时出现),划选原文时浮出「质询」,点了打开
* 这份报告的提问框。能不能问由页面按运行状态定(审批卡:运行停在审批上;运行详情:封存了的探索运行),approvalId 是
* 运行停在的那张审批。给了它,页面上要同时挂这份报告的 ReportInquiry(问答串和提问框在那里)
*/
inquiry?: InquiryAccess | null
}>(function EvidenceDoc({ doc, artifact, runId, dense = false, label, panel = 'auto', dock, onView, tally = false, runClass,
tier }, ref) {
tier, 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), [blocks, overlay])
const model = useMemo(() => buildModel(blocks, overlay, ask), [blocks, overlay, ask])
const counts = useMemo(() => docTally(doc), [doc])
const claims = useMemo(() => claimCounts(doc, overlay), [doc, overlay])
// 探索运行的报告(开了裁判的按需文档,或调用方说了是探索运行):证据图带着这次运行里之前按需判过的句子
@@ -272,7 +306,9 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
const segEl = useCallback((id: string) => {
const unit = unitOfKey(id)
const sel = unit ? `[data-ev-claim="${CSS.escape(unit)}"]` : `[data-seg="${CSS.escape(id)}"]`
const asked = unitOfAsk(id)
const sel = unit ? `[data-ev-claim="${CSS.escape(unit)}"]`
: asked ? `[data-inquiry-ask="${CSS.escape(asked)}"]` : `[data-seg="${CSS.escape(id)}"]`
return rootRef.current?.querySelector<HTMLElement>(sel) ?? null
}, [])
@@ -373,13 +409,29 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
},
}), [step, model, viewKey, current, focusSeg, openSeg, openViolations, doc])
// 事件委托:片段只带 data-seg、句末徽标只带 data-ev-claim,点击和按键都在容器上认
// 事件委托:片段只带 data-seg、句末徽标只带 data-ev-claim,点击和按键都在容器上认。「质询」只有进了键盘顺序的才算
// (跟在有可点片段的句子后面的那种不算:方向键在它上面不动,Tab 照常离开)
const segOf = (target: EventTarget | null): string | null => {
const el = (target as HTMLElement | null)?.closest?.<HTMLElement>('[data-seg], [data-ev-claim]')
const el = (target as HTMLElement | null)?.closest?.<HTMLElement>('[data-seg], [data-ev-claim], [data-inquiry-ask]')
if (!el || !rootRef.current?.contains(el)) return null
if (el.dataset.inquiryAsk) return model.index.has(askKey(el.dataset.inquiryAsk)) ? askKey(el.dataset.inquiryAsk) : null
return el.dataset.seg ?? (el.dataset.evClaim ? badgeKey(el.dataset.evClaim) : null)
}
/** 点了句末的「质询」:打开这份报告的提问框,问的是这一句 */
const askUnit = (el: HTMLElement) => {
const id = el.dataset.inquiryAsk
if (!id || !inquiry || !runId || !report) return
const unit = blocks.flatMap((b) => b.units ?? []).find((u) => u.id === id)
if (model.index.has(askKey(id))) setCurrent(askKey(id))
askAbout(runId, report, inquiry, { unit: id, quote: null, text: unit ? unitText(unit).trim() : '' }, el)
}
const onClick = (e: ReactMouseEvent) => {
const asking = (e.target as HTMLElement | null)?.closest?.<HTMLElement>('[data-inquiry-ask]')
if (asking && rootRef.current?.contains(asking)) {
e.preventDefault()
askUnit(asking)
return
}
const id = segOf(e.target)
if (!id) return
e.preventDefault()
@@ -445,11 +497,40 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
/>
)
// 审批质询:划选一段原文,浮出「质询」,问的是选中的这段(带上它起头的那一句)
const pickAnchor = useRef<HTMLDivElement>(null)
const [pick, dropPick] = useSelectionPick(rootRef, pickAnchor, ask)
const askPick = () => {
if (!pick || !inquiry || !runId || !report) return
// 浮出的按钮点完就没了:Esc 之后焦点回到那一句的「质询」
const back = pick.unit ? rootRef.current?.querySelector<HTMLElement>(`[data-inquiry-ask="${CSS.escape(pick.unit)}"]`) : null
document.getSelection()?.removeAllRanges()
dropPick()
askAbout(runId, report, inquiry, { unit: pick.unit, quote: pick.quote, text: pick.quote }, back)
}
// 画不了线的两种分开说:列表序号、代码块标签里的数字,和正文里根本没有对应字的(句末依据里的引用)
const summary = tallySummary({ ...counts, claims }, model.order.length > 0)
const summary = [tallySummary({ ...counts, claims }, model.order.length > 0), ask ? INQUIRY_TEXT.keysHint : '']
.filter(Boolean).join('。')
return (
<div ref={rootRef} data-evidence-doc="" className="min-w-0">
{ask && (
// 划选原文浮出的「质询」挂在文档里(高度为 0 的定位锚点下面,按锚点算坐标),不挂到页面末尾:画布上的审批浮层
// 按下在它外面就收起,挂在外面的按钮一按,浮层连同报告一起收掉。也因此会被报告框裁切,位置按可见范围摆(见
// useSelectionPick)
<div ref={pickAnchor} className="relative" data-inquiry-pick-anchor="">
{pick && (
// 按下时不抢焦点、不清掉选区:点下去才读选区
<button type="button" className="btn btn-xs absolute z-20 whitespace-nowrap shadow-elev-3" data-inquiry-selection=""
style={{ left: pick.left, top: pick.top }} aria-label={INQUIRY_TEXT.askSelection}
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => { e.stopPropagation(); askPick() }}>
<MessageCircleQuestion size={11} aria-hidden /> {INQUIRY_TEXT.ask}
</button>
)}
</div>
)}
<p id={summaryId} className="sr-only" data-evidence-summary="">{summary}</p>
<div className="sr-only" aria-live="polite" aria-atomic="true">{announce}</div>
{tally && (counts.total > 0 || counts.other > 0 || !!counts.suspect || !!claims?.total) && (
@@ -473,7 +554,7 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
<DocBlock block={block} dense={dense} doc={doc} visual={visual}
current={b === currentBlock ? current : null}
active={b === activeBlock ? activeSeg : null}
panelId={panelId} model={model} />
panelId={panelId} model={model} ask={ask} />
{mode === 'inline' && b === activeBlock && panelNode}
</Fragment>
))}
@@ -498,6 +579,103 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
)
})
/** 划选的一段原文:要问的字、它起头的那一句、浮出按钮的位置(相对定位锚点) */
interface SelectionPick { quote: string; unit: string | null; left: number; top: number }
/** 浮出按钮的大致尺寸(宁大勿小):放在选区末尾下方,贴边时收进可见范围,下方放不下时放到选区上方 */
const PICK_W = 64
const PICK_H = 26
/** 按钮和选区、和可见范围边上留的空 */
const PICK_GAP = 6
/**
* 一个元素眼下看得见的范围(视口坐标):视口,再和每一层会裁切内容的祖先(overflow 不是 visible 的,比如审批卡里
* 限高滚动的报告框)取交集。浮出的按钮挂在文档里,超出这个范围的部分会被裁掉
*/
function visibleBox(el: Element): { top: number; bottom: number; left: number; right: number } {
const box = { top: 0, left: 0, bottom: window.innerHeight, right: window.innerWidth }
for (let p: Element | null = el; p && p !== document.documentElement; p = p.parentElement) {
const style = getComputedStyle(p)
if (style.overflowX === 'visible' && style.overflowY === 'visible') continue
const r = p.getBoundingClientRect()
box.top = Math.max(box.top, r.top)
box.left = Math.max(box.left, r.left)
box.bottom = Math.min(box.bottom, r.bottom)
box.right = Math.min(box.right, r.right)
}
return box
}
/**
* 报告正文里划选了一段原文:返回要问的字和浮出按钮的位置,选区收起、移出正文、滚出可见范围时为 null。只认报告正文
* 里的字——栏内展开的证据面板也在这个容器里,那里的字不算。要问的字按选区克隆出来算,去掉句末徽标、「无证据」小标签、
* 读屏文字和「质询」本身(它们在 DOM 里,却不是报告的原文)。
*
* 位置相对 anchor(文档里高度为 0 的定位锚点)算:按钮挂在文档里,会被限高滚动的报告框裁切,所以按可见范围摆——
* 默认在选区末尾下方,下方放不下放到选区上方,左右收进文档的宽度(不把报告框撑出横向滚动)和可见范围
*/
function useSelectionPick(root: RefObject<HTMLElement | null>, anchor: RefObject<HTMLElement | null>,
enabled: boolean): [SelectionPick | null, () => void] {
const [pick, setPick] = useState<SelectionPick | null>(null)
useEffect(() => {
if (!enabled) { setPick(null); return }
let frame = 0
const read = () => {
frame = 0
const el = root.current
const origin = anchor.current
const sel = document.getSelection()
if (!el || !origin || !sel || sel.isCollapsed || !sel.rangeCount) { setPick(null); return }
const range = sel.getRangeAt(0)
const at = (n: Node) => (n instanceof Element ? n : n.parentElement)
const inside = (x: Element | null) => !!x && el.contains(x) && !!x.closest('[data-block]') && !x.closest('[data-evidence-panel]')
const start = at(range.startContainer)
if (!inside(start) || !inside(at(range.endContainer))) { setPick(null); return }
const frag = range.cloneContents()
frag.querySelectorAll('[data-inquiry-ask], .ev-tag, .ev-badge, .sr-only').forEach((n) => n.remove())
const quote = (frag.textContent ?? '').replace(/\s+/g, ' ').trim()
if ([...quote].length < 2) { setPick(null); return }
const rects = range.getClientRects()
const last = rects[rects.length - 1] ?? range.getBoundingClientRect()
const first = rects[0] ?? last
const view = visibleBox(el)
// 选区整个滚出了可见范围:按钮也不留(滚回来再量出来)
if (last.bottom <= view.top || first.top >= view.bottom) { setPick(null); return }
const doc = el.getBoundingClientRect()
const lo = Math.max(doc.left, view.left)
const hi = Math.min(doc.right, view.right) - PICK_W
const x = Math.max(lo, Math.min(last.right - PICK_W / 2, hi))
const fitsBelow = last.bottom + PICK_GAP + PICK_H <= view.bottom
const fitsAbove = first.top - PICK_GAP - PICK_H >= view.top
const y = fitsBelow || !fitsAbove ? Math.min(last.bottom + PICK_GAP, Math.max(view.top, view.bottom - PICK_H))
: first.top - PICK_GAP - PICK_H
const o = origin.getBoundingClientRect()
const next: SelectionPick = {
quote, unit: start?.closest('[data-ev-unit]')?.getAttribute('data-ev-unit') ?? null,
left: Math.round(x - o.left), top: Math.round(y - o.top),
}
// 滚动时每一帧都会量一次:没变就不换对象,整份报告不跟着重画
setPick((cur) => (cur && cur.quote === next.quote && cur.unit === next.unit && cur.left === next.left && cur.top === next.top
? cur : next))
}
// 选区一变、页面或报告框一滚、窗口一变都重新量:可见范围变了,按钮放上方还是下方跟着变。文档自己的尺寸变了也量:
// 悬停一句时它的「质询」出现、那一段多折一行,下面的字整体下移,既不滚动也不改选区,按钮却得跟着挪
const schedule = () => { if (!frame) frame = requestAnimationFrame(read) }
document.addEventListener('selectionchange', schedule)
window.addEventListener('scroll', schedule, true)
window.addEventListener('resize', schedule)
const resized = typeof ResizeObserver === 'function' ? new ResizeObserver(schedule) : null
if (root.current) resized?.observe(root.current)
return () => {
cancelAnimationFrame(frame)
document.removeEventListener('selectionchange', schedule)
window.removeEventListener('scroll', schedule, true)
window.removeEventListener('resize', schedule)
resized?.disconnect()
}
}, [root, anchor, enabled])
return [pick, useCallback(() => setPick(null), [])]
}
/** 定位到的片段描一下边(CSS 的 [data-flash]),同 AssistantStream 的 flash */
function flashOnce(el: HTMLElement) {
el.dataset.flash = 'focus'
@@ -590,12 +768,14 @@ export function ClaimTally({ claims, lead }: { claims: ClaimTallyCounts; lead: b
/** 列表项的开头:结构片段里带着行首的 - / 1. 。列表项被分成几句时,后面几句接在同一项里 */
const ITEM_START = /(^|\n)[ \t]*([-*+]|\d+[.)])[ \t]+$/
const DocBlock = memo(function DocBlock({ block, dense, doc, visual, current, active, panelId, model }: {
const DocBlock = memo(function DocBlock({ block, dense, doc, visual, current, active, panelId, model, ask }: {
block: EvidenceBlock
dense: boolean
doc: EvidenceDocData
/** 句末徽标从这里取:这一句眼下的判定(封存后追加的盖过文档里的) */
model: Model
/** 审批质询:结论句后面挂「质询」 */
ask: boolean
/**
* 长文档:块上加 content-visibility,屏幕外的块不排版不绘制。短文档不加:它自带 paint
* containment,贴着块边的片段的焦点描边会被裁掉一截,而短文档本来就不慢
@@ -614,7 +794,8 @@ const DocBlock = memo(function DocBlock({ block, dense, doc, visual, current, ac
}
const unit = (u: EvidenceUnit, opts?: { code?: boolean; tag?: boolean }) => (
<UnitView key={u.id} unit={u} doc={doc} current={current} active={active} panelId={panelId}
code={opts?.code} tag={opts?.tag ?? true} badge={model.badge.get(badgeKey(u.id))} />
code={opts?.code} tag={opts?.tag ?? true} badge={model.badge.get(badgeKey(u.id))}
ask={ask && askable(u, block) ? (model.ask.has(askKey(u.id)) ? 'roving' : 'follow') : undefined} />
)
switch (block.type) {
@@ -684,9 +865,14 @@ const DocBlock = memo(function DocBlock({ block, dense, doc, visual, current, ac
* 被切坏),有状态的片段包成按钮。含无证据片段的句子末尾挂「? 无证据」;有判定的结论句末尾挂
* 一枚徽标(也是按钮),证据相矛盾(和旧的证据不支持)的整句铺浅底——字本身不画线
*/
function UnitView({ unit, doc, current, active, panelId, code, tag, badge }: {
function UnitView({ unit, doc, current, active, panelId, code, tag, badge, ask }: {
unit: EvidenceUnit; doc: EvidenceDocData; current: string | null; active: string | null; panelId: string
code?: boolean; tag: boolean; badge?: Badge
/**
* 审批质询的「质询」:roving 是这一句一个可点的都没有,「质询」本身进键盘顺序;follow 跟在这一句后面,焦点在这一句上时
* 按 Tab 到达
*/
ask?: 'roving' | 'follow'
}) {
const segs = (unit.segments ?? []).filter((s) => s.kind !== 'structural')
const text = segs.map((s) => s.text).join('')
@@ -727,7 +913,7 @@ function UnitView({ unit, doc, current, active, panelId, code, tag, badge }: {
const key = badge ? badgeKey(unit.id) : ''
const meta = badge ? EVIDENCE_STATE[badge.state] : null
const open = !!badge && active === key
return (
const body = (
<>
{conflicting(badge?.state)
// 证据相矛盾(和旧的证据不支持):整句铺一层浅底(折行时每行各自带圆角),字色、字重都不动,也不画线
@@ -762,6 +948,29 @@ function UnitView({ unit, doc, current, active, panelId, code, tag, badge }: {
)}
</>
)
if (!ask) return body
// 「质询」跟在句末,是一个固定宽度的小图标:平时淡淡的,悬停这一句、焦点落在这一句上时显出来(强调色、描边)。
// 显没显出来只换颜色,宽度一直是那么宽——悬停时撑开的话,后面的字整体右移,鼠标底下的数字跟着挪走,点不到。
// 触屏没有悬停,一直显出来,并写上「质询」两个字。
// 跟随的那种只在焦点在这一句上时进 Tab 顺序,整份报告不因此多出一串 Tab 位
const here = current != null && (current === key || current === askKey(unit.id)
|| (unit.segments ?? []).some((sg) => sg.id === current))
return (
<span className="group/evu" data-ev-unit={unit.id}>
{body}
<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 '
+ '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)] '
+ 'pointer-coarse:border-[color:var(--border)] pointer-coarse:text-[color:var(--accent)] pointer-coarse:opacity-100'}>
<MessageCircleQuestion size={11} aria-hidden />
<span aria-hidden="true" className="hidden pointer-coarse:inline">{INQUIRY_TEXT.ask}</span>
</button>
</span>
)
}
/**
File diff suppressed because it is too large Load Diff
+18 -6
View File
@@ -5,15 +5,16 @@ import { api } from '../api/client'
import { useStudio } from '../store/studio'
import { useCatalog } from '../store/catalog'
import { NODE_DEFS } from '../canvas/nodeDefs'
import { StatusDot, useToast } from '../components/ui'
import { Spinner, StatusDot, useToast } from '../components/ui'
import { useLocalActor } from '../lib/actor'
import { formatDateTime, formatSpan, formatTime, parseServerTime } from '../lib/format'
import { runClassLabel, runName, TOOL_TRUST_TEXT } from '../lib/terms'
import { INQUIRY_TEXT, runClassLabel, runName, TOOL_TRUST_TEXT } from '../lib/terms'
import { useRunClock } from './useRunClock'
import { Markdown } from './Markdown'
import { ApprovalReports, ApprovalRunClass } from './ApprovalReport'
import { ChallengeSummary } from './ChallengeSummary'
import { DualCheckApproval } from './DualCheckApproval'
import { useInquiryBusy } from './Inquiry'
import type { Approval, ApprovalReport } from '../types'
// 发起运行的表单搬去了 run/RunControl.tsx 的工具栏控件里。它原先常驻在
@@ -48,6 +49,8 @@ export function ApprovalCard({ approval, onResolved, showWorkflow = true }: {
approval.payload?.draft != null ? String(approval.payload.draft) : '',
)
const [busy, setBusy] = useState(false)
// 审批质询正在回答:服务端这时拒绝审批回复(409),批准 / 驳回先停用,并说明原因
const inquiring = useInquiryBusy(approval.run_id)
const payload = approval.payload ?? {}
// 本机署名随 X-Actor 发给后端、写进审批留痕;在设置或发布弹窗里改了,卡底这句跟着变
const actor = useLocalActor()
@@ -152,7 +155,8 @@ export function ApprovalCard({ approval, onResolved, showWorkflow = true }: {
{/* 双盲复算对不上、交给人裁决的审批,卡上画两路结果的对照 */}
{dualCheck && <DualCheckApproval approval={approval} onResolved={onResolved} />}
<ApprovalReports reports={reports} runId={approval.run_id} runClass={approval.run_class} />
<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} />
{payload.tool && (
@@ -193,20 +197,28 @@ export function ApprovalCard({ approval, onResolved, showWorkflow = true }: {
/>
<div className="flex gap-2">
<button className="btn btn-primary flex-1 justify-center" disabled={busy} onClick={() => decide(true)}>
<button className="btn btn-primary flex-1 justify-center" disabled={busy || inquiring} onClick={() => decide(true)}>
<Check size={12} aria-hidden /> {approveMode ? '批准' : '提交'}
</button>
{trustKey && (
<button className="btn flex-1 justify-center" disabled={busy} data-approval-always
<button className="btn flex-1 justify-center" disabled={busy || inquiring} data-approval-always
title={TOOL_TRUST_TEXT.alwaysHint(trustKey)} aria-describedby={`approval-always-${approval.id}`}
onClick={() => decide(true, true)}>
<CheckCheck size={12} aria-hidden /> {TOOL_TRUST_TEXT.alwaysButton}
</button>
)}
<button className="btn btn-danger flex-1 justify-center" disabled={busy} onClick={() => decide(false)}>
<button className="btn btn-danger flex-1 justify-center" disabled={busy || inquiring} onClick={() => decide(false)}>
<Ban size={12} aria-hidden /> {approveMode ? '驳回' : '驳回并终止'}
</button>
</div>
{inquiring && (
// 字用正文色:审批卡铺着一层提醒色的底,提醒色的字在浅色主题下对比度不够;提醒色交给转圈的图标
<div className="mt-1.5 flex items-start gap-1 text-2xs font-medium leading-relaxed text-fg" role="status"
data-approval-inquiry-busy="">
<span className="mt-[3px] shrink-0" style={{ color: 'var(--st-waiting)' }}><Spinner size={11} /></span>
<span>{INQUIRY_TEXT.decideBlocked}</span>
</div>
)}
{/* 批了会怎样,常显而不是藏在 hover 里:审批人得在按下去之前知道 */}
<div className="mt-1.5 text-2xs leading-relaxed text-dim">
{approveMode
+53 -1
View File
@@ -3,7 +3,7 @@ import type { NodeState, RunPhase } from './trace'
import { formatDuration, formatNumber } from '../lib/format'
import {
ABSENCE_CLAIM_TEXT, CATALOG_DRIFT_TEXT, CATALOG_PATCH_TEXT, COPILOT_CONTEXT_TEXT, JUDGE_TEXT, MERGE_TEXT, RUN_SQL_CHECK_TEXT, SCHEMA_PARTIAL_TEXT, SQL_CHECK_TEXT, TYPE_LABEL,
CATALOG_TABLE_FIELD_LABEL, CHALLENGE_TEXT, DUAL_CHECK_TEXT, NODE_TYPE_LABEL,
CATALOG_TABLE_FIELD_LABEL, CHALLENGE_TEXT, DUAL_CHECK_TEXT, INQUIRY_TEXT, NODE_TYPE_LABEL,
claimTally,
evidenceTally, issuanceLabel, nodeTypeLabel,
} from '../lib/terms'
@@ -704,6 +704,52 @@ const JUDGE_FAILED_TITLE = '结论句裁判未能执行'
/** 审批行末尾:批的时候点了「始终允许」,这个工具之后不再问人 */
const ALWAYS_NOTE = ',并设为「始终允许 · 门控把关」'
/** 「审批人质询」那一行的标题里,问题最多带几个字(按字数,不按 UTF-16 码元) */
const INQUIRY_TITLE = 40
/**
* approval.inquiry → 一行「审批人质询」:标题带问题的前 40 字,副标题说回答是否完成、查了几次(封存之后问的另写一句)。
*
* 只认服务端给的:ok 为真才说「回答完成」,带着触顶原因的说不完整、写明是哪个上限;没说成没成(缺字段)又有回答正文的
* 只说「已回答」。查了几次认 queries,没有再认 inquiry_artifacts,两样都没有就不说次数——不拿 0 去顶。字段类型不对的
* 当没给:问题不是字符串就只写「审批人质询」
*/
function inquiryStep(d: Record<string, any>, seq: number): Step {
const T = INQUIRY_TEXT
const str = (v: unknown) => (typeof v === 'string' ? v.trim() : '')
const question = str(d.question)
const chars = [...question]
const head = chars.length > INQUIRY_TITLE ? `${chars.slice(0, INQUIRY_TITLE).join('')}…` : question
const limit = str(d.limit)
const limitText = limit ? T.limit[limit] ?? T.limitOther : ''
const answer = str(d.answer_text)
const error = str(d.error)
const state = d.ok === true ? (limit ? T.incomplete(limitText) : T.complete)
: d.ok === false || !answer ? T.failed(limitText || null) : T.answered
const runs = Array.isArray(d.queries) ? d.queries.length
: Array.isArray(d.inquiry_artifacts) ? d.inquiry_artifacts.length : null
const fine = !limit && !error && (d.ok === true || (d.ok !== false && !!answer))
const quote = str(d.quote)
const model = str(d.model)
const doc = str(d.answer_doc)
const detail = [
question ? T.detail.question(question) : '',
quote ? T.detail.quote(quote) : '',
answer ? T.detail.answer(answer) : '',
error ? T.detail.error(error) : '',
model ? T.detail.model(model) : '',
].filter(Boolean).join('\n')
return {
id: `iq-${seq}`, seq, kind: 'human', status: 'done', code: 'approval_inquiry',
level: fine ? 'info' : 'warn',
title: T.timeline(head),
sub: [state, runs != null ? T.queries(runs) : '', d.post_seal === true ? T.postSeal : ''].filter(Boolean).join(' · '),
...(detail ? { detail } : {}),
// 回答组装成的文档(数字带出处):展开区可以下钻
...(doc ? { artifact: doc } : {}),
}
}
/**
* 门控模型(工具信任档「始终允许 · 门控把关」)对一次调用的结论。team:协作团队里拦下的,
* 成员停不下来等人,这次调用不执行。没有工具名(老数据、字段缺了)就说「一次工具调用」
@@ -2176,6 +2222,12 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt
break
}
case 'approval.inquiry':
// 审批人在批准前就待审的报告提问(审批质询):运行停在审批上时服务端追加这一条,node_id 为空,运行本身没动、
// 报告也不改(相位照旧,见 nextPhase)。封存之后探索运行也能问,带 post_seal。一条事件就是一问一答,挂在顶层
out.push(inquiryStep(d, seq))
break
case 'catalog.drift': {
// 从发布版本发起的正式运行:发布之后数据目录有变化。只提醒、运行照常,所以是一条警告,不是失败
const drift = catalogDrift(d)
+105
View File
@@ -3709,3 +3709,108 @@ export interface ChallengeArtifact {
claims: ChallengeClaim[]
model: string | null
}
// ---------------------------------------------------------------------------
// 审批质询:审批人就待审的报告提问,核查模型只读查库作答。形状照前后端共用的接口约定(F5a / F5b 同一份),字段名一个
// 不改;回答正文、行数、触顶原因都是服务端给的,界面只排版
// ---------------------------------------------------------------------------
/** 回答停在哪个上限上:每次最多查询次数、时长、这一次的金额、核查的每日金额 */
export type InquiryLimit = 'max_queries' | 'timeout_s' | 'max_cost_usd' | 'daily_max_usd'
/** 质询的回答 */
export interface InquiryAnswer {
/** 渲染后的正文(数字已按出处换好) */
text: string
/** compose_doc 组装的文档工件:有它,正文里的数字可以点开看出处;没有(组装失败)只显示 text */
doc_artifact: string | null
ok: boolean
limit: InquiryLimit | (string & {}) | null
/** 没答成时的中文说明 */
error: string | null
}
/** 核查模型为回答执行的一次只读查询。编号是这次质询自己的,快照按质询工件接口取(遮罩列已遮掉) */
export interface InquiryQuery {
alias: string
artifact: string
sql: string
row_count: number | null
truncated: boolean
/** 数据源名 */
source: string | null
}
export interface InquiryUsage {
input_tokens?: number
output_tokens?: number
cost_usd?: number | null
calls?: number
unpriced_calls?: number
}
/** 一次质询:问的是哪份报告的哪一句(或划选的哪段原文)、问了什么、怎么答的、查了什么 */
export interface Inquiry {
/** 运行记录里那条 approval.inquiry 事件的序号;没写进运行记录时为 null */
seq: number | null
/** 什么时候问的(ISO 时间或秒级时间戳) */
at: string | number | null
/** 提问人的署名;没署名时为 null */
actor: string | null
/** 报告撰写节点 id */
report: string
/** 句子单元 id(如 u12);就整份报告提问时为 null */
unit: string | null
/** 划选的原文 */
quote: string | null
question: string
answer: InquiryAnswer
queries: InquiryQuery[]
/** 作答的核查模型 */
model: string | null
usage: InquiryUsage
/** 运行封存之后才问的(只有探索运行可以) */
post_seal: boolean
/** 提问时停在的那张审批;封存之后问的为 null */
approval_id: string | null
}
/** POST /runs/{id}/inquiries 的请求体 */
export interface InquiryRequest {
report: string
unit: string | null
quote?: string | null
question: string
/** 运行停在审批上时必填:指明是哪一张 */
approval_id?: string
}
/** POST /runs/{id}/inquiries 的答复 */
export interface InquiryResponse {
inquiry: Inquiry
/** 是否写进了运行记录 */
recorded: boolean
/** 没写进运行记录时的中文说明,否则为 null */
note: string | null
}
/** GET /runs/{id}/inquiries 的答复 */
export interface InquiryList {
items: Inquiry[]
}
/**
* GET /runs/{id}/inquiries/artifacts/{artifact}:质询引用过的查询快照,遮罩列照证据面板的规则遮掉(值写「已遮罩」)。
* 约定只说了是快照:可能是快照本身,也可能包在 {id, content} 里(同工件接口),界面两种都认
*/
export interface InquirySnapshot {
columns: string[]
rows: unknown[][]
sql: string | null
truncated: boolean
/** 结果一共几行(快照的 row_count / total_rows),不给时按 rows 的长度 */
total: number | null
/** 遮掉的列 */
masked: string[]
source: string | null
}
+1 -1
View File
@@ -98,7 +98,7 @@ const ESTIMATE = {
stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87,
evidence: 91, versions: 60, catalog: 45, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
// 占位,换成真检查时按实测改
schedules: 60, compare: 45, verify: 71, inquiry: 1, bundle: 16, whatif: 33,
schedules: 60, compare: 45, verify: 71, inquiry: 60, bundle: 16, whatif: 33,
}
// 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段
// 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。
+77
View File
@@ -2319,5 +2319,82 @@ await section('核查类日志与出具缺口(期 4):无法复算、未能
rows.map((r) => `${r?.title}|${r?.sub ?? ''}`).join(' ; '))
})
await section('审批质询 approval.inquiry(期 4):一行「审批人质询」,标题带问题前 40 字,副标题说回答是否完成、查了几次', async () => {
// 前后端共用的约定:运行停在审批上时服务端追加 approval.inquiry,node_id 为空;data 里有问题、划选的原文、回答正文、
// 回答文档、成没成、触顶原因、查询(queries 和 inquiry_artifacts 两份)。以前这类事件落进「未识别的事件」
const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 1790000000 + seq, data })
const DOC = 'd0c0'.repeat(16)
const art = (i) => String(i).repeat(64).slice(0, 64)
const q = (i) => ({ alias: `Q${i}`, artifact: art(i), sql: `SELECT ${i}`, row_count: 1, truncated: false, source: 'shop' })
const LONG = '本月华东区销售额环比增长 12%,其中新客贡献了多少?请按门店拆开说明,并说明是否扣除了退款与优惠券抵扣的金额'
const base = { report: 'write', unit: 'u3', approval_id: 'ap-1', answer_doc: DOC, model: 'verify-mini', usage: { calls: 3 } }
const events = [
E(1, 'run.started', null, { nodes: 3 }),
E(2, 'node.started', 'h', { node_type: 'human', label: '主管审批' }),
E(3, 'human.requested', 'h', { kind: 'human_node', node_id: 'h', mode: 'approve', title: '月报可以发出吗?' }),
E(4, 'run.interrupted', 'h', { payload: { kind: 'human_node', node_id: 'h' } }),
E(5, 'approval.inquiry', null, { ...base, question: '销售额是否扣除了退款?', quote: null, answer_text: '已扣除退款,退款合计 21,500 元。',
ok: true, limit: null, error: null, inquiry_artifacts: [art(1), art(2)], queries: [q(1), q(2)] }),
E(6, 'approval.inquiry', null, { ...base, question: LONG, quote: '华东区增长最快', answer_text: '按已查到的结果:新客贡献约一半。',
ok: true, limit: 'max_queries', error: null, inquiry_artifacts: [1, 2, 3, 4, 5, 6].map(art), queries: [1, 2, 3, 4, 5, 6].map(q) }),
E(7, 'approval.inquiry', null, { ...base, unit: null, question: '新客数是多少?', quote: null, answer_text: '', answer_doc: null,
ok: false, limit: null, error: '核查模型未配置,无法作答', inquiry_artifacts: [], queries: [] }),
]
const steps = mod.decodeRun(events)
const all = flatten(steps)
const rows = steps.filter((s) => s.code === 'approval_inquiry')
check('三条质询各一行,挂在顶层(node_id 为空),不再是「未识别的事件」', rows.length === 3
&& !all.some((s) => s.title === '未识别的事件'), steps.map((s) => s.title).join(' | '))
check('……审批那一行仍在等待,相位仍是 waiting', all.some((s) => s.kind === 'human' && s.nodeId === 'h' && s.status === 'waiting')
&& mod.isAwaitingHuman(steps) && mod.decodePhase(events) === 'waiting', mod.decodePhase(events))
const [a, b, c] = rows
check('标题:「审批人质询:」加问题', a?.title === '审批人质询:销售额是否扣除了退款?', a?.title)
check('长问题只取前 40 字,后面加省略号', b?.title === `审批人质询:${[...LONG].slice(0, 40).join('')}…` && [...LONG].length > 40, b?.title)
check('答完了:副标题写回答完成、查询 2 次,不用提醒色', a?.sub === '回答完成 · 查询 2 次' && a.level === 'info', `${a?.sub} / ${a?.level}`)
check('触顶收尾:回答不完整、写明是哪个上限,查询 6 次,提醒色', b?.sub === '回答不完整(已达查询次数上限) · 查询 6 次'
&& b.level === 'warn', `${b?.sub} / ${b?.level}`)
check('没答成:未能回答、未执行查询,提醒色', c?.sub === '未能回答 · 未执行查询' && c.level === 'warn', `${c?.sub} / ${c?.level}`)
check('展开区:完整的问题、划选的原文、回答(没答成的写原因)、核查模型', !!b?.detail?.includes(LONG) && b.detail.includes('原文:「华东区增长最快」')
&& b.detail.includes('按已查到的结果') && !!c?.detail?.includes('核查模型未配置,无法作答') && !!a?.detail?.includes('verify-mini'),
`${b?.detail} // ${c?.detail}`)
check('回答文档可下钻,没有文档的不挂工件', a?.artifact === DOC && c?.artifact === undefined, `${a?.artifact} / ${c?.artifact}`)
check('图标同人工审批(kind human),状态完成,不在转圈', rows.every((s) => s.kind === 'human' && s.status === 'done'))
check('标题、副标题里没有内部代码', !rows.some((s) => /approval\.inquiry|approval_inquiry|max_queries|post_seal/.test(`${s.title}${s.sub ?? ''}`)))
// 其余三个上限、封存之后追加(只有探索运行可以)
const more = mod.decodeRun([
E(1, 'run.started', null, { nodes: 2 }), E(2, 'run.finished', null, { output: {} }),
E(3, 'approval.inquiry', null, { ...base, approval_id: null, post_seal: true, question: '退款率怎么算的?', answer_text: '退款金额除以订单金额。',
ok: true, limit: null, error: null, queries: [q(1)], inquiry_artifacts: [art(1)] }),
E(4, 'approval.inquiry', null, { ...base, question: '甲', answer_text: '乙', ok: true, limit: 'timeout_s', queries: [q(1)] }),
E(5, 'approval.inquiry', null, { ...base, question: '丙', answer_text: '', ok: false, limit: 'daily_max_usd', queries: [] }),
E(6, 'approval.inquiry', null, { ...base, question: '丁', answer_text: '戊', ok: true, limit: 'max_cost_usd', queries: [q(1), q(2)] }),
]).filter((s) => s.code === 'approval_inquiry')
check('封存之后问的:副标题写明封存后追加', more[0]?.sub === '回答完成 · 查询 1 次 · 封存后追加', more[0]?.sub)
check('时长上限、每日金额上限、单次金额上限各有说法', more[1]?.sub === '回答不完整(已达时长上限) · 查询 1 次'
&& more[2]?.sub === '未能回答(已达核查每日金额上限) · 未执行查询' && more[3]?.sub === '回答不完整(已达单次金额上限) · 查询 2 次',
more.slice(1).map((s) => s.sub).join(' / '))
check('运行跑完之后来的质询不把相位带回等待', mod.decodePhase([E(1, 'run.started', null, {}), E(2, 'run.finished', null, {}),
E(3, 'approval.inquiry', null, { question: 'x', ok: true })]) === 'succeeded')
// 缺字段、类型不对:不崩,不出 undefined、NaN、[object Object],不瞎填数
const odd = [{}, { question: 7, queries: 'x', ok: 'yes' }, { question: ' ', answer_text: 3, inquiry_artifacts: {} },
{ question: '只给了工件', inquiry_artifacts: [art(1), art(2), art(3)] }, { question: '有回答没写成没成', answer_text: '有' }]
let broken = null
let oddRows = []
try {
oddRows = mod.decodeRun(odd.map((d, i) => E(i + 1, 'approval.inquiry', null, d))).filter((s) => s.code === 'approval_inquiry')
} catch (e) {
broken = String(e?.message ?? e)
}
check('缺字段的质询事件:不抛异常,每条照样一行', broken === null && oddRows.length === odd.length, broken ?? `${oddRows.length} 行`)
check('……问题不是字符串、是空白:标题只写「审批人质询」', oddRows[0]?.title === '审批人质询' && oddRows[1]?.title === '审批人质询'
&& oddRows[2]?.title === '审批人质询', oddRows.slice(0, 3).map((s) => s.title).join(' / '))
check('……前三条不出 undefined、NaN、[object Object],也不瞎填数', !oddRows.slice(0, 3).some((s) => /undefined|NaN|object Object|\d/.test(`${s.title}${s.sub ?? ''}`)),
oddRows.slice(0, 3).map((s) => `${s.title}|${s.sub}`).join(' ; '))
check('……只给了质询工件:按工件数说查了几次', oddRows[3]?.sub === '未能回答 · 查询 3 次', oddRows[3]?.sub)
check('……有回答、没写成没成:只说已回答,不说完成', oddRows[4]?.sub === '已回答' && oddRows[4].level === 'info', `${oddRows[4]?.sub} / ${oddRows[4]?.level}`)
})
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 解码器全部通过')
process.exit(failed ? 1 : 0)
+1371 -6
View File
File diff suppressed because it is too large Load Diff
+53
View File
@@ -195,6 +195,59 @@ await section('墙钟 / 执行 / 等人', async () => {
project(t, ts('run.interrupted') + 1).nodes.h?.state === 'waiting')
})
await section('等待审批时来了一条质询(approval.inquiry)', async () => {
// 审批人在批准前就报告提问:服务端在运行停在审批上时追加一条 approval.inquiry(node_id 为空),运行本身没动。
// 它不能把相位从等待里带出来(标题变成「正在执行」、工具栏挂出必然 409 的「停止」),也不能把等待的节点、
// 等待的那段时长收掉;恢复之后照常接着走
const e = fixtures.human
const before = upto(e, 6)
const last = before[before.length - 1]
const inquiry = {
seq: last.seq + 1, type: 'approval.inquiry', node_id: null, ts: last.ts + 0.5,
data: { question: '本月销售额是否扣除了退款?', quote: null, report: 'write', unit: 'u3', approval_id: 'ap-1',
answer_text: '已扣除退款。', answer_doc: null, ok: true, limit: null, error: null,
inquiry_artifacts: ['a'.repeat(64)], queries: [{ alias: 'Q1', artifact: 'a'.repeat(64), sql: 'SELECT 1', row_count: 1,
truncated: false, source: 'shop' }], model: 'verify-mini', usage: { calls: 2 } },
}
const asked = [...before, inquiry]
const t = run(asked, G.human)
check('相位仍是 waiting,等的还是 h', t.phase === 'waiting' && t.waitingNodeId === 'h' && t.nodes.h?.state === 'waiting',
`${t.phase} · ${t.waitingNodeId} · ${t.nodes.h?.state}`)
check('decodePhase 同样是 waiting', decode.decodePhase(asked) === 'waiting', decode.decodePhase(asked))
check('等待那一段没有被收掉', t.nodes.h?.segments.map((s) => `${s.kind}:${s.end == null ? 'open' : 'closed'}`).join(',') === 'run:closed,wait:open',
t.nodes.h?.segments.map((s) => `${s.kind}:${s.status}`).join(','))
check('等待节点的入边仍是闸门、没有光点', activeEdgesOf(t, G.human).length === 0 && heldEdgesOf(t, G.human).join(' ') === 'in|h|',
`流动 ${activeEdgesOf(t, G.human).join(' ')} · 闸门 ${heldEdgesOf(t, G.human).join(' ')}`)
const later = project(t, inquiry.ts * 1000 + 5000)
check('质询不算执行:之后墙钟和等待一起涨,执行时长不涨',
later.waitMs >= 5000 && later.elapsedMs - later.waitMs < 50,
`墙钟 ${ms(later.elapsedMs)} · 等待 ${ms(later.waitMs)} · 执行 ${ms(later.activeMs)}`)
const steps = decode.decodeRun(asked)
check('右栏:审批那一行仍在等待', decode.isAwaitingHuman(steps)
&& flat(steps).some((s) => s.kind === 'human' && s.nodeId === 'h' && s.status === 'waiting'),
flat(steps).map((s) => `${s.kind}:${s.status}`).join(','))
check('右栏:质询单独成一行,挂在顶层,不当成未识别的事件', steps.some((s) => s.code === 'approval_inquiry' && s.status === 'done')
&& !flat(steps).some((s) => s.title === '未识别的事件'), steps.map((s) => s.title).join(' | '))
// 恢复之后:后面的事件序号往后挪一位,接在质询后面
const resumed = [...asked, ...e.filter((x) => x.seq > 6).map((x) => ({ ...x, seq: x.seq + 1 }))]
const done = run(resumed, G.human)
check('恢复后照常跑完:h 只执行一次,一行三段(跑 → 等 → 接着跑)', done.phase === 'succeeded' && done.nodes.h?.count === 1
&& done.nodes.h.segments.map((s) => s.kind).join(',') === 'run,wait,run',
`${done.phase} · ×${done.nodes.h?.count} · ${done.nodes.h?.segments.map((s) => s.kind).join(',')}`)
const p = project(done, LATER)
const wait = (e.find((x) => x.type === 'run.resumed').ts - e.find((x) => x.type === 'run.interrupted').ts) * 1000
check('等待时长照旧是中断到恢复(质询那一刻不切断等待)', Math.abs(p.waitMs - wait) < 1, `${ms(p.waitMs)} vs ${ms(wait)}`)
let same = true
let where = ''
let tt = emptyTrace()
for (let i = 0; i < resumed.length; i++) {
tt = foldEvent(tt, resumed[i])
const expect = decode.decodePhase(resumed.slice(0, i + 1))
if (tt.phase !== expect) { same = false; where = `#${resumed[i].seq}: ${tt.phase} vs ${expect}` }
}
check('每个前缀上 trace.phase === decodePhase(含质询那一条)', same, where)
})
await section('循环轮次', async () => {
const e = fixtures.think
const t = run(e, G.think)