lynkr 9.14.13 → 9.14.15
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/bin/lynkr-init.js +7 -0
- package/package.json +2 -2
- package/scripts/harvest-followup-eval.js +87 -0
- package/src/api/router.js +299 -38
- package/src/clients/databricks.js +8 -0
- package/src/routing/affinity-store.js +136 -1
- package/src/routing/index.js +71 -7
- package/src/routing/intent-score.js +85 -71
- package/src/routing/jev-router.js +128 -25
- package/src/routing/task-ledger.js +443 -0
- package/src/routing/telemetry.js +44 -2
|
@@ -77,6 +77,14 @@ function _db() {
|
|
|
77
77
|
ttl_ms INTEGER NOT NULL
|
|
78
78
|
);
|
|
79
79
|
CREATE INDEX IF NOT EXISTS idx_cache_state_ts ON session_cache_state(last_request_at);
|
|
80
|
+
CREATE TABLE IF NOT EXISTS session_task_state (
|
|
81
|
+
session_id TEXT PRIMARY KEY,
|
|
82
|
+
anchor_hash TEXT,
|
|
83
|
+
effective_band INTEGER,
|
|
84
|
+
open INTEGER,
|
|
85
|
+
low_streak INTEGER,
|
|
86
|
+
updated_at INTEGER
|
|
87
|
+
);
|
|
80
88
|
`);
|
|
81
89
|
schemaEnsured = true;
|
|
82
90
|
} catch (err) {
|
|
@@ -253,6 +261,122 @@ function loadCacheState(sessionId) {
|
|
|
253
261
|
}
|
|
254
262
|
}
|
|
255
263
|
|
|
264
|
+
// TaskBand — per-session task difficulty state. Lives in its OWN table for
|
|
265
|
+
// the same reason as session_cache_state: piggybacking non-pin rows onto
|
|
266
|
+
// session_pins let tier-less rows be served as routing pins (see note above
|
|
267
|
+
// saveCacheState). Falls back to this in-memory Map when the DB is
|
|
268
|
+
// unavailable so continuation floors keep working without persistence.
|
|
269
|
+
const taskStateMem = new Map();
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Load TaskBand state for a session. Returns null when absent or on any
|
|
273
|
+
* DB failure.
|
|
274
|
+
*
|
|
275
|
+
* @param {string} sessionId
|
|
276
|
+
* @returns {{anchorHash:string|null, effectiveBand:number, open:boolean, lowStreak:number}|null}
|
|
277
|
+
*/
|
|
278
|
+
function getTaskState(sessionId) {
|
|
279
|
+
if (!sessionId) return null;
|
|
280
|
+
const db = _db();
|
|
281
|
+
if (!db) {
|
|
282
|
+
const mem = taskStateMem.get(sessionId);
|
|
283
|
+
return mem ? { ...mem } : null;
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
const row = _stmt(
|
|
287
|
+
db,
|
|
288
|
+
"task_state_load",
|
|
289
|
+
"SELECT anchor_hash, effective_band, open, low_streak FROM session_task_state WHERE session_id = ?"
|
|
290
|
+
).get(sessionId);
|
|
291
|
+
if (!row) return null;
|
|
292
|
+
return {
|
|
293
|
+
anchorHash: row.anchor_hash ?? null,
|
|
294
|
+
effectiveBand: Number(row.effective_band) || 0,
|
|
295
|
+
open: !!row.open,
|
|
296
|
+
lowStreak: Number(row.low_streak) || 0,
|
|
297
|
+
};
|
|
298
|
+
} catch (err) {
|
|
299
|
+
degradation.record("feedback", err);
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Upsert TaskBand state. effective_band is MAX-merged only while the stored
|
|
306
|
+
* anchor matches — a new anchor means a new task, whose band must not
|
|
307
|
+
* inherit the old peak, so the whole row is overwritten instead.
|
|
308
|
+
*
|
|
309
|
+
* @param {string} sessionId
|
|
310
|
+
* @param {{anchorHash:string|null, effectiveBand:number, open:boolean, lowStreak?:number}} state
|
|
311
|
+
*/
|
|
312
|
+
function setTaskState(sessionId, state) {
|
|
313
|
+
if (!sessionId || !state) return;
|
|
314
|
+
const next = {
|
|
315
|
+
anchorHash: state.anchorHash ?? null,
|
|
316
|
+
effectiveBand: Number.isFinite(state.effectiveBand) ? state.effectiveBand : 0,
|
|
317
|
+
open: state.open ? 1 : 0,
|
|
318
|
+
lowStreak: Number.isFinite(state.lowStreak) ? state.lowStreak : 0,
|
|
319
|
+
};
|
|
320
|
+
const db = _db();
|
|
321
|
+
if (!db) {
|
|
322
|
+
const prev = taskStateMem.get(sessionId);
|
|
323
|
+
if (prev && prev.anchorHash === next.anchorHash) {
|
|
324
|
+
next.effectiveBand = Math.max(prev.effectiveBand, next.effectiveBand);
|
|
325
|
+
}
|
|
326
|
+
taskStateMem.set(sessionId, {
|
|
327
|
+
anchorHash: next.anchorHash,
|
|
328
|
+
effectiveBand: next.effectiveBand,
|
|
329
|
+
open: !!next.open,
|
|
330
|
+
lowStreak: next.lowStreak,
|
|
331
|
+
});
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
_stmt(
|
|
336
|
+
db,
|
|
337
|
+
"task_state_upsert",
|
|
338
|
+
// IS (not =) so a null↔null anchor comparison still MAX-merges. All
|
|
339
|
+
// SET expressions see the pre-update row, so assignment order below
|
|
340
|
+
// doesn't affect the CASE.
|
|
341
|
+
`INSERT INTO session_task_state (session_id, anchor_hash, effective_band, open, low_streak, updated_at)
|
|
342
|
+
VALUES (@session_id, @anchor_hash, @effective_band, @open, @low_streak, @updated_at)
|
|
343
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
344
|
+
effective_band = CASE WHEN anchor_hash IS excluded.anchor_hash
|
|
345
|
+
THEN MAX(effective_band, excluded.effective_band)
|
|
346
|
+
ELSE excluded.effective_band END,
|
|
347
|
+
anchor_hash = excluded.anchor_hash,
|
|
348
|
+
open = excluded.open,
|
|
349
|
+
low_streak = excluded.low_streak,
|
|
350
|
+
updated_at = excluded.updated_at`
|
|
351
|
+
).run({
|
|
352
|
+
session_id: sessionId,
|
|
353
|
+
anchor_hash: next.anchorHash,
|
|
354
|
+
effective_band: next.effectiveBand,
|
|
355
|
+
open: next.open,
|
|
356
|
+
low_streak: next.lowStreak,
|
|
357
|
+
updated_at: Date.now(),
|
|
358
|
+
});
|
|
359
|
+
} catch (err) {
|
|
360
|
+
degradation.record("feedback", err);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Remove TaskBand state for a session.
|
|
366
|
+
* @param {string} sessionId
|
|
367
|
+
*/
|
|
368
|
+
function clearTaskState(sessionId) {
|
|
369
|
+
if (!sessionId) return;
|
|
370
|
+
taskStateMem.delete(sessionId);
|
|
371
|
+
const db = _db();
|
|
372
|
+
if (!db) return;
|
|
373
|
+
try {
|
|
374
|
+
_stmt(db, "task_state_delete", "DELETE FROM session_task_state WHERE session_id = ?").run(sessionId);
|
|
375
|
+
} catch (err) {
|
|
376
|
+
degradation.record("feedback", err);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
256
380
|
/**
|
|
257
381
|
* Remove a pin.
|
|
258
382
|
* @param {string} sessionId
|
|
@@ -298,4 +422,15 @@ function _clear() {
|
|
|
298
422
|
} catch { /* best-effort */ }
|
|
299
423
|
}
|
|
300
424
|
|
|
301
|
-
module.exports = {
|
|
425
|
+
module.exports = {
|
|
426
|
+
load,
|
|
427
|
+
save,
|
|
428
|
+
remove,
|
|
429
|
+
cleanup,
|
|
430
|
+
saveCacheState,
|
|
431
|
+
loadCacheState,
|
|
432
|
+
getTaskState,
|
|
433
|
+
setTaskState,
|
|
434
|
+
clearTaskState,
|
|
435
|
+
_clear,
|
|
436
|
+
};
|
package/src/routing/index.js
CHANGED
|
@@ -625,7 +625,16 @@ async function checkPinScoreDrift(pin, payload) {
|
|
|
625
625
|
// ceiling+margin comparisons are meaningless.
|
|
626
626
|
let freshScore = null;
|
|
627
627
|
if (intentScoreMode() !== 'legacy') {
|
|
628
|
-
|
|
628
|
+
// TaskBand: same judge context + memo identity as the fresh-decision
|
|
629
|
+
// scoring of this ask (the router stamps these on the body) — drift
|
|
630
|
+
// must compare scores produced from identical inputs.
|
|
631
|
+
const intent = await scoreIntent({
|
|
632
|
+
messages: [{ role: 'user', content: text }],
|
|
633
|
+
_conversationContext: typeof payload?._conversationContext === 'string'
|
|
634
|
+
? payload._conversationContext : null,
|
|
635
|
+
_taskAnchorHash: payload?._taskAnchorHash ?? null,
|
|
636
|
+
_isContinuation: !!payload?._isContinuation,
|
|
637
|
+
});
|
|
629
638
|
if (intent && Number.isFinite(intent.score)) freshScore = intent.score;
|
|
630
639
|
}
|
|
631
640
|
if (freshScore === null) {
|
|
@@ -872,6 +881,18 @@ function writeSessionPin(sessionId, decision, payload) {
|
|
|
872
881
|
}, '[Routing] Risk-forced decision — pin write skipped');
|
|
873
882
|
return;
|
|
874
883
|
}
|
|
884
|
+
// Continuation-floored tiers are inherited, not scored — pinning one would
|
|
885
|
+
// fossilize the floor past its decay window (same one-way ratchet as risk
|
|
886
|
+
// above; the floor re-derives on every turn that still earns it).
|
|
887
|
+
if (method.includes('+continuation_inherit')) {
|
|
888
|
+
logger.debug({
|
|
889
|
+
sessionId,
|
|
890
|
+
provider: decision.provider,
|
|
891
|
+
tier: decision.tier,
|
|
892
|
+
method,
|
|
893
|
+
}, '[Routing] Continuation-floored decision — pin write skipped');
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
875
896
|
const messageCount = Array.isArray(payload?.messages) ? payload.messages.length : 0;
|
|
876
897
|
// Opener-only sessions (≤2 messages) never pin: a bare opener has nothing
|
|
877
898
|
// to stabilize, and identical openers share a fingerprint for the 6h TTL,
|
|
@@ -1106,11 +1127,20 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
1106
1127
|
analysis.score = intent.score;
|
|
1107
1128
|
analysis.scoreMode = intent.mode; // 'anchor' | 'lexical' (clean-text fallback)
|
|
1108
1129
|
analysis.anchorClass = intent.class ?? null;
|
|
1130
|
+
// Pure anchor score BEFORE classifier reconciliation — the Jev
|
|
1131
|
+
// override cap derives its base tier from this, so the judge can
|
|
1132
|
+
// never stack a second band on top of a reconcile lift.
|
|
1133
|
+
analysis.anchorScore = Number.isFinite(intent.anchorScore) ? intent.anchorScore : null;
|
|
1109
1134
|
// Jev verdict rides alongside the score so the shortfall floor,
|
|
1110
1135
|
// risk corroboration and telemetry can consume it downstream.
|
|
1111
1136
|
// Null unless the Jev leg ran (pinned serves, side frames and
|
|
1112
1137
|
// classifier skips leave no verdict — same as classifierResult).
|
|
1113
1138
|
if (intent.jev && typeof intent.jev === 'object') analysis.jev = intent.jev;
|
|
1139
|
+
// How _reconcile disposed of the verdict ('down_capped' means a weak
|
|
1140
|
+
// context-fed downward verdict was floored at one band below the
|
|
1141
|
+
// anchor) — the override below must honor that gate, not re-apply
|
|
1142
|
+
// the raw verdict.
|
|
1143
|
+
analysis.reconciled = intent.reconciled ?? false;
|
|
1114
1144
|
intentScored = true;
|
|
1115
1145
|
logger.debug({
|
|
1116
1146
|
score: intent.score,
|
|
@@ -1230,6 +1260,8 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
1230
1260
|
let baseTier = null;
|
|
1231
1261
|
const escalations = [];
|
|
1232
1262
|
let _jevOverrode = false;
|
|
1263
|
+
let _jevCapChanged = false;
|
|
1264
|
+
let _jevCapBaseTier = null;
|
|
1233
1265
|
if (config.modelTiers?.enabled) {
|
|
1234
1266
|
try {
|
|
1235
1267
|
const selector = getModelTierSelector();
|
|
@@ -1244,11 +1276,32 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
1244
1276
|
// badges and telemetry stay coherent with the decided tier.
|
|
1245
1277
|
try {
|
|
1246
1278
|
const { jevTierOverride } = require('./jev-router');
|
|
1247
|
-
|
|
1248
|
-
|
|
1279
|
+
// Cap base for upward verdicts: the stronger of the pure anchor
|
|
1280
|
+
// tier and any inherited continuation floor the request carries.
|
|
1281
|
+
// The judge may lift one band above THAT and no further (live bug:
|
|
1282
|
+
// uncapped verdicts catapulted follow-ups straight to REASONING).
|
|
1283
|
+
_jevCapBaseTier = Number.isFinite(analysis.anchorScore)
|
|
1284
|
+
? selector.getTier(analysis.anchorScore)
|
|
1285
|
+
: baseTier;
|
|
1286
|
+
const _inheritedFloor = payload?._inheritedFloorTier;
|
|
1287
|
+
if (_inheritedFloor && (TIER_DEFINITIONS[_inheritedFloor]?.priority ?? -1)
|
|
1288
|
+
> (TIER_DEFINITIONS[_jevCapBaseTier]?.priority ?? -1)) {
|
|
1289
|
+
_jevCapBaseTier = _inheritedFloor;
|
|
1290
|
+
}
|
|
1291
|
+
const _jevArgs = {
|
|
1249
1292
|
jevTier: analysis.jev?.tier,
|
|
1250
1293
|
jevConfidence: analysis.jev?.confidence,
|
|
1251
|
-
}
|
|
1294
|
+
};
|
|
1295
|
+
// 'down_capped' means _reconcile already floored this exact verdict
|
|
1296
|
+
// at one band below the anchor (weak, context-fed); the verdict sits
|
|
1297
|
+
// strictly below baseTier, so only the uncapped downward leg could
|
|
1298
|
+
// fire here — landing the full drop the reconcile gate prevented.
|
|
1299
|
+
const _downCapped = analysis.reconciled === 'down_capped';
|
|
1300
|
+
const _ov = _downCapped ? null : jevTierOverride(_jevArgs, baseTier, _jevCapBaseTier);
|
|
1301
|
+
// REASONING cap = the full range: the delta against it is exactly
|
|
1302
|
+
// "did the cap change the outcome" for the method/ledger marker.
|
|
1303
|
+
const _ovUncapped = _downCapped ? null : jevTierOverride(_jevArgs, baseTier, 'REASONING');
|
|
1304
|
+
if ((_ovUncapped?.tier ?? null) !== (_ov?.tier ?? null)) _jevCapChanged = true;
|
|
1252
1305
|
if (_ov) {
|
|
1253
1306
|
escalations.push({
|
|
1254
1307
|
source: 'jev_override',
|
|
@@ -1323,6 +1376,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
1323
1376
|
// but Phase 1.2 lets the cost-optimizer pick a cheaper qualifying model when safe).
|
|
1324
1377
|
let provider;
|
|
1325
1378
|
let method = _jevOverrode ? 'tier_config+jev_override' : 'tier_config';
|
|
1379
|
+
if (_jevCapChanged) method += '+jev_override_capped';
|
|
1326
1380
|
let costOptimized = false;
|
|
1327
1381
|
|
|
1328
1382
|
const selector = getModelTierSelector();
|
|
@@ -1426,14 +1480,24 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
1426
1480
|
// any other decision.
|
|
1427
1481
|
try {
|
|
1428
1482
|
const { jevFloorTarget } = require('./jev-router');
|
|
1429
|
-
const
|
|
1430
|
-
?
|
|
1483
|
+
const _floorArgs = shortfallInfo && shortfallInfo.legacy
|
|
1484
|
+
? {
|
|
1431
1485
|
selectedTier: tier,
|
|
1432
1486
|
legacyTier: shortfallInfo.legacy.tier,
|
|
1433
1487
|
jevTier: analysis.jev?.tier,
|
|
1434
1488
|
jevConfidence: analysis.jev?.confidence,
|
|
1435
|
-
}
|
|
1489
|
+
}
|
|
1436
1490
|
: null;
|
|
1491
|
+
// Same cap as the override above: the judged floor can't hold a tier
|
|
1492
|
+
// more than one band above the anchor/inherited-floor base.
|
|
1493
|
+
const _floorTier = _floorArgs ? jevFloorTarget(_floorArgs, _jevCapBaseTier) : null;
|
|
1494
|
+
if (_floorArgs) {
|
|
1495
|
+
const _floorUncapped = jevFloorTarget(_floorArgs, null);
|
|
1496
|
+
if ((_floorUncapped ?? null) !== (_floorTier ?? null)
|
|
1497
|
+
&& !method.includes('+jev_override_capped')) {
|
|
1498
|
+
method = method + '+jev_override_capped';
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1437
1501
|
if (_floorTier) {
|
|
1438
1502
|
const _floorSel = selector.selectModel(_floorTier, null);
|
|
1439
1503
|
if (_floorSel && _floorSel.provider && _floorSel.model) {
|
|
@@ -96,53 +96,29 @@ function intentScoreMode() {
|
|
|
96
96
|
/**
|
|
97
97
|
* Extract the text the USER actually authored this turn: the latest user
|
|
98
98
|
* message that has real text after stripping harness-injected content.
|
|
99
|
-
* Returns null when there is nothing to score (e.g.
|
|
99
|
+
* Returns {text:null, index:-1} when there is nothing to score (e.g.
|
|
100
|
+
* tool-result-only turn). Per-message cleaning delegates to jev-router's
|
|
101
|
+
* shared cleaner (wrapper/command tags, line-start unclosed-tag rule,
|
|
102
|
+
* whole-message harness content) so the anchor scorer, the judge and the
|
|
103
|
+
* task ledger all read the same user text.
|
|
100
104
|
*/
|
|
101
|
-
function
|
|
105
|
+
function _latestUserAsk(payload) {
|
|
102
106
|
const msgs = payload?.messages;
|
|
103
|
-
if (!Array.isArray(msgs)) return null;
|
|
107
|
+
if (!Array.isArray(msgs)) return { text: null, index: -1 };
|
|
108
|
+
const { cleanUserText } = require('./jev-router');
|
|
104
109
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
105
110
|
const msg = msgs[i];
|
|
106
111
|
if (msg?.role !== 'user') continue;
|
|
107
|
-
|
|
108
|
-
if (
|
|
109
|
-
text = msg.content;
|
|
110
|
-
} else if (Array.isArray(msg.content)) {
|
|
111
|
-
text = msg.content
|
|
112
|
-
.filter((b) => b?.type === 'text' && typeof b.text === 'string')
|
|
113
|
-
.map((b) => b.text)
|
|
114
|
-
.join(' ');
|
|
115
|
-
}
|
|
116
|
-
if (text == null) continue;
|
|
117
|
-
text = text
|
|
118
|
-
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
|
|
119
|
-
// Harness task/background-agent notifications — not user-authored.
|
|
120
|
-
.replace(/<task-notification>[\s\S]*?<\/task-notification>/g, '')
|
|
121
|
-
// Codex harness blocks (merged into the typed text upstream):
|
|
122
|
-
// sandbox/permission profile and AGENTS.md contents.
|
|
123
|
-
.replace(/<environment_context>[\s\S]*?<\/environment_context>/g, '')
|
|
124
|
-
.replace(/<user_instructions>[\s\S]*?<\/user_instructions>/g, '')
|
|
125
|
-
// Goose harness block wrapping every typed message (time, cwd, todo
|
|
126
|
-
// notes) — its "tasks"/"update"/"requirements" vocabulary scored a
|
|
127
|
-
// bare "Hi" as substantive/MEDIUM.
|
|
128
|
-
.replace(/<turn-context>[\s\S]*?<\/turn-context>/g, '')
|
|
129
|
-
// Lynkr's own injected notices (quota banners, badges) start with the
|
|
130
|
-
// [Lynkr] marker — the user didn't type those.
|
|
131
|
-
.replace(/^\s*\[Lynkr\][^\n]*$/gm, '')
|
|
132
|
-
.trim();
|
|
133
|
-
// Whole-message harness content: compaction/continuation summaries and
|
|
134
|
-
// system notifications arrive as user-role messages the user never
|
|
135
|
-
// typed. Treat as empty and keep walking back.
|
|
136
|
-
if (/^\s*(\[SYSTEM NOTIFICATION|<conversation[\s>]|<session[\s>]|\[Request interrupted|This session is being continued from a previous conversation)/i.test(text)) {
|
|
137
|
-
// Continuation summaries PARAPHRASE the prior session, so they carry
|
|
138
|
-
// force-phrase vocabulary the user typed hours ago.
|
|
139
|
-
text = '';
|
|
140
|
-
}
|
|
141
|
-
if (text) return text;
|
|
112
|
+
const text = cleanUserText(msg);
|
|
113
|
+
if (text) return { text, index: i };
|
|
142
114
|
// A user message that was ALL injected content (or all tool_results)
|
|
143
115
|
// doesn't end the search — keep walking back for the real user turn.
|
|
144
116
|
}
|
|
145
|
-
return null;
|
|
117
|
+
return { text: null, index: -1 };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function extractCleanUserText(payload) {
|
|
121
|
+
return _latestUserAsk(payload).text;
|
|
146
122
|
}
|
|
147
123
|
|
|
148
124
|
function cosine(a, b) {
|
|
@@ -317,13 +293,21 @@ function _lexicalCleanScore(text) {
|
|
|
317
293
|
// - Agreement → keep anchor score as-is.
|
|
318
294
|
// - Classifier lower than anchor → trust classifier (catches embedding
|
|
319
295
|
// false-positives like "list exports" scoring REASONING). Position
|
|
320
|
-
// score at midpoint of classifier's target band.
|
|
321
|
-
//
|
|
296
|
+
// score at midpoint of classifier's target band. When the verdict was
|
|
297
|
+
// produced WITH conversation_context at confidence < 0.6, the drop is
|
|
298
|
+
// capped at one band below the anchor: background-thread context reads
|
|
299
|
+
// follow-ups as small asks, and a weak context-fed verdict must not
|
|
300
|
+
// collapse a heavyweight anchor to SIMPLE.
|
|
301
|
+
// - Classifier higher than anchor → safety-gate: require confidence≥0.3
|
|
302
|
+
// (set 2026-09-22 — see JEV_PROMOTE_CONFIDENCE note)
|
|
322
303
|
// before trusting an escalation to a more expensive tier. Below that,
|
|
323
304
|
// keep the cheaper anchor decision.
|
|
324
|
-
function _reconcile(anchorScore, anchorClass, classifierResult) {
|
|
305
|
+
function _reconcile(anchorScore, anchorClass, classifierResult, hasContext = false) {
|
|
325
306
|
if (!classifierResult) return { score: anchorScore, reconciled: false };
|
|
326
|
-
|
|
307
|
+
// Single 0.3 cut (set 2026-09-22): below it the verdict is noise in both
|
|
308
|
+
// directions. Note this also lets 0.3+ verdicts pull DOWN a band —
|
|
309
|
+
// the price of trusting the judge symmetrically.
|
|
310
|
+
if (classifierResult.confidence < 0.3) return { score: anchorScore, reconciled: false };
|
|
327
311
|
|
|
328
312
|
// Anchor class → implied tier (matches model-tiers.js band definitions).
|
|
329
313
|
const anchorTier = anchorScore <= 19 ? 'SIMPLE'
|
|
@@ -343,6 +327,13 @@ function _reconcile(anchorScore, anchorClass, classifierResult) {
|
|
|
343
327
|
|
|
344
328
|
if (classifierIdx < anchorIdx) {
|
|
345
329
|
// Classifier says LOWER tier — trust it. Fixes over-routing.
|
|
330
|
+
if (hasContext && classifierResult.confidence < 0.6) {
|
|
331
|
+
const flooredIdx = Math.max(classifierIdx, anchorIdx - 1);
|
|
332
|
+
return {
|
|
333
|
+
score: TIER_MIDPOINT[TIER_ORDER[flooredIdx]],
|
|
334
|
+
reconciled: flooredIdx > classifierIdx ? 'down_capped' : 'down',
|
|
335
|
+
};
|
|
336
|
+
}
|
|
346
337
|
return { score: TIER_MIDPOINT[classifierTier], reconciled: 'down' };
|
|
347
338
|
}
|
|
348
339
|
// Classifier says HIGHER tier — gate on confidence, and cap the jump at
|
|
@@ -354,7 +345,7 @@ function _reconcile(anchorScore, anchorClass, classifierResult) {
|
|
|
354
345
|
// the adjacent band now takes consecutive turns that keep re-scoring
|
|
355
346
|
// higher, which is exactly the persistence a genuinely hard conversation
|
|
356
347
|
// exhibits.
|
|
357
|
-
if (classifierResult.confidence >= 0.
|
|
348
|
+
if (classifierResult.confidence >= 0.3) {
|
|
358
349
|
const cappedIdx = Math.min(classifierIdx, anchorIdx + 1);
|
|
359
350
|
return {
|
|
360
351
|
score: TIER_MIDPOINT[TIER_ORDER[cappedIdx]],
|
|
@@ -364,11 +355,14 @@ function _reconcile(anchorScore, anchorClass, classifierResult) {
|
|
|
364
355
|
return { score: anchorScore, reconciled: 'up_gated' };
|
|
365
356
|
}
|
|
366
357
|
|
|
367
|
-
// Memoize the final reconciled score per cleaned text
|
|
368
|
-
// live and can time out under load, sending identical text
|
|
369
|
-
// fallback paths — which made the same turn score differently
|
|
370
|
-
// router and the pin-drift checker (observed: 63 vs 77 for one
|
|
371
|
-
// suite-wide Ollama contention). First resolution wins for the
|
|
358
|
+
// Memoize the final reconciled score per cleaned text + task identity. The
|
|
359
|
+
// classifier leg is live and can time out under load, sending identical text
|
|
360
|
+
// down different fallback paths — which made the same turn score differently
|
|
361
|
+
// between the router and the pin-drift checker (observed: 63 vs 77 for one
|
|
362
|
+
// prompt under suite-wide Ollama contention). First resolution wins for the
|
|
363
|
+
// process life. The key carries the task anchor hash and continuation flag:
|
|
364
|
+
// the judge's verdict depends on them, so identical text under a different
|
|
365
|
+
// task must not share an entry — a text-only key served stale-context scores.
|
|
372
366
|
const _scoreMemo = new Map();
|
|
373
367
|
const _SCORE_MEMO_MAX = 500;
|
|
374
368
|
|
|
@@ -376,20 +370,36 @@ async function scoreIntent(payload, opts = {}) {
|
|
|
376
370
|
const mode = opts.mode ?? intentScoreMode();
|
|
377
371
|
if (mode === 'legacy') return null;
|
|
378
372
|
|
|
379
|
-
const text =
|
|
373
|
+
const { text, index: askIdx } = _latestUserAsk(payload);
|
|
380
374
|
if (!text) return null;
|
|
381
375
|
|
|
382
376
|
// Only memoize plain calls — opts that alter scoring (custom centroids,
|
|
383
|
-
// embedFn, risk
|
|
377
|
+
// embedFn, risk inputs) must not share cache entries.
|
|
384
378
|
const memoizable = !opts.centroids && !opts.embedFn && !opts.forceMatched
|
|
385
379
|
&& !opts.riskLevel && !opts.skipClassifier && !opts.priorTurns;
|
|
386
|
-
|
|
380
|
+
|
|
381
|
+
// Prior-task ledger (messages BEFORE the current ask): names the task for
|
|
382
|
+
// the memo key and feeds the judge's context + flat signals. It never
|
|
383
|
+
// touches the anchor scorer's input — envelope invariance holds.
|
|
384
|
+
let ledger = null;
|
|
385
|
+
if (!opts.skipClassifier) {
|
|
386
|
+
try {
|
|
387
|
+
const { deriveTaskLedger } = require('./task-ledger');
|
|
388
|
+
const msgs = Array.isArray(payload?.messages) ? payload.messages : [];
|
|
389
|
+
ledger = deriveTaskLedger(msgs.slice(0, Math.max(askIdx, 0)));
|
|
390
|
+
} catch { /* ledger unavailable — task-less key, context-free judge */ }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const memoKey = memoizable
|
|
394
|
+
? `${text}\0${payload?._taskAnchorHash || ledger?.anchorHash || ''}\0${payload?._isContinuation ? '1' : '0'}`
|
|
395
|
+
: null;
|
|
396
|
+
if (memoizable && _scoreMemo.has(memoKey)) return _scoreMemo.get(memoKey);
|
|
387
397
|
const _memoSet = (result) => {
|
|
388
398
|
if (memoizable && result) {
|
|
389
399
|
if (_scoreMemo.size >= _SCORE_MEMO_MAX) {
|
|
390
400
|
_scoreMemo.delete(_scoreMemo.keys().next().value);
|
|
391
401
|
}
|
|
392
|
-
_scoreMemo.set(
|
|
402
|
+
_scoreMemo.set(memoKey, result);
|
|
393
403
|
}
|
|
394
404
|
return result;
|
|
395
405
|
};
|
|
@@ -423,27 +433,31 @@ async function scoreIntent(payload, opts = {}) {
|
|
|
423
433
|
// on NODE_ENV — .env pins it to production in every context.
|
|
424
434
|
let classifierResult = null;
|
|
425
435
|
let jevResult = null;
|
|
436
|
+
// Task context (anchor + last ask of the prior-task ledger) so the
|
|
437
|
+
// judge resolves follow-ups. Explicit caller context wins;
|
|
438
|
+
// single-message payloads yield null (unchanged behavior). Tracked
|
|
439
|
+
// out here so _reconcile knows whether the verdict was context-fed.
|
|
440
|
+
let jevContext = typeof payload?._conversationContext === 'string'
|
|
441
|
+
? payload._conversationContext
|
|
442
|
+
: null;
|
|
426
443
|
if (!opts.skipClassifier) {
|
|
427
444
|
try {
|
|
428
445
|
const { classifyJev } = require('./jev-router');
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
446
|
+
let signals = {};
|
|
447
|
+
try {
|
|
448
|
+
const { buildTaskContext, buildJevSignals } = require('./task-ledger');
|
|
449
|
+
if (jevContext === null) jevContext = buildTaskContext(ledger);
|
|
450
|
+
signals = buildJevSignals({
|
|
451
|
+
payload,
|
|
452
|
+
ledger,
|
|
453
|
+
isContinuation: payload?._isContinuation === true,
|
|
454
|
+
inheritedFloorIdx: payload?._inheritedFloorIdx ?? null,
|
|
455
|
+
});
|
|
456
|
+
} catch { /* ledger unavailable — judge runs on bare signals */ }
|
|
435
457
|
jevResult = await classifyJev(text, {
|
|
436
|
-
context:
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
signals: {
|
|
440
|
-
message_count: msgs.length,
|
|
441
|
-
tools_attached: toolDefs.length,
|
|
442
|
-
effective_tools: toolDefs.length,
|
|
443
|
-
has_tool_history: toolResults > 0,
|
|
444
|
-
session_turn: Math.max(1, Math.ceil(msgs.length / 2)),
|
|
445
|
-
},
|
|
446
|
-
fetchFn: jevFetchFn,
|
|
458
|
+
context: jevContext,
|
|
459
|
+
signals,
|
|
460
|
+
fetchFn: opts.jevFetchFn,
|
|
447
461
|
});
|
|
448
462
|
if (jevResult) {
|
|
449
463
|
classifierResult = { tier: jevResult.tier, confidence: jevResult.confidence };
|
|
@@ -453,7 +467,7 @@ async function scoreIntent(payload, opts = {}) {
|
|
|
453
467
|
}
|
|
454
468
|
}
|
|
455
469
|
|
|
456
|
-
const { score, reconciled } = _reconcile(anchorScore, cls, classifierResult);
|
|
470
|
+
const { score, reconciled } = _reconcile(anchorScore, cls, classifierResult, !!jevContext);
|
|
457
471
|
if (reconciled) {
|
|
458
472
|
logger.debug({
|
|
459
473
|
text: text.slice(0, 80),
|