lynkr 9.7.3 → 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 +63 -25
- package/bin/cli.js +16 -1
- package/bin/lynkr-init.js +44 -1
- package/bin/lynkr-usage.js +78 -0
- package/bin/wrap.js +60 -35
- package/config/difficulty-anchors.json +22 -0
- package/package.json +23 -2
- package/scripts/audit-log-reader.js +399 -0
- package/scripts/build-eval-set.js +256 -0
- package/scripts/calibrate-thresholds.js +38 -157
- package/scripts/compact-dictionary.js +204 -0
- package/scripts/mine-difficulty-anchors.js +288 -0
- package/scripts/test-deduplication.js +448 -0
- package/scripts/validate-difficulty-classifier.js +123 -0
- package/scripts/validate-intent-anchors.js +186 -0
- package/scripts/ws7-anchor-replay.js +108 -0
- package/skills/lynkr/SKILL.md +195 -0
- package/src/api/middleware/loop-guard.js +87 -0
- package/src/api/middleware/request-logging.js +5 -64
- package/src/api/middleware/session.js +0 -0
- package/src/api/openai-router.js +120 -101
- package/src/api/providers-handler.js +27 -2
- package/src/api/router.js +467 -125
- package/src/budget/index.js +2 -19
- package/src/cache/semantic.js +9 -0
- package/src/clients/databricks.js +455 -142
- package/src/clients/gpt-utils.js +11 -105
- package/src/clients/openai-format.js +10 -3
- package/src/clients/openrouter-utils.js +49 -24
- package/src/clients/prompt-cache-injection.js +1 -0
- package/src/clients/provider-capabilities.js +1 -1
- package/src/clients/responses-format.js +34 -3
- package/src/clients/routing.js +15 -0
- package/src/config/index.js +36 -2
- package/src/context/gcf.js +275 -0
- package/src/context/tool-result-compressor.js +932 -47
- package/src/dashboard/api.js +1 -0
- package/src/logger/index.js +14 -1
- package/src/memory/search.js +12 -40
- package/src/memory/tools.js +3 -24
- package/src/orchestrator/bypass.js +4 -2
- package/src/orchestrator/index.js +120 -85
- package/src/routing/affinity-store.js +194 -0
- package/src/routing/agentic-detector.js +36 -6
- package/src/routing/bandit.js +25 -6
- package/src/routing/calibration.js +212 -0
- package/src/routing/classifier-setup.js +207 -0
- package/src/routing/client-profiles.js +292 -0
- package/src/routing/complexity-analyzer.js +88 -15
- package/src/routing/deescalator.js +148 -0
- package/src/routing/degradation.js +109 -0
- package/src/routing/difficulty-classifier.js +219 -0
- package/src/routing/feedback.js +157 -0
- package/src/routing/index.js +931 -90
- package/src/routing/intent-score.js +441 -0
- package/src/routing/interaction.js +3 -0
- package/src/routing/knn-router.js +70 -21
- package/src/routing/model-registry.js +28 -7
- package/src/routing/model-tiers.js +25 -2
- package/src/routing/reward-pipeline.js +68 -2
- package/src/routing/risk-analyzer.js +30 -1
- package/src/routing/risk-classifier.js +6 -2
- package/src/routing/session-affinity.js +162 -34
- package/src/routing/telemetry.js +286 -13
- package/src/routing/verifier.js +267 -0
- package/src/server.js +86 -21
- package/src/sessions/cleanup.js +17 -0
- package/src/tools/index.js +1 -15
- package/src/tools/smart-selection.js +10 -0
- package/src/tools/web-client.js +3 -3
- package/.eslintrc.cjs +0 -12
- package/benchmark-configs/litellm_config.yaml +0 -86
- package/benchmark-configs/lynkr.env +0 -48
- package/benchmark-configs/portkey-config.json +0 -60
- package/benchmark-configs/portkey-docker.sh +0 -23
- package/benchmark-tier-routing.js +0 -449
- package/funding.json +0 -110
- package/src/api/middleware/validation.js +0 -261
- package/src/routing/drift-monitor.js +0 -113
- package/src/workers/helpers.js +0 -185
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* De-escalation policy (WS2.3).
|
|
3
|
+
*
|
|
4
|
+
* The routing pipeline today only ratchets *up* — risk / agentic minTier /
|
|
5
|
+
* context / vision / kNN-ambiguous all escalate; nothing ever demotes. That
|
|
6
|
+
* means once a request_type has been escalated for ANY reason (even a
|
|
7
|
+
* one-off), it stays over-provisioned forever.
|
|
8
|
+
*
|
|
9
|
+
* This module supplies the missing signal: for a given (tier, request_type),
|
|
10
|
+
* has the *lower* tier historically served ≥ N similar requests at quality
|
|
11
|
+
* ≥ Q with error rate < E? If yes, the caller may demote — cheaper AND
|
|
12
|
+
* evidence-backed.
|
|
13
|
+
*
|
|
14
|
+
* The check is a plain SELECT against routing_telemetry (cached 60s) so
|
|
15
|
+
* enabling it in-line does not add DB pressure at request rate.
|
|
16
|
+
*
|
|
17
|
+
* @module routing/deescalator
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const logger = require('../logger');
|
|
21
|
+
const telemetry = require('./telemetry');
|
|
22
|
+
|
|
23
|
+
const TIER_ORDER = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
|
|
24
|
+
|
|
25
|
+
// Fixed thresholds. Chosen conservatively so a demotion only fires when the
|
|
26
|
+
// lower tier has demonstrable evidence: a full-week window, a meaningful
|
|
27
|
+
// sample count, high average quality, and a low error rate.
|
|
28
|
+
const MIN_SAMPLES = 30;
|
|
29
|
+
const MIN_QUALITY = 70;
|
|
30
|
+
const MAX_ERROR_RATE = 0.05;
|
|
31
|
+
const WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
32
|
+
const CACHE_TTL_MS = 60 * 1000;
|
|
33
|
+
|
|
34
|
+
/** @type {Map<string, {value: string|null, ts: number}>} */
|
|
35
|
+
const _cache = new Map();
|
|
36
|
+
|
|
37
|
+
function _cacheKey(tier, requestType) {
|
|
38
|
+
return `${tier}::${requestType || '<none>'}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function _lowerTier(tier) {
|
|
42
|
+
const idx = TIER_ORDER.indexOf(tier);
|
|
43
|
+
if (idx <= 0) return null;
|
|
44
|
+
return TIER_ORDER[idx - 1];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Return the demoted tier if evidence supports it; null otherwise.
|
|
49
|
+
*
|
|
50
|
+
* @param {Object} args
|
|
51
|
+
* @param {string} args.tier - The tier the router currently plans to serve.
|
|
52
|
+
* @param {string|null} args.requestType - Complexity analyzer's request_type.
|
|
53
|
+
* @param {Object} [args.analysis] - Passed through for callers that want it;
|
|
54
|
+
* currently unused by the rule, kept for signature stability.
|
|
55
|
+
* @param {Object} [args.deps] - Test-injectable dependencies.
|
|
56
|
+
* @param {Function} [args.deps.getQualityByTierAndType] - Override the telemetry query.
|
|
57
|
+
* @param {Function} [args.deps.now] - Override Date.now() for tests.
|
|
58
|
+
* @returns {string|null} The lower tier, or null if demotion is not warranted.
|
|
59
|
+
*/
|
|
60
|
+
function suggestDemotion({ tier, requestType, analysis, deps = {} } = {}) {
|
|
61
|
+
if (!tier || !requestType) return null;
|
|
62
|
+
const lower = _lowerTier(tier);
|
|
63
|
+
if (!lower) return null;
|
|
64
|
+
|
|
65
|
+
const now = typeof deps.now === 'function' ? deps.now() : Date.now();
|
|
66
|
+
const key = _cacheKey(tier, requestType);
|
|
67
|
+
const cached = _cache.get(key);
|
|
68
|
+
if (cached && now - cached.ts < CACHE_TTL_MS) {
|
|
69
|
+
return cached.value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const query = deps.getQualityByTierAndType || telemetry.getQualityByTierAndType;
|
|
73
|
+
if (typeof query !== 'function') return null;
|
|
74
|
+
|
|
75
|
+
let rows = null;
|
|
76
|
+
try {
|
|
77
|
+
rows = query({
|
|
78
|
+
since: now - WINDOW_MS,
|
|
79
|
+
until: now,
|
|
80
|
+
tiers: [lower],
|
|
81
|
+
});
|
|
82
|
+
} catch (err) {
|
|
83
|
+
logger.debug({ err: err.message }, '[Deescalator] telemetry query failed');
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const match = Array.isArray(rows)
|
|
88
|
+
? rows.find((r) => r.tier === lower && r.request_type === requestType)
|
|
89
|
+
: null;
|
|
90
|
+
|
|
91
|
+
const ok = match
|
|
92
|
+
&& match.count >= MIN_SAMPLES
|
|
93
|
+
&& (match.avg_quality ?? 0) >= MIN_QUALITY
|
|
94
|
+
&& (match.error_rate ?? 1) < MAX_ERROR_RATE;
|
|
95
|
+
|
|
96
|
+
const value = ok ? lower : null;
|
|
97
|
+
_cache.set(key, { value, ts: now });
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Test helper — clear the memoized decisions. */
|
|
102
|
+
function _clearCache() {
|
|
103
|
+
_cache.clear();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Shadow-mode policy. Wraps the live routing decision by delegating to
|
|
108
|
+
* `determineProviderSmart` and then applying `suggestDemotion` on the result.
|
|
109
|
+
* Registered with shadow-mode.js under name 'deescalate-v1' so operators can
|
|
110
|
+
* evaluate the projected cost/quality delta before enabling live demotion.
|
|
111
|
+
*/
|
|
112
|
+
async function shadowDeescalate(payload) {
|
|
113
|
+
// Late require to avoid the circular src/routing/index.js ↔ deescalator.js
|
|
114
|
+
// reference (index.js registers the shadow policy at module load).
|
|
115
|
+
const { determineProviderSmart } = require('./index');
|
|
116
|
+
const base = await determineProviderSmart(payload, { _shadow: true });
|
|
117
|
+
if (!base?.tier) return base;
|
|
118
|
+
const requestType = base?.analysis?.breakdown?.taskType?.reason
|
|
119
|
+
?? base?.analysis?.taskType
|
|
120
|
+
?? null;
|
|
121
|
+
const demoted = suggestDemotion({
|
|
122
|
+
tier: base.tier,
|
|
123
|
+
requestType,
|
|
124
|
+
analysis: base.analysis,
|
|
125
|
+
});
|
|
126
|
+
if (!demoted) return base;
|
|
127
|
+
try {
|
|
128
|
+
const { getModelTierSelector } = require('./model-tiers');
|
|
129
|
+
const selected = getModelTierSelector().selectModel(demoted, null);
|
|
130
|
+
return {
|
|
131
|
+
...base,
|
|
132
|
+
provider: selected.provider,
|
|
133
|
+
model: selected.model,
|
|
134
|
+
tier: demoted,
|
|
135
|
+
method: (base.method || 'tier_config') + '+deescalated_shadow',
|
|
136
|
+
_shadowDemotedFrom: base.tier,
|
|
137
|
+
};
|
|
138
|
+
} catch {
|
|
139
|
+
return base;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = {
|
|
144
|
+
suggestDemotion,
|
|
145
|
+
shadowDeescalate,
|
|
146
|
+
TIER_ORDER,
|
|
147
|
+
_clearCache,
|
|
148
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Degradation Registry
|
|
3
|
+
*
|
|
4
|
+
* Tracks silent-fallback occurrences across the routing pipeline. Every catch
|
|
5
|
+
* block in the routing path that used to `logger.debug` and fall through now
|
|
6
|
+
* calls `record(component, err)` here instead, giving us:
|
|
7
|
+
*
|
|
8
|
+
* - A counter per subsystem (visible via getRoutingStats)
|
|
9
|
+
* - A warn-once-per-hour policy per component (so the first failure surfaces
|
|
10
|
+
* in ops, but a wedged subsystem doesn't spam)
|
|
11
|
+
* - The last error message and timestamp for postmortem
|
|
12
|
+
*
|
|
13
|
+
* @module routing/degradation
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const logger = require("../logger");
|
|
17
|
+
|
|
18
|
+
// TODO(prometheus): expose these counters as Prometheus gauges once the
|
|
19
|
+
// project ships a prom-client registry (no such registry today; the WS0 plan
|
|
20
|
+
// authorised skipping this — getRoutingStats().degradation is the interim
|
|
21
|
+
// signal).
|
|
22
|
+
|
|
23
|
+
const KNOWN_COMPONENTS = new Set([
|
|
24
|
+
"risk",
|
|
25
|
+
"embeddings",
|
|
26
|
+
"agentic",
|
|
27
|
+
"tier_select",
|
|
28
|
+
"cost_optimize",
|
|
29
|
+
"context_validate",
|
|
30
|
+
"vision_guard",
|
|
31
|
+
"knn",
|
|
32
|
+
"bandit",
|
|
33
|
+
"deadline",
|
|
34
|
+
"tenant",
|
|
35
|
+
"feedback",
|
|
36
|
+
"calibration",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
const WARN_INTERVAL_MS = 60 * 60 * 1000;
|
|
40
|
+
|
|
41
|
+
/** @type {Map<string, {count:number, lastError:string|null, lastAt:number, lastWarnedAt:number}>} */
|
|
42
|
+
const counters = new Map();
|
|
43
|
+
|
|
44
|
+
function _entry(component) {
|
|
45
|
+
let e = counters.get(component);
|
|
46
|
+
if (!e) {
|
|
47
|
+
e = { count: 0, lastError: null, lastAt: 0, lastWarnedAt: 0 };
|
|
48
|
+
counters.set(component, e);
|
|
49
|
+
}
|
|
50
|
+
return e;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Record a degradation event. Warns once per hour per component, otherwise
|
|
55
|
+
* increments the counter silently at debug level.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} component - One of KNOWN_COMPONENTS. Unknown names are
|
|
58
|
+
* accepted (kept for forward compatibility) but log a debug notice.
|
|
59
|
+
* @param {Error|{message?:string}|string} err
|
|
60
|
+
*/
|
|
61
|
+
function record(component, err) {
|
|
62
|
+
if (!component) return;
|
|
63
|
+
if (!KNOWN_COMPONENTS.has(component)) {
|
|
64
|
+
logger.debug({ component }, "[Degradation] Unknown component recorded");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const message = err == null
|
|
68
|
+
? "unknown"
|
|
69
|
+
: typeof err === "string"
|
|
70
|
+
? err
|
|
71
|
+
: (err.message || String(err));
|
|
72
|
+
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
const e = _entry(component);
|
|
75
|
+
e.count += 1;
|
|
76
|
+
e.lastError = message;
|
|
77
|
+
e.lastAt = now;
|
|
78
|
+
|
|
79
|
+
if (now - e.lastWarnedAt >= WARN_INTERVAL_MS) {
|
|
80
|
+
e.lastWarnedAt = now;
|
|
81
|
+
logger.warn({ component, err: message, count: e.count }, "[Degradation] Subsystem failed, falling through");
|
|
82
|
+
} else {
|
|
83
|
+
logger.debug({ component, err: message, count: e.count }, "[Degradation] Subsystem failed, falling through");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Snapshot of per-component counters, safe to serialize into stats responses.
|
|
89
|
+
* @returns {Object<string, {count:number, lastError:string|null, lastAt:number}>}
|
|
90
|
+
*/
|
|
91
|
+
function getCounts() {
|
|
92
|
+
const out = {};
|
|
93
|
+
for (const [name, e] of counters.entries()) {
|
|
94
|
+
out[name] = { count: e.count, lastError: e.lastError, lastAt: e.lastAt };
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Test helper — reset all counters. */
|
|
100
|
+
function _clear() {
|
|
101
|
+
counters.clear();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = {
|
|
105
|
+
record,
|
|
106
|
+
getCounts,
|
|
107
|
+
KNOWN_COMPONENTS,
|
|
108
|
+
_clear,
|
|
109
|
+
};
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Difficulty classifier — LLM-based 4-way classification of user prompts.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS: The anchor-embedding scorer (intent-score.js) measures
|
|
5
|
+
* *topical* similarity to labeled difficulty exemplars. It confuses "list
|
|
6
|
+
* the exports from this file" (LOW difficulty, technical vocabulary) with
|
|
7
|
+
* hard problems. An LLM reading the actual sentence knows better.
|
|
8
|
+
*
|
|
9
|
+
* DESIGN:
|
|
10
|
+
* - Uses whatever model is configured for the SIMPLE tier (fetched at call
|
|
11
|
+
* time via getModelTierSelector). When the user later swaps in a
|
|
12
|
+
* fine-tuned classifier as SIMPLE, this picks it up automatically.
|
|
13
|
+
* - Structured JSON output; parse failure → null → caller falls back.
|
|
14
|
+
* - Hard 2500ms timeout; on timeout → null → caller falls back.
|
|
15
|
+
* - LRU cache keyed by sha256(text.trim().toLowerCase()); capacity 500.
|
|
16
|
+
* - Skip conditions surface via classifyDifficulty returning null with
|
|
17
|
+
* reason=skipped: text.length<15, force-pattern matched, risk=high,
|
|
18
|
+
* cache hit is transparent (returns cached).
|
|
19
|
+
* - Hardcoded kill-switch CLASSIFIER_ENABLED — no env var per user policy.
|
|
20
|
+
*
|
|
21
|
+
* Failure modes are all null: anchor+lexical scoring already handles the
|
|
22
|
+
* fallback. This module never blocks routing.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const crypto = require('crypto');
|
|
26
|
+
const logger = require('../logger');
|
|
27
|
+
|
|
28
|
+
const CLASSIFIER_ENABLED = true;
|
|
29
|
+
// Thinking models (minimax-m2.5) spend 1-4s in <thinking> before emitting
|
|
30
|
+
// JSON — 2.5s is not enough. Raise to 10s and rely on skip conditions +
|
|
31
|
+
// LRU cache to keep amortized latency low.
|
|
32
|
+
const TIMEOUT_MS = 10000;
|
|
33
|
+
const CACHE_CAPACITY = 500;
|
|
34
|
+
const MIN_TEXT_LENGTH = 15;
|
|
35
|
+
|
|
36
|
+
// Classifier model — decoupled from tier serving so SIMPLE tier can run a
|
|
37
|
+
// more capable model for real traffic while the classifier stays fast and
|
|
38
|
+
// cheap. Chosen 2026-07-19: qwen2.5:3b hit 87.3% hand-labeled accuracy at
|
|
39
|
+
// ~500ms warm latency, zero cost-critical over-routes. Replace with your
|
|
40
|
+
// fine-tuned classifier when you build one. Hardcoded per user directive
|
|
41
|
+
// (no env var).
|
|
42
|
+
const CLASSIFIER_PROVIDER = 'ollama';
|
|
43
|
+
const CLASSIFIER_MODEL = 'qwen2.5:3b';
|
|
44
|
+
|
|
45
|
+
const VALID_TIERS = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
|
|
46
|
+
|
|
47
|
+
// One-shot classification prompt. Kept in a const so drift is diffable.
|
|
48
|
+
// Difficulty framing (not intent) — matches config B routing goals.
|
|
49
|
+
const CLASSIFY_PROMPT = `You are a classifier for an LLM routing proxy. Classify the difficulty of the user prompt below into exactly one of four tiers. Reply with ONLY valid JSON on a single line, no other text.
|
|
50
|
+
|
|
51
|
+
Tiers:
|
|
52
|
+
- SIMPLE: casual acknowledgments, greetings, one-word answers, trivial factual lookups. Any tiny model handles.
|
|
53
|
+
examples: "hi", "ok thanks", "yes continue", "what time is it"
|
|
54
|
+
- MEDIUM: one specific mechanical task or a focused explanation. Mid-size local model suffices.
|
|
55
|
+
examples: "list the exports from this file", "run the unit tests", "fix the linter warnings", "explain this regex", "add error handling to this block"
|
|
56
|
+
- COMPLEX: multi-file design, systemic refactor, architecture review, debugging that requires broad code understanding. Needs a strong general model.
|
|
57
|
+
examples: "architecture review of the orchestrator", "refactor the entire ingestion pipeline", "debug this complex race condition across three services"
|
|
58
|
+
- REASONING: formal proof, correctness verification, security audit, novel algorithm design, formal reasoning from first principles. Needs a frontier reasoning model.
|
|
59
|
+
examples: "prove the correctness of this lock-free queue", "security audit the auth middleware", "formally verify this state machine never deadlocks", "derive the optimal eviction policy and prove its competitive ratio"
|
|
60
|
+
|
|
61
|
+
Reply format (strict): {"tier":"SIMPLE|MEDIUM|COMPLEX|REASONING","confidence":0.0-1.0}
|
|
62
|
+
|
|
63
|
+
User prompt: `;
|
|
64
|
+
|
|
65
|
+
// --- LRU cache --------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
class LruCache {
|
|
68
|
+
constructor(capacity) {
|
|
69
|
+
this.capacity = capacity;
|
|
70
|
+
this.map = new Map();
|
|
71
|
+
}
|
|
72
|
+
get(key) {
|
|
73
|
+
if (!this.map.has(key)) return undefined;
|
|
74
|
+
const val = this.map.get(key);
|
|
75
|
+
this.map.delete(key);
|
|
76
|
+
this.map.set(key, val); // move to MRU
|
|
77
|
+
return val;
|
|
78
|
+
}
|
|
79
|
+
set(key, val) {
|
|
80
|
+
if (this.map.has(key)) this.map.delete(key);
|
|
81
|
+
else if (this.map.size >= this.capacity) {
|
|
82
|
+
const oldest = this.map.keys().next().value;
|
|
83
|
+
this.map.delete(oldest);
|
|
84
|
+
}
|
|
85
|
+
this.map.set(key, val);
|
|
86
|
+
}
|
|
87
|
+
stats() { return { size: this.map.size, capacity: this.capacity }; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const _cache = new LruCache(CACHE_CAPACITY);
|
|
91
|
+
|
|
92
|
+
function _cacheKey(text) {
|
|
93
|
+
return crypto.createHash('sha256').update(text.trim().toLowerCase()).digest('hex');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- Ollama dispatch (only supported provider for the SIMPLE tier today) ----
|
|
97
|
+
|
|
98
|
+
async function _callOllama(text, opts) {
|
|
99
|
+
const config = require('../config');
|
|
100
|
+
|
|
101
|
+
// First cut supports ollama-family classifier only. Other providers can
|
|
102
|
+
// be wired later; today's classifier is qwen2.5:3b on ollama.
|
|
103
|
+
if (CLASSIFIER_PROVIDER !== 'ollama') {
|
|
104
|
+
logger.debug({ provider: CLASSIFIER_PROVIDER, model: CLASSIFIER_MODEL }, '[DifficultyClassifier] non-ollama classifier not yet supported');
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const endpoint = config.ollama?.endpoint || 'http://localhost:11434';
|
|
109
|
+
const url = `${endpoint.replace(/\/$/, '')}/api/chat`;
|
|
110
|
+
const body = {
|
|
111
|
+
model: CLASSIFIER_MODEL,
|
|
112
|
+
messages: [{ role: 'user', content: CLASSIFY_PROMPT + '"""' + text + '"""' }],
|
|
113
|
+
stream: false,
|
|
114
|
+
format: 'json',
|
|
115
|
+
// num_predict generous because thinking models (minimax-m2.5) burn
|
|
116
|
+
// budget in message.thinking before producing message.content JSON.
|
|
117
|
+
options: { temperature: 0, num_predict: 512 },
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const controller = new AbortController();
|
|
121
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs || TIMEOUT_MS);
|
|
122
|
+
try {
|
|
123
|
+
const res = await fetch(url, {
|
|
124
|
+
method: 'POST',
|
|
125
|
+
headers: { 'content-type': 'application/json' },
|
|
126
|
+
body: JSON.stringify(body),
|
|
127
|
+
signal: controller.signal,
|
|
128
|
+
});
|
|
129
|
+
if (!res.ok) return null;
|
|
130
|
+
const json = await res.json();
|
|
131
|
+
// Thinking models put output in message.thinking; regular in message.content.
|
|
132
|
+
// /api/generate variants use `response`. Try all three, biggest first.
|
|
133
|
+
const candidates = [
|
|
134
|
+
json?.message?.content,
|
|
135
|
+
json?.message?.thinking,
|
|
136
|
+
json?.response,
|
|
137
|
+
].filter(s => typeof s === 'string' && s.length > 0);
|
|
138
|
+
return candidates.join('\n') || null;
|
|
139
|
+
} catch (err) {
|
|
140
|
+
logger.debug({ err: err.message, textPreview: text.slice(0, 60) }, '[DifficultyClassifier] call failed');
|
|
141
|
+
return null;
|
|
142
|
+
} finally {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// --- Parse & validate the model's JSON output -------------------------------
|
|
148
|
+
|
|
149
|
+
function _parseResult(raw) {
|
|
150
|
+
if (!raw) return null;
|
|
151
|
+
// Ollama with format:'json' usually gives clean JSON, but the model can
|
|
152
|
+
// still surround it with junk — extract the first { ... } span.
|
|
153
|
+
const match = raw.match(/\{[\s\S]*?\}/);
|
|
154
|
+
if (!match) return null;
|
|
155
|
+
let obj;
|
|
156
|
+
try { obj = JSON.parse(match[0]); } catch { return null; }
|
|
157
|
+
const tier = String(obj.tier || '').toUpperCase().trim();
|
|
158
|
+
if (!VALID_TIERS.includes(tier)) return null;
|
|
159
|
+
const rawConf = Number(obj.confidence);
|
|
160
|
+
const confidence = Number.isFinite(rawConf) ? Math.max(0, Math.min(1, rawConf)) : 0.5;
|
|
161
|
+
return { tier, confidence };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// --- Public API -------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Classify difficulty of user text.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} text — cleaned user prompt
|
|
170
|
+
* @param {object} [opts]
|
|
171
|
+
* @param {number} [opts.timeoutMs] — override default 2500ms
|
|
172
|
+
* @param {boolean} [opts.forceMatched] — caller already matched a FORCE_* pattern; skip classifier
|
|
173
|
+
* @param {string} [opts.riskLevel] — 'high' | 'medium' | 'low'; skip when 'high'
|
|
174
|
+
* @returns {Promise<{tier:string,confidence:number,source:'cache'|'model'}|null>}
|
|
175
|
+
* null when: disabled, skipped, model failure, parse failure, timeout.
|
|
176
|
+
*/
|
|
177
|
+
async function classifyDifficulty(text, opts = {}) {
|
|
178
|
+
if (!CLASSIFIER_ENABLED) return null;
|
|
179
|
+
if (typeof text !== 'string') return null;
|
|
180
|
+
const trimmed = text.trim();
|
|
181
|
+
if (trimmed.length < MIN_TEXT_LENGTH) return null;
|
|
182
|
+
if (opts.forceMatched) return null;
|
|
183
|
+
if (opts.riskLevel === 'high') return null;
|
|
184
|
+
|
|
185
|
+
const key = _cacheKey(trimmed);
|
|
186
|
+
const cached = _cache.get(key);
|
|
187
|
+
if (cached) return { ...cached, source: 'cache' };
|
|
188
|
+
|
|
189
|
+
const raw = await _callOllama(trimmed, opts);
|
|
190
|
+
const parsed = _parseResult(raw);
|
|
191
|
+
if (!parsed) return null;
|
|
192
|
+
_cache.set(key, parsed);
|
|
193
|
+
return { ...parsed, source: 'model' };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// --- Test helpers -----------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
function _clearCacheForTests() { _cache.map.clear(); }
|
|
199
|
+
function _getCacheStats() { return _cache.stats(); }
|
|
200
|
+
|
|
201
|
+
// Exposed metadata for the bootstrap module — keeps model choice in one file
|
|
202
|
+
// so classifier-setup.js pulls exactly what the classifier will call.
|
|
203
|
+
const CLASSIFIER_MODEL_INFO = Object.freeze({
|
|
204
|
+
provider: CLASSIFIER_PROVIDER,
|
|
205
|
+
model: CLASSIFIER_MODEL,
|
|
206
|
+
endpoint: process.env.OLLAMA_ENDPOINT || 'http://localhost:11434',
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
module.exports = {
|
|
210
|
+
classifyDifficulty,
|
|
211
|
+
CLASSIFIER_ENABLED,
|
|
212
|
+
CLASSIFIER_MODEL_INFO,
|
|
213
|
+
VALID_TIERS,
|
|
214
|
+
// internals for tests only
|
|
215
|
+
_parseResult,
|
|
216
|
+
_cacheKey,
|
|
217
|
+
_clearCacheForTests,
|
|
218
|
+
_getCacheStats,
|
|
219
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WS5.3 — feedback loop.
|
|
3
|
+
*
|
|
4
|
+
* Every routing decision produces an outcome (quality, cost, latency,
|
|
5
|
+
* status, error). Historically the bandit's `update()` was a dead export
|
|
6
|
+
* and `reward-pipeline.js` had no importer at all — the ML pipeline was
|
|
7
|
+
* built but never trained. This module closes that loop:
|
|
8
|
+
*
|
|
9
|
+
* 1. Compute the reward from the outcome (`reward-pipeline`).
|
|
10
|
+
* 2. If the decision came from a bandit pick, feed the reward back with
|
|
11
|
+
* the same context vector the bandit saw (`bandit.update`).
|
|
12
|
+
* 3. If we captured the query embedding at decision time and the outcome
|
|
13
|
+
* is conclusive (quality ≥ 70 or ≤ 40), add the entry to the kNN
|
|
14
|
+
* index for online growth.
|
|
15
|
+
*
|
|
16
|
+
* Contracts:
|
|
17
|
+
* - `recordOutcome()` NEVER throws. Every failure is swallowed into the
|
|
18
|
+
* degradation registry so we never poison the response path.
|
|
19
|
+
* - Work runs on `setImmediate` — the caller is invoking this from the
|
|
20
|
+
* hot path right after `telemetry.record(...)`.
|
|
21
|
+
* - Missing / partial routing metadata is acceptable: the function
|
|
22
|
+
* records what it can and skips what it can't.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const logger = require('../logger');
|
|
26
|
+
const degradation = require('./degradation');
|
|
27
|
+
const { getRewardPipeline } = require('./reward-pipeline');
|
|
28
|
+
const { getBandit } = require('./bandit');
|
|
29
|
+
const { getKnnRouter } = require('./knn-router');
|
|
30
|
+
|
|
31
|
+
// Quality thresholds for kNN online growth. Above HIGH → positive exemplar;
|
|
32
|
+
// below LOW → negative exemplar. The mid-band is intentionally excluded to
|
|
33
|
+
// avoid polluting the index with ambiguous cases.
|
|
34
|
+
const KNN_POSITIVE_QUALITY = 70;
|
|
35
|
+
const KNN_NEGATIVE_QUALITY = 40;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {object} args
|
|
39
|
+
* @param {object} args.routingResult — the decision returned by
|
|
40
|
+
* `determineProviderSmart` (or `pickTierByIntent` — see call sites in
|
|
41
|
+
* src/clients/databricks.js). Only underscored internals are consumed.
|
|
42
|
+
* @param {object} args.body — the original request body (unused today but
|
|
43
|
+
* accepted so future signals — user id, workspace, task id — can be
|
|
44
|
+
* threaded without a signature change).
|
|
45
|
+
* @param {object} args.outcome — {qualityScore, costUsd, latencyMs,
|
|
46
|
+
* statusCode, errorType, wasFallback}.
|
|
47
|
+
* @returns {void}
|
|
48
|
+
*/
|
|
49
|
+
function recordOutcome(args) {
|
|
50
|
+
// Never trust the caller's shape. Any non-object gets silently dropped —
|
|
51
|
+
// the response path is not the place to surface a caller bug.
|
|
52
|
+
const safeArgs = (args && typeof args === 'object') ? args : {};
|
|
53
|
+
setImmediate(() => {
|
|
54
|
+
try {
|
|
55
|
+
_recordOutcomeSync(safeArgs);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
// Absolute last-resort guard. Every sub-call inside _recordOutcomeSync
|
|
58
|
+
// already has its own try/catch → degradation.record(...); this catch
|
|
59
|
+
// exists so a truly unexpected throw (e.g. degradation itself blowing
|
|
60
|
+
// up) can never leak.
|
|
61
|
+
try { degradation.record('feedback', err); } catch (_) { /* nope */ }
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function _recordOutcomeSync({ routingResult, outcome }) {
|
|
67
|
+
if (!routingResult || !outcome) return;
|
|
68
|
+
|
|
69
|
+
const {
|
|
70
|
+
qualityScore = null,
|
|
71
|
+
costUsd = null,
|
|
72
|
+
latencyMs = null,
|
|
73
|
+
} = outcome;
|
|
74
|
+
|
|
75
|
+
let reward = null;
|
|
76
|
+
try {
|
|
77
|
+
const pipeline = getRewardPipeline();
|
|
78
|
+
reward = pipeline.reward({
|
|
79
|
+
quality: qualityScore,
|
|
80
|
+
cost: costUsd ?? 0,
|
|
81
|
+
latency: latencyMs ?? 0,
|
|
82
|
+
});
|
|
83
|
+
} catch (err) {
|
|
84
|
+
degradation.record('feedback', err);
|
|
85
|
+
reward = null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Bandit update — only when the decision actually came from a bandit pick
|
|
89
|
+
// (i.e. we stashed the context vector on the decision). Without ctx, an
|
|
90
|
+
// update would be meaningless: LinUCB's A/b matrices require the same
|
|
91
|
+
// feature vector the arm was scored on.
|
|
92
|
+
if (routingResult._banditContext
|
|
93
|
+
&& routingResult.provider
|
|
94
|
+
&& routingResult.model
|
|
95
|
+
&& routingResult.tier
|
|
96
|
+
&& typeof reward === 'number') {
|
|
97
|
+
try {
|
|
98
|
+
const bandit = getBandit();
|
|
99
|
+
bandit.update(
|
|
100
|
+
routingResult.tier,
|
|
101
|
+
routingResult.provider,
|
|
102
|
+
routingResult.model,
|
|
103
|
+
routingResult._banditContext,
|
|
104
|
+
reward,
|
|
105
|
+
);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
degradation.record('feedback', err);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// kNN online growth — conclusive quality only. We paid for the embedding
|
|
112
|
+
// at decision time (stashed on `_queryEmbedding`), so add() is essentially
|
|
113
|
+
// free. Skipping the mid-band (40 < q < 70) keeps the index from filling
|
|
114
|
+
// up with ambiguous exemplars that would only muddy future advice.
|
|
115
|
+
if (routingResult._queryEmbedding
|
|
116
|
+
&& typeof qualityScore === 'number'
|
|
117
|
+
&& (qualityScore >= KNN_POSITIVE_QUALITY || qualityScore <= KNN_NEGATIVE_QUALITY)) {
|
|
118
|
+
try {
|
|
119
|
+
const router = getKnnRouter();
|
|
120
|
+
router.add(routingResult._queryEmbedding, {
|
|
121
|
+
query: routingResult._queryText ?? null,
|
|
122
|
+
provider: routingResult.provider,
|
|
123
|
+
model: routingResult.model,
|
|
124
|
+
tier: routingResult.tier,
|
|
125
|
+
quality: qualityScore,
|
|
126
|
+
cost: costUsd ?? 0,
|
|
127
|
+
latency: latencyMs ?? 0,
|
|
128
|
+
// A negative exemplar is worth recording so future queries whose
|
|
129
|
+
// neighbours are these bad outcomes score poorly. The router's
|
|
130
|
+
// score function already blends quality naturally; no separate
|
|
131
|
+
// "polarity" flag is needed.
|
|
132
|
+
});
|
|
133
|
+
} catch (err) {
|
|
134
|
+
degradation.record('feedback', err);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (reward != null) {
|
|
139
|
+
logger.debug({
|
|
140
|
+
reward: reward.toFixed(2),
|
|
141
|
+
tier: routingResult.tier,
|
|
142
|
+
provider: routingResult.provider,
|
|
143
|
+
model: routingResult.model,
|
|
144
|
+
hasBandit: !!routingResult._banditContext,
|
|
145
|
+
hasEmbedding: !!routingResult._queryEmbedding,
|
|
146
|
+
qualityScore,
|
|
147
|
+
}, '[Feedback] Outcome recorded');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = {
|
|
152
|
+
recordOutcome,
|
|
153
|
+
// Test-only export so unit tests can drive the sync path directly.
|
|
154
|
+
_recordOutcomeSync,
|
|
155
|
+
KNN_POSITIVE_QUALITY,
|
|
156
|
+
KNN_NEGATIVE_QUALITY,
|
|
157
|
+
};
|