crewhaus 0.1.8 → 0.2.1
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 +10 -3
- package/dist/advice-apply.d.ts +182 -0
- package/dist/advice-apply.js +286 -0
- package/dist/advise-rules.d.ts +348 -0
- package/dist/advise-rules.js +905 -0
- package/dist/alert-sink.d.ts +48 -0
- package/dist/alert-sink.js +86 -0
- package/dist/approval-gate.d.ts +127 -0
- package/dist/approval-gate.js +254 -0
- package/dist/audit-verify.d.ts +69 -0
- package/dist/audit-verify.js +97 -0
- package/dist/autodistill.d.ts +113 -0
- package/dist/autodistill.js +256 -0
- package/dist/channel-provision.d.ts +360 -0
- package/dist/channel-provision.js +881 -0
- package/dist/ci-scaffold.d.ts +31 -0
- package/dist/ci-scaffold.js +343 -0
- package/dist/compile-check.d.ts +90 -0
- package/dist/compile-check.js +285 -0
- package/dist/compliance-schedule.d.ts +35 -0
- package/dist/compliance-schedule.js +36 -0
- package/dist/context-pressure.d.ts +80 -0
- package/dist/context-pressure.js +166 -0
- package/dist/dataset-mine.d.ts +173 -0
- package/dist/dataset-mine.js +404 -0
- package/dist/datasets.d.ts +124 -0
- package/dist/datasets.js +260 -0
- package/dist/deploy-canary.d.ts +83 -0
- package/dist/deploy-canary.js +87 -0
- package/dist/doctor-checks.d.ts +33 -0
- package/dist/doctor-checks.js +92 -0
- package/dist/doctor-detect.d.ts +108 -0
- package/dist/doctor-detect.js +214 -0
- package/dist/doctor-fix.d.ts +81 -0
- package/dist/doctor-fix.js +164 -0
- package/dist/egress-triage.d.ts +121 -0
- package/dist/egress-triage.js +261 -0
- package/dist/eval-bridge.d.ts +114 -0
- package/dist/eval-bridge.js +158 -0
- package/dist/eval-coverage.d.ts +140 -0
- package/dist/eval-coverage.js +428 -0
- package/dist/eval-history.d.ts +48 -0
- package/dist/eval-history.js +157 -0
- package/dist/eval-matrix.d.ts +80 -0
- package/dist/eval-matrix.js +182 -0
- package/dist/eval-sentinel.d.ts +65 -0
- package/dist/eval-sentinel.js +132 -0
- package/dist/faq.d.ts +68 -0
- package/dist/faq.js +168 -0
- package/dist/feedback.d.ts +9 -2
- package/dist/feedback.js +17 -7
- package/dist/fewshot.d.ts +83 -0
- package/dist/fewshot.js +158 -0
- package/dist/fleet.d.ts +207 -0
- package/dist/fleet.js +488 -0
- package/dist/flywheel.d.ts +193 -0
- package/dist/flywheel.js +519 -0
- package/dist/graders-suggest.d.ts +186 -0
- package/dist/graders-suggest.js +658 -0
- package/dist/incident.d.ts +99 -0
- package/dist/incident.js +217 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.js +11664 -963
- package/dist/init-interactive.d.ts +105 -0
- package/dist/init-interactive.js +208 -0
- package/dist/intents.d.ts +105 -0
- package/dist/intents.js +292 -0
- package/dist/judge-calibrate.d.ts +137 -0
- package/dist/judge-calibrate.js +247 -0
- package/dist/justification-calibrate.d.ts +150 -0
- package/dist/justification-calibrate.js +262 -0
- package/dist/justification-gate.d.ts +27 -6
- package/dist/justification-gate.js +30 -6
- package/dist/knowledge-sync.d.ts +179 -0
- package/dist/knowledge-sync.js +551 -0
- package/dist/lessons.d.ts +87 -0
- package/dist/lessons.js +207 -0
- package/dist/lint.d.ts +127 -0
- package/dist/lint.js +226 -0
- package/dist/loadtest.d.ts +114 -0
- package/dist/loadtest.js +196 -0
- package/dist/marketplace-cli.d.ts +110 -0
- package/dist/marketplace-cli.js +250 -0
- package/dist/mcp-doctor.d.ts +121 -0
- package/dist/mcp-doctor.js +249 -0
- package/dist/model-scan.d.ts +116 -0
- package/dist/model-scan.js +226 -0
- package/dist/onchain-tune.d.ts +164 -0
- package/dist/onchain-tune.js +346 -0
- package/dist/permissions-suggest.d.ts +126 -0
- package/dist/permissions-suggest.js +333 -0
- package/dist/pii-tune.d.ts +107 -0
- package/dist/pii-tune.js +122 -0
- package/dist/propose.d.ts +117 -0
- package/dist/propose.js +184 -0
- package/dist/refresh-goldens.d.ts +82 -0
- package/dist/refresh-goldens.js +221 -0
- package/dist/regression-pin.d.ts +160 -0
- package/dist/regression-pin.js +281 -0
- package/dist/retention.d.ts +193 -0
- package/dist/retention.js +607 -0
- package/dist/retire.d.ts +118 -0
- package/dist/retire.js +291 -0
- package/dist/right-size.d.ts +100 -0
- package/dist/right-size.js +123 -0
- package/dist/route.d.ts +19 -0
- package/dist/route.js +90 -0
- package/dist/scaffold-evals.d.ts +138 -0
- package/dist/scaffold-evals.js +410 -0
- package/dist/scope-audit-drift.d.ts +139 -0
- package/dist/scope-audit-drift.js +260 -0
- package/dist/security-corpus.d.ts +237 -0
- package/dist/security-corpus.js +516 -0
- package/dist/security-digest.d.ts +174 -0
- package/dist/security-digest.js +656 -0
- package/dist/sessions-index.d.ts +27 -0
- package/dist/sessions-index.js +51 -0
- package/dist/slo-doctor.d.ts +67 -0
- package/dist/slo-doctor.js +119 -0
- package/dist/slo-sink.d.ts +96 -0
- package/dist/slo-sink.js +107 -0
- package/dist/spec-changelog.d.ts +102 -0
- package/dist/spec-changelog.js +237 -0
- package/dist/state-backup.d.ts +223 -0
- package/dist/state-backup.js +648 -0
- package/dist/tools-cli.d.ts +170 -0
- package/dist/tools-cli.js +300 -0
- package/dist/triage.d.ts +202 -0
- package/dist/triage.js +403 -0
- package/dist/upgrade.d.ts +57 -0
- package/dist/upgrade.js +113 -0
- package/dist/version.d.ts +6 -0
- package/dist/version.js +27 -0
- package/dist/voice-eval.d.ts +138 -0
- package/dist/voice-eval.js +309 -0
- package/dist/watch.d.ts +58 -0
- package/dist/watch.js +97 -0
- package/package.json +90 -65
package/dist/intents.js
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Item 67 — `crewhaus intents`: end-user intent analytics. What are people
|
|
3
|
+
* actually asking the harness, and how well is it serving them?
|
|
4
|
+
*
|
|
5
|
+
* Clusters `user_message` inputs across sessions with the SAME deterministic
|
|
6
|
+
* greedy-Jaccard token clustering the `faq` / `graders suggest` / coverage
|
|
7
|
+
* features use (no model call), then ranks each intent cluster by:
|
|
8
|
+
*
|
|
9
|
+
* - FREQUENCY: total occurrences + distinct sessions.
|
|
10
|
+
* - SATISFACTION: mean normalized rating of the cluster's rated turns.
|
|
11
|
+
* - FAILURE RATE: share of the cluster's turns that hit a struggle signal
|
|
12
|
+
* (error / tool-error / loop / retry — mined by the same
|
|
13
|
+
* `dataset mine` signals), i.e. an UNMET intent.
|
|
14
|
+
* - RECENCY: first vs second half of the session window → a "rising"
|
|
15
|
+
* intent asked much more in the recent half.
|
|
16
|
+
*
|
|
17
|
+
* From those it surfaces four views: top intents, rising intents, low-
|
|
18
|
+
* satisfaction intents (down-rated / high-error), and unmet intents (the agent
|
|
19
|
+
* couldn't answer — errors/loops/retries). This is the product-analytics feed
|
|
20
|
+
* for `dataset mine` / `faq distill` / few-shot harvest.
|
|
21
|
+
*
|
|
22
|
+
* Everything here is pure + deterministic (the CLI entry file reads sessions +
|
|
23
|
+
* feedback and PII/secret-redacts the rendered examples via a passed-in
|
|
24
|
+
* callback). No credential/secret text enters a rendered example un-redacted.
|
|
25
|
+
*/
|
|
26
|
+
import { mergeFeedback, normalizeRating } from "./feedback";
|
|
27
|
+
import { normalizeEvidenceTokens } from "./graders-suggest";
|
|
28
|
+
/** Thrown on malformed inputs / bad flags. The CLI routes it through `die()`. */
|
|
29
|
+
export class IntentsError extends Error {
|
|
30
|
+
name = "IntentsError";
|
|
31
|
+
}
|
|
32
|
+
function jaccard(a, b) {
|
|
33
|
+
let intersection = 0;
|
|
34
|
+
for (const t of a)
|
|
35
|
+
if (b.has(t))
|
|
36
|
+
intersection += 1;
|
|
37
|
+
const union = a.size + b.size - intersection;
|
|
38
|
+
return union === 0 ? 0 : intersection / union;
|
|
39
|
+
}
|
|
40
|
+
/** session#turn composite key — `#` never collides (session ids are hex,
|
|
41
|
+
* turnNumber an int), so no control/NUL byte enters a map key. */
|
|
42
|
+
function turnKey(sessionId, turnNumber) {
|
|
43
|
+
return `${sessionId}#${turnNumber}`;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Cluster ordered user turns into ranked intents. Deterministic: turns are
|
|
47
|
+
* clustered greedily by normalized-token Jaccard overlap in `order` sequence;
|
|
48
|
+
* each cluster's representative is the highest-token-frequency question (ties →
|
|
49
|
+
* shortest, then lexicographic). The recency split is the median `order`.
|
|
50
|
+
*/
|
|
51
|
+
export function clusterIntents(turns, feedback, failedTurnKeys, opts = {}) {
|
|
52
|
+
const threshold = opts.threshold ?? 0.5;
|
|
53
|
+
const lowBelow = opts.lowSatisfactionBelow ?? 0.5;
|
|
54
|
+
const unmetAtLeast = opts.unmetFailureAtLeast ?? 0.34;
|
|
55
|
+
const topN = opts.topN ?? 5;
|
|
56
|
+
const examplesPer = opts.examplesPerIntent ?? 3;
|
|
57
|
+
const ratingByKey = new Map();
|
|
58
|
+
for (const fb of mergeFeedback(feedback)) {
|
|
59
|
+
const r = normalizeRating(fb);
|
|
60
|
+
if (r !== undefined)
|
|
61
|
+
ratingByKey.set(turnKey(fb.sessionId, fb.turnNumber), r);
|
|
62
|
+
}
|
|
63
|
+
const failedKeys = new Set(failedTurnKeys.map((s) => turnKey(s.sessionId, s.turnNumber)));
|
|
64
|
+
const records = turns
|
|
65
|
+
.filter((t) => t.input.trim() !== "")
|
|
66
|
+
.map((t) => ({
|
|
67
|
+
sessionId: t.sessionId,
|
|
68
|
+
turnNumber: t.turnNumber,
|
|
69
|
+
question: t.input.trim(),
|
|
70
|
+
order: t.order,
|
|
71
|
+
rating: ratingByKey.get(turnKey(t.sessionId, t.turnNumber)),
|
|
72
|
+
failed: failedKeys.has(turnKey(t.sessionId, t.turnNumber)),
|
|
73
|
+
}))
|
|
74
|
+
// Stable clustering order: chronological, tie-broken deterministically.
|
|
75
|
+
.sort((a, b) => a.order - b.order || a.sessionId.localeCompare(b.sessionId) || a.turnNumber - b.turnNumber);
|
|
76
|
+
const totalSessions = new Set(records.map((r) => r.sessionId)).size;
|
|
77
|
+
// Median order for the rising split (older half < median ≤ recent half).
|
|
78
|
+
const orders = records.map((r) => r.order).sort((a, b) => a - b);
|
|
79
|
+
const medianOrder = orders.length === 0 ? 0 : orders[Math.floor(orders.length / 2)];
|
|
80
|
+
const clusters = [];
|
|
81
|
+
for (const rec of records) {
|
|
82
|
+
const tokens = new Set(normalizeEvidenceTokens(rec.question));
|
|
83
|
+
if (tokens.size === 0)
|
|
84
|
+
continue;
|
|
85
|
+
let bestIdx = -1;
|
|
86
|
+
let bestOverlap = 0;
|
|
87
|
+
for (let i = 0; i < clusters.length; i += 1) {
|
|
88
|
+
const overlap = jaccard(tokens, clusters[i].seed);
|
|
89
|
+
if (overlap > bestOverlap) {
|
|
90
|
+
bestOverlap = overlap;
|
|
91
|
+
bestIdx = i;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (bestIdx >= 0 && bestOverlap >= threshold) {
|
|
95
|
+
const c = clusters[bestIdx];
|
|
96
|
+
c.items.push(rec);
|
|
97
|
+
for (const t of tokens)
|
|
98
|
+
c.tokenFreq.set(t, (c.tokenFreq.get(t) ?? 0) + 1);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
const tokenFreq = new Map();
|
|
102
|
+
for (const t of tokens)
|
|
103
|
+
tokenFreq.set(t, 1);
|
|
104
|
+
clusters.push({ seed: tokens, items: [rec], tokenFreq });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const intents = clusters.map((c) => {
|
|
108
|
+
const occurrences = c.items.length;
|
|
109
|
+
const sessionCount = new Set(c.items.map((r) => r.sessionId)).size;
|
|
110
|
+
const rated = c.items.filter((r) => r.rating !== undefined);
|
|
111
|
+
const meanRating = rated.length === 0
|
|
112
|
+
? undefined
|
|
113
|
+
: rated.reduce((acc, r) => acc + r.rating, 0) / rated.length;
|
|
114
|
+
const failedTurns = c.items.filter((r) => r.failed).length;
|
|
115
|
+
const earlyCount = c.items.filter((r) => r.order < medianOrder).length;
|
|
116
|
+
const recentCount = occurrences - earlyCount;
|
|
117
|
+
// Representative: score each question by summed token frequency; higher is
|
|
118
|
+
// more central. Ties → shorter question, then lexicographic (deterministic).
|
|
119
|
+
const scored = c.items
|
|
120
|
+
.map((r) => {
|
|
121
|
+
const toks = normalizeEvidenceTokens(r.question);
|
|
122
|
+
const freq = toks.reduce((acc, t) => acc + (c.tokenFreq.get(t) ?? 0), 0);
|
|
123
|
+
return { question: r.question, freq };
|
|
124
|
+
})
|
|
125
|
+
.sort((a, b) => b.freq - a.freq ||
|
|
126
|
+
a.question.length - b.question.length ||
|
|
127
|
+
a.question.localeCompare(b.question));
|
|
128
|
+
const representative = scored[0]?.question ?? "";
|
|
129
|
+
// Distinct example questions, most-central first.
|
|
130
|
+
const examples = [];
|
|
131
|
+
for (const s of scored) {
|
|
132
|
+
if (!examples.includes(s.question))
|
|
133
|
+
examples.push(s.question);
|
|
134
|
+
if (examples.length >= examplesPer)
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
representative,
|
|
139
|
+
occurrences,
|
|
140
|
+
sessionCount,
|
|
141
|
+
meanRating,
|
|
142
|
+
failedTurns,
|
|
143
|
+
failureRate: occurrences === 0 ? 0 : failedTurns / occurrences,
|
|
144
|
+
earlyCount,
|
|
145
|
+
recentCount,
|
|
146
|
+
examples,
|
|
147
|
+
};
|
|
148
|
+
});
|
|
149
|
+
// Rank: frequency-first for the master list.
|
|
150
|
+
const byFrequency = [...intents].sort((a, b) => b.occurrences - a.occurrences ||
|
|
151
|
+
b.sessionCount - a.sessionCount ||
|
|
152
|
+
a.representative.localeCompare(b.representative));
|
|
153
|
+
const topIntents = byFrequency.slice(0, topN);
|
|
154
|
+
// Rising: recentCount > earlyCount, ranked by the delta (then frequency).
|
|
155
|
+
const risingIntents = [...intents]
|
|
156
|
+
.filter((i) => i.recentCount > i.earlyCount)
|
|
157
|
+
.sort((a, b) => b.recentCount - b.earlyCount - (a.recentCount - a.earlyCount) ||
|
|
158
|
+
b.occurrences - a.occurrences ||
|
|
159
|
+
a.representative.localeCompare(b.representative))
|
|
160
|
+
.slice(0, topN);
|
|
161
|
+
// Low satisfaction: rated AND below the threshold, worst first (then frequency).
|
|
162
|
+
const lowSatisfactionIntents = [...intents]
|
|
163
|
+
.filter((i) => i.meanRating !== undefined && i.meanRating < lowBelow)
|
|
164
|
+
.sort((a, b) => a.meanRating - b.meanRating ||
|
|
165
|
+
b.occurrences - a.occurrences ||
|
|
166
|
+
a.representative.localeCompare(b.representative))
|
|
167
|
+
.slice(0, topN);
|
|
168
|
+
// Unmet: failure rate at/above the threshold, highest first (then frequency).
|
|
169
|
+
const unmetIntents = [...intents]
|
|
170
|
+
.filter((i) => i.failureRate >= unmetAtLeast && i.failedTurns > 0)
|
|
171
|
+
.sort((a, b) => b.failureRate - a.failureRate ||
|
|
172
|
+
b.occurrences - a.occurrences ||
|
|
173
|
+
a.representative.localeCompare(b.representative))
|
|
174
|
+
.slice(0, topN);
|
|
175
|
+
return {
|
|
176
|
+
totalTurns: records.length,
|
|
177
|
+
totalSessions,
|
|
178
|
+
intents: byFrequency,
|
|
179
|
+
topIntents,
|
|
180
|
+
risingIntents,
|
|
181
|
+
lowSatisfactionIntents,
|
|
182
|
+
unmetIntents,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/** Build ordered turns from per-session events, oldest session first. The
|
|
186
|
+
* caller passes sessions already in chronological order (oldest→newest). */
|
|
187
|
+
export function orderedTurnsFromSessions(perSession, deriveTurns) {
|
|
188
|
+
const out = [];
|
|
189
|
+
let order = 0;
|
|
190
|
+
for (const { sessionId, events } of perSession) {
|
|
191
|
+
for (const t of deriveTurns(events)) {
|
|
192
|
+
out.push({ ...t, sessionId, order });
|
|
193
|
+
order += 1;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Rendering — text / json / html. `redact` is applied to every example string.
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
function redactExamples(intent, redact) {
|
|
202
|
+
return {
|
|
203
|
+
...intent,
|
|
204
|
+
representative: redact(intent.representative),
|
|
205
|
+
examples: intent.examples.map(redact),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
/** Apply the redactor to every rendered string in the digest. */
|
|
209
|
+
export function redactDigest(digest, redact) {
|
|
210
|
+
const map = (list) => list.map((i) => redactExamples(i, redact));
|
|
211
|
+
return {
|
|
212
|
+
...digest,
|
|
213
|
+
intents: map(digest.intents),
|
|
214
|
+
topIntents: map(digest.topIntents),
|
|
215
|
+
risingIntents: map(digest.risingIntents),
|
|
216
|
+
lowSatisfactionIntents: map(digest.lowSatisfactionIntents),
|
|
217
|
+
unmetIntents: map(digest.unmetIntents),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function fmtRating(r) {
|
|
221
|
+
return r === undefined ? "n/a" : `${(r * 100).toFixed(0)}%`;
|
|
222
|
+
}
|
|
223
|
+
/** Render the digest as plain text. */
|
|
224
|
+
export function renderIntentsText(digest) {
|
|
225
|
+
const lines = [];
|
|
226
|
+
lines.push(`intent analytics: ${digest.totalTurns} user turns across ${digest.totalSessions} sessions, ${digest.intents.length} intent(s)`);
|
|
227
|
+
const section = (title, list, detail) => {
|
|
228
|
+
lines.push("");
|
|
229
|
+
lines.push(title);
|
|
230
|
+
if (list.length === 0) {
|
|
231
|
+
lines.push(" (none)");
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
for (const i of list)
|
|
235
|
+
lines.push(` - ${i.representative} — ${detail(i)}`);
|
|
236
|
+
};
|
|
237
|
+
section("TOP INTENTS", digest.topIntents, (i) => `${i.occurrences}× in ${i.sessionCount} session(s), rating ${fmtRating(i.meanRating)}`);
|
|
238
|
+
section("RISING INTENTS", digest.risingIntents, (i) => `${i.earlyCount} → ${i.recentCount} (older → recent)`);
|
|
239
|
+
section("LOW SATISFACTION", digest.lowSatisfactionIntents, (i) => `rating ${fmtRating(i.meanRating)} over ${i.occurrences} turn(s)`);
|
|
240
|
+
section("UNMET (errors/loops/retries)", digest.unmetIntents, (i) => `${(i.failureRate * 100).toFixed(0)}% failed (${i.failedTurns}/${i.occurrences})`);
|
|
241
|
+
return `${lines.join("\n")}\n`;
|
|
242
|
+
}
|
|
243
|
+
/** Render the digest as pretty JSON. */
|
|
244
|
+
export function renderIntentsJson(digest) {
|
|
245
|
+
return `${JSON.stringify(digest, null, 2)}\n`;
|
|
246
|
+
}
|
|
247
|
+
function esc(s) {
|
|
248
|
+
return s
|
|
249
|
+
.replace(/&/g, "&")
|
|
250
|
+
.replace(/</g, "<")
|
|
251
|
+
.replace(/>/g, ">")
|
|
252
|
+
.replace(/"/g, """);
|
|
253
|
+
}
|
|
254
|
+
/** Render the digest as a self-contained HTML report. */
|
|
255
|
+
export function renderIntentsHtml(digest) {
|
|
256
|
+
const rows = (list, cols) => list.length === 0
|
|
257
|
+
? '<tr><td class="empty">(none)</td></tr>'
|
|
258
|
+
: list
|
|
259
|
+
.map((i) => `<tr><td>${esc(i.representative)}</td>${cols(i)
|
|
260
|
+
.map((c) => `<td>${esc(c)}</td>`)
|
|
261
|
+
.join("")}</tr>`)
|
|
262
|
+
.join("");
|
|
263
|
+
return `<!doctype html>
|
|
264
|
+
<html><head><meta charset="utf-8"><title>Intent analytics</title>
|
|
265
|
+
<style>
|
|
266
|
+
body{font:14px system-ui,sans-serif;margin:2rem;color:#111}
|
|
267
|
+
h1{font-size:1.4rem} h2{font-size:1.05rem;margin-top:1.5rem}
|
|
268
|
+
table{border-collapse:collapse;width:100%;margin-top:.5rem}
|
|
269
|
+
th,td{border:1px solid #ddd;padding:.4rem .6rem;text-align:left;vertical-align:top}
|
|
270
|
+
th{background:#f5f5f5} td.empty{color:#999;font-style:italic}
|
|
271
|
+
</style></head><body>
|
|
272
|
+
<h1>Intent analytics</h1>
|
|
273
|
+
<p>${digest.totalTurns} user turns across ${digest.totalSessions} sessions, ${digest.intents.length} intent(s).</p>
|
|
274
|
+
<h2>Top intents</h2>
|
|
275
|
+
<table><tr><th>Intent</th><th>Occurrences</th><th>Sessions</th><th>Rating</th></tr>
|
|
276
|
+
${rows(digest.topIntents, (i) => [String(i.occurrences), String(i.sessionCount), fmtRating(i.meanRating)])}
|
|
277
|
+
</table>
|
|
278
|
+
<h2>Rising intents</h2>
|
|
279
|
+
<table><tr><th>Intent</th><th>Older</th><th>Recent</th></tr>
|
|
280
|
+
${rows(digest.risingIntents, (i) => [String(i.earlyCount), String(i.recentCount)])}
|
|
281
|
+
</table>
|
|
282
|
+
<h2>Low satisfaction</h2>
|
|
283
|
+
<table><tr><th>Intent</th><th>Rating</th><th>Turns</th></tr>
|
|
284
|
+
${rows(digest.lowSatisfactionIntents, (i) => [fmtRating(i.meanRating), String(i.occurrences)])}
|
|
285
|
+
</table>
|
|
286
|
+
<h2>Unmet (errors / loops / retries)</h2>
|
|
287
|
+
<table><tr><th>Intent</th><th>Failure rate</th><th>Failed / total</th></tr>
|
|
288
|
+
${rows(digest.unmetIntents, (i) => [`${(i.failureRate * 100).toFixed(0)}%`, `${i.failedTurns}/${i.occurrences}`])}
|
|
289
|
+
</table>
|
|
290
|
+
</body></html>
|
|
291
|
+
`;
|
|
292
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Item 8 — `crewhaus judge calibrate`: measure how well an llm_judge grader
|
|
3
|
+
* agrees with real human ratings, and recommend the score cut that best
|
|
4
|
+
* separates the answers users liked from the ones they didn't.
|
|
5
|
+
*
|
|
6
|
+
* The command pairs, for each turn that carries BOTH a human `user_feedback`
|
|
7
|
+
* rating AND can be judged, the (human rating, llm_judge score). The judge
|
|
8
|
+
* call itself is a model-dependent seam that lives in the CLI entry file
|
|
9
|
+
* (re-running the judge over the rated transcript turn); this module owns the
|
|
10
|
+
* PURE statistics over the resulting pairs so they are unit-testable without a
|
|
11
|
+
* provider:
|
|
12
|
+
* - agreement: Pearson correlation + a confusion matrix at the current cut.
|
|
13
|
+
* - systematic bias: judge mean − human mean (both normalized to [0,1]).
|
|
14
|
+
* - the ROC-optimal cut: the judge score threshold maximizing Youden's J
|
|
15
|
+
* (TPR − FPR) at separating up- from down-rated turns.
|
|
16
|
+
* - per-rubric disagreement flags: rubrics whose judge-vs-human disagreement
|
|
17
|
+
* exceeds a threshold, naming the specific exemplars to re-anchor.
|
|
18
|
+
*
|
|
19
|
+
* `--apply` writes the per-spec calibrated `--min-score` default to
|
|
20
|
+
* `.crewhaus/judge-calibration.json` (a small documented file distill/optimize
|
|
21
|
+
* could later consult). Kept side-effect-free (the CLI entry file runs an argv
|
|
22
|
+
* switch on import) mirroring `feedback.ts` / `graders-suggest.ts`; all
|
|
23
|
+
* filesystem access + the judge model call live in `apps/cli/src/index.ts`.
|
|
24
|
+
*/
|
|
25
|
+
/** Thrown on malformed flags / unusable inputs. The CLI entry file routes it
|
|
26
|
+
* through `die()`; tests assert on `.message`. */
|
|
27
|
+
export declare class JudgeCalibrateError extends Error {
|
|
28
|
+
readonly name = "JudgeCalibrateError";
|
|
29
|
+
}
|
|
30
|
+
/** The [0,1] score at/above which a turn is treated as "up-rated" (positive)
|
|
31
|
+
* for confusion + ROC — the same 0.5 midpoint normalizeRating maps a thumbs
|
|
32
|
+
* up/down onto. */
|
|
33
|
+
export declare const POSITIVE_THRESHOLD = 0.5;
|
|
34
|
+
/** Default judge cut (normalized [0,1]) the confusion matrix reports against,
|
|
35
|
+
* matching distill/eval's default --min-score 0.7 → passing_score 3/5. */
|
|
36
|
+
export declare const DEFAULT_JUDGE_CUT = 0.5;
|
|
37
|
+
/** One paired observation: a human rating and the llm_judge's score for the
|
|
38
|
+
* same turn. Both are recorded in native units; helpers normalize. */
|
|
39
|
+
export type CalibrationPair = {
|
|
40
|
+
readonly sessionId: string;
|
|
41
|
+
readonly turnNumber: number;
|
|
42
|
+
/** Human rating normalized to [0,1] (thumbs up=1/down=0, stars (n-1)/4, …). */
|
|
43
|
+
readonly human: number;
|
|
44
|
+
/** Raw 1–5 llm_judge score. */
|
|
45
|
+
readonly judge: number;
|
|
46
|
+
/** Per-criterion 1–5 judge scores, when the rubric had named criteria. */
|
|
47
|
+
readonly criterionScores?: Readonly<Record<string, number>>;
|
|
48
|
+
};
|
|
49
|
+
/** Normalize a 1–5 judge score to [0,1] — the same (n-1)/4 map GradeResult uses. */
|
|
50
|
+
export declare function normalizeJudge(score: number): number;
|
|
51
|
+
export type ConfusionMatrix = {
|
|
52
|
+
/** judge-pass & human-up. */
|
|
53
|
+
readonly tp: number;
|
|
54
|
+
/** judge-pass & human-down. */
|
|
55
|
+
readonly fp: number;
|
|
56
|
+
/** judge-fail & human-down. */
|
|
57
|
+
readonly tn: number;
|
|
58
|
+
/** judge-fail & human-up. */
|
|
59
|
+
readonly fn: number;
|
|
60
|
+
};
|
|
61
|
+
/** Confusion of judge (normalized ≥ `judgeCut`) vs human (≥ POSITIVE_THRESHOLD). */
|
|
62
|
+
export declare function confusionAt(pairs: ReadonlyArray<CalibrationPair>, judgeCut: number): ConfusionMatrix;
|
|
63
|
+
/** Accuracy of the judge's pass/fail vs the human up/down at a cut. */
|
|
64
|
+
export declare function accuracyAt(pairs: ReadonlyArray<CalibrationPair>, judgeCut: number): number;
|
|
65
|
+
/**
|
|
66
|
+
* Pearson correlation between the human ratings and the normalized judge
|
|
67
|
+
* scores. Returns 0 when there is no variance in either series (a degenerate
|
|
68
|
+
* "all the same" set correlates with nothing).
|
|
69
|
+
*/
|
|
70
|
+
export declare function pearson(pairs: ReadonlyArray<CalibrationPair>): number;
|
|
71
|
+
/** Systematic bias: mean(normalized judge) − mean(human). Positive ⇒ the judge
|
|
72
|
+
* is more generous than users; negative ⇒ harsher. */
|
|
73
|
+
export declare function judgeBias(pairs: ReadonlyArray<CalibrationPair>): number;
|
|
74
|
+
export type RocPoint = {
|
|
75
|
+
readonly cut: number;
|
|
76
|
+
readonly tpr: number;
|
|
77
|
+
readonly fpr: number;
|
|
78
|
+
readonly youdenJ: number;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Sweep candidate judge cut points (the 5 normalized anchors 0, .25, .5, .75, 1
|
|
82
|
+
* plus midpoints between observed scores) and return the one that maximizes
|
|
83
|
+
* Youden's J = TPR − FPR at separating up-rated (human ≥ 0.5) from down-rated
|
|
84
|
+
* turns. Ties break toward the LOWER cut (more inclusive), then the midpoint
|
|
85
|
+
* default. Returns undefined when the set has no positives or no negatives (a
|
|
86
|
+
* ROC point is undefined without both classes).
|
|
87
|
+
*/
|
|
88
|
+
export declare function rocOptimalCut(pairs: ReadonlyArray<CalibrationPair>): RocPoint | undefined;
|
|
89
|
+
export type RubricDisagreement = {
|
|
90
|
+
readonly criterion: string;
|
|
91
|
+
/** Mean |normalized judge criterion − human| for this criterion. */
|
|
92
|
+
readonly meanAbsError: number;
|
|
93
|
+
/** The sessionId#turn exemplars where they disagreed most (up to 3). */
|
|
94
|
+
readonly exemplars: ReadonlyArray<string>;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Flag rubric criteria whose judge-vs-human disagreement exceeds `threshold`
|
|
98
|
+
* (mean absolute error on [0,1]). For each flagged criterion the worst
|
|
99
|
+
* exemplars are named so the user knows exactly which transcripts to look at
|
|
100
|
+
* when re-anchoring the rubric. Criteria absent from the pairs are skipped.
|
|
101
|
+
*/
|
|
102
|
+
export declare function flagDisagreements(pairs: ReadonlyArray<CalibrationPair>, threshold?: number): RubricDisagreement[];
|
|
103
|
+
export type CalibrationCard = {
|
|
104
|
+
readonly specName?: string;
|
|
105
|
+
readonly model?: string;
|
|
106
|
+
readonly pairCount: number;
|
|
107
|
+
readonly correlation: number;
|
|
108
|
+
readonly bias: number;
|
|
109
|
+
readonly accuracyAtDefault: number;
|
|
110
|
+
readonly confusionAtDefault: ConfusionMatrix;
|
|
111
|
+
readonly recommendedCut?: RocPoint;
|
|
112
|
+
readonly disagreements: ReadonlyArray<RubricDisagreement>;
|
|
113
|
+
};
|
|
114
|
+
/** Compute the full calibration card from the paired data. */
|
|
115
|
+
export declare function buildCalibrationCard(pairs: ReadonlyArray<CalibrationPair>, opts?: {
|
|
116
|
+
specName?: string;
|
|
117
|
+
model?: string;
|
|
118
|
+
}): CalibrationCard;
|
|
119
|
+
/** The on-disk `.crewhaus/judge-calibration.json` schema `--apply` writes. */
|
|
120
|
+
export type JudgeCalibrationFile = {
|
|
121
|
+
readonly version: 1;
|
|
122
|
+
/** Per spec name → the calibrated recommended --min-score cut ([0,1]). */
|
|
123
|
+
readonly calibrations: Record<string, {
|
|
124
|
+
readonly minScore: number;
|
|
125
|
+
readonly model?: string;
|
|
126
|
+
readonly correlation: number;
|
|
127
|
+
readonly bias: number;
|
|
128
|
+
readonly pairCount: number;
|
|
129
|
+
readonly updatedAt: string;
|
|
130
|
+
}>;
|
|
131
|
+
};
|
|
132
|
+
/** Build/merge the persisted calibration entry for a spec. Existing entries for
|
|
133
|
+
* OTHER specs are preserved. Returns the file object to write. The recommended
|
|
134
|
+
* cut is the ROC-optimal cut when available, else the default. */
|
|
135
|
+
export declare function buildCalibrationFile(existing: JudgeCalibrationFile | undefined, card: CalibrationCard, now: string): JudgeCalibrationFile;
|
|
136
|
+
/** Render the calibration card as a terminal report. */
|
|
137
|
+
export declare function renderCalibrationCard(card: CalibrationCard): string;
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Item 8 — `crewhaus judge calibrate`: measure how well an llm_judge grader
|
|
3
|
+
* agrees with real human ratings, and recommend the score cut that best
|
|
4
|
+
* separates the answers users liked from the ones they didn't.
|
|
5
|
+
*
|
|
6
|
+
* The command pairs, for each turn that carries BOTH a human `user_feedback`
|
|
7
|
+
* rating AND can be judged, the (human rating, llm_judge score). The judge
|
|
8
|
+
* call itself is a model-dependent seam that lives in the CLI entry file
|
|
9
|
+
* (re-running the judge over the rated transcript turn); this module owns the
|
|
10
|
+
* PURE statistics over the resulting pairs so they are unit-testable without a
|
|
11
|
+
* provider:
|
|
12
|
+
* - agreement: Pearson correlation + a confusion matrix at the current cut.
|
|
13
|
+
* - systematic bias: judge mean − human mean (both normalized to [0,1]).
|
|
14
|
+
* - the ROC-optimal cut: the judge score threshold maximizing Youden's J
|
|
15
|
+
* (TPR − FPR) at separating up- from down-rated turns.
|
|
16
|
+
* - per-rubric disagreement flags: rubrics whose judge-vs-human disagreement
|
|
17
|
+
* exceeds a threshold, naming the specific exemplars to re-anchor.
|
|
18
|
+
*
|
|
19
|
+
* `--apply` writes the per-spec calibrated `--min-score` default to
|
|
20
|
+
* `.crewhaus/judge-calibration.json` (a small documented file distill/optimize
|
|
21
|
+
* could later consult). Kept side-effect-free (the CLI entry file runs an argv
|
|
22
|
+
* switch on import) mirroring `feedback.ts` / `graders-suggest.ts`; all
|
|
23
|
+
* filesystem access + the judge model call live in `apps/cli/src/index.ts`.
|
|
24
|
+
*/
|
|
25
|
+
/** Thrown on malformed flags / unusable inputs. The CLI entry file routes it
|
|
26
|
+
* through `die()`; tests assert on `.message`. */
|
|
27
|
+
export class JudgeCalibrateError extends Error {
|
|
28
|
+
name = "JudgeCalibrateError";
|
|
29
|
+
}
|
|
30
|
+
/** The [0,1] score at/above which a turn is treated as "up-rated" (positive)
|
|
31
|
+
* for confusion + ROC — the same 0.5 midpoint normalizeRating maps a thumbs
|
|
32
|
+
* up/down onto. */
|
|
33
|
+
export const POSITIVE_THRESHOLD = 0.5;
|
|
34
|
+
/** Default judge cut (normalized [0,1]) the confusion matrix reports against,
|
|
35
|
+
* matching distill/eval's default --min-score 0.7 → passing_score 3/5. */
|
|
36
|
+
export const DEFAULT_JUDGE_CUT = 0.5;
|
|
37
|
+
/** Normalize a 1–5 judge score to [0,1] — the same (n-1)/4 map GradeResult uses. */
|
|
38
|
+
export function normalizeJudge(score) {
|
|
39
|
+
return clamp01((score - 1) / 4);
|
|
40
|
+
}
|
|
41
|
+
function clamp01(n) {
|
|
42
|
+
return n < 0 ? 0 : n > 1 ? 1 : n;
|
|
43
|
+
}
|
|
44
|
+
/** Confusion of judge (normalized ≥ `judgeCut`) vs human (≥ POSITIVE_THRESHOLD). */
|
|
45
|
+
export function confusionAt(pairs, judgeCut) {
|
|
46
|
+
let tp = 0;
|
|
47
|
+
let fp = 0;
|
|
48
|
+
let tn = 0;
|
|
49
|
+
let fn = 0;
|
|
50
|
+
for (const p of pairs) {
|
|
51
|
+
const judgePass = normalizeJudge(p.judge) >= judgeCut;
|
|
52
|
+
const humanUp = p.human >= POSITIVE_THRESHOLD;
|
|
53
|
+
if (judgePass && humanUp)
|
|
54
|
+
tp += 1;
|
|
55
|
+
else if (judgePass && !humanUp)
|
|
56
|
+
fp += 1;
|
|
57
|
+
else if (!judgePass && !humanUp)
|
|
58
|
+
tn += 1;
|
|
59
|
+
else
|
|
60
|
+
fn += 1;
|
|
61
|
+
}
|
|
62
|
+
return { tp, fp, tn, fn };
|
|
63
|
+
}
|
|
64
|
+
/** Accuracy of the judge's pass/fail vs the human up/down at a cut. */
|
|
65
|
+
export function accuracyAt(pairs, judgeCut) {
|
|
66
|
+
if (pairs.length === 0)
|
|
67
|
+
return 0;
|
|
68
|
+
const { tp, fp, tn, fn } = confusionAt(pairs, judgeCut);
|
|
69
|
+
return (tp + tn) / (tp + fp + tn + fn);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Pearson correlation between the human ratings and the normalized judge
|
|
73
|
+
* scores. Returns 0 when there is no variance in either series (a degenerate
|
|
74
|
+
* "all the same" set correlates with nothing).
|
|
75
|
+
*/
|
|
76
|
+
export function pearson(pairs) {
|
|
77
|
+
const n = pairs.length;
|
|
78
|
+
if (n < 2)
|
|
79
|
+
return 0;
|
|
80
|
+
const xs = pairs.map((p) => p.human);
|
|
81
|
+
const ys = pairs.map((p) => normalizeJudge(p.judge));
|
|
82
|
+
const mx = mean(xs);
|
|
83
|
+
const my = mean(ys);
|
|
84
|
+
let cov = 0;
|
|
85
|
+
let vx = 0;
|
|
86
|
+
let vy = 0;
|
|
87
|
+
for (let i = 0; i < n; i += 1) {
|
|
88
|
+
const dx = xs[i] - mx;
|
|
89
|
+
const dy = ys[i] - my;
|
|
90
|
+
cov += dx * dy;
|
|
91
|
+
vx += dx * dx;
|
|
92
|
+
vy += dy * dy;
|
|
93
|
+
}
|
|
94
|
+
if (vx === 0 || vy === 0)
|
|
95
|
+
return 0;
|
|
96
|
+
return cov / Math.sqrt(vx * vy);
|
|
97
|
+
}
|
|
98
|
+
/** Systematic bias: mean(normalized judge) − mean(human). Positive ⇒ the judge
|
|
99
|
+
* is more generous than users; negative ⇒ harsher. */
|
|
100
|
+
export function judgeBias(pairs) {
|
|
101
|
+
if (pairs.length === 0)
|
|
102
|
+
return 0;
|
|
103
|
+
return mean(pairs.map((p) => normalizeJudge(p.judge))) - mean(pairs.map((p) => p.human));
|
|
104
|
+
}
|
|
105
|
+
function mean(xs) {
|
|
106
|
+
return xs.length === 0 ? 0 : xs.reduce((a, b) => a + b, 0) / xs.length;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Sweep candidate judge cut points (the 5 normalized anchors 0, .25, .5, .75, 1
|
|
110
|
+
* plus midpoints between observed scores) and return the one that maximizes
|
|
111
|
+
* Youden's J = TPR − FPR at separating up-rated (human ≥ 0.5) from down-rated
|
|
112
|
+
* turns. Ties break toward the LOWER cut (more inclusive), then the midpoint
|
|
113
|
+
* default. Returns undefined when the set has no positives or no negatives (a
|
|
114
|
+
* ROC point is undefined without both classes).
|
|
115
|
+
*/
|
|
116
|
+
export function rocOptimalCut(pairs) {
|
|
117
|
+
const positives = pairs.filter((p) => p.human >= POSITIVE_THRESHOLD);
|
|
118
|
+
const negatives = pairs.filter((p) => p.human < POSITIVE_THRESHOLD);
|
|
119
|
+
if (positives.length === 0 || negatives.length === 0)
|
|
120
|
+
return undefined;
|
|
121
|
+
const scores = [...new Set(pairs.map((p) => normalizeJudge(p.judge)))].sort((a, b) => a - b);
|
|
122
|
+
const candidateCuts = new Set([0, 0.25, 0.5, 0.75, 1]);
|
|
123
|
+
for (let i = 0; i < scores.length; i += 1) {
|
|
124
|
+
const s = scores[i];
|
|
125
|
+
candidateCuts.add(s);
|
|
126
|
+
const next = scores[i + 1];
|
|
127
|
+
if (next !== undefined)
|
|
128
|
+
candidateCuts.add((s + next) / 2);
|
|
129
|
+
}
|
|
130
|
+
let best;
|
|
131
|
+
for (const cut of [...candidateCuts].sort((a, b) => a - b)) {
|
|
132
|
+
let tp = 0;
|
|
133
|
+
let fp = 0;
|
|
134
|
+
for (const p of positives)
|
|
135
|
+
if (normalizeJudge(p.judge) >= cut)
|
|
136
|
+
tp += 1;
|
|
137
|
+
for (const p of negatives)
|
|
138
|
+
if (normalizeJudge(p.judge) >= cut)
|
|
139
|
+
fp += 1;
|
|
140
|
+
const tpr = tp / positives.length;
|
|
141
|
+
const fpr = fp / negatives.length;
|
|
142
|
+
const youdenJ = tpr - fpr;
|
|
143
|
+
if (best === undefined || youdenJ > best.youdenJ) {
|
|
144
|
+
best = { cut, tpr, fpr, youdenJ };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return best;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Flag rubric criteria whose judge-vs-human disagreement exceeds `threshold`
|
|
151
|
+
* (mean absolute error on [0,1]). For each flagged criterion the worst
|
|
152
|
+
* exemplars are named so the user knows exactly which transcripts to look at
|
|
153
|
+
* when re-anchoring the rubric. Criteria absent from the pairs are skipped.
|
|
154
|
+
*/
|
|
155
|
+
export function flagDisagreements(pairs, threshold = 0.34) {
|
|
156
|
+
const byCriterion = new Map();
|
|
157
|
+
for (const p of pairs) {
|
|
158
|
+
if (p.criterionScores === undefined)
|
|
159
|
+
continue;
|
|
160
|
+
for (const [name, raw] of Object.entries(p.criterionScores)) {
|
|
161
|
+
const err = Math.abs(normalizeJudge(raw) - p.human);
|
|
162
|
+
const bucket = byCriterion.get(name) ?? [];
|
|
163
|
+
bucket.push({ ref: `${p.sessionId}#${p.turnNumber}`, err });
|
|
164
|
+
byCriterion.set(name, bucket);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const out = [];
|
|
168
|
+
for (const [criterion, errs] of byCriterion) {
|
|
169
|
+
const meanAbsError = mean(errs.map((e) => e.err));
|
|
170
|
+
if (meanAbsError < threshold)
|
|
171
|
+
continue;
|
|
172
|
+
const exemplars = [...errs]
|
|
173
|
+
.sort((a, b) => b.err - a.err || a.ref.localeCompare(b.ref))
|
|
174
|
+
.slice(0, 3)
|
|
175
|
+
.map((e) => e.ref);
|
|
176
|
+
out.push({ criterion, meanAbsError, exemplars });
|
|
177
|
+
}
|
|
178
|
+
return out.sort((a, b) => b.meanAbsError - a.meanAbsError || a.criterion.localeCompare(b.criterion));
|
|
179
|
+
}
|
|
180
|
+
/** Compute the full calibration card from the paired data. */
|
|
181
|
+
export function buildCalibrationCard(pairs, opts = {}) {
|
|
182
|
+
const roc = rocOptimalCut(pairs);
|
|
183
|
+
return {
|
|
184
|
+
...(opts.specName !== undefined ? { specName: opts.specName } : {}),
|
|
185
|
+
...(opts.model !== undefined ? { model: opts.model } : {}),
|
|
186
|
+
pairCount: pairs.length,
|
|
187
|
+
correlation: round(pearson(pairs)),
|
|
188
|
+
bias: round(judgeBias(pairs)),
|
|
189
|
+
accuracyAtDefault: round(accuracyAt(pairs, DEFAULT_JUDGE_CUT)),
|
|
190
|
+
confusionAtDefault: confusionAt(pairs, DEFAULT_JUDGE_CUT),
|
|
191
|
+
...(roc !== undefined ? { recommendedCut: roc } : {}),
|
|
192
|
+
disagreements: flagDisagreements(pairs),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function round(n) {
|
|
196
|
+
return Math.round(n * 1000) / 1000;
|
|
197
|
+
}
|
|
198
|
+
/** Build/merge the persisted calibration entry for a spec. Existing entries for
|
|
199
|
+
* OTHER specs are preserved. Returns the file object to write. The recommended
|
|
200
|
+
* cut is the ROC-optimal cut when available, else the default. */
|
|
201
|
+
export function buildCalibrationFile(existing, card, now) {
|
|
202
|
+
const specKey = card.specName ?? "default";
|
|
203
|
+
const minScore = card.recommendedCut?.cut ?? DEFAULT_JUDGE_CUT;
|
|
204
|
+
return {
|
|
205
|
+
version: 1,
|
|
206
|
+
calibrations: {
|
|
207
|
+
...(existing?.calibrations ?? {}),
|
|
208
|
+
[specKey]: {
|
|
209
|
+
minScore: round(minScore),
|
|
210
|
+
...(card.model !== undefined ? { model: card.model } : {}),
|
|
211
|
+
correlation: card.correlation,
|
|
212
|
+
bias: card.bias,
|
|
213
|
+
pairCount: card.pairCount,
|
|
214
|
+
updatedAt: now,
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
// -------- rendering --------
|
|
220
|
+
/** Render the calibration card as a terminal report. */
|
|
221
|
+
export function renderCalibrationCard(card) {
|
|
222
|
+
const lines = [];
|
|
223
|
+
const forSpec = card.specName !== undefined ? ` for "${card.specName}"` : "";
|
|
224
|
+
const withModel = card.model !== undefined ? ` (judge: ${card.model})` : "";
|
|
225
|
+
lines.push(`judge calibration${forSpec}${withModel}`);
|
|
226
|
+
lines.push(` paired ratings: ${card.pairCount}`);
|
|
227
|
+
lines.push(` correlation: ${card.correlation.toFixed(3)} (human vs judge)`);
|
|
228
|
+
lines.push(` systematic bias: ${card.bias >= 0 ? "+" : ""}${card.bias.toFixed(3)} (${card.bias > 0.05 ? "judge too GENEROUS" : card.bias < -0.05 ? "judge too HARSH" : "well-centered"})`);
|
|
229
|
+
const c = card.confusionAtDefault;
|
|
230
|
+
lines.push(` at cut ${DEFAULT_JUDGE_CUT}: accuracy ${(card.accuracyAtDefault * 100).toFixed(0)}% ` +
|
|
231
|
+
`(tp ${c.tp} / fp ${c.fp} / tn ${c.tn} / fn ${c.fn})`);
|
|
232
|
+
if (card.recommendedCut !== undefined) {
|
|
233
|
+
const r = card.recommendedCut;
|
|
234
|
+
lines.push(` ROC-optimal cut: ${r.cut.toFixed(3)} (TPR ${(r.tpr * 100).toFixed(0)}%, FPR ${(r.fpr * 100).toFixed(0)}%, Youden J ${r.youdenJ.toFixed(3)})`);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
lines.push(" ROC-optimal cut: n/a (need both up- and down-rated turns)");
|
|
238
|
+
}
|
|
239
|
+
if (card.disagreements.length > 0) {
|
|
240
|
+
lines.push("");
|
|
241
|
+
lines.push(` ${card.disagreements.length} rubric criterion/criteria disagree with users — re-anchor:`);
|
|
242
|
+
for (const d of card.disagreements) {
|
|
243
|
+
lines.push(` - "${d.criterion}" (mean abs error ${d.meanAbsError.toFixed(3)}) e.g. ${d.exemplars.join(", ")}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return `${lines.join("\n")}\n`;
|
|
247
|
+
}
|