@wyattjoh/demur 0.5.0 → 0.7.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 +82 -25
- package/extensions/demur/index.ts +99 -15
- package/extensions/demur/training-store.ts +213 -35
- package/package.json +4 -2
- package/src/adapters/pi-worker.ts +21 -7
- package/src/cli.ts +554 -135
- package/src/guard.internal.ts +83 -33
- package/src/guard.ts +42 -2
- package/src/judge.ts +53 -7
- package/src/policy.ts +10 -1
- package/src/questions.ts +9 -1
- package/src/settings-model.ts +60 -0
- package/src/state.ts +14 -16
- package/src/training-evaluation.ts +408 -0
- package/src/training-review-model.ts +18 -2
- package/src/training-review-tui.tsx +408 -44
- package/src/types.ts +56 -0
package/src/cli.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { createInterface } from "node:readline/promises";
|
|
3
2
|
import { Predicate } from "effect";
|
|
4
3
|
import { loadCostTotals } from "../extensions/demur/cost-tracker.ts";
|
|
5
4
|
import {
|
|
5
|
+
loadDemurSettings,
|
|
6
|
+
saveDemurSettings,
|
|
7
|
+
type DemurSettings,
|
|
8
|
+
} from "../extensions/demur/settings.ts";
|
|
9
|
+
import {
|
|
10
|
+
isTrainingCorrectionReason,
|
|
6
11
|
loadTrainingRecords,
|
|
7
12
|
loadTrainingReviews,
|
|
8
13
|
recordTrainingReview,
|
|
@@ -11,26 +16,39 @@ import {
|
|
|
11
16
|
type TrainingReviewInput,
|
|
12
17
|
} from "../extensions/demur/training-store.ts";
|
|
13
18
|
import { guard } from "./guard.ts";
|
|
19
|
+
import { judgeRenderedState, type JudgeResult } from "./judge.ts";
|
|
14
20
|
import {
|
|
15
21
|
deleteApiKey,
|
|
16
22
|
resolveApiKey,
|
|
17
23
|
storeApiKey,
|
|
18
24
|
type ResolvedApiKey,
|
|
19
25
|
} from "./key.ts";
|
|
26
|
+
import {
|
|
27
|
+
analyzeTrainingFeedback,
|
|
28
|
+
replayTrainingQuestions,
|
|
29
|
+
} from "./training-evaluation.ts";
|
|
20
30
|
import {
|
|
21
31
|
buildTrainingReviewEntries,
|
|
32
|
+
createTrainingReviewInput,
|
|
33
|
+
filterTrainingReviewEntries,
|
|
34
|
+
getLatestTrainingReview,
|
|
22
35
|
getTrainingReviewFilter,
|
|
36
|
+
type TrainingReviewEntry,
|
|
37
|
+
type TrainingReviewFilter,
|
|
23
38
|
type TrainingReviewSnapshot,
|
|
24
39
|
} from "./training-review-model.ts";
|
|
25
40
|
import type { TrainingReviewTuiResult } from "./training-review-tui.tsx";
|
|
26
|
-
import type { Verdict } from "./types.ts";
|
|
41
|
+
import type { RenderedCommandState, Verdict } from "./types.ts";
|
|
27
42
|
|
|
28
43
|
const USAGE = `Usage:
|
|
29
44
|
demur
|
|
30
45
|
demur auth login
|
|
31
46
|
demur auth status
|
|
32
47
|
demur auth logout
|
|
33
|
-
demur training
|
|
48
|
+
demur training list [--status=<all|unreviewed|allow|ask|deny>] [--cwd=<query>] [--json]
|
|
49
|
+
demur training evaluate [--replay] [--limit=<1-100>] [--json]
|
|
50
|
+
demur training review
|
|
51
|
+
demur training review <record-id> --decision=<allow|ask|deny> [--reason=<correction-reason>] [--note=<text>] [--json]
|
|
34
52
|
demur judge "<command>" [--cwd=<path>]`;
|
|
35
53
|
|
|
36
54
|
/**
|
|
@@ -38,6 +56,7 @@ const USAGE = `Usage:
|
|
|
38
56
|
*/
|
|
39
57
|
export type CliDependencies = {
|
|
40
58
|
judge(command: string, cwd: string): Promise<Verdict>;
|
|
59
|
+
judgeTrainingState(state: RenderedCommandState): Promise<JudgeResult>;
|
|
41
60
|
resolveApiKey(): Promise<ResolvedApiKey | undefined>;
|
|
42
61
|
storeApiKey(value: string): Promise<void>;
|
|
43
62
|
deleteApiKey(): Promise<boolean>;
|
|
@@ -45,28 +64,25 @@ export type CliDependencies = {
|
|
|
45
64
|
loadTrainingReviews(): Promise<ReadonlyArray<TrainingReview>>;
|
|
46
65
|
loadGlobalEstimatedCostUsd(): Promise<number>;
|
|
47
66
|
recordTrainingReview(input: TrainingReviewInput): Promise<TrainingReview>;
|
|
67
|
+
loadDemurSettings(): Promise<DemurSettings>;
|
|
68
|
+
saveDemurSettings(settings: DemurSettings): Promise<void>;
|
|
48
69
|
runTrainingReviewTui(
|
|
49
70
|
snapshot: TrainingReviewSnapshot,
|
|
71
|
+
settings: DemurSettings,
|
|
50
72
|
reloadSnapshot: () => Promise<TrainingReviewSnapshot>,
|
|
51
73
|
recordReview: (input: TrainingReviewInput) => Promise<TrainingReview>,
|
|
74
|
+
saveSettings: (settings: DemurSettings) => Promise<void>,
|
|
52
75
|
): Promise<TrainingReviewTuiResult>;
|
|
53
76
|
isInteractive(): boolean;
|
|
54
77
|
readSecret(prompt: string): Promise<string>;
|
|
55
|
-
readLine(prompt: string): Promise<string>;
|
|
56
78
|
cwd(): string;
|
|
57
79
|
stdout(message: string): void;
|
|
58
80
|
stderr(message: string): void;
|
|
59
81
|
};
|
|
60
82
|
|
|
61
|
-
let lineInput:
|
|
62
|
-
| {
|
|
63
|
-
terminal: ReturnType<typeof createInterface>;
|
|
64
|
-
lines: AsyncIterator<string>;
|
|
65
|
-
}
|
|
66
|
-
| undefined;
|
|
67
|
-
|
|
68
83
|
const defaultDependencies: CliDependencies = {
|
|
69
84
|
judge: (command, cwd) => guard(command, cwd, "cli"),
|
|
85
|
+
judgeTrainingState: judgeRenderedState,
|
|
70
86
|
resolveApiKey,
|
|
71
87
|
storeApiKey,
|
|
72
88
|
deleteApiKey,
|
|
@@ -75,15 +91,28 @@ const defaultDependencies: CliDependencies = {
|
|
|
75
91
|
loadGlobalEstimatedCostUsd: async () =>
|
|
76
92
|
(await loadCostTotals()).estimatedCostUsd,
|
|
77
93
|
recordTrainingReview,
|
|
78
|
-
|
|
94
|
+
loadDemurSettings,
|
|
95
|
+
saveDemurSettings,
|
|
96
|
+
runTrainingReviewTui: async (
|
|
97
|
+
snapshot,
|
|
98
|
+
settings,
|
|
99
|
+
reloadSnapshot,
|
|
100
|
+
recordReview,
|
|
101
|
+
saveSettings,
|
|
102
|
+
) => {
|
|
79
103
|
const { runTrainingReviewTui } = await import(
|
|
80
104
|
"./training-review-tui.tsx"
|
|
81
105
|
);
|
|
82
|
-
return runTrainingReviewTui(
|
|
106
|
+
return runTrainingReviewTui(
|
|
107
|
+
snapshot,
|
|
108
|
+
settings,
|
|
109
|
+
reloadSnapshot,
|
|
110
|
+
recordReview,
|
|
111
|
+
saveSettings,
|
|
112
|
+
);
|
|
83
113
|
},
|
|
84
114
|
isInteractive: () => Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
85
115
|
readSecret,
|
|
86
|
-
readLine,
|
|
87
116
|
cwd: () => process.cwd(),
|
|
88
117
|
stdout: (message) => console.log(message),
|
|
89
118
|
stderr: (message) => console.error(message),
|
|
@@ -104,7 +133,7 @@ export async function runCli(
|
|
|
104
133
|
if (args.length === 0) {
|
|
105
134
|
if (!dependencies.isInteractive()) {
|
|
106
135
|
dependencies.stderr(
|
|
107
|
-
"demur: the interactive app requires a terminal; use `demur training
|
|
136
|
+
"demur: the interactive app requires a terminal; use `demur training list --json` or review a record by ID.",
|
|
108
137
|
);
|
|
109
138
|
return 2;
|
|
110
139
|
}
|
|
@@ -127,10 +156,17 @@ export async function runCli(
|
|
|
127
156
|
const judgeArgs = args[0] === "judge" ? args.slice(1) : args;
|
|
128
157
|
return await runJudge(judgeArgs, dependencies);
|
|
129
158
|
} catch (error: unknown) {
|
|
130
|
-
|
|
159
|
+
if (isTrainingJsonRequest(args)) {
|
|
160
|
+
writeTrainingJsonError(
|
|
161
|
+
trainingOperation(args),
|
|
162
|
+
"unexpected_error",
|
|
163
|
+
errorDetail(error),
|
|
164
|
+
dependencies,
|
|
165
|
+
);
|
|
166
|
+
} else {
|
|
167
|
+
dependencies.stderr(`demur: ${errorDetail(error)}`);
|
|
168
|
+
}
|
|
131
169
|
return 1;
|
|
132
|
-
} finally {
|
|
133
|
-
closeLineInput();
|
|
134
170
|
}
|
|
135
171
|
}
|
|
136
172
|
|
|
@@ -201,43 +237,364 @@ async function runAuth(
|
|
|
201
237
|
return 2;
|
|
202
238
|
}
|
|
203
239
|
|
|
240
|
+
type ParsedArguments = {
|
|
241
|
+
positionals: ReadonlyArray<string>;
|
|
242
|
+
options: ReadonlyMap<string, string | true>;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
type ParseArgumentsResult =
|
|
246
|
+
| { parsed: ParsedArguments; error: undefined }
|
|
247
|
+
| { parsed: undefined; error: string };
|
|
248
|
+
|
|
204
249
|
async function runTraining(
|
|
205
250
|
args: ReadonlyArray<string>,
|
|
206
251
|
dependencies: CliDependencies,
|
|
207
252
|
): Promise<number> {
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
253
|
+
const command = args[0];
|
|
254
|
+
if (command === "list") {
|
|
255
|
+
return runTrainingList(args.slice(1), dependencies);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (command === "evaluate") {
|
|
259
|
+
return runTrainingEvaluate(args.slice(1), dependencies);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (command === "review") {
|
|
263
|
+
if (args.length === 1) {
|
|
264
|
+
return runInteractiveTrainingReview(dependencies);
|
|
265
|
+
}
|
|
266
|
+
return runTrainingReview(args.slice(1), dependencies);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return writeTrainingUsageError(
|
|
270
|
+
trainingOperation(["training", ...args]),
|
|
271
|
+
args.includes("--json"),
|
|
272
|
+
"expected `training list`, `training evaluate`, or `training review`",
|
|
273
|
+
dependencies,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function runInteractiveTrainingReview(
|
|
278
|
+
dependencies: CliDependencies,
|
|
279
|
+
): Promise<number> {
|
|
280
|
+
if (!dependencies.isInteractive()) {
|
|
281
|
+
dependencies.stderr(
|
|
282
|
+
"demur: `training review` requires a terminal; use `training list --json` and review a record by ID.",
|
|
283
|
+
);
|
|
216
284
|
return 2;
|
|
217
285
|
}
|
|
218
286
|
|
|
219
287
|
const loadSnapshot = () => loadTrainingReviewSnapshot(dependencies);
|
|
220
288
|
const snapshot = await loadSnapshot();
|
|
289
|
+
const settings = await dependencies.loadDemurSettings();
|
|
290
|
+
const result = await dependencies.runTrainingReviewTui(
|
|
291
|
+
snapshot,
|
|
292
|
+
settings,
|
|
293
|
+
loadSnapshot,
|
|
294
|
+
dependencies.recordTrainingReview,
|
|
295
|
+
dependencies.saveDemurSettings,
|
|
296
|
+
);
|
|
297
|
+
printReviewSummary(result, dependencies);
|
|
298
|
+
return 0;
|
|
299
|
+
}
|
|
221
300
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
301
|
+
async function runTrainingList(
|
|
302
|
+
args: ReadonlyArray<string>,
|
|
303
|
+
dependencies: CliDependencies,
|
|
304
|
+
): Promise<number> {
|
|
305
|
+
const operation = "training.list";
|
|
306
|
+
const parsedResult = parseArguments(
|
|
307
|
+
args,
|
|
308
|
+
new Set(["json"]),
|
|
309
|
+
new Set(["status", "cwd"]),
|
|
310
|
+
);
|
|
311
|
+
const json = args.includes("--json");
|
|
312
|
+
if (parsedResult.error !== undefined) {
|
|
313
|
+
return writeTrainingUsageError(
|
|
314
|
+
operation,
|
|
315
|
+
json,
|
|
316
|
+
parsedResult.error,
|
|
317
|
+
dependencies,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const parsed = parsedResult.parsed;
|
|
322
|
+
if (parsed.positionals.length !== 0) {
|
|
323
|
+
return writeTrainingUsageError(
|
|
324
|
+
operation,
|
|
325
|
+
hasOption(parsed, "json"),
|
|
326
|
+
"`training list` does not accept positional arguments",
|
|
327
|
+
dependencies,
|
|
227
328
|
);
|
|
228
|
-
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const statusValue = getStringOption(parsed, "status") ?? "all";
|
|
332
|
+
if (!isTrainingReviewFilter(statusValue)) {
|
|
333
|
+
return writeTrainingUsageError(
|
|
334
|
+
operation,
|
|
335
|
+
hasOption(parsed, "json"),
|
|
336
|
+
`unsupported status: ${statusValue}`,
|
|
337
|
+
dependencies,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const cwdQuery = getStringOption(parsed, "cwd") ?? "";
|
|
342
|
+
const entries = filterTrainingReviewEntries(
|
|
343
|
+
await loadTrainingEntries(dependencies),
|
|
344
|
+
statusValue,
|
|
345
|
+
cwdQuery,
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
if (hasOption(parsed, "json")) {
|
|
349
|
+
writeTrainingJsonSuccess(
|
|
350
|
+
operation,
|
|
351
|
+
{
|
|
352
|
+
filter: {
|
|
353
|
+
status: statusValue,
|
|
354
|
+
cwd: cwdQuery === "" ? null : cwdQuery,
|
|
355
|
+
},
|
|
356
|
+
records: entries.map(trainingListRecord),
|
|
357
|
+
},
|
|
358
|
+
dependencies,
|
|
359
|
+
);
|
|
360
|
+
return 0;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (entries.length === 0) {
|
|
364
|
+
dependencies.stdout("No matching demur training records.");
|
|
229
365
|
return 0;
|
|
230
366
|
}
|
|
231
367
|
|
|
232
|
-
|
|
233
|
-
.
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
368
|
+
dependencies.stdout(
|
|
369
|
+
`${entries.length} matching training record${entries.length === 1 ? "" : "s"}:`,
|
|
370
|
+
);
|
|
371
|
+
for (const entry of entries) {
|
|
372
|
+
const record = entry.record;
|
|
373
|
+
dependencies.stdout(
|
|
374
|
+
[
|
|
375
|
+
record.id,
|
|
376
|
+
getTrainingReviewFilter(entry),
|
|
377
|
+
`model=${record.verdict.decision}`,
|
|
378
|
+
record.cwd,
|
|
379
|
+
summarizeTrainingCommand(record.command),
|
|
380
|
+
].join("\t"),
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
return 0;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function runTrainingEvaluate(
|
|
387
|
+
args: ReadonlyArray<string>,
|
|
388
|
+
dependencies: CliDependencies,
|
|
389
|
+
): Promise<number> {
|
|
390
|
+
const operation = "training.evaluate";
|
|
391
|
+
const parsedResult = parseArguments(
|
|
392
|
+
args,
|
|
393
|
+
new Set(["json", "replay"]),
|
|
394
|
+
new Set(["limit"]),
|
|
395
|
+
);
|
|
396
|
+
const json = args.includes("--json");
|
|
397
|
+
if (parsedResult.error !== undefined) {
|
|
398
|
+
return writeTrainingUsageError(
|
|
399
|
+
operation,
|
|
400
|
+
json,
|
|
401
|
+
parsedResult.error,
|
|
402
|
+
dependencies,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const parsed = parsedResult.parsed;
|
|
407
|
+
if (parsed.positionals.length !== 0) {
|
|
408
|
+
return writeTrainingUsageError(
|
|
409
|
+
operation,
|
|
410
|
+
hasOption(parsed, "json"),
|
|
411
|
+
"`training evaluate` does not accept positional arguments",
|
|
412
|
+
dependencies,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const limitValue = getStringOption(parsed, "limit");
|
|
417
|
+
if (limitValue !== undefined && !hasOption(parsed, "replay")) {
|
|
418
|
+
return writeTrainingUsageError(
|
|
419
|
+
operation,
|
|
420
|
+
hasOption(parsed, "json"),
|
|
421
|
+
"`--limit` requires `--replay`",
|
|
422
|
+
dependencies,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
const replayLimit = limitValue === undefined ? 20 : Number(limitValue);
|
|
426
|
+
if (
|
|
427
|
+
!Number.isInteger(replayLimit) ||
|
|
428
|
+
replayLimit < 1 ||
|
|
429
|
+
replayLimit > 100
|
|
430
|
+
) {
|
|
431
|
+
return writeTrainingUsageError(
|
|
432
|
+
operation,
|
|
433
|
+
hasOption(parsed, "json"),
|
|
434
|
+
"`--limit` must be an integer from 1 to 100",
|
|
435
|
+
dependencies,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const entries = await loadTrainingEntries(dependencies);
|
|
440
|
+
const report = analyzeTrainingFeedback(entries);
|
|
441
|
+
const replay = hasOption(parsed, "replay")
|
|
442
|
+
? await replayTrainingQuestions(
|
|
443
|
+
entries,
|
|
444
|
+
dependencies.judgeTrainingState,
|
|
445
|
+
replayLimit,
|
|
446
|
+
)
|
|
447
|
+
: undefined;
|
|
448
|
+
if (hasOption(parsed, "json")) {
|
|
449
|
+
writeTrainingJsonSuccess(
|
|
450
|
+
operation,
|
|
451
|
+
replay === undefined ? report : { offline: report, replay },
|
|
452
|
+
dependencies,
|
|
453
|
+
);
|
|
237
454
|
return 0;
|
|
238
455
|
}
|
|
239
456
|
|
|
240
|
-
|
|
457
|
+
dependencies.stdout(
|
|
458
|
+
`Reviewed ${report.reviewed}; evaluable ${report.evaluable}; unavailable ${report.unavailable}.`,
|
|
459
|
+
);
|
|
460
|
+
dependencies.stdout(
|
|
461
|
+
`Replay fidelity: ${report.completeReplayRecords} complete, ${report.policyOnlyRecords} policy-only legacy.`,
|
|
462
|
+
);
|
|
463
|
+
dependencies.stdout(
|
|
464
|
+
`Current policy: ${report.current.matches}/${report.current.evaluated} match; weighted loss ${report.current.weightedLoss}.`,
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
const reasons = Object.entries(report.correctionsByReason);
|
|
468
|
+
if (reasons.length > 0) {
|
|
469
|
+
dependencies.stdout(
|
|
470
|
+
`Correction reasons: ${reasons.map(([reason, count]) => `${reason}=${count}`).join(", ")}.`,
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (report.candidates.length === 0) {
|
|
475
|
+
dependencies.stdout("No single-threshold candidate improved observed weighted loss.");
|
|
476
|
+
} else {
|
|
477
|
+
dependencies.stdout("Exploratory single-threshold candidates:");
|
|
478
|
+
for (const candidate of report.candidates) {
|
|
479
|
+
dependencies.stdout(
|
|
480
|
+
` ${candidate.field}=${candidate.value}: ${candidate.metrics.matches}/${candidate.metrics.evaluated} match; weighted loss ${candidate.metrics.weightedLoss}`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (report.warning !== undefined) dependencies.stdout(`Warning: ${report.warning}`);
|
|
486
|
+
dependencies.stdout(
|
|
487
|
+
"Candidates are not applied automatically; validate them on an independent holdout and the synthetic corpus.",
|
|
488
|
+
);
|
|
489
|
+
if (replay !== undefined) {
|
|
490
|
+
dependencies.stdout(
|
|
491
|
+
`Current questions replay: ${replay.metrics.matches}/${replay.metrics.evaluated} match; ${replay.improved} improved; ${replay.regressed} regressed; ${replay.unavailable} unavailable; ${replay.skipped} skipped by limit.`,
|
|
492
|
+
);
|
|
493
|
+
dependencies.stdout(
|
|
494
|
+
`Replay usage: ${replay.inputTokens} input / ${replay.outputTokens} output tokens. Stored commands remained data and were never executed.`,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
return 0;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function runTrainingReview(
|
|
501
|
+
args: ReadonlyArray<string>,
|
|
502
|
+
dependencies: CliDependencies,
|
|
503
|
+
): Promise<number> {
|
|
504
|
+
const operation = "training.review";
|
|
505
|
+
const parsedResult = parseArguments(
|
|
506
|
+
args,
|
|
507
|
+
new Set(["json"]),
|
|
508
|
+
new Set(["decision", "reason", "note"]),
|
|
509
|
+
);
|
|
510
|
+
const json = args.includes("--json");
|
|
511
|
+
if (parsedResult.error !== undefined) {
|
|
512
|
+
return writeTrainingUsageError(
|
|
513
|
+
operation,
|
|
514
|
+
json,
|
|
515
|
+
parsedResult.error,
|
|
516
|
+
dependencies,
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const parsed = parsedResult.parsed;
|
|
521
|
+
if (parsed.positionals.length !== 1) {
|
|
522
|
+
return writeTrainingUsageError(
|
|
523
|
+
operation,
|
|
524
|
+
hasOption(parsed, "json"),
|
|
525
|
+
"`training review` requires exactly one record ID",
|
|
526
|
+
dependencies,
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const decisionValue = getStringOption(parsed, "decision");
|
|
531
|
+
if (decisionValue === undefined || !isDecision(decisionValue)) {
|
|
532
|
+
return writeTrainingUsageError(
|
|
533
|
+
operation,
|
|
534
|
+
hasOption(parsed, "json"),
|
|
535
|
+
"`--decision` must be allow, ask, or deny",
|
|
536
|
+
dependencies,
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const recordId = parsed.positionals[0];
|
|
541
|
+
const entries = await loadTrainingEntries(dependencies);
|
|
542
|
+
const entry = entries.find((candidate) => candidate.record.id === recordId);
|
|
543
|
+
if (entry === undefined) {
|
|
544
|
+
return writeTrainingDomainError(
|
|
545
|
+
operation,
|
|
546
|
+
hasOption(parsed, "json"),
|
|
547
|
+
"record_not_found",
|
|
548
|
+
`training record not found: ${recordId}`,
|
|
549
|
+
dependencies,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const corrected = decisionValue !== entry.record.verdict.decision;
|
|
554
|
+
const reasonValue = getStringOption(parsed, "reason");
|
|
555
|
+
if (corrected && !isTrainingCorrectionReason(reasonValue)) {
|
|
556
|
+
return writeTrainingUsageError(
|
|
557
|
+
operation,
|
|
558
|
+
hasOption(parsed, "json"),
|
|
559
|
+
"corrected reviews require `--reason` with a supported correction reason",
|
|
560
|
+
dependencies,
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
if (!corrected && reasonValue !== undefined) {
|
|
564
|
+
return writeTrainingUsageError(
|
|
565
|
+
operation,
|
|
566
|
+
hasOption(parsed, "json"),
|
|
567
|
+
"`--reason` is only valid when correcting the model decision",
|
|
568
|
+
dependencies,
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const correctionReason = corrected && isTrainingCorrectionReason(reasonValue)
|
|
573
|
+
? reasonValue
|
|
574
|
+
: undefined;
|
|
575
|
+
const previousReview = getLatestTrainingReview(entry) ?? null;
|
|
576
|
+
const review = await dependencies.recordTrainingReview(
|
|
577
|
+
createTrainingReviewInput(
|
|
578
|
+
entry.record,
|
|
579
|
+
decisionValue,
|
|
580
|
+
correctionReason,
|
|
581
|
+
getStringOption(parsed, "note"),
|
|
582
|
+
),
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
if (hasOption(parsed, "json")) {
|
|
586
|
+
writeTrainingJsonSuccess(
|
|
587
|
+
operation,
|
|
588
|
+
{ review, corrected, previousReview },
|
|
589
|
+
dependencies,
|
|
590
|
+
);
|
|
591
|
+
return 0;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
dependencies.stdout(
|
|
595
|
+
`Recorded ${decisionValue.toUpperCase()} review for ${recordId}${corrected ? ` (model: ${entry.record.verdict.decision.toUpperCase()})` : " (accepted model decision)"}.`,
|
|
596
|
+
);
|
|
597
|
+
return 0;
|
|
241
598
|
}
|
|
242
599
|
|
|
243
600
|
async function loadTrainingReviewSnapshot(
|
|
@@ -251,44 +608,18 @@ async function loadTrainingReviewSnapshot(
|
|
|
251
608
|
return { records, reviews, globalEstimatedCostUsd };
|
|
252
609
|
}
|
|
253
610
|
|
|
254
|
-
async function
|
|
255
|
-
pending: ReadonlyArray<TrainingRecord>,
|
|
611
|
+
async function loadTrainingEntries(
|
|
256
612
|
dependencies: CliDependencies,
|
|
257
|
-
): Promise<
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
continue;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
const isCorrection = expectedDecision !== record.verdict.decision;
|
|
271
|
-
const note = isCorrection
|
|
272
|
-
? (await dependencies.readLine("Correction note (optional): ")).trim() ||
|
|
273
|
-
undefined
|
|
274
|
-
: undefined;
|
|
275
|
-
await dependencies.recordTrainingReview({
|
|
276
|
-
recordId: record.id,
|
|
277
|
-
originalDecision: record.verdict.decision,
|
|
278
|
-
expectedDecision,
|
|
279
|
-
note,
|
|
280
|
-
});
|
|
281
|
-
reviewed += 1;
|
|
282
|
-
if (isCorrection) corrected += 1;
|
|
283
|
-
dependencies.stdout(
|
|
284
|
-
isCorrection
|
|
285
|
-
? `Recorded correction: ${record.verdict.decision} → ${expectedDecision}.`
|
|
286
|
-
: `Accepted ${record.verdict.decision} decision.`,
|
|
287
|
-
);
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
printReviewSummary({ reviewed, corrected, skipped }, dependencies);
|
|
291
|
-
return 0;
|
|
613
|
+
): Promise<ReadonlyArray<TrainingReviewEntry>> {
|
|
614
|
+
const [records, reviews] = await Promise.all([
|
|
615
|
+
dependencies.loadTrainingRecords(),
|
|
616
|
+
dependencies.loadTrainingReviews(),
|
|
617
|
+
]);
|
|
618
|
+
return buildTrainingReviewEntries({
|
|
619
|
+
records,
|
|
620
|
+
reviews,
|
|
621
|
+
globalEstimatedCostUsd: 0,
|
|
622
|
+
});
|
|
292
623
|
}
|
|
293
624
|
|
|
294
625
|
function printReviewSummary(
|
|
@@ -303,54 +634,162 @@ function printReviewSummary(
|
|
|
303
634
|
);
|
|
304
635
|
}
|
|
305
636
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
if (
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
)
|
|
325
|
-
|
|
637
|
+
function parseArguments(
|
|
638
|
+
args: ReadonlyArray<string>,
|
|
639
|
+
booleanOptions: ReadonlySet<string>,
|
|
640
|
+
valueOptions: ReadonlySet<string>,
|
|
641
|
+
): ParseArgumentsResult {
|
|
642
|
+
const positionals: Array<string> = [];
|
|
643
|
+
const options = new Map<string, string | true>();
|
|
644
|
+
|
|
645
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
646
|
+
const argument = args[index];
|
|
647
|
+
if (argument === undefined) continue;
|
|
648
|
+
if (!argument.startsWith("--")) {
|
|
649
|
+
positionals.push(argument);
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const separator = argument.indexOf("=");
|
|
654
|
+
const name = argument.slice(2, separator < 0 ? undefined : separator);
|
|
655
|
+
const inlineValue = separator < 0 ? undefined : argument.slice(separator + 1);
|
|
656
|
+
if (options.has(name)) {
|
|
657
|
+
return { parsed: undefined, error: `duplicate option: --${name}` };
|
|
326
658
|
}
|
|
327
|
-
|
|
659
|
+
|
|
660
|
+
if (booleanOptions.has(name)) {
|
|
661
|
+
if (inlineValue !== undefined) {
|
|
662
|
+
return {
|
|
663
|
+
parsed: undefined,
|
|
664
|
+
error: `option --${name} does not accept a value`,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
options.set(name, true);
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (!valueOptions.has(name)) {
|
|
672
|
+
return { parsed: undefined, error: `unknown option: --${name}` };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const followingValue = args[index + 1];
|
|
676
|
+
const value = inlineValue ??
|
|
677
|
+
(followingValue !== undefined && !followingValue.startsWith("--")
|
|
678
|
+
? followingValue
|
|
679
|
+
: undefined);
|
|
680
|
+
if (value === undefined) {
|
|
681
|
+
return {
|
|
682
|
+
parsed: undefined,
|
|
683
|
+
error: `option --${name} requires a value`,
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
if (inlineValue === undefined) index += 1;
|
|
687
|
+
options.set(name, value);
|
|
328
688
|
}
|
|
689
|
+
|
|
690
|
+
return { parsed: { positionals, options }, error: undefined };
|
|
329
691
|
}
|
|
330
692
|
|
|
331
|
-
function
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
693
|
+
function getStringOption(
|
|
694
|
+
parsed: ParsedArguments,
|
|
695
|
+
name: string,
|
|
696
|
+
): string | undefined {
|
|
697
|
+
const value = parsed.options.get(name);
|
|
698
|
+
return typeof value === "string" ? value : undefined;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function hasOption(parsed: ParsedArguments, name: string): boolean {
|
|
702
|
+
return parsed.options.has(name);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function isTrainingReviewFilter(value: string): value is TrainingReviewFilter {
|
|
706
|
+
return value === "all" ||
|
|
707
|
+
value === "unreviewed" ||
|
|
708
|
+
value === "allow" ||
|
|
709
|
+
value === "ask" ||
|
|
710
|
+
value === "deny";
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function isDecision(value: string): value is "allow" | "ask" | "deny" {
|
|
714
|
+
return value === "allow" || value === "ask" || value === "deny";
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function trainingListRecord(entry: TrainingReviewEntry): object {
|
|
718
|
+
return {
|
|
719
|
+
status: getTrainingReviewFilter(entry),
|
|
720
|
+
record: entry.record,
|
|
721
|
+
reviews: entry.reviews,
|
|
722
|
+
latestReview: getLatestTrainingReview(entry) ?? null,
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function summarizeTrainingCommand(command: string): string {
|
|
727
|
+
return command.replaceAll(/\s+/g, " ").trim();
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function writeTrainingUsageError(
|
|
731
|
+
operation: string,
|
|
732
|
+
json: boolean,
|
|
733
|
+
message: string,
|
|
335
734
|
dependencies: CliDependencies,
|
|
336
|
-
):
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
dependencies.stdout(
|
|
344
|
-
`verdict: ${record.verdict.decision.toUpperCase()} — ${record.verdict.reason}`,
|
|
345
|
-
);
|
|
346
|
-
if (record.verdict.judgments !== undefined) {
|
|
347
|
-
dependencies.stdout(
|
|
348
|
-
`judgments: ${JSON.stringify(record.verdict.judgments)}`,
|
|
735
|
+
): number {
|
|
736
|
+
if (json) {
|
|
737
|
+
writeTrainingJsonError(
|
|
738
|
+
operation,
|
|
739
|
+
"invalid_arguments",
|
|
740
|
+
message,
|
|
741
|
+
dependencies,
|
|
349
742
|
);
|
|
743
|
+
} else {
|
|
744
|
+
dependencies.stderr(`demur: ${message}`);
|
|
745
|
+
dependencies.stderr(USAGE);
|
|
350
746
|
}
|
|
351
|
-
|
|
352
|
-
|
|
747
|
+
return 2;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function writeTrainingDomainError(
|
|
751
|
+
operation: string,
|
|
752
|
+
json: boolean,
|
|
753
|
+
code: string,
|
|
754
|
+
message: string,
|
|
755
|
+
dependencies: CliDependencies,
|
|
756
|
+
): number {
|
|
757
|
+
if (json) {
|
|
758
|
+
writeTrainingJsonError(operation, code, message, dependencies);
|
|
759
|
+
} else {
|
|
760
|
+
dependencies.stderr(`demur: ${message}`);
|
|
353
761
|
}
|
|
762
|
+
return 1;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function writeTrainingJsonSuccess(
|
|
766
|
+
operation: string,
|
|
767
|
+
result: unknown,
|
|
768
|
+
dependencies: CliDependencies,
|
|
769
|
+
): void {
|
|
770
|
+
dependencies.stdout(JSON.stringify({ version: 1, ok: true, operation, result }));
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function writeTrainingJsonError(
|
|
774
|
+
operation: string,
|
|
775
|
+
code: string,
|
|
776
|
+
message: string,
|
|
777
|
+
dependencies: CliDependencies,
|
|
778
|
+
): void {
|
|
779
|
+
dependencies.stdout(JSON.stringify({
|
|
780
|
+
version: 1,
|
|
781
|
+
ok: false,
|
|
782
|
+
operation,
|
|
783
|
+
error: { code, message },
|
|
784
|
+
}));
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function isTrainingJsonRequest(args: ReadonlyArray<string>): boolean {
|
|
788
|
+
return args[0] === "training" && args.includes("--json");
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function trainingOperation(args: ReadonlyArray<string>): string {
|
|
792
|
+
return `training.${args[1] ?? "unknown"}`;
|
|
354
793
|
}
|
|
355
794
|
|
|
356
795
|
async function runJudge(
|
|
@@ -403,26 +842,6 @@ async function runJudge(
|
|
|
403
842
|
return 0;
|
|
404
843
|
}
|
|
405
844
|
|
|
406
|
-
async function readLine(prompt: string): Promise<string> {
|
|
407
|
-
if (lineInput === undefined) {
|
|
408
|
-
const terminal = createInterface({ input: process.stdin });
|
|
409
|
-
lineInput = {
|
|
410
|
-
terminal,
|
|
411
|
-
lines: terminal[Symbol.asyncIterator](),
|
|
412
|
-
};
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
process.stderr.write(prompt);
|
|
416
|
-
const next = await lineInput.lines.next();
|
|
417
|
-
if (next.done) throw new Error("training review input ended");
|
|
418
|
-
return next.value;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
function closeLineInput(): void {
|
|
422
|
-
lineInput?.terminal.close();
|
|
423
|
-
lineInput = undefined;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
845
|
async function readSecret(prompt: string): Promise<string> {
|
|
427
846
|
if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== "function") {
|
|
428
847
|
return (await Bun.stdin.text()).trim();
|