mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 07:02:00 +00:00
可点击证据第四期:结论句由模型裁判;只引单元格的问数据图能发成受管
一、结论句裁判
- 报告节点新增 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:
@@ -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 位>.<格式>)
|
||||
|
||||
@@ -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(工具参数、子工作流入参、出具契约)。
|
||||
*
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: '运行中',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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); }
|
||||
|
||||
|
||||
@@ -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
@@ -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('和')}约束` : '不设上限,每日费用只受模型调用次数约束(每份报告、每次点击也都不限)'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录页「证据」页签:报告、常驻面板、审计表。审计表按状态分组,四组的说法和片段状态同一套
|
||||
*/
|
||||
|
||||
@@ -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 换了主题,这里跟着变,不会留着旧值。
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 : ''
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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 }[] = []
|
||||
|
||||
@@ -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
@@ -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[]
|
||||
/** 片段为什么是现在这个状态,一句人话 */
|
||||
|
||||
Reference in New Issue
Block a user