crewhaus 0.1.8 → 0.2.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 +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 +172 -0
- package/dist/dataset-mine.js +403 -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 +11601 -964
- 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/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 +173 -0
- package/dist/security-digest.js +650 -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 +298 -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 +89 -65
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
import { gradersConfigToYaml, mergeFeedback, normalizeRating, } from "./feedback";
|
|
2
|
+
/** Thrown on malformed flags / unusable evidence. The CLI entry file routes
|
|
3
|
+
* it through `die()`; tests assert on `.message`. */
|
|
4
|
+
export class GradersSuggestError extends Error {
|
|
5
|
+
name = "GradersSuggestError";
|
|
6
|
+
}
|
|
7
|
+
/** How many recent runs (for the cwd spec) feed the suggestion by default. */
|
|
8
|
+
export const DEFAULT_SUGGEST_RUNS = 10;
|
|
9
|
+
/** Default output path for the review file. */
|
|
10
|
+
export const DEFAULT_SUGGESTED_GRADERS_FILE = "graders-suggested.yaml";
|
|
11
|
+
/** One-liner printed by `crewhaus distill` when it falls back to the floor
|
|
12
|
+
* grader (see {@link isFloorGraderConfig}). */
|
|
13
|
+
export const FLOOR_GRADER_HINT = "hint: draft sharper graders from recent eval failures and ratings with `crewhaus graders suggest`";
|
|
14
|
+
/** True when a synthesized config is exactly the non-empty-answer floor
|
|
15
|
+
* grader `synthesizeGraders` emits when the ratings carry no signal — the
|
|
16
|
+
* trigger for the {@link FLOOR_GRADER_HINT} suggestion hook. */
|
|
17
|
+
export function isFloorGraderConfig(config) {
|
|
18
|
+
const g = config.graders[0];
|
|
19
|
+
return (config.graders.length === 1 &&
|
|
20
|
+
g !== undefined &&
|
|
21
|
+
g.type === "regex" &&
|
|
22
|
+
g.name === "non_empty_answer");
|
|
23
|
+
}
|
|
24
|
+
/** Parse `--runs`: `last:N` / `last N` selects the N most recent indexed
|
|
25
|
+
* runs; anything else is a run directory path. */
|
|
26
|
+
export function parseRunsFlag(value) {
|
|
27
|
+
const m = /^last[:\s]?(\d+)$/i.exec(value.trim());
|
|
28
|
+
if (m !== null) {
|
|
29
|
+
const n = Number.parseInt(m[1], 10);
|
|
30
|
+
if (n < 1)
|
|
31
|
+
throw new GradersSuggestError(`invalid --runs "${value}" — need at least 1 run`);
|
|
32
|
+
return { kind: "last", n };
|
|
33
|
+
}
|
|
34
|
+
return { kind: "dir", dir: value };
|
|
35
|
+
}
|
|
36
|
+
/** Mirror of eval-runner's per-sample dir sanitizer — the key LoadedRun's
|
|
37
|
+
* perSample map uses. Keep in sync with run-sample.ts. */
|
|
38
|
+
export function sanitizeSampleId(id) {
|
|
39
|
+
return id.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
40
|
+
}
|
|
41
|
+
/** Unique tool names (first-seen order) from a per-sample events.jsonl —
|
|
42
|
+
* `tool_call_end` trace events carry the runtime tool name. Tolerant of
|
|
43
|
+
* torn/corrupt lines. */
|
|
44
|
+
export function toolNamesFromEventsJsonl(text) {
|
|
45
|
+
const out = [];
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
for (const line of text.split("\n")) {
|
|
48
|
+
const trimmed = line.trim();
|
|
49
|
+
if (trimmed === "")
|
|
50
|
+
continue;
|
|
51
|
+
let parsed;
|
|
52
|
+
try {
|
|
53
|
+
parsed = JSON.parse(trimmed);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const ev = parsed;
|
|
59
|
+
if (ev.kind !== "tool_call_end" || typeof ev.toolName !== "string")
|
|
60
|
+
continue;
|
|
61
|
+
if (seen.has(ev.toolName))
|
|
62
|
+
continue;
|
|
63
|
+
seen.add(ev.toolName);
|
|
64
|
+
out.push(ev.toolName);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Strip runner/judge boilerplate from a grader rationale so clustering sees
|
|
70
|
+
* the substance: the `[name: ✗]` combinator markers, the `judge=N (need
|
|
71
|
+
* ≥M):` prefix. Infra noise ("grader threw: …", "agent invocation error:
|
|
72
|
+
* …") returns "" — an outage is not a failure theme.
|
|
73
|
+
*/
|
|
74
|
+
export function cleanRationale(rationale) {
|
|
75
|
+
let s = rationale.trim();
|
|
76
|
+
if (s.startsWith("grader threw:") || s.startsWith("agent invocation error"))
|
|
77
|
+
return "";
|
|
78
|
+
s = s.replace(/\[[^\]]*: [✓✗]\]/g, " ");
|
|
79
|
+
s = s.replace(/judge=\d+(?:\.\d+)? \(need ≥\d+(?:\.\d+)?\):/g, " ");
|
|
80
|
+
return s.replace(/\s+/g, " ").trim();
|
|
81
|
+
}
|
|
82
|
+
/** Defensive parse of a raw grades.json for judge criterionScores (not part
|
|
83
|
+
* of GradeResult today — see the module doc). Criteria scored ≤ 2 of 5
|
|
84
|
+
* become failure evidence in their own right. */
|
|
85
|
+
export function criterionEvidence(gradesText, sampleId, runId) {
|
|
86
|
+
if (gradesText.trim() === "")
|
|
87
|
+
return [];
|
|
88
|
+
let parsed;
|
|
89
|
+
try {
|
|
90
|
+
parsed = JSON.parse(gradesText);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
const perGrader = parsed.perGrader;
|
|
96
|
+
if (!Array.isArray(perGrader))
|
|
97
|
+
return [];
|
|
98
|
+
const out = [];
|
|
99
|
+
for (const entry of perGrader) {
|
|
100
|
+
const e = entry;
|
|
101
|
+
if (typeof e.name !== "string" || typeof e.criterionScores !== "object")
|
|
102
|
+
continue;
|
|
103
|
+
if (e.criterionScores === null)
|
|
104
|
+
continue;
|
|
105
|
+
const scores = Object.entries(e.criterionScores)
|
|
106
|
+
.filter((kv) => typeof kv[1] === "number" && kv[1] <= 2)
|
|
107
|
+
.sort((a, b) => a[0].localeCompare(b[0]));
|
|
108
|
+
for (const [criterion, score] of scores) {
|
|
109
|
+
out.push({
|
|
110
|
+
sampleId,
|
|
111
|
+
runId,
|
|
112
|
+
source: `${e.name}:criterion`,
|
|
113
|
+
text: `judge criterion ${criterion} scored ${score}/5`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Extract failure evidence + pass exemplars from one loaded eval run.
|
|
121
|
+
* Samples are visited in sampleId order (determinism), errored/grader-threw
|
|
122
|
+
* samples are skipped wholesale (infra noise, not a failure theme — the
|
|
123
|
+
* same posture as triage's pin guards).
|
|
124
|
+
*/
|
|
125
|
+
export function evidenceFromRun(run) {
|
|
126
|
+
const runId = run.summary.runId;
|
|
127
|
+
const failures = [];
|
|
128
|
+
const passes = [];
|
|
129
|
+
const samples = [...run.summary.samples].sort((a, b) => a.sampleId.localeCompare(b.sampleId));
|
|
130
|
+
for (const s of samples) {
|
|
131
|
+
if (s.error !== undefined || s.graderError !== undefined)
|
|
132
|
+
continue;
|
|
133
|
+
const perSample = run.perSample[sanitizeSampleId(s.sampleId)];
|
|
134
|
+
if (s.grades.overall.passed) {
|
|
135
|
+
passes.push({
|
|
136
|
+
sampleId: s.sampleId,
|
|
137
|
+
runId,
|
|
138
|
+
output: s.agentOutput,
|
|
139
|
+
toolNames: toolNamesFromEventsJsonl(perSample?.events ?? ""),
|
|
140
|
+
});
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
for (const g of s.grades.perGrader) {
|
|
144
|
+
if (g.passed)
|
|
145
|
+
continue;
|
|
146
|
+
const text = cleanRationale(g.rationale);
|
|
147
|
+
if (text === "")
|
|
148
|
+
continue;
|
|
149
|
+
failures.push({ sampleId: s.sampleId, runId, source: g.name, text, output: s.agentOutput });
|
|
150
|
+
}
|
|
151
|
+
failures.push(...criterionEvidence(perSample?.grades ?? "", s.sampleId, runId));
|
|
152
|
+
}
|
|
153
|
+
return { failures, passes };
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Fold user ratings into the evidence: down-rated comments become failure
|
|
157
|
+
* evidence, up-rated turns become pass exemplars (their outputs + observed
|
|
158
|
+
* tool names). Positive comments are returned separately — they season the
|
|
159
|
+
* llm_judge rubric prompt, not the clustering.
|
|
160
|
+
*/
|
|
161
|
+
export function evidenceFromFeedback(turns, records, minScore) {
|
|
162
|
+
const turnByKey = new Map();
|
|
163
|
+
for (const t of turns)
|
|
164
|
+
turnByKey.set(`${t.sessionId}#${t.turnNumber}`, t);
|
|
165
|
+
const failures = [];
|
|
166
|
+
const passes = [];
|
|
167
|
+
const positiveComments = [];
|
|
168
|
+
for (const fb of mergeFeedback(records)) {
|
|
169
|
+
const turn = turnByKey.get(`${fb.sessionId}#${fb.turnNumber}`);
|
|
170
|
+
if (turn === undefined)
|
|
171
|
+
continue;
|
|
172
|
+
const score = normalizeRating(fb);
|
|
173
|
+
const isPositive = (score !== undefined && score >= minScore) || fb.correction !== undefined;
|
|
174
|
+
const sampleId = `${fb.sessionId}_t${fb.turnNumber}`;
|
|
175
|
+
if (isPositive) {
|
|
176
|
+
if (turn.output.trim() !== "") {
|
|
177
|
+
passes.push({
|
|
178
|
+
sampleId,
|
|
179
|
+
runId: "feedback",
|
|
180
|
+
output: turn.output,
|
|
181
|
+
toolNames: turn.toolNames,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
if (fb.comment !== undefined && fb.comment.trim() !== "") {
|
|
185
|
+
positiveComments.push(fb.comment);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
else if (fb.comment !== undefined && fb.comment.trim() !== "") {
|
|
189
|
+
failures.push({
|
|
190
|
+
sampleId,
|
|
191
|
+
runId: "feedback",
|
|
192
|
+
source: "user_feedback",
|
|
193
|
+
text: fb.comment,
|
|
194
|
+
output: turn.output,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return { failures, passes, positiveComments };
|
|
199
|
+
}
|
|
200
|
+
// -------- deterministic clustering --------
|
|
201
|
+
/** Stopwords for the theme tokens: common English glue plus grader-rationale
|
|
202
|
+
* boilerplate that would otherwise weld unrelated failures together. */
|
|
203
|
+
const THEME_STOPWORDS = new Set([
|
|
204
|
+
"the",
|
|
205
|
+
"and",
|
|
206
|
+
"that",
|
|
207
|
+
"this",
|
|
208
|
+
"with",
|
|
209
|
+
"from",
|
|
210
|
+
"your",
|
|
211
|
+
"have",
|
|
212
|
+
"will",
|
|
213
|
+
"here",
|
|
214
|
+
"there",
|
|
215
|
+
"what",
|
|
216
|
+
"when",
|
|
217
|
+
"which",
|
|
218
|
+
"would",
|
|
219
|
+
"could",
|
|
220
|
+
"should",
|
|
221
|
+
"about",
|
|
222
|
+
"into",
|
|
223
|
+
"then",
|
|
224
|
+
"than",
|
|
225
|
+
"them",
|
|
226
|
+
"they",
|
|
227
|
+
"you",
|
|
228
|
+
"for",
|
|
229
|
+
"are",
|
|
230
|
+
"was",
|
|
231
|
+
"were",
|
|
232
|
+
"its",
|
|
233
|
+
"not",
|
|
234
|
+
"but",
|
|
235
|
+
"does",
|
|
236
|
+
// grader-rationale boilerplate
|
|
237
|
+
"output",
|
|
238
|
+
"expected",
|
|
239
|
+
"got",
|
|
240
|
+
"saw",
|
|
241
|
+
]);
|
|
242
|
+
/** Normalized token list for one evidence text: lowercase, alphanumeric,
|
|
243
|
+
* length ≥ 3, non-stopword, unique, in first-seen order. */
|
|
244
|
+
export function normalizeEvidenceTokens(text) {
|
|
245
|
+
const out = [];
|
|
246
|
+
const seen = new Set();
|
|
247
|
+
for (const raw of text.toLowerCase().split(/[^a-z0-9]+/)) {
|
|
248
|
+
if (raw.length < 3 || THEME_STOPWORDS.has(raw) || seen.has(raw))
|
|
249
|
+
continue;
|
|
250
|
+
seen.add(raw);
|
|
251
|
+
out.push(raw);
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
function jaccard(a, b) {
|
|
256
|
+
let intersection = 0;
|
|
257
|
+
for (const t of a)
|
|
258
|
+
if (b.has(t))
|
|
259
|
+
intersection += 1;
|
|
260
|
+
const union = a.size + b.size - intersection;
|
|
261
|
+
return union === 0 ? 0 : intersection / union;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Deterministic greedy clustering by normalized token overlap — no model
|
|
265
|
+
* call. Items are visited in a stable sort order (runId, sampleId, source,
|
|
266
|
+
* text) and joined to the best existing cluster when the Jaccard overlap
|
|
267
|
+
* with its SEED item's tokens reaches `threshold` (seed comparison keeps a
|
|
268
|
+
* cluster from drifting as it grows); otherwise they seed a new cluster.
|
|
269
|
+
* Themes come back largest-first, labelled by their most frequent tokens.
|
|
270
|
+
*/
|
|
271
|
+
export function clusterFailures(items, opts = {}) {
|
|
272
|
+
const threshold = opts.threshold ?? 0.34;
|
|
273
|
+
const sorted = [...items].sort((a, b) => a.runId.localeCompare(b.runId) ||
|
|
274
|
+
a.sampleId.localeCompare(b.sampleId) ||
|
|
275
|
+
a.source.localeCompare(b.source) ||
|
|
276
|
+
a.text.localeCompare(b.text));
|
|
277
|
+
const clusters = [];
|
|
278
|
+
for (const item of sorted) {
|
|
279
|
+
const tokens = new Set(normalizeEvidenceTokens(item.text));
|
|
280
|
+
if (tokens.size === 0)
|
|
281
|
+
continue;
|
|
282
|
+
let bestIdx = -1;
|
|
283
|
+
let bestOverlap = 0;
|
|
284
|
+
for (let i = 0; i < clusters.length; i += 1) {
|
|
285
|
+
const overlap = jaccard(tokens, clusters[i].seed);
|
|
286
|
+
if (overlap > bestOverlap) {
|
|
287
|
+
bestOverlap = overlap;
|
|
288
|
+
bestIdx = i;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (bestIdx >= 0 && bestOverlap >= threshold) {
|
|
292
|
+
const c = clusters[bestIdx];
|
|
293
|
+
c.items.push(item);
|
|
294
|
+
c.tokenSets.push(tokens);
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
clusters.push({ seed: tokens, items: [item], tokenSets: [tokens] });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const themes = clusters.map((c) => {
|
|
301
|
+
const freq = new Map();
|
|
302
|
+
for (const set of c.tokenSets) {
|
|
303
|
+
for (const t of set)
|
|
304
|
+
freq.set(t, (freq.get(t) ?? 0) + 1);
|
|
305
|
+
}
|
|
306
|
+
const tokens = [...freq.entries()]
|
|
307
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
308
|
+
.slice(0, 3)
|
|
309
|
+
.map(([t]) => t);
|
|
310
|
+
return {
|
|
311
|
+
label: tokens.join(" "),
|
|
312
|
+
tokens,
|
|
313
|
+
items: c.items,
|
|
314
|
+
sampleIds: [...new Set(c.items.map((i) => i.sampleId))],
|
|
315
|
+
runIds: [...new Set(c.items.map((i) => i.runId))],
|
|
316
|
+
sources: [...new Set(c.items.map((i) => i.source))].sort(),
|
|
317
|
+
};
|
|
318
|
+
});
|
|
319
|
+
return themes.sort((a, b) => b.items.length - a.items.length || a.label.localeCompare(b.label));
|
|
320
|
+
}
|
|
321
|
+
const slugify = (s) => s
|
|
322
|
+
.toLowerCase()
|
|
323
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
324
|
+
.replace(/^_+|_+$/g, "")
|
|
325
|
+
.slice(0, 40);
|
|
326
|
+
function themeEvidenceLine(theme) {
|
|
327
|
+
return (`evidence: ${theme.items.length} failing rationale(s) on ${theme.sampleIds.length} sample(s) ` +
|
|
328
|
+
`across ${theme.runIds.length} run(s)/source(s) [${theme.sources.join(", ")}]`);
|
|
329
|
+
}
|
|
330
|
+
/** Tools present in EVERY tool-using pass exemplar (falling back to the
|
|
331
|
+
* single most common tool) — the same selection `synthesizeGraders` makes. */
|
|
332
|
+
function sharedPassTools(passes) {
|
|
333
|
+
const counts = new Map();
|
|
334
|
+
let toolUsing = 0;
|
|
335
|
+
for (const p of passes) {
|
|
336
|
+
const uniq = [...new Set(p.toolNames)];
|
|
337
|
+
if (uniq.length > 0)
|
|
338
|
+
toolUsing += 1;
|
|
339
|
+
for (const t of uniq)
|
|
340
|
+
counts.set(t, (counts.get(t) ?? 0) + 1);
|
|
341
|
+
}
|
|
342
|
+
if (toolUsing === 0 || counts.size === 0)
|
|
343
|
+
return [];
|
|
344
|
+
const inEvery = [...counts.entries()]
|
|
345
|
+
.filter(([, c]) => c === toolUsing)
|
|
346
|
+
.map(([t]) => t)
|
|
347
|
+
.sort();
|
|
348
|
+
if (inEvery.length > 0)
|
|
349
|
+
return inEvery;
|
|
350
|
+
const top = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0];
|
|
351
|
+
return top !== undefined ? [top[0]] : [];
|
|
352
|
+
}
|
|
353
|
+
function themeMentionsTools(theme, toolNames) {
|
|
354
|
+
if (theme.tokens.includes("tool") || theme.tokens.includes("tools"))
|
|
355
|
+
return true;
|
|
356
|
+
const lowered = new Set(toolNames.map((t) => t.toLowerCase()));
|
|
357
|
+
return theme.items.some((i) => normalizeEvidenceTokens(i.text).some((tok) => lowered.has(tok)));
|
|
358
|
+
}
|
|
359
|
+
/** All pass outputs parse as JSON objects sharing ≥1 top-level key → that
|
|
360
|
+
* key (alphabetically first among common keys); undefined otherwise. */
|
|
361
|
+
function commonJsonKey(passes) {
|
|
362
|
+
if (passes.length === 0)
|
|
363
|
+
return undefined;
|
|
364
|
+
let common;
|
|
365
|
+
for (const p of passes) {
|
|
366
|
+
let parsed;
|
|
367
|
+
try {
|
|
368
|
+
parsed = JSON.parse(p.output);
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
374
|
+
return undefined;
|
|
375
|
+
const keys = new Set(Object.keys(parsed));
|
|
376
|
+
common = common === undefined ? keys : new Set([...common].filter((k) => keys.has(k)));
|
|
377
|
+
}
|
|
378
|
+
const sorted = [...(common ?? [])].sort();
|
|
379
|
+
return sorted[0];
|
|
380
|
+
}
|
|
381
|
+
/** Longest common prefix of the trimmed pass outputs (≥ 2 passes, ≥ 4
|
|
382
|
+
* chars, must contain a non-space char); undefined otherwise. */
|
|
383
|
+
function commonOutputPrefix(passes) {
|
|
384
|
+
if (passes.length < 2)
|
|
385
|
+
return undefined;
|
|
386
|
+
const outputs = passes.map((p) => p.output.trim());
|
|
387
|
+
if (outputs.some((o) => o === ""))
|
|
388
|
+
return undefined;
|
|
389
|
+
let prefix = outputs[0];
|
|
390
|
+
for (const o of outputs.slice(1)) {
|
|
391
|
+
let i = 0;
|
|
392
|
+
while (i < prefix.length && i < o.length && prefix[i] === o[i])
|
|
393
|
+
i += 1;
|
|
394
|
+
prefix = prefix.slice(0, i);
|
|
395
|
+
if (prefix === "")
|
|
396
|
+
return undefined;
|
|
397
|
+
}
|
|
398
|
+
prefix = prefix.trimEnd();
|
|
399
|
+
if (prefix.length < 4 || prefix.trim() === "")
|
|
400
|
+
return undefined;
|
|
401
|
+
return prefix.slice(0, 60);
|
|
402
|
+
}
|
|
403
|
+
const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
404
|
+
/**
|
|
405
|
+
* The most discriminative token: appears in ≥ half of the up-rated outputs
|
|
406
|
+
* and is ranked by how much rarer it is in the failing outputs (pass
|
|
407
|
+
* document-frequency minus fail document-frequency, ties alphabetical).
|
|
408
|
+
*/
|
|
409
|
+
function discriminativeToken(passes, failOutputs) {
|
|
410
|
+
const nonEmpty = passes.map((p) => p.output).filter((o) => o.trim() !== "");
|
|
411
|
+
if (nonEmpty.length === 0)
|
|
412
|
+
return undefined;
|
|
413
|
+
const passFreq = new Map();
|
|
414
|
+
for (const output of nonEmpty) {
|
|
415
|
+
for (const t of new Set(normalizeEvidenceTokens(output))) {
|
|
416
|
+
passFreq.set(t, (passFreq.get(t) ?? 0) + 1);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const failFreq = new Map();
|
|
420
|
+
const fails = failOutputs.filter((o) => o.trim() !== "");
|
|
421
|
+
for (const output of fails) {
|
|
422
|
+
for (const t of new Set(normalizeEvidenceTokens(output))) {
|
|
423
|
+
failFreq.set(t, (failFreq.get(t) ?? 0) + 1);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const threshold = Math.ceil(nonEmpty.length / 2);
|
|
427
|
+
const ranked = [...passFreq.entries()]
|
|
428
|
+
.filter(([t, c]) => t.length >= 4 && c >= threshold)
|
|
429
|
+
.map(([t, c]) => ({
|
|
430
|
+
token: t,
|
|
431
|
+
score: c / nonEmpty.length - (fails.length === 0 ? 0 : (failFreq.get(t) ?? 0) / fails.length),
|
|
432
|
+
}))
|
|
433
|
+
.sort((a, b) => b.score - a.score || a.token.localeCompare(b.token));
|
|
434
|
+
const best = ranked[0];
|
|
435
|
+
// A token every failing output also carries discriminates nothing.
|
|
436
|
+
return best !== undefined && best.score > 0 ? best.token : undefined;
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Draft one deterministic grader per failure theme from the observed
|
|
440
|
+
* up-rated outputs, in priority order: tool_call_sequence (theme mentions
|
|
441
|
+
* tools + passes share tools) → json_path (all pass outputs are JSON
|
|
442
|
+
* objects with a common key) → regex (shared output prefix) → contains
|
|
443
|
+
* (discriminative pass token). Themes with no deterministic signal come
|
|
444
|
+
* back in `undrafted` so the review file can note them.
|
|
445
|
+
*/
|
|
446
|
+
export function draftGradersForThemes(themes, passes) {
|
|
447
|
+
const suggestions = [];
|
|
448
|
+
const undrafted = [];
|
|
449
|
+
const usedNames = new Set();
|
|
450
|
+
const uniqueName = (base) => {
|
|
451
|
+
let name = base;
|
|
452
|
+
let i = 2;
|
|
453
|
+
while (usedNames.has(name)) {
|
|
454
|
+
name = `${base}_${i}`;
|
|
455
|
+
i += 1;
|
|
456
|
+
}
|
|
457
|
+
usedNames.add(name);
|
|
458
|
+
return name;
|
|
459
|
+
};
|
|
460
|
+
const passTools = sharedPassTools(passes);
|
|
461
|
+
const jsonKey = commonJsonKey(passes);
|
|
462
|
+
const prefix = commonOutputPrefix(passes);
|
|
463
|
+
for (const theme of themes) {
|
|
464
|
+
const rawSlug = slugify(theme.label);
|
|
465
|
+
const slug = rawSlug === "" ? "theme" : rawSlug;
|
|
466
|
+
const failOutputs = theme.items
|
|
467
|
+
.map((i) => i.output)
|
|
468
|
+
.filter((o) => o !== undefined);
|
|
469
|
+
if (passTools.length > 0 && themeMentionsTools(theme, passTools)) {
|
|
470
|
+
suggestions.push({
|
|
471
|
+
spec: {
|
|
472
|
+
name: uniqueName(`suggested_tools_${slug}`),
|
|
473
|
+
type: "tool_call_sequence",
|
|
474
|
+
expected: passTools,
|
|
475
|
+
mode: "set",
|
|
476
|
+
},
|
|
477
|
+
evidence: [
|
|
478
|
+
themeEvidenceLine(theme),
|
|
479
|
+
`drafted from ${passes.length} up-rated output(s): every tool-using one called [${passTools.join(", ")}]`,
|
|
480
|
+
],
|
|
481
|
+
});
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
if (jsonKey !== undefined) {
|
|
485
|
+
const path = /^[A-Za-z_][A-Za-z0-9_]*$/.test(jsonKey)
|
|
486
|
+
? `$.${jsonKey}`
|
|
487
|
+
: `$[${JSON.stringify(jsonKey)}]`;
|
|
488
|
+
suggestions.push({
|
|
489
|
+
spec: { name: uniqueName(`suggested_json_${slug}`), type: "json_path", path },
|
|
490
|
+
evidence: [
|
|
491
|
+
themeEvidenceLine(theme),
|
|
492
|
+
`drafted from ${passes.length} up-rated output(s): all are JSON objects carrying key "${jsonKey}"`,
|
|
493
|
+
],
|
|
494
|
+
});
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (prefix !== undefined) {
|
|
498
|
+
suggestions.push({
|
|
499
|
+
spec: {
|
|
500
|
+
name: uniqueName(`suggested_format_${slug}`),
|
|
501
|
+
type: "regex",
|
|
502
|
+
pattern: `^${escapeRegex(prefix)}`,
|
|
503
|
+
},
|
|
504
|
+
evidence: [
|
|
505
|
+
themeEvidenceLine(theme),
|
|
506
|
+
`drafted from ${passes.length} up-rated output(s): all start with "${prefix}"`,
|
|
507
|
+
],
|
|
508
|
+
});
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
const token = discriminativeToken(passes, failOutputs);
|
|
512
|
+
if (token !== undefined) {
|
|
513
|
+
suggestions.push({
|
|
514
|
+
spec: {
|
|
515
|
+
name: uniqueName(`suggested_contains_${slug}`),
|
|
516
|
+
type: "contains",
|
|
517
|
+
substring: token,
|
|
518
|
+
case_insensitive: true,
|
|
519
|
+
},
|
|
520
|
+
evidence: [
|
|
521
|
+
themeEvidenceLine(theme),
|
|
522
|
+
`drafted from ${passes.length} up-rated output(s): "${token}" is common in up-rated outputs and rare in failing ones`,
|
|
523
|
+
],
|
|
524
|
+
});
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
undrafted.push(theme);
|
|
528
|
+
}
|
|
529
|
+
return { suggestions, undrafted };
|
|
530
|
+
}
|
|
531
|
+
// -------- model-drafted llm_judge rubric (pure halves) --------
|
|
532
|
+
/** System block for the one-shot rubric drafting call. */
|
|
533
|
+
export const RUBRIC_SUGGESTION_SYSTEM = `You design eval rubrics for AI agent harnesses. You will receive real GOOD exemplars (outputs users or graders rated up), real BAD evidence (grader rationales / user complaints), and the clustered failure themes. Write one llm_judge rubric criterion that separates the good from the bad.
|
|
534
|
+
|
|
535
|
+
Hard rules:
|
|
536
|
+
- Output exactly one JSON object: {"name": "...", "description": "...", "anchors": {"1": "...", "2": "...", "3": "...", "4": "...", "5": "..."}, "passing_score": 3}. No text outside the JSON.
|
|
537
|
+
- "name" is a short snake_case identifier.
|
|
538
|
+
- The five anchors must be concrete, observable behaviors grounded in the exemplars — 1 is the worst observed failure mode, 5 is the best observed behavior.
|
|
539
|
+
- Do not quote any exemplar verbatim for more than a short phrase.`;
|
|
540
|
+
const MAX_EXEMPLARS_IN_PROMPT = 4;
|
|
541
|
+
const MAX_EXEMPLAR_CHARS = 600;
|
|
542
|
+
const clipExemplar = (s) => s.length > MAX_EXEMPLAR_CHARS ? `${s.slice(0, MAX_EXEMPLAR_CHARS)}…` : s;
|
|
543
|
+
/** The user message for the rubric call: clipped good/bad exemplars + the
|
|
544
|
+
* deterministic theme labels. */
|
|
545
|
+
export function buildRubricSuggestionPrompt(goodOutputs, badEvidence, themes) {
|
|
546
|
+
const good = goodOutputs
|
|
547
|
+
.filter((o) => o.trim() !== "")
|
|
548
|
+
.slice(0, MAX_EXEMPLARS_IN_PROMPT)
|
|
549
|
+
.map((o, i) => `GOOD ${i + 1}:\n${clipExemplar(o)}`);
|
|
550
|
+
const bad = badEvidence
|
|
551
|
+
.slice(0, MAX_EXEMPLARS_IN_PROMPT)
|
|
552
|
+
.map((e, i) => `BAD ${i + 1} (${e.source}): ${clipExemplar(e.text)}`);
|
|
553
|
+
const themeLines = themes.map((t) => `- ${t.label} (${t.items.length} rationale(s))`);
|
|
554
|
+
return [
|
|
555
|
+
"FAILURE THEMES:",
|
|
556
|
+
...(themeLines.length > 0 ? themeLines : ["(none clustered)"]),
|
|
557
|
+
"",
|
|
558
|
+
...(good.length > 0 ? good : ["(no up-rated exemplars)"]),
|
|
559
|
+
"",
|
|
560
|
+
...(bad.length > 0 ? bad : ["(no failure evidence)"]),
|
|
561
|
+
"",
|
|
562
|
+
"Write the rubric JSON now.",
|
|
563
|
+
].join("\n");
|
|
564
|
+
}
|
|
565
|
+
const ANCHOR_KEYS = ["1", "2", "3", "4", "5"];
|
|
566
|
+
/**
|
|
567
|
+
* Tolerant parse of the rubric response into a complete single-criterion
|
|
568
|
+
* `llm_judge` GraderSpecObject (all five anchors required). Returns
|
|
569
|
+
* undefined on ANY shape failure — the caller keeps the deterministic
|
|
570
|
+
* suggestions and notes the skipped rubric.
|
|
571
|
+
*/
|
|
572
|
+
export function parseRubricSuggestion(raw, model) {
|
|
573
|
+
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
|
574
|
+
if (jsonMatch === null)
|
|
575
|
+
return undefined;
|
|
576
|
+
let parsed;
|
|
577
|
+
try {
|
|
578
|
+
parsed = JSON.parse(jsonMatch[0]);
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
return undefined;
|
|
582
|
+
}
|
|
583
|
+
const r = parsed;
|
|
584
|
+
if (typeof r.description !== "string" || r.description.trim() === "")
|
|
585
|
+
return undefined;
|
|
586
|
+
if (typeof r.anchors !== "object" || r.anchors === null)
|
|
587
|
+
return undefined;
|
|
588
|
+
const anchorsIn = r.anchors;
|
|
589
|
+
const anchors = {};
|
|
590
|
+
for (const k of ANCHOR_KEYS) {
|
|
591
|
+
const v = anchorsIn[k];
|
|
592
|
+
if (typeof v !== "string" || v.trim() === "")
|
|
593
|
+
return undefined;
|
|
594
|
+
anchors[k] = v;
|
|
595
|
+
}
|
|
596
|
+
const rawName = typeof r.name === "string" ? slugify(r.name) : "";
|
|
597
|
+
const name = rawName === "" ? "suggested_judge_rubric" : rawName;
|
|
598
|
+
const passing = typeof r.passing_score === "number" && r.passing_score >= 1 && r.passing_score <= 5
|
|
599
|
+
? r.passing_score
|
|
600
|
+
: 3;
|
|
601
|
+
return {
|
|
602
|
+
name,
|
|
603
|
+
type: "llm_judge",
|
|
604
|
+
rubric: {
|
|
605
|
+
criteria: [
|
|
606
|
+
{
|
|
607
|
+
name,
|
|
608
|
+
description: r.description,
|
|
609
|
+
anchors: anchors,
|
|
610
|
+
},
|
|
611
|
+
],
|
|
612
|
+
passing_score: passing,
|
|
613
|
+
},
|
|
614
|
+
...(model !== undefined ? { model } : {}),
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Render the review file: a hard-AND-aware header, then a valid graders.yaml
|
|
619
|
+
* (parseable by `parseGradersConfig`, so a reviewed grader can be copied —
|
|
620
|
+
* or the file trimmed to one grader and used directly), with each grader
|
|
621
|
+
* preceded by a `# evidence:` comment citing what it was drafted from.
|
|
622
|
+
*/
|
|
623
|
+
export function renderSuggestedGradersYaml(suggestions, opts) {
|
|
624
|
+
if (suggestions.length === 0) {
|
|
625
|
+
throw new GradersSuggestError("no graders to render — nothing was drafted");
|
|
626
|
+
}
|
|
627
|
+
const forSpec = opts.specName !== undefined ? ` for spec "${opts.specName}"` : "";
|
|
628
|
+
const feedbackNote = opts.feedbackCount > 0 ? ` + ${opts.feedbackCount} user feedback comment(s)` : "";
|
|
629
|
+
const judgeCount = suggestions.filter((s) => s.spec.type === "llm_judge").length;
|
|
630
|
+
const header = [
|
|
631
|
+
`# Drafted by \`crewhaus graders suggest\`${forSpec} from ${opts.failureCount} failure rationale(s)`,
|
|
632
|
+
`# across ${opts.runsSeen} eval run(s)${feedbackNote}. REVIEW FILE — never applied automatically.`,
|
|
633
|
+
"#",
|
|
634
|
+
"# GOTCHA — stacking graders hard-ANDs their scores: `crewhaus eval` combines every",
|
|
635
|
+
"# grader in a graders.yaml with eval-grader's `all(...)` (overall passes only when",
|
|
636
|
+
"# EVERY grader passes; overall score is the minimum). Do NOT adopt this whole file.",
|
|
637
|
+
`# Adopt ONE grader into eval/graders.yaml${judgeCount > 0 ? " — the llm_judge rubric is the broadest single choice" : ""};`,
|
|
638
|
+
"# only stack additional graders you genuinely want ALL to pass on every sample.",
|
|
639
|
+
];
|
|
640
|
+
if (opts.undraftedLabels.length > 0) {
|
|
641
|
+
header.push("#", `# ${opts.undraftedLabels.length} theme(s) had no deterministic signal in the up-rated outputs`, `# (${opts.undraftedLabels.join("; ")}) — consider an llm_judge rubric for those.`);
|
|
642
|
+
}
|
|
643
|
+
const yaml = gradersConfigToYaml({ graders: suggestions.map((s) => s.spec) }, header);
|
|
644
|
+
const lines = yaml.split("\n");
|
|
645
|
+
const out = [];
|
|
646
|
+
let graderIdx = 0;
|
|
647
|
+
for (const line of lines) {
|
|
648
|
+
if (line.startsWith(" - name:")) {
|
|
649
|
+
const suggestion = suggestions[graderIdx];
|
|
650
|
+
graderIdx += 1;
|
|
651
|
+
for (const evidence of suggestion?.evidence ?? []) {
|
|
652
|
+
out.push(` # ${evidence}`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
out.push(line);
|
|
656
|
+
}
|
|
657
|
+
return out.join("\n");
|
|
658
|
+
}
|