crewhaus 0.1.6 → 0.1.8
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/dist/feedback.d.ts +239 -0
- package/dist/feedback.js +602 -0
- package/dist/index.js +403 -26
- package/package.json +65 -64
package/dist/feedback.js
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response-feedback core — the pure, side-effect-free half of the
|
|
3
|
+
* `crewhaus rate` / `crewhaus feedback` / `crewhaus distill` subcommands.
|
|
4
|
+
*
|
|
5
|
+
* A `FeedbackRecord` is one human rating placed on one assistant turn (a
|
|
6
|
+
* thumbs up/down, a 1–5 star vote, an arbitrary scale vote, and/or a
|
|
7
|
+
* free-text comment or correction). The capture surfaces write it durably as
|
|
8
|
+
* a `user_feedback` event-log line (payload = FeedbackRecord); the web-UI host
|
|
9
|
+
* writes bare records to `.crewhaus/feedback/*.jsonl`. `distill` reads both,
|
|
10
|
+
* pairs each rating to a conversation turn, and emits the two artifacts the
|
|
11
|
+
* R-eval stack already consumes: a `Sample[]` dataset (`@crewhaus/eval-dataset`)
|
|
12
|
+
* and a `graders.yaml` (`@crewhaus/eval-grader`).
|
|
13
|
+
*
|
|
14
|
+
* Everything here is pure so it is unit-testable; all filesystem access lives
|
|
15
|
+
* in `apps/cli/src/index.ts` (mirrors `doctor-checks.ts` / `scope-audit.ts`).
|
|
16
|
+
*
|
|
17
|
+
* turnNumber convention: the 1-based ordinal of USER-TEXT turns in a session.
|
|
18
|
+
* A `user_message` whose content is a string (or an array containing a `text`
|
|
19
|
+
* block and no `tool_result` block) starts a new turn; a `user_message` that
|
|
20
|
+
* carries only `tool_result` blocks is a tool-result echo, and one marked
|
|
21
|
+
* `synthetic: true` (a runtime-injected loop/continue/tombstone nudge) is NOT a
|
|
22
|
+
* turn. This is the same count the CLI `--turn` flag, the web-UI (prompts
|
|
23
|
+
* sent), and the runtime's runContext.turnNumber all compute, so a rating keys
|
|
24
|
+
* back to the exact exchange it rated even after a recovery fires mid-session.
|
|
25
|
+
*/
|
|
26
|
+
/** The event-log EventKind under which a FeedbackRecord is persisted. */
|
|
27
|
+
export const FEEDBACK_EVENT_KIND = "user_feedback";
|
|
28
|
+
export const FEEDBACK_SCHEMA_VERSION = 1;
|
|
29
|
+
const SESSION_ID_REGEX = /^sess_[0-9a-f]{16}$/;
|
|
30
|
+
/** Max stored length for a free-text comment/correction. Untrusted input (a
|
|
31
|
+
* hand-edited or web-UI-supplied `.crewhaus/feedback` line) flows into
|
|
32
|
+
* expected_output/metadata and the optimizer meta-prompt, so it is bounded at
|
|
33
|
+
* ingestion and stripped of control chars (newline/tab kept). */
|
|
34
|
+
export const MAX_FEEDBACK_TEXT = 8192;
|
|
35
|
+
export function clipFeedbackText(s) {
|
|
36
|
+
// Drop C0/C1 control chars and DEL (keeping tab, newline, carriage return),
|
|
37
|
+
// then bound the length. Built by code point so the source carries no
|
|
38
|
+
// literal control characters.
|
|
39
|
+
let out = "";
|
|
40
|
+
for (const ch of s) {
|
|
41
|
+
const c = ch.codePointAt(0) ?? 0;
|
|
42
|
+
const printable = c === 9 || c === 10 || c === 13 || (c >= 0x20 && c !== 0x7f && !(c >= 0x80 && c <= 0x9f));
|
|
43
|
+
if (printable)
|
|
44
|
+
out += ch;
|
|
45
|
+
if (out.length >= MAX_FEEDBACK_TEXT)
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
return out.slice(0, MAX_FEEDBACK_TEXT);
|
|
49
|
+
}
|
|
50
|
+
function isValidRating(rating) {
|
|
51
|
+
const { thumbs, stars, scale } = rating;
|
|
52
|
+
if (thumbs !== undefined && thumbs !== "up" && thumbs !== "down")
|
|
53
|
+
return false;
|
|
54
|
+
if (stars !== undefined) {
|
|
55
|
+
if (typeof stars !== "number" || !Number.isInteger(stars) || stars < 1 || stars > 5)
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
if (scale !== undefined) {
|
|
59
|
+
if (typeof scale !== "object" || scale === null)
|
|
60
|
+
return false;
|
|
61
|
+
const s = scale;
|
|
62
|
+
if (!["value", "min", "max"].every((k) => typeof s[k] === "number" && Number.isFinite(s[k]))) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
/** Narrow an arbitrary parsed JSON value to a FeedbackRecord. Tolerant of the
|
|
69
|
+
* optional fields; rejects anything missing the required core OR carrying a
|
|
70
|
+
* malformed rating (so a corrupt/crafted line can't coerce a NaN/spurious
|
|
71
|
+
* score downstream). */
|
|
72
|
+
export function isFeedbackRecord(value) {
|
|
73
|
+
if (typeof value !== "object" || value === null)
|
|
74
|
+
return false;
|
|
75
|
+
const v = value;
|
|
76
|
+
if (v["schemaVersion"] !== 1)
|
|
77
|
+
return false;
|
|
78
|
+
if (typeof v["id"] !== "string")
|
|
79
|
+
return false;
|
|
80
|
+
if (typeof v["sessionId"] !== "string" || !SESSION_ID_REGEX.test(v["sessionId"])) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
if (typeof v["turnNumber"] !== "number" || !Number.isFinite(v["turnNumber"]))
|
|
84
|
+
return false;
|
|
85
|
+
const modality = v["modality"];
|
|
86
|
+
if (modality !== "binary" &&
|
|
87
|
+
modality !== "stars" &&
|
|
88
|
+
modality !== "scale" &&
|
|
89
|
+
modality !== "comment") {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
if (typeof v["rating"] !== "object" || v["rating"] === null)
|
|
93
|
+
return false;
|
|
94
|
+
if (!isValidRating(v["rating"]))
|
|
95
|
+
return false;
|
|
96
|
+
if (typeof v["source"] !== "string")
|
|
97
|
+
return false;
|
|
98
|
+
if (typeof v["ts"] !== "string")
|
|
99
|
+
return false;
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Assemble + validate a FeedbackRecord from capture-surface inputs. The
|
|
104
|
+
* modality is inferred from which rating fields are present (thumbs → binary,
|
|
105
|
+
* stars → stars, score → scale, else → comment). Throws a plain Error (the CLI
|
|
106
|
+
* routes it through `die`) when no rating signal at all is supplied.
|
|
107
|
+
*/
|
|
108
|
+
export function buildFeedbackRecord(input) {
|
|
109
|
+
const rating = {};
|
|
110
|
+
let modality;
|
|
111
|
+
if (input.thumbs !== undefined) {
|
|
112
|
+
rating.thumbs = input.thumbs;
|
|
113
|
+
modality = "binary";
|
|
114
|
+
}
|
|
115
|
+
if (input.stars !== undefined) {
|
|
116
|
+
if (!Number.isInteger(input.stars) || input.stars < 1 || input.stars > 5) {
|
|
117
|
+
throw new Error(`--stars must be an integer 1–5 (got ${input.stars})`);
|
|
118
|
+
}
|
|
119
|
+
rating.stars = input.stars;
|
|
120
|
+
modality = "stars";
|
|
121
|
+
}
|
|
122
|
+
if (input.score !== undefined) {
|
|
123
|
+
if (!Number.isFinite(input.score) || input.score < 0 || input.score > 1) {
|
|
124
|
+
throw new Error(`--score must be a number in [0,1] (got ${input.score})`);
|
|
125
|
+
}
|
|
126
|
+
rating.scale = { value: input.score, min: 0, max: 1 };
|
|
127
|
+
modality = "scale";
|
|
128
|
+
}
|
|
129
|
+
if (modality === undefined) {
|
|
130
|
+
if (input.comment === undefined && input.correction === undefined) {
|
|
131
|
+
throw new Error("no rating supplied — give --thumbs, --stars, --score, --text, or --correction");
|
|
132
|
+
}
|
|
133
|
+
modality = "comment";
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
schemaVersion: FEEDBACK_SCHEMA_VERSION,
|
|
137
|
+
id: input.id,
|
|
138
|
+
sessionId: input.sessionId,
|
|
139
|
+
...(input.runId !== undefined ? { runId: input.runId } : {}),
|
|
140
|
+
turnNumber: input.turnNumber,
|
|
141
|
+
...(input.targetSpanId !== undefined ? { targetSpanId: input.targetSpanId } : {}),
|
|
142
|
+
modality,
|
|
143
|
+
rating,
|
|
144
|
+
...(input.comment !== undefined ? { comment: clipFeedbackText(input.comment) } : {}),
|
|
145
|
+
...(input.correction !== undefined ? { correction: clipFeedbackText(input.correction) } : {}),
|
|
146
|
+
source: input.source,
|
|
147
|
+
...(input.rater !== undefined ? { rater: input.rater } : {}),
|
|
148
|
+
ts: input.ts,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Normalize a rating to [0,1] — the same convention `GradeResult.score` uses
|
|
153
|
+
* ((n-1)/4 for a 1–5 judge score). Returns `undefined` for a comment-only
|
|
154
|
+
* record (no numeric signal). Thumbs: up→1, down→0. Stars n→(n-1)/4. Scale
|
|
155
|
+
* value→(value-min)/(max-min), clamped.
|
|
156
|
+
*/
|
|
157
|
+
export function normalizeRating(r) {
|
|
158
|
+
const { thumbs, stars, scale } = r.rating;
|
|
159
|
+
if (thumbs !== undefined)
|
|
160
|
+
return thumbs === "up" ? 1 : 0;
|
|
161
|
+
if (stars !== undefined)
|
|
162
|
+
return clamp01((stars - 1) / 4);
|
|
163
|
+
if (scale !== undefined) {
|
|
164
|
+
const span = scale.max - scale.min;
|
|
165
|
+
if (span <= 0)
|
|
166
|
+
return undefined;
|
|
167
|
+
return clamp01((scale.value - scale.min) / span);
|
|
168
|
+
}
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
function clamp01(n) {
|
|
172
|
+
return n < 0 ? 0 : n > 1 ? 1 : n;
|
|
173
|
+
}
|
|
174
|
+
function turnKey(sessionId, turnNumber) {
|
|
175
|
+
return `${sessionId}#${turnNumber}`;
|
|
176
|
+
}
|
|
177
|
+
function contentBlocks(payload) {
|
|
178
|
+
const content = payload?.content;
|
|
179
|
+
if (typeof content === "string")
|
|
180
|
+
return { blocks: [], text: content };
|
|
181
|
+
if (Array.isArray(content))
|
|
182
|
+
return { blocks: content };
|
|
183
|
+
return { blocks: [] };
|
|
184
|
+
}
|
|
185
|
+
/** The text when a `user_message` is a real user-text turn (string content, or
|
|
186
|
+
* an array with ≥1 text block and no tool_result block); undefined otherwise.
|
|
187
|
+
* Runtime-injected nudges (loop warnings, "continue"/tombstone recovery) carry
|
|
188
|
+
* `synthetic: true` and are NOT turns — skipping them keeps deriveTurns's turn
|
|
189
|
+
* ordinal aligned with the runtime's runContext.turnNumber and the web UI. */
|
|
190
|
+
function userTurnText(payload) {
|
|
191
|
+
if (payload !== null &&
|
|
192
|
+
typeof payload === "object" &&
|
|
193
|
+
payload.synthetic === true) {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
const { blocks, text } = contentBlocks(payload);
|
|
197
|
+
if (text !== undefined)
|
|
198
|
+
return text;
|
|
199
|
+
const hasToolResult = blocks.some((b) => b.type === "tool_result");
|
|
200
|
+
if (hasToolResult)
|
|
201
|
+
return undefined;
|
|
202
|
+
const texts = blocks
|
|
203
|
+
.filter((b) => b.type === "text" && typeof b.text === "string")
|
|
204
|
+
.map((b) => b.text);
|
|
205
|
+
return texts.length > 0 ? texts.join("\n") : undefined;
|
|
206
|
+
}
|
|
207
|
+
function assistantText(payload) {
|
|
208
|
+
const { blocks, text } = contentBlocks(payload);
|
|
209
|
+
if (text !== undefined)
|
|
210
|
+
return text;
|
|
211
|
+
return blocks
|
|
212
|
+
.filter((b) => b.type === "text" && typeof b.text === "string")
|
|
213
|
+
.map((b) => b.text)
|
|
214
|
+
.join("\n");
|
|
215
|
+
}
|
|
216
|
+
function assistantToolNames(payload) {
|
|
217
|
+
const { blocks } = contentBlocks(payload);
|
|
218
|
+
return blocks
|
|
219
|
+
.filter((b) => b.type === "tool_use" && typeof b.name === "string")
|
|
220
|
+
.map((b) => b.name);
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Reconstruct conversation turns from a session transcript. Each user-text
|
|
224
|
+
* message opens a turn; the assistant's messages until the next user-text
|
|
225
|
+
* message form its response. `output` is the LAST assistant text (the answer);
|
|
226
|
+
* `toolNames` are the unique tool names the assistant called, in first-seen
|
|
227
|
+
* order.
|
|
228
|
+
*/
|
|
229
|
+
export function deriveTurns(events) {
|
|
230
|
+
const turns = [];
|
|
231
|
+
let current;
|
|
232
|
+
let counter = 0;
|
|
233
|
+
const finalize = () => {
|
|
234
|
+
if (!current)
|
|
235
|
+
return;
|
|
236
|
+
turns.push({
|
|
237
|
+
turnNumber: current.turnNumber,
|
|
238
|
+
input: current.input,
|
|
239
|
+
output: current.texts.length > 0 ? current.texts[current.texts.length - 1] : "",
|
|
240
|
+
toolNames: current.tools,
|
|
241
|
+
});
|
|
242
|
+
};
|
|
243
|
+
for (const ev of events) {
|
|
244
|
+
if (ev.kind === "user_message") {
|
|
245
|
+
const text = userTurnText(ev.payload);
|
|
246
|
+
if (text !== undefined) {
|
|
247
|
+
finalize();
|
|
248
|
+
counter += 1;
|
|
249
|
+
current = { turnNumber: counter, input: text, texts: [], tools: [], toolSeen: new Set() };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
else if (ev.kind === "assistant_message" && current) {
|
|
253
|
+
const t = assistantText(ev.payload);
|
|
254
|
+
if (t !== "")
|
|
255
|
+
current.texts.push(t);
|
|
256
|
+
for (const name of assistantToolNames(ev.payload)) {
|
|
257
|
+
if (!current.toolSeen.has(name)) {
|
|
258
|
+
current.toolSeen.add(name);
|
|
259
|
+
current.tools.push(name);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
finalize();
|
|
265
|
+
return turns;
|
|
266
|
+
}
|
|
267
|
+
/** Extract FeedbackRecords from parsed JSON objects. Accepts both event-log
|
|
268
|
+
* envelopes (`{ kind: "user_feedback", payload }`) and bare records (the
|
|
269
|
+
* web-UI host's `.crewhaus/feedback/*.jsonl`). */
|
|
270
|
+
export function extractFeedbackRecords(objects) {
|
|
271
|
+
const out = [];
|
|
272
|
+
for (const obj of objects) {
|
|
273
|
+
if (obj && typeof obj === "object" && obj.kind === FEEDBACK_EVENT_KIND) {
|
|
274
|
+
const payload = obj.payload;
|
|
275
|
+
if (isFeedbackRecord(payload))
|
|
276
|
+
out.push(payload);
|
|
277
|
+
}
|
|
278
|
+
else if (isFeedbackRecord(obj)) {
|
|
279
|
+
out.push(obj);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Collapse records to one per (sessionId, turnNumber). Rather than pure
|
|
286
|
+
* last-write-wins — which would let a later comment-only `feedback` erase an
|
|
287
|
+
* earlier `rate` on the same turn — fields are merged chronologically: the
|
|
288
|
+
* newest value of each field wins, but a later comment-only record keeps the
|
|
289
|
+
* earlier record's rating. So `rate --thumbs up` then `feedback --text "…"`
|
|
290
|
+
* yields one record carrying BOTH.
|
|
291
|
+
*/
|
|
292
|
+
export function mergeFeedback(records) {
|
|
293
|
+
const sorted = [...records].sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
|
|
294
|
+
const byKey = new Map();
|
|
295
|
+
for (const r of sorted) {
|
|
296
|
+
const key = turnKey(r.sessionId, r.turnNumber);
|
|
297
|
+
const existing = byKey.get(key);
|
|
298
|
+
byKey.set(key, existing === undefined ? r : foldRecords(existing, r));
|
|
299
|
+
}
|
|
300
|
+
return [...byKey.values()];
|
|
301
|
+
}
|
|
302
|
+
function hasRating(r) {
|
|
303
|
+
return (r.rating.thumbs !== undefined || r.rating.stars !== undefined || r.rating.scale !== undefined);
|
|
304
|
+
}
|
|
305
|
+
/** Merge an earlier + later record for the same turn (later ts wins per field;
|
|
306
|
+
* a comment-only later record does not erase the earlier rating). */
|
|
307
|
+
function foldRecords(earlier, later) {
|
|
308
|
+
const ratingRecord = hasRating(later) ? later : hasRating(earlier) ? earlier : later;
|
|
309
|
+
const comment = later.comment ?? earlier.comment;
|
|
310
|
+
const correction = later.correction ?? earlier.correction;
|
|
311
|
+
const rater = later.rater ?? earlier.rater;
|
|
312
|
+
const runId = later.runId ?? earlier.runId;
|
|
313
|
+
const targetSpanId = later.targetSpanId ?? earlier.targetSpanId;
|
|
314
|
+
return {
|
|
315
|
+
schemaVersion: FEEDBACK_SCHEMA_VERSION,
|
|
316
|
+
id: later.id,
|
|
317
|
+
sessionId: later.sessionId,
|
|
318
|
+
turnNumber: later.turnNumber,
|
|
319
|
+
modality: hasRating(ratingRecord) ? ratingRecord.modality : "comment",
|
|
320
|
+
rating: ratingRecord.rating,
|
|
321
|
+
source: later.source,
|
|
322
|
+
ts: later.ts,
|
|
323
|
+
...(runId !== undefined ? { runId } : {}),
|
|
324
|
+
...(targetSpanId !== undefined ? { targetSpanId } : {}),
|
|
325
|
+
...(comment !== undefined ? { comment } : {}),
|
|
326
|
+
...(correction !== undefined ? { correction } : {}),
|
|
327
|
+
...(rater !== undefined ? { rater } : {}),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Tag-all distillation. Every rated turn becomes a Sample; positively-rated
|
|
332
|
+
* turns (normalized ≥ minScore) — and any turn with a `correction` — become
|
|
333
|
+
* GOLD (expected_output set), the rest carry a low `metadata.user_rating` and
|
|
334
|
+
* no expected_output so they feed the optimizer's failure channel. A single
|
|
335
|
+
* deterministic grader is synthesized from the positive turns' behavior
|
|
336
|
+
* (exactly one, to avoid the hard-AND collapse in `all(...)`).
|
|
337
|
+
*/
|
|
338
|
+
export function distill(turns, feedback, opts) {
|
|
339
|
+
const turnByKey = new Map();
|
|
340
|
+
for (const t of turns)
|
|
341
|
+
turnByKey.set(turnKey(t.sessionId, t.turnNumber), t);
|
|
342
|
+
const merged = mergeFeedback(feedback);
|
|
343
|
+
const samples = [];
|
|
344
|
+
const positiveTurns = [];
|
|
345
|
+
const positiveComments = [];
|
|
346
|
+
const negativeComments = [];
|
|
347
|
+
const warnings = [];
|
|
348
|
+
let matched = 0;
|
|
349
|
+
let unmatched = 0;
|
|
350
|
+
let positives = 0;
|
|
351
|
+
let negatives = 0;
|
|
352
|
+
for (const fb of merged) {
|
|
353
|
+
const turn = turnByKey.get(turnKey(fb.sessionId, fb.turnNumber));
|
|
354
|
+
if (turn === undefined) {
|
|
355
|
+
unmatched += 1;
|
|
356
|
+
warnings.push(`rating for ${fb.sessionId} turn ${fb.turnNumber} has no matching turn in the transcript — skipped`);
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
matched += 1;
|
|
360
|
+
const score = normalizeRating(fb);
|
|
361
|
+
const isPositive = (score !== undefined && score >= opts.minScore) || fb.correction !== undefined;
|
|
362
|
+
// Gold = the user's correction, else the assistant's answer for a positive
|
|
363
|
+
// turn. Omit an empty answer (e.g. a turn that errored before replying) so
|
|
364
|
+
// we never emit a meaningless empty-string gold.
|
|
365
|
+
const goldCandidate = fb.correction ?? (isPositive ? turn.output : undefined);
|
|
366
|
+
const expectedOutput = goldCandidate !== undefined && goldCandidate.trim() !== "" ? goldCandidate : undefined;
|
|
367
|
+
const metadata = {
|
|
368
|
+
sessionId: fb.sessionId,
|
|
369
|
+
turnNumber: fb.turnNumber,
|
|
370
|
+
source: fb.source,
|
|
371
|
+
raw_rating: fb.rating,
|
|
372
|
+
};
|
|
373
|
+
if (score !== undefined)
|
|
374
|
+
metadata["user_rating"] = score;
|
|
375
|
+
if (fb.comment !== undefined)
|
|
376
|
+
metadata["comment"] = fb.comment;
|
|
377
|
+
if (fb.correction !== undefined)
|
|
378
|
+
metadata["correction"] = fb.correction;
|
|
379
|
+
if (fb.rater !== undefined)
|
|
380
|
+
metadata["rater"] = fb.rater;
|
|
381
|
+
const sample = { id: `${fb.sessionId}_t${fb.turnNumber}`, input: turn.input, metadata };
|
|
382
|
+
if (expectedOutput !== undefined)
|
|
383
|
+
sample.expected_output = expectedOutput;
|
|
384
|
+
if (isPositive && turn.toolNames.length > 0)
|
|
385
|
+
sample.expected_tools = turn.toolNames;
|
|
386
|
+
samples.push(sample);
|
|
387
|
+
if (isPositive) {
|
|
388
|
+
positives += 1;
|
|
389
|
+
positiveTurns.push(turn);
|
|
390
|
+
if (fb.comment !== undefined)
|
|
391
|
+
positiveComments.push(fb.comment);
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
negatives += 1;
|
|
395
|
+
if (fb.comment !== undefined)
|
|
396
|
+
negativeComments.push(fb.comment);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
samples,
|
|
401
|
+
graders: opts.judge === true
|
|
402
|
+
? { graders: [buildJudgeRubricGrader(positiveComments, negativeComments, opts.judgeModel)] }
|
|
403
|
+
: synthesizeGraders(positiveTurns, warnings),
|
|
404
|
+
stats: {
|
|
405
|
+
totalFeedback: merged.length,
|
|
406
|
+
matchedTurns: matched,
|
|
407
|
+
unmatchedFeedback: unmatched,
|
|
408
|
+
positives,
|
|
409
|
+
negatives,
|
|
410
|
+
},
|
|
411
|
+
warnings,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
const STOPWORDS = new Set([
|
|
415
|
+
"the",
|
|
416
|
+
"and",
|
|
417
|
+
"that",
|
|
418
|
+
"this",
|
|
419
|
+
"with",
|
|
420
|
+
"from",
|
|
421
|
+
"your",
|
|
422
|
+
"have",
|
|
423
|
+
"will",
|
|
424
|
+
"here",
|
|
425
|
+
"there",
|
|
426
|
+
"what",
|
|
427
|
+
"when",
|
|
428
|
+
"which",
|
|
429
|
+
"would",
|
|
430
|
+
"could",
|
|
431
|
+
"should",
|
|
432
|
+
"about",
|
|
433
|
+
"into",
|
|
434
|
+
"then",
|
|
435
|
+
"than",
|
|
436
|
+
"them",
|
|
437
|
+
"they",
|
|
438
|
+
"you",
|
|
439
|
+
"for",
|
|
440
|
+
"are",
|
|
441
|
+
"was",
|
|
442
|
+
"were",
|
|
443
|
+
"its",
|
|
444
|
+
]);
|
|
445
|
+
/**
|
|
446
|
+
* Pick the single deterministic grader that best captures the up-rated
|
|
447
|
+
* behavior. Prefers a `tool_call_sequence` over the tools every tool-using
|
|
448
|
+
* positive turn shared (falling back to the most common tool); if the positive
|
|
449
|
+
* turns used no tools, a `contains` over a distinctive token common to their
|
|
450
|
+
* answers; failing that, a non-empty-answer floor (with a warning).
|
|
451
|
+
*/
|
|
452
|
+
export function synthesizeGraders(positiveTurns, warnings = []) {
|
|
453
|
+
const toolCounts = new Map();
|
|
454
|
+
let turnsWithTools = 0;
|
|
455
|
+
for (const t of positiveTurns) {
|
|
456
|
+
const uniq = [...new Set(t.toolNames)];
|
|
457
|
+
if (uniq.length > 0)
|
|
458
|
+
turnsWithTools += 1;
|
|
459
|
+
for (const name of uniq)
|
|
460
|
+
toolCounts.set(name, (toolCounts.get(name) ?? 0) + 1);
|
|
461
|
+
}
|
|
462
|
+
if (turnsWithTools > 0 && toolCounts.size > 0) {
|
|
463
|
+
const inEvery = [...toolCounts.entries()]
|
|
464
|
+
.filter(([, c]) => c === turnsWithTools)
|
|
465
|
+
.map(([n]) => n);
|
|
466
|
+
let expected = inEvery;
|
|
467
|
+
if (expected.length === 0) {
|
|
468
|
+
const top = [...toolCounts.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
469
|
+
expected = top ? [top[0]] : [];
|
|
470
|
+
}
|
|
471
|
+
if (expected.length > 0) {
|
|
472
|
+
return {
|
|
473
|
+
graders: [{ name: "preferred_tools", type: "tool_call_sequence", expected, mode: "set" }],
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
const token = commonToken(positiveTurns.map((t) => t.output));
|
|
478
|
+
if (token !== undefined) {
|
|
479
|
+
return {
|
|
480
|
+
graders: [
|
|
481
|
+
{ name: "preferred_phrase", type: "contains", substring: token, case_insensitive: true },
|
|
482
|
+
],
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
warnings.push("no consistent tool or phrase signal in the up-rated turns — emitted a non-empty-answer floor grader; add ratings or edit graders.yaml for a sharper eval");
|
|
486
|
+
return { graders: [{ name: "non_empty_answer", type: "regex", pattern: "\\S" }] };
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Build a single `llm_judge` grader seeded from the aggregated comment themes.
|
|
490
|
+
* One criterion ("user_preference") with fixed 1–5 anchors and a description
|
|
491
|
+
* folding a short, quoted, clipped summary of what users praised vs criticized.
|
|
492
|
+
* Emitted as the SOLE grader so it is never hard-ANDed with a deterministic one
|
|
493
|
+
* (the `all(...)` min-collapse). The untrusted comment text is bounded and
|
|
494
|
+
* quoted-as-data; the rubric is trusted context sent verbatim to the judge.
|
|
495
|
+
*/
|
|
496
|
+
export function buildJudgeRubricGrader(positiveComments, negativeComments, model) {
|
|
497
|
+
const praised = summarizeComments(positiveComments);
|
|
498
|
+
const criticized = summarizeComments(negativeComments);
|
|
499
|
+
const praisedNote = praised !== undefined ? ` Users praised responses like: '${praised}'.` : "";
|
|
500
|
+
const criticizedNote = criticized !== undefined ? ` Users criticized responses like: '${criticized}'.` : "";
|
|
501
|
+
const description = `Judge how well the response matches the qualities real users prefer, learned from their ratings.${praisedNote}${criticizedNote}`;
|
|
502
|
+
return {
|
|
503
|
+
name: "user_preference",
|
|
504
|
+
type: "llm_judge",
|
|
505
|
+
rubric: {
|
|
506
|
+
criteria: [
|
|
507
|
+
{
|
|
508
|
+
name: "user_preference",
|
|
509
|
+
description,
|
|
510
|
+
anchors: {
|
|
511
|
+
"1": "Strongly conflicts with what users liked; clearly exhibits the criticized traits.",
|
|
512
|
+
"2": "Mostly misses the qualities users preferred.",
|
|
513
|
+
"3": "Partially matches user preferences; a mixed response.",
|
|
514
|
+
"4": "Largely matches the qualities users praised.",
|
|
515
|
+
"5": "Strongly matches the user-preferred style and avoids the criticized traits.",
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
],
|
|
519
|
+
passing_score: 3,
|
|
520
|
+
},
|
|
521
|
+
...(model !== undefined ? { model } : {}),
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
/** Dedupe, join, and clip a batch of comments into one short quoted summary for
|
|
525
|
+
* a rubric description (~400 chars). Undefined when there are no comments. */
|
|
526
|
+
function summarizeComments(comments) {
|
|
527
|
+
const cleaned = [...new Set(comments.map((c) => c.trim()).filter((c) => c !== ""))];
|
|
528
|
+
if (cleaned.length === 0)
|
|
529
|
+
return undefined;
|
|
530
|
+
const joined = clipFeedbackText(cleaned.slice(0, 8).join("; ")).replace(/'/g, "");
|
|
531
|
+
return joined.length > 400 ? `${joined.slice(0, 400)}…` : joined;
|
|
532
|
+
}
|
|
533
|
+
/** The most frequent distinctive token (alnum, length ≥ 4, non-stopword)
|
|
534
|
+
* appearing in at least half of the answers. Undefined when none qualifies. */
|
|
535
|
+
function commonToken(answers) {
|
|
536
|
+
const nonEmpty = answers.filter((a) => a.trim() !== "");
|
|
537
|
+
if (nonEmpty.length === 0)
|
|
538
|
+
return undefined;
|
|
539
|
+
const docFreq = new Map();
|
|
540
|
+
for (const answer of nonEmpty) {
|
|
541
|
+
const seen = new Set();
|
|
542
|
+
for (const raw of answer.toLowerCase().split(/[^a-z0-9]+/)) {
|
|
543
|
+
if (raw.length < 4 || STOPWORDS.has(raw) || seen.has(raw))
|
|
544
|
+
continue;
|
|
545
|
+
seen.add(raw);
|
|
546
|
+
docFreq.set(raw, (docFreq.get(raw) ?? 0) + 1);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const threshold = Math.ceil(nonEmpty.length / 2);
|
|
550
|
+
const ranked = [...docFreq.entries()]
|
|
551
|
+
.filter(([, c]) => c >= threshold)
|
|
552
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
553
|
+
return ranked.length > 0 ? ranked[0]?.[0] : undefined;
|
|
554
|
+
}
|
|
555
|
+
// -------- serialization --------
|
|
556
|
+
/** One SampleSchema-valid JSON object per line (round-trips through loadJsonl). */
|
|
557
|
+
export function samplesToJsonl(samples) {
|
|
558
|
+
return `${samples.map((s) => JSON.stringify(s)).join("\n")}\n`;
|
|
559
|
+
}
|
|
560
|
+
/** Emit a GradersConfigSchema-valid graders.yaml. Scalars are JSON-encoded so
|
|
561
|
+
* arbitrary substrings/tool names stay YAML-safe (JSON is a YAML subset). */
|
|
562
|
+
export function gradersConfigToYaml(config) {
|
|
563
|
+
const lines = [
|
|
564
|
+
"# Synthesized by `crewhaus distill` from user ratings.",
|
|
565
|
+
"# Exactly one grader — stacking graders hard-ANDs their scores (see eval-grader `all`).",
|
|
566
|
+
"graders:",
|
|
567
|
+
];
|
|
568
|
+
for (const g of config.graders) {
|
|
569
|
+
lines.push(` - name: ${JSON.stringify(g.name)}`);
|
|
570
|
+
lines.push(` type: ${g.type}`);
|
|
571
|
+
if (g.type === "tool_call_sequence") {
|
|
572
|
+
lines.push(` expected: ${JSON.stringify(g.expected)}`);
|
|
573
|
+
lines.push(` mode: ${g.mode}`);
|
|
574
|
+
}
|
|
575
|
+
else if (g.type === "contains") {
|
|
576
|
+
lines.push(` substring: ${JSON.stringify(g.substring)}`);
|
|
577
|
+
if (g.case_insensitive !== undefined)
|
|
578
|
+
lines.push(` case_insensitive: ${g.case_insensitive}`);
|
|
579
|
+
}
|
|
580
|
+
else if (g.type === "llm_judge") {
|
|
581
|
+
lines.push(" rubric:");
|
|
582
|
+
lines.push(" criteria:");
|
|
583
|
+
for (const c of g.rubric.criteria) {
|
|
584
|
+
lines.push(` - name: ${JSON.stringify(c.name)}`);
|
|
585
|
+
lines.push(` description: ${JSON.stringify(c.description)}`);
|
|
586
|
+
lines.push(" anchors:");
|
|
587
|
+
for (const k of ["1", "2", "3", "4", "5"]) {
|
|
588
|
+
lines.push(` ${JSON.stringify(k)}: ${JSON.stringify(c.anchors[k])}`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
if (g.rubric.passing_score !== undefined) {
|
|
592
|
+
lines.push(` passing_score: ${g.rubric.passing_score}`);
|
|
593
|
+
}
|
|
594
|
+
if (g.model !== undefined)
|
|
595
|
+
lines.push(` model: ${JSON.stringify(g.model)}`);
|
|
596
|
+
}
|
|
597
|
+
else {
|
|
598
|
+
lines.push(` pattern: ${JSON.stringify(g.pattern)}`);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return `${lines.join("\n")}\n`;
|
|
602
|
+
}
|