thumbgate 1.29.2 → 1.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +54 -16
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/forge/forge.yaml +3 -3
- package/adapters/mcp/server-stdio.js +143 -14
- package/adapters/opencode/opencode.json +1 -1
- package/bench/observability-eval-suite.json +2 -2
- package/bin/cli.js +154 -36
- package/config/evals/generation-quality-golden.json +95 -0
- package/config/evals/rag-answer-quality-golden.json +91 -0
- package/config/evals/retrieval-hybrid-ablation.json +66 -0
- package/config/evals/retrieval-ranking-golden.json +522 -0
- package/config/gates/default.json +217 -50
- package/config/mcp-allowlists.json +215 -185
- package/config/model-tiers.json +7 -2
- package/config/post-deploy-marketing-pages.json +26 -1
- package/glama.json +6 -0
- package/package.json +94 -11
- package/public/architecture.html +130 -0
- package/public/assets/diagrams/agent-integration.png +0 -0
- package/public/assets/diagrams/before-after.svg +22 -0
- package/public/assets/diagrams/decision.svg +36 -0
- package/public/assets/diagrams/feedback-pipeline.png +0 -0
- package/public/assets/diagrams/hero-thumbs.svg +68 -0
- package/public/assets/diagrams/loop.svg +40 -0
- package/public/assets/diagrams/plugin-topology.png +0 -0
- package/public/assets/diagrams/pre-action-gate-loop.svg +59 -0
- package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
- package/public/assets/diagrams/stack.svg +18 -0
- package/public/assets/diagrams/thumbgate-architecture.png +0 -0
- package/public/case-studies.html +151 -0
- package/public/compare.html +1 -0
- package/public/dashboard.html +126 -28
- package/public/eval-scorecard.html +195 -0
- package/public/eval-scorecard.json +18 -0
- package/public/evaluations.html +168 -0
- package/public/index.html +143 -13
- package/public/numbers.html +3 -2
- package/public/pricing.html +143 -30
- package/public/whitepaper.html +189 -0
- package/scripts/a-plus-evidence-scorecard.js +303 -0
- package/scripts/activation-quickstart.js +1 -0
- package/scripts/agent-outcome-monitor.js +71 -1
- package/scripts/async-eval-observability.js +36 -11
- package/scripts/audit-trail.js +37 -1
- package/scripts/auto-promote-gates.js +149 -34
- package/scripts/billing.js +3 -1
- package/scripts/claude-feedback-sync.js +3 -2
- package/scripts/cli-feedback.js +13 -7
- package/scripts/colbert-style-maxsim.js +236 -0
- package/scripts/cross-encoder-reranker.js +359 -126
- package/scripts/dashboard-chat.js +350 -17
- package/scripts/document-intake.js +283 -7
- package/scripts/eval-quality-suite.js +204 -0
- package/scripts/feedback-aggregate.js +5 -2
- package/scripts/feedback-loop.js +359 -189
- package/scripts/feedback-paths.js +32 -13
- package/scripts/feedback-quality.js +53 -0
- package/scripts/filesystem-search.js +17 -7
- package/scripts/gates-engine.js +98 -4
- package/scripts/generate-case-study-outreach.js +253 -0
- package/scripts/generate-eval-scorecard.js +276 -0
- package/scripts/growth-campaigns.js +183 -0
- package/scripts/harness-tool-names.js +70 -0
- package/scripts/hook-runtime.js +10 -3
- package/scripts/jsonl-watcher.js +1 -0
- package/scripts/lesson-db.js +16 -5
- package/scripts/lesson-embedding-index.js +67 -20
- package/scripts/lesson-embedding-maintenance.js +177 -0
- package/scripts/lesson-inference.js +23 -4
- package/scripts/lesson-reranker.js +55 -9
- package/scripts/lesson-retrieval.js +375 -32
- package/scripts/lesson-search.js +48 -11
- package/scripts/llm-client.js +304 -15
- package/scripts/mcp-config.js +26 -5
- package/scripts/mcp-oauth.js +37 -2
- package/scripts/model-eval.js +308 -0
- package/scripts/model-tier-router.js +593 -0
- package/scripts/parallel-workflow-orchestrator.js +86 -22
- package/scripts/pragmatic-hybrid-search.js +379 -0
- package/scripts/published-cli.js +11 -1
- package/scripts/rag-document-pipeline.js +461 -0
- package/scripts/rag-structured-output.js +441 -0
- package/scripts/ragas-style-metrics.js +351 -0
- package/scripts/refresh-proof-pack.js +261 -0
- package/scripts/request-envelope.js +178 -0
- package/scripts/rerank-pipeline.js +370 -0
- package/scripts/rerank-quality-eval.js +155 -0
- package/scripts/retrieval-hybrid-ablation.js +120 -0
- package/scripts/retrieval-quality-tier.js +118 -0
- package/scripts/risk-scorer.js +144 -15
- package/scripts/secret-scanner.js +395 -4
- package/scripts/self-distill-agent.js +7 -1
- package/scripts/self-healing-check.js +25 -0
- package/scripts/skill-packs.js +183 -0
- package/scripts/slow-loop.js +72 -0
- package/scripts/statusline-links.js +1 -1
- package/scripts/statusline-local-stats.js +1 -1
- package/scripts/statusline.sh +8 -1
- package/scripts/telemetry-analytics.js +13 -1
- package/scripts/thumbgate-bench.js +13 -0
- package/scripts/thumbgate-search.js +98 -6
- package/scripts/tier-budget-guard.js +186 -0
- package/scripts/tool-kpi-tracker.js +124 -0
- package/scripts/tool-registry.js +95 -1
- package/scripts/vector-store.js +108 -4
- package/scripts/verify-marketing-pages-deployed.js +85 -3
- package/server.json +44 -0
- package/smithery.yaml +17 -0
- package/src/api/server.js +424 -99
|
@@ -14,12 +14,21 @@
|
|
|
14
14
|
// -----------------------------------------------------------------------------
|
|
15
15
|
|
|
16
16
|
const path = require('path');
|
|
17
|
+
const {
|
|
18
|
+
parseModelStructuredAnswer,
|
|
19
|
+
structuredOutputInstruction,
|
|
20
|
+
} = require('./rag-structured-output');
|
|
17
21
|
|
|
18
22
|
const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models';
|
|
19
23
|
const PERPLEXITY_ENDPOINT = 'https://api.perplexity.ai/chat/completions';
|
|
20
24
|
const DEFAULT_MODEL = 'gemini-2.5-flash';
|
|
21
25
|
const MAX_QUESTION_CHARS = 2000;
|
|
22
26
|
const MAX_CONTEXT_LESSONS = 8;
|
|
27
|
+
const GEMINI_MODEL_BY_TIER = Object.freeze({
|
|
28
|
+
nano: 'gemini-2.5-flash-lite',
|
|
29
|
+
mini: 'gemini-2.5-flash',
|
|
30
|
+
frontier: 'gemini-2.5-pro',
|
|
31
|
+
});
|
|
23
32
|
|
|
24
33
|
// Allowlist the model so a user-supplied `model` cannot route the call to an
|
|
25
34
|
// arbitrary / unexpected (or more expensive) endpoint. Anything not on the list
|
|
@@ -49,6 +58,63 @@ function resolveApiKey(opts = {}) {
|
|
|
49
58
|
return key.trim().replace(/^["']|["']$/g, '');
|
|
50
59
|
}
|
|
51
60
|
|
|
61
|
+
function resolveBudgetedProviderRoute({
|
|
62
|
+
localEndpoint,
|
|
63
|
+
localModel,
|
|
64
|
+
isPerplexity,
|
|
65
|
+
requestedModel,
|
|
66
|
+
budgetTier,
|
|
67
|
+
perplexityFrontierModel,
|
|
68
|
+
} = {}) {
|
|
69
|
+
const tier = String(budgetTier || 'mini');
|
|
70
|
+
if (localEndpoint) {
|
|
71
|
+
return {
|
|
72
|
+
allowed: true,
|
|
73
|
+
provider: 'local',
|
|
74
|
+
model: localModel,
|
|
75
|
+
tier: 'localFrontier',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (isPerplexity) {
|
|
80
|
+
if (tier === 'mini') {
|
|
81
|
+
return { allowed: true, provider: 'perplexity', model: 'sonar', tier };
|
|
82
|
+
}
|
|
83
|
+
if (tier === 'frontier' && String(perplexityFrontierModel || '').trim()) {
|
|
84
|
+
return {
|
|
85
|
+
allowed: true,
|
|
86
|
+
provider: 'perplexity',
|
|
87
|
+
model: String(perplexityFrontierModel).trim(),
|
|
88
|
+
tier,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
allowed: false,
|
|
93
|
+
provider: 'perplexity',
|
|
94
|
+
model: null,
|
|
95
|
+
tier,
|
|
96
|
+
reason: `no_perplexity_model_for_tier:${tier}`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const mappedModel = GEMINI_MODEL_BY_TIER[tier];
|
|
101
|
+
if (!mappedModel) {
|
|
102
|
+
return {
|
|
103
|
+
allowed: false,
|
|
104
|
+
provider: 'gemini',
|
|
105
|
+
model: null,
|
|
106
|
+
tier,
|
|
107
|
+
reason: `no_gemini_model_for_tier:${tier}`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const requested = String(requestedModel || '');
|
|
111
|
+
const requestedMatchesTier = (tier === 'frontier' && /pro/i.test(requested))
|
|
112
|
+
|| (tier === 'mini' && /flash/i.test(requested) && !/lite/i.test(requested))
|
|
113
|
+
|| (tier === 'nano' && /lite/i.test(requested));
|
|
114
|
+
const model = requestedMatchesTier ? requested : mappedModel;
|
|
115
|
+
return { allowed: true, provider: 'gemini', model, tier };
|
|
116
|
+
}
|
|
117
|
+
|
|
52
118
|
function debugChatFallback(label, err) {
|
|
53
119
|
if (process.env.THUMBGATE_DEBUG_CHAT !== '1') return;
|
|
54
120
|
const detail = err?.message ? err.message : String(err);
|
|
@@ -168,20 +234,60 @@ function retrieveMetricsContext() {
|
|
|
168
234
|
return snapshot;
|
|
169
235
|
}
|
|
170
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Hybrid lesson retrieval (lexical + dense RRF + rerank) when available.
|
|
239
|
+
* Falls back to lesson-search. Always merges optional LanceDB vector hits.
|
|
240
|
+
*/
|
|
241
|
+
async function retrieveHybridLessonContext(question, opts = {}) {
|
|
242
|
+
try {
|
|
243
|
+
const {
|
|
244
|
+
retrieveRelevantLessonsAsync,
|
|
245
|
+
retrieveRelevantLessons,
|
|
246
|
+
} = require(path.join(__dirname, 'lesson-retrieval'));
|
|
247
|
+
const actionContext = String(question || '');
|
|
248
|
+
const toolName = opts.toolName || 'dashboard_chat';
|
|
249
|
+
let rows = [];
|
|
250
|
+
if (typeof retrieveRelevantLessonsAsync === 'function' && opts.useHybrid !== false) {
|
|
251
|
+
rows = await retrieveRelevantLessonsAsync(toolName, actionContext, {
|
|
252
|
+
maxResults: MAX_CONTEXT_LESSONS,
|
|
253
|
+
feedbackDir: opts.feedbackDir,
|
|
254
|
+
embedder: opts.embedder,
|
|
255
|
+
});
|
|
256
|
+
} else if (typeof retrieveRelevantLessons === 'function') {
|
|
257
|
+
rows = retrieveRelevantLessons(toolName, actionContext, {
|
|
258
|
+
maxResults: MAX_CONTEXT_LESSONS,
|
|
259
|
+
feedbackDir: opts.feedbackDir,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return (rows || []).map((lesson) => lessonToContextItem({
|
|
263
|
+
id: lesson.id || lesson.memoryId,
|
|
264
|
+
signal: lesson.signal || lesson.feedback,
|
|
265
|
+
title: lesson.title || lesson.summary || '',
|
|
266
|
+
content: lesson.content || lesson.whatWentWrong || lesson.rule || lesson.summary || '',
|
|
267
|
+
tags: lesson.tags || [],
|
|
268
|
+
}));
|
|
269
|
+
} catch (err) {
|
|
270
|
+
debugChatFallback('hybrid lesson retrieval unavailable', err);
|
|
271
|
+
return [];
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
171
275
|
// Retrieve relevant stored lessons and optional raw feedback vector matches.
|
|
172
276
|
async function retrieveContext(question, opts = {}) {
|
|
173
|
-
const
|
|
277
|
+
const hybrid = await retrieveHybridLessonContext(question, opts);
|
|
278
|
+
const lessons = hybrid.length ? hybrid : retrieveLessonContext(question, opts);
|
|
174
279
|
const vectors = await retrieveVectorContext(question, opts);
|
|
175
280
|
return dedupeContextItems([...lessons, ...vectors]);
|
|
176
281
|
}
|
|
177
282
|
|
|
178
283
|
// Build a grounded RAG prompt. Pure function (testable).
|
|
179
|
-
function buildChatPrompt(question, lessons, metrics) {
|
|
284
|
+
function buildChatPrompt(question, lessons, metrics, options = {}) {
|
|
180
285
|
const q = String(question || '').slice(0, MAX_QUESTION_CHARS).trim();
|
|
181
286
|
const context = (lessons || []).map((l, i) => {
|
|
182
287
|
const mark = /pos|up/i.test(l.signal) ? 'WORKED' : (/neg|down/i.test(l.signal) ? 'MISTAKE' : 'NOTE');
|
|
183
288
|
const tags = (l.tags || []).length ? ` [tags: ${l.tags.join(', ')}]` : '';
|
|
184
|
-
|
|
289
|
+
const idHint = l.id ? ` id=${l.id}` : '';
|
|
290
|
+
return `(${i + 1}) [${mark}]${idHint} ${l.title || ''}${tags}\n ${l.content}`;
|
|
185
291
|
}).join('\n');
|
|
186
292
|
|
|
187
293
|
const metricsBlock = metrics && Object.keys(metrics).length
|
|
@@ -196,7 +302,11 @@ function buildChatPrompt(question, lessons, metrics) {
|
|
|
196
302
|
'If neither source contains the answer, say so plainly — do not invent facts.',
|
|
197
303
|
].join(' ');
|
|
198
304
|
|
|
199
|
-
|
|
305
|
+
const structured = options.structured !== false
|
|
306
|
+
? `\n${structuredOutputInstruction()}\n`
|
|
307
|
+
: '';
|
|
308
|
+
|
|
309
|
+
return `${system}${structured}\n=== Captured lessons (your data) ===\n${context || '(no relevant lessons found)'}\n${metricsBlock}\n=== Question ===\n${q}`;
|
|
200
310
|
}
|
|
201
311
|
|
|
202
312
|
// Parse the Gemini generateContent response into plain text. Pure (testable).
|
|
@@ -251,18 +361,18 @@ async function callLocalOpenAiEndpoint({ endpoint, apiKey, model, prompt, fetchI
|
|
|
251
361
|
return { ok: true, answer: answer.trim() || '(no answer returned)', sources, model: json.model || model };
|
|
252
362
|
}
|
|
253
363
|
|
|
254
|
-
async function callPerplexityEndpoint({ apiKey, prompt, fetchImpl, sources }) {
|
|
364
|
+
async function callPerplexityEndpoint({ apiKey, model, prompt, fetchImpl, sources }) {
|
|
255
365
|
const res = await fetchImpl(PERPLEXITY_ENDPOINT, {
|
|
256
366
|
method: 'POST',
|
|
257
367
|
headers: { 'content-type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
|
|
258
|
-
body: buildOpenAiChatPayload(prompt,
|
|
368
|
+
body: buildOpenAiChatPayload(prompt, model),
|
|
259
369
|
});
|
|
260
370
|
const json = await res.json().catch(() => ({}));
|
|
261
371
|
if (!res.ok) {
|
|
262
372
|
return { ok: false, error: 'perplexity_error', status: res.status, message: parseModelError(json, res.status), sources };
|
|
263
373
|
}
|
|
264
374
|
const answer = parseOpenAiChatAnswer(json);
|
|
265
|
-
return { ok: true, answer: answer.trim() || '(no answer returned)', sources, model: json.model ||
|
|
375
|
+
return { ok: true, answer: answer.trim() || '(no answer returned)', sources, model: json.model || model };
|
|
266
376
|
}
|
|
267
377
|
|
|
268
378
|
async function callGeminiEndpoint({ apiKey, model, prompt, fetchImpl, sources }) {
|
|
@@ -282,20 +392,81 @@ async function callGeminiEndpoint({ apiKey, model, prompt, fetchImpl, sources })
|
|
|
282
392
|
return { ok: true, answer: answer || '(no answer returned)', sources, model: json.modelVersion || model };
|
|
283
393
|
}
|
|
284
394
|
|
|
395
|
+
function attachStructured(result, sources) {
|
|
396
|
+
if (!result || !result.ok) return result;
|
|
397
|
+
const structured = parseModelStructuredAnswer(result.answer, sources);
|
|
398
|
+
return {
|
|
399
|
+
...result,
|
|
400
|
+
// Keep plain answer string for existing dashboard clients.
|
|
401
|
+
answer: structured.value?.answer || result.answer,
|
|
402
|
+
structured: structured.value,
|
|
403
|
+
structuredMode: structured.mode,
|
|
404
|
+
structuredValid: structured.ok,
|
|
405
|
+
structuredErrors: structured.errors || [],
|
|
406
|
+
sources,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
285
410
|
// Answer a question grounded in this install's lessons. Returns
|
|
286
|
-
// { ok, answer, sources, model } or { ok:false, error, ... }.
|
|
411
|
+
// { ok, answer, sources, model, structured?, envelope? } or { ok:false, error, ... }.
|
|
287
412
|
async function answerDataQuestion(question, opts = {}) {
|
|
413
|
+
const {
|
|
414
|
+
createRequestEnvelope,
|
|
415
|
+
finalizeRequestEnvelope,
|
|
416
|
+
summarizeRetrieval,
|
|
417
|
+
estimateTokensFromText,
|
|
418
|
+
estimateCostCents,
|
|
419
|
+
hashSensitiveText,
|
|
420
|
+
} = require('./request-envelope');
|
|
421
|
+
const { probeEmbeddingQuality } = require('./retrieval-quality-tier');
|
|
422
|
+
const { classifyTask } = require('./model-tier-router');
|
|
423
|
+
const {
|
|
424
|
+
enforceTierBudgets,
|
|
425
|
+
recordFrontierInvocation,
|
|
426
|
+
} = require('./tier-budget-guard');
|
|
427
|
+
|
|
288
428
|
const q = String(question || '').trim();
|
|
289
|
-
|
|
429
|
+
const envelope = createRequestEnvelope({
|
|
430
|
+
surface: 'dashboard_chat',
|
|
431
|
+
startedAt: Date.now(),
|
|
432
|
+
promptHash: hashSensitiveText(q),
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
if (!q) {
|
|
436
|
+
return {
|
|
437
|
+
ok: false,
|
|
438
|
+
error: 'empty_question',
|
|
439
|
+
message: 'Ask a question about your data.',
|
|
440
|
+
envelope: finalizeRequestEnvelope(envelope, { outcome: 'error', error: 'empty_question' }),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
290
443
|
if (q.length > MAX_QUESTION_CHARS) {
|
|
291
|
-
return {
|
|
444
|
+
return {
|
|
445
|
+
ok: false,
|
|
446
|
+
error: 'question_too_long',
|
|
447
|
+
message: `Question exceeds ${MAX_QUESTION_CHARS} characters.`,
|
|
448
|
+
envelope: finalizeRequestEnvelope(envelope, { outcome: 'error', error: 'question_too_long' }),
|
|
449
|
+
};
|
|
292
450
|
}
|
|
293
451
|
|
|
294
452
|
const localEndpoint = opts.localEndpoint || process.env.THUMBGATE_LOCAL_LLM_ENDPOINT || '';
|
|
295
453
|
const localModel = opts.localModel || process.env.THUMBGATE_LOCAL_LLM_MODEL || 'llama3';
|
|
296
454
|
const apiKey = resolveApiKey(opts);
|
|
455
|
+
const quality = typeof opts.qualityTier === 'object' && opts.qualityTier
|
|
456
|
+
? opts.qualityTier
|
|
457
|
+
: probeEmbeddingQuality({ indexUpdatedAtMs: opts.indexUpdatedAtMs ?? null });
|
|
458
|
+
|
|
297
459
|
const lessons = await retrieveContext(q, opts);
|
|
298
|
-
const sources = lessons.map((l) => ({
|
|
460
|
+
const sources = lessons.map((l, i) => ({
|
|
461
|
+
id: l.id || `lesson-${i + 1}`,
|
|
462
|
+
title: l.title,
|
|
463
|
+
signal: l.signal,
|
|
464
|
+
}));
|
|
465
|
+
const retrievalSummary = summarizeRetrieval(lessons, {
|
|
466
|
+
strategy: 'dashboard_hybrid',
|
|
467
|
+
qualityTier: quality.qualityTier,
|
|
468
|
+
degradedReasons: quality.degradedReasons,
|
|
469
|
+
});
|
|
299
470
|
|
|
300
471
|
if (!apiKey && !localEndpoint) {
|
|
301
472
|
return {
|
|
@@ -303,22 +474,182 @@ async function answerDataQuestion(question, opts = {}) {
|
|
|
303
474
|
error: 'no_api_key',
|
|
304
475
|
message: 'Chat is not configured. Set a valid GEMINI_API_KEY, PERPLEXITY_API_KEY, or THUMBGATE_LOCAL_LLM_ENDPOINT in the project .env.',
|
|
305
476
|
sources,
|
|
477
|
+
qualityTier: quality.qualityTier,
|
|
478
|
+
semanticClaimsAllowed: quality.semanticClaimsAllowed,
|
|
479
|
+
envelope: finalizeRequestEnvelope(envelope, {
|
|
480
|
+
outcome: 'error',
|
|
481
|
+
error: 'no_api_key',
|
|
482
|
+
retrieval: retrievalSummary,
|
|
483
|
+
qualityTier: quality.qualityTier,
|
|
484
|
+
}),
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Risk-aware tier + hard budgets (cost / frontier daily / session)
|
|
489
|
+
const classification = classifyTask({
|
|
490
|
+
type: opts.taskType || 'review',
|
|
491
|
+
contextTokens: estimateTokensFromText(q) + lessons.length * 200,
|
|
492
|
+
riskLevel: opts.riskLevel || 'low',
|
|
493
|
+
tags: opts.tags || [],
|
|
494
|
+
});
|
|
495
|
+
const budgetedTokens = estimateTokensFromText(q) + 2500;
|
|
496
|
+
const budgetDecision = enforceTierBudgets(
|
|
497
|
+
{
|
|
498
|
+
type: opts.taskType || 'review',
|
|
499
|
+
contextTokens: classification.contextTokens,
|
|
500
|
+
riskLevel: opts.riskLevel || 'low',
|
|
501
|
+
tags: opts.tags || [],
|
|
502
|
+
reason: 'dashboard_chat',
|
|
503
|
+
expectedLatencyMs: opts.expectedLatencyMs,
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
classification,
|
|
507
|
+
frontierBudget: opts.frontierBudget || null,
|
|
508
|
+
estimatedTokens: budgetedTokens,
|
|
509
|
+
},
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
if (!budgetDecision.allowed) {
|
|
513
|
+
return {
|
|
514
|
+
ok: false,
|
|
515
|
+
error: 'budget_exceeded',
|
|
516
|
+
message: `Request denied by cost/latency budget: ${budgetDecision.reasons.join('; ')}`,
|
|
517
|
+
sources,
|
|
518
|
+
qualityTier: quality.qualityTier,
|
|
519
|
+
semanticClaimsAllowed: quality.semanticClaimsAllowed,
|
|
520
|
+
envelope: finalizeRequestEnvelope(envelope, {
|
|
521
|
+
outcome: 'denied',
|
|
522
|
+
error: 'budget_exceeded',
|
|
523
|
+
tier: budgetDecision.tier,
|
|
524
|
+
budget: budgetDecision,
|
|
525
|
+
retrieval: retrievalSummary,
|
|
526
|
+
qualityTier: quality.qualityTier,
|
|
527
|
+
estimatedCostCents: budgetDecision.estimatedCostCents,
|
|
528
|
+
}),
|
|
306
529
|
};
|
|
307
530
|
}
|
|
308
531
|
|
|
309
|
-
const
|
|
532
|
+
const requestedModel = resolveModel(opts.model);
|
|
310
533
|
const metrics = retrieveMetricsContext();
|
|
311
|
-
const prompt = buildChatPrompt(q, lessons, metrics);
|
|
534
|
+
const prompt = buildChatPrompt(q, lessons, metrics, { structured: opts.structured !== false });
|
|
312
535
|
const fetchImpl = opts.fetch || globalThis.fetch;
|
|
313
536
|
const isPerplexity = apiKey && (apiKey.startsWith('pplx-') || apiKey.includes('perplexity'));
|
|
537
|
+
const route = resolveBudgetedProviderRoute({
|
|
538
|
+
localEndpoint,
|
|
539
|
+
localModel,
|
|
540
|
+
isPerplexity,
|
|
541
|
+
requestedModel,
|
|
542
|
+
budgetTier: budgetDecision.tier,
|
|
543
|
+
perplexityFrontierModel: opts.perplexityFrontierModel
|
|
544
|
+
|| process.env.THUMBGATE_PERPLEXITY_FRONTIER_MODEL,
|
|
545
|
+
});
|
|
546
|
+
const inputTokens = estimateTokensFromText(prompt);
|
|
547
|
+
|
|
548
|
+
if (!route.allowed) {
|
|
549
|
+
return {
|
|
550
|
+
ok: false,
|
|
551
|
+
error: 'budget_route_unavailable',
|
|
552
|
+
message: `Request denied: ${route.reason}`,
|
|
553
|
+
sources,
|
|
554
|
+
qualityTier: quality.qualityTier,
|
|
555
|
+
semanticClaimsAllowed: quality.semanticClaimsAllowed,
|
|
556
|
+
envelope: finalizeRequestEnvelope(envelope, {
|
|
557
|
+
outcome: 'denied',
|
|
558
|
+
error: 'budget_route_unavailable',
|
|
559
|
+
provider: route.provider,
|
|
560
|
+
tier: route.tier,
|
|
561
|
+
budget: { ...budgetDecision, route },
|
|
562
|
+
retrieval: retrievalSummary,
|
|
563
|
+
qualityTier: quality.qualityTier,
|
|
564
|
+
}),
|
|
565
|
+
};
|
|
566
|
+
}
|
|
314
567
|
|
|
315
568
|
try {
|
|
316
|
-
|
|
317
|
-
if (
|
|
318
|
-
|
|
569
|
+
let result;
|
|
570
|
+
if (localEndpoint) {
|
|
571
|
+
result = await callLocalOpenAiEndpoint({
|
|
572
|
+
endpoint: localEndpoint,
|
|
573
|
+
apiKey,
|
|
574
|
+
model: route.model,
|
|
575
|
+
prompt,
|
|
576
|
+
fetchImpl,
|
|
577
|
+
sources,
|
|
578
|
+
});
|
|
579
|
+
} else if (isPerplexity) {
|
|
580
|
+
result = await callPerplexityEndpoint({
|
|
581
|
+
apiKey,
|
|
582
|
+
model: route.model,
|
|
583
|
+
prompt,
|
|
584
|
+
fetchImpl,
|
|
585
|
+
sources,
|
|
586
|
+
});
|
|
587
|
+
} else {
|
|
588
|
+
result = await callGeminiEndpoint({
|
|
589
|
+
apiKey,
|
|
590
|
+
model: route.model,
|
|
591
|
+
prompt,
|
|
592
|
+
fetchImpl,
|
|
593
|
+
sources,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
let frontierSessionCharge = null;
|
|
598
|
+
if (route.tier === 'frontier' && result?.ok) {
|
|
599
|
+
recordFrontierInvocation();
|
|
600
|
+
if (opts.frontierBudget && typeof opts.frontierBudget.spend === 'function') {
|
|
601
|
+
frontierSessionCharge = opts.frontierBudget.spend(budgetedTokens, 'dashboard_chat');
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const structured = attachStructured(result, sources);
|
|
606
|
+
const outputTokens = estimateTokensFromText(structured.answer || '');
|
|
607
|
+
const estimatedCostCents = estimateCostCents({ inputTokens, outputTokens });
|
|
608
|
+
const finalized = finalizeRequestEnvelope(envelope, {
|
|
609
|
+
outcome: structured.ok ? 'ok' : 'error',
|
|
610
|
+
error: structured.ok ? null : structured.error,
|
|
611
|
+
model: structured.model || route.model,
|
|
612
|
+
tier: route.tier,
|
|
613
|
+
provider: route.provider,
|
|
614
|
+
inputTokens,
|
|
615
|
+
outputTokens,
|
|
616
|
+
estimatedCostCents,
|
|
617
|
+
budget: { ...budgetDecision, route, frontierSessionCharge },
|
|
618
|
+
retrieval: retrievalSummary,
|
|
619
|
+
qualityTier: quality.qualityTier,
|
|
620
|
+
structured: {
|
|
621
|
+
ok: structured.structuredValid !== false && structured.ok !== false,
|
|
622
|
+
mode: structured.structuredMode || null,
|
|
623
|
+
grounded: structured.structured?.grounded ?? null,
|
|
624
|
+
},
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
return {
|
|
628
|
+
...structured,
|
|
629
|
+
qualityTier: quality.qualityTier,
|
|
630
|
+
semanticClaimsAllowed: quality.semanticClaimsAllowed,
|
|
631
|
+
degradedReasons: quality.degradedReasons,
|
|
632
|
+
envelope: finalized,
|
|
633
|
+
};
|
|
319
634
|
} catch (err) {
|
|
320
635
|
const safeMessage = (err && err.message) ? String(err.message).split('\n')[0].slice(0, 100) : 'An unexpected error occurred.';
|
|
321
|
-
return {
|
|
636
|
+
return {
|
|
637
|
+
ok: false,
|
|
638
|
+
error: 'network',
|
|
639
|
+
message: safeMessage,
|
|
640
|
+
sources,
|
|
641
|
+
qualityTier: quality.qualityTier,
|
|
642
|
+
semanticClaimsAllowed: quality.semanticClaimsAllowed,
|
|
643
|
+
envelope: finalizeRequestEnvelope(envelope, {
|
|
644
|
+
outcome: 'error',
|
|
645
|
+
error: 'network',
|
|
646
|
+
retrieval: retrievalSummary,
|
|
647
|
+
qualityTier: quality.qualityTier,
|
|
648
|
+
budget: { ...budgetDecision, route },
|
|
649
|
+
tier: route.tier,
|
|
650
|
+
provider: route.provider,
|
|
651
|
+
}),
|
|
652
|
+
};
|
|
322
653
|
}
|
|
323
654
|
}
|
|
324
655
|
|
|
@@ -327,6 +658,8 @@ module.exports = {
|
|
|
327
658
|
buildChatPrompt,
|
|
328
659
|
parseGeminiAnswer,
|
|
329
660
|
retrieveContext,
|
|
661
|
+
retrieveHybridLessonContext,
|
|
662
|
+
resolveBudgetedProviderRoute,
|
|
330
663
|
DEFAULT_MODEL,
|
|
331
664
|
MAX_QUESTION_CHARS,
|
|
332
665
|
};
|