auto-model-router 0.4.2 → 0.4.3
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/.omp-plugin/marketplace.json +2 -2
- package/README.md +21 -2
- package/omp-extension/report-logic.ts +46 -0
- package/omp-extension/router-configure.ts +55 -3
- package/package.json +1 -1
- package/src/cli/config-wizard.ts +7 -1
- package/src/config/defaults.ts +7 -0
- package/src/config/schema.ts +4 -1
- package/src/config/types.ts +27 -0
- package/src/cost/ledger.ts +75 -8
- package/src/cost/report.ts +8 -3
- package/src/cost/summary.ts +231 -0
- package/src/cost/types.ts +31 -3
- package/src/router/candidates.ts +1 -1
- package/src/router/classify.ts +2 -2
- package/src/router/compaction.ts +1 -0
- package/src/router/learned.ts +11 -1
- package/src/router/select.ts +5 -1
- package/src/server/compaction-digest.ts +127 -0
- package/src/server/digest.ts +13 -3
- package/src/server/http.ts +29 -1
- package/src/server/turn.ts +27 -1
- package/src/util/sqlite.ts +7 -0
- package/src/wire/openai/request.ts +4 -0
- package/src/wire/types.ts +7 -0
- package/test/compaction.test.ts +40 -3
- package/test/digest.test.ts +22 -0
- package/test/failover.test.ts +3 -3
- package/test/learned.test.ts +21 -1
- package/test/report-logic.test.ts +11 -1
- package/test/select.test.ts +2 -2
- package/test/summary.test.ts +171 -0
- package/test/tokens.test.ts +44 -0
- package/test/trust-attribution.test.ts +37 -0
- package/test/turn.test.ts +68 -3
- package/tools/train-classifier.ts +75 -20
|
@@ -6,10 +6,15 @@
|
|
|
6
6
|
* bun tools/train-classifier.ts --dry-run # evaluate only
|
|
7
7
|
* bun tools/train-classifier.ts --days 30 # bound the training window
|
|
8
8
|
* bun tools/train-classifier.ts --out path.json
|
|
9
|
+
* bun tools/train-classifier.ts --label feedback # learn from /router good|bad verdicts
|
|
9
10
|
*
|
|
10
|
-
* Label: the served turn escalated — a cheaper attempt
|
|
11
|
-
* first (attempt > 0).
|
|
12
|
-
* the
|
|
11
|
+
* Label (default `escalation`): the served turn escalated — a cheaper attempt
|
|
12
|
+
* was probe-rejected first (attempt > 0). With `--label feedback` the rows are
|
|
13
|
+
* the turns a person judged with /router good|bad and the positive class is
|
|
14
|
+
* `bad`: what a user rejected, which a probe cannot see. Verdicts are scarce,
|
|
15
|
+
* so this needs at least FEEDBACK_MIN_ROWS of them and prints the count.
|
|
16
|
+
* Split: the oldest 80% train, the newest 20% test, so the score is what the
|
|
17
|
+
* model would have done on turns it had not seen.
|
|
13
18
|
* Prints holdout AUC for the learned model and for the heuristic's own score,
|
|
14
19
|
* plus precision/recall at a few thresholds. Read-only on the ledger.
|
|
15
20
|
*
|
|
@@ -20,7 +25,7 @@
|
|
|
20
25
|
import { Database } from "bun:sqlite";
|
|
21
26
|
import { dirname, join } from "node:path";
|
|
22
27
|
import { loadConfig } from "../src/config/load.ts";
|
|
23
|
-
import { auc, FEATURE_NAMES, LEARNED_MODEL_VERSION, learnedVector, type LearnedModel, trainLogistic } from "../src/router/learned.ts";
|
|
28
|
+
import { auc, FEATURE_NAMES, LEARNED_MODEL_VERSION, type LearnedLabel, learnedVector, type LearnedModel, trainLogistic } from "../src/router/learned.ts";
|
|
24
29
|
import type { Features } from "../src/router/types.ts";
|
|
25
30
|
|
|
26
31
|
const argv = process.argv.slice(2);
|
|
@@ -29,6 +34,14 @@ const flag = (name: string): string | undefined => {
|
|
|
29
34
|
return i >= 0 ? argv[i + 1] : undefined;
|
|
30
35
|
};
|
|
31
36
|
const dryRun = argv.includes("--dry-run");
|
|
37
|
+
const labelFlag = flag("--label") ?? "escalation";
|
|
38
|
+
if (labelFlag !== "escalation" && labelFlag !== "feedback") {
|
|
39
|
+
console.error(`--label must be escalation or feedback, not "${labelFlag}"`);
|
|
40
|
+
process.exit(2);
|
|
41
|
+
}
|
|
42
|
+
const label: LearnedLabel = labelFlag;
|
|
43
|
+
/** Verdict rows are a person's time; a fit on fewer than this is noise dressed as a model. */
|
|
44
|
+
const FEEDBACK_MIN_ROWS = 100;
|
|
32
45
|
const days = Number.parseInt(flag("--days") ?? "0", 10);
|
|
33
46
|
const cfg = loadConfig({});
|
|
34
47
|
const out = flag("--out") ?? join(dirname(cfg.ledger.path), "classifier-learned.json");
|
|
@@ -38,28 +51,70 @@ const since = days > 0 ? Date.now() - days * 86_400_000 : 0;
|
|
|
38
51
|
// For the heuristic baseline on an escalated turn, the score that MATTERS is
|
|
39
52
|
// the one the rejected first attempt was routed on; the served row carries the
|
|
40
53
|
// escalation classification (score 1 by construction), which would leak the label.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
54
|
+
interface TrainRow {
|
|
55
|
+
features: string;
|
|
56
|
+
attempt: number;
|
|
57
|
+
score: number | null;
|
|
58
|
+
/** Feedback label only: the person's verdict on this turn. */
|
|
59
|
+
verdict?: string;
|
|
60
|
+
}
|
|
61
|
+
function feedbackRows(): TrainRow[] {
|
|
62
|
+
try {
|
|
63
|
+
return db
|
|
64
|
+
.query(
|
|
65
|
+
`SELECT l.features, l.attempt, l.score, f.verdict
|
|
66
|
+
FROM feedback f JOIN ledger l ON l.id = f.ledger_id
|
|
67
|
+
WHERE l.features IS NOT NULL AND f.created_at_ms >= ?
|
|
68
|
+
ORDER BY f.created_at_ms ASC`,
|
|
69
|
+
)
|
|
70
|
+
.all(since) as TrainRow[];
|
|
71
|
+
} catch (err) {
|
|
72
|
+
if (String(err).includes("no such table: feedback")) {
|
|
73
|
+
console.error("this ledger has no feedback table yet (a router older than v0.4.1 wrote it); restart the router once, then judge some turns");
|
|
74
|
+
process.exit(2);
|
|
75
|
+
}
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const rows =
|
|
80
|
+
label === "feedback"
|
|
81
|
+
? feedbackRows()
|
|
82
|
+
: (db
|
|
83
|
+
.query(
|
|
84
|
+
`SELECT l.features, l.attempt,
|
|
85
|
+
COALESCE((SELECT w.score FROM ledger w WHERE w.conversation_key = l.conversation_key AND w.turn = l.turn AND w.attempt = 0 AND w.wasted = 1 LIMIT 1), l.score) AS score
|
|
86
|
+
FROM ledger l
|
|
87
|
+
WHERE l.features IS NOT NULL AND l.wasted = 0 AND l.error IS NULL AND l.created_at_ms >= ?
|
|
88
|
+
ORDER BY l.created_at_ms ASC`,
|
|
89
|
+
)
|
|
90
|
+
.all(since) as TrainRow[]);
|
|
50
91
|
db.close();
|
|
51
|
-
|
|
52
|
-
|
|
92
|
+
const minRows = label === "feedback" ? FEEDBACK_MIN_ROWS : 200;
|
|
93
|
+
if (rows.length < minRows) {
|
|
94
|
+
console.error(
|
|
95
|
+
label === "feedback"
|
|
96
|
+
? `only ${rows.length} judged turns with features (need ${minRows}); keep using /router good|bad and try again later`
|
|
97
|
+
: `only ${rows.length} rows with features; need a few hundred to fit anything`,
|
|
98
|
+
);
|
|
53
99
|
process.exit(2);
|
|
54
100
|
}
|
|
55
101
|
|
|
56
102
|
const xs = rows.map((r) => learnedVector(JSON.parse(r.features) as Partial<Features>));
|
|
57
|
-
const ys: number[] = rows.map((r) => (r.attempt > 0 ? 1 : 0));
|
|
103
|
+
const ys: number[] = rows.map((r) => (label === "feedback" ? (r.verdict === "bad" ? 1 : 0) : r.attempt > 0 ? 1 : 0));
|
|
104
|
+
const positiveName = label === "feedback" ? "bad" : "escalate";
|
|
105
|
+
if (label === "feedback") {
|
|
106
|
+
const bad = ys.reduce((s, y) => s + y, 0);
|
|
107
|
+
if (bad === 0 || bad === ys.length) {
|
|
108
|
+
console.error(`all ${ys.length} verdicts are ${bad === 0 ? "good" : "bad"}; a label with one class cannot be learned`);
|
|
109
|
+
process.exit(2);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
58
112
|
const heuristic = rows.map((r) => r.score ?? 0);
|
|
59
113
|
const split = Math.floor(rows.length * 0.8);
|
|
60
114
|
const fit = trainLogistic(xs.slice(0, split), ys.slice(0, split));
|
|
61
115
|
const model: LearnedModel = {
|
|
62
116
|
version: LEARNED_MODEL_VERSION,
|
|
117
|
+
label,
|
|
63
118
|
trainedAtMs: Date.now(),
|
|
64
119
|
rows: rows.length,
|
|
65
120
|
positives: ys.reduce((s, y) => s + y, 0),
|
|
@@ -82,9 +137,9 @@ const testP = testX.map(score);
|
|
|
82
137
|
model.auc = auc(testP, testY);
|
|
83
138
|
const heuristicAuc = auc(heuristic.slice(split), testY);
|
|
84
139
|
|
|
85
|
-
console.log(`rows ${rows.length} (train ${split}, test ${rows.length - split}); positives ${model.positives} (${((100 * model.positives) / rows.length).toFixed(2)}%)`);
|
|
140
|
+
console.log(`label ${label}: rows ${rows.length} (train ${split}, test ${rows.length - split}); positives (${positiveName}) ${model.positives} (${((100 * model.positives) / rows.length).toFixed(2)}%)`);
|
|
86
141
|
console.log(`holdout AUC: learned ${model.auc.toFixed(3)} heuristic score ${heuristicAuc.toFixed(3)}`);
|
|
87
|
-
console.log(
|
|
142
|
+
console.log(`\nprecision / recall on the holdout at p(${positiveName}) thresholds:`);
|
|
88
143
|
for (const t of [0.5, 0.7, 0.8, 0.9]) {
|
|
89
144
|
let tp = 0;
|
|
90
145
|
let fp = 0;
|
|
@@ -99,7 +154,7 @@ for (const t of [0.5, 0.7, 0.8, 0.9]) {
|
|
|
99
154
|
const rec = tp + fn > 0 ? tp / (tp + fn) : 0;
|
|
100
155
|
console.log(` p ≥ ${t.toFixed(1)} flagged ${String(tp + fp).padStart(5)} precision ${(100 * prec).toFixed(1).padStart(5)}% recall ${(100 * rec).toFixed(1).padStart(5)}%`);
|
|
101
156
|
}
|
|
102
|
-
console.log(
|
|
157
|
+
console.log(`\nweights (standardised; positive ⇒ more likely ${positiveName}):`);
|
|
103
158
|
const ranked = model.names.map((n, i) => [n, model.weights[i]!] as const).sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]));
|
|
104
159
|
for (const [n, w] of ranked.slice(0, 12)) console.log(` ${n.padEnd(30)} ${w >= 0 ? "+" : ""}${w.toFixed(3)}`);
|
|
105
160
|
|
|
@@ -107,5 +162,5 @@ if (dryRun) {
|
|
|
107
162
|
console.log("\ndry run: nothing written");
|
|
108
163
|
} else {
|
|
109
164
|
await Bun.write(out, JSON.stringify(model, null, 1));
|
|
110
|
-
console.log(`\nwrote ${out}\nset classifier.learnedModelPath to it to record learned
|
|
165
|
+
console.log(`\nwrote ${out}\nset classifier.learnedModelPath to it to record learned: p(${positiveName}) on every decision (advisory).`);
|
|
111
166
|
}
|