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/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import {
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
4
5
|
import { SpecParseError, compile, lower } from "@crewhaus/compiler";
|
|
5
6
|
import { buildContextBundle, discoverRoots } from "@crewhaus/context-bundle";
|
|
@@ -9,6 +10,7 @@ import { parseGradersConfig } from "@crewhaus/eval-grader";
|
|
|
9
10
|
import { optimizeSpec } from "@crewhaus/eval-optimizer-orchestrator";
|
|
10
11
|
import { diffReports, loadRun, renderReport } from "@crewhaus/eval-report";
|
|
11
12
|
import { runEval as runEvalLib } from "@crewhaus/eval-runner";
|
|
13
|
+
import { openEventLog } from "@crewhaus/event-log";
|
|
12
14
|
import { loadHooks } from "@crewhaus/hooks-engine";
|
|
13
15
|
import { ArgParseError, parseArgs, } from "@crewhaus/infra-utils";
|
|
14
16
|
import { createLogger } from "@crewhaus/logging";
|
|
@@ -33,6 +35,10 @@ import { buildCredentialChecks, extractSpecModel } from "./doctor-checks";
|
|
|
33
35
|
// imports the optional semantic package only when "semantic" is selected, so
|
|
34
36
|
// the default path pulls in no embedding dependency.
|
|
35
37
|
import { DEFAULT_EGRESS_EMBEDDER_MODEL, InvalidEgressMatcherChoiceError, createEgressMatcher, resolveEgressMatcherChoice, } from "./egress-matcher";
|
|
38
|
+
// Response-feedback core — pure, side-effect-free so it is unit-testable
|
|
39
|
+
// (this entry file runs an argv switch on import). Powers `rate`/`feedback`
|
|
40
|
+
// (capture) and `distill` (ratings → eval dataset + graders).
|
|
41
|
+
import { FEEDBACK_EVENT_KIND, buildFeedbackRecord, deriveTurns, distill as distillFeedback, extractFeedbackRecords, gradersConfigToYaml, samplesToJsonl, } from "./feedback";
|
|
36
42
|
// FR-004 — Pillar 3 intent-gate judge + durable audit-sink resolution, in a
|
|
37
43
|
// side-effect-free module so it is unit-testable (this entry file runs an
|
|
38
44
|
// argv switch on import).
|
|
@@ -134,9 +140,15 @@ const OPTIMIZE_SCHEMA = {
|
|
|
134
140
|
flags: [
|
|
135
141
|
{ name: "dataset", takesValue: true },
|
|
136
142
|
{ name: "graders", takesValue: true },
|
|
143
|
+
// Inline-distill user ratings into the training set (Pillar 2 — close the
|
|
144
|
+
// loop from real feedback). Value: a session id (sess_<16 hex>) or "all".
|
|
145
|
+
// Synthesizes the dataset (and, when --graders is omitted, the graders too).
|
|
146
|
+
{ name: "ratings", takesValue: true },
|
|
147
|
+
{ name: "min-score", takesValue: true },
|
|
137
148
|
{ name: "mutator", takesValue: true },
|
|
138
149
|
{ name: "iterations", takesValue: true },
|
|
139
150
|
{ name: "seed", takesValue: true },
|
|
151
|
+
{ name: "concurrency", takesValue: true },
|
|
140
152
|
{ name: "improvement-threshold", takesValue: true },
|
|
141
153
|
// FR-003 — dollar ceiling for model-driven runs (composes with --iterations).
|
|
142
154
|
{ name: "budget-usd", takesValue: true },
|
|
@@ -170,6 +182,40 @@ const COST_SUMMARY_SCHEMA = {
|
|
|
170
182
|
{ name: "help", short: "h" },
|
|
171
183
|
],
|
|
172
184
|
};
|
|
185
|
+
const RATE_SCHEMA = {
|
|
186
|
+
flags: [
|
|
187
|
+
{ name: "session", takesValue: true },
|
|
188
|
+
{ name: "turn", takesValue: true },
|
|
189
|
+
{ name: "thumbs", takesValue: true },
|
|
190
|
+
{ name: "stars", takesValue: true },
|
|
191
|
+
{ name: "score", takesValue: true },
|
|
192
|
+
{ name: "comment", takesValue: true },
|
|
193
|
+
{ name: "rater", takesValue: true },
|
|
194
|
+
{ name: "help", short: "h" },
|
|
195
|
+
],
|
|
196
|
+
};
|
|
197
|
+
const FEEDBACK_SCHEMA = {
|
|
198
|
+
flags: [
|
|
199
|
+
{ name: "session", takesValue: true },
|
|
200
|
+
{ name: "turn", takesValue: true },
|
|
201
|
+
{ name: "text", takesValue: true },
|
|
202
|
+
{ name: "correction", takesValue: true },
|
|
203
|
+
{ name: "rater", takesValue: true },
|
|
204
|
+
{ name: "help", short: "h" },
|
|
205
|
+
],
|
|
206
|
+
};
|
|
207
|
+
const DISTILL_SCHEMA = {
|
|
208
|
+
flags: [
|
|
209
|
+
{ name: "session", takesValue: true },
|
|
210
|
+
{ name: "all-sessions", takesValue: false },
|
|
211
|
+
{ name: "out", short: "o", takesValue: true },
|
|
212
|
+
{ name: "graders-out", takesValue: true },
|
|
213
|
+
{ name: "min-score", takesValue: true },
|
|
214
|
+
{ name: "judge", takesValue: false },
|
|
215
|
+
{ name: "judge-model", takesValue: true },
|
|
216
|
+
{ name: "help", short: "h" },
|
|
217
|
+
],
|
|
218
|
+
};
|
|
173
219
|
const SANDBOX_SCHEMA = {
|
|
174
220
|
flags: [
|
|
175
221
|
{ name: "probe", takesValue: false },
|
|
@@ -268,6 +314,7 @@ function usageText() {
|
|
|
268
314
|
" [-o <out-dir>]",
|
|
269
315
|
" optimize <spec.yaml> --dataset <data> --graders <graders.yaml>",
|
|
270
316
|
" [--mutator rule-based|claude] [--iterations N] [--seed N]",
|
|
317
|
+
" [--ratings <session>|all] distill user ratings into the training set (Pillar 2)",
|
|
271
318
|
" [--budget-usd N] stop a model-driven run before it exceeds $N (FR-003)",
|
|
272
319
|
" [--write-back] [-o <out-dir>] active eval-driven optimization (Pillar 2)",
|
|
273
320
|
" init [name] scaffold a new crewhaus.yaml",
|
|
@@ -275,6 +322,12 @@ function usageText() {
|
|
|
275
322
|
" context --bundle [-o <file>] emit a single-markdown orientation manifest",
|
|
276
323
|
" [--factory-root <p>] [--docs-root <p>] [--demos-root <p>]",
|
|
277
324
|
" cost-summary --session <id> summarize cost_accrual events for a session",
|
|
325
|
+
" rate --session <id> [--turn N] rate an assistant turn 👍/👎, ⭐, or 0–1",
|
|
326
|
+
" (--thumbs up|down | --stars 1-5 | --score 0-1) [--comment <t>]",
|
|
327
|
+
" feedback --session <id> --text <msg> attach a comment/correction to a turn",
|
|
328
|
+
" [--turn N] [--correction <better answer>]",
|
|
329
|
+
" distill --session <id> -o <ds.jsonl> turn ratings into an eval dataset + graders",
|
|
330
|
+
" [--all-sessions] [--graders-out <g.yaml>] [--min-score F]",
|
|
278
331
|
" secrets doctor list known secrets via the configured backend",
|
|
279
332
|
" secrets rotate <name> [--value V] rotate a named secret (file backend)",
|
|
280
333
|
" spec put|list|get|pin|alias ... versioned spec storage (Section 28 spec-registry)",
|
|
@@ -1331,8 +1384,8 @@ async function runDoctorPhilosophyAlignment() {
|
|
|
1331
1384
|
*/
|
|
1332
1385
|
async function runOptimize(args) {
|
|
1333
1386
|
if (args.flags["help"]) {
|
|
1334
|
-
process.stdout.write("usage: crewhaus optimize <spec.yaml> --dataset <data> --graders <graders.yaml> " +
|
|
1335
|
-
"[--mutator rule-based|claude] [--iterations N] [--seed N] " +
|
|
1387
|
+
process.stdout.write("usage: crewhaus optimize <spec.yaml> (--dataset <data> --graders <graders.yaml> | --ratings <session>) " +
|
|
1388
|
+
"[--min-score F] [--mutator rule-based|claude] [--iterations N] [--seed N] [--concurrency N] " +
|
|
1336
1389
|
"[--improvement-threshold F] [--budget-usd N] [--write-back] [-o <out-dir>]\n");
|
|
1337
1390
|
return;
|
|
1338
1391
|
}
|
|
@@ -1341,10 +1394,18 @@ async function runOptimize(args) {
|
|
|
1341
1394
|
die("missing <spec.yaml>");
|
|
1342
1395
|
const datasetPath = args.flags["dataset"];
|
|
1343
1396
|
const gradersPath = args.flags["graders"];
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1397
|
+
const ratingsArg = args.flags["ratings"];
|
|
1398
|
+
if (typeof datasetPath !== "string" && typeof ratingsArg !== "string") {
|
|
1399
|
+
die("missing --dataset <data> (or --ratings <session> to distill one from feedback)");
|
|
1400
|
+
}
|
|
1401
|
+
if (typeof gradersPath !== "string" && typeof ratingsArg !== "string") {
|
|
1402
|
+
die("missing --graders <graders.yaml> (or --ratings to synthesize one from feedback)");
|
|
1403
|
+
}
|
|
1404
|
+
const ratingsMinScore = floatFlag(args, "min-score") ?? 0.7;
|
|
1405
|
+
if (ratingsMinScore < 0 || ratingsMinScore > 1) {
|
|
1406
|
+
die(`invalid --min-score "${ratingsMinScore}" — must be in [0,1]`);
|
|
1407
|
+
}
|
|
1408
|
+
const ratingsDistill = typeof ratingsArg === "string" ? distillRatings(ratingsArg, ratingsMinScore) : undefined;
|
|
1348
1409
|
const iterationsFlag = args.flags["iterations"];
|
|
1349
1410
|
const seedFlag = args.flags["seed"];
|
|
1350
1411
|
const thresholdFlag = args.flags["improvement-threshold"];
|
|
@@ -1354,6 +1415,15 @@ async function runOptimize(args) {
|
|
|
1354
1415
|
if (Number.isNaN(iterations) || iterations < 1) {
|
|
1355
1416
|
die(`invalid --iterations "${iterationsFlag}" — must be positive integer`);
|
|
1356
1417
|
}
|
|
1418
|
+
// Per-candidate eval concurrency. Each iteration runs a full eval pass on
|
|
1419
|
+
// the dev set; on a low provider rate-limit tier a high fan-out trips 429s,
|
|
1420
|
+
// so this is exposed (mirroring `crewhaus eval --concurrency`) and defaults
|
|
1421
|
+
// to 4. The nightly flywheel sets `--concurrency 1` on constrained tiers.
|
|
1422
|
+
const concurrencyFlag = args.flags["concurrency"];
|
|
1423
|
+
const concurrency = typeof concurrencyFlag === "string" ? Number.parseInt(concurrencyFlag, 10) : 4;
|
|
1424
|
+
if (Number.isNaN(concurrency) || concurrency < 1) {
|
|
1425
|
+
die(`invalid --concurrency "${concurrencyFlag}" — must be a positive integer`);
|
|
1426
|
+
}
|
|
1357
1427
|
// FR-003 — optional dollar budget for model-driven runs. Omit → today's
|
|
1358
1428
|
// behaviour (iterations cap only). On a rule-based run the gate is inert
|
|
1359
1429
|
// (no model calls → $0), so passing it is harmless.
|
|
@@ -1373,25 +1443,40 @@ async function runOptimize(args) {
|
|
|
1373
1443
|
? resolve(outDirArg)
|
|
1374
1444
|
: resolve(join(".crewhaus", "optimize", runId));
|
|
1375
1445
|
let gradersYaml;
|
|
1376
|
-
|
|
1377
|
-
|
|
1446
|
+
if (typeof gradersPath === "string") {
|
|
1447
|
+
try {
|
|
1448
|
+
gradersYaml = readFileSync(resolve(gradersPath), "utf-8");
|
|
1449
|
+
}
|
|
1450
|
+
catch (err) {
|
|
1451
|
+
die(`could not read ${gradersPath}: ${err.message}`);
|
|
1452
|
+
}
|
|
1378
1453
|
}
|
|
1379
|
-
|
|
1380
|
-
|
|
1454
|
+
else {
|
|
1455
|
+
// No --graders: use the grader synthesized from the ratings (guaranteed
|
|
1456
|
+
// present here because the missing-flags gate required --ratings).
|
|
1457
|
+
gradersYaml = ratingsDistill.gradersYaml;
|
|
1381
1458
|
}
|
|
1382
1459
|
const { compiled } = parseGradersConfig(gradersYaml);
|
|
1383
|
-
|
|
1384
|
-
//
|
|
1460
|
+
// Materialize the training set once — we'll re-iterate per fitness call. It
|
|
1461
|
+
// is the union of the file dataset (if any) and the distilled ratings (if
|
|
1462
|
+
// any); at least one is present per the missing-flags gate above.
|
|
1385
1463
|
const samples = [];
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1464
|
+
let datasetName = "ratings";
|
|
1465
|
+
if (typeof datasetPath === "string") {
|
|
1466
|
+
const dataset = await loadDataset(resolve(datasetPath));
|
|
1467
|
+
datasetName = dataset.name;
|
|
1468
|
+
for await (const s of dataset.samples) {
|
|
1469
|
+
samples.push({
|
|
1470
|
+
id: s.id,
|
|
1471
|
+
input: s.input,
|
|
1472
|
+
...(s.expected_output !== undefined ? { expected_output: s.expected_output } : {}),
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1392
1475
|
}
|
|
1476
|
+
if (ratingsDistill !== undefined)
|
|
1477
|
+
samples.push(...ratingsDistill.samples);
|
|
1393
1478
|
if (samples.length === 0)
|
|
1394
|
-
die(`dataset "${
|
|
1479
|
+
die(`dataset "${datasetName}" yielded zero samples`);
|
|
1395
1480
|
// Train/dev split: 70/30 deterministic split by sample id ordering.
|
|
1396
1481
|
const splitIdx = Math.max(1, Math.floor(samples.length * 0.7));
|
|
1397
1482
|
const trainSet = samples.slice(0, splitIdx);
|
|
@@ -1399,8 +1484,16 @@ async function runOptimize(args) {
|
|
|
1399
1484
|
if (devSet.length === 0) {
|
|
1400
1485
|
die(`dataset has ${samples.length} samples — need at least 2 (70/30 split needs a dev split)`);
|
|
1401
1486
|
}
|
|
1487
|
+
// Index the dev set by id so the fitness fn can join each graded
|
|
1488
|
+
// sample-result back to the input + reference it was scored against.
|
|
1489
|
+
const devById = new Map(devSet.map((s) => [s.id, s]));
|
|
1402
1490
|
// Fitness fn: patch the spec with the candidate prompt, lower to IR,
|
|
1403
|
-
// run eval-runner, return
|
|
1491
|
+
// run eval-runner, and return the pass-rate PLUS per-sample grades. The
|
|
1492
|
+
// aggregate `passRate` still drives the search (unchanged scoring); the
|
|
1493
|
+
// grades are additive — they carry each sample's overall score and the
|
|
1494
|
+
// grader's rationale to the mutator (via OptimizerState.bestGrades) so
|
|
1495
|
+
// a model-driven rewrite can target the samples the prompt actually
|
|
1496
|
+
// fails and the reason it fails them. Each call is one full eval pass.
|
|
1404
1497
|
const fitness = async (prompt) => {
|
|
1405
1498
|
const yamlText = readFileSync(absSpec, "utf-8");
|
|
1406
1499
|
// Re-parse to capture spec.target without depending on the
|
|
@@ -1422,7 +1515,7 @@ async function runOptimize(args) {
|
|
|
1422
1515
|
catch (err) {
|
|
1423
1516
|
if (err instanceof SpecParseError) {
|
|
1424
1517
|
process.stderr.write("[optimize] candidate compiled invalid spec, skipping\n");
|
|
1425
|
-
return 0;
|
|
1518
|
+
return { score: 0 };
|
|
1426
1519
|
}
|
|
1427
1520
|
throw err;
|
|
1428
1521
|
}
|
|
@@ -1431,15 +1524,24 @@ async function runOptimize(args) {
|
|
|
1431
1524
|
}
|
|
1432
1525
|
const summary = await runEvalLib({
|
|
1433
1526
|
ir,
|
|
1434
|
-
dataset: { name:
|
|
1527
|
+
dataset: { name: datasetName, samples: makeAsyncIterable(devSet) },
|
|
1435
1528
|
compiledGraders: compiled,
|
|
1436
1529
|
opts: {
|
|
1437
1530
|
outDir: join(outDir, "evals", `${prompt.length}_${ir.agent.instructions.length}`),
|
|
1438
|
-
concurrency
|
|
1531
|
+
concurrency,
|
|
1439
1532
|
seed,
|
|
1440
1533
|
},
|
|
1441
1534
|
});
|
|
1442
|
-
|
|
1535
|
+
const grades = summary.samples.map((r) => {
|
|
1536
|
+
const dev = devById.get(r.sampleId);
|
|
1537
|
+
return {
|
|
1538
|
+
input: dev?.input ?? r.sampleId,
|
|
1539
|
+
score: r.grades.overall.score,
|
|
1540
|
+
...(dev?.expected_output !== undefined ? { expected: dev.expected_output } : {}),
|
|
1541
|
+
rationale: r.grades.overall.rationale,
|
|
1542
|
+
};
|
|
1543
|
+
});
|
|
1544
|
+
return { score: summary.aggregates.passRate, grades };
|
|
1443
1545
|
};
|
|
1444
1546
|
const mutator = args.flags["mutator"];
|
|
1445
1547
|
let mutatorImpl;
|
|
@@ -1462,7 +1564,7 @@ async function runOptimize(args) {
|
|
|
1462
1564
|
else if (mutator !== undefined && mutator !== "rule-based") {
|
|
1463
1565
|
die(`unknown --mutator "${mutator}" — supported: rule-based, claude`);
|
|
1464
1566
|
}
|
|
1465
|
-
process.stdout.write(`[optimize] runId=${runId} spec=${specPath} dataset=${
|
|
1567
|
+
process.stdout.write(`[optimize] runId=${runId} spec=${specPath} dataset=${datasetName} ` +
|
|
1466
1568
|
`(${trainSet.length} train / ${devSet.length} dev) iterations=${iterations} ` +
|
|
1467
1569
|
`mutator=${mutator ?? "rule-based"}\n`);
|
|
1468
1570
|
// FR-003 — thread a real trace bus into the optimize run so the per-call
|
|
@@ -1709,6 +1811,272 @@ async function runCostSummary(args) {
|
|
|
1709
1811
|
}
|
|
1710
1812
|
}
|
|
1711
1813
|
}
|
|
1814
|
+
// -------- response feedback: rate / feedback / distill --------
|
|
1815
|
+
const SESSIONS_SUBDIR = join(".crewhaus", "sessions");
|
|
1816
|
+
const FEEDBACK_SUBDIR = join(".crewhaus", "feedback");
|
|
1817
|
+
function sessionJsonlPath(session) {
|
|
1818
|
+
return join(process.cwd(), SESSIONS_SUBDIR, `${session}.jsonl`);
|
|
1819
|
+
}
|
|
1820
|
+
/** Parse a JSONL blob into objects, skipping blank/malformed lines. */
|
|
1821
|
+
function parseJsonlObjects(text) {
|
|
1822
|
+
const out = [];
|
|
1823
|
+
for (const line of text.split("\n")) {
|
|
1824
|
+
if (line.trim() === "")
|
|
1825
|
+
continue;
|
|
1826
|
+
try {
|
|
1827
|
+
out.push(JSON.parse(line));
|
|
1828
|
+
}
|
|
1829
|
+
catch {
|
|
1830
|
+
// A single malformed line must not abort a read.
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
return out;
|
|
1834
|
+
}
|
|
1835
|
+
function readSessionEvents(session) {
|
|
1836
|
+
const file = sessionJsonlPath(session);
|
|
1837
|
+
if (!existsSync(file))
|
|
1838
|
+
die(`session log not found at ${file}`);
|
|
1839
|
+
return parseJsonlObjects(readFileSync(file, "utf-8"));
|
|
1840
|
+
}
|
|
1841
|
+
function strFlag(args, name) {
|
|
1842
|
+
const v = args.flags[name];
|
|
1843
|
+
return typeof v === "string" ? v : undefined;
|
|
1844
|
+
}
|
|
1845
|
+
function intFlag(args, name) {
|
|
1846
|
+
const v = args.flags[name];
|
|
1847
|
+
if (typeof v !== "string")
|
|
1848
|
+
return undefined;
|
|
1849
|
+
const n = Number.parseInt(v, 10);
|
|
1850
|
+
if (Number.isNaN(n))
|
|
1851
|
+
die(`invalid --${name} "${v}" — must be an integer`);
|
|
1852
|
+
return n;
|
|
1853
|
+
}
|
|
1854
|
+
function floatFlag(args, name) {
|
|
1855
|
+
const v = args.flags[name];
|
|
1856
|
+
if (typeof v !== "string")
|
|
1857
|
+
return undefined;
|
|
1858
|
+
const n = Number.parseFloat(v);
|
|
1859
|
+
if (Number.isNaN(n))
|
|
1860
|
+
die(`invalid --${name} "${v}" — must be a number`);
|
|
1861
|
+
return n;
|
|
1862
|
+
}
|
|
1863
|
+
/** Resolve --turn against the transcript-derived turns; default to the last. */
|
|
1864
|
+
function resolveTurn(args, turns) {
|
|
1865
|
+
const flag = args.flags["turn"];
|
|
1866
|
+
const last = turns[turns.length - 1];
|
|
1867
|
+
if (typeof flag !== "string")
|
|
1868
|
+
return last.turnNumber;
|
|
1869
|
+
const n = Number.parseInt(flag, 10);
|
|
1870
|
+
if (Number.isNaN(n))
|
|
1871
|
+
die(`invalid --turn "${flag}" — must be an integer`);
|
|
1872
|
+
if (!turns.some((t) => t.turnNumber === n)) {
|
|
1873
|
+
die(`turn ${n} not found — session has turns 1..${turns.length}`);
|
|
1874
|
+
}
|
|
1875
|
+
return n;
|
|
1876
|
+
}
|
|
1877
|
+
/** Shared capture path for `rate` and `feedback`: validate the session, derive
|
|
1878
|
+
* turns, build a FeedbackRecord, and append it as a `user_feedback` event. */
|
|
1879
|
+
async function captureFeedback(args, source, fields) {
|
|
1880
|
+
const session = args.flags["session"];
|
|
1881
|
+
if (typeof session !== "string")
|
|
1882
|
+
die("missing --session <id>");
|
|
1883
|
+
if (!SESSION_ID_REGEX.test(session))
|
|
1884
|
+
die(`invalid --session "${session}" — expected sess_<16 hex>`);
|
|
1885
|
+
const turns = deriveTurns(readSessionEvents(session));
|
|
1886
|
+
if (turns.length === 0)
|
|
1887
|
+
die(`session ${session} has no user turns to rate`);
|
|
1888
|
+
const turnNumber = resolveTurn(args, turns);
|
|
1889
|
+
let record;
|
|
1890
|
+
try {
|
|
1891
|
+
record = buildFeedbackRecord({
|
|
1892
|
+
id: `fb_${randomBytes(6).toString("hex")}`,
|
|
1893
|
+
sessionId: session,
|
|
1894
|
+
turnNumber,
|
|
1895
|
+
ts: new Date().toISOString(),
|
|
1896
|
+
source,
|
|
1897
|
+
...fields,
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1900
|
+
catch (err) {
|
|
1901
|
+
die(err instanceof Error ? err.message : String(err));
|
|
1902
|
+
}
|
|
1903
|
+
const log = await openEventLog(session, { rootDir: join(process.cwd(), SESSIONS_SUBDIR) });
|
|
1904
|
+
await log.append({ kind: FEEDBACK_EVENT_KIND, payload: record });
|
|
1905
|
+
process.stdout.write(`recorded ${record.modality} feedback on ${session} turn ${turnNumber}\n`);
|
|
1906
|
+
}
|
|
1907
|
+
async function runRate(args) {
|
|
1908
|
+
if (args.flags["help"]) {
|
|
1909
|
+
process.stdout.write("usage: crewhaus rate --session <id> [--turn N] " +
|
|
1910
|
+
"(--thumbs up|down | --stars 1-5 | --score 0-1) [--comment <text>] [--rater <who>]\n");
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
const thumbsFlag = args.flags["thumbs"];
|
|
1914
|
+
let thumbs;
|
|
1915
|
+
if (typeof thumbsFlag === "string") {
|
|
1916
|
+
if (thumbsFlag !== "up" && thumbsFlag !== "down")
|
|
1917
|
+
die(`--thumbs must be "up" or "down" (got "${thumbsFlag}")`);
|
|
1918
|
+
thumbs = thumbsFlag;
|
|
1919
|
+
}
|
|
1920
|
+
const stars = intFlag(args, "stars");
|
|
1921
|
+
const score = floatFlag(args, "score");
|
|
1922
|
+
if (thumbs === undefined && stars === undefined && score === undefined) {
|
|
1923
|
+
die("give one of --thumbs up|down, --stars 1-5, or --score 0-1");
|
|
1924
|
+
}
|
|
1925
|
+
await captureFeedback(args, "cli", {
|
|
1926
|
+
...(thumbs !== undefined ? { thumbs } : {}),
|
|
1927
|
+
...(stars !== undefined ? { stars } : {}),
|
|
1928
|
+
...(score !== undefined ? { score } : {}),
|
|
1929
|
+
...(strFlag(args, "comment") !== undefined ? { comment: strFlag(args, "comment") } : {}),
|
|
1930
|
+
...(strFlag(args, "rater") !== undefined ? { rater: strFlag(args, "rater") } : {}),
|
|
1931
|
+
});
|
|
1932
|
+
}
|
|
1933
|
+
async function runFeedbackCmd(args) {
|
|
1934
|
+
if (args.flags["help"]) {
|
|
1935
|
+
process.stdout.write("usage: crewhaus feedback --session <id> [--turn N] --text <msg> [--correction <better answer>] [--rater <who>]\n");
|
|
1936
|
+
return;
|
|
1937
|
+
}
|
|
1938
|
+
const text = strFlag(args, "text");
|
|
1939
|
+
const correction = strFlag(args, "correction");
|
|
1940
|
+
if (text === undefined && correction === undefined) {
|
|
1941
|
+
die("give --text <msg> and/or --correction <better answer>");
|
|
1942
|
+
}
|
|
1943
|
+
await captureFeedback(args, "cli", {
|
|
1944
|
+
...(text !== undefined ? { comment: text } : {}),
|
|
1945
|
+
...(correction !== undefined ? { correction } : {}),
|
|
1946
|
+
...(strFlag(args, "rater") !== undefined ? { rater: strFlag(args, "rater") } : {}),
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
/** List `sess_*` ids that have a transcript under `.crewhaus/sessions`. */
|
|
1950
|
+
function listSessionIds(sessionsDir) {
|
|
1951
|
+
if (!existsSync(sessionsDir))
|
|
1952
|
+
return [];
|
|
1953
|
+
return readdirSync(sessionsDir)
|
|
1954
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
1955
|
+
.map((f) => f.slice(0, -".jsonl".length))
|
|
1956
|
+
.filter((id) => SESSION_ID_REGEX.test(id));
|
|
1957
|
+
}
|
|
1958
|
+
/** Read bare FeedbackRecords from `.crewhaus/feedback/*.jsonl` (the web-UI host
|
|
1959
|
+
* sink, which has no event-log handle). */
|
|
1960
|
+
function readFeedbackDir(feedbackDir) {
|
|
1961
|
+
if (!existsSync(feedbackDir))
|
|
1962
|
+
return [];
|
|
1963
|
+
const objects = [];
|
|
1964
|
+
for (const f of readdirSync(feedbackDir)) {
|
|
1965
|
+
if (!f.endsWith(".jsonl"))
|
|
1966
|
+
continue;
|
|
1967
|
+
objects.push(...parseJsonlObjects(readFileSync(join(feedbackDir, f), "utf-8")));
|
|
1968
|
+
}
|
|
1969
|
+
return extractFeedbackRecords(objects);
|
|
1970
|
+
}
|
|
1971
|
+
async function runDistill(args) {
|
|
1972
|
+
if (args.flags["help"]) {
|
|
1973
|
+
process.stdout.write("usage: crewhaus distill (--session <id> | --all-sessions) -o <dataset.jsonl> " +
|
|
1974
|
+
"[--graders-out <graders.yaml>] [--min-score F] [--judge] [--judge-model <model>]\n");
|
|
1975
|
+
return;
|
|
1976
|
+
}
|
|
1977
|
+
const outPath = args.flags["out"];
|
|
1978
|
+
if (typeof outPath !== "string")
|
|
1979
|
+
die("missing -o <dataset.jsonl>");
|
|
1980
|
+
const allSessions = args.flags["all-sessions"] === true;
|
|
1981
|
+
const session = args.flags["session"];
|
|
1982
|
+
if (!allSessions && typeof session !== "string")
|
|
1983
|
+
die("missing --session <id> (or --all-sessions)");
|
|
1984
|
+
if (typeof session === "string" && !SESSION_ID_REGEX.test(session)) {
|
|
1985
|
+
die(`invalid --session "${session}" — expected sess_<16 hex>`);
|
|
1986
|
+
}
|
|
1987
|
+
const minScore = floatFlag(args, "min-score") ?? 0.7;
|
|
1988
|
+
if (minScore < 0 || minScore > 1)
|
|
1989
|
+
die(`invalid --min-score "${minScore}" — must be in [0,1]`);
|
|
1990
|
+
const useJudge = args.flags["judge"] === true;
|
|
1991
|
+
const judgeModel = args.flags["judge-model"];
|
|
1992
|
+
const sessionsDir = join(process.cwd(), SESSIONS_SUBDIR);
|
|
1993
|
+
const sessionIds = allSessions ? listSessionIds(sessionsDir) : [session];
|
|
1994
|
+
if (sessionIds.length === 0)
|
|
1995
|
+
die(`no sessions found under ${sessionsDir}`);
|
|
1996
|
+
// Derive turns per session (tagged with the sessionId join key) and gather
|
|
1997
|
+
// feedback from both the in-transcript events and the web-UI feedback dir.
|
|
1998
|
+
const turns = [];
|
|
1999
|
+
const feedback = [];
|
|
2000
|
+
for (const id of sessionIds) {
|
|
2001
|
+
const events = readSessionEvents(id);
|
|
2002
|
+
for (const t of deriveTurns(events))
|
|
2003
|
+
turns.push({ ...t, sessionId: id });
|
|
2004
|
+
feedback.push(...extractFeedbackRecords(events));
|
|
2005
|
+
}
|
|
2006
|
+
feedback.push(...readFeedbackDir(join(process.cwd(), FEEDBACK_SUBDIR)));
|
|
2007
|
+
if (feedback.length === 0) {
|
|
2008
|
+
die("no feedback found — record some with `crewhaus rate` / `crewhaus feedback` first");
|
|
2009
|
+
}
|
|
2010
|
+
const result = distillFeedback(turns, feedback, {
|
|
2011
|
+
minScore,
|
|
2012
|
+
...(useJudge ? { judge: true } : {}),
|
|
2013
|
+
...(typeof judgeModel === "string" ? { judgeModel } : {}),
|
|
2014
|
+
});
|
|
2015
|
+
for (const w of result.warnings)
|
|
2016
|
+
process.stderr.write(`[distill] warning: ${w}\n`);
|
|
2017
|
+
if (result.samples.length === 0)
|
|
2018
|
+
die("no rated turns could be matched to the transcript(s)");
|
|
2019
|
+
const absOut = resolve(outPath);
|
|
2020
|
+
mkdirSync(dirname(absOut), { recursive: true });
|
|
2021
|
+
writeFileSync(absOut, samplesToJsonl(result.samples), { mode: 0o600 });
|
|
2022
|
+
const gradersOut = args.flags["graders-out"];
|
|
2023
|
+
if (typeof gradersOut === "string") {
|
|
2024
|
+
const absGraders = resolve(gradersOut);
|
|
2025
|
+
mkdirSync(dirname(absGraders), { recursive: true });
|
|
2026
|
+
writeFileSync(absGraders, gradersConfigToYaml(result.graders), { mode: 0o600 });
|
|
2027
|
+
}
|
|
2028
|
+
const { stats } = result;
|
|
2029
|
+
process.stdout.write(`[distill] ${stats.matchedTurns} rated turn(s) → ${result.samples.length} sample(s) ` +
|
|
2030
|
+
`(${stats.positives} positive, ${stats.negatives} low-rated) → ${absOut}\n`);
|
|
2031
|
+
if (typeof gradersOut === "string") {
|
|
2032
|
+
const g = result.graders.graders[0];
|
|
2033
|
+
process.stdout.write(`[distill] grader: ${g?.name} (${g?.type}) → ${resolve(gradersOut)}\n`);
|
|
2034
|
+
}
|
|
2035
|
+
if (stats.unmatchedFeedback > 0) {
|
|
2036
|
+
process.stdout.write(`[distill] ${stats.unmatchedFeedback} rating(s) had no matching turn (skipped)\n`);
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
/**
|
|
2040
|
+
* Inline-distill ratings for `crewhaus optimize --ratings`. `ratingsArg` is a
|
|
2041
|
+
* session id or "all". Returns optimizer-shaped samples plus a synthesized
|
|
2042
|
+
* graders.yaml (used only when the user did not pass their own --graders).
|
|
2043
|
+
*/
|
|
2044
|
+
function distillRatings(ratingsArg, minScore) {
|
|
2045
|
+
const sessionsDir = join(process.cwd(), SESSIONS_SUBDIR);
|
|
2046
|
+
let sessionIds;
|
|
2047
|
+
if (ratingsArg === "all") {
|
|
2048
|
+
sessionIds = listSessionIds(sessionsDir);
|
|
2049
|
+
}
|
|
2050
|
+
else {
|
|
2051
|
+
if (!SESSION_ID_REGEX.test(ratingsArg)) {
|
|
2052
|
+
die(`invalid --ratings "${ratingsArg}" — expected a session id (sess_<16 hex>) or "all"`);
|
|
2053
|
+
}
|
|
2054
|
+
sessionIds = [ratingsArg];
|
|
2055
|
+
}
|
|
2056
|
+
if (sessionIds.length === 0)
|
|
2057
|
+
die(`no sessions found under ${sessionsDir}`);
|
|
2058
|
+
const turns = [];
|
|
2059
|
+
const feedback = [];
|
|
2060
|
+
for (const id of sessionIds) {
|
|
2061
|
+
const events = readSessionEvents(id);
|
|
2062
|
+
for (const t of deriveTurns(events))
|
|
2063
|
+
turns.push({ ...t, sessionId: id });
|
|
2064
|
+
feedback.push(...extractFeedbackRecords(events));
|
|
2065
|
+
}
|
|
2066
|
+
feedback.push(...readFeedbackDir(join(process.cwd(), FEEDBACK_SUBDIR)));
|
|
2067
|
+
if (feedback.length === 0) {
|
|
2068
|
+
die("no feedback found for --ratings — record some with `crewhaus rate` / `crewhaus feedback` first");
|
|
2069
|
+
}
|
|
2070
|
+
const result = distillFeedback(turns, feedback, { minScore });
|
|
2071
|
+
for (const w of result.warnings)
|
|
2072
|
+
process.stderr.write(`[optimize] ratings warning: ${w}\n`);
|
|
2073
|
+
const samples = result.samples.map((s) => ({
|
|
2074
|
+
id: s.id,
|
|
2075
|
+
input: s.input,
|
|
2076
|
+
...(s.expected_output !== undefined ? { expected_output: s.expected_output } : {}),
|
|
2077
|
+
}));
|
|
2078
|
+
return { samples, gradersYaml: gradersConfigToYaml(result.graders) };
|
|
2079
|
+
}
|
|
1712
2080
|
/**
|
|
1713
2081
|
* Section 27 — `crewhaus secrets <action> <name> [opts]`. Two actions:
|
|
1714
2082
|
* doctor list configured secrets and report missing
|
|
@@ -2283,6 +2651,15 @@ switch (subcommand) {
|
|
|
2283
2651
|
case "cost-summary":
|
|
2284
2652
|
await runCostSummary(parseFor(rest, COST_SUMMARY_SCHEMA));
|
|
2285
2653
|
break;
|
|
2654
|
+
case "rate":
|
|
2655
|
+
await runRate(parseFor(rest, RATE_SCHEMA));
|
|
2656
|
+
break;
|
|
2657
|
+
case "feedback":
|
|
2658
|
+
await runFeedbackCmd(parseFor(rest, FEEDBACK_SCHEMA));
|
|
2659
|
+
break;
|
|
2660
|
+
case "distill":
|
|
2661
|
+
await runDistill(parseFor(rest, DISTILL_SCHEMA));
|
|
2662
|
+
break;
|
|
2286
2663
|
case "secrets": {
|
|
2287
2664
|
const action = rest[0] ?? "";
|
|
2288
2665
|
if (action !== "doctor" && action !== "rotate") {
|