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.
Files changed (80) hide show
  1. package/README.md +63 -25
  2. package/bin/cli.js +16 -1
  3. package/bin/lynkr-init.js +44 -1
  4. package/bin/lynkr-usage.js +78 -0
  5. package/bin/wrap.js +60 -35
  6. package/config/difficulty-anchors.json +22 -0
  7. package/package.json +23 -2
  8. package/scripts/audit-log-reader.js +399 -0
  9. package/scripts/build-eval-set.js +256 -0
  10. package/scripts/calibrate-thresholds.js +38 -157
  11. package/scripts/compact-dictionary.js +204 -0
  12. package/scripts/mine-difficulty-anchors.js +288 -0
  13. package/scripts/test-deduplication.js +448 -0
  14. package/scripts/validate-difficulty-classifier.js +123 -0
  15. package/scripts/validate-intent-anchors.js +186 -0
  16. package/scripts/ws7-anchor-replay.js +108 -0
  17. package/skills/lynkr/SKILL.md +195 -0
  18. package/src/api/middleware/loop-guard.js +87 -0
  19. package/src/api/middleware/request-logging.js +5 -64
  20. package/src/api/middleware/session.js +0 -0
  21. package/src/api/openai-router.js +120 -101
  22. package/src/api/providers-handler.js +27 -2
  23. package/src/api/router.js +467 -125
  24. package/src/budget/index.js +2 -19
  25. package/src/cache/semantic.js +9 -0
  26. package/src/clients/databricks.js +455 -142
  27. package/src/clients/gpt-utils.js +11 -105
  28. package/src/clients/openai-format.js +10 -3
  29. package/src/clients/openrouter-utils.js +49 -24
  30. package/src/clients/prompt-cache-injection.js +1 -0
  31. package/src/clients/provider-capabilities.js +1 -1
  32. package/src/clients/responses-format.js +34 -3
  33. package/src/clients/routing.js +15 -0
  34. package/src/config/index.js +36 -2
  35. package/src/context/gcf.js +275 -0
  36. package/src/context/tool-result-compressor.js +932 -47
  37. package/src/dashboard/api.js +1 -0
  38. package/src/logger/index.js +14 -1
  39. package/src/memory/search.js +12 -40
  40. package/src/memory/tools.js +3 -24
  41. package/src/orchestrator/bypass.js +4 -2
  42. package/src/orchestrator/index.js +120 -85
  43. package/src/routing/affinity-store.js +194 -0
  44. package/src/routing/agentic-detector.js +36 -6
  45. package/src/routing/bandit.js +25 -6
  46. package/src/routing/calibration.js +212 -0
  47. package/src/routing/classifier-setup.js +207 -0
  48. package/src/routing/client-profiles.js +292 -0
  49. package/src/routing/complexity-analyzer.js +88 -15
  50. package/src/routing/deescalator.js +148 -0
  51. package/src/routing/degradation.js +109 -0
  52. package/src/routing/difficulty-classifier.js +219 -0
  53. package/src/routing/feedback.js +157 -0
  54. package/src/routing/index.js +931 -90
  55. package/src/routing/intent-score.js +441 -0
  56. package/src/routing/interaction.js +3 -0
  57. package/src/routing/knn-router.js +70 -21
  58. package/src/routing/model-registry.js +28 -7
  59. package/src/routing/model-tiers.js +25 -2
  60. package/src/routing/reward-pipeline.js +68 -2
  61. package/src/routing/risk-analyzer.js +30 -1
  62. package/src/routing/risk-classifier.js +6 -2
  63. package/src/routing/session-affinity.js +162 -34
  64. package/src/routing/telemetry.js +286 -13
  65. package/src/routing/verifier.js +267 -0
  66. package/src/server.js +86 -21
  67. package/src/sessions/cleanup.js +17 -0
  68. package/src/tools/index.js +1 -15
  69. package/src/tools/smart-selection.js +10 -0
  70. package/src/tools/web-client.js +3 -3
  71. package/.eslintrc.cjs +0 -12
  72. package/benchmark-configs/litellm_config.yaml +0 -86
  73. package/benchmark-configs/lynkr.env +0 -48
  74. package/benchmark-configs/portkey-config.json +0 -60
  75. package/benchmark-configs/portkey-docker.sh +0 -23
  76. package/benchmark-tier-routing.js +0 -449
  77. package/funding.json +0 -110
  78. package/src/api/middleware/validation.js +0 -261
  79. package/src/routing/drift-monitor.js +0 -113
  80. 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
+ };