lynkr 9.9.0 → 9.9.1
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 +42 -14
- package/bin/cli.js +11 -7
- package/bin/lynkr-init.js +30 -0
- package/package.json +3 -3
- package/scripts/build-eval-set.js +256 -0
- package/scripts/mine-difficulty-anchors.js +288 -0
- package/scripts/validate-difficulty-classifier.js +123 -0
- package/scripts/validate-intent-anchors.js +186 -0
- package/src/api/router.js +17 -0
- package/src/context/tool-result-compressor.js +883 -40
- package/src/routing/classifier-setup.js +207 -0
- package/src/routing/complexity-analyzer.js +40 -4
- package/src/routing/difficulty-classifier.js +219 -0
- package/src/routing/index.js +35 -4
- package/src/routing/intent-score.js +114 -12
- package/src/server.js +20 -0
|
@@ -47,14 +47,15 @@ const ANCHORS_PATHS = [
|
|
|
47
47
|
];
|
|
48
48
|
const VECTORS_CACHE_PATH = path.join(__dirname, '../../data/difficulty-anchors.vectors.json');
|
|
49
49
|
|
|
50
|
-
// Class → representative score. Chosen so each class lands inside
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
50
|
+
// Class → representative score. Chosen so each class lands inside a tier
|
|
51
|
+
// band and routing boundaries are tunable via tier edges (model-tiers.js).
|
|
52
|
+
// Config B (local → GLM → Claude): substantive=MEDIUM/ollama,
|
|
53
|
+
// heavyweight=COMPLEX/GLM, frontier=REASONING/Claude.
|
|
54
54
|
const CLASS_VALUES = {
|
|
55
55
|
trivial: 10,
|
|
56
56
|
substantive: 45,
|
|
57
57
|
heavyweight: 68,
|
|
58
|
+
frontier: 85,
|
|
58
59
|
};
|
|
59
60
|
|
|
60
61
|
// Softmax temperature over cosine sims. Real inter-class sim gaps on
|
|
@@ -77,8 +78,16 @@ const CLASS_BANDS = {
|
|
|
77
78
|
trivial: [0, 25],
|
|
78
79
|
substantive: [26, 50],
|
|
79
80
|
heavyweight: [51, 75],
|
|
81
|
+
frontier: [76, 100],
|
|
80
82
|
};
|
|
81
83
|
|
|
84
|
+
// Frontier class (REASONING tier) requires minimum similarity — a weak
|
|
85
|
+
// topical match can't jump to the expensive tier. Tuned on the validation
|
|
86
|
+
// set (scripts/validate-intent-anchors.js). Below this floor, frontier is
|
|
87
|
+
// excluded from the blend entirely and scoring behaves like the 3-class
|
|
88
|
+
// baseline. Hardcoded constant (no env var per user directive).
|
|
89
|
+
const FRONTIER_MIN_SIM = 0.50;
|
|
90
|
+
|
|
82
91
|
function intentScoreMode() {
|
|
83
92
|
const m = (process.env.LYNKR_INTENT_SCORE_MODE || 'anchor').toLowerCase();
|
|
84
93
|
return m === 'legacy' ? 'legacy' : 'anchor';
|
|
@@ -191,21 +200,32 @@ function classify(embedding, centroids) {
|
|
|
191
200
|
}
|
|
192
201
|
|
|
193
202
|
/**
|
|
194
|
-
* Softmax-blend class sims into a continuous score.
|
|
195
|
-
*
|
|
203
|
+
* Softmax-blend class sims into a continuous score. Frontier class requires
|
|
204
|
+
* minimum similarity (FRONTIER_MIN_SIM) to participate — below the floor,
|
|
205
|
+
* it's excluded from the blend and scoring behaves like the 3-class baseline.
|
|
206
|
+
* Lexical fallback path stays clamped ≤75 (see _lexicalCleanScore).
|
|
196
207
|
*/
|
|
197
208
|
function blendScore(sims) {
|
|
198
209
|
const classes = Object.keys(CLASS_VALUES);
|
|
199
|
-
|
|
210
|
+
// Frontier similarity floor: a weak topical match can't jump tiers.
|
|
211
|
+
const frontierSim = sims.frontier ?? -1;
|
|
212
|
+
const activeSims = { ...sims };
|
|
213
|
+
if (frontierSim < FRONTIER_MIN_SIM) {
|
|
214
|
+
delete activeSims.frontier; // excluded from blend
|
|
215
|
+
}
|
|
216
|
+
const activeClasses = Object.keys(CLASS_VALUES).filter(c => c in activeSims);
|
|
217
|
+
if (activeClasses.length === 0) return CLASS_VALUES.substantive; // degenerate
|
|
218
|
+
|
|
219
|
+
const max = Math.max(...activeClasses.map((c) => activeSims[c] ?? -1));
|
|
200
220
|
let totalW = 0;
|
|
201
221
|
let total = 0;
|
|
202
|
-
for (const cls of
|
|
203
|
-
const w = Math.exp(((
|
|
222
|
+
for (const cls of activeClasses) {
|
|
223
|
+
const w = Math.exp(((activeSims[cls] ?? -1) - max) / BLEND_TEMPERATURE);
|
|
204
224
|
totalW += w;
|
|
205
225
|
total += w * CLASS_VALUES[cls];
|
|
206
226
|
}
|
|
207
227
|
const score = totalW > 0 ? total / totalW : CLASS_VALUES.substantive;
|
|
208
|
-
return Math.round(Math.max(0, Math.min(
|
|
228
|
+
return Math.round(Math.max(0, Math.min(100, score)));
|
|
209
229
|
}
|
|
210
230
|
|
|
211
231
|
// --- default centroids (lazy singleton, disk-cached) ------------------------
|
|
@@ -294,6 +314,46 @@ function _lexicalCleanScore(text) {
|
|
|
294
314
|
* @returns {Promise<{score:number, mode:'anchor'|'lexical', class?:string, sims?:object, text:string}|null>}
|
|
295
315
|
* null → caller keeps its legacy score (legacy mode, or nothing to score)
|
|
296
316
|
*/
|
|
317
|
+
// Reconcile anchor's implied tier with the LLM classifier's tier.
|
|
318
|
+
// - Agreement → keep anchor score as-is.
|
|
319
|
+
// - Classifier lower than anchor → trust classifier (catches embedding
|
|
320
|
+
// false-positives like "list exports" scoring REASONING). Position
|
|
321
|
+
// score at midpoint of classifier's target band.
|
|
322
|
+
// - Classifier higher than anchor → safety-gate: require confidence≥0.8
|
|
323
|
+
// before trusting an escalation to a more expensive tier. Below that,
|
|
324
|
+
// keep the cheaper anchor decision.
|
|
325
|
+
function _reconcile(anchorScore, anchorClass, classifierResult) {
|
|
326
|
+
if (!classifierResult) return { score: anchorScore, reconciled: false };
|
|
327
|
+
if (classifierResult.confidence < 0.6) return { score: anchorScore, reconciled: false };
|
|
328
|
+
|
|
329
|
+
// Anchor class → implied tier (matches model-tiers.js band definitions).
|
|
330
|
+
const anchorTier = anchorScore <= 19 ? 'SIMPLE'
|
|
331
|
+
: anchorScore <= 50 ? 'MEDIUM'
|
|
332
|
+
: anchorScore <= 75 ? 'COMPLEX'
|
|
333
|
+
: 'REASONING';
|
|
334
|
+
const classifierTier = classifierResult.tier;
|
|
335
|
+
|
|
336
|
+
if (anchorTier === classifierTier) return { score: anchorScore, reconciled: false };
|
|
337
|
+
|
|
338
|
+
const TIER_ORDER = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
|
|
339
|
+
const anchorIdx = TIER_ORDER.indexOf(anchorTier);
|
|
340
|
+
const classifierIdx = TIER_ORDER.indexOf(classifierTier);
|
|
341
|
+
|
|
342
|
+
// Midpoints of each tier band (from model-tiers.js defaults):
|
|
343
|
+
// SIMPLE 0-19 → 10, MEDIUM 20-50 → 35, COMPLEX 51-75 → 63, REASONING 76-100 → 88
|
|
344
|
+
const TIER_MIDPOINT = { SIMPLE: 10, MEDIUM: 35, COMPLEX: 63, REASONING: 88 };
|
|
345
|
+
|
|
346
|
+
if (classifierIdx < anchorIdx) {
|
|
347
|
+
// Classifier says LOWER tier — trust it. Fixes over-routing.
|
|
348
|
+
return { score: TIER_MIDPOINT[classifierTier], reconciled: 'down' };
|
|
349
|
+
}
|
|
350
|
+
// Classifier says HIGHER tier — gate on confidence.
|
|
351
|
+
if (classifierResult.confidence >= 0.8) {
|
|
352
|
+
return { score: TIER_MIDPOINT[classifierTier], reconciled: 'up' };
|
|
353
|
+
}
|
|
354
|
+
return { score: anchorScore, reconciled: 'up_gated' };
|
|
355
|
+
}
|
|
356
|
+
|
|
297
357
|
async function scoreIntent(payload, opts = {}) {
|
|
298
358
|
const mode = opts.mode ?? intentScoreMode();
|
|
299
359
|
if (mode === 'legacy') return null;
|
|
@@ -314,8 +374,46 @@ async function scoreIntent(payload, opts = {}) {
|
|
|
314
374
|
if (Array.isArray(embedding) && embedding.length > 0) {
|
|
315
375
|
const { cls, sims } = classify(embedding, centroids);
|
|
316
376
|
const [lo, hi] = CLASS_BANDS[cls];
|
|
317
|
-
const
|
|
318
|
-
|
|
377
|
+
const anchorScore = Math.max(lo, Math.min(hi, blendScore(sims)));
|
|
378
|
+
|
|
379
|
+
// LLM classifier — second opinion. Skipped in tests (opts.skipClassifier)
|
|
380
|
+
// to keep unit tests hermetic. Runs live otherwise.
|
|
381
|
+
let classifierResult = null;
|
|
382
|
+
if (!opts.skipClassifier) {
|
|
383
|
+
try {
|
|
384
|
+
const { classifyDifficulty } = require('./difficulty-classifier');
|
|
385
|
+
classifierResult = await classifyDifficulty(text, {
|
|
386
|
+
forceMatched: opts.forceMatched,
|
|
387
|
+
riskLevel: opts.riskLevel,
|
|
388
|
+
});
|
|
389
|
+
} catch (err) {
|
|
390
|
+
logger.debug({ err: err.message }, '[IntentScore] classifier failed — anchor only');
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const { score, reconciled } = _reconcile(anchorScore, cls, classifierResult);
|
|
395
|
+
if (reconciled) {
|
|
396
|
+
logger.debug({
|
|
397
|
+
text: text.slice(0, 80),
|
|
398
|
+
anchorScore,
|
|
399
|
+
anchorClass: cls,
|
|
400
|
+
classifierTier: classifierResult?.tier,
|
|
401
|
+
classifierConfidence: classifierResult?.confidence,
|
|
402
|
+
reconciled,
|
|
403
|
+
finalScore: score,
|
|
404
|
+
}, '[IntentScore] classifier reconciled anchor score');
|
|
405
|
+
}
|
|
406
|
+
return {
|
|
407
|
+
score,
|
|
408
|
+
mode: reconciled ? 'anchor+classifier' : 'anchor',
|
|
409
|
+
class: cls,
|
|
410
|
+
sims,
|
|
411
|
+
text,
|
|
412
|
+
anchorScore,
|
|
413
|
+
classifierTier: classifierResult?.tier ?? null,
|
|
414
|
+
classifierConfidence: classifierResult?.confidence ?? null,
|
|
415
|
+
reconciled,
|
|
416
|
+
};
|
|
319
417
|
}
|
|
320
418
|
}
|
|
321
419
|
} catch (err) {
|
|
@@ -327,6 +425,8 @@ async function scoreIntent(payload, opts = {}) {
|
|
|
327
425
|
|
|
328
426
|
module.exports = {
|
|
329
427
|
CLASS_VALUES,
|
|
428
|
+
CLASS_BANDS,
|
|
429
|
+
FRONTIER_MIN_SIM,
|
|
330
430
|
intentScoreMode,
|
|
331
431
|
extractCleanUserText,
|
|
332
432
|
cosine,
|
|
@@ -336,4 +436,6 @@ module.exports = {
|
|
|
336
436
|
scoreIntent,
|
|
337
437
|
// exposed for the replay script
|
|
338
438
|
getDefaultCentroids,
|
|
439
|
+
// exposed for tests
|
|
440
|
+
_reconcile,
|
|
339
441
|
};
|
package/src/server.js
CHANGED
|
@@ -246,6 +246,26 @@ async function start() {
|
|
|
246
246
|
console.log(`Claude→Databricks proxy listening on http://localhost:${config.port}`);
|
|
247
247
|
});
|
|
248
248
|
|
|
249
|
+
// Classifier bootstrap check — non-blocking, log-only.
|
|
250
|
+
// Detects ollama + confirms the classifier model is pulled. Never auto-
|
|
251
|
+
// installs (that's `lynkr init`'s job); warns and lets scoring fall back
|
|
252
|
+
// to anchor-only if either is missing.
|
|
253
|
+
(async () => {
|
|
254
|
+
try {
|
|
255
|
+
const { ensureClassifierReady } = require('./routing/classifier-setup');
|
|
256
|
+
const result = await ensureClassifierReady({
|
|
257
|
+
mode: 'boot',
|
|
258
|
+
log: (m) => logger.info(m),
|
|
259
|
+
warn: (m) => logger.warn(m),
|
|
260
|
+
});
|
|
261
|
+
if (result.ready) {
|
|
262
|
+
logger.info({ classifier: 'ready', warmed: result.warmed }, '[classifier-setup] Difficulty classifier ready');
|
|
263
|
+
}
|
|
264
|
+
} catch (err) {
|
|
265
|
+
logger.debug({ err: err.message }, '[classifier-setup] bootstrap check failed (classifier will fall back)');
|
|
266
|
+
}
|
|
267
|
+
})();
|
|
268
|
+
|
|
249
269
|
// Start session cleanup manager. It also drives routing-side maintenance
|
|
250
270
|
// (telemetry retention + session-pin TTL) via its runCleanup tick — see
|
|
251
271
|
// src/sessions/cleanup.js.
|