dsh-llm-verifier 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +133 -9
- package/lib/auto-B5V49e-A.js +1517 -0
- package/lib/auto-B5V49e-A.js.map +1 -0
- package/lib/{caller-DJd_FlrQ.js → caller-DR_C7ueP.js} +154 -20
- package/lib/caller-DR_C7ueP.js.map +1 -0
- package/lib/caller.js +2 -2
- package/lib/client.js +804 -29
- package/lib/client.js.map +1 -1
- package/lib/core.js +226 -3
- package/lib/core.js.map +1 -1
- package/lib/index.js +1014 -1349
- package/lib/index.js.map +1 -1
- package/lib/replay.js +157 -0
- package/lib/replay.js.map +1 -0
- package/lib/types/auto.js +1 -1
- package/lib/types/auto.js.map +1 -1
- package/lib/types/caller.d.ts +65 -0
- package/lib/types/caller.d.ts.map +1 -1
- package/lib/types/caller.js +113 -5
- package/lib/types/caller.js.map +1 -1
- package/lib/types/client-i18n.d.ts +163 -6
- package/lib/types/client-i18n.d.ts.map +1 -1
- package/lib/types/client-i18n.js +137 -26
- package/lib/types/client-i18n.js.map +1 -1
- package/lib/types/client.d.ts +30 -0
- package/lib/types/client.d.ts.map +1 -1
- package/lib/types/client.js +94 -9
- package/lib/types/client.js.map +1 -1
- package/lib/types/config.d.ts +33 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/config.js +17 -2
- package/lib/types/config.js.map +1 -1
- package/lib/types/core.d.ts +84 -0
- package/lib/types/core.d.ts.map +1 -1
- package/lib/types/core.js +222 -2
- package/lib/types/core.js.map +1 -1
- package/lib/types/criteria.d.ts +33 -0
- package/lib/types/criteria.d.ts.map +1 -0
- package/lib/types/criteria.js +67 -0
- package/lib/types/criteria.js.map +1 -0
- package/lib/types/decisions.d.ts +84 -0
- package/lib/types/decisions.d.ts.map +1 -0
- package/lib/types/decisions.js +209 -0
- package/lib/types/decisions.js.map +1 -0
- package/lib/types/engine.d.ts +54 -1
- package/lib/types/engine.d.ts.map +1 -1
- package/lib/types/engine.js +175 -12
- package/lib/types/engine.js.map +1 -1
- package/lib/types/index.d.ts +1 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +392 -38
- package/lib/types/index.js.map +1 -1
- package/lib/types/replay.d.ts +88 -0
- package/lib/types/replay.d.ts.map +1 -0
- package/lib/types/replay.js +136 -0
- package/lib/types/replay.js.map +1 -0
- package/lib/types/router.d.ts +29 -5
- package/lib/types/router.d.ts.map +1 -1
- package/lib/types/router.js +271 -45
- package/lib/types/router.js.map +1 -1
- package/lib/types/statistics.d.ts +10 -1
- package/lib/types/statistics.d.ts.map +1 -1
- package/lib/types/statistics.js +35 -7
- package/lib/types/statistics.js.map +1 -1
- package/lib/types/top-logprobs.d.ts +36 -0
- package/lib/types/top-logprobs.d.ts.map +1 -1
- package/lib/types/top-logprobs.js +41 -14
- package/lib/types/top-logprobs.js.map +1 -1
- package/package.json +6 -1
- package/scripts/eval-replay.mjs +118 -0
- package/src/auto.ts +1 -1
- package/src/caller.test.ts +62 -1
- package/src/caller.ts +124 -4
- package/src/client-i18n.ts +137 -25
- package/src/client.test.ts +21 -0
- package/src/client.tsx +145 -7
- package/src/config.test.ts +32 -0
- package/src/config.ts +49 -2
- package/src/core.test.ts +95 -0
- package/src/core.ts +217 -2
- package/src/criteria.test.ts +60 -0
- package/src/criteria.ts +76 -0
- package/src/decisions.test.ts +150 -0
- package/src/decisions.ts +244 -0
- package/src/engine.test.ts +131 -0
- package/src/engine.ts +167 -10
- package/src/index.test.ts +278 -8
- package/src/index.ts +373 -37
- package/src/replay.test.ts +82 -0
- package/src/replay.ts +155 -0
- package/src/router.test.ts +217 -5
- package/src/router.ts +286 -47
- package/src/statistics.test.ts +49 -1
- package/src/statistics.ts +44 -7
- package/src/top-logprobs.test.ts +28 -0
- package/src/top-logprobs.ts +56 -8
- package/lib/caller-DJd_FlrQ.js.map +0 -1
package/src/client.tsx
CHANGED
|
@@ -4,15 +4,16 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
|
4
4
|
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
5
5
|
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
|
6
6
|
import { Button, IconDataOutline16, IconRefreshOutline16, Input } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
7
|
-
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
7
|
+
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
|
|
8
8
|
import {
|
|
9
9
|
zh, en, dictionaries, toolLabels, tFormat, useLanguage, detectLanguage,
|
|
10
10
|
compact, money, duration, dateTime, type I18nDict,
|
|
11
11
|
type VerdictSummary, resolveCacheDirOnSave, sameSettingValue, sectionForSave,
|
|
12
|
-
WORST_CASE_ROUTE_CALLS_PER_JUDGE, WORST_CASE_FINAL_CALLS_PER_JUDGE, WORST_CASE_TASK_PER_JUDGE, WORST_CASE_SESSION_PER_JUDGE,
|
|
12
|
+
WORST_CASE_ROUTE_CALLS_PER_JUDGE, WORST_CASE_FINAL_CALLS_PER_JUDGE, WORST_CASE_TASK_PER_JUDGE, WORST_CASE_SESSION_PER_JUDGE, WORST_CASE_CRITERIA_PER_COMPARISON,
|
|
13
13
|
computeJudgeCount, computeWorstCaseBudget, type BudgetWarningState,
|
|
14
14
|
evaluateBudgetWarning, isVerdictFailed, formatPercentage, formatVerdictDetails,
|
|
15
15
|
} from './client-i18n.ts'
|
|
16
|
+
import { CRITERIA_PRESETS, DEFAULT_GROUND_TRUTH_NOTE, buildPairwisePrompt } from './core.ts'
|
|
16
17
|
import {
|
|
17
18
|
type ExtraJudgeDraft,
|
|
18
19
|
MAX_EXTRA_JUDGES,
|
|
@@ -32,14 +33,17 @@ export {
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
const NS = 'llm-verifier'
|
|
35
|
-
|
|
36
|
+
interface DecisionCallView { label: string; channel: string; prompt: string; output: string; score?: number }
|
|
37
|
+
interface DecisionRecordView { id: string; toolName: string; phase: string; provider: string; model: string; startedAt: number; calls: DecisionCallView[] }
|
|
38
|
+
|
|
39
|
+
export interface Values { enabled: boolean; captureDecisions: boolean; autoVerifyMode: 'manual'|'smart'|'strict'; autoVerifyThreshold: number; autoVerifyRepeats: number; autoTrackRepeats: number; autoVerifyFinalRepeats: number; autoVerifyMinToolCalls: number; autoVerifyMaxChars: number; autoVerifyMaxPerTask: number; autoVerifyMaxPerSession: number; autoRouteSemantic: boolean; autoRouteMinConfidence: number; autoRouteMaxCandidates: number; autoRouteMaxPerTask: number; autoRouteMaxPerSession: number; autoTrackCompletionThreshold: number; autoRouteMaxItemChars: number; autoRouteMaxInputChars: number; autoMaxModelCallsPerTask: number; autoMaxModelCallsPerSession: number; autoVerifyTeamTasks: boolean; autoVerifyPlanMode: boolean; criteriaPreset: 'coding'|'debug'|'research'|'ops'|'writing'|'custom'; criteriaFile: string; provider: string; model: string; reasoningEffort?: string; maxTokens: number; temperature: number; label?: string; maxConcurrency: number; maxRetries: number; retryBaseDelayMs: number; timeoutMs: number; cacheDir: string; cacheMaxEntries: number; estimatedInputUsdPerMillion: number; estimatedOutputUsdPerMillion: number; autoVerifySubagents: boolean; extraJudges: ExtraJudgeDraft[] }
|
|
36
40
|
export interface Loaded { groups: readonly ModelProviderGroup[]; settings: SettingsNamespaceView; writable: boolean; failures: string[] }
|
|
37
41
|
export interface RunStats { calls: number; attempts: number; retries: number; inputTokens: number; cachedInputTokens: number; outputTokens: number; reasoningTokens: number; cacheHits: number; cacheMisses: number; estimatedCostUsd: number; topLogprobScores: number; explicitTagScores: number }
|
|
38
42
|
export interface InvocationRecord { id: string; toolName: string; sessionId?: string; startedAt: number; finishedAt: number; durationMs: number; success: boolean; errorName?: string; errorMessage?: string; provider: string; model: string; stats: RunStats; verdict?: VerdictSummary }
|
|
39
43
|
interface DailyStatistics { date: string; invocations: number; successes: number; failures: number; calls: number; tokens: number; estimatedCostUsd: number; byTool: Record<string, number> }
|
|
40
44
|
interface ToolStatistics { toolName: string; invocations: number; successes: number; failures: number; successRate: number; averageDurationMs: number; calls: number; tokens: number; cacheHits: number; cacheMisses: number; estimatedCostUsd: number }
|
|
41
45
|
interface ModelStatistics { provider: string; model: string; invocations: number; calls: number; tokens: number; estimatedCostUsd: number }
|
|
42
|
-
interface Totals extends RunStats { invocations: number; successes: number; failures: number; successRate: number; averageDurationMs: number; tokens: number; cacheHitRate: number }
|
|
46
|
+
interface Totals extends RunStats { invocations: number; successes: number; failures: number; successRate: number; averageDurationMs: number; tokens: number; cacheHitRate: number; prefixCacheHitRate: number }
|
|
43
47
|
interface StatisticsOverview { generatedAt: number; fromMs: number; toMs: number; sessionId?: string; totals: Totals; daily: DailyStatistics[]; tools: ToolStatistics[]; models: ModelStatistics[]; recent: InvocationRecord[] }
|
|
44
48
|
interface VerifierRemote {
|
|
45
49
|
session: {
|
|
@@ -67,6 +71,9 @@ interface VerifierRemote {
|
|
|
67
71
|
}
|
|
68
72
|
}
|
|
69
73
|
|
|
74
|
+
export interface JudgeProbeView { label: string; provider: string; model: string; ok: boolean; channel?: string; channelProbed?: boolean; scoreA?: number; scoreB?: number; latencyMs: number; calls?: number; inputTokens?: number; cachedInputTokens?: number; outputTokens?: number; error?: string }
|
|
75
|
+
export interface ProbeResultView { judges: JudgeProbeView[]; channelProbed?: boolean; rubric: { source: string; count: number; file?: string; error?: string } }
|
|
76
|
+
|
|
70
77
|
interface VerifierSettingsProps { remote: VerifierRemote }
|
|
71
78
|
interface StatisticsPageProps {
|
|
72
79
|
sessionId?: string
|
|
@@ -87,10 +94,10 @@ const toggleStyle = (enabled: boolean): React.CSSProperties => ({ position: 'rel
|
|
|
87
94
|
const toggleThumbStyle = (enabled: boolean): React.CSSProperties => ({ position: 'absolute', top: 3, left: enabled ? 21 : 3, width: 16, height: 16, borderRadius: '50%', background: 'var(--dsw-static-neutral-00, #fff)', boxShadow: '0 1px 3px rgba(0,0,0,.28)', transition: 'left .15s ease' })
|
|
88
95
|
const dashboardCard: React.CSSProperties = { border: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.13))', background: 'color-mix(in srgb, var(--dsw-alias-bg-module, #171925) 88%, transparent)', borderRadius: 16, boxShadow: '0 12px 36px rgba(0,0,0,.12)' }
|
|
89
96
|
const muted: React.CSSProperties = { color: 'var(--dsw-text-secondary)', fontSize: 12 }
|
|
90
|
-
const toolColors: Record<string, string> = { verifier_route_classify: '#d97706', verifier_compare: '#4f8cff', verifier_select: '#8b6df6', verifier_track: '#2fc5c9', verifier_current_session: '#f5a524' }
|
|
97
|
+
const toolColors: Record<string, string> = { verifier_route_classify: '#d97706', verifier_compare: '#4f8cff', verifier_select: '#8b6df6', verifier_track: '#2fc5c9', verifier_best_of_n: '#e2569b', verifier_current_session: '#f5a524' }
|
|
91
98
|
|
|
92
99
|
function record(value: unknown): Record<string, unknown> { return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {} }
|
|
93
|
-
function values(view: SettingsNamespaceView): Values { const v=record(view.value); const mode=v.autoVerifyMode==='manual'||v.autoVerifyMode==='strict'?v.autoVerifyMode:'smart'; return { enabled:v.enabled!==false,autoVerifyMode:mode,autoVerifyThreshold:Number(v.autoVerifyThreshold??.65),autoVerifyRepeats:Number(v.autoVerifyRepeats??1),autoVerifyFinalRepeats:Number(v.autoVerifyFinalRepeats??2),autoVerifyMinToolCalls:Number(v.autoVerifyMinToolCalls??3),autoVerifyMaxChars:Number(v.autoVerifyMaxChars??80000),autoVerifyMaxPerTask:Number(v.autoVerifyMaxPerTask??2),autoVerifyMaxPerSession:Number(v.autoVerifyMaxPerSession??8),autoRouteSemantic:v.autoRouteSemantic!==false,autoRouteMinConfidence:Number(v.autoRouteMinConfidence??.9),autoRouteMaxCandidates:Number(v.autoRouteMaxCandidates??8),autoRouteMaxPerTask:Number(v.autoRouteMaxPerTask??2),autoRouteMaxPerSession:Number(v.autoRouteMaxPerSession??8),autoTrackCompletionThreshold:Number(v.autoTrackCompletionThreshold??.
|
|
100
|
+
function values(view: SettingsNamespaceView): Values { const v=record(view.value); const mode=v.autoVerifyMode==='manual'||v.autoVerifyMode==='strict'?v.autoVerifyMode:'smart'; return { enabled:v.enabled!==false,captureDecisions:v.captureDecisions!==false,autoVerifyMode:mode,autoVerifyThreshold:Number(v.autoVerifyThreshold??.65),autoVerifyRepeats:Number(v.autoVerifyRepeats??1),autoTrackRepeats:Number(v.autoTrackRepeats??3),autoVerifyFinalRepeats:Number(v.autoVerifyFinalRepeats??2),autoVerifyMinToolCalls:Number(v.autoVerifyMinToolCalls??3),autoVerifyMaxChars:Number(v.autoVerifyMaxChars??80000),autoVerifyMaxPerTask:Number(v.autoVerifyMaxPerTask??2),autoVerifyMaxPerSession:Number(v.autoVerifyMaxPerSession??8),autoRouteSemantic:v.autoRouteSemantic!==false,autoRouteMinConfidence:Number(v.autoRouteMinConfidence??.9),autoRouteMaxCandidates:Number(v.autoRouteMaxCandidates??8),autoRouteMaxPerTask:Number(v.autoRouteMaxPerTask??2),autoRouteMaxPerSession:Number(v.autoRouteMaxPerSession??8),autoTrackCompletionThreshold:Number(v.autoTrackCompletionThreshold??.684),autoRouteMaxItemChars:Number(v.autoRouteMaxItemChars??20000),autoRouteMaxInputChars:Number(v.autoRouteMaxInputChars??60000),autoMaxModelCallsPerTask:Number(v.autoMaxModelCallsPerTask??96),autoMaxModelCallsPerSession:Number(v.autoMaxModelCallsPerSession??240),autoVerifyTeamTasks:v.autoVerifyTeamTasks!==false,autoVerifyPlanMode:v.autoVerifyPlanMode!==false,criteriaPreset:(v.criteriaPreset==='debug'||v.criteriaPreset==='research'||v.criteriaPreset==='ops'||v.criteriaPreset==='writing'||v.criteriaPreset==='custom')?v.criteriaPreset:'coding',criteriaFile:typeof v.criteriaFile==='string'?v.criteriaFile.trim():'',provider:String(v.provider??''),model:String(v.model??''),...(typeof v.reasoningEffort==='string'?{reasoningEffort:v.reasoningEffort}:{}),maxTokens:Number(v.maxTokens??32768),temperature:Number(v.temperature??0.2),...(typeof v.label==='string'&&v.label.trim()?{label:v.label.trim()}:{}),maxConcurrency:Number(v.maxConcurrency??8),maxRetries:Number(v.maxRetries??3),retryBaseDelayMs:Number(v.retryBaseDelayMs??500),timeoutMs:Number(v.timeoutMs??300000),cacheDir:typeof v.cacheDir==='string'&&v.cacheDir.trim()?v.cacheDir.trim():'verifier',cacheMaxEntries:Number(v.cacheMaxEntries??10000),estimatedInputUsdPerMillion:Number(v.estimatedInputUsdPerMillion??0),estimatedOutputUsdPerMillion:Number(v.estimatedOutputUsdPerMillion??0),autoVerifySubagents:v.autoVerifySubagents===true,extraJudges:normalizeExtraJudges(v.extraJudges) } }
|
|
94
101
|
function message(error: unknown): string { return error instanceof Error ? error.message : String(error) }
|
|
95
102
|
/** The endpoint answered but rejected the request: a transport fallback would only repeat it. */
|
|
96
103
|
class EndpointError extends Error {}
|
|
@@ -165,13 +172,18 @@ export function VerifierSettings({ remote }: VerifierSettingsProps) {
|
|
|
165
172
|
}
|
|
166
173
|
const budgetWarning = useMemo(() => {
|
|
167
174
|
if (!draft) return null
|
|
175
|
+
// The engine reserves budget from the REAL criteria count. A built-in preset's count is known
|
|
176
|
+
// here; a custom file's is not (it lives on the server), so the estimate falls back to the
|
|
177
|
+
// three-criteria default and the file field's help says as much.
|
|
178
|
+
const criteria = draft.criteriaPreset === 'custom' ? WORST_CASE_CRITERIA_PER_COMPARISON : CRITERIA_PRESETS[draft.criteriaPreset].length
|
|
168
179
|
return evaluateBudgetWarning(
|
|
169
180
|
draft.autoVerifyMode,
|
|
170
181
|
draft.extraJudges.length,
|
|
171
182
|
draft.autoMaxModelCallsPerTask,
|
|
172
183
|
draft.autoMaxModelCallsPerSession,
|
|
184
|
+
criteria,
|
|
173
185
|
)
|
|
174
|
-
}, [draft?.autoVerifyMode, draft?.extraJudges.length, draft?.autoMaxModelCallsPerTask, draft?.autoMaxModelCallsPerSession])
|
|
186
|
+
}, [draft?.autoVerifyMode, draft?.criteriaPreset, draft?.extraJudges.length, draft?.autoMaxModelCallsPerTask, draft?.autoMaxModelCallsPerSession])
|
|
175
187
|
// The saved section holds only real overrides: a draft field equal to the
|
|
176
188
|
// composition base is dropped so a later plugin default still reaches this
|
|
177
189
|
// install (see sectionForSave).
|
|
@@ -231,8 +243,12 @@ export function VerifierSettings({ remote }: VerifierSettingsProps) {
|
|
|
231
243
|
|
|
232
244
|
<section style={group}><GroupTitle>{t['section.autoVerify']}</GroupTitle>
|
|
233
245
|
<div style={row}><Label title={t['field.autoVerifyMode.title']} help={t['field.autoVerifyMode.help']}/><select style={selectStyle} disabled={busy} aria-label={t['field.autoVerifyMode.title']} value={draft.autoVerifyMode} onChange={e=>patch('autoVerifyMode',e.target.value as Values['autoVerifyMode'])}><option value="manual">{t['field.autoVerifyMode.manual']}</option><option value="smart">{t['field.autoVerifyMode.smart']}</option><option value="strict">{t['field.autoVerifyMode.strict']}</option></select></div>
|
|
246
|
+
<div style={row}><Label title={t['field.criteriaPreset.title']} help={t['field.criteriaPreset.help']}/><select style={selectStyle} disabled={busy} aria-label={t['field.criteriaPreset.title']} value={draft.criteriaPreset} onChange={e=>patch('criteriaPreset',e.target.value as Values['criteriaPreset'])}>{(['coding','debug','research','ops','writing','custom'] as const).map(id=><option key={id} value={id}>{t[('field.criteriaPreset.'+id) as keyof I18nDict] as string}</option>)}</select></div>
|
|
247
|
+
{draft.criteriaPreset==='custom'&&<div style={row}><Label title={t['field.criteriaFile.title']} help={t['field.criteriaFile.help']}/>{textField('criteriaFile','criteria/my-task.md')}</div>}
|
|
248
|
+
{draft.criteriaPreset!=='custom'&&(()=>{const preset=CRITERIA_PRESETS[draft.criteriaPreset];const first=preset[0];if(!first)return null;return <details style={{margin:'2px 0 0 4px',fontSize:12}}><summary style={{cursor:'pointer',color:'var(--dsw-text-secondary)'}}>{tFormat(t['field.criteriaPreset.previewSummary'],{count:preset.length})}</summary><pre style={{whiteSpace:'pre-wrap',wordBreak:'break-word',maxHeight:240,overflow:'auto',background:'var(--dsw-surface-sunken)',border:'1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.12))',borderRadius:8,padding:10,marginTop:8}}>{buildPairwisePrompt(t['field.criteriaPreset.sampleTask'],t['field.criteriaPreset.sampleA'],t['field.criteriaPreset.sampleB'],first,DEFAULT_GROUND_TRUTH_NOTE)}</pre></details>})()}
|
|
234
249
|
<div style={row}><Label title={t['field.autoRouteSemantic.title']} help={t['field.autoRouteSemantic.help']}/><button type="button" role="switch" aria-checked={draft.autoRouteSemantic} aria-label={t['field.autoRouteSemantic.title']} onClick={()=>patch('autoRouteSemantic',!draft.autoRouteSemantic)} style={toggleStyle(draft.autoRouteSemantic)}><span style={toggleThumbStyle(draft.autoRouteSemantic)}/></button></div>
|
|
235
250
|
<div style={row}><Label title={t['field.autoVerifyTeamTasks.title']} help={t['field.autoVerifyTeamTasks.help']}/><button type="button" role="switch" aria-checked={draft.autoVerifyTeamTasks} aria-label={t['field.autoVerifyTeamTasks.title']} onClick={()=>patch('autoVerifyTeamTasks',!(draft.autoVerifyTeamTasks))} style={toggleStyle(draft.autoVerifyTeamTasks)}><span style={toggleThumbStyle(draft.autoVerifyTeamTasks)}/></button></div>
|
|
251
|
+
<div style={row}><Label title={t['field.captureDecisions.title']} help={t['field.captureDecisions.help']}/><button type="button" role="switch" aria-checked={draft.captureDecisions} aria-label={t['field.captureDecisions.title']} onClick={()=>patch('captureDecisions',!draft.captureDecisions)} style={toggleStyle(draft.captureDecisions)}><span style={toggleThumbStyle(draft.captureDecisions)}/></button></div>
|
|
236
252
|
<div style={row}><Label title={t['field.autoVerifySubagents.title']} help={t['field.autoVerifySubagents.help']}/><button type="button" role="switch" aria-checked={draft.autoVerifySubagents} aria-label={t['field.autoVerifySubagents.title']} onClick={()=>patch('autoVerifySubagents',!draft.autoVerifySubagents)} style={toggleStyle(draft.autoVerifySubagents)}><span style={toggleThumbStyle(draft.autoVerifySubagents)}/></button></div>
|
|
237
253
|
<div style={row}><Label title={t['field.autoVerifyPlanMode.title']} help={t['field.autoVerifyPlanMode.help']}/><button type="button" role="switch" aria-checked={draft.autoVerifyPlanMode} aria-label={t['field.autoVerifyPlanMode.title']} onClick={()=>patch('autoVerifyPlanMode',!(draft.autoVerifyPlanMode))} style={toggleStyle(draft.autoVerifyPlanMode)}><span style={toggleThumbStyle(draft.autoVerifyPlanMode)}/></button></div>
|
|
238
254
|
<div style={row}><Label title={t['field.autoRouteMinConfidence.title']} help={t['field.autoRouteMinConfidence.help']}/>{numeric('autoRouteMinConfidence',0)}</div>
|
|
@@ -248,6 +264,7 @@ export function VerifierSettings({ remote }: VerifierSettingsProps) {
|
|
|
248
264
|
{budgetWarning?.warnSession&&<p style={{margin:'-4px 0 10px',fontSize:12,lineHeight:'18px',color:'var(--dsw-alias-state-warn-label)'}}>{tFormat(t['field.autoMaxModelCallsPerSession.warnBudget'],{current:draft.autoMaxModelCallsPerSession,required:budgetWarning.worstCaseSession,judges:budgetWarning.judgeCount})}</p>}
|
|
249
265
|
<div style={row}><Label title={t['field.autoVerifyThreshold.title']} help={t['field.autoVerifyThreshold.help']}/>{numeric('autoVerifyThreshold',0)}</div>
|
|
250
266
|
<div style={row}><Label title={t['field.autoVerifyRepeats.title']} help={t['field.autoVerifyRepeats.help']}/>{numeric('autoVerifyRepeats',1)}</div>
|
|
267
|
+
<div style={row}><Label title={t['field.autoTrackRepeats.title']} help={t['field.autoTrackRepeats.help']}/>{numeric('autoTrackRepeats',1)}</div>
|
|
251
268
|
<div style={row}><Label title={t['field.autoVerifyFinalRepeats.title']} help={t['field.autoVerifyFinalRepeats.help']}/>{numeric('autoVerifyFinalRepeats',1)}</div>
|
|
252
269
|
<div style={row}><Label title={t['field.autoVerifyMinToolCalls.title']} help={t['field.autoVerifyMinToolCalls.help']}/>{numeric('autoVerifyMinToolCalls',1)}</div>
|
|
253
270
|
<div style={row}><Label title={t['field.autoVerifyMaxChars.title']} help={t['field.autoVerifyMaxChars.help']}/>{numeric('autoVerifyMaxChars',1000)}</div>
|
|
@@ -363,6 +380,63 @@ export function StatisticsPage({ sessionId, rpc, isGlobal }: StatisticsPageProps
|
|
|
363
380
|
const [loading, setLoading] = useState(true)
|
|
364
381
|
const [error, setError] = useState<string | null>(null)
|
|
365
382
|
const [refresh, setRefresh] = useState(0)
|
|
383
|
+
// Decision snapshots are fetched on demand: they carry whole prompts, and a dashboard
|
|
384
|
+
// that loaded all of them would ship megabytes of text for rows nobody opened.
|
|
385
|
+
const [snapshot, setSnapshot] = useState<{ id: string; record?: DecisionRecordView; error?: string } | null>(null)
|
|
386
|
+
const snapshotRequest = useRef(0)
|
|
387
|
+
const toggleSnapshot = async (id: string) => {
|
|
388
|
+
if (snapshot?.id === id) { setSnapshot(null); return }
|
|
389
|
+
const request = ++snapshotRequest.current
|
|
390
|
+
setSnapshot({ id })
|
|
391
|
+
const payload = { kind: 'decision', id }
|
|
392
|
+
try {
|
|
393
|
+
let record: DecisionRecordView | undefined
|
|
394
|
+
if (rpc && typeof rpc.call === 'function') {
|
|
395
|
+
try {
|
|
396
|
+
const result = await rpc.call('/api', 'llm-verifier/statistics', payload)
|
|
397
|
+
if (result && result.ok === true) record = (result.value as { decision?: DecisionRecordView }).decision
|
|
398
|
+
} catch (rpcError) { console.warn('[llm-verifier] decision rpc.call failed, trying fetch fallback:', rpcError) }
|
|
399
|
+
}
|
|
400
|
+
if (record === undefined) {
|
|
401
|
+
const response = await fetch('/api/llm-verifier/statistics', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) })
|
|
402
|
+
const body = await response.json().catch(() => undefined)
|
|
403
|
+
const value = body?.ok === true ? body.value : body?.result?.ok === true ? body.result.value : undefined
|
|
404
|
+
if (value === undefined || value === null) throw new EndpointError(body?.error?.message ?? body?.result?.error?.message ?? t['stats.requestFailed'])
|
|
405
|
+
record = (value as { decision?: DecisionRecordView }).decision
|
|
406
|
+
}
|
|
407
|
+
if (snapshotRequest.current === request) setSnapshot({ id, ...(record === undefined ? { error: t['recent.decisionMissing'] } : { record }) })
|
|
408
|
+
} catch (cause) {
|
|
409
|
+
if (snapshotRequest.current === request) setSnapshot({ id, error: message(cause) })
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// Judge probe: one real, bounded call per configured judge. Not recorded in the statistics —
|
|
413
|
+
// it answers "which channel is this judge on, and is my rubric the one in effect", which the
|
|
414
|
+
// aggregate counters cannot.
|
|
415
|
+
const [probe, setProbe] = useState<{ busy: boolean; value?: ProbeResultView; error?: string } | null>(null)
|
|
416
|
+
const runProbe = async () => {
|
|
417
|
+
if (probe?.busy) return
|
|
418
|
+
setProbe({ busy: true })
|
|
419
|
+
const payload = { kind: 'probe' }
|
|
420
|
+
try {
|
|
421
|
+
let value: ProbeResultView | undefined
|
|
422
|
+
if (rpc && typeof rpc.call === 'function') {
|
|
423
|
+
try {
|
|
424
|
+
const result = await rpc.call('/api', 'llm-verifier/statistics', payload)
|
|
425
|
+
if (result && result.ok === true) value = result.value as ProbeResultView
|
|
426
|
+
} catch (rpcError) { console.warn('[llm-verifier] probe rpc.call failed, trying fetch fallback:', rpcError) }
|
|
427
|
+
}
|
|
428
|
+
if (value === undefined) {
|
|
429
|
+
const response = await fetch('/api/llm-verifier/statistics', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) })
|
|
430
|
+
const body = await response.json().catch(() => undefined)
|
|
431
|
+
const fallback = body?.ok === true ? body.value : body?.result?.ok === true ? body.result.value : undefined
|
|
432
|
+
if (fallback === undefined || fallback === null) throw new EndpointError(body?.error?.message ?? body?.result?.error?.message ?? t['stats.requestFailed'])
|
|
433
|
+
value = fallback as ProbeResultView
|
|
434
|
+
}
|
|
435
|
+
setProbe({ busy: false, value })
|
|
436
|
+
} catch (cause) {
|
|
437
|
+
setProbe({ busy: false, error: message(cause) })
|
|
438
|
+
}
|
|
439
|
+
}
|
|
366
440
|
// Data belongs to the range/session that produced it: keeping the previous
|
|
367
441
|
// range's numbers under an error banner reads as if they were current.
|
|
368
442
|
const queryKey = days + '|' + sessionOnly + '|' + String(sessionId ?? '')
|
|
@@ -462,10 +536,32 @@ export function StatisticsPage({ sessionId, rpc, isGlobal }: StatisticsPageProps
|
|
|
462
536
|
{[7, 30, 90].map(value => <button key={value} aria-pressed={days === value} onClick={() => setDays(value)} style={{ border: 0, borderRadius: 7, padding: '6px 10px', cursor: 'pointer', color: days === value ? '#fff' : 'var(--dsw-text-secondary)', background: days === value ? '#3f68d8' : 'transparent' }}>{tFormat(t['stats.daysUnit'], { days: value })}</button>)}
|
|
463
537
|
</div>
|
|
464
538
|
{Boolean(sessionId) && <button aria-pressed={sessionOnly} onClick={() => setSessionOnly(value => !value)} style={{ border: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.15))', borderRadius: 9, padding: '7px 11px', cursor: 'pointer', color: 'var(--dsw-text-primary)', background: sessionOnly ? 'rgba(79,140,255,.18)' : 'var(--dsw-surface-sunken)' }}>{sessionOnly ? t['stats.currentSession'] : t['stats.allSessions']}</button>}
|
|
539
|
+
<button type="button" disabled={probe?.busy === true} onClick={() => void runProbe()} style={{ border: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.15))', borderRadius: 9, padding: '7px 11px', cursor: 'pointer', color: 'var(--dsw-text-primary)', background: 'var(--dsw-surface-sunken)' }}>{probe?.busy === true ? t['probe.running'] : t['probe.button']}</button>
|
|
465
540
|
<button title={t['stats.refresh']} onClick={() => setRefresh(value => value + 1)} style={{ display: 'grid', placeItems: 'center', width: 34, height: 34, borderRadius: 9, border: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.15))', color: 'var(--dsw-text-primary)', background: 'var(--dsw-surface-sunken)', cursor: 'pointer' }}><IconRefreshOutline16 size={16} /></button>
|
|
466
541
|
</div>
|
|
467
542
|
</header>
|
|
468
543
|
{error && <div style={{ ...dashboardCard, padding: 18, borderColor: 'var(--dsw-danger, #e85858)', color: 'var(--dsw-danger, #e85858)' }}>{error}<div style={{ ...muted, marginTop: 6 }}>{t['stats.hostRestartHint']}</div></div>}
|
|
544
|
+
{probe !== null && <section style={{ ...dashboardCard, padding: '16px 18px' }}>
|
|
545
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}><strong>{t['probe.title']}</strong><span style={muted}>{t['probe.note']}</span></div>
|
|
546
|
+
{probe.error !== undefined && <div style={{ marginTop: 10, fontSize: 12, color: '#e76565' }}>{probe.error}</div>}
|
|
547
|
+
{probe.value !== undefined && <>
|
|
548
|
+
<div style={{ ...muted, marginTop: 8 }}>{tFormat(t['probe.rubric'], { source: probe.value.rubric.source, count: probe.value.rubric.count, file: probe.value.rubric.file ?? '' })}</div>
|
|
549
|
+
{probe.value.rubric.error !== undefined && <div style={{ marginTop: 6, fontSize: 12, color: '#e3bd63' }}>{tFormat(t['probe.rubricFallback'], { error: probe.value.rubric.error })}</div>}
|
|
550
|
+
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
551
|
+
{probe.value.judges.map(judge => <div key={judge.label + judge.model} style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center', fontSize: 12, padding: '9px 11px', borderRadius: 9, background: 'var(--dsw-surface-sunken)' }}>
|
|
552
|
+
<span style={{ color: judge.ok ? '#77d49b' : '#e76565', fontWeight: 600 }}>{judge.ok ? 'OK' : t['probe.failed']}</span>
|
|
553
|
+
<strong>{judge.label}</strong>
|
|
554
|
+
<span style={muted}>{judge.provider}/{judge.model}</span>
|
|
555
|
+
{judge.ok ? <>
|
|
556
|
+
{judge.channel !== undefined && <span style={muted}>{tFormat(t['probe.channel'], { channel: judge.channel })}{judge.channelProbed === true ? ' · ' + t['probe.reprobed'] : ''}</span>}
|
|
557
|
+
{judge.scoreA !== undefined && judge.scoreB !== undefined && <span style={muted}>{tFormat(t['probe.scores'], { a: formatPercentage(judge.scoreA), b: formatPercentage(judge.scoreB) })}</span>}
|
|
558
|
+
<span style={muted}>{tFormat(t['probe.latency'], { ms: String(judge.latencyMs) })}</span>
|
|
559
|
+
{judge.calls !== undefined && <span style={muted}>{tFormat(t['probe.calls'], { calls: String(judge.calls) })}</span>}
|
|
560
|
+
</> : <span style={{ color: '#e76565' }}>{judge.error}</span>}
|
|
561
|
+
</div>)}
|
|
562
|
+
</div>
|
|
563
|
+
</>}
|
|
564
|
+
</section>}
|
|
469
565
|
{loading && !data ? <div style={{ ...dashboardCard, padding: 32, textAlign: 'center', ...muted }}>{t['stats.loading']}</div> : <>
|
|
470
566
|
<section style={{ ...dashboardCard, padding: '22px 24px', display: 'grid', gridTemplateColumns: 'minmax(220px,1.4fr) minmax(240px,1fr)', gap: 24, alignItems: 'center' }}>
|
|
471
567
|
<div>
|
|
@@ -481,6 +577,7 @@ export function StatisticsPage({ sessionId, rpc, isGlobal }: StatisticsPageProps
|
|
|
481
577
|
</section>
|
|
482
578
|
<section style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(180px,1fr))', gap: 12 }}>
|
|
483
579
|
<Metric label={t['metric.cacheHitRate']} value={((totals?.cacheHitRate ?? 0) * 100).toFixed(1) + '%'} note={tFormat(t['metric.cacheHitNote'], { hits: compact(totals?.cacheHits ?? 0, lang), total: compact((totals?.cacheHits ?? 0) + (totals?.cacheMisses ?? 0), lang) })} accent="#b7dd64" />
|
|
580
|
+
<Metric label={t['metric.prefixCacheHitRate']} value={((totals?.prefixCacheHitRate ?? 0) * 100).toFixed(1) + '%'} note={tFormat(t['metric.prefixCacheHitNote'], { cached: compact(totals?.cachedInputTokens ?? 0, lang), input: compact((totals?.inputTokens ?? 0) + (totals?.cachedInputTokens ?? 0), lang) })} />
|
|
484
581
|
<Metric label={t['metric.tokens']} value={compact(totals?.tokens ?? 0, lang)} note={tFormat(t['metric.tokensNote'], { input: compact((totals?.inputTokens ?? 0) + (totals?.cachedInputTokens ?? 0), lang), output: compact(totals?.outputTokens ?? 0, lang) })} />
|
|
485
582
|
<Metric label={t['metric.avgModelCalls']} value={(totals?.invocations ?? 0) > 0 ? ((totals?.calls ?? 0) / (totals?.invocations ?? 1)).toFixed(1) : '0'} note={tFormat(t['metric.avgModelCallsNote'], { attempts: compact(totals?.attempts ?? 0, lang), retries: compact(totals?.retries ?? 0, lang) })} />
|
|
486
583
|
<Metric label={t['metric.scoringMode']} value={compact(totals?.topLogprobScores ?? 0, lang)} note={tFormat(t['metric.scoringModeNote'], { explicit: compact(totals?.explicitTagScores ?? 0, lang) })} />
|
|
@@ -506,6 +603,47 @@ export function StatisticsPage({ sessionId, rpc, isGlobal }: StatisticsPageProps
|
|
|
506
603
|
{verdictInfo.criteriaText && <span style={{ color: 'var(--dsw-text-secondary)' }}>{verdictInfo.criteriaText}</span>}
|
|
507
604
|
{verdictInfo.winnerText && <span style={{ color: 'var(--dsw-text-secondary)' }}>{verdictInfo.winnerText}</span>}
|
|
508
605
|
</div>}
|
|
606
|
+
<div style={{ margin: '6px 0 0 15px' }}>
|
|
607
|
+
<button type="button" onClick={() => void toggleSnapshot(item.id)} style={{ fontSize: 11, padding: '2px 7px', borderRadius: 5, cursor: 'pointer', color: 'var(--dsw-text-secondary)', background: 'var(--dsw-surface-sunken)', border: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.14))' }}>{snapshot?.id === item.id ? t['recent.decisionHide'] : t['recent.decision']}</button>
|
|
608
|
+
</div>
|
|
609
|
+
<details style={{ margin: '6px 0 0 15px' }}>
|
|
610
|
+
<summary style={{ cursor: 'pointer', fontSize: 11, color: 'var(--dsw-text-secondary)' }}>{t['recent.details']}</summary>
|
|
611
|
+
<div style={{ marginTop: 8, border: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.14))', borderRadius: 8, padding: '8px 10px', background: 'var(--dsw-surface-sunken)', fontSize: 11 }}>
|
|
612
|
+
{(item.verdict?.criteria?.length ?? 0) > 0 && <div style={{ display: 'grid', gridTemplateColumns: 'minmax(120px,1fr) auto auto', gap: '4px 14px', marginBottom: 8 }}>
|
|
613
|
+
<strong style={{ color: 'var(--dsw-text-secondary)' }}>{t['recent.detail.criterion']}</strong>
|
|
614
|
+
<strong style={{ color: 'var(--dsw-text-secondary)' }}>{t['recent.detail.score']}</strong>
|
|
615
|
+
<strong style={{ color: 'var(--dsw-text-secondary)' }}>{t['recent.detail.threshold']}</strong>
|
|
616
|
+
{item.verdict!.criteria!.map(criterion => {
|
|
617
|
+
const threshold = typeof item.verdict!.threshold === 'number' ? item.verdict!.threshold : undefined
|
|
618
|
+
const missed = threshold !== undefined && criterion.score < threshold
|
|
619
|
+
return <Fragment key={criterion.id}>
|
|
620
|
+
<span>{criterion.id}</span>
|
|
621
|
+
<span style={{ color: missed ? '#e76565' : '#77d49b' }}>{formatPercentage(criterion.score)}</span>
|
|
622
|
+
<span style={{ color: 'var(--dsw-text-secondary)' }}>{threshold === undefined ? '—' : formatPercentage(threshold)}</span>
|
|
623
|
+
</Fragment>
|
|
624
|
+
})}
|
|
625
|
+
</div>}
|
|
626
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, color: 'var(--dsw-text-secondary)' }}>
|
|
627
|
+
<span>{tFormat(t['recent.detail.calls'], { calls: compact(item.stats.calls, lang) })}</span>
|
|
628
|
+
<span>{tFormat(t['recent.detail.tokens'], { input: compact(item.stats.inputTokens, lang), cached: compact(item.stats.cachedInputTokens, lang), output: compact(item.stats.outputTokens, lang) })}</span>
|
|
629
|
+
<span>{tFormat(t['recent.detail.scoreCache'], { hits: compact(item.stats.cacheHits, lang), misses: compact(item.stats.cacheMisses, lang) })}</span>
|
|
630
|
+
<span>{tFormat(t['recent.detail.prefixCache'], { rate: ((item.stats.inputTokens + item.stats.cachedInputTokens) > 0 ? (100 * item.stats.cachedInputTokens / (item.stats.inputTokens + item.stats.cachedInputTokens)).toFixed(0) : '0') + '%' })}</span>
|
|
631
|
+
<span>{tFormat(t['recent.detail.cost'], { cost: money(item.stats.estimatedCostUsd) })}</span>
|
|
632
|
+
</div>
|
|
633
|
+
</div>
|
|
634
|
+
</details>
|
|
635
|
+
{snapshot?.id === item.id && <div style={{ margin: '8px 0 2px 15px', border: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.14))', borderRadius: 8, padding: '8px 10px', background: 'var(--dsw-surface-sunken)' }}>
|
|
636
|
+
{snapshot.error !== undefined && <div style={{ fontSize: 11, color: '#e76565' }}>{snapshot.error}</div>}
|
|
637
|
+
{snapshot.record === undefined && snapshot.error === undefined && <div style={{ ...muted, fontSize: 11 }}>{t['recent.decisionLoading']}</div>}
|
|
638
|
+
{snapshot.record !== undefined && snapshot.record.calls.length === 0 && <div style={{ ...muted, fontSize: 11 }}>{t['recent.decisionEmpty']}</div>}
|
|
639
|
+
{(snapshot.record?.calls ?? []).map((call, index) => <div key={index} style={{ marginBottom: index === snapshot.record!.calls.length - 1 ? 0 : 10 }}>
|
|
640
|
+
<div style={{ fontSize: 11, color: 'var(--dsw-text-secondary)' }}>{call.label} · {call.channel}{call.score === undefined ? '' : ' · ' + formatPercentage(call.score)}</div>
|
|
641
|
+
<div style={{ fontSize: 11, marginTop: 4 }}>{t['recent.decisionPrompt']}</div>
|
|
642
|
+
<pre style={{ margin: 0, maxHeight: 180, overflow: 'auto', fontSize: 11, whiteSpace: 'pre-wrap', wordBreak: 'break-word', background: 'rgba(0,0,0,.2)', padding: '6px 8px', borderRadius: 6 }}>{call.prompt}</pre>
|
|
643
|
+
<div style={{ fontSize: 11, marginTop: 4 }}>{t['recent.decisionOutput']}</div>
|
|
644
|
+
<pre style={{ margin: 0, maxHeight: 140, overflow: 'auto', fontSize: 11, whiteSpace: 'pre-wrap', wordBreak: 'break-word', background: 'rgba(0,0,0,.2)', padding: '6px 8px', borderRadius: 6 }}>{call.output}</pre>
|
|
645
|
+
</div>)}
|
|
646
|
+
</div>}
|
|
509
647
|
</div>
|
|
510
648
|
<div style={{ textAlign: 'right' }}><div style={{ fontSize: 12 }}>{duration(item.durationMs)}</div><div style={{ ...muted, marginTop: 3 }}>{dateTime(item.startedAt, lang)}</div></div>
|
|
511
649
|
</div>
|
package/src/config.test.ts
CHANGED
|
@@ -311,6 +311,20 @@ describe('config - judges ensemble resolution', () => {
|
|
|
311
311
|
).toThrow('llm-verifier: extraJudges[0] must be an object')
|
|
312
312
|
})
|
|
313
313
|
|
|
314
|
+
it('resolves the criteria preset and defaults to the historical coding rubric', () => {
|
|
315
|
+
const resolved = resolveConfig({})
|
|
316
|
+
// Default MUST stay 'coding': it is the only preset byte-identical to DEFAULT_CRITERIA, so an
|
|
317
|
+
// existing installation's verdicts and cache keys do not move.
|
|
318
|
+
expect(resolved.criteriaPreset).toBe('coding')
|
|
319
|
+
expect(resolved.criteriaFile).toBe('')
|
|
320
|
+
expect(resolveConfig({ criteriaPreset: 'research' }).criteriaPreset).toBe('research')
|
|
321
|
+
expect(resolveConfig({ criteriaPreset: 'custom', criteriaFile: ' rubric.md ' }).criteriaFile).toBe('rubric.md')
|
|
322
|
+
// A custom preset with no file is NOT a config error: the resolver degrades to coding and
|
|
323
|
+
// reports why, so a typo in a rubric path cannot disable the gate.
|
|
324
|
+
expect(resolveConfig({ criteriaPreset: 'custom' }).criteriaFile).toBe('')
|
|
325
|
+
expect(() => resolveConfig({ criteriaPreset: 'nonsense' as any })).toThrow('llm-verifier: criteriaPreset must be one of coding, debug, research, ops, writing, custom')
|
|
326
|
+
})
|
|
327
|
+
|
|
314
328
|
it('regression: primary configuration still validates and behaves as before', () => {
|
|
315
329
|
expect(() => resolveConfig({ provider: '' })).toThrow('llm-verifier: provider must be non-empty')
|
|
316
330
|
expect(() => resolveConfig({ provider: ' ' })).toThrow('llm-verifier: provider must be non-empty')
|
|
@@ -377,6 +391,24 @@ describe('config - settings payload seam', () => {
|
|
|
377
391
|
expect(resolved.judges.map(judge => judge.maxTokens)).toEqual([32768, 32768, 32768, 32768])
|
|
378
392
|
})
|
|
379
393
|
|
|
394
|
+
it('carries the criteria preset across the settings schema, JSON boundary and resolveConfig', () => {
|
|
395
|
+
// The host validates the stored section with the schema and the RPC carries plain JSON, so the
|
|
396
|
+
// new keys must survive Config -> JSON -> Config -> resolveConfig, not just resolveConfig.
|
|
397
|
+
const payload = JSON.parse(JSON.stringify(Config({ provider: 'deepseek-official', model: 'deepseek-flash', criteriaPreset: 'ops', criteriaFile: ' criteria/ops.md ' })))
|
|
398
|
+
expect(payload.criteriaPreset).toBe('ops')
|
|
399
|
+
expect(payload.criteriaFile).toBe(' criteria/ops.md ')
|
|
400
|
+
const resolved = resolveConfig(payload)
|
|
401
|
+
expect(resolved.criteriaPreset).toBe('ops')
|
|
402
|
+
// The schema preserves what the user typed; resolveConfig is what trims.
|
|
403
|
+
expect(resolved.criteriaFile).toBe('criteria/ops.md')
|
|
404
|
+
// Every selectable value round-trips, and unset stays on the historical default.
|
|
405
|
+
for (const preset of ['coding', 'debug', 'research', 'ops', 'writing', 'custom'] as const) {
|
|
406
|
+
expect(resolveConfig(Config({ criteriaPreset: preset })).criteriaPreset).toBe(preset)
|
|
407
|
+
}
|
|
408
|
+
expect(Config({}).criteriaPreset).toBe('coding')
|
|
409
|
+
expect(resolveConfig({}).criteriaFile).toBe('')
|
|
410
|
+
})
|
|
411
|
+
|
|
380
412
|
it('rejects the section the settings page blocks: a duplicate of the primary judge', () => {
|
|
381
413
|
const drafts: ExtraJudgeDraft[] = [{ provider: 'deepseek-official', model: 'deepseek-flash' }]
|
|
382
414
|
const payload = JSON.parse(JSON.stringify(serializeExtraJudges(drafts))) as NonNullable<Config['extraJudges']>
|
package/src/config.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { Context } from '@deepseek-ai/cordis'
|
|
2
2
|
import z from 'schemastery'
|
|
3
|
+
import { CRITERIA_PRESET_IDS, type CriteriaPresetId } from './core.ts'
|
|
4
|
+
|
|
5
|
+
/** A configured rubric: a bundled task-class preset, or a markdown file. */
|
|
6
|
+
export type CriteriaPresetSelection = CriteriaPresetId | 'custom'
|
|
3
7
|
|
|
4
8
|
export const VERIFIER_SETTINGS_NAMESPACE = 'llm-verifier' as never
|
|
5
9
|
|
|
@@ -28,6 +32,16 @@ export interface Config {
|
|
|
28
32
|
autoVerifyMode?: AutoVerifyMode
|
|
29
33
|
autoVerifyThreshold?: number
|
|
30
34
|
autoVerifyRepeats?: number
|
|
35
|
+
/**
|
|
36
|
+
* Scoring repeats for an automatic `track` route only.
|
|
37
|
+
*
|
|
38
|
+
* Progress scores on a model without token logprobs come from the explicit-tag
|
|
39
|
+
* channel, i.e. ONE sampled letter per call, and one letter is worth 5.3% of the
|
|
40
|
+
* A–T scale — adjacent bands are a couple of samples apart. Averaging repeats is
|
|
41
|
+
* what the upstream `n_evaluations` does for exactly this reason, and a track call
|
|
42
|
+
* is the cheapest kind (one prompt, no tournament), so it defaults to 3.
|
|
43
|
+
*/
|
|
44
|
+
autoTrackRepeats?: number
|
|
31
45
|
/**
|
|
32
46
|
* Scoring repeats for the FINAL session acceptance only.
|
|
33
47
|
*
|
|
@@ -50,6 +64,22 @@ export interface Config {
|
|
|
50
64
|
autoRouteMaxInputChars?: number
|
|
51
65
|
autoMaxModelCallsPerTask?: number
|
|
52
66
|
autoMaxModelCallsPerSession?: number
|
|
67
|
+
/**
|
|
68
|
+
* Persist a bounded snapshot (prompt + raw answer) of every verifier model call.
|
|
69
|
+
*
|
|
70
|
+
* Answers "why did the judge say that?" without replaying a session from its event
|
|
71
|
+
* log; capped per call, per record and per invocation by `decisions.ts`.
|
|
72
|
+
*/
|
|
73
|
+
captureDecisions?: boolean
|
|
74
|
+
/**
|
|
75
|
+
* Rubric the automatic gate (final acceptance, routed compare/select/track) scores with.
|
|
76
|
+
*
|
|
77
|
+
* Defaults to `coding` = the historical DEFAULT_CRITERIA, so an existing installation is
|
|
78
|
+
* unaffected. Judging a research or ops task with the coding rubric measures the wrong thing.
|
|
79
|
+
*/
|
|
80
|
+
criteriaPreset?: CriteriaPresetSelection
|
|
81
|
+
/** Markdown rubric file, read when `criteriaPreset` is `custom`. See README for the format. */
|
|
82
|
+
criteriaFile?: string
|
|
53
83
|
autoVerifyTeamTasks?: boolean
|
|
54
84
|
autoVerifyPlanMode?: boolean
|
|
55
85
|
autoVerifySubagents?: boolean
|
|
@@ -75,6 +105,7 @@ export interface ResolvedConfig {
|
|
|
75
105
|
autoVerifyMode: AutoVerifyMode
|
|
76
106
|
autoVerifyThreshold: number
|
|
77
107
|
autoVerifyRepeats: number
|
|
108
|
+
autoTrackRepeats: number
|
|
78
109
|
autoVerifyFinalRepeats: number
|
|
79
110
|
autoVerifyMinToolCalls: number
|
|
80
111
|
autoVerifyMaxChars: number
|
|
@@ -90,6 +121,9 @@ export interface ResolvedConfig {
|
|
|
90
121
|
autoRouteMaxInputChars: number
|
|
91
122
|
autoMaxModelCallsPerTask: number
|
|
92
123
|
autoMaxModelCallsPerSession: number
|
|
124
|
+
captureDecisions: boolean
|
|
125
|
+
criteriaPreset: CriteriaPresetSelection
|
|
126
|
+
criteriaFile: string
|
|
93
127
|
autoVerifyTeamTasks: boolean
|
|
94
128
|
autoVerifyPlanMode: boolean
|
|
95
129
|
autoVerifySubagents: boolean
|
|
@@ -122,6 +156,7 @@ export const Config: z<Config> = z.object({
|
|
|
122
156
|
autoVerifyMode: z.union(['manual', 'smart', 'strict']).default('smart'),
|
|
123
157
|
autoVerifyThreshold: z.number().min(0).max(1).default(0.65),
|
|
124
158
|
autoVerifyRepeats: z.number().step(1).min(1).default(1),
|
|
159
|
+
autoTrackRepeats: z.number().step(1).min(1).default(3),
|
|
125
160
|
autoVerifyFinalRepeats: z.number().step(1).min(1).default(2),
|
|
126
161
|
autoVerifyMinToolCalls: z.number().step(1).min(1).default(3),
|
|
127
162
|
autoVerifyMaxChars: z.number().step(1).min(1000).default(80000),
|
|
@@ -132,11 +167,14 @@ export const Config: z<Config> = z.object({
|
|
|
132
167
|
autoRouteMaxCandidates: z.number().step(1).min(3).default(8),
|
|
133
168
|
autoRouteMaxPerTask: z.number().step(1).min(1).default(2),
|
|
134
169
|
autoRouteMaxPerSession: z.number().step(1).min(1).default(8),
|
|
135
|
-
autoTrackCompletionThreshold: z.number().min(0).max(1).default(0.
|
|
170
|
+
autoTrackCompletionThreshold: z.number().min(0).max(1).default(0.684),
|
|
136
171
|
autoRouteMaxItemChars: z.number().step(1).min(100).default(20000),
|
|
137
172
|
autoRouteMaxInputChars: z.number().step(1).min(1000).default(60000),
|
|
138
173
|
autoMaxModelCallsPerTask: z.number().step(1).min(1).default(96),
|
|
139
174
|
autoMaxModelCallsPerSession: z.number().step(1).min(1).default(240),
|
|
175
|
+
captureDecisions: z.boolean().default(true),
|
|
176
|
+
criteriaPreset: z.union([...CRITERIA_PRESET_IDS, 'custom'] as const).default('coding'),
|
|
177
|
+
criteriaFile: z.string().default(''),
|
|
140
178
|
autoVerifyTeamTasks: z.boolean().default(true),
|
|
141
179
|
autoVerifyPlanMode: z.boolean().default(true),
|
|
142
180
|
autoVerifySubagents: z.boolean().default(false),
|
|
@@ -201,6 +239,7 @@ export function resolveConfig(config: Config = {}): ResolvedConfig {
|
|
|
201
239
|
if (!Number.isFinite(temperature) || temperature < 0 || temperature > 2) throw new Error('llm-verifier: temperature must be between 0 and 2')
|
|
202
240
|
const values = {
|
|
203
241
|
autoVerifyRepeats: config.autoVerifyRepeats ?? 1,
|
|
242
|
+
autoTrackRepeats: config.autoTrackRepeats ?? 3,
|
|
204
243
|
autoVerifyFinalRepeats: config.autoVerifyFinalRepeats ?? 2,
|
|
205
244
|
autoVerifyMinToolCalls: config.autoVerifyMinToolCalls ?? 3,
|
|
206
245
|
autoVerifyMaxChars: config.autoVerifyMaxChars ?? 80000,
|
|
@@ -224,7 +263,7 @@ export function resolveConfig(config: Config = {}): ResolvedConfig {
|
|
|
224
263
|
if (values.autoRouteMaxCandidates < 3 || values.autoRouteMaxCandidates > 16) throw new Error('llm-verifier: autoRouteMaxCandidates must be between 3 and 16')
|
|
225
264
|
if (values.autoRouteMaxInputChars < values.autoRouteMaxItemChars * 2) throw new Error('llm-verifier: autoRouteMaxInputChars must fit at least two route items')
|
|
226
265
|
const autoRouteMinConfidence = config.autoRouteMinConfidence ?? 0.9
|
|
227
|
-
const autoTrackCompletionThreshold = config.autoTrackCompletionThreshold ?? 0.
|
|
266
|
+
const autoTrackCompletionThreshold = config.autoTrackCompletionThreshold ?? 0.684
|
|
228
267
|
if (![autoRouteMinConfidence, autoTrackCompletionThreshold].every(value => Number.isFinite(value) && value >= 0 && value <= 1)) throw new Error('llm-verifier: auto route thresholds must be between 0 and 1')
|
|
229
268
|
const maxRetries = config.maxRetries ?? 3
|
|
230
269
|
if (!Number.isSafeInteger(maxRetries) || maxRetries < 0) throw new Error('llm-verifier: maxRetries must be a non-negative safe integer')
|
|
@@ -235,6 +274,11 @@ export function resolveConfig(config: Config = {}): ResolvedConfig {
|
|
|
235
274
|
const estimatedInputUsdPerMillion = config.estimatedInputUsdPerMillion ?? 0
|
|
236
275
|
const estimatedOutputUsdPerMillion = config.estimatedOutputUsdPerMillion ?? 0
|
|
237
276
|
if (![estimatedInputUsdPerMillion, estimatedOutputUsdPerMillion].every(value => Number.isFinite(value) && value >= 0)) throw new Error('llm-verifier: estimated token prices must be finite non-negative numbers')
|
|
277
|
+
const criteriaPreset = config.criteriaPreset ?? 'coding'
|
|
278
|
+
if (criteriaPreset !== 'custom' && !CRITERIA_PRESET_IDS.includes(criteriaPreset as CriteriaPresetId)) throw new Error('llm-verifier: criteriaPreset must be one of ' + [...CRITERIA_PRESET_IDS, 'custom'].join(', '))
|
|
279
|
+
// A half-configured custom rubric is NOT a config error: CriteriaResolver falls back to the
|
|
280
|
+
// coding preset and reports why, so a typo in a rubric path cannot disable the gate.
|
|
281
|
+
const criteriaFile = (config.criteriaFile ?? '').trim()
|
|
238
282
|
const reasoningEffort = config.reasoningEffort?.trim()
|
|
239
283
|
|
|
240
284
|
const extraJudges = config.extraJudges ?? []
|
|
@@ -306,6 +350,9 @@ export function resolveConfig(config: Config = {}): ResolvedConfig {
|
|
|
306
350
|
autoRouteSemantic: config.autoRouteSemantic ?? true,
|
|
307
351
|
autoRouteMinConfidence,
|
|
308
352
|
autoTrackCompletionThreshold,
|
|
353
|
+
captureDecisions: config.captureDecisions ?? true,
|
|
354
|
+
criteriaPreset,
|
|
355
|
+
criteriaFile,
|
|
309
356
|
autoVerifyTeamTasks: config.autoVerifyTeamTasks ?? true,
|
|
310
357
|
autoVerifyPlanMode: config.autoVerifyPlanMode ?? true,
|
|
311
358
|
autoVerifySubagents: config.autoVerifySubagents ?? false,
|
package/src/core.test.ts
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import {
|
|
3
|
+
CRITERIA_PRESETS,
|
|
3
4
|
DEFAULT_CRITERIA,
|
|
5
|
+
EMPTY_WORK_BASELINE,
|
|
4
6
|
accumulatePairs,
|
|
5
7
|
bradleyTerry,
|
|
8
|
+
buildGenerationPrompt,
|
|
6
9
|
buildPairwisePrompt,
|
|
7
10
|
buildProgressPrompt,
|
|
8
11
|
evidenceNonce,
|
|
9
12
|
extractProgressScore,
|
|
10
13
|
extractScore,
|
|
14
|
+
parseCriteriaMarkdown,
|
|
11
15
|
pivotRoundPairs,
|
|
12
16
|
rankScores,
|
|
13
17
|
renderDelimitedBlock,
|
|
14
18
|
ringCycle,
|
|
19
|
+
slugCriterionId,
|
|
15
20
|
topPivots,
|
|
16
21
|
} from './core.ts'
|
|
17
22
|
|
|
@@ -145,3 +150,93 @@ describe('pivot tournament', () => {
|
|
|
145
150
|
expect(rankScores(wins, counts)[0]?.index).toBe(0)
|
|
146
151
|
})
|
|
147
152
|
})
|
|
153
|
+
|
|
154
|
+
describe('criteria presets', () => {
|
|
155
|
+
it('keeps the default preset byte-identical to the historical default rubric', () => {
|
|
156
|
+
// The default preset must not change a single verdict, prompt or cache key.
|
|
157
|
+
expect(CRITERIA_PRESETS.coding).toEqual(DEFAULT_CRITERIA)
|
|
158
|
+
expect(CRITERIA_PRESETS.coding).toBe(DEFAULT_CRITERIA)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('gives every preset 2-4 narrow, uniquely identified criteria', () => {
|
|
162
|
+
for (const [name, criteria] of Object.entries(CRITERIA_PRESETS)) {
|
|
163
|
+
expect(criteria.length, name).toBeGreaterThanOrEqual(2)
|
|
164
|
+
expect(criteria.length, name).toBeLessThanOrEqual(4)
|
|
165
|
+
expect(new Set(criteria.map(criterion => criterion.id)).size, name).toBe(criteria.length)
|
|
166
|
+
for (const criterion of criteria) {
|
|
167
|
+
expect(criterion.id, name).toMatch(/^[a-z0-9_]+$/)
|
|
168
|
+
expect(criterion.name.trim().length, name).toBeGreaterThan(0)
|
|
169
|
+
expect(criterion.description.trim().length, name).toBeGreaterThan(40)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('criteria markdown', () => {
|
|
176
|
+
const file = [
|
|
177
|
+
'# Task — Verifier Criteria',
|
|
178
|
+
'<!-- an author note the judge never sees -->',
|
|
179
|
+
'',
|
|
180
|
+
'## Ground Truth Note',
|
|
181
|
+
'',
|
|
182
|
+
'Do NOT trust the agent self-assessment.',
|
|
183
|
+
'',
|
|
184
|
+
'## Criteria',
|
|
185
|
+
'',
|
|
186
|
+
'### Final Answer Correctness',
|
|
187
|
+
'',
|
|
188
|
+
'Check the answer against what the task asked for.',
|
|
189
|
+
'',
|
|
190
|
+
'### Empirical Verification {#verification}',
|
|
191
|
+
'',
|
|
192
|
+
'Look at the commands the agent actually ran.',
|
|
193
|
+
'',
|
|
194
|
+
].join('\n')
|
|
195
|
+
|
|
196
|
+
it('parses the note, the slugged ids and the pinned anchor', () => {
|
|
197
|
+
const parsed = parseCriteriaMarkdown(file)
|
|
198
|
+
expect(parsed.groundTruthNote).toBe('Do NOT trust the agent self-assessment.')
|
|
199
|
+
expect(parsed.criteria).toEqual([
|
|
200
|
+
{ id: 'final_answer_correctness', name: 'Final Answer Correctness', description: 'Check the answer against what the task asked for.' },
|
|
201
|
+
{ id: 'verification', name: 'Empirical Verification', description: 'Look at the commands the agent actually ran.' },
|
|
202
|
+
])
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('slugs and de-duplicates colliding ids instead of merging two criteria', () => {
|
|
206
|
+
const parsed = parseCriteriaMarkdown('## Criteria\n\n### Same Name\n\nfirst body\n\n### Same Name\n\nsecond body\n')
|
|
207
|
+
expect(parsed.criteria.map(criterion => criterion.id)).toEqual(['same_name', 'same_name_2'])
|
|
208
|
+
expect(parsed.criteria).toHaveLength(2)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('fails closed on a file with no criteria or with an empty instruction', () => {
|
|
212
|
+
expect(() => parseCriteriaMarkdown('# nothing here\n')).toThrow(/no criteria/u)
|
|
213
|
+
expect(() => parseCriteriaMarkdown('## Criteria\n\n### Empty\n\n### Other\n\nbody\n')).toThrow(/no instruction: empty/u)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('derives ids with the same slug rule the parser uses', () => {
|
|
217
|
+
expect(slugCriterionId('Final Answer Correctness')).toBe('final_answer_correctness')
|
|
218
|
+
expect(slugCriterionId('***')).toBe('criterion')
|
|
219
|
+
expect(slugCriterionId('x'.repeat(80)).length).toBe(40)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('renders a drafting prompt that cannot be mistaken for the judge contract', () => {
|
|
223
|
+
const prompt = buildGenerationPrompt('Fix the parser\nexactly.', 1, 3)
|
|
224
|
+
expect(prompt).toContain('Draft 2 of 3.')
|
|
225
|
+
expect(prompt).toContain('Fix the parser\nexactly.')
|
|
226
|
+
// Here the request IS the instruction to follow and the answer is free-form work product,
|
|
227
|
+
// so it is deliberately NOT wrapped in a data-only block nor given an A-T verdict contract.
|
|
228
|
+
expect(prompt).not.toContain('<<<TASK:')
|
|
229
|
+
expect(prompt).not.toContain('SECURITY')
|
|
230
|
+
expect(prompt).not.toContain('<score_A>')
|
|
231
|
+
// The only text that varies between the N drafts is the final line, so all of them share a
|
|
232
|
+
// maximal prompt prefix (the request itself) and a prefix cache can serve it.
|
|
233
|
+
const other = buildGenerationPrompt('Fix the parser\nexactly.', 2, 3)
|
|
234
|
+
let shared = 0
|
|
235
|
+
while (shared < prompt.length && prompt[shared] === other[shared]) shared += 1
|
|
236
|
+
expect(shared).toBeGreaterThan(prompt.length - 20)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('keeps exactly one definition of the gate baseline', () => {
|
|
240
|
+
expect(EMPTY_WORK_BASELINE).toBe('(No useful work or verification was performed.)')
|
|
241
|
+
})
|
|
242
|
+
})
|