可点击证据第四期:结论句由模型裁判;只引单元格的问数据图能发成受管

一、结论句裁判

- 报告节点新增 claims: judge:写完、核对完之后,把结论句和它引用的证据摘录交给裁判模型,
  判为支持 / 部分支持 / 不支持 / 不是结论,附一句理由;判定只标注,不改写正文
- 送裁判的句子按固定规则预筛:带引用的、含方向词或因果词的送去判,短句、过渡句不送
- 证据摘录:指标写名称、值和原式;查询给 SQL 前 300 字和被引用的行,被引用的列一定给到;遮罩列一律不给;
  规则里写明「数字已经由系统核对过,不要复算」
- 一份报告合并成一次调用,超出句数就分批;结构化输出失败时退回 JSON 解析,再失败记为未裁判
- 裁判模型的优先级:节点上写的 > 设置里的「证据裁判模型」> Copilot 的模型;思考关闭;
  和写作模型相同时给警告(等于自己审自己)
- rewrite_once(默认关):把不支持的句子和理由交回写作者只改这几句,再裁一次;
  改写稿冒出原稿没有的问题就不采用,保留原稿和原来的判定

二、成本上限

- 每份报告的句数、金额、时长,探索运行每次点击的金额,全局每日金额,每一项都可以自定义,也都可以设成不限;
  默认 40 句、$0.05、30 秒,每次点击 $0.01,每天 $2
- 调用前先估算,超出上限按优先级截断:先判带引用的,再判含方向词、因果词的,其余记为未裁判并写明「已到上限」;
  不报错,也不让报告失败
- 每日花费按本地日期计数,持久化在设置表里;重跑同一次运行不重复计费;价格目录里没有的模型只计次数,并写明估不出金额

三、正式运行与探索运行

- 正式运行在报告节点内裁判,判定写进报告文档、随文档封存;不支持的按 on_unsupported(degrade / withhold)降档或不予出具,
  裁判没跑成记缺口,不能判完整出具
- 探索运行按需裁判:POST /api/runs/{id}/evidence/judge,点开哪句判哪句;判定作为 evidence.judged 事件追加在封存之后,
  标「封存后追加」,封存核对仍然一致;只读封存范围内的工件;同一句不重复判;运行在裁判期间被接着跑时不记录
- 正式运行调这个接口返回 409,写明「正式运行的裁判在节点内完成」

四、治理与自动修复

- G3 放开 judge:受管级别写 judge 必须写预算(judge.max_cost_usd,或显式写 null 表示不限)
- 发布前自动修复:缺预算时给选项(默认金额 / 不限),不替人选;
  从 require_citation 改成 judge 不算降低要求,judge 改回 off、withhold 放宽成 degrade 算降低要求
- Copilot 手册:受管模板可以写 judge 并带预算;问数据按需裁判,不替人打开结论句策略

五、界面

- 结论句句末放徽标:支持 ◆、部分支持 ◇、不支持 ! 整句浅底、未裁判 ?;不用表示「已核对」的绿色;n / N 也跳到不支持的句子
- 证据面板里「模型的解释」:带「模型判断 · 模型名 · 非确定」,封存后追加的另外标出;
  「请模型判断这句」只在探索运行、还没判过时出现,触顶时写明已到上限和怎么调
- 出具横幅多一段结论句计数;设置页新增「证据裁判」一组(裁判模型、各项上限、今日花费),设成不限时写明费用受什么约束;
  报告节点可以配 judge;运行轨迹里裁判相关的事件都有中文标题

六、门禁修正

- 引用模式下,图里没有口径卡(只引查询单元格)的,出具契约不再要求 metrics_from,受管门禁也不再要求 required:
  strict 加 cells 已经保证每个数都点得开、对不上就不予出具;有口径卡的照旧要求。
  以前问数据的图怎么都发不成受管,Copilot 补上契约也会被判「冒出新的问题」作废
This commit is contained in:
ThearchyHelios
2026-09-29 11:10:53 +08:00
parent d071bf119f
commit 9727c21d8d
47 changed files with 12367 additions and 263 deletions
+11 -1
View File
@@ -1,6 +1,6 @@
import type {
Approval, AutofixResult, Conversation, ConversationDetail, ConversationTurn, CustomTool, DataSource, EvidenceGraph,
EvidenceAudit, EvidenceSegmentDetail, GraphSpec, KbDocument, MemoryItem, Provider, PublishCheck, PublishLevel, ReviewResult, Run,
EvidenceAudit, EvidenceJudgeResult, EvidenceSegmentDetail, GraphSpec, KbDocument, MemoryItem, Provider, PublishCheck, PublishLevel, ReviewResult, Run,
RunEvent, RunStatus, Skill, ToolChange, ToolInfo, ToolTrust, ValidationIssue, VarIssue, Variable, Workflow,
WorkflowVersion,
} from '../types'
@@ -545,6 +545,9 @@ export const api = {
settings: {
get: () => get<Record<string, any>>('/settings'),
put: (values: Record<string, any>) => put<Record<string, any>>('/settings', { values }),
/** 今天(本地日期)结论句裁判花了多少:设置页放在每日上限旁边。只读,计数只有裁判自己记 */
judgeSpend: () => get<{ date?: string; usd?: number; calls?: number; unpriced_calls?: number
daily_max_usd?: number | null }>('/settings/judge/spend'),
},
// ---- 工具 ----
@@ -708,6 +711,13 @@ export const api = {
*/
audit: (runId: string, opts?: RequestOptions & { groups?: string[] }) =>
get<EvidenceAudit>(`/runs/${encodeURIComponent(runId)}/evidence/audit${qs({ groups: opts?.groups?.join(',') || undefined })}`, opts),
/**
* 按需裁判(四期,只在探索运行里有):请模型判断这几句结论。答复是 evidence.judged 事件的载荷
* (判定、花费、触顶的上限),判定标 post_seal:落在封存之后,不改封存的文档。正式运行回 409。
* 一次运行里有好几份报告时 report 给报告节点 id
*/
judge: (runId: string, body: { units: string[]; report?: string }) =>
post<EvidenceJudgeResult>(`/runs/${encodeURIComponent(runId)}/evidence/judge`, body),
/**
* 导出审计表:?format=json / csv 作为附件下载。回来的是文件本身和后端给的文件名
* (Content-Disposition;没给就用 evidence-<运行号前 8 位>.<格式>)
+160 -5
View File
@@ -8,11 +8,11 @@ import { fieldOfIssue, unboundToolOf, withToolBound, type FieldRef } from './iss
import { hintOf } from './shortcuts'
import { isActivePhase } from '../run/trace'
import { EDIT_LOCK_TEXT, useEditLock, useStudio } from '../store/studio'
import { datasourceTools, modelOptions, useCatalog, useDatasources } from '../store/catalog'
import { datasourceTools, modelOptions, providerOfModel, useCatalog, useDatasources } from '../store/catalog'
import { api } from '../api/client'
import { IconButton, JsonInput, Modal, isComposing, useRadioGroup } from '../components/ui'
import { formatShortcut } from '../lib/keys'
import { SUBGRAPH_UPGRADE_HELP, upgradeNewerText } from '../lib/terms'
import { JUDGE_FIELD_LABEL, JUDGE_ON_UNSUPPORTED_LABEL, JUDGE_SETTING_TEXT, SUBGRAPH_UPGRADE_HELP, judgeByModelText, upgradeNewerText } from '../lib/terms'
import { TemplateText } from './TemplateText'
import type { NodeType, ValidationIssue, WorkflowVersion } from '../types'
@@ -403,9 +403,10 @@ function Field({ field, nodeId, value, config, issues, onChange }: {
const id = `f-${nodeId}-${field.key}`
const syntax = syntaxOf(field)
const composite = field.type === 'cases' || field.type === 'agents' || field.type === 'ioFields'
|| field.type === 'metricsList' || field.type === 'caliberFrom'
// 复合字段自己把问题落到第几项;落不到具体某一项的,和普通字段一样挂在下面
const own = composite ? issues.filter((i) => i.at?.index == null) : issues
|| field.type === 'metricsList' || field.type === 'caliberFrom' || field.type === 'judge'
// 复合字段自己把问题落到第几项;落不到具体某一项的,和普通字段一样挂在下面。结论句裁判按子键(judge.max_cost_usd)落
const own = field.type === 'judge' ? issues.filter((i) => !i.at?.sub || !JUDGE_SUB_KEYS.has(i.at.sub))
: composite ? issues.filter((i) => i.at?.index == null) : issues
const bad = own.some((i) => i.level === 'error')
const errorId = own.length ? `${id}-issues` : undefined
// 长文本在 360px 宽的栏里没法写:提示词、代码可以展开到大编辑器里(同一份值,边写边存)
@@ -608,6 +609,9 @@ function FieldInput({ field, id, nodeId, syntax, value, config, invalid, describ
case 'caliberFrom':
return <CaliberFromPicker id={id} value={value} invalid={invalid} describedBy={describedBy} />
case 'judge':
return <JudgeConfig id={id} value={value} config={config} issues={issues} onChange={onChange} />
case 'agents':
return <AgentList value={value ?? []} issues={issues} config={config} onChange={onChange} />
@@ -616,6 +620,157 @@ function FieldInput({ field, id, nodeId, syntax, value, config, invalid, describ
}
}
/** judge 子配置里各有自己一行的键:落在这些键上的问题挂在那一行下面 */
const JUDGE_SUB_KEYS = new Set(['provider', 'model', 'max_claims', 'max_cost_usd', 'timeout_s', 'rewrite_once', 'on_unsupported'])
/**
* 结论句裁判(claims: judge)的子配置:裁判模型、三项上限(各自可以设成不限)、改写一次、证据不支持时怎么判档。
*
* 上限有三种写法,得分得清:没写(跟随设置里「证据裁判」的默认值)、写数、写 null(不限)。勾「不限」写 null,
* 清空数字框回到没写。全都没写时整个 judge 去掉,不留一个空对象。受管级别发布要求写了 max_cost_usd(数或 null)
*/
function JudgeConfig({ id, value, config, issues, onChange }: {
id: string; value: any; config: Record<string, any>; issues: FieldIssue[]; onChange: (v: any) => void
}) {
const providers = useCatalog((s) => s.providers)
const judge: Record<string, any> = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
const set = (key: string, v: unknown) => {
const next = { ...judge }
if (v === undefined) delete next[key]
else next[key] = v
onChange(Object.keys(next).length ? next : undefined)
}
const issuesOf = (key: string) => issues.filter((i) => i.at?.sub === key)
const lines = (key: string) => issuesOf(key).map((i, k) => <IssueLine key={k} issue={i} />)
const provider = typeof judge.provider === 'string' ? judge.provider : ''
const pv = provider ? providers.find((p) => p.name === provider) : undefined
const model = typeof judge.model === 'string' ? judge.model : ''
// 和写报告的模型是同一个:等于自己审自己。校验也会报(report.judge_same_model),报了就不重复说
const same = !!model && model === config.model && !issuesOf('model').length
// 节点上只写了模型:这一级写了就整组用节点上的(judge_model_spec 不跨级拼),后端按模型名找接入——
// 不再跟随设置,接入留空那一项不能还叫「跟随设置」
const byModel = !provider && !!model.trim()
const owner = byModel ? providerOfModel(providers, model.trim()) : undefined
const enabled = providers.filter((p) => p.enabled)
const fallback = enabled.find((p) => p.kind !== 'mock') ?? enabled[0]
const byModelHint = byModel && providers.length > 0
? judgeByModelText('node', model.trim(), owner && { name: owner.provider.name, enabled: owner.enabled }, fallback?.name)
: ''
const models = pv ? (pv.models ?? []).map((m) => m.id) : [...new Set(modelOptions(providers).map((o) => o.value))]
return (
<div className="space-y-2.5 rounded border px-2.5 py-2" data-judge-config="">
<div data-judge-key="model">
<div className="grid grid-cols-2 gap-2">
<div className="min-w-0">
<label className="label" htmlFor={`${id}-provider`}>{JUDGE_FIELD_LABEL.provider}</label>
<select id={`${id}-provider`} className="field" value={provider}
onChange={(e) => set('provider', e.target.value || undefined)}>
<option value="">{byModel ? JUDGE_SETTING_TEXT.providerFromModel : '跟随设置'}</option>
{provider && !pv && providers.length > 0 && <option value={provider}>{provider}(不存在)</option>}
{providers.map((p) => <option key={p.id} value={p.name}>{p.name}{p.enabled ? '' : '(已停用)'}</option>)}
</select>
</div>
<div className="min-w-0">
<label className="label" htmlFor={`${id}-model`}>{JUDGE_FIELD_LABEL.model}</label>
<input id={`${id}-model`} className="field mono text-xs" list={`${id}-models`} value={model} spellCheck={false}
placeholder={provider ? '留空:接入的默认模型' : '跟随设置'}
onChange={(e) => set('model', e.target.value.trim() ? e.target.value : undefined)} />
<datalist id={`${id}-models`}>{models.map((m) => <option key={m} value={m} />)}</datalist>
</div>
</div>
<div className="mt-1 text-2xs leading-snug text-faint">
接入和模型都没写就用设置里的「证据裁判模型」;写了哪一项就整组用节点上的,不和设置拼。建议和写报告的模型不同:同一个模型审自己写的,写错的地方它多半也看不出来
</div>
{byModelHint && (
<div className="mt-1 text-2xs leading-snug" data-judge-by-model={owner ? (owner.enabled ? 'found' : 'disabled') : 'default'}
style={owner?.enabled ? undefined : { color: 'var(--st-waiting)' }}>
<span className={owner?.enabled ? 'text-faint' : undefined}>{byModelHint}</span>
</div>
)}
{same && (
<div className="mt-1 text-2xs leading-snug" style={{ color: 'var(--st-waiting)' }} data-judge-same="">
裁判模型和写报告的模型都是「{model}」:等于自己审自己
</div>
)}
{lines('provider')}{lines('model')}
</div>
{(['max_claims', 'max_cost_usd', 'timeout_s'] as const).map((key) => (
<JudgeLimit key={key} id={`${id}-${key}`} name={key} value={judge[key]} issues={lines(key)}
onChange={(v) => set(key, v)} />
))}
<div data-judge-key="rewrite_once">
<label className="flex cursor-pointer items-start gap-2 text-xs">
<input type="checkbox" className="mt-0.5 accent-[var(--accent)]" checked={judge.rewrite_once === true}
onChange={(e) => set('rewrite_once', e.target.checked ? true : undefined)} />
<span>
{JUDGE_FIELD_LABEL.rewrite_once}
<span className="mt-0.5 block text-2xs leading-snug text-faint">
默认关。打开后裁判认为证据不支持的句子连同理由交回写作者只改这几句,再判一次,最多一轮;多一次写作和裁判的花费,
改写稿冒出原稿没有的问题就不采用
</span>
</span>
</label>
{lines('rewrite_once')}
</div>
<div data-judge-key="on_unsupported">
<label className="label" htmlFor={`${id}-on_unsupported`}>{JUDGE_FIELD_LABEL.on_unsupported}</label>
<select id={`${id}-on_unsupported`} className="field" value={judge.on_unsupported ?? ''}
onChange={(e) => set('on_unsupported', e.target.value || undefined)}>
<option value="">默认:{JUDGE_ON_UNSUPPORTED_LABEL.degrade}</option>
<option value="degrade">{JUDGE_ON_UNSUPPORTED_LABEL.degrade}(degrade)</option>
<option value="withhold">{JUDGE_ON_UNSUPPORTED_LABEL.withhold}(withhold)</option>
{typeof judge.on_unsupported === 'string' && judge.on_unsupported
&& !(judge.on_unsupported in JUDGE_ON_UNSUPPORTED_LABEL) && (
<option value={judge.on_unsupported} disabled>{judge.on_unsupported}:不认识的值,只能选上面两项</option>
)}
</select>
<div className="mt-1 text-2xs leading-snug text-faint">只管正式运行;探索运行只标注,不拦</div>
{lines('on_unsupported')}
</div>
</div>
)
}
/** 一项裁判上限:数字框加「不限」。没写、写数、写 null(不限)三种各有一句说明 */
function JudgeLimit({ id, name, value, issues, onChange }: {
id: string; name: 'max_claims' | 'max_cost_usd' | 'timeout_s'; value: unknown; issues: React.ReactNode
onChange: (v: number | null | undefined) => void
}) {
const off = value === null
const shown = typeof value === 'number' || typeof value === 'string' ? String(value) : ''
const note = off ? JUDGE_LIMIT_OFF[name]
: value === undefined ? '没写:用设置里「证据裁判」的默认值' : ''
return (
<div data-judge-key={name} data-judge-unlimited={off ? '' : undefined}>
<label className="label" htmlFor={id}>{JUDGE_FIELD_LABEL[name]}</label>
<div className="flex items-center gap-2">
<input id={id} className="field tnum min-w-0 flex-1" type="number" min={0} step={name === 'max_claims' ? 1 : 'any'}
value={off ? '' : shown} disabled={off} placeholder={off ? '不限' : '跟随设置'}
onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))} />
<label className="flex shrink-0 cursor-pointer items-center gap-1 text-xs">
<input type="checkbox" className="accent-[var(--accent)]" checked={off} aria-label={`${JUDGE_FIELD_LABEL[name]}:不限`}
onChange={(e) => onChange(e.target.checked ? null : undefined)} />
不限
</label>
</div>
{note && (
<div className="mt-1 text-2xs leading-snug" style={off ? { color: 'var(--st-waiting)' } : undefined}
data-judge-note={off ? 'unlimited' : 'default'}>
<span className={off ? undefined : 'text-faint'}>{note}</span>
</div>
)}
{issues}
</div>
)
}
/** 节点上某项设成不限时写明:费用还受什么约束(同设置页,节点上的只说这一份报告) */
const JUDGE_LIMIT_OFF: Record<'max_claims' | 'max_cost_usd' | 'timeout_s', string> = {
max_claims: '不设上限,句数只受报告里结论句多少约束',
max_cost_usd: '不设上限,这份报告的裁判费用只受句数、时长上限和每日上限约束',
timeout_s: '不设上限,时长只受模型接口自身的超时约束',
}
/**
* 值里能写 {{ }} 的 JSON(工具参数、子工作流入参、出具契约)。
*
+12 -2
View File
@@ -16,7 +16,7 @@ import { matchedSource } from '../lib/evidence'
import { explainRunError } from '../lib/explain'
import { formatDuration, formatLapse, formatNumber, formatTokens, NONE, shortId } from '../lib/format'
import { statusMeta } from '../lib/status'
import { issuanceLabel, reportStampText, RUN_CLASS_LABEL } from '../lib/terms'
import { claimTally, issuanceLabel, reportStampText, RUN_CLASS_LABEL } from '../lib/terms'
import { useReportStamp, type ReportStamp } from './reportStamp'
import { ApprovalCard } from '../run/RunPanel'
import type { NodeState, NodeTrace } from '../run/trace'
@@ -188,6 +188,13 @@ function matchedLines(matched: unknown): string {
return `回指明细:\n${lines.join('\n')}${more}`
}
/** stats 里的裁判句数(claims 为 judge 时才有)→「结论 4 句(支持 1 · 不支持 1 · 未裁判 2)」 */
function claimsLine(st: ReportStamp['stats']): string {
const n = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
const c = { supported: n(st.supported), partial: n(st.partial), unsupported: n(st.unsupported), unjudged: n(st.unjudged), uncited: 0 }
return claimTally({ ...c, total: c.supported + c.partial + c.unsupported + c.unjudged })
}
/** 报告节点章的悬停说明:两个数怎么来的,缺口里各有什么 */
function reportStampTitle(r: ReportStamp): string {
const st = r.stats
@@ -200,7 +207,10 @@ function reportStampTitle(r: ReportStamp): string {
n(st.unresolved) ? `引用解析不了 ${formatNumber(n(st.unresolved))} 处` : '',
n(st.unknown_entities) ? `可能是编造的名字 ${formatNumber(n(st.unknown_entities))} 个` : '',
n(st.unverified_entities) ? `核对不了的名字 ${formatNumber(n(st.unverified_entities))} 个(只标注,不计入)` : '',
uncited ? `没挂依据的结论句 ${formatNumber(uncited)} 句${r.claims === 'require_citation' ? '(计入缺口)' : '(结论句策略不要求,不计入)'}` : '',
uncited ? `没挂依据的结论句 ${formatNumber(uncited)} 句${r.claims === 'require_citation' || r.claims === 'judge'
? '(计入缺口)' : '(结论句策略不要求,不计入)'}` : '',
// 结论句裁判(四期):模型的判断,不计入章上的两个数
r.claims === 'judge' && claimsLine(st) ? `${claimsLine(st)}(模型判断,非确定,不计入)` : '',
'完整清单在记录页这次运行的「证据」页签',
].filter(Boolean).join('\n')
}
+34 -3
View File
@@ -11,7 +11,7 @@
* 那一栏,定位落到工具上(见 toolBindingOf)。
*/
import { ApiError } from '../api/client'
import { APPROVAL_POLICY_LABEL } from '../lib/terms'
import { APPROVAL_POLICY_LABEL, JUDGE_FIELD_LABEL, JUDGE_ON_UNSUPPORTED_LABEL } from '../lib/terms'
import { NODE_DEFS } from './nodeDefs'
import type { FlowNode } from '../store/studio'
import type { AutofixResult, GraphSpec, PublishCheck, PublishFix, PublishLevel, ValidationIssue } from '../types'
@@ -370,14 +370,33 @@ export type NodeNameOf = (id: string) => string | undefined
const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
/** 修复改的是哪个字段:顶层键用节点定义里的叫法,契约里的键另有一张表,其余原样 */
/** 修复改的是哪个字段:顶层键用节点定义里的叫法,契约里的键另有一张表,结论句裁判(judge.*)另一张,其余原样 */
export function fixFieldLabel(field: string | null | undefined, nodeType?: string): string {
if (!field) return ''
const [head, ...rest] = field.split('.')
if (head === 'label') return '节点名称'
const def = nodeType ? NODE_DEFS[nodeType as keyof typeof NODE_DEFS] : undefined
const base = def?.fields.find((f) => f.key === head)?.label ?? EXTRA_FIELDS[head] ?? head
return [base, ...rest.map((k) => SUB_KEYS[k] ?? k)].join(' · ')
const sub = head === 'judge' ? JUDGE_FIELD_LABEL : SUB_KEYS
return [base, ...rest.map((k) => sub[k] ?? k)].join(' · ')
}
/** judge 里写 null 表示不限的三项上限:预览里 null 写「不限」,不写「(空)」——没写和不限是两回事 */
const JUDGE_LIMITS = new Set(['max_claims', 'max_cost_usd', 'timeout_s'])
/** judge.* 一项的值怎么写:上限带单位,null 是不限,判档写选项文字。不是 judge 的键返回 null */
function judgeValueText(value: unknown, key: string): string | null {
if (JUDGE_LIMITS.has(key)) {
if (value === null) return '不限'
if (typeof value === 'number' && Number.isFinite(value)) {
return key === 'max_cost_usd' ? `$${value}` : key === 'timeout_s' ? `${value} 秒` : `${value} 句`
}
return null
}
if (key === 'on_unsupported' && typeof value === 'string' && value in JUDGE_ON_UNSUPPORTED_LABEL) {
return JUDGE_ON_UNSUPPORTED_LABEL[value]
}
return null
}
/**
@@ -386,6 +405,10 @@ export function fixFieldLabel(field: string | null | undefined, nodeType?: strin
* 其余压成一行
*/
export function fixValueText(value: unknown, field?: string | null, nameOf?: NodeNameOf): string {
if (field?.startsWith('judge.')) {
const said = judgeValueText(value, field.slice(6))
if (said != null) return said
}
if (value === undefined || value === null || value === '' || (Array.isArray(value) && !value.length)
|| (isRecord(value) && !Object.keys(value).length)) return '(空)'
const key = field?.split('.').pop()
@@ -414,6 +437,14 @@ export function fixValueText(value: unknown, field?: string | null, nameOf?: Nod
* 不是整份契约的返回 null
*/
export function fixValueLines(value: unknown, field?: string | null, nameOf?: NodeNameOf): string[] | null {
// 整份结论句裁判(补预算那条修复按整份 judge 记):一键一行,「金额上限(美元):不限」
if (field === 'judge' && isRecord(value) && Object.keys(value).length) {
const order = Object.keys(JUDGE_FIELD_LABEL)
return Object.keys(value).sort((a, b) => {
const [x, y] = [order.indexOf(a), order.indexOf(b)]
return (x < 0 ? order.length : x) - (y < 0 ? order.length : y)
}).map((k) => `${JUDGE_FIELD_LABEL[k] ?? k}:${fixValueText(value[k], `judge.${k}`, nameOf)}`)
}
if (field !== 'contract' || !isRecord(value) || !Object.keys(value).length) return null
const order = Object.keys(CONTRACT_KEYS)
const keys = Object.keys(value).sort((a, b) => {
+13 -6
View File
@@ -8,7 +8,7 @@ import type { NodeType } from '../types'
export type FieldType =
| 'text' | 'textarea' | 'prompt' | 'code' | 'number' | 'select' | 'switch'
| 'json' | 'model' | 'tools' | 'skills' | 'collection'
| 'ioFields' | 'cases' | 'agents' | 'metricsList' | 'nodeRefs' | 'caliberFrom'
| 'ioFields' | 'cases' | 'agents' | 'metricsList' | 'nodeRefs' | 'caliberFrom' | 'judge'
/**
* 这个字段里写的是什么语法。
@@ -564,17 +564,24 @@ export const NODE_DEFS: Record<NodeType, NodeDef> = {
help: '有违规时把清单交回去让模型重写。每次都是一整篇的调用,写不对的模型多给几次也大多写不对',
},
{
// 后端只认 off / require_citation(schema.REPORT_CLAIMS);judge 是四期的模型裁判,写了会被校验拦下。
// 没写等于 off:select 显示第一项,正好是默认
// 后端认 off / require_citation / judge(schema.CLAIMS_VALUES)。没写等于 off:select 显示第一项,正好是默认
key: 'claims', label: '没挂依据的结论句', type: 'select',
options: [
{ value: 'off', label: '不管(默认):不参与出具判档' },
{ value: 'require_citation', label: '计入缺口:出具按档位降档' },
{ value: 'judge', label: '计入缺口,并请模型逐句判断证据支不支持' },
],
help: '结论句是带数字、方向词或因果词的句子。选「计入缺口」时,写作提示要求每句结论挂上 [[see:…]] 依据,'
+ '没挂的记为缺口、出具降档(系统自动链接的表名字段名不算依据)。受管级别发布必须显式选它。'
+ '结论句裁判在后续版本支持',
unknownLabel: (v) => (v === 'judge' ? 'judge:结论句裁判在后续版本支持,现在不能用' : `${v}:不认识的值,只能选上面两项`),
+ '没挂的记为缺口、出具降档(系统自动链接的表名字段名不算依据)。选「请模型逐句判断」时,另一个模型按证据'
+ '判断每句结论:正式运行里当场判、结果随报告封存,探索运行点开哪句判哪句;判断是模型给的,不是系统核对。'
+ '受管级别发布必须显式选后两项之一,选模型判断的还要在高级选项里写金额上限(或写不限)',
unknownLabel: (v) => `${v}:不认识的值,只能选上面三项`,
},
{
// claims 为 judge 时的子配置(schema.JUDGE_KEYS)。没写的上限用设置里「证据裁判」的默认值;写 null 是不限
key: 'judge', label: '结论句裁判', type: 'judge', advanced: true,
when: (c) => c.claims === 'judge' || (!!c.judge && typeof c.judge === 'object'),
help: '没写的上限用设置里「证据裁判」的默认值;勾「不限」写 null,只受其余上限约束。裁判模型建议和写报告的模型不同',
},
{
// 后端只认 link / off(report.py 的 ENTITIES)
+19
View File
@@ -11,6 +11,7 @@ import type { RunEvent } from '../types'
import fixtures from '../run/__tests__/fixtures.json'
import mdSamples from '../run/__tests__/markdown-samples.json'
import evidenceFixture from '../run/__tests__/evidence-doc.json'
import evidenceJudge from '../run/__tests__/evidence-judge.json'
import {
CALIBER_ONE_OUTPUT, CALIBERS_OUTPUT, COPILOT_STUCK, COPILOT_TOOLS_DROPPED, MIXED_OUTPUT, abandonedRun, cancelledRun, exhaustedTeam, longLoop,
markupRun, mixedRun, pipelineRun, repairRun, teamRun, timeoutRun,
@@ -234,11 +235,29 @@ function evidenceTurns(): StreamTurn[] {
]
}
/**
* 四期:结论句裁判走问数据页、画布右栏同一条路(Output → EvidenceField → 出具横幅)。
* - judge-plain:探索运行、报告没开裁判(require_citation)。点开结论句请模型判断之后,横幅得多出「结论 N 句」那一段;
* - judge-formal:正式运行、报告 claims=judge,节点里判好了。没挂依据的那句送了裁判(到上限没判),出具照样按没挂依据记缺口。
* 报告工件、片段接口、按需裁判的 POST 由 check-evidence 用 page.route 伪造(夹具 evidence-judge.json)
*/
function evidenceJudgeTurns(): StreamTurn[] {
const { plain, formal } = evidenceJudge
return [
{ id: 'judge-plain', question: '写一份本周周报', phase: 'done', status: '已完成', runClass: 'exploratory',
steps: [], output: plain.output, runId: plain.run_id },
{ id: 'judge-formal', question: '写一份本周周报(受管模板)', phase: 'done', status: '已完成', runClass: 'formal',
steps: [], output: formal.output, runId: formal.run_id },
]
}
/** 合成场景:新后端才有的事件,老库里导不出来 */
function synthetic(name: string): StreamTurn[] {
switch (name) {
case 'evidence':
return evidenceTurns()
case 'evidence-judge':
return evidenceJudgeTurns()
case 'team-live': {
const ev = rebase(teamRun('members'))
return [{ id: 'team-live', question: '比较三家供应商的交期风险', phase: 'running', status: '运行中',
+60
View File
@@ -27,6 +27,7 @@ import { Markdown } from '../run/Markdown'
import evidenceFixture from '../run/__tests__/evidence-doc.json'
import evidenceQuery from '../run/__tests__/evidence-query.json'
import evidenceEntity from '../run/__tests__/evidence-entity.json'
import evidenceJudge from '../run/__tests__/evidence-judge.json'
import { EvidenceGuessView } from '../run/EvidenceGuess'
import { IssuanceBanner } from '../run/AssistantStream'
import mdPinned from '../run/__tests__/markdown-pinned.json'
@@ -194,6 +195,10 @@ const EV_QUERY = evidenceQuery.doc as unknown as EvidenceDocData
const EV_LEGACY = evidenceFixture.legacy
/** 第三期:表名字段名、可疑实体、核对不了的名字、逐字引文(evidence-entity.json,compose_doc 真跑出来的) */
const EV_ENTITY = evidenceEntity.doc as unknown as EvidenceDocData
/** 第四期:结论句裁判。正式运行节点里判好的(inline)、探索运行按需判的(on_demand)、没开裁判的探索运行 */
const EV_JUDGE = evidenceJudge.formal.doc as unknown as EvidenceDocData
const EV_JUDGE_EXPLORE = evidenceJudge.explore.doc as unknown as EvidenceDocData
const EV_JUDGE_PLAIN = evidenceJudge.plain.doc as unknown as EvidenceDocData
/** 同一份旧契约的出具,去掉位置:老运行就是这样,只能按字符串标 */
const LEGACY_LOOSE = {
matched: EV_LEGACY.matched.map(({ start: _s, end: _e, ...m }) => m),
@@ -306,6 +311,60 @@ function EvidenceOnly() {
))}
</div>
</div>
{/*
第四期:结论句裁判。片段接口、按需裁判接口由 check-evidence 用 page.route 伪造(按 runId 认出夹具)。
探索运行没开裁判的那份不给运行类别:面板按运行 id 取一次
*/}
<div className="flex flex-col gap-4" id="evidence-judge-demo">
<div id="evidence-judge" className="rounded-lg border p-3">
<div className="mb-1 text-2xs text-faint">正式运行 · 节点里当场判的结论句(进封存)</div>
<EvidenceDoc doc={EV_JUDGE} artifact={evidenceJudge.formal.doc_artifact} runId={evidenceJudge.formal.run_id}
runClass="formal" label="answer" tally />
</div>
<div id="evidence-judge-narrow" className="rounded-lg border bg-panel p-2.5" style={{ width: 360, maxWidth: '100%' }}>
<EvidenceDoc doc={EV_JUDGE} artifact={evidenceJudge.formal.doc_artifact} runId={evidenceJudge.formal.run_id}
runClass="formal" label="answer" dense tally />
</div>
<div id="evidence-judge-explore" className="rounded-lg border p-3">
<div className="mb-1 text-2xs text-faint">探索运行 · 按需裁判(点开哪句判哪句,封存后追加)</div>
<EvidenceDoc doc={EV_JUDGE_EXPLORE} artifact={evidenceJudge.explore.doc_artifact} runId={evidenceJudge.explore.run_id}
label="answer" tally />
</div>
<div id="evidence-judge-explore-narrow" className="rounded-lg border bg-panel p-2.5" style={{ width: 360, maxWidth: '100%' }}>
<EvidenceDoc doc={EV_JUDGE_EXPLORE} artifact={evidenceJudge.explore.doc_artifact} runId={evidenceJudge.explore.run_id}
label="answer" dense tally />
</div>
<div id="evidence-judge-plain" className="rounded-lg border p-3">
<div className="mb-1 text-2xs text-faint">探索运行 · 没开裁判的报告(照样能点开结论句请模型判断)</div>
<EvidenceDoc doc={EV_JUDGE_PLAIN} artifact={evidenceJudge.plain.doc_artifact} runId={evidenceJudge.plain.run_id}
label="answer" tally />
</div>
{/* 出具横幅那一行多一段结论句:「结论 4 句(支持 1 · 部分支持 1 · 不支持 1 · 未裁判 1)」 */}
<div id="issuance-claims" className="flex flex-col gap-2 rounded-lg border p-3">
{/*
judge-uncited:开了裁判的文档,没挂依据的那句送了裁判、按判定数在「支持 3」里(或预筛放掉了不数),
出具(io.py)照样按没挂依据记 1 句——横幅得另起「没挂依据的结论句 1」,不能被结论句那一段吞掉
*/}
{([
['judged', { total: 4, supported: 1, partial: 1, unsupported: 1, unjudged: 1, uncited: 0 }, null],
['cited', { total: 4, supported: 3, partial: 0, unsupported: 0, unjudged: 0, uncited: 1 }, null],
['all', { total: 2, supported: 2, partial: 0, unsupported: 0, unjudged: 0, uncited: 0 }, null],
['judge-uncited', { total: 3, supported: 3, partial: 0, unsupported: 0, unjudged: 0, uncited: 0 },
{ policy: 'judge', uncited_claims: 1 }],
] as const).map(([key, claims, issued]) => (
<div key={key} data-issuance-case={key}>
<IssuanceBanner
issuance={{ tier: 'degraded', matched_numbers: 3, metrics_checked: 0,
claims: issued ?? { policy: 'require_citation', uncited_claims: claims.uncited } }}
evidence={{
counts: { total: 3, cited: 3, none: 0, other: 0, hidden: 0, structural: 0, noSegment: 0, suspect: 0,
unverified: 0, claims },
onNext: () => { const w = window as any; w.__claimsNext = (w.__claimsNext ?? 0) + 1 },
}} />
</div>
))}
</div>
</div>
<div id="evidence-long" className="rounded-lg border p-3">
<div className="mb-1 text-2xs text-faint">长报告:{EV_LONG.blocks.length} 块,块级 content-visibility,超过 3000 字先折叠</div>
<EvidenceDoc doc={EV_LONG} label="长报告" tally />
@@ -599,6 +658,7 @@ function Harness() {
evidenceFixture,
evidenceQuery,
evidenceEntity,
evidenceJudge,
}
createRoot(document.getElementById('root')!).render(
+33
View File
@@ -1639,6 +1639,39 @@
vertical-align: 1px;
color: var(--st-waiting);
}
/* 结论句的判定(四期):句末上标的小字形,是按钮但不像按钮。颜色随判定(组件写成 --ev-glyph),
绝不用确定性的绿;整句的字不画线——判定是模型给的,不能长得像系统核对过的数字 */
.ev-badge {
display: inline-block;
margin: 0 1px 0 2px;
padding: 0 3px;
border: 0;
border-radius: 3px;
background: none;
font: inherit;
font-size: 0.72em;
font-weight: 700;
line-height: 1;
vertical-align: super;
color: var(--ev-glyph, currentColor);
text-decoration: none;
cursor: pointer;
}
.ev-badge:hover,
.ev-badge[aria-expanded="true"] { background: var(--ev-soft, transparent); }
.ev-badge:focus-visible,
.ev-badge[data-flash] {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* 证据不支持:整句铺一层浅底,折行时每一行各自带圆角;不画线、不改字色 */
.ev-claim {
background: var(--ev-soft, transparent);
border-radius: 2px;
text-decoration: none;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}
.ev-panel-side { animation: sheet-in var(--dur-base) var(--ease-out); }
.ev-panel-rise { animation: fade-up var(--dur-base) var(--ease-out); }
+186 -9
View File
@@ -11,17 +11,22 @@
* 三期多了表名字段名、逐字引文:有出处的实体、引文和数字同一套「有出处」线型(状态就是
* 确定性,另有 EVIDENCE_KIND_STYLE 给它们各自的字形和说法);可疑实体(可能是编造的
* 名字)和核对不了的名字是无证据的两个变体,线型同无证据、字形和文字各不相同;旧运行
* 按数值猜的候选用最淡的点状线。概率性的几种先把外观定下,四期接上时不用再各处补。
* 按数值猜的候选用最淡的点状线。
*
* 四期接上了概率性的几种:结论句的判定是模型给的,只在句末挂一枚小徽标(◆ 有依据、◇ 部分有依据、
* ! 证据不支持且整句浅底、? 未裁判),整句的字不画线。判定有两个来源:封存的文档里节点当场判的,
* 和探索运行里点开再判、封存之后追加的(后者盖过前者,另写「封存后追加」)。
*/
import {
EVIDENCE_AUDIT_TEXT, EVIDENCE_KIND_LABEL, EVIDENCE_STATE_LABEL, EVIDENCE_TEXT, UPGRADE_POLICY_LABEL, evidenceTally,
EVIDENCE_AUDIT_TEXT, EVIDENCE_KIND_LABEL, EVIDENCE_STATE_LABEL, EVIDENCE_TEXT, JUDGE_TEXT, UPGRADE_POLICY_LABEL,
claimTally, evidenceTally, type ClaimTallyCounts,
} from './terms'
import { NONE, formatNumber, shortId } from './format'
import type {
EvidenceBlock, EvidenceCaliberSource, EvidenceCaliberUpgrade, EvidenceCandidate, EvidenceDocData, EvidenceFieldRef,
EvidenceGraph, EvidenceGuess, EvidenceInput, EvidenceLocator, EvidenceSeal, EvidenceSegment, EvidenceSegmentDetail,
EvidenceStats, EvidenceStep, EvidenceUnit, EvidenceViolation, ReviewResult,
EvidenceStats, EvidenceStep, EvidenceUnit, EvidenceVerdict, EvidenceViolation, ReviewResult,
} from '../types'
import type { MarkSpec } from '../run/Markdown'
@@ -85,7 +90,7 @@ export const EVIDENCE_STATE: Record<EvidenceStateCode, EvidenceStateMeta> = {
code: 'unjudged', label: EVIDENCE_STATE_LABEL.unjudged, line: 'badge', glyph: '?',
color: 'var(--st-cancelled)', decoration: 'transparent', soft: 'var(--st-cancelled-soft)',
alert: false, phase: 4,
hint: '这句话带了引用,还没请模型判断引用支不支持它',
hint: '还没请模型判断证据支不支持这句话:探索运行点开再判,或者到了上限没判',
},
none: {
code: 'none', label: EVIDENCE_STATE_LABEL.none, line: 'dotted', glyph: '?',
@@ -452,6 +457,8 @@ export function locatable(doc: EvidenceDocData, v: EvidenceViolation): EvidenceS
export interface EvidenceTally {
/** 数字总数:带引用的数字片段 + 裸数字(含列表序号、代码块标签里的) */
total: number
/** 四期:结论句按判定分的句数。文档没开裁判、也没有按需判过的句子时是 null(横幅不多说这一段) */
claims?: ClaimTallyCounts | null
/** 可疑实体(可能是编造的名字)、核对不了的名字:数字那半句之外另说 */
suspect?: number
unverified?: number
@@ -504,7 +511,7 @@ export function docTally(doc: EvidenceDocData): EvidenceTally {
const suspect = num(st.unknown_entities) ?? all.filter((v) => v.code === 'unknown_entity').length
const unverified = num(st.unverified_entities) ?? all.filter((v) => v.code === 'unverified_entity').length
return { total, cited, none, other, hidden: hidden.length, structural, noSegment: hidden.length - structural,
suspect, unverified }
suspect, unverified, claims: claimCounts(doc) }
}
/**
@@ -522,15 +529,16 @@ export function statsTally(stats: EvidenceDocData['stats'] | null | undefined):
return { total, cited, none, other }
}
/** 读屏摘要:计数、画不了线的两种各在哪、按键说明 */
/** 读屏摘要:计数、结论句的判定、画不了线的两种各在哪、按键说明 */
export function tallySummary(t: EvidenceTally, keys: boolean): string {
return [
evidenceTally(t.cited, t.total, t.other),
t.claims?.total ? `${claimTally(t.claims)}(模型判断,非确定)` : '',
t.suspect ? `${formatNumber(t.suspect)} 个${EVIDENCE_STATE.suspect.label}` : '',
t.unverified ? `${formatNumber(t.unverified)} 个名字${EVIDENCE_STATE.unverified.label}` : '',
t.structural ? EVIDENCE_TEXT.structuralCount(t.structural) : '',
t.noSegment ? EVIDENCE_TEXT.noSegmentCount(t.noSegment) : '',
keys ? EVIDENCE_TEXT.keysHint : '',
keys ? (t.claims?.unsupported || t.claims?.partial ? JUDGE_TEXT.keysHint : EVIDENCE_TEXT.keysHint) : '',
].filter(Boolean).join('。')
}
@@ -812,7 +820,8 @@ export function evidenceTrace(
/**
* 章上的两个数:「引用 N」是有出处的片段(数字、值、表名字段名、引文),「无证据 M」是没出处的
* 数字、解析不了的引用、可疑实体,结论句策略为 require_citation 时再加上没挂依据的结论句。
* 数字、解析不了的引用、可疑实体,结论句策略为 require_citation 或 judge 时再加上没挂依据的结论句
* (judge 在挂依据这件事上和 require_citation 一样严,出口同样计入缺口)。
* 统计缺了数字那两项(别的版本的后端)返回 null,不画章
*/
export function stampCounts(stats: EvidenceStats | null | undefined, claims?: string | null):
@@ -821,10 +830,178 @@ export function stampCounts(stats: EvidenceStats | null | undefined, claims?: st
if (!t) return null
const cited = t.cited + (num(stats?.values) ?? 0) + (num(stats?.entities) ?? 0) + (num(stats?.quotes) ?? 0)
const none = t.none + t.other + (num(stats?.unknown_entities) ?? 0)
+ (claims === 'require_citation' ? num(stats?.uncited_claims) ?? 0 : 0)
+ (claims === 'require_citation' || claims === 'judge' ? num(stats?.uncited_claims) ?? 0 : 0)
return { cited, none }
}
// -------------------------------------------------------------------------
// 四期:结论句裁判
// -------------------------------------------------------------------------
/** 判过了的四种(不含未裁判):同后端 judge.JUDGED。判过的不再给「请模型判断这句」 */
const JUDGED = new Set(['supported', 'partial', 'unsupported', 'not_a_claim'])
/** 触顶的四个上限:同后端 judge.LIMITS,这几种没判的写「已到上限」和怎么调 */
export const JUDGE_LIMITS: readonly string[] = ['max_claims', 'max_cost_usd', 'daily_max_usd', 'timeout_s']
export const isJudged = (v: Pick<EvidenceVerdict, 'status'> | null | undefined): boolean => !!v && JUDGED.has(v.status)
/** 没判的原因是不是上限,是的话返回是哪个 */
export const limitOf = (v: Pick<EvidenceVerdict, 'status' | 'reason'> | null | undefined): string | null =>
v?.status === 'unjudged' && v.reason && JUDGE_LIMITS.includes(v.reason) ? v.reason : null
/**
* 判定在句末画成哪种徽标。「不是结论句」不挂:它不需要证据,挂个记号只添噪音(面板里照样写明模型的判断);
* 认不出的判定也不挂——宁可不画,不能把不知道的东西画成某种结论
*/
export function verdictState(v: Pick<EvidenceVerdict, 'status'> | null | undefined): EvidenceStateCode | null {
switch (v?.status) {
case 'supported': return 'supported'
case 'partial': return 'partial'
case 'unsupported': return 'unsupported'
case 'unjudged': return 'unjudged'
default: return null
}
}
/** 一句的判定:封存之后按需追加的盖过文档里的(探索运行里文档记的是「未裁判 · 按需」) */
export function unitVerdict(
unit: Pick<EvidenceUnit, 'id' | 'verdict'>, overlay?: Record<string, EvidenceVerdict> | null,
): EvidenceVerdict | undefined {
return overlay?.[unit.id] ?? unit.verdict ?? undefined
}
/** 这一句能不能请模型判断:结论句、不在表格和代码块里(同后端 candidates(units=…) 的条件) */
export const judgeable = (unit: Pick<EvidenceUnit, 'kind'>, block?: Pick<EvidenceBlock, 'type'>): boolean =>
unit.kind === 'claim' && !['table', 'code', 'heading'].includes(block?.type ?? '')
/**
* 结论句的计数(出具横幅、证据条)。文档开了裁判(有 judge 摘要)时按候选句的判定数,预筛放掉的、
* 表格里的不算;没开裁判的文档只在有按需判过的句子时才数,没判的按有没有挂依据分成「未裁判」「无证据」。
* 模型判为「不是结论句」的不算进总数。什么判定都没有返回 null:升级前的文档、没开裁判也没点过的,横幅照旧。
*
* 「判过」认的是句子身上带着的判定,不只认 overlay:横幅拿到的是 withVerdicts 叠好的文档(判定已经写进
* unit.verdict,没再单独给 overlay)。没开裁判的封存文档本身从不带判定,带着的只能是封存后追加的
*/
export function claimCounts(
doc: Pick<EvidenceDocData, 'blocks' | 'judge'>, overlay?: Record<string, EvidenceVerdict> | null,
): ClaimTallyCounts | null {
const judged = !!doc.judge && typeof doc.judge === 'object'
const c: ClaimTallyCounts = { total: 0, supported: 0, partial: 0, unsupported: 0, unjudged: 0, uncited: 0 }
let seen = judged
for (const block of doc.blocks ?? []) {
for (const unit of block.units ?? []) {
const v = unitVerdict(unit, overlay)
if (v) seen = true
if (v) {
if (v.status === 'supported' || v.status === 'partial' || v.status === 'unsupported' || v.status === 'unjudged') {
c[v.status] += 1
c.total += 1
}
continue
}
if (judged || !judgeable(unit, block)) continue
if (unit.cites?.length) c.unjudged += 1
else c.uncited += 1
c.total += 1
}
}
return seen ? c : null
}
/** 把封存之后追加的判定叠到文档上(只换有判定的那几句,别的块原样共用)。给横幅计数用,不写回封存的文档 */
export function withVerdicts<T extends Pick<EvidenceDocData, 'blocks'>>(
doc: T, overlay: Record<string, EvidenceVerdict> | null | undefined,
): T {
if (!overlay || !Object.keys(overlay).length) return doc
let touched = false
const blocks = (doc.blocks ?? []).map((b) => {
if (!(b.units ?? []).some((u) => overlay[u.id])) return b
touched = true
return { ...b, units: b.units.map((u) => (overlay[u.id] ? { ...u, verdict: overlay[u.id] } : u)) }
})
return touched ? { ...doc, blocks } : doc
}
/** 句子太长时截一截,给 aria-label 和面板标题用 */
const clip = (text: string, n = 40) => (text.length > n ? `${text.slice(0, n - 1)}…` : text)
/**
* 句末徽标的 aria-label:句子、判定都写全,封存后追加的另说。「结论句「增长主要来自新客首单…」,
* 模型判断:证据不支持」;到上限没判的说「未裁判:已到上限」
*/
export function claimLabel(unit: Pick<EvidenceUnit, 'segments'>, v: EvidenceVerdict): string {
const state = verdictState(v)
const said = state ? EVIDENCE_STATE[state].label : JUDGE_TEXT.notClaim
const why = limitOf(v) ? `${said}:${JUDGE_TEXT.limit}` : said
return JUDGE_TEXT.badgeLabel(clip(unitText(unit).trim()), why, !!v.post_seal)
}
/** 一条判定认成前端的形状:status 缺了(或者写在 verdict 里)的认 verdict,缺 judge 的补上答复里的模型 */
function asVerdict(v: unknown, fallback: { model?: unknown; postSeal: boolean }): EvidenceVerdict | null {
if (!v || typeof v !== 'object') return null
const o = v as Record<string, unknown>
const status = typeof o.status === 'string' ? o.status : typeof o.verdict === 'string' ? o.verdict : null
if (!status) return null
return {
...(o as object), status,
rationale: typeof o.rationale === 'string' ? o.rationale : undefined,
judge: typeof o.judge === 'string' ? o.judge : typeof fallback.model === 'string' ? fallback.model : null,
post_seal: typeof o.post_seal === 'boolean' ? o.post_seal : fallback.postSeal,
used: Array.isArray(o.used) ? o.used.map(String) : undefined,
reason: typeof o.reason === 'string' ? o.reason : undefined,
}
}
/**
* 按需裁判接口的答复 → {unit: 判定}。认 {verdicts: {u4: …}}(evidence.judged 的载荷)和
* {verdicts: [{unit: 'u4', …}]};外层没有 verdicts 时认 units。按需判的都是封存后追加的:缺 post_seal 的记 true
*/
export function judgedVerdicts(body: unknown): Record<string, EvidenceVerdict> {
if (!body || typeof body !== 'object') return {}
const b = body as Record<string, unknown>
const raw = b.verdicts ?? b.units
const fallback = { model: b.model, postSeal: typeof b.post_seal === 'boolean' ? b.post_seal : true }
const out: Record<string, EvidenceVerdict> = {}
if (Array.isArray(raw)) {
for (const item of raw) {
const id = item && typeof item === 'object' ? (item as any).unit ?? (item as any).id : null
const v = asVerdict(item, fallback)
if (typeof id === 'string' && id && v) out[id] = v
}
} else if (raw && typeof raw === 'object') {
for (const [id, item] of Object.entries(raw)) {
const v = asVerdict(item, fallback)
if (v) out[id] = v
}
}
return out
}
/** 证据图里某份报告封存之后追加的判定(reports[].post_seal_verdicts);后端没给时是空的 */
export function graphVerdicts(graph: EvidenceGraph | null | undefined, report: string | undefined): Record<string, EvidenceVerdict> {
const hit = (graph?.reports ?? []).find((r) => !report || !r?.node_id || r.node_id === report)
return hit?.post_seal_verdicts && typeof hit.post_seal_verdicts === 'object'
? judgedVerdicts({ verdicts: hit.post_seal_verdicts, post_seal: true }) : {}
}
/**
* 这一句和「改写一次」(rewrite_once)的关系:改写稿采用了、这句是新写的(changed 里有它),给出交回
* 改写的原句(units 里对应是 null 的那几句);改写稿没采用、这句是交回过的,给出没采用的原因。其余 null。
* 只按 changed / units 认编号——没按这两项映射过的编号在封存文档里指的是别的句子
*/
export function rewriteOf(doc: Pick<EvidenceDocData, 'judge'>, uid: string):
{ kind: 'changed'; sentences: string[] } | { kind: 'rejected'; reason: string } | null {
const r = doc.judge?.rewrite
if (!r || typeof r !== 'object') return null
const units = Array.isArray(r.units) ? r.units : []
const sentences = Array.isArray(r.sentences) ? r.sentences.map(String) : []
if (r.applied && Array.isArray(r.changed) && r.changed.includes(uid)) {
return { kind: 'changed', sentences: sentences.filter((_, i) => units[i] == null) }
}
if (!r.applied && typeof r.reason === 'string' && r.reason && units.includes(uid)) return { kind: 'rejected', reason: r.reason }
return null
}
// -------------------------------------------------------------------------
// 旧运行的猜测
// -------------------------------------------------------------------------
+185 -3
View File
@@ -132,15 +132,15 @@ export const APPROVAL_POLICY_LABEL: Record<'dangerous' | 'always' | 'never', str
* 可点击证据:片段状态的叫法。正文里片段的 aria-label、面板标题、图例都用这一份。
* 外观(线型、字形、颜色)在 lib/evidence.ts,和这里一一对应。
*
* 本期(数字层)实际只会出现「有出处」和「无证据」;其余几种是后续期的,先把叫法定下,
* 免得到时候各处各起一个名字。
* 概率性的四种(四期的结论句裁判)挂在句末的徽标上:有依据、部分有依据、证据不支持都是模型的判断,
* 叫法里写明「模型判断」;未裁判不带「有引用」——没挂依据、只有方向词的结论句也会送裁判、也会没判。
*/
export const EVIDENCE_STATE_LABEL = {
deterministic: '有出处',
supported: '模型判断:有依据',
partial: '模型判断:部分有依据',
unsupported: '模型判断:证据不支持',
unjudged: '有引用,未裁判',
unjudged: '未裁判',
none: '无证据',
connective: '连接性文字',
candidate: '猜测的来源',
@@ -313,6 +313,188 @@ export const EVIDENCE_TEXT = {
guessCandidates: '可能来自',
} as const
/** 结论句按裁判结论分成的几堆(lib/evidence 的 claimCounts 数出来的) */
export interface ClaimTallyCounts {
/** 结论句总数:模型判为「不是结论句」的不算 */
total: number
supported: number
partial: number
unsupported: number
/** 送了裁判、没判成(到上限、调用失败)或还没请模型判断的 */
unjudged: number
/** 没有判定、也没挂依据的结论句 */
uncited: number
}
/**
* 「结论 4 句(支持 3 · 无证据 1)」:出具横幅、证据条、报告核对那一行共用,和数字那一段同一种说法——
* 先说总数,括号里按状态分,为 0 的不写。没有结论句时返回空串
*/
export function claimTally(c: ClaimTallyCounts): string {
if (!c.total) return ''
const parts = [
['支持', c.supported], ['部分支持', c.partial], ['不支持', c.unsupported], ['未裁判', c.unjudged], ['无证据', c.uncited],
].filter(([, n]) => (n as number) > 0).map(([k, n]) => `${k} ${formatNumber(n as number)}`)
return `结论 ${formatNumber(c.total)} 句${parts.length ? `(${parts.join(' · ')})` : ''}`
}
/**
* 结论句裁判(四期)在证据面板、句末徽标里的说法。判断是模型给的:一律写明「模型判断」「非确定」,
* 封存之后按需追加的另写「封存后追加」
*/
export const JUDGE_TEXT = {
section: '模型的解释',
claim: '结论句',
/** 读屏摘要末尾的操作说明:有证据不支持、部分支持的结论句时 n 也跳到它们的句末徽标 */
keysHint: '用左右方向键逐个查看,上下键按句子走,n 跳到下一处无证据或证据不支持的句子,回车打开证据,Esc 关闭',
/** 面板里判断的徽标:谁判的、而且不是确定的 */
badge: (model: string | null | undefined) => `模型判断 · ${model || '裁判模型'} · 非确定`,
postSeal: '封存后追加',
/** 判定里没记模型名时的叫法 */
judgeModel: '裁判模型',
/** 句末徽标的悬停说明:到上限没判的 */
limitNotJudged: '已到上限,这句没判(模型没有看过这句)',
/** 没判的句子(到上限、没跑成)记着的裁判模型:中性地说,不挂「模型判断」的徽标 */
notJudgedBy: (model: string) => `裁判模型 ${model} 没有判这句`,
sealedDoc: '报告文档已封存 · 核对一致',
sealedWithDoc: '报告文档已封存 · 核对一致,这条判断随报告一起封存',
postSealHint: '运行封存之后按需追加的判断:不在封存范围内,封存核对照样一致。它是模型的解释,不是证据',
sealedHint: '正式运行里报告撰写节点当场判的,和报告一起封存。它是模型的解释,不是证据',
used: (aliases: string[]) => `裁判看过的证据:${aliases.join('、')}`,
cites: '挂的依据',
noCites: '这句没挂依据(只有方向词、因果词):裁判看不到任何证据摘录',
notClaim: '模型判断:不是结论句',
ask: '请模型判断这句',
askAgain: '再请模型判断一次',
asking: '正在请模型判断…',
askHint: '探索运行按需裁判:点一次判这一句,花费计入每次点击和每日的上限',
notAsked: '还没请模型判断这句',
screened: '预筛认为这句不陈述数据事实(短句、只有过渡的话),没有送裁判',
noPrice: '裁判模型不在价格目录里,按令牌估不出金额:金额上限对它不起作用',
limit: '已到上限',
/** 触顶之后怎么调。按需裁判(每次点击)和正式运行(每份报告)调的地方不一样 */
limitHow: {
click: {
max_cost_usd: '到「设置 → 偏好设置 → 证据裁判」调高「每次点击的金额上限」,或者设成不限,再点一次',
daily_max_usd: '到「设置 → 偏好设置 → 证据裁判」调高「每日金额上限」或设成不限;也可以等到明天(按本地日期重新计)',
max_claims: '到「设置 → 偏好设置 → 证据裁判」调高「每份报告最多判几句」(报告撰写节点里写了的以节点为准)',
timeout_s: '到「设置 → 偏好设置 → 证据裁判」调高「每份报告的时长上限」或设成不限,再点一次',
} as Record<string, string>,
report: {
max_cost_usd: '到报告撰写节点的「结论句裁判」调高金额上限(没写就是设置里「证据裁判」的默认值),也可以设成不限,再发起一次运行',
daily_max_usd: '到「设置 → 偏好设置 → 证据裁判」调高「每日金额上限」或设成不限,再发起一次运行',
max_claims: '到报告撰写节点的「结论句裁判」调高最多判几句(或设置里的默认值),也可以设成不限,再发起一次运行',
timeout_s: '到报告撰写节点的「结论句裁判」调高时长上限(或设置里的默认值),也可以设成不限,再发起一次运行',
} as Record<string, string>,
},
failed: (why: string) => `没判成:${why}`,
oldBackend: '这个后端还不支持按需裁判',
notFound: '报告里没有这一句',
formalOnly: '正式运行的裁判在报告撰写节点里当场完成,不能按需追加',
rewritten: '这句是按裁判的意见改写过的(改写一次)',
rewriteFrom: '交回改写的原句',
rewriteRejected: (reason: string) => `这句交回写作者改写过一次,改写稿没有采用:${reason}`,
/**
* 横幅上结论句那一段之外另起的「没挂依据的结论句 N」:开了裁判的文档里,没挂依据的句子按判定数(或预筛
* 放掉了不数),出具却照样按没挂依据计缺口——悬停时说清这两件事不冲突
*/
uncitedBesides: '没写依据([[see:]])的结论句:出具照样计入缺口。模型判过的也算在里面——判定是模型的解释,代替不了依据',
/** 句末徽标的 aria-label:「结论句「增长主要来自新客首单」,模型判断:证据不支持,封存后追加」 */
badgeLabel: (sentence: string, state: string, postSeal: boolean) =>
`结论句「${sentence}」,${state}${postSeal ? ',封存后追加' : ''}`,
} as const
/**
* 报告撰写节点的 judge 子配置(claims 为 judge 时生效):检查器、发布前修复的预览(「结论句裁判 · 金额上限」)
* 共用这份叫法。键同后端 schema.JUDGE_KEYS
*/
export const JUDGE_FIELD_LABEL: Record<string, string> = {
provider: '裁判模型 · 接入',
model: '裁判模型',
max_claims: '最多判几句',
max_cost_usd: '金额上限(美元)',
timeout_s: '时长上限(秒)',
rewrite_once: '证据不支持的句子交回改写一次',
on_unsupported: '证据不支持时',
}
/** judge.on_unsupported:正式运行里证据不支持的结论句怎么判档(探索运行只标注) */
export const JUDGE_ON_UNSUPPORTED_LABEL: Record<string, string> = {
degrade: '出具降档',
withhold: '不予出具',
}
/** 设置页「证据裁判」一组(后端 engine/judge.py 的 JUDGE_DEFAULTS) */
export const JUDGE_SETTING_TEXT = {
label: '证据裁判',
hint: '报告里的结论句(「增长主要来自新客首单」这类)由另一个模型按证据逐句判断:正式运行在报告撰写节点里当场判,探索运行点开哪句才判哪句。判断是模型给的,不是系统核对,界面上一律标「非确定」。',
provider: '裁判模型 · 接入',
model: '裁判模型 · 模型',
/** 接入留空、模型也留空:后端依次用 Copilot 的模型、默认接入(judge_model_spec)。只有这时才叫「跟随 Copilot」 */
providerDefault: '跟随 Copilot 的模型',
/** 接入留空、模型填了:这一级写了就整组用这一级,后端按模型名找接入——不再跟随 Copilot */
providerFromModel: '按模型名找接入',
modelDefault: '留空:用接入的默认模型',
modelFollow: '留空:跟随 Copilot 的模型',
differ: '建议和写报告的模型不同:同一个模型审自己写的,写错的地方它多半也看不出来',
limits: '上限(每一项都可以设成不限)',
reportClaims: '每份报告最多判几句',
reportCost: '每份报告的金额上限(美元)',
reportTimeout: '每份报告的时长上限(秒)',
clickCost: '每次点击的金额上限(美元)',
daily: '每日金额上限(美元)',
unlimited: '不限',
nodeWins: '报告撰写节点的「结论句裁判」里写了的上限以节点为准',
spend: (usd: string, calls: number) => `今天已花 $${usd}(${formatNumber(calls)} 次调用)`,
unpriced: (n: number) => `另有 ${formatNumber(n)} 次调用估不出金额(模型不在价格目录里),没算进去`,
spendNone: '今天还没有裁判调用',
} as const
/**
* 只填了裁判模型、接入留空时实际用哪个接入(后端 judge_model_spec:哪一级写了就整组用哪一级,不跨级拼;
* 再由 resolve_provider 按模型名找接入)。scope:设置页(上一级是 Copilot 的模型)还是报告撰写节点(上一级是设置)。
* found:按模型名找到的接入;fallback:认不出时后端去调的默认接入
*/
export function judgeByModelText(
scope: 'settings' | 'node', model: string, found: { name: string; enabled: boolean } | null | undefined,
fallback: string | null | undefined,
): string {
const follow = scope === 'settings' ? '「跟随 Copilot」只在接入和模型都留空时生效'
: '接入和模型都没写时才跟随设置里的「证据裁判模型」'
if (found && !found.enabled) {
return `只填了模型:「${model}」属于已停用的接入「${found.name}」,裁判调用会失败、结论句都记为未裁判;启用它或选别的接入`
}
if (found) return `只填了模型:按模型名用接入「${found.name}」。${follow}`
return `只填了模型:没有哪个接入的模型清单里有「${model}」,会拿它去调默认接入${fallback ? `「${fallback}」` : ''},`
+ `调不通时结论句都记为未裁判;不是这家的模型请选上接入。${follow}`
}
/**
* 某项上限设成不限时写明它还受什么约束——不要悄悄生效。别的上限也不限时照实少说几样,全不限时
* 说到底只受调用次数(结论句的多少)约束。set:各项眼下是不是有上限
*/
export function judgeUnlimitedText(
key: 'claims' | 'cost' | 'timeout' | 'click' | 'daily',
set: { claims: boolean; cost: boolean; timeout: boolean; click: boolean; daily: boolean },
): string {
const list = (items: [boolean, string][]) => items.filter(([on]) => on).map(([, t]) => t)
switch (key) {
case 'claims': return '不设上限,句数只受报告里结论句多少约束'
case 'timeout': return '不设上限,时长只受模型接口自身的超时约束'
case 'cost': {
const by = list([[set.claims, '句数上限'], [set.timeout, '时长上限'], [set.daily, '每日上限']])
return by.length ? `不设上限,费用只受${by.join('、')}约束` : '不设上限,费用只受报告里结论句的多少约束(句数、时长、每日也都不限)'
}
case 'click': {
const by = list([[set.claims, '句数上限'], [set.timeout, '时长上限'], [set.daily, '每日上限']])
return by.length ? `不设上限,每次点击只受${by.join('、')}约束` : '不设上限,每次点击只受点开的句数约束(句数、时长、每日也都不限)'
}
case 'daily': {
const by = list([[set.cost, '每份报告的金额上限'], [set.click, '每次点击的金额上限']])
return by.length ? `不设上限,每日费用只受${by.join('和')}约束` : '不设上限,每日费用只受模型调用次数约束(每份报告、每次点击也都不限)'
}
}
}
/**
* 记录页「证据」页签:报告、常驻面板、审计表。审计表按状态分组,四组的说法和片段状态同一套
*/
+218 -10
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react'
import { Navigate, useParams } from 'react-router-dom'
import {
Check, Cpu, Download, KeyRound, Monitor, Moon, Plug, Plus, Settings as SettingsIcon, Sun, X,
@@ -7,7 +7,7 @@ import {
import clsx from 'clsx'
import { api } from '../api/client'
import type { ProviderDraft } from '../api/client'
import { useCatalog, useOnReconnect } from '../store/catalog'
import { modelOptions, providerOfModel, useCatalog, useOnReconnect } from '../store/catalog'
import {
confirmDialog, DeleteButton, EmptyState, ErrorState, Field, HealthPill, isComposing, Modal, PageHeader,
promptDialog, SectionBar, Skeleton, Spinner, TabPanel, Tabs, toast, useRadioGroup, useTabRoute,
@@ -22,7 +22,7 @@ import type { Provider } from '../types'
import { normalizeTheme } from '../lib/theme'
import { useLeaveGuard } from '../lib/leave'
import { localActor, setLocalActor } from '../lib/actor'
import { AGENT_GUARD_TEXT, TOOL_GATE_TEXT } from '../lib/terms'
import { AGENT_GUARD_TEXT, JUDGE_SETTING_TEXT, TOOL_GATE_TEXT, judgeByModelText, judgeUnlimitedText } from '../lib/terms'
import type { ThemePref } from '../lib/theme'
// 提到模块级:tab 名同时是 URL 的最后一段,两处各写一份迟早对不上。
@@ -610,11 +610,17 @@ function PrefsTab() {
const [draft, setDraft] = useState<{
actor: string; scope: string; collection: string; confirm: boolean; gateProvider: string; gateModel: string
steps: string; budgetTokens: string; budgetUsd: string
judgeProvider: string; judgeModel: string
// 证据裁判的五项上限:null 是不限(存 null),字符串是正在填的数
jClaims: string | null; jCost: string | null; jTimeout: string | null; jClick: string | null; jDaily: string | null
} | null>(null)
const [spend, setSpend] = useState<{ usd?: number; calls?: number; unpriced_calls?: number } | null>(null)
const [saving, setSaving] = useState(false)
const [savedFlash, setSavedFlash] = useState(0)
const [saveError, setSaveError] = useState<unknown>(null)
// 今天裁判花了多少:只读,放在每日上限旁边。老后端没有这个接口就不显示
const loadSpend = () => api.settings.judgeSpend().then(setSpend, () => setSpend(null))
const load = async () => {
try {
setValues(await api.settings.get())
@@ -623,6 +629,7 @@ function PrefsTab() {
setLoadError(e)
}
api.memory.scopes().then(setScopes, () => {})
void loadSpend()
}
useEffect(() => { void load() }, [])
// 只在首次没拉到时重拉:重拉会覆盖正在编辑的表单
@@ -640,6 +647,14 @@ function PrefsTab() {
steps: String(values?.run?.agent_max_steps ?? 100),
budgetTokens: values?.run?.agent_budget_tokens == null ? '' : String(values.run.agent_budget_tokens),
budgetUsd: values?.run?.agent_budget_usd == null ? '' : String(values.run.agent_budget_usd),
// 证据裁判(后端 engine/judge.py,GET 给的是引擎实际生效的样子:存坏了的显示默认,null 是不限)
judgeProvider: values?.judge?.provider ?? '',
judgeModel: values?.judge?.model ?? '',
jClaims: limitText(values?.judge, 'report_max_claims'),
jCost: limitText(values?.judge, 'report_max_cost_usd'),
jTimeout: limitText(values?.judge, 'report_timeout_s'),
jClick: limitText(values?.judge, 'click_max_cost_usd'),
jDaily: limitText(values?.judge, 'daily_max_usd'),
}
const cur = draft ?? saved
const stepCap = Number(values?.limits?.max_agent_steps) || 100
@@ -657,6 +672,21 @@ function PrefsTab() {
? undefined : '填大于 0 的金额,或者留空表示不限',
}
const guardInvalid = Object.values(guardErrors).some(Boolean)
// 裁判的上限同样只校验改过的;null(不限)总是对的
const judgeError = (key: 'jClaims' | 'jCost' | 'jTimeout' | 'jClick' | 'jDaily') => {
const v = cur[key]
if (v === saved[key] || v === null) return undefined
const n = Number(v.trim())
if (key === 'jClaims') return /^\d+$/.test(v.trim()) && n >= 1 ? undefined : '填正整数,或者勾「不限」'
if (key === 'jTimeout') return v.trim() !== '' && Number.isFinite(n) && n > 0 ? undefined : '填大于 0 的秒数,或者勾「不限」'
return v.trim() !== '' && Number.isFinite(n) && n > 0 ? undefined : '填大于 0 的金额,或者勾「不限」'
}
const judgeErrors = {
jClaims: judgeError('jClaims'), jCost: judgeError('jCost'), jTimeout: judgeError('jTimeout'),
jClick: judgeError('jClick'), jDaily: judgeError('jDaily'),
}
const judgeInvalid = Object.values(judgeErrors).some(Boolean)
const invalid = guardInvalid || judgeInvalid
const changed = (Object.keys(saved) as (keyof typeof saved)[]).filter((k) => cur[k] !== saved[k])
const dirty = values ? changed.length : 0
// 外壳唯一的 blocker 在地址(pathname)变化时问:切标签、导航、⌘K、⌥ 数字、后退都算
@@ -678,7 +708,7 @@ function PrefsTab() {
* 有改动才 PUT——只改了署名时不必碰服务端,断线时也存得上
*/
const save = async () => {
if (!values || !dirty || guardInvalid) return
if (!values || !dirty || invalid) return
setSaving(true)
setSaveError(null)
const actor = cur.actor.trim()
@@ -687,9 +717,20 @@ function PrefsTab() {
setLocalActor(actor)
savedActor.current = actor
}
const runChanged = changed.some((k) => k !== 'actor')
const judgeChanged = changed.some((k) => JUDGE_DRAFT.has(k))
const runChanged = changed.some((k) => k !== 'actor' && !JUDGE_DRAFT.has(k))
try {
if (runChanged) {
// 裁判一组整组写:后端对没给的键回落到默认值,只发改过的那一项会把别的项冲回默认
const judge = judgeChanged ? {
provider: cur.judgeProvider || null,
model: cur.judgeModel.trim() || null,
report_max_claims: parseLimit(cur.jClaims),
report_max_cost_usd: parseLimit(cur.jCost),
report_timeout_s: parseLimit(cur.jTimeout),
click_max_cost_usd: parseLimit(cur.jClick),
daily_max_usd: parseLimit(cur.jDaily),
} : null
if (runChanged || judge) {
const run = {
...(values.run ?? {}),
default_memory_scope: cur.scope,
@@ -701,8 +742,14 @@ function PrefsTab() {
agent_budget_tokens: cur.budgetTokens.trim() === '' ? null : Number(cur.budgetTokens),
agent_budget_usd: cur.budgetUsd.trim() === '' ? null : Number(cur.budgetUsd),
}
const out = await api.settings.put({ run })
setValues((v) => ({ ...(v ?? {}), run: out?.run ?? run }))
const out = await api.settings.put({ ...(runChanged ? { run } : {}), ...(judge ? { judge } : {}) })
setValues((v) => ({
...(v ?? {}),
...(runChanged ? { run: out?.run ?? run } : {}),
...(judge ? { judge: out?.judge ?? judge } : {}),
}))
// 每日上限改了:旁边那句「今天已花」的分母跟着变,顺手再取一次
if (judge) void loadSpend()
}
setDraft(null)
setSavedFlash(Date.now())
@@ -724,6 +771,29 @@ function PrefsTab() {
const gateProvider = cur.gateProvider ? providers.find((p) => p.name === cur.gateProvider) : defaultProvider
const gateProviderMissing = !!cur.gateProvider && providers.length > 0 && !gateProvider
const gateModels = (gateProvider?.models ?? []).map((m) => m.id)
// 裁判模型的接入:接入和模型都留空时后端依次用 Copilot 的模型、默认接入(judge_model_spec)。只填了模型时
// 这一级写了就整组用这一级,按模型名找接入(resolve_provider)——不再跟随 Copilot,留空那一项不能还叫「跟随 Copilot」
const judgeProvider = cur.judgeProvider ? providers.find((p) => p.name === cur.judgeProvider) : undefined
const judgeProviderMissing = !!cur.judgeProvider && providers.length > 0 && !judgeProvider
const judgeModelName = cur.judgeModel.trim()
const judgeByModel = !cur.judgeProvider && !!judgeModelName
const judgeOwner = judgeByModel ? providerOfModel(providers, judgeModelName) : undefined
const judgeByModelHint = judgeByModel && providers.length > 0
? judgeByModelText('settings', judgeModelName, judgeOwner && { name: judgeOwner.provider.name, enabled: judgeOwner.enabled },
defaultProvider?.name)
: ''
// 接入留空时按模型名找接入:所有启用接入的模型都能选;选了接入只列这一家的
const judgeModels = judgeProvider ? (judgeProvider.models ?? []).map((m) => m.id)
: [...new Set(modelOptions(providers).map((o) => o.value))]
const pickJudgeProvider = (name: string) => {
const next = name ? providers.find((p) => p.name === name) : undefined
const keep = !name || !cur.judgeModel || (next?.models ?? []).some((m) => m.id === cur.judgeModel) || next?.default_model === cur.judgeModel
edit({ judgeProvider: name, ...(keep ? {} : { judgeModel: '' }) })
}
const limitSet = {
claims: cur.jClaims !== null, cost: cur.jCost !== null, timeout: cur.jTimeout !== null,
click: cur.jClick !== null, daily: cur.jDaily !== null,
}
const pickGateProvider = (name: string) => {
// 换了接入,原来填的模型不在新接入的清单里就清掉:拿 A 家的模型名去调 B 家必然失败
const next = name ? providers.find((p) => p.name === name) : defaultProvider
@@ -864,6 +934,85 @@ function PrefsTab() {
</div>
</div>
</section>
{values.judge && (
<section data-judge-settings>
<SectionBar title={JUDGE_SETTING_TEXT.label} hint={JUDGE_SETTING_TEXT.hint} />
<div role="group" aria-labelledby="pref-judge-model-label" aria-describedby="pref-judge-model-hint" data-judge-model>
<div id="pref-judge-model-label" className="text-xs font-medium">裁判模型</div>
<p id="pref-judge-model-hint" className="mb-2 mt-0.5 text-2xs leading-relaxed text-faint">{JUDGE_SETTING_TEXT.differ}</p>
<div className="grid grid-cols-2 gap-3">
<Field label={JUDGE_SETTING_TEXT.provider} htmlFor="pref-judge-provider"
error={judgeProviderMissing ? `「${cur.judgeProvider}」这个接入不存在:裁判调用会失败,结论句都记为未裁判` : undefined}
hint={judgeByModelHint ? (
<span data-judge-by-model={judgeOwner ? (judgeOwner.enabled ? 'found' : 'disabled') : 'default'}
style={judgeOwner?.enabled ? undefined : { color: 'var(--st-waiting)' }}>{judgeByModelHint}</span>
) : undefined}>
{(p) => (
<select {...p} className="field" value={cur.judgeProvider} onChange={(e) => pickJudgeProvider(e.target.value)}>
<option value="">{judgeByModel ? JUDGE_SETTING_TEXT.providerFromModel : JUDGE_SETTING_TEXT.providerDefault}</option>
{judgeProviderMissing && <option value={cur.judgeProvider}>{cur.judgeProvider}(不存在)</option>}
{providers.map((pv) => (
<option key={pv.id} value={pv.name}>{pv.name}{pv.enabled ? '' : '(已停用)'}</option>
))}
</select>
)}
</Field>
<Field label={JUDGE_SETTING_TEXT.model} htmlFor="pref-judge-model"
hint={judgeProvider?.default_model ? `留空就用 ${judgeProvider.default_model}` : undefined}>
{(p) => (
<>
<input {...p} className="field mono text-xs" value={cur.judgeModel} list="pref-judge-models"
placeholder={cur.judgeProvider ? JUDGE_SETTING_TEXT.modelDefault : JUDGE_SETTING_TEXT.modelFollow}
spellCheck={false}
onChange={(e) => edit({ judgeModel: e.target.value })} />
<datalist id="pref-judge-models">
{judgeModels.map((m) => <option key={m} value={m} />)}
</datalist>
</>
)}
</Field>
</div>
</div>
<div className="mt-4" role="group" aria-labelledby="pref-judge-limits-label" aria-describedby="pref-judge-limits-hint"
data-judge-limits>
<div id="pref-judge-limits-label" className="text-xs font-medium">{JUDGE_SETTING_TEXT.limits}</div>
<p id="pref-judge-limits-hint" className="mb-2 mt-0.5 text-2xs leading-relaxed text-faint">{JUDGE_SETTING_TEXT.nodeWins}</p>
<div className="grid grid-cols-2 gap-3 max-sm:grid-cols-1">
<LimitField id="pref-judge-claims" dataKey="report_max_claims" label={JUDGE_SETTING_TEXT.reportClaims}
value={cur.jClaims} fallback={JUDGE_FALLBACK.report_max_claims} integer
error={judgeErrors.jClaims} unlimited={judgeUnlimitedText('claims', limitSet)}
onChange={(v) => edit({ jClaims: v })} />
<LimitField id="pref-judge-cost" dataKey="report_max_cost_usd" label={JUDGE_SETTING_TEXT.reportCost}
value={cur.jCost} fallback={JUDGE_FALLBACK.report_max_cost_usd}
error={judgeErrors.jCost} unlimited={judgeUnlimitedText('cost', limitSet)}
onChange={(v) => edit({ jCost: v })} />
<LimitField id="pref-judge-timeout" dataKey="report_timeout_s" label={JUDGE_SETTING_TEXT.reportTimeout}
value={cur.jTimeout} fallback={JUDGE_FALLBACK.report_timeout_s}
error={judgeErrors.jTimeout} unlimited={judgeUnlimitedText('timeout', limitSet)}
onChange={(v) => edit({ jTimeout: v })} />
<LimitField id="pref-judge-click" dataKey="click_max_cost_usd" label={JUDGE_SETTING_TEXT.clickCost}
value={cur.jClick} fallback={JUDGE_FALLBACK.click_max_cost_usd}
error={judgeErrors.jClick} unlimited={judgeUnlimitedText('click', limitSet)}
onChange={(v) => edit({ jClick: v })} />
<LimitField id="pref-judge-daily" dataKey="daily_max_usd" label={JUDGE_SETTING_TEXT.daily}
value={cur.jDaily} fallback={JUDGE_FALLBACK.daily_max_usd}
error={judgeErrors.jDaily} unlimited={judgeUnlimitedText('daily', limitSet)}
onChange={(v) => edit({ jDaily: v })}
extra={spend && (
<div className="mt-1 text-2xs leading-relaxed text-dim" data-judge-spend="">
{spend.calls ? JUDGE_SETTING_TEXT.spend(formatSpend(spend.usd), spend.calls) : JUDGE_SETTING_TEXT.spendNone}
{!!spend.unpriced_calls && (
<span className="block" style={{ color: 'var(--st-waiting)' }} data-judge-unpriced="">
{JUDGE_SETTING_TEXT.unpriced(spend.unpriced_calls)}
</span>
)}
</div>
)} />
</div>
</div>
</section>
)}
</>
)}
@@ -884,6 +1033,9 @@ function PrefsTab() {
actor: '署名', scope: '记忆作用域', collection: '知识库', confirm: '危险工具的默认审批策略',
gateProvider: '门控模型的接入', gateModel: '门控模型',
steps: '默认最大步数', budgetTokens: '令牌预算', budgetUsd: '金额预算',
judgeProvider: '裁判模型的接入', judgeModel: '裁判模型',
jClaims: '每份报告最多判几句', jCost: '每份报告的金额上限', jTimeout: '每份报告的时长上限',
jClick: '每次点击的金额上限', jDaily: '每日金额上限',
})[k]).join('、')}
</span>
</span>
@@ -893,8 +1045,9 @@ function PrefsTab() {
<span className="flex-1" />
<button className="btn btn-sm btn-ghost" disabled={saving}
onClick={() => { setDraft(null); setSaveError(null) }}>放弃</button>
<button className="btn btn-sm btn-primary" disabled={saving || guardInvalid}
title={guardInvalid ? '护栏那几项有没填对的,改好再保存' : undefined}
<button className="btn btn-sm btn-primary" disabled={saving || invalid}
title={guardInvalid ? '护栏那几项有没填对的,改好再保存'
: judgeInvalid ? '证据裁判的上限有没填对的,改好再保存' : undefined}
onClick={() => void save()}>
{saving ? <Spinner size={11} /> : <Check size={12} aria-hidden />} 保存设置
</button>
@@ -910,6 +1063,61 @@ function PrefsTab() {
)
}
/** 裁判那一组在草稿里的键:它们改了才写 judge 一组 */
const JUDGE_DRAFT = new Set<string>(['judgeProvider', 'judgeModel', 'jClaims', 'jCost', 'jTimeout', 'jClick', 'jDaily'])
/** 勾掉「不限」时填回的数:同后端 JUDGE_DEFAULTS */
const JUDGE_FALLBACK = {
report_max_claims: '40', report_max_cost_usd: '0.05', report_timeout_s: '30', click_max_cost_usd: '0.01', daily_max_usd: '2',
}
/** 设置里的一项上限 → 表单:null 是不限,没有这一项(老后端)当空串 */
function limitText(group: Record<string, any> | undefined, key: string): string | null {
const v = group?.[key]
return v === null ? null : v === undefined ? '' : String(v)
}
/** 表单 → 存进去的值:不限存 null */
const parseLimit = (v: string | null): number | null => (v === null ? null : Number(v.trim()))
/** 今天花了多少美元:小额按分以下的精度写,不四舍五入成 0 */
const formatSpend = (usd: number | undefined): string =>
usd == null || !Number.isFinite(usd) ? '0' : usd >= 1 ? usd.toFixed(2) : String(Number(usd.toFixed(4)))
/**
* 一项裁判上限:数字框加「不限」。勾了不限,框变灰,下面写明不设上限之后费用还受什么约束——
* 不许悄悄生效;勾掉时填回之前的数(没有就填默认值)
*/
function LimitField({ id, dataKey, label, value, fallback, integer, error, unlimited, onChange, extra }: {
id: string; dataKey: string; label: string; value: string | null; fallback: string; integer?: boolean
error?: string; unlimited: string; onChange: (v: string | null) => void; extra?: ReactNode
}) {
const last = useRef(value ?? fallback)
if (value !== null) last.current = value || last.current
const off = value === null
return (
<div className="min-w-0" data-judge-limit={dataKey} data-unlimited={off ? '' : undefined}>
<Field label={label} htmlFor={id} error={error}
hint={off ? <span style={{ color: 'var(--st-waiting)' }} data-judge-unlimited="">{unlimited}</span> : undefined}>
{(p) => (
<div className="flex items-center gap-2">
<input {...p} className="field tnum min-w-0 flex-1" inputMode={integer ? 'numeric' : 'decimal'}
value={off ? '' : value} disabled={off} placeholder={off ? JUDGE_SETTING_TEXT.unlimited : undefined}
onChange={(e) => onChange(e.target.value)} />
<label className="flex shrink-0 cursor-pointer items-center gap-1 text-xs">
<input type="checkbox" checked={off} aria-label={`${label}:${JUDGE_SETTING_TEXT.unlimited}`}
aria-describedby={p['aria-describedby']} data-judge-unlimited-toggle=""
onChange={(e) => onChange(e.target.checked ? null : last.current || fallback)} />
{JUDGE_SETTING_TEXT.unlimited}
</label>
</div>
)}
</Field>
{extra}
</div>
)
}
/**
* 主题:选中即生效、即保存。显示的是眼下实际应用的偏好(useTheme)——从导航、
* ⌘K 换了主题,这里跟着变,不会留着旧值。
+1 -1
View File
@@ -143,7 +143,7 @@ export function EvidencePane({ run, output, labelOf, refreshKey }: {
</header>
{doc ? (
<EvidenceDoc ref={(h) => { handles.current.set(r.node_id!, h) }} doc={doc} artifact={r.doc_artifact}
runId={run.id} label={r.fields?.join('、') || r.node_id} tally
runId={run.id} runClass={run.run_class} label={r.fields?.join('、') || r.node_id} tally
panel={wide ? 'dock' : 'auto'} dock={dock} onView={(o) => onView(r.node_id!, o)} />
) : slot?.status === 'error' ? (
<p className="text-2xs" style={{ color: 'var(--st-failed)' }}>{T.docGone(labelOf(r.node_id) ?? r.node_id ?? '')}</p>
+18 -9
View File
@@ -15,14 +15,14 @@ import { humanizeError } from '../lib/errors'
import { explainRunError } from '../lib/explain'
import { formatClock, formatDuration, formatNumber, NONE, shortId } from '../lib/format'
import { statusLabel } from '../lib/status'
import { EVIDENCE_TEXT, evidenceTally, issuanceLabel, nodeTypeLabel } from '../lib/terms'
import { EVIDENCE_TEXT, JUDGE_TEXT, evidenceTally, issuanceLabel, nodeTypeLabel } from '../lib/terms'
import {
childrenByExec, compactSteps, parseQueryResult, progressOf, spread, teamVerdictOf,
type Exec, type FixKind, type ResultTable as Table, type Step, type StepKind, type TeamMemberEx,
type TeamRun,
} from './decode'
import { CODE_COLUMN, CopyChip, LEADING_ZERO, Markdown, type MarkSpec } from './Markdown'
import { EvidenceField, type EvidenceDocHandle } from './EvidenceDoc'
import { ClaimTally, EvidenceField, type EvidenceDocHandle } from './EvidenceDoc'
import { EVIDENCE_STATE, docTally, evidenceFields, issuanceMarks, type EvidenceTally } from '../lib/evidence'
import type { EvidenceDocData } from '../types'
import { useRunClock } from './useRunClock'
@@ -2027,7 +2027,7 @@ function Output({ output, runClass, broken, question, onFollowUp, runId }: {
<EvidenceField
ref={key === firstEvidence ? docRef : undefined}
artifact={evidence.get(key)!.artifact} text={value} runId={runId} dense={dense} label={key}
tally={!banner}
tally={!banner} runClass={runClass}
onDoc={key === firstEvidence ? setTallyDoc : undefined}
/>
)
@@ -2262,6 +2262,12 @@ export function IssuanceBanner({ issuance, runClass, evidence }: {
const uncitedClaims = Number(issuance?.claims?.uncited_claims ?? 0) || 0
const numbers = counts ? evidenceTally(counts.cited, counts.total, counts.other) : ''
const numbersOk = !counts?.none && !counts?.other
// 四期:结论句有判定(开了裁判,或探索运行里按需判过)时多一段「结论 4 句(支持 3 · 无证据 1)」。
// 没开裁判的文档里没挂依据的都数在这一段的「无证据」里,不再另起一句;开了裁判的文档里,没挂依据却送了
// 裁判的句子按判定数、预筛放掉的不数——出具照样按没挂依据计缺口(require_citation、judge 都是),这时
// 结论句那一段盖不全,照出具的数另起「没挂依据的结论句 N」,不能让花了档位的缺口从横幅上消失
const claims = counts?.claims?.total ? counts.claims : null
const uncitedLine = uncitedClaims > (claims?.uncited ?? 0)
return (
// data-issuance-banner:画布上成果节点的出具印章据此滚过来;tabIndex 让「去看出具」能把焦点带到这里
@@ -2281,9 +2287,10 @@ export function IssuanceBanner({ issuance, runClass, evidence }: {
</span>
</div>
{meta.hint && <div className="mt-1 text-2xs leading-relaxed text-dim">{meta.hint}</div>}
{counts && (counts.total > 0 || counts.other > 0 || suspect > 0 || uncitedClaims > 0) && (
{counts && (counts.total > 0 || counts.other > 0 || suspect > 0 || uncitedClaims > 0 || !!claims) && (
// 逐段证据的计数:和报告核对那一行、证据条同一种说法。可疑名字、没挂依据的结论句不是数字,
// 各自另起一句。有无证据的数字、可疑名字时才给「定位下一处」(结论句不是片段,定位不到)
// 各自另起一句。有无证据的数字、可疑名字、证据不支持(或部分支持)的结论句时才给「定位下一处」——
// 结论句靠句末徽标定位;只是没挂依据的结论句没有徽标,定位不到
<div className="mt-1 flex flex-wrap items-center gap-2 text-2xs" data-evidence-line="">
{numbers && (
<span className="tnum" style={{ color: numbersOk ? 'var(--st-done)' : 'var(--st-waiting)' }}>{numbers}</span>
@@ -2293,12 +2300,14 @@ export function IssuanceBanner({ issuance, runClass, evidence }: {
{numbers ? '· ' : ''}{EVIDENCE_STATE.suspect.glyph} {EVIDENCE_TEXT.suspectTag} {formatNumber(suspect)}
</span>
)}
{uncitedClaims > 0 && (
<span className="tnum" style={{ color: 'var(--st-waiting)' }} data-evidence-line-claims="">
{numbers || suspect > 0 ? '· ' : ''}{EVIDENCE_TEXT.uncitedClaimsTag} {formatNumber(uncitedClaims)}
{claims && <ClaimTally claims={claims} lead={!!numbers || suspect > 0} />}
{uncitedLine && (
<span className="tnum" style={{ color: 'var(--st-waiting)' }} data-evidence-line-claims=""
title={claims ? JUDGE_TEXT.uncitedBesides : undefined}>
{numbers || suspect > 0 || claims ? '· ' : ''}{EVIDENCE_TEXT.uncitedClaimsTag} {formatNumber(uncitedClaims)}
</span>
)}
{(counts.none > 0 || counts.other > 0 || suspect > 0) && evidence?.onNext && (
{(counts.none > 0 || counts.other > 0 || suspect > 0 || !!claims?.unsupported || !!claims?.partial) && evidence?.onNext && (
<button type="button" className="btn btn-xs" data-evidence-next="" onClick={evidence.onNext}>
{EVIDENCE_TEXT.locateNext}
</button>
+168 -50
View File
@@ -8,13 +8,13 @@ import { ListChecks } from 'lucide-react'
import clsx from 'clsx'
import { rovingTarget } from '../components/ui'
import {
EVIDENCE_STATE, docTally, segmentKind, segmentLabel, segmentState, tallySummary, unitText, type EvidenceStateCode,
type EvidenceTally as Tally,
EVIDENCE_STATE, claimCounts, claimLabel, docTally, segmentKind, segmentLabel, segmentState, tallySummary, unitText,
isJudged, limitOf, unitVerdict, verdictState, withVerdicts, type EvidenceStateCode, type EvidenceTally as Tally,
} from '../lib/evidence'
import { formatNumber } from '../lib/format'
import { EVIDENCE_STATE_LABEL, EVIDENCE_TEXT, evidenceTally } from '../lib/terms'
import { useEvidence } from '../store/evidence'
import type { EvidenceBlock, EvidenceDocData, EvidenceSegment, EvidenceUnit } from '../types'
import { EVIDENCE_STATE_LABEL, EVIDENCE_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 { BlockView, Markdown, StyledSlice, inlineStyles, numericColumns, type InlineStyles } from './Markdown'
@@ -26,10 +26,13 @@ import { BlockView, Markdown, StyledSlice, inlineStyles, numericColumns, type In
* BlockView,和普通答案长得一模一样;有出处的数字画细实线,没有出处的画点状线、
* 句末挂「无证据」。
*
* 键盘:整份报告只占一个 Tab 位(roving tabindex)。←/→ 在有状态的片段间走,↑/↓ 按
* 结论句的判定(四期)只在句末挂一枚小徽标(◆ ◇ ! ?),整句不画线;证据不支持的整句铺一层浅底。
* 徽标也是按钮:点开是这一句的「模型的解释」。判定是模型给的,颜色一律不用确定性的绿。
*
* 键盘:整份报告只占一个 Tab 位(roving tabindex)。←/→ 在有状态的片段、句末徽标间走,↑/↓ 按
* 句子走(表格里按列走到上一行 / 下一行,出了表格接着按句子走),n / N 跳到下一处 /
* 上一处无证据,回车打开证据面板,Esc 关掉并留在原片段;面板开着时方向键走到哪、面板
* 跟到哪,Tab 进面板。
* 上一处无证据、证据不支持的地方,回车打开证据面板,Esc 关掉并留在原片段;面板开着时方向键走到哪、
* 面板跟到哪,Tab 进面板。
*
* 长报告:事件都挂在容器上(片段只带 data-seg),块用 memo,外加
* content-visibility: auto;超过 TEXT_CAP 的部分先折叠,跳转到折叠里的片段时自动展开。
@@ -47,11 +50,19 @@ export interface EvidenceDocHandle {
/** 和 AssistantStream 的长文本折叠同一个量级:先显示这么多字,后面的折起来 */
const TEXT_CAP = 3000
/** 句末徽标在键盘顺序里的键:片段 id 是 s0…,不会撞 */
const badgeKey = (unit: string) => `claim:${unit}`
const unitOfKey = (key: string) => (key.startsWith('claim:') ? key.slice(6) : null)
interface Badge { unit: EvidenceUnit; block: number; verdict: EvidenceVerdict; state: EvidenceStateCode }
interface Model {
/** 可点的片段,按正文顺序 */
/** 可点的片段和句末徽标,按正文顺序 */
order: string[]
index: Map<string, number>
seg: Map<string, { seg: EvidenceSegment; unit: EvidenceUnit; block: number }>
/** 句末徽标:键是 claim:<unit>,带着这一句眼下的判定 */
badge: Map<string, Badge>
/** 每个有可点片段的句子里第一个可点片段,按正文顺序(↑/↓ 用) */
unitFirst: string[]
unitOf: Map<string, number>
@@ -65,10 +76,11 @@ interface Model {
grid: Map<string, string>
}
function buildModel(blocks: EvidenceBlock[]): Model {
function buildModel(blocks: EvidenceBlock[], overlay?: Record<string, EvidenceVerdict>): 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 unitFirst: string[] = []
const unitOf = new Map<string, number>()
const alerts: string[] = []
@@ -96,12 +108,28 @@ function buildModel(blocks: EvidenceBlock[]): Model {
if (!grid.has(key)) grid.set(key, s.id)
}
}
// 句末徽标排在这一句的片段后面:←/→ 走到句尾就是它,证据不支持的也进 n / N
const verdict = unitVerdict(unit, overlay)
const state = verdictState(verdict)
if (verdict && state) {
const key = badgeKey(unit.id)
badge.set(key, { unit, block: b, verdict, state })
index.set(key, order.length)
order.push(key)
if (first) { unitFirst.push(key); first = false }
unitOf.set(key, unitFirst.length - 1)
if (EVIDENCE_STATE[state].alert) alerts.push(key)
}
}
chars.push(total)
})
return { order, index, seg, unitFirst, unitOf, alerts, chars, cell, grid }
return { order, index, seg, badge, unitFirst, unitOf, alerts, 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
/**
* ↑/↓ 该去哪。表格里按列走:下一行同一列(那一格空着就找这一行离它最近的一格);
* 走出表格的上下边,接着按句子走到表格前后那一句——不在表格里一格一格地横着挪
@@ -123,11 +151,11 @@ function verticalTarget(model: Model, id: string, down: boolean): string | null
// 出了表格:往下找表格之后的第一句,往上找表格之前的最后一句
const u = model.unitOf.get(id) ?? 0
const list = down ? model.unitFirst.slice(u + 1) : model.unitFirst.slice(0, u).reverse()
const out = list.find((sid) => model.seg.get(sid)?.block !== at.block)
const out = list.find((sid) => blockOf(model, sid) !== at.block)
if (out) return out
// 表格就是整份报告的首尾:和句子一样首尾相接
const wrap = down ? model.unitFirst : [...model.unitFirst].reverse()
return wrap.find((sid) => model.seg.get(sid)?.block !== at.block) ?? null
return wrap.find((sid) => blockOf(model, sid) !== at.block) ?? null
}
const u = model.unitOf.get(id) ?? 0
return model.unitFirst[(u + (down ? 1 : -1) + n) % n] ?? null
@@ -168,10 +196,24 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
onView?: (open: boolean) => void
/** 自己显示「N/N 数字有出处」那一条。上面已经有出具横幅(它会写这一行)时不用 */
tally?: boolean
}>(function EvidenceDoc({ doc, artifact, runId, dense = false, label, panel = 'auto', dock, onView, tally = false }, ref) {
/**
* 运行类别(formal / exploratory):「请模型判断这句」只在探索运行里有。开了裁判的文档自己记着
* (on_demand / inline),没开的看这里,不给就按运行 id 取一次
*/
runClass?: string
}>(function EvidenceDoc({ doc, artifact, runId, dense = false, label, panel = 'auto', dock, onView, tally = false, runClass }, ref) {
const blocks = useMemo(() => doc.blocks ?? [], [doc])
const model = useMemo(() => buildModel(blocks), [blocks])
const report = doc.node_id || undefined
// 封存之后按需追加的判定:盖过文档里的「未裁判 · 按需」。只换句末徽标,封存的文档一个字不改
const overlay = useVerdicts(runId, report)
const model = useMemo(() => buildModel(blocks, overlay), [blocks, overlay])
const counts = useMemo(() => docTally(doc), [doc])
const claims = useMemo(() => claimCounts(doc, overlay), [doc, overlay])
// 探索运行的报告(开了裁判的按需文档,或调用方说了是探索运行):证据图带着这次运行里之前按需判过的句子
// (reports[].post_seal_verdicts),取一次,重新打开页面时徽标还在。别的报告不多发这个请求
const loadGraph = useEvidence((s) => s.loadGraph)
const lateOnes = doc.judge?.mode === 'on_demand' || (runClass === 'exploratory' && doc.judge?.mode !== 'inline')
useEffect(() => { if (lateOnes && runId) void loadGraph(runId) }, [lateOnes, runId, loadGraph])
const rootRef = useRef<HTMLDivElement>(null)
const instance = useId()
const panelId = `${instance}-panel`
@@ -183,11 +225,17 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
const pendingFocus = useRef<string | null>(null)
const mode = usePanelMode(panel, dense)
// 换了一份文档:焦点位回到第一个片段,面板关掉
// 换了一份文档:焦点位回到第一个片段,面板关掉。只认文档本身——判定回来了(徽标变了)不算换文档,
// 面板正开着这一句的解释,不能因此收起
useEffect(() => {
setCurrent(model.order[0] ?? null)
setView(null)
}, [model])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [blocks])
// 拿着 Tab 位的徽标没了(判成「不是结论句」):Tab 位交回第一个,整份报告不能一个 Tab 位都没有
useEffect(() => {
if (current && !model.index.has(current)) setCurrent(model.order[0] ?? null)
}, [model, current])
// 同一时刻只开一个面板:另一份报告打开了自己的面板,这边的就收起
const owner = useEvidence((s) => s.owner)
@@ -205,15 +253,18 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
const shownBlocks = expanded ? blocks.length : foldAt
const totalChars = model.chars[model.chars.length - 1] ?? 0
const segEl = useCallback((id: string) =>
rootRef.current?.querySelector<HTMLElement>(`[data-seg="${CSS.escape(id)}"]`) ?? null, [])
const segEl = useCallback((id: string) => {
const unit = unitOfKey(id)
const sel = unit ? `[data-ev-claim="${CSS.escape(unit)}"]` : `[data-seg="${CSS.escape(id)}"]`
return rootRef.current?.querySelector<HTMLElement>(sel) ?? null
}, [])
/** 把焦点挪到某个片段上。在折叠里的先展开,渲染出来再聚焦 */
/** 把焦点挪到某个片段(或句末徽标)上。在折叠里的先展开,渲染出来再聚焦 */
const focusSeg = useCallback((id: string, opts?: { flash?: boolean }) => {
const hit = model.seg.get(id)
if (!hit) return
const block = blockOf(model, id)
if (block < 0) return
setCurrent(id)
if (hit.block >= shownBlocks) {
if (block >= shownBlocks) {
pendingFocus.current = id
setExpanded(true)
return
@@ -233,6 +284,13 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
}, [expanded, segEl])
const openSeg = useCallback((id: string) => {
const badge = model.badge.get(id)
if (badge) {
claim(instance)
setView({ kind: 'unit', id: badge.unit.id })
setAnnounce(`${JUDGE_TEXT.section}:${claimLabel(badge.unit, badge.verdict)}`)
return
}
const hit = model.seg.get(id)
if (!hit) return
claim(instance)
@@ -247,7 +305,7 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
}, [claim, instance, doc])
const close = useCallback((refocus = true) => {
const back = view?.kind === 'seg' ? view.id : current
const back = view?.kind === 'seg' ? view.id : view?.kind === 'unit' ? badgeKey(view.id) : current
setView(null)
setAnnounce('')
if (refocus && back) requestAnimationFrame(() => segEl(back)?.focus())
@@ -265,16 +323,18 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
const open = !!view
useEffect(() => { onView?.(open) }, [open, onView])
const viewKey = view?.kind === 'seg' ? view.id : view?.kind === 'unit' ? badgeKey(view.id) : null
useImperativeHandle(ref, () => ({
open: (id, opts) => {
if (!model.seg.has(id)) return false
if (!model.seg.has(id) && !model.badge.has(id)) return false
if (opts?.focus) focusSeg(id, { flash: true })
else setCurrent(id)
openSeg(id)
return true
},
next: (dir = 1) => {
const target = step(model.alerts, view?.kind === 'seg' ? view.id : current, dir)
const target = step(model.alerts, viewKey ?? current, dir)
if (target) {
focusSeg(target, { flash: true })
openSeg(target)
@@ -288,19 +348,20 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
openViolations()
return true
},
}), [step, model, view, current, focusSeg, openSeg, openViolations, doc])
}), [step, model, viewKey, current, focusSeg, openSeg, openViolations, doc])
// 事件委托:片段只带 data-seg,点击和按键都在容器上认
// 事件委托:片段只带 data-seg、句末徽标只带 data-ev-claim,点击和按键都在容器上认
const segOf = (target: EventTarget | null): string | null => {
const el = (target as HTMLElement | null)?.closest?.<HTMLElement>('[data-seg]')
return el && rootRef.current?.contains(el) ? el.dataset.seg ?? null : null
const el = (target as HTMLElement | null)?.closest?.<HTMLElement>('[data-seg], [data-ev-claim]')
if (!el || !rootRef.current?.contains(el)) return null
return el.dataset.seg ?? (el.dataset.evClaim ? badgeKey(el.dataset.evClaim) : null)
}
const onClick = (e: ReactMouseEvent) => {
const id = segOf(e.target)
if (!id) return
e.preventDefault()
setCurrent(id)
if (view?.kind === 'seg' && view.id === id) close(false)
if (viewKey === id) close(false)
else openSeg(id)
}
const onFocus = (e: ReactFocusEvent) => {
@@ -341,9 +402,9 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
if (view) openSeg(target)
}
const activeSeg = view?.kind === 'seg' ? view.id : null
const activeBlock = activeSeg ? model.seg.get(activeSeg)?.block ?? -1 : -1
const currentBlock = current ? model.seg.get(current)?.block ?? -1 : -1
const activeSeg = viewKey
const activeBlock = blockOf(model, activeSeg)
const currentBlock = blockOf(model, current)
const visual = blocks.length > 40
const panelNode = view && (
@@ -353,6 +414,7 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
doc={doc}
artifact={artifact}
runId={runId}
runClass={runClass}
view={view}
onClose={() => close()}
onLocate={(id) => { focusSeg(id, { flash: true }); openSeg(id) }}
@@ -361,14 +423,14 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
)
// 画不了线的两种分开说:列表序号、代码块标签里的数字,和正文里根本没有对应字的(句末依据里的引用)
const summary = tallySummary(counts, model.order.length > 0)
const summary = tallySummary({ ...counts, claims }, model.order.length > 0)
return (
<div ref={rootRef} data-evidence-doc="" className="min-w-0">
<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) && (
<EvidenceTally counts={counts} onNext={() => {
{tally && (counts.total > 0 || counts.other > 0 || !!counts.suspect || !!claims?.total) && (
<EvidenceTally counts={{ ...counts, claims }} onNext={() => {
const target = step(model.alerts, current, 1)
if (target) { focusSeg(target, { flash: true }); openSeg(target) } else openViolations()
}} onList={doc.violations?.length ? openViolations : undefined} />
@@ -388,7 +450,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} />
panelId={panelId} model={model} />
{mode === 'inline' && b === activeBlock && panelNode}
</Fragment>
))}
@@ -437,14 +499,18 @@ function usePanelMode(panel: 'auto' | PanelMode, dense: boolean): PanelMode {
return wide ? 'side' : 'drawer'
}
/** 证据条:没有出具横幅时由文档自己说「7/12 数字有出处 · 无证据 5 · 另有 1 处引用解析不了」 */
/**
* 证据条:没有出具横幅时由文档自己说「7/12 数字有出处 · 无证据 5 · 另有 1 处引用解析不了」,
* 有结论句判定时接着说「结论 4 句(支持 3 · 不支持 1)」
*/
export function EvidenceTally({ counts, onNext, onList }: {
counts: Pick<Tally, 'total' | 'cited' | 'none' | 'other' | 'suspect'>
counts: Pick<Tally, 'total' | 'cited' | 'none' | 'other' | 'suspect' | 'claims'>
onNext?: () => void
onList?: () => void
}) {
const suspect = counts.suspect ?? 0
const clean = !counts.none && !counts.other && !suspect
const claims = counts.claims
const clean = !counts.none && !counts.other && !suspect && !claims?.unsupported && !claims?.partial
const numbers = evidenceTally(counts.cited, counts.total, counts.other)
return (
<div className="mb-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-2xs" data-evidence-tally="">
@@ -459,6 +525,7 @@ export function EvidenceTally({ counts, onNext, onList }: {
{numbers ? '· ' : ''}{EVIDENCE_STATE.suspect.glyph} {EVIDENCE_TEXT.suspectTag} {formatNumber(suspect)}
</span>
)}
{claims && claims.total > 0 && <ClaimTally claims={claims} lead={!!numbers || suspect > 0} />}
{!clean && onNext && (
<button type="button" className="btn btn-xs" data-evidence-next="" onClick={onNext}>
{EVIDENCE_TEXT.locateNext}
@@ -473,6 +540,22 @@ export function EvidenceTally({ counts, onNext, onList }: {
)
}
/**
* 「结论 4 句(支持 3 · 不支持 1)」:和数字那一段同一种说法。判定是模型给的:全都支持也不用确定性的绿,
* 有不支持、部分支持、没挂依据的用提醒色,其余用暗字色(它是概率性的,不该和「有出处」一样笃定)
*/
export function ClaimTally({ claims, lead }: { claims: ClaimTallyCounts; lead: boolean }) {
const text = claimTally(claims)
if (!text) return null
const warn = claims.unsupported > 0 || claims.partial > 0 || claims.uncited > 0
return (
<span className="tnum" style={{ color: warn ? 'var(--st-waiting)' : 'var(--text-dim)' }} data-evidence-claims=""
title={JUDGE_TEXT.badge(null)}>
{lead ? '· ' : ''}{text}
</span>
)
}
// -------------------------------------------------------------------------
// 块与片段
// -------------------------------------------------------------------------
@@ -480,10 +563,12 @@ export function EvidenceTally({ counts, onNext, onList }: {
/** 列表项的开头:结构片段里带着行首的 - / 1. 。列表项被分成几句时,后面几句接在同一项里 */
const ITEM_START = /(^|\n)[ \t]*([-*+]|\d+[.)])[ \t]+$/
const DocBlock = memo(function DocBlock({ block, dense, doc, visual, current, active, panelId }: {
const DocBlock = memo(function DocBlock({ block, dense, doc, visual, current, active, panelId, model }: {
block: EvidenceBlock
dense: boolean
doc: EvidenceDocData
/** 句末徽标从这里取:这一句眼下的判定(封存后追加的盖过文档里的) */
model: Model
/**
* 长文档:块上加 content-visibility,屏幕外的块不排版不绘制。短文档不加:它自带 paint
* containment,贴着块边的片段的焦点描边会被裁掉一截,而短文档本来就不慢
@@ -502,7 +587,7 @@ 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} />
code={opts?.code} tag={opts?.tag ?? true} badge={model.badge.get(badgeKey(u.id))} />
)
switch (block.type) {
@@ -569,11 +654,12 @@ const DocBlock = memo(function DocBlock({ block, dense, doc, visual, current, ac
/**
* 一句话:文字按行内 Markdown 渲染(整句算一次样式,跨片段的 `code`、**粗体** 不会
* 被切坏),有状态的片段包成按钮。含无证据片段的句子末尾挂「? 无证据」
* 被切坏),有状态的片段包成按钮。含无证据片段的句子末尾挂「? 无证据」;有判定的结论句末尾挂
* 一枚徽标(也是按钮),证据不支持的整句铺浅底——字本身不画线
*/
function UnitView({ unit, doc, current, active, panelId, code, tag }: {
function UnitView({ unit, doc, current, active, panelId, code, tag, badge }: {
unit: EvidenceUnit; doc: EvidenceDocData; current: string | null; active: string | null; panelId: string
code?: boolean; tag: boolean
code?: boolean; tag: boolean; badge?: Badge
}) {
const segs = (unit.segments ?? []).filter((s) => s.kind !== 'structural')
const text = segs.map((s) => s.text).join('')
@@ -611,14 +697,41 @@ function UnitView({ unit, doc, current, active, panelId, code, tag }: {
</button>
)
})
const key = badge ? badgeKey(unit.id) : ''
const meta = badge ? EVIDENCE_STATE[badge.state] : null
const open = !!badge && active === key
return (
<>
{nodes}
{badge?.state === 'unsupported'
// 证据不支持:整句铺一层浅底(折行时每行各自带圆角),字色、字重都不动,也不画线
? <span className="ev-claim" data-ev-claim-shade={unit.id} style={{ '--ev-soft': meta!.soft } as CSSProperties}>{nodes}</span>
: nodes}
{tag && [...alerts].map((state) => (
<span key={state} className="ev-tag" aria-hidden="true" title={EVIDENCE_STATE[state].hint} data-ev-tag={state}>
{EVIDENCE_STATE[state].glyph}{TAG_TEXT[state]}
</span>
))}
{badge && meta && (
<button
type="button"
className="ev-badge"
data-ev-claim={unit.id}
data-ev-verdict={badge.state}
data-ev-post-seal={badge.verdict.post_seal ? '' : undefined}
tabIndex={current === key ? 0 : -1}
aria-label={claimLabel(unit, badge.verdict)}
aria-expanded={open}
aria-controls={open ? panelId : undefined}
// 模型判过的写明谁判的、非确定;模型没判过的(按需还没点、到上限、没跑成)只说这是什么状态——
// 到上限没判的也记着裁判模型,但不能写成「(模型 · 非确定)」,像是它判过一样
title={isJudged(badge.verdict)
? `${meta.label}${badge.verdict.post_seal ? ` · ${JUDGE_TEXT.postSeal}` : ''}(${badge.verdict.judge || JUDGE_TEXT.judgeModel} · 非确定)`
: limitOf(badge.verdict) ? `${meta.label}:${JUDGE_TEXT.limitNotJudged}` : `${meta.label}:${meta.hint}`}
style={segStyle(badge.state)}
>
<span aria-hidden="true">{meta.glyph}</span>
</button>
)}
</>
)
}
@@ -631,8 +744,10 @@ function UnitView({ unit, doc, current, active, panelId, code, tag }: {
*/
export const EvidenceField = forwardRef<EvidenceDocHandle, {
artifact: string; text: string; runId?: string; dense?: boolean; label?: string; tally?: boolean
/** 交给出具横幅数数的文档:封存之后按需追加的判定已经叠上去了(只给计数用,正文照样按封存的那份画) */
onDoc?: (doc: EvidenceDocData | null) => void
}>(function EvidenceField({ artifact, text, runId, dense, label, tally, onDoc }, ref) {
runClass?: string
}>(function EvidenceField({ artifact, text, runId, dense, label, tally, onDoc, runClass }, ref) {
const slot = useEvidence((s) => s.docs[artifact])
const loadDoc = useEvidence((s) => s.loadDoc)
useEffect(() => { void loadDoc(artifact) }, [artifact, loadDoc])
@@ -640,9 +755,12 @@ export const EvidenceField = forwardRef<EvidenceDocHandle, {
const mismatch = !!doc && (typeof doc.markdown !== 'string' || doc.markdown.trim() !== text.trim())
const otherRun = !!doc && !!runId && typeof doc.run_id === 'string' && !!doc.run_id && doc.run_id !== runId
const usable = doc && !mismatch && !otherRun ? doc : null
useEffect(() => { onDoc?.(usable) }, [usable, onDoc])
const overlay = useVerdicts(runId, usable?.node_id || undefined)
const counted = useMemo(() => (usable ? withVerdicts(usable, overlay) : null), [usable, overlay])
useEffect(() => { onDoc?.(counted) }, [counted, onDoc])
if (usable) {
return <EvidenceDoc ref={ref} doc={usable} artifact={artifact} runId={runId} dense={dense} label={label} tally={tally} />
return <EvidenceDoc ref={ref} doc={usable} artifact={artifact} runId={runId} dense={dense} label={label} tally={tally}
runClass={runClass} />
}
const note = slot?.status === 'error' ? EVIDENCE_TEXT.docMissing
: otherRun ? EVIDENCE_TEXT.docOtherRun : mismatch ? EVIDENCE_TEXT.docMismatch : ''
+319 -21
View File
@@ -3,22 +3,25 @@ import {
type ReactNode,
} from 'react'
import { createPortal } from 'react-dom'
import { ArrowLeft, CornerDownRight, ListChecks, ShieldCheck, X } from 'lucide-react'
import { Link } from 'react-router-dom'
import { ArrowLeft, CornerDownRight, ListChecks, Scale, ShieldCheck, X } from 'lucide-react'
import clsx from 'clsx'
import { StatusBadge, isComposing } from '../components/ui'
import { ApiError } from '../api/client'
import { Spinner, StatusBadge, isComposing } from '../components/ui'
import {
EVIDENCE_KIND_STYLE, EVIDENCE_STATE, caliberSourceText, caliberUpgradeText, closestNames, docForeign, entitySources,
evidenceTrace, evidenceValue as valueText, graphDoc, inputSource, integrityFailures, locatable, locatorText, notableInput,
queryOf, queryWindow, quoteWhere, quoteWindow, reasonOf, sealVerdict, segName, segmentKind, segmentState, sourceOf,
type SealStatus, type SourceTone,
evidenceTrace, evidenceValue as valueText, graphDoc, inputSource, integrityFailures, isJudged, judgeable, limitOf,
locatable, locatorText, notableInput, queryOf, queryWindow, quoteWhere, quoteWindow, reasonOf, rewriteOf, sealVerdict,
segName, segmentKind, segmentState, sourceOf, unitText, unitVerdict, verdictState, type SealStatus, type SourceTone,
} from '../lib/evidence'
import { humanizeError } from '../lib/errors'
import { formatDateTime, formatNumber, NONE, shortId } from '../lib/format'
import { EVIDENCE_TEXT } from '../lib/terms'
import { EVIDENCE_TEXT, JUDGE_TEXT } from '../lib/terms'
import { useCatalog } from '../store/catalog'
import { segmentKey, useEvidence } from '../store/evidence'
import { askKey, segmentKey, useEvidence, useExplore, useVerdicts } from '../store/evidence'
import type {
EvidenceDocData, EvidenceInput, EvidenceQuoteSource, EvidenceSeal, EvidenceSegment, EvidenceStep, EvidenceUnit,
EvidenceViolation,
EvidenceBlock, EvidenceDocData, EvidenceInput, EvidenceOnDemand, EvidenceQuoteSource, EvidenceSeal, EvidenceSegment,
EvidenceStep, EvidenceUnit, EvidenceVerdict, EvidenceViolation,
} from '../types'
import { ArtifactViewer, ResultTable } from './AssistantStream'
import { useEvidenceHost } from './evidenceHost'
@@ -46,21 +49,27 @@ import { CopyChip } from './Markdown'
* 名字给出最接近的已知名字),逐字引文(原文所在的文档和片段,引文在原文里的位置高亮)。
* 画布右栏里点开片段时,把证据路径交给画布(evidenceHost),节点名点一下就对准那个节点。
*
* 四期多了「模型的解释」:只对结论句显示——裁判模型的判定、理由(斜体)、带「模型判断 · 模型名 · 非确定」
* 的徽标,封存之后按需追加的另写「封存后追加」。探索运行里还没判过的句子给「请模型判断这句」;到了上限
* 写明是哪个上限、到哪里调。点句末徽标打开的是整句(kind: 'unit'):句子、模型的解释、挂的依据、封存。
*
* 四种摆法:side 从右侧弹出(宽屏),drawer 从底部抽出(窄屏),inline 在 360px 的
* 画布右栏里直接栏内展开,dock 放进页面给的一块常驻位置(记录页的「证据」页签)。
* 都不是模态的:开着面板照样能在正文里走。
*/
export type PanelMode = 'side' | 'drawer' | 'inline' | 'dock'
export type PanelView = { kind: 'seg'; id: string } | { kind: 'violations' }
export type PanelView = { kind: 'seg'; id: string } | { kind: 'unit'; id: string } | { kind: 'violations' }
export function EvidencePanel({ id, mode, doc, artifact, runId, view, onClose, onLocate, onViolations }: {
export function EvidencePanel({ id, mode, doc, artifact, runId, runClass, view, onClose, onLocate, onViolations }: {
id: string
mode: PanelMode
doc: EvidenceDocData
/** 正文这份文档的工件 id:和证据接口报的封存文档比对,不是同一份就不信接口给的链 */
artifact?: string
runId?: string
/** 运行类别:没开裁判的文档靠它认探索运行(「请模型判断这句」只在探索运行里有) */
runClass?: string
view: PanelView
onClose: () => void
/** 跳到正文里的某个片段(违规清单里的「定位」) */
@@ -69,7 +78,11 @@ export function EvidencePanel({ id, mode, doc, artifact, runId, view, onClose, o
}) {
const titleId = `${id}-title`
const found = useMemo(() => (view.kind === 'seg' ? findSeg(doc, view.id) : null), [doc, view])
const state = found ? segmentState(found.seg) : null
const claimAt = useMemo(() => (view.kind === 'unit' ? findUnit(doc, view.id) : null), [doc, view])
const overlay = useVerdicts(runId, doc.node_id || undefined)
const claimVerdict = claimAt ? unitVerdict(claimAt.unit, overlay) : undefined
const claimState = verdictState(claimVerdict)
const state = found ? segmentState(found.seg) : claimState
const meta = state ? EVIDENCE_STATE[state] : null
const onKeyDown = (e: ReactKeyboardEvent) => {
@@ -81,7 +94,8 @@ export function EvidencePanel({ id, mode, doc, artifact, runId, view, onClose, o
}
const title = view.kind === 'violations' ? EVIDENCE_TEXT.violations
: found ? segName(found.seg) : EVIDENCE_TEXT.panelTitle
: found ? segName(found.seg)
: claimAt ? unitText(claimAt.unit).trim() || JUDGE_TEXT.claim : EVIDENCE_TEXT.panelTitle
// 有出处的实体、引文:徽标写得更具体(「有出处 · 逐字引文」),颜色和字形照同一套
const kind = found && state === 'deterministic' ? segmentKind(found.seg) : null
const badge = kind ? { ...meta!, label: EVIDENCE_KIND_STYLE[kind].label, glyph: EVIDENCE_KIND_STYLE[kind].glyph } : meta
@@ -110,7 +124,10 @@ export function EvidencePanel({ id, mode, doc, artifact, runId, view, onClose, o
</button>
)}
{badge && (
<span className="chip shrink-0" style={{ color: badge.color, borderColor: badge.color }} data-ev-badge={badge.code}>
// 概率性的判定(结论句)用虚线框:和系统核对过的「有出处」连框线都不一样
<span className="chip shrink-0"
style={{ color: badge.color, borderColor: badge.color, borderStyle: badge.line === 'badge' ? 'dashed' : undefined }}
data-ev-badge={badge.code}>
{badge.glyph && <span aria-hidden>{badge.glyph}</span>}{badge.label}
</span>
)}
@@ -129,24 +146,35 @@ export function EvidencePanel({ id, mode, doc, artifact, runId, view, onClose, o
{view.kind === 'violations'
? <ViolationList doc={doc} onLocate={onLocate} />
: found
? <SegmentBody panelId={id} doc={doc} artifact={artifact} runId={runId} seg={found.seg} unit={found.unit}
onViolations={onViolations} />
: <p className="text-dim">{NONE}</p>}
? <SegmentBody panelId={id} doc={doc} artifact={artifact} runId={runId} runClass={runClass} seg={found.seg}
unit={found.unit} block={found.block} onViolations={onViolations} />
: claimAt
? <ClaimBody doc={doc} artifact={artifact} runId={runId} runClass={runClass} unit={claimAt.unit}
block={claimAt.block} />
: <p className="text-dim">{NONE}</p>}
</div>
</section>
)
}
function findSeg(doc: EvidenceDocData, id: string): { seg: EvidenceSegment; unit: EvidenceUnit } | null {
function findSeg(doc: EvidenceDocData, id: string): { seg: EvidenceSegment; unit: EvidenceUnit; block: EvidenceBlock } | null {
for (const block of doc.blocks ?? []) {
for (const unit of block.units ?? []) {
const seg = (unit.segments ?? []).find((s) => s.id === id)
if (seg) return { seg, unit }
if (seg) return { seg, unit, block }
}
}
return null
}
function findUnit(doc: EvidenceDocData, id: string): { unit: EvidenceUnit; block: EvidenceBlock } | null {
for (const block of doc.blocks ?? []) {
const unit = (block.units ?? []).find((u) => u.id === id)
if (unit) return { unit, block }
}
return null
}
/** 面板里的一节:小标题 + 内容 */
function Part({ title, children, ...rest }: { title: string; children: ReactNode } & Record<`data-${string}`, string>) {
return (
@@ -163,9 +191,10 @@ function flashOnce(el: HTMLElement) {
setTimeout(() => { if (el.dataset.flash === 'focus') delete el.dataset.flash }, 1600)
}
function SegmentBody({ panelId, doc, artifact, runId, seg, unit, onViolations }: {
function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block, onViolations }: {
panelId: string
doc: EvidenceDocData; artifact?: string; runId?: string; seg: EvidenceSegment; unit: EvidenceUnit
doc: EvidenceDocData; artifact?: string; runId?: string; runClass?: string; seg: EvidenceSegment; unit: EvidenceUnit
block: EvidenceBlock
onViolations: () => void
}) {
const report = doc.node_id || undefined
@@ -191,6 +220,7 @@ function SegmentBody({ panelId, doc, artifact, runId, seg, unit, onViolations }:
const inGraph = graphDoc(graphData, artifact)
const docIssue: 'foreign' | 'tampered' | null = foreign || inGraph === 'foreign' ? 'foreign'
: inGraph === 'tampered' ? 'tampered' : null
useLateVerdict(runId, report, docIssue ? undefined : detail?.unit, unit.id)
const state = segmentState(seg)
const cite = seg.cite
@@ -355,6 +385,9 @@ function SegmentBody({ panelId, doc, artifact, runId, seg, unit, onViolations }:
<p className="text-dim">{sourceOf(seg, doc) || detail?.note}</p>
)}
<JudgePart doc={doc} unit={unit} block={block} runId={runId} runClass={runClass}
onDemand={detail?.unit?.on_demand} pending={pending} />
{host.onNode && (trace.producers.length > 0 || trace.consumers.length > 0) && (
// 画布右栏:这段证据经过的节点,点一下在画布上选中并对准
<Part title="画布上的节点" data-ev-nodes="">
@@ -386,6 +419,271 @@ function SegmentBody({ panelId, doc, artifact, runId, seg, unit, onViolations }:
)
}
/**
* 片段接口答的这一句带着封存之后追加的判定(后端按最新的 evidence.judged 叠上的):记进缓存,正文的句末
* 徽标、横幅的计数跟着变。接口答的是另一份报告时调用方不传 unit
*/
function useLateVerdict(runId: string | undefined, report: string | undefined,
answered: { id?: string; verdict?: EvidenceVerdict } | undefined, unitId: string) {
const noteVerdicts = useEvidence((s) => s.noteVerdicts)
const late = answered?.verdict?.post_seal && (!answered.id || answered.id === unitId) ? answered.verdict : undefined
const sig = late ? JSON.stringify(late) : ''
useEffect(() => {
if (runId && late) noteVerdicts(runId, report, { [unitId]: late })
// 按内容比:同一条判定不重记
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [runId, report, unitId, sig, noteVerdicts])
}
/**
* 点句末徽标打开的整句:句子、模型的解释、挂的依据、封存。封存状态和叠上的判定借这句第一个片段的
* 片段接口取(接口答的是另一份报告时一概不用)
*/
function ClaimBody({ doc, artifact, runId, runClass, unit, block }: {
doc: EvidenceDocData; artifact?: string; runId?: string; runClass?: string; unit: EvidenceUnit; block: EvidenceBlock
}) {
const report = doc.node_id || undefined
const first = (unit.segments ?? []).find((s) => s.kind !== 'structural')
const key = runId && first ? segmentKey(runId, first.id, report) : ''
const slot = useEvidence((s) => (key ? s.segments[key] : undefined))
const loadSegment = useEvidence((s) => s.loadSegment)
useEffect(() => {
if (runId && first) void loadSegment(runId, first.id, report)
}, [runId, first, report, loadSegment])
const answered = slot?.status === 'ok' ? slot.data : undefined
const foreign = first ? docForeign(answered, { artifact, node: report, seg: first }) : null
useLateVerdict(runId, report, foreign ? undefined : answered?.unit, unit.id)
const overlay = useVerdicts(runId, report)
const verdict = unitVerdict(unit, overlay)
const seal = foreign ? undefined : answered?.seal
const late = !!verdict?.post_seal
const sealed = sealVerdict(seal, {
foreign: !!foreign, pending: !!runId && !seal && slot?.status === 'loading', noRun: !runId, docOnly: true,
})
// 一整句没有「这一段的证据」可言:封存核对通过时只说报告文档封存了,节点里当场判的判定跟着一起封存
const sealLabel = sealed.status !== 'done' ? sealed.label
: verdict && !late && isJudged(verdict) ? JUDGE_TEXT.sealedWithDoc : JUDGE_TEXT.sealedDoc
const cites = (unit.cites ?? []).map((alias) => ({ alias, label: doc.catalog?.[alias]?.label as string | undefined }))
return (
<>
{foreign && (
<IntegrityList code="doc" items={[EVIDENCE_TEXT.integrity.doc,
...(foreign.sealedText != null ? [EVIDENCE_TEXT.integrity.sealedText(foreign.sealedText)] : [])]} />
)}
<Part title={JUDGE_TEXT.claim} data-ev-sentence="">
<Sentence unit={unit} active="" />
</Part>
<JudgePart doc={doc} unit={unit} block={block} runId={runId} runClass={runClass}
onDemand={foreign ? undefined : answered?.unit?.on_demand}
pending={!!runId && !!first && (!slot || slot.status === 'loading')} />
<Part title={JUDGE_TEXT.cites} data-ev-cites="">
{cites.length ? (
<ul className="space-y-0.5">
{cites.map((c) => (
<li key={c.alias} className="flex min-w-0 items-baseline gap-1.5">
<span className="mono shrink-0 font-medium">{c.alias}</span>
{c.label && <span className="min-w-0 truncate text-dim" title={c.label}>{c.label}</span>}
</li>
))}
</ul>
) : <p className="text-dim">{JUDGE_TEXT.noCites}</p>}
</Part>
<Part title={EVIDENCE_TEXT.seal} data-ev-seal="">
<SealLine status={sealed.status} label={sealLabel} />
{late && (
// 封存后追加的判定:报告文档封存着,这条判断不在封存范围里——两件事分开说
<p className="mt-1 flex items-center gap-1.5" data-ev-seal-late="">
<StatusBadge status="waiting" size={12} decorative animate={false} />
<span>{JUDGE_TEXT.postSeal}:{JUDGE_TEXT.postSealHint}</span>
</p>
)}
</Part>
</>
)
}
/**
* 模型的解释:只对结论句。判定、理由(斜体)、「模型判断 · 模型名 · 非确定」的徽标,封存之后按需追加的
* 另写「封存后追加」;到上限没判的写明哪个上限、到哪里调;改写过一次的写明原句。探索运行里还没判过的
* (没有判定、未裁判)给「请模型判断这句」。什么都说不上的(正式运行、没开裁判的文档)不画这一节
*/
function JudgePart({ doc, unit, block, runId, runClass, onDemand, pending }: {
doc: EvidenceDocData; unit: EvidenceUnit; block: EvidenceBlock; runId?: string; runClass?: string
/**
* 片段接口答的「这句能不能请模型判断」:有它就照它(运行还没跑完封存、封存被改过这些前端认不出来);
* 老后端没有时按运行类别和文档自己认
*/
onDemand?: EvidenceOnDemand
/** 片段接口还在取:先不按运行类别另取一次 */
pending?: boolean
}) {
const report = doc.node_id || undefined
const overlay = useVerdicts(runId, report)
const verdict = unitVerdict(unit, overlay)
const eligible = judgeable(unit, block)
const known = onDemand && typeof onDemand.available === 'boolean' ? onDemand : undefined
const explore = useExplore(doc, runId, runClass, eligible && !isJudged(verdict) && !known && !pending)
const ask = useEvidence((s) => (runId ? s.asks[askKey(runId, report, unit.id)] : undefined))
const judge = useEvidence((s) => s.judge)
const state = verdictState(verdict)
const meta = state ? EVIDENCE_STATE[state] : null
const limit = limitOf(verdict)
const late = !!verdict?.post_seal
const rewrite = rewriteOf(doc, unit.id)
const judgeDoc = !!doc.judge
const canAsk = !!runId && !isJudged(verdict) && (known ? !!known.available : eligible && explore === true)
// 探索运行里暂时判不了(还没跑完封存、封存被改过、升级前的运行):照接口的原话说为什么没有按钮
const blocked = known && !known.available && BLOCKED.has(known.reason ?? '') ? known.message || '' : ''
// 这一次按需裁判的答复里没有这一句的判定:接口说了为什么(跳过了、触顶)就照说
const res = ask?.status === 'ok' ? ask.data : undefined
const skipped = res?.skipped?.[unit.id]
const resLimit = !verdict || verdict.status !== 'unjudged' ? null
: (Array.isArray(res?.limits_hit) && res.limits_hit.find((l) => typeof l === 'string')) || null
const shownLimit = limit ?? resLimit
const judged = isJudged(verdict)
// 这一次答复的那句话(message):判定之外接口还想说的,照原话写出来。最要紧的是「运行已经接着跑了,这次的
// 判定没有记进运行记录」——判定照样交回来、也标着封存后追加,只看徽标会以为它记进去了。已经由别处说过的不重复:
// 跳过的那一行就是它、上限的那一框说的就是它、和判定里写的理由一字不差
const resMessage = typeof res?.message === 'string' ? res.message.trim() : ''
const showMessage = !!resMessage && !(skipped && !judged) && !(shownLimit && resMessage.startsWith(JUDGE_TEXT.limit))
&& resMessage !== verdict?.rationale
// 只对结论句:表格单元格、代码、标题不是一句话,开了裁判的文档里也不画这一节
const show = !!verdict || (judgeDoc && eligible) || canAsk || !!blocked || ask?.status === 'error' || showMessage
if (!show) return null
const asking = ask?.status === 'loading'
const how = shownLimit ? JUDGE_TEXT.limitHow[late || explore ? 'click' : 'report'][shownLimit] : ''
return (
<Part title={JUDGE_TEXT.section} data-ev-judge={verdict?.status ?? 'none'}>
{verdict && judged && (
// 「模型判断 · 模型名 · 非确定」只挂在模型真判过的句子上:到上限、没跑成的未裁判也记着裁判模型,
// 但模型没看过这句,挂上这枚徽标就像它判过一样
<div className="mb-1 flex flex-wrap items-center gap-1">
<span className="chip max-w-full truncate" data-ev-judge-badge=""
style={{ color: 'var(--st-running)', borderColor: 'var(--st-running)', borderStyle: 'dashed' }}
title={late ? JUDGE_TEXT.postSealHint : JUDGE_TEXT.sealedHint}>
<Scale size={10} aria-hidden /> {JUDGE_TEXT.badge(verdict.judge)}
</span>
{late && (
<span className="chip" data-ev-post-seal="" style={{ color: 'var(--st-waiting)', borderColor: 'var(--st-waiting)', borderStyle: 'dashed' }}
title={JUDGE_TEXT.postSealHint}>
{JUDGE_TEXT.postSeal}
</span>
)}
</div>
)}
{verdict && (
<p className="font-medium" style={{ color: meta?.color ?? 'var(--text-dim)' }} data-ev-verdict={verdict.status}>
{meta?.glyph && <span aria-hidden className="mr-1">{meta.glyph}</span>}
{meta ? meta.label : JUDGE_TEXT.notClaim}
</p>
)}
{showMessage && (
<p className="mb-1 mt-0.5 text-2xs leading-relaxed" role="note" style={{ color: 'var(--st-waiting)' }} data-ev-judge-message="">
{resMessage}
</p>
)}
{verdict && !judged && verdict.judge && (
// 没判的句子只中性地说一句裁判模型是谁(到上限时知道是哪个模型的价钱、哪个模型没跑成)
<p className="mt-0.5 text-2xs text-faint" data-ev-judge-model="">{JUDGE_TEXT.notJudgedBy(verdict.judge)}</p>
)}
{verdict?.rationale && !shownLimit && (
// 理由是模型写的话:斜体,和系统给的说明分开
<p className="mt-0.5 italic text-dim" data-ev-rationale=""><em>{verdict.rationale}</em></p>
)}
{shownLimit && (
<div className="mt-1 rounded border px-2 py-1" role="note" data-ev-judge-limit={shownLimit}
style={{ borderColor: 'var(--st-waiting)', background: 'var(--st-waiting-soft)' }}>
<p><span className="font-medium" style={{ color: 'var(--st-waiting)' }}>{JUDGE_TEXT.limit}</span>
{limitWords(verdict?.rationale) ? `:${limitWords(verdict?.rationale)}` : ''}</p>
{how && (
<p className="mt-0.5 text-2xs text-dim" data-ev-judge-how="">
{how}
{how.startsWith('到「设置') && (
<> <Link to="/settings/prefs" className="text-[var(--accent)] underline-offset-2 hover:underline" data-ev-judge-settings="">去设置</Link></>
)}
</p>
)}
</div>
)}
{verdict?.status === 'unjudged' && !shownLimit && verdict.reason !== 'on_demand' && verdict.rationale && (
<p className="mt-0.5 text-dim" data-ev-judge-why="">{verdict.rationale}</p>
)}
{!!verdict?.used?.length && judged && (
<p className="mono mt-0.5 text-2xs text-faint" data-ev-used="">{JUDGE_TEXT.used(verdict.used)}</p>
)}
{!verdict && judgeDoc && eligible && !canAsk && (
// 开了裁判的文档里没有判定的结论句:预筛放掉的照实说;探索运行里暂时判不了的由上面 blocked 说
<p className="text-dim" data-ev-judge-screened="">
{doc.judge?.screened?.includes(unit.id) || explore !== false ? JUDGE_TEXT.screened : JUDGE_TEXT.formalOnly}
</p>
)}
{rewrite?.kind === 'changed' && (
<div className="mt-1 text-2xs" data-ev-rewritten="">
<p style={{ color: 'var(--st-waiting)' }}>{JUDGE_TEXT.rewritten}</p>
{rewrite.sentences.length > 0 && (
<p className="mt-0.5 text-dim">{JUDGE_TEXT.rewriteFrom}:{rewrite.sentences.map((t) => `「${t}」`).join('')}</p>
)}
</div>
)}
{rewrite?.kind === 'rejected' && (
<p className="mt-1 text-2xs" style={{ color: 'var(--st-waiting)' }} data-ev-rewrite-rejected="">
{JUDGE_TEXT.rewriteRejected(rewrite.reason)}
</p>
)}
{verdict && !late && judged && (
// 封存后追加的那句说明放在「封存」一节(它说的是封存);节点里当场判的在这里说一句随报告封存
<p className="mt-1 text-2xs text-faint" data-ev-judge-sealed="">{JUDGE_TEXT.sealedHint}</p>
)}
{skipped && !judged && (
<p className="mt-1 text-2xs text-dim" data-ev-judge-skipped={skipped}>
{res?.message || (skipped === 'not_a_claim' ? JUDGE_TEXT.notClaim : JUDGE_TEXT.notFound)}
</p>
)}
{blocked && !canAsk && (
<p className="mt-1 text-2xs text-dim" data-ev-judge-blocked={known?.reason ?? ''}>{blocked}</p>
)}
{ask?.status === 'error' && (
<p className="mt-1 text-2xs" style={{ color: 'var(--st-failed)' }} role="alert" data-ev-judge-error="">
{askError(ask.error)}
</p>
)}
{canAsk && (
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1">
{!verdict || verdict.reason === 'on_demand' ? <span className="text-dim">{JUDGE_TEXT.notAsked}</span> : null}
<button type="button" className="btn btn-xs" data-ev-judge-ask="" disabled={asking} aria-busy={asking || undefined}
onClick={() => { if (runId) void judge(runId, report, [unit.id]) }}>
{asking ? <Spinner size={10} /> : <Scale size={11} aria-hidden />}
{asking ? JUDGE_TEXT.asking : verdict && verdict.reason !== 'on_demand' ? JUDGE_TEXT.askAgain : JUDGE_TEXT.ask}
</button>
<span className="basis-full text-2xs text-faint">{JUDGE_TEXT.askHint}</span>
</div>
)}
</Part>
)
}
/** 「已到上限(这份报告的裁判金额上限 $0.05),这句没判」→「这份报告的裁判金额上限 $0.05,这句没判」 */
function limitWords(rationale: string | undefined): string {
if (!rationale) return ''
const m = /^已到上限((.+?))[,,]?\s*(.*)$/.exec(rationale)
return m ? [m[1], m[2]].filter(Boolean).join(',') : rationale.replace(/^已到上限[::]?/, '')
}
/** 接口说这句暂时判不了、值得告诉人为什么的几种(正式运行、判过了、不是结论句不用说) */
const BLOCKED = new Set(['unsealed', 'seal_broken', 'legacy'])
/**
* 按需裁判没成:409 是这次运行不让判(正式运行、还没跑完封存、封存被改过),照后端的原话;老后端没有这个
* 接口另有说法;其余按通用的错误说法
*/
function askError(error: unknown): string {
if (error instanceof ApiError) {
if (error.status === 409) return error.message || JUDGE_TEXT.formalOnly
if ((error.status === 404 || error.status === 405) && !error.code) return JUDGE_TEXT.oldBackend
}
return JUDGE_TEXT.failed(humanizeError(error).title)
}
type InputLink = { index: number; alias: string; go: () => void } | undefined
/**
File diff suppressed because it is too large Load Diff
+100 -5
View File
@@ -1,7 +1,7 @@
import type { RunEvent, TeamMember, TeamRound, TeamRun, ToolChange } from '../types'
import type { NodeState, RunPhase } from './trace'
import { formatDuration, formatNumber } from '../lib/format'
import { TYPE_LABEL, evidenceTally, issuanceLabel, nodeTypeLabel } from '../lib/terms'
import { TYPE_LABEL, claimTally, evidenceTally, issuanceLabel, nodeTypeLabel } from '../lib/terms'
import { statsTally } from '../lib/evidence'
// 泳道数据画布也要用(supervisor 节点要展开成协作矩阵),所以类型放在
@@ -646,6 +646,23 @@ function queryTitle(sql: string, source: string): string {
/** agent 开了 cite_fields 时循环结束后那次结构化抽取 */
const EXTRACT_TITLE = '按出处抽取字段'
/**
* report.checked 带的裁判摘要 → 「结论 4 句(支持 1 · 不支持 1 · 未裁判 2)」,和出具横幅同一种说法。
* 「不是结论句」不算;没有摘要(没开裁判、老后端)返回空串
*/
function judgeTally(judge: unknown): string {
const c = judge && typeof judge === 'object' ? (judge as { counts?: Record<string, unknown> }).counts : undefined
if (!c || typeof c !== 'object') return ''
const n = (k: string) => (typeof c[k] === 'number' && Number.isFinite(c[k] as number) ? c[k] as number : 0)
const counts = { supported: n('supported'), partial: n('partial'), unsupported: n('unsupported'), unjudged: n('unjudged'),
uncited: 0 }
return claimTally({ ...counts, total: counts.supported + counts.partial + counts.unsupported + counts.unjudged })
}
/** 报告撰写节点请裁判模型判断结论句(四期,claims: judge):units 是这一批几句 */
const judgeTitle = (units: unknown) =>
`请裁判模型判断${typeof units === 'number' && units > 0 ? ` ${formatNumber(units)} 句` : ''}结论`
const JUDGE_FAILED_TITLE = '结论句裁判没跑成'
/** 审批行末尾:批的时候点了「始终允许」,这个工具之后不再问人 */
const ALWAYS_NOTE = ',并设为「始终允许 · 门控把关」'
@@ -731,6 +748,59 @@ function explainLog(code: string | undefined, message: string): Pick<Step, 'titl
next: '模型说的出处在查询结果里找不到。让它先查到这些字段再交,查不到的就是空',
}
}
case 'judge_limit': {
// 「结论句裁判已到上限(这份报告的裁判金额上限 $0.05):3 句没判,记为未裁判;已判的保留」(judge.py run_request)
const n = message.match(/[::]\s*(\d+)\s*句没判/)?.[1]
return {
title: `结论句裁判已到上限${n ? `,${n} 句没判` : ''}(记为未裁判,已判的保留)`,
sub: message.match(/已到上限((.+?))/)?.[1],
next: '没判的结论句不能算完整出具。到报告撰写节点的「结论句裁判」调高上限(也可以设成不限),或到设置 → 证据裁判改默认值',
fix: 'canvas',
}
}
case 'judge_failed': {
// 「结论句裁判没跑完:有 2 句结论没裁判:裁判调用失败(…)」
return {
title: '结论句裁判没跑完,没判的记为未裁判',
sub: message.replace(/^结论句裁判没跑完[::]\s*/, '') || undefined,
next: '没判的结论句记一条缺口,出具不能算完整。先看裁判模型的接入(设置 → 证据裁判)能不能用,再重跑',
}
}
case 'judge_unpriced': {
// 「模型「x」不在价格目录里,按令牌估不出金额:金额上限(每份报告、每次点击、每日)对它不起作用,…」
const model = message.match(/模型「(.+?)」/)?.[1]
return {
title: `裁判模型${model ? `「${model}」` : ''}估不出金额,金额上限对它不起作用`,
sub: message.split(/[::]/).slice(1).join(':').trim() || undefined,
next: '费用只受句数、时长上限约束。要按金额控住,换一个价格目录里有的裁判模型',
}
}
case 'judge_same_model': {
// 「裁判模型和写作模型都是「x」:等于自己审自己,…。到设置里选一个不同的「证据裁判模型」…」
const model = message.match(/都是「(.+?)」/)?.[1]
return {
title: `裁判模型和写作模型都是${model ? `「${model}」` : '同一个'}:等于自己审自己`,
next: '到设置 → 证据裁判选一个不同的模型,或者在报告撰写节点的「结论句裁判」里指定裁判模型',
fix: 'canvas',
}
}
case 'report_rewrite': {
// 「裁判认为 2 句结论证据不支持(「…」「…」),已交回写作者只改这几句」
const n = message.match(/裁判认为\s*(\d+)\s*句/)?.[1]
return {
title: `裁判认为${n ? ` ${n} 句` : ''}结论证据不支持,已交回写作者只改这几句`,
sub: message.match(/证据不支持((.+))/)?.[1],
}
}
case 'report_rewrite_rejected': {
// 「改写稿冒出 1 处原稿没有的问题(「…」),没有采用,保留原稿和原来的判定」
const n = message.match(/冒出\s*(\d+)\s*处/)?.[1]
return {
title: `改写稿冒出${n ? ` ${n} 处` : ''}原稿没有的问题,没有采用`,
sub: message.match(/问题((.+))/)?.[1],
next: '保留的是原稿和原来的判定',
}
}
case 'repair_invented': {
// 「第 1 次修复作废:修复时出现了原文没有的值:total_count=0」
const values = message.match(/原文没有的值[::]\s*(.+)$/)?.[1]?.trim()
@@ -1387,12 +1457,15 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt
// 一出现就成行:模型想的那几十秒里得有一行在走,而不是等 end 才冒出来。
// 团队成员和调度者的调用没有 start(它们的 end 只用来记账),不会走到这
closeLlm(nodeId, 'done', at)
// agent 开了 cite_fields:循环结束后多一次结构化抽取,按出处把字段交出来。它不是在「作答」
// agent 开了 cite_fields:循环结束后多一次结构化抽取,按出处把字段交出来。它不是在「作答」。
// 报告撰写节点请裁判模型判断结论句(purpose judge)同理,另起名字
const extract = d.purpose === 'cite_fields'
const judging = d.purpose === 'judge'
const step: Step = {
id: `llm-${seq}`, seq, kind: 'llm', title: extract ? EXTRACT_TITLE : '思考并作答',
id: `llm-${seq}`, seq, kind: 'llm',
title: extract ? EXTRACT_TITLE : judging ? judgeTitle(d.units) : '思考并作答',
status: 'running', nodeId, startedAt: at,
...(extract ? { code: 'cite_fields' } : {}),
...(extract ? { code: 'cite_fields' } : judging ? { code: 'judge' } : {}),
...(d.model ? { detail: `模型:${d.model}` } : {}),
}
pendingLlm.set(nodeId ?? '_', step)
@@ -1429,6 +1502,12 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt
step.title = `${EXTRACT_TITLE}没跑成`
step.detail = [String(d.error), step.detail].filter(Boolean).join('\n')
}
if (d.purpose === 'judge' && d.error) {
// 裁判没跑成(超时、调用失败):这一批记为未裁判、报告照常交——同样是提醒,不是失败
step.level = 'warn'
step.title = JUDGE_FAILED_TITLE
step.detail = [String(d.error), step.detail].filter(Boolean).join('\n')
}
pendingLlm.delete(nodeId ?? '_')
break
}
@@ -1684,7 +1763,8 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt
const none = counts ? counts.none + counts.other : 0
const violations: any[] = Array.isArray(d.violations) ? d.violations : []
const repairs = num(d.repairs) ?? 0
const tally = counts ? evidenceTally(counts.cited, counts.total, counts.other) : ''
const tally = [counts ? evidenceTally(counts.cited, counts.total, counts.other) : '', judgeTally(d.judge)]
.filter(Boolean).join(' · ')
const listed = violations.slice(0, 8)
.map((v) => `· ${String(v?.message ?? v?.text ?? v?.code ?? '')}`).filter((x) => x.length > 2)
const more = violations.length > listed.length ? [`…另有 ${violations.length - listed.length} 处`] : []
@@ -1701,6 +1781,21 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt
break
}
case 'evidence.judged': {
// 探索运行跑完、封存之后,有人点开结论句请模型判断:判定追加在封存之后,不改封存的报告
const verdicts = d.verdicts && typeof d.verdicts === 'object' ? Object.values(d.verdicts as Record<string, any>) : []
const judged = verdicts.filter((v) => ['supported', 'partial', 'unsupported', 'not_a_claim'].includes(v?.status)).length
const hit: string[] = Array.isArray(d.limits_hit) ? d.limits_hit : []
push({
id: `ej-${seq}`, seq, kind: 'note', nodeId, status: 'done', code: 'evidence_judged',
level: hit.length || (Array.isArray(d.gaps) && d.gaps.length) ? 'warn' : 'info',
title: `封存后按需裁判了 ${formatNumber(judged)} 句结论${hit.length ? ',有的到了上限没判' : ''}`,
sub: '封存后追加 · 模型判断,非确定',
...(d.model ? { detail: [`模型:${d.model}`, ...(Array.isArray(d.gaps) ? d.gaps.map(String) : [])].join('\n') } : {}),
}, nodeId)
break
}
case 'caliber.upgrade':
out.push({
id: `cu-${seq}`, seq, kind: 'note', level: 'warn', status: 'done',
+15
View File
@@ -392,6 +392,21 @@ export function hasPendingApproval(approvals: Approval[], runId: string | null |
return approvals.some((a) => a.run_id === runId && a.status === 'pending')
}
/**
* 只写了模型、没选接入时,后端按模型名找接入(providers/factory.resolve_provider):启用的接入里模型清单有它的,
* 再看默认模型是它的;只有停用的接入认得它时后端直接报错(不会悄悄换一家)。都认不出返回 undefined——
* 后端这时用第一个启用的非 mock 接入去调
*/
export function providerOfModel(providers: Provider[], model: string):
{ provider: Provider; enabled: boolean } | undefined {
const owns = (p: Provider) => (p.models ?? []).some((m) => m?.id === model)
const enabled = providers.filter((p) => p.enabled)
const hit = enabled.find(owns) ?? enabled.find((p) => p.default_model === model)
if (hit) return { provider: hit, enabled: true }
const off = providers.find((p) => !p.enabled && (owns(p) || p.default_model === model))
return off ? { provider: off, enabled: false } : undefined
}
/** 把所有 provider 的模型拉平成下拉选项。 */
export function modelOptions(providers: Provider[]): { value: string; label: string; group: string }[] {
const out: { value: string; label: string; group: string }[] = []
+92 -1
View File
@@ -1,6 +1,10 @@
import { useEffect, useMemo } from 'react'
import { create } from 'zustand'
import { ApiError, api } from '../api/client'
import type { EvidenceDocData, EvidenceGraph, EvidenceSegmentDetail, EvidenceStats } from '../types'
import { graphVerdicts, judgedVerdicts } from '../lib/evidence'
import type {
EvidenceDocData, EvidenceGraph, EvidenceJudgeResult, EvidenceSegmentDetail, EvidenceStats, EvidenceVerdict,
} from '../types'
/**
* 可点击证据的缓存:报告文档、证据图、点开过的片段。
@@ -12,6 +16,10 @@ import type { EvidenceDocData, EvidenceGraph, EvidenceSegmentDetail, EvidenceSta
*
* 出错的不缓存成定论:下次点开再取一次(后端刚重启、接口还没部署到这个版本都会是
* 一时的)。正在取的不重复发。
*
* 四期:封存之后按需追加的结论句判定按 `runId@报告` 记在 judged 里(按需裁判接口、片段接口叠上的
* 判定都记进来),正文的句末徽标、横幅的结论句计数据此盖过文档里的「未裁判 · 按需」。封存的文档本身
* 一个字不改。同一句正在判的不重复发;判过的(判定不是未裁判)不再给按钮,也就不会再发
*/
export interface Slot<T> {
@@ -49,8 +57,23 @@ interface EvidenceState {
* 不一定还是最近的。清掉以后,运行摘下来(「清除」、换一张图再回来)时 loadLatest 重新取
*/
dropLatest: (workflowId: string) => void
/** 封存之后追加的判定:`runId@报告` → {unit: 判定} */
judged: Record<string, Record<string, EvidenceVerdict>>
/** 按需裁判的请求:`runId@报告:unit` → 这一次的答复(触顶、失败说什么靠它) */
asks: Record<string, Slot<EvidenceJudgeResult>>
/** 运行类别(formal / exploratory):没开裁判的文档靠它认探索运行,按需取一次 */
runClasses: Record<string, Slot<string | null>>
/** 记下几句封存后追加的判定(片段接口叠上的、证据图带来的) */
noteVerdicts: (runId: string, report: string | undefined, verdicts: Record<string, EvidenceVerdict>) => void
/** 请模型判断这几句(探索运行)。判定记进 judged;答复(含触顶、出错)记进 asks */
judge: (runId: string, report: string | undefined, units: string[]) => Promise<void>
loadRunClass: (runId: string) => Promise<void>
}
/** 封存后追加的判定按哪份报告记:一次运行里有好几份报告时 unit id 会撞(每份都从 u0 数起) */
export const docKey = (runId: string, report?: string) => `${runId}@${report ?? ''}`
export const askKey = (runId: string, report: string | undefined, unit: string) => `${docKey(runId, report)}:${unit}`
/**
* 片段的缓存键:`runId:segId`。一次运行里有好几份报告时片段 id 会撞(每份都从 s0 数起),
* 带上报告节点:`runId:segId@report`
@@ -134,5 +157,73 @@ export const useEvidence = create<EvidenceState>((set, get) => {
return { latest }
})
},
judged: {},
asks: {},
runClasses: {},
noteVerdicts: (runId, report, verdicts) => {
const key = docKey(runId, report)
const cur = get().judged[key] ?? {}
// 内容一样就不换引用:正文按它重算徽标,片段接口每次答同样的判定不该让整份报告重渲染
const fresh = Object.entries(verdicts).filter(([u, v]) => JSON.stringify(cur[u]) !== JSON.stringify(v))
if (!fresh.length) return
set((s) => ({ judged: { ...s.judged, [key]: { ...(s.judged[key] ?? {}), ...Object.fromEntries(fresh) } } }))
},
judge: async (runId, report, units) => {
const keys = units.map((u) => askKey(runId, report, u))
if (!units.length || keys.some((k) => get().asks[k]?.status === 'loading')) return
const mark = (slot: Slot<EvidenceJudgeResult>) =>
set((s) => ({ asks: { ...s.asks, ...Object.fromEntries(keys.map((k) => [k, slot])) } }))
mark({ status: 'loading' })
try {
const res = await api.evidence.judge(runId, { units, ...(report ? { report } : {}) })
get().noteVerdicts(runId, report, judgedVerdicts(res))
mark({ status: 'ok', data: res })
} catch (error) {
mark({ status: 'error', error })
}
},
loadRunClass: (runId) => {
const cur = get().runClasses[runId]
if (cur && cur.status !== 'error') return Promise.resolve()
set((s) => ({ runClasses: { ...s.runClasses, [runId]: { status: 'loading' } } }))
return api.runs.get(runId).then(
(run) => set((s) => ({ runClasses: { ...s.runClasses, [runId]: { status: 'ok', data: run?.run_class ?? null } } })),
(error) => set((s) => ({ runClasses: { ...s.runClasses, [runId]: { status: 'error', error } } })),
)
},
}
})
/**
* 一份报告封存之后追加的判定:证据图带来的(reports[].judged),叠上这一页里按需判的、片段接口答的
* (后者新)。没有运行、什么都没有时是 undefined
*/
export function useVerdicts(runId: string | undefined, report: string | undefined):
Record<string, EvidenceVerdict> | undefined {
const own = useEvidence((s) => (runId ? s.judged[docKey(runId, report)] : undefined))
const graph = useEvidence((s) => (runId ? s.graphs[runId]?.data : undefined))
return useMemo(() => {
const fromGraph = graphVerdicts(graph, report)
if (!own && !Object.keys(fromGraph).length) return undefined
return { ...fromGraph, ...(own ?? {}) }
}, [own, graph, report])
}
/**
* 这份报告出自探索运行吗(按需裁判只在探索运行里有)。开了裁判的文档自己记着:on_demand 是探索运行、
* inline 是正式运行;没开裁判的看调用方给的运行类别,再没有就按运行 id 取一次(enabled 时才取)。
* 还不知道时是 null:按钮先不出,免得在正式运行里闪一下
*/
export function useExplore(doc: Pick<EvidenceDocData, 'judge'>, runId: string | undefined, runClass: string | undefined,
enabled: boolean): boolean | null {
const mode = doc.judge?.mode
const known = mode === 'on_demand' ? true : mode === 'inline' ? false
: runClass ? runClass === 'exploratory' : undefined
const slot = useEvidence((s) => (runId ? s.runClasses[runId] : undefined))
const loadRunClass = useEvidence((s) => s.loadRunClass)
const need = known === undefined && enabled && !!runId
useEffect(() => { if (need && runId) void loadRunClass(runId) }, [need, runId, loadRunClass])
if (known !== undefined) return known
if (!runId) return false
return slot?.status === 'ok' ? slot.data === 'exploratory' : null
}
+110 -2
View File
@@ -706,6 +706,95 @@ export interface EvidenceUnit {
loc?: { row: number; col: number }
/** 列表项的缩进层级 */
depth?: number
/**
* 结论句裁判的判定(四期):只有送了裁判的候选句有。正式运行里节点当场判的进封存(post_seal false);
* 探索运行里候选句先标 unjudged + reason on_demand,点开再判
*/
verdict?: EvidenceVerdict
}
/**
* 模型对一句结论的判定。是模型的判断,不是证据,不能被引用。
* status 为 unjudged 时 reason 写明为什么没判:四个上限(max_claims / max_cost_usd / daily_max_usd /
* timeout_s,rationale 以「已到上限(…)」开头)、error、format、missing、on_demand(探索运行按需)
*/
export interface EvidenceVerdict {
status: 'supported' | 'partial' | 'unsupported' | 'not_a_claim' | 'unjudged' | string
/** 理由,最多 60 字 */
rationale?: string
/** 裁判模型 id;探索运行还没判的是 null */
judge?: string | null
/** 封存之后按需追加的(evidence.judged 事件) */
post_seal?: boolean
/** 裁判看过、摘录里真有的证据编号 */
used?: string[]
reason?: string
cost_usd?: number
}
/** 文档(和 report.checked、节点产出)里的裁判摘要:键的顺序同后端 judge.SUMMARY_KEYS */
export interface EvidenceJudgeSummary {
/** inline:正式运行节点里当场判;on_demand:探索运行按需 */
mode?: 'inline' | 'on_demand' | string
model?: string | null
on_unsupported?: 'degrade' | 'withhold' | string
rewrite_once?: boolean
limits?: { max_claims?: number | null; max_cost_usd?: number | null; timeout_s?: number | null
daily_max_usd?: number | null } | null
candidates?: number
/** 预筛放掉、没送裁判的结论句 */
screened?: string[]
counts?: Partial<Record<'supported' | 'partial' | 'unsupported' | 'not_a_claim' | 'unjudged', number>>
unjudged?: Record<string, number>
limits_hit?: string[]
complete?: boolean
priced?: boolean | null
cost_usd?: number
calls?: number
reused?: number
duration_ms?: number
gaps?: string[]
notes?: string[]
same_model?: boolean
/**
* 不支持的句子交回写作者改写一次(rewrite_once)。units:交回的句子在封存文档里的编号(采用了改写稿时,
* 改掉的句子是 null);sentences:交回的原句;changed:改写稿里新写的句子的编号(没采用时为空);
* reason:没采用的原因
*/
rewrite?: { units?: (string | null)[]; sentences?: string[]; applied?: boolean; reason?: string | null
changed?: string[] } | null
}
/**
* POST /runs/{id}/evidence/judge 的答复:evidence.judged 事件的载荷(NOTES-A4 §8)。前端对缺字段宽容:
* verdicts 也认成 [{unit, …}] 的列表
*/
export interface EvidenceJudgeResult {
/** 判的是哪份报告:{node_id, doc_artifact}(事件载荷里是节点 id 字符串) */
report?: string | { node_id?: string; doc_artifact?: string }
doc_artifact?: string
verdicts?: Record<string, EvidenceVerdict> | (EvidenceVerdict & { unit?: string })[]
model?: string | null
cost_usd?: number
calls?: number
limits_hit?: string[]
gaps?: string[]
notes?: string[]
skipped?: Record<string, string>
post_seal?: boolean
/** 触顶、没跑成、点的不是结论句时的一句话;判成了是 null */
message?: string | null
/** 触顶时怎么调,一个上限一句 */
adjust?: string[]
[key: string]: unknown
}
/** 这句能不能「请模型判断」:片段接口按运行的实际情况答(和按需裁判接口同一套条件) */
export interface EvidenceOnDemand {
available?: boolean
/** formal / legacy / not_a_claim / judged / unsealed / seal_broken */
reason?: string | null
message?: string | null
}
export interface EvidenceBlock {
@@ -750,6 +839,12 @@ export interface EvidenceStats {
quotes?: number
unknown_entities?: number
unverified_entities?: number
/** 四期:claims 为 judge 时追加,候选句按判定的句数 */
supported?: number
partial?: number
unsupported?: number
not_a_claim?: number
unjudged?: number
}
/** report_doc 工件的内容 */
@@ -764,6 +859,8 @@ export interface EvidenceDocData {
blocks: EvidenceBlock[]
stats?: EvidenceStats
violations?: EvidenceViolation[]
/** 四期:写作时结论句策略是 judge 才有 */
judge?: EvidenceJudgeSummary
}
/**
@@ -807,7 +904,14 @@ export interface EvidenceGraph {
/** 文档取回时哈希复验:false 对不上(或不是这次运行这个节点写的),null 文件不在了 */
hash_ok?: boolean | null
/** 写这份报告时的结论句策略(report.checked 里记的;老后端、升级前的运行没有) */
claims?: string | null }[]
claims?: string | null
/**
* 四期:封存之后按需追加的判定(evidence.judged 叠在一起),{unit: 判定}。后端没给时前端只认
* 点开时片段接口、按需裁判接口答的那几句
*/
post_seal_verdicts?: Record<string, EvidenceVerdict> | null
/** 封存的裁判摘要(文档的 judge) */
judge?: EvidenceJudgeSummary | null }[]
evidence?: { alias?: string; eid?: string; kind?: string; label?: string; node_id?: string; artifact?: string
sealed?: boolean; cited_by?: string[]; report?: string }[]
edges?: { from: string; to: string; rel: string }[]
@@ -1042,7 +1146,11 @@ export interface EvidenceAudit {
export interface EvidenceSegmentDetail {
report?: { node_id?: string; doc_artifact?: string }
segment?: Partial<EvidenceSegment> & { unit?: string }
unit?: { id?: string; kind?: string; text?: string; span?: [number, number]; cites?: string[] }
unit?: { id?: string; kind?: string; text?: string; span?: [number, number]; cites?: string[]
/** 这一句的判定:封存的文档里的,或者按最新的 evidence.judged 叠上的(post_seal) */
verdict?: EvidenceVerdict
/** 这一句能不能请模型判断、不能的话为什么 */
on_demand?: EvidenceOnDemand }
block?: { id?: string; type?: string }
chain?: EvidenceStep[]
/** 片段为什么是现在这个状态,一句人话 */