@kienntpixon/dieu-phoi 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +101 -2
- package/bin/dieu-phoi.cjs +82 -0
- package/hook.cjs +834 -0
- package/lib/aggregate.cjs +337 -0
- package/lib/args.cjs +55 -0
- package/lib/choose.cjs +466 -0
- package/lib/commands.cjs +626 -0
- package/lib/setup.cjs +266 -0
- package/package.json +34 -4
- package/pricing.json +25 -0
package/lib/choose.cjs
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const core = require('../hook.cjs');
|
|
7
|
+
const agg = require('./aggregate.cjs');
|
|
8
|
+
|
|
9
|
+
// Tier ladder, cheapest first. Ceiling = opus/high (core safety rule).
|
|
10
|
+
const LADDER = [
|
|
11
|
+
{ model: 'haiku', effort: 'low' },
|
|
12
|
+
{ model: 'haiku', effort: 'medium' },
|
|
13
|
+
{ model: 'haiku', effort: 'high' },
|
|
14
|
+
{ model: 'sonnet', effort: 'medium' },
|
|
15
|
+
{ model: 'sonnet', effort: 'high' },
|
|
16
|
+
{ model: 'opus', effort: 'medium' },
|
|
17
|
+
{ model: 'opus', effort: 'high' },
|
|
18
|
+
];
|
|
19
|
+
const TOP = LADDER.length - 1;
|
|
20
|
+
const EFFORT_RANK = { low: 0, medium: 1, high: 2, xhigh: 3, max: 4 };
|
|
21
|
+
|
|
22
|
+
// Built-in defaults = skill/profile/profile.json (v1.0.0); used only when no profile is found.
|
|
23
|
+
const DEFAULT_PRIORS = {
|
|
24
|
+
search: 'haiku/medium',
|
|
25
|
+
extract: 'haiku/medium',
|
|
26
|
+
'mechanical-edit': 'sonnet/medium',
|
|
27
|
+
'feature-impl': 'opus/medium',
|
|
28
|
+
debug: 'opus/medium',
|
|
29
|
+
refactor: 'opus/medium',
|
|
30
|
+
'test-write': 'sonnet/high',
|
|
31
|
+
review: 'opus/medium',
|
|
32
|
+
'research-synth': 'opus/medium',
|
|
33
|
+
'content-write': 'sonnet/high',
|
|
34
|
+
'plan-design': 'opus/high',
|
|
35
|
+
'data-analysis': 'sonnet/high',
|
|
36
|
+
};
|
|
37
|
+
const DEFAULT_TIME_CAP_MIN = {
|
|
38
|
+
search: 3,
|
|
39
|
+
extract: 5,
|
|
40
|
+
'mechanical-edit': 10,
|
|
41
|
+
'feature-impl': 25,
|
|
42
|
+
debug: 25,
|
|
43
|
+
refactor: 30,
|
|
44
|
+
'test-write': 15,
|
|
45
|
+
review: 10,
|
|
46
|
+
'research-synth': 20,
|
|
47
|
+
'content-write': 15,
|
|
48
|
+
'plan-design': 15,
|
|
49
|
+
'data-analysis': 15,
|
|
50
|
+
};
|
|
51
|
+
const DEFAULT_LAMBDA = { relaxed: 0.02, normal: 0.1, urgent: 1.0 };
|
|
52
|
+
const DEFAULT_EXPLORE = 0.1;
|
|
53
|
+
|
|
54
|
+
function tierName(i) {
|
|
55
|
+
return `${LADDER[i].model}/${LADDER[i].effort}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function shortModel(m) {
|
|
59
|
+
const id = core.normalizeModel(m);
|
|
60
|
+
if (!id) return null;
|
|
61
|
+
for (const k of ['opus', 'sonnet', 'haiku', 'fable']) if (id.includes(k)) return k;
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Map any (model, effort) onto the ladder, clamped to the ceiling. Fable -> opus. */
|
|
66
|
+
function tierIndex(model, effort) {
|
|
67
|
+
let m = shortModel(model);
|
|
68
|
+
if (!m) return null;
|
|
69
|
+
if (m === 'fable') m = 'opus';
|
|
70
|
+
const e = EFFORT_RANK[String(effort || 'medium').toLowerCase()];
|
|
71
|
+
const er = e === undefined ? 1 : e;
|
|
72
|
+
const same = LADDER.map((t, i) => ({ t, i })).filter((x) => x.t.model === m);
|
|
73
|
+
let best = same[0];
|
|
74
|
+
for (const x of same) if (EFFORT_RANK[x.t.effort] <= er) best = x;
|
|
75
|
+
if (er < EFFORT_RANK[same[0].t.effort]) best = same[0];
|
|
76
|
+
return best.i;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parsePrior(p) {
|
|
80
|
+
if (!p) return null;
|
|
81
|
+
if (typeof p === 'string') {
|
|
82
|
+
const [m, e] = p.split('/');
|
|
83
|
+
return tierIndex(m, e);
|
|
84
|
+
}
|
|
85
|
+
if (typeof p === 'object') return tierIndex(p.model, p.effort);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Load profile.json from <skillDir>/profile, merged over built-in defaults. */
|
|
90
|
+
function loadProfile(skillDir) {
|
|
91
|
+
let raw = null;
|
|
92
|
+
let source = 'builtin';
|
|
93
|
+
if (skillDir) {
|
|
94
|
+
const f = path.join(skillDir, 'profile', 'profile.json');
|
|
95
|
+
if (fs.existsSync(f)) {
|
|
96
|
+
raw = core.readJsonFile(f, null);
|
|
97
|
+
if (raw) source = f.split(path.sep).join('/');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const r = raw && typeof raw === 'object' ? raw : {};
|
|
101
|
+
const priors = {};
|
|
102
|
+
for (const [k, v] of Object.entries(DEFAULT_PRIORS)) priors[k] = parsePrior(v);
|
|
103
|
+
if (r.priors && typeof r.priors === 'object') {
|
|
104
|
+
for (const [k, v] of Object.entries(r.priors)) {
|
|
105
|
+
const idx = parsePrior(v);
|
|
106
|
+
if (idx !== null) priors[k] = idx;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const lambda = Object.assign({}, DEFAULT_LAMBDA);
|
|
110
|
+
if (r.lambda_usd_per_min && typeof r.lambda_usd_per_min === 'object') {
|
|
111
|
+
for (const u of core.URGENCIES) {
|
|
112
|
+
const v = Number(r.lambda_usd_per_min[u]);
|
|
113
|
+
if (isFinite(v) && v >= 0) lambda[u] = v;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
let explore = DEFAULT_EXPLORE;
|
|
117
|
+
if (r.explore_rate !== undefined) {
|
|
118
|
+
const v = Number(r.explore_rate);
|
|
119
|
+
if (isFinite(v)) explore = Math.min(1, Math.max(0, v));
|
|
120
|
+
}
|
|
121
|
+
const timeCap = Object.assign({}, DEFAULT_TIME_CAP_MIN,
|
|
122
|
+
r.time_cap_min && typeof r.time_cap_min === 'object' ? r.time_cap_min : {});
|
|
123
|
+
const extra = Array.isArray(r.extra_task_types) ? r.extra_task_types.map(String) : [];
|
|
124
|
+
return { source, dept: r.dept || null, priors, lambda, explore_rate: explore, time_cap_min: timeCap, extra_task_types: extra };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ------------------------------------------------------------------ seeded RNG
|
|
128
|
+
|
|
129
|
+
function makeRng(seed) {
|
|
130
|
+
let a;
|
|
131
|
+
if (seed === undefined || seed === null || seed === true) a = crypto.randomBytes(4).readUInt32LE(0);
|
|
132
|
+
else a = crypto.createHash('sha256').update(String(seed)).digest().readUInt32LE(0);
|
|
133
|
+
return function mulberry32() {
|
|
134
|
+
a = (a + 0x6D2B79F5) >>> 0;
|
|
135
|
+
let t = a;
|
|
136
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
137
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
138
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function normal(rng) {
|
|
143
|
+
let u = 0;
|
|
144
|
+
while (u === 0) u = rng();
|
|
145
|
+
const v = rng();
|
|
146
|
+
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function gamma(rng, k) {
|
|
150
|
+
if (k < 1) return gamma(rng, k + 1) * rng() ** (1 / k);
|
|
151
|
+
const d = k - 1 / 3;
|
|
152
|
+
const c = 1 / Math.sqrt(9 * d);
|
|
153
|
+
for (;;) {
|
|
154
|
+
let x;
|
|
155
|
+
let v;
|
|
156
|
+
do { x = normal(rng); v = 1 + c * x; } while (v <= 0);
|
|
157
|
+
v = v * v * v;
|
|
158
|
+
const u = rng();
|
|
159
|
+
if (u < 1 - 0.0331 * x ** 4) return d * v;
|
|
160
|
+
if (Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v))) return d * v;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function betaSample(rng, a, b) {
|
|
165
|
+
const x = gamma(rng, a);
|
|
166
|
+
const y = gamma(rng, b);
|
|
167
|
+
return x / (x + y);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ------------------------------------------------------------------ escalation
|
|
171
|
+
|
|
172
|
+
const OPUS_MEDIUM = 5;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Escalation within one task_id (CONTRACT "Luật an toàn"):
|
|
176
|
+
* - one fail at a tier -> same tier again (retry with the error);
|
|
177
|
+
* - two fails at the same tier -> next tier up;
|
|
178
|
+
* - if that next tier would exceed opus/medium for role=exec -> ask the user (no auto re-plan).
|
|
179
|
+
* Fail = agent fail|timeout or user reject cause=quality (eff_outcome). Attempts steered
|
|
180
|
+
* plan/redirect by the user are ignored.
|
|
181
|
+
*/
|
|
182
|
+
function escalation(task, role) {
|
|
183
|
+
const none = { floor: 0, ask: false, note: null, fails: 0, tried: [] };
|
|
184
|
+
if (!task) return none;
|
|
185
|
+
const att = task.attempts
|
|
186
|
+
.filter((a) => a.eff_outcome && a.role !== 'verify' && !a.steered)
|
|
187
|
+
.map((a) => ({ a, i: tierIndex(a.model, a.effort) }))
|
|
188
|
+
.filter((x) => x.i !== null)
|
|
189
|
+
.sort((x, y) => (x.a.try - y.a.try) || (x.a.ts - y.a.ts));
|
|
190
|
+
if (!att.length) return none;
|
|
191
|
+
const tried = Array.from(new Set(att.map((x) => tierName(x.i))));
|
|
192
|
+
const totalFails = att.filter((x) => x.a.eff_outcome === 'fail').length;
|
|
193
|
+
const last = att[att.length - 1];
|
|
194
|
+
const cur = last.i;
|
|
195
|
+
const base = { tried, fails: totalFails, ask: false };
|
|
196
|
+
if (last.a.eff_outcome !== 'fail') return Object.assign(base, { floor: cur, note: null });
|
|
197
|
+
const fails = att.filter((x) => x.i === cur && x.a.eff_outcome === 'fail').length;
|
|
198
|
+
if (fails < 2) {
|
|
199
|
+
return Object.assign(base, { floor: cur, note: `task fail 1 lần ở ${tierName(cur)} → làm lại cùng tier kèm lỗi` });
|
|
200
|
+
}
|
|
201
|
+
const next = cur + 1;
|
|
202
|
+
if (((role || 'exec') === 'exec' && next > OPUS_MEDIUM) || next > TOP) {
|
|
203
|
+
return Object.assign(base, {
|
|
204
|
+
floor: Math.min(TOP, next), ask: true,
|
|
205
|
+
note: `task đã fail ${totalFails} lần (đã thử ${tried.join(', ')}); tier kế vượt opus/medium — hỏi người dùng: làm rõ yêu cầu, đổi kế hoạch hay chạy opus/high`,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return Object.assign(base, { floor: next, note: `task fail ${fails} lần ở ${tierName(cur)} → lên ${tierName(next)}` });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ------------------------------------------------------------------ override learning
|
|
212
|
+
|
|
213
|
+
function parseTier(s) {
|
|
214
|
+
if (typeof s !== 'string') return null;
|
|
215
|
+
const [m, e] = s.trim().toLowerCase().split('/');
|
|
216
|
+
const i = LADDER.findIndex((t) => t.model === shortModel(m) && t.effort === e);
|
|
217
|
+
return i >= 0 ? i : null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Prior learned from the user's overrides for a task type: the last >=3 overrides all moved in
|
|
222
|
+
* the same direction and >=3 of the last 5 chose the same tier -> that tier.
|
|
223
|
+
* At most one override per task_id counts (its latest); overrides equal to the suggestion are ignored.
|
|
224
|
+
*/
|
|
225
|
+
function overridePrior(overrides, type) {
|
|
226
|
+
const perTask = new Map();
|
|
227
|
+
(overrides || [])
|
|
228
|
+
.filter((o) => o.task_type === type)
|
|
229
|
+
.map((o) => ({ s: parseTier(o.suggested_tier), c: parseTier(o.chosen_tier), t: agg.tsOf(o), i: o._idx || 0, task: o.task_id }))
|
|
230
|
+
.filter((o) => o.s !== null && o.c !== null && o.s !== o.c)
|
|
231
|
+
.sort((a, b) => a.t - b.t || a.i - b.i)
|
|
232
|
+
.forEach((o, k) => perTask.set(o.task ? `t:${o.task}` : `i:${k}`, o));
|
|
233
|
+
const list = Array.from(perTask.values()).sort((a, b) => a.t - b.t || a.i - b.i);
|
|
234
|
+
if (list.length < 3) return null;
|
|
235
|
+
const last3 = list.slice(-3).map((o) => Math.sign(o.c - o.s));
|
|
236
|
+
if (last3[0] === 0 || !last3.every((d) => d === last3[0])) return null;
|
|
237
|
+
const counts = new Map();
|
|
238
|
+
for (const o of list.slice(-5)) counts.set(o.c, (counts.get(o.c) || 0) + 1);
|
|
239
|
+
let best = null;
|
|
240
|
+
for (const [tier, n] of counts) if (!best || n > best.n || (n === best.n && tier > best.tier)) best = { tier, n };
|
|
241
|
+
if (!best || best.n < 3) return null;
|
|
242
|
+
return { tier: best.tier, n: best.n, direction: last3[0] > 0 ? 'lên' : 'xuống' };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ------------------------------------------------------------------ rough estimates
|
|
246
|
+
|
|
247
|
+
// Rough per-attempt token assumption by tier when a cell has n<5 (documented in README):
|
|
248
|
+
// uncached input incl. ~44k system prompt + context, output incl. thinking. Minutes are guesses.
|
|
249
|
+
const ROUGH = {
|
|
250
|
+
'haiku/low': { input: 50000, output: 2000, minutes: 1 },
|
|
251
|
+
'haiku/medium': { input: 50000, output: 4000, minutes: 2 },
|
|
252
|
+
'haiku/high': { input: 55000, output: 6000, minutes: 3 },
|
|
253
|
+
'sonnet/medium': { input: 55000, output: 6000, minutes: 4 },
|
|
254
|
+
'sonnet/high': { input: 60000, output: 8000, minutes: 6 },
|
|
255
|
+
'opus/medium': { input: 60000, output: 8000, minutes: 8 },
|
|
256
|
+
'opus/high': { input: 80000, output: 12000, minutes: 12 },
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
function roughEstimate(idx, pricing) {
|
|
260
|
+
const name = tierName(idx);
|
|
261
|
+
const r = ROUGH[name];
|
|
262
|
+
const p = pricing || core.loadPricing(null);
|
|
263
|
+
const c = core.requestCost(p, core.MODEL_ALIASES[LADDER[idx].model], {
|
|
264
|
+
input: r.input, output: r.output, thinking: 0, cache_write_5m: 0, cache_write_1h: 0, cache_read: 0,
|
|
265
|
+
});
|
|
266
|
+
return { est_cost_usd: c === null ? null : Math.round(c * 1000) / 1000, est_time_min: r.minutes, est_source: 'rough' };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function estimateFor(idx, cell, pricing) {
|
|
270
|
+
if (cell && cell.enough && cell.attempt_p50_min !== null) {
|
|
271
|
+
return {
|
|
272
|
+
est_cost_usd: cell.attempt_cost_p50 === null ? null : Math.round(cell.attempt_cost_p50 * 1000) / 1000,
|
|
273
|
+
est_time_min: Math.round(cell.attempt_p50_min * 10) / 10,
|
|
274
|
+
est_source: 'data',
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
return roughEstimate(idx, pricing);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ------------------------------------------------------------------ choose
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* opts: { type, score, urgency, reversible, verifiable, role, seed, events, profile, taskId, override, pricing }
|
|
284
|
+
* Returns { action, model, model_id, effort, reason, explored, tier, budget_s, est_cost_usd,
|
|
285
|
+
* est_time_min, est_source, profile_source, suggested_tier?, overridden? , fails?, tried? }.
|
|
286
|
+
* Throws when an override goes below a safety floor or off the ladder.
|
|
287
|
+
*/
|
|
288
|
+
function choose(opts) {
|
|
289
|
+
const type = String(opts.type || '').toLowerCase();
|
|
290
|
+
if (!type) throw new Error('Thiếu --type');
|
|
291
|
+
const score = Number(opts.score);
|
|
292
|
+
if (opts.score === undefined || opts.score === null || opts.score === true || !isFinite(score)
|
|
293
|
+
|| score < 0 || score > 12) throw new Error('--score phải trong 0–12');
|
|
294
|
+
const urgency = opts.urgency || 'normal';
|
|
295
|
+
if (!core.URGENCIES.includes(urgency)) throw new Error(`--urgency phải là ${core.URGENCIES.join('|')}`);
|
|
296
|
+
const role = opts.role || 'exec';
|
|
297
|
+
if (!core.ROLES.includes(role)) throw new Error(`--role phải là ${core.ROLES.join('|')}`);
|
|
298
|
+
const ladderList = LADDER.map((t) => `${t.model}/${t.effort}`).join(', ');
|
|
299
|
+
let overrideIdx = null;
|
|
300
|
+
if (opts.override !== undefined && opts.override !== null) {
|
|
301
|
+
overrideIdx = parseTier(String(opts.override));
|
|
302
|
+
if (overrideIdx === null) {
|
|
303
|
+
throw new Error(`--override "${opts.override}" không nằm trên thang tier (trần opus/high, không Fable): ${ladderList}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
let approvedIdx = null;
|
|
307
|
+
if (opts.approved !== undefined && opts.approved !== null) {
|
|
308
|
+
approvedIdx = parseTier(String(opts.approved));
|
|
309
|
+
if (approvedIdx === null) throw new Error(`--approved "${opts.approved}" không nằm trên thang tier: ${ladderList}`);
|
|
310
|
+
if (overrideIdx !== null) throw new Error('Dùng --override hoặc --approved, không dùng cả hai');
|
|
311
|
+
}
|
|
312
|
+
const reversible = opts.reversible !== false;
|
|
313
|
+
const verifiable = opts.verifiable !== false;
|
|
314
|
+
const profile = opts.profile || loadProfile(null);
|
|
315
|
+
const lambda = profile.lambda[urgency];
|
|
316
|
+
const rng = makeRng(opts.seed);
|
|
317
|
+
const notes = [];
|
|
318
|
+
|
|
319
|
+
const analysis = agg.analyze(opts.events || [], { lambda });
|
|
320
|
+
const known = core.TASK_TYPES.includes(type) || profile.extra_task_types.includes(type);
|
|
321
|
+
let prior = profile.priors[type];
|
|
322
|
+
let priorWhy = 'prior';
|
|
323
|
+
if (prior === undefined || prior === null) {
|
|
324
|
+
prior = OPUS_MEDIUM;
|
|
325
|
+
notes.push(known ? 'không có prior, dùng opus/medium' : `loại "${type}" lạ, dùng opus/medium`);
|
|
326
|
+
}
|
|
327
|
+
const ov = overridePrior(analysis.overrides, type);
|
|
328
|
+
if (ov && ov.tier !== prior) {
|
|
329
|
+
notes.push(`prior ${tierName(prior)} → ${tierName(ov.tier)} theo override của người dùng (${ov.n}/5 lần gần nhất, cùng hướng ${ov.direction})`);
|
|
330
|
+
prior = ov.tier;
|
|
331
|
+
priorWhy = 'prior theo override của người dùng';
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Safety floors (core only; a profile can never lower them).
|
|
335
|
+
let safetyFloor = 0;
|
|
336
|
+
const floorWhy = [];
|
|
337
|
+
if (score >= 9) { safetyFloor = Math.max(safetyFloor, TOP); floorWhy.push(`score ${score}≥9 → tối thiểu opus/high`); }
|
|
338
|
+
if (!verifiable) { safetyFloor = Math.max(safetyFloor, OPUS_MEDIUM); floorWhy.push('không kiểm chứng bằng máy → tối thiểu opus/medium'); }
|
|
339
|
+
if (!reversible && role === 'exec') { safetyFloor = Math.max(safetyFloor, OPUS_MEDIUM); floorWhy.push('khó hoàn tác → tối thiểu opus/medium'); }
|
|
340
|
+
|
|
341
|
+
const noExploreWhy = [];
|
|
342
|
+
if (score >= 9) noExploreWhy.push(`score ${score}≥9`);
|
|
343
|
+
if (!verifiable) noExploreWhy.push('không kiểm chứng bằng máy');
|
|
344
|
+
if (!reversible) noExploreWhy.push('khó hoàn tác');
|
|
345
|
+
if (urgency === 'urgent') noExploreWhy.push('urgent');
|
|
346
|
+
if (overrideIdx !== null) noExploreWhy.push('người dùng chọn tier');
|
|
347
|
+
const noExplore = noExploreWhy.length > 0;
|
|
348
|
+
|
|
349
|
+
const task = opts.taskId ? analysis.tasks.find((t) => t.task_id === opts.taskId) : null;
|
|
350
|
+
if (opts.taskId && !task) notes.push(`chưa thấy task ${opts.taskId} trong log`);
|
|
351
|
+
const esc = escalation(task, role);
|
|
352
|
+
|
|
353
|
+
const capMin = Number(profile.time_cap_min[type]);
|
|
354
|
+
const budgetFor = (cell) => {
|
|
355
|
+
if (cell && cell.enough && cell.attempt_p50_min !== null) return Math.round(cell.attempt_p50_min * 2 * 60);
|
|
356
|
+
return isFinite(capMin) && capMin > 0 ? Math.round(capMin * 60) : null;
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const cellAt = (i) => analysis.cells.find((c) => c.task_type === type
|
|
360
|
+
&& shortModel(c.model) === LADDER[i].model && c.effort === LADDER[i].effort) || null;
|
|
361
|
+
|
|
362
|
+
const src = profile.source || 'builtin';
|
|
363
|
+
const finish = (idx, reason, explored, extra) => {
|
|
364
|
+
const t = LADDER[idx];
|
|
365
|
+
const cell = cellAt(idx);
|
|
366
|
+
return Object.assign({
|
|
367
|
+
action: 'dispatch',
|
|
368
|
+
model: t.model,
|
|
369
|
+
model_id: core.MODEL_ALIASES[t.model],
|
|
370
|
+
effort: t.effort,
|
|
371
|
+
reason: `${reason}; profile: ${src}`,
|
|
372
|
+
explored,
|
|
373
|
+
tier: tierName(idx),
|
|
374
|
+
role,
|
|
375
|
+
budget_s: budgetFor(cell),
|
|
376
|
+
}, estimateFor(idx, cell, opts.pricing), { profile_source: src }, extra || {});
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
if (overrideIdx !== null && overrideIdx < safetyFloor) {
|
|
380
|
+
throw new Error(`Từ chối override ${tierName(overrideIdx)}: dưới sàn an toàn ${tierName(safetyFloor)} (${floorWhy.join(', ')}).`);
|
|
381
|
+
}
|
|
382
|
+
// Escalation beats override/approval: the user must decide first.
|
|
383
|
+
if (esc.ask) {
|
|
384
|
+
const userIdx = overrideIdx !== null ? overrideIdx : (approvedIdx !== null ? approvedIdx : 0);
|
|
385
|
+
const idx = Math.max(userIdx, esc.floor, safetyFloor);
|
|
386
|
+
return finish(idx, esc.note, false, { action: 'ask_user', fails: esc.fails, tried: esc.tried });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Tier approved by the user in the plan table: no Thompson/exploration; only floors can raise it.
|
|
390
|
+
if (approvedIdx !== null) {
|
|
391
|
+
const forced = Math.max(safetyFloor, esc.floor);
|
|
392
|
+
if (approvedIdx >= forced) {
|
|
393
|
+
const n = esc.note ? `; ${esc.note}` : '';
|
|
394
|
+
return finish(approvedIdx, `người dùng đã duyệt ${tierName(approvedIdx)} trong bảng phân công${n}`, false, { deviation: false });
|
|
395
|
+
}
|
|
396
|
+
const why = [];
|
|
397
|
+
if (safetyFloor > approvedIdx) why.push(`sàn an toàn ${tierName(safetyFloor)} (${floorWhy.join(', ')})`);
|
|
398
|
+
if (esc.floor > approvedIdx) why.push(esc.note || `leo thang → ${tierName(esc.floor)}`);
|
|
399
|
+
return finish(forced, `lệch khỏi tier đã duyệt ${tierName(approvedIdx)} → ${tierName(forced)}: ${why.join('; ')}`, false, { deviation: true, approved_tier: tierName(approvedIdx) });
|
|
400
|
+
}
|
|
401
|
+
if (esc.note && !esc.ask) notes.push(esc.note);
|
|
402
|
+
|
|
403
|
+
const floor = Math.max(safetyFloor, esc.ask ? 0 : esc.floor);
|
|
404
|
+
const base = Math.min(TOP, Math.max(prior, floor));
|
|
405
|
+
if (base > prior) {
|
|
406
|
+
const why = floorWhy.slice();
|
|
407
|
+
if (esc.floor > prior && esc.floor >= safetyFloor && !esc.ask) why.push(`leo thang → ${tierName(esc.floor)}`);
|
|
408
|
+
notes.unshift(`sàn ${tierName(base)} cao hơn ${priorWhy} ${tierName(prior)}: ${why.join(', ') || 'luật an toàn'}`);
|
|
409
|
+
} else if (floorWhy.length) {
|
|
410
|
+
notes.push(`sàn an toàn: ${floorWhy.join(', ')}`);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const minIdx = noExplore ? base : floor;
|
|
414
|
+
const candidates = [];
|
|
415
|
+
for (let i = minIdx; i <= TOP; i++) {
|
|
416
|
+
const c = cellAt(i);
|
|
417
|
+
if (i === base || (c && c.enough)) candidates.push({ i, cell: c });
|
|
418
|
+
}
|
|
419
|
+
// Tiers whose p75 time breaks the type's time cap are dropped (if anything else remains).
|
|
420
|
+
const capped = candidates.filter((x) => !(isFinite(capMin) && capMin > 0 && x.cell && x.cell.enough
|
|
421
|
+
&& x.cell.attempt_p75_min !== null && x.cell.attempt_p75_min > capMin));
|
|
422
|
+
const pool = capped.length ? capped : candidates;
|
|
423
|
+
if (capped.length < candidates.length) notes.push('loại tier có p75 vượt trần thời gian');
|
|
424
|
+
|
|
425
|
+
let chosen = base;
|
|
426
|
+
let why;
|
|
427
|
+
const baseCell = cellAt(base);
|
|
428
|
+
const scored = pool.filter((x) => x.cell && x.cell.enough);
|
|
429
|
+
if (!baseCell || !baseCell.enough || !scored.length) {
|
|
430
|
+
why = `${priorWhy} ${tierName(base)} (${baseCell ? `n=${baseCell.n}` : 'n=0'}, chưa đủ dữ liệu)`;
|
|
431
|
+
} else {
|
|
432
|
+
let best = null;
|
|
433
|
+
for (const x of scored) {
|
|
434
|
+
const p = betaSample(rng, 1 + x.cell.passes, 1 + x.cell.fails);
|
|
435
|
+
const cost = x.cell.attempt_cost_usd || 0;
|
|
436
|
+
const time = urgency === 'urgent' && x.cell.attempt_p75_min !== null
|
|
437
|
+
? x.cell.attempt_p75_min : (x.cell.attempt_time_min || 0);
|
|
438
|
+
const s = cost / p + lambda * time / p;
|
|
439
|
+
if (!best || s < best.s) best = { i: x.i, s, p };
|
|
440
|
+
}
|
|
441
|
+
chosen = best.i;
|
|
442
|
+
why = `Thompson: ${tierName(chosen)} điểm ${best.s.toFixed(3)} (p≈${best.p.toFixed(2)}, ${scored.length} ô đủ dữ liệu)`;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
let explored = false;
|
|
446
|
+
const u = rng();
|
|
447
|
+
if (!noExplore && chosen - 1 >= floor && u < profile.explore_rate) {
|
|
448
|
+
chosen -= 1;
|
|
449
|
+
explored = true;
|
|
450
|
+
why = `thăm dò xuống ${tierName(chosen)} (xác suất ${profile.explore_rate}); ${why}`;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (overrideIdx !== null) {
|
|
454
|
+
const sugg = tierName(chosen);
|
|
455
|
+
return finish(overrideIdx, `người dùng chọn ${tierName(overrideIdx)} (đề xuất là ${sugg}: ${why})`, false, {
|
|
456
|
+
overridden: overrideIdx !== chosen, suggested_tier: sugg,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
if (noExplore) notes.push(`không thăm dò: ${noExploreWhy.join(', ')}`);
|
|
460
|
+
return finish(chosen, [why].concat(notes).join('; '), explored);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
module.exports = {
|
|
464
|
+
LADDER, DEFAULT_PRIORS, DEFAULT_LAMBDA, DEFAULT_TIME_CAP_MIN, ROUGH, tierIndex, tierName, parsePrior,
|
|
465
|
+
parseTier, loadProfile, makeRng, betaSample, escalation, overridePrior, roughEstimate, choose, shortModel,
|
|
466
|
+
};
|