thumbgate 1.30.0 → 1.34.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 +105 -10
- package/adapters/opencode/opencode.json +1 -1
- package/bench/observability-eval-suite.json +2 -2
- package/bin/cli.js +168 -31
- 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/claim-verifiers.example.json +42 -0
- package/config/gates/claim-verifiers.json +25 -0
- package/config/gates/default.json +217 -50
- package/config/mcp-allowlists.json +233 -206
- package/config/model-tiers.json +7 -2
- package/glama.json +6 -0
- package/hooks/hooks.json +1 -1
- package/package.json +69 -12
- package/public/assets/diagrams/before-after.svg +17 -16
- package/public/assets/diagrams/hero-thumbs.svg +68 -0
- package/public/assets/diagrams/loop.svg +19 -13
- package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
- package/public/compare.html +1 -0
- package/public/dashboard.html +126 -28
- package/public/evaluations.html +1 -1
- package/public/index.html +142 -13
- package/public/numbers.html +3 -2
- package/public/pricing.html +143 -30
- package/scripts/a-plus-evidence-scorecard.js +303 -0
- package/scripts/agent-readiness.js +110 -0
- 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/auto-wire-hooks.js +20 -8
- package/scripts/cli-schema.js +14 -0
- package/scripts/colbert-style-maxsim.js +236 -0
- package/scripts/cross-encoder-reranker.js +356 -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-loop.js +115 -7
- package/scripts/feedback-paths.js +32 -13
- package/scripts/feedback-quality.js +53 -0
- package/scripts/feedback-schema.js +3 -0
- package/scripts/file-ledger-lock.js +130 -0
- package/scripts/filesystem-search.js +17 -7
- package/scripts/financial-control-plane.js +1514 -0
- package/scripts/gates-engine.js +202 -7
- package/scripts/gemini-embedding-policy.js +1 -0
- package/scripts/harness-tool-names.js +70 -0
- package/scripts/hook-runtime.js +15 -3
- package/scripts/hook-stop-anti-claim.js +63 -3
- package/scripts/human-escalation.js +353 -41
- 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-reranker.js +55 -9
- package/scripts/lesson-retrieval.js +305 -29
- package/scripts/lesson-search.js +22 -8
- package/scripts/llm-client.js +304 -15
- package/scripts/model-tier-router.js +593 -0
- package/scripts/pragmatic-hybrid-search.js +379 -0
- package/scripts/provider-action-normalizer.js +11 -4
- 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/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/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.sh +8 -1
- package/scripts/telemetry-analytics.js +13 -1
- package/scripts/thumbgate-search.js +98 -6
- package/scripts/tier-budget-guard.js +186 -0
- package/scripts/tool-registry.js +141 -5
- package/scripts/universal-claim-evaluator.js +767 -0
- package/scripts/vector-store.js +154 -17
- package/scripts/verify-marketing-pages-deployed.js +85 -3
- package/scripts/workflow-sentinel.js +77 -11
- package/server.json +44 -0
- package/smithery.yaml +17 -0
- package/src/api/server.js +196 -13
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Offline Ragas-style generation metrics for ThumbGate.
|
|
5
|
+
*
|
|
6
|
+
* Metrics (deterministic, no API key required):
|
|
7
|
+
* - faithfulness: answer claims supported by context (no contradiction drift)
|
|
8
|
+
* - groundedness: answer content covered by retrieved context
|
|
9
|
+
* - answer_relevance: answer addresses the query (token / keyword overlap)
|
|
10
|
+
* - context_precision / context_recall: retrieval-side complements (optional inputs)
|
|
11
|
+
*
|
|
12
|
+
* Optional LLM path can refine scores when ANTHROPIC_API_KEY is present; never
|
|
13
|
+
* fabricates a pass when offline scores fail floors.
|
|
14
|
+
*
|
|
15
|
+
* Honesty: these are *lexical/claim* proxies of Ragas metrics, not the full
|
|
16
|
+
* neural Ragas library. They are stable in CI and comparable across commits.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const METRICS_VERSION = '2026-07-31.a-plus.1';
|
|
20
|
+
|
|
21
|
+
const STOP = new Set([
|
|
22
|
+
'a', 'an', 'the', 'and', 'or', 'to', 'of', 'in', 'on', 'for', 'is', 'are', 'was',
|
|
23
|
+
'be', 'as', 'at', 'by', 'with', 'from', 'that', 'this', 'it', 'we', 'you', 'our',
|
|
24
|
+
'not', 'no', 'do', 'does', 'did', 'if', 'then', 'than', 'into', 'via',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
function tokenize(text) {
|
|
28
|
+
return String(text || '')
|
|
29
|
+
.toLowerCase()
|
|
30
|
+
.replace(/[^\w\s.-]/g, ' ')
|
|
31
|
+
.split(/[\s_]+/)
|
|
32
|
+
.filter((t) => t.length >= 3 && !STOP.has(t));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function uniqueTokens(text) {
|
|
36
|
+
return [...new Set(tokenize(text))];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function jaccard(aTokens, bTokens) {
|
|
40
|
+
const a = new Set(aTokens);
|
|
41
|
+
const b = new Set(bTokens);
|
|
42
|
+
if (a.size === 0 && b.size === 0) return 1;
|
|
43
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
44
|
+
let inter = 0;
|
|
45
|
+
for (const t of a) if (b.has(t)) inter += 1;
|
|
46
|
+
return inter / (a.size + b.size - inter);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function coverage(answerTokens, contextTokens) {
|
|
50
|
+
const ctx = new Set(contextTokens);
|
|
51
|
+
if (answerTokens.length === 0) return 0;
|
|
52
|
+
let hits = 0;
|
|
53
|
+
for (const t of answerTokens) if (ctx.has(t)) hits += 1;
|
|
54
|
+
return hits / answerTokens.length;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function splitClaims(answer) {
|
|
58
|
+
return String(answer || '')
|
|
59
|
+
.split(/[.!?\n]+/)
|
|
60
|
+
.map((s) => s.trim())
|
|
61
|
+
.filter((s) => s.length >= 12);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Faithfulness: each answer claim must be supported by context (token coverage),
|
|
66
|
+
* and answer must not introduce contradiction tokens absent from context when
|
|
67
|
+
* expected constraint is known.
|
|
68
|
+
*
|
|
69
|
+
* @param {{ answer: string, context: string, expectedConstraint?: string }} input
|
|
70
|
+
* @returns {{ score: number, details: object }}
|
|
71
|
+
*/
|
|
72
|
+
function faithfulness(input = {}) {
|
|
73
|
+
const answer = String(input.answer || '');
|
|
74
|
+
const context = String(input.context || '');
|
|
75
|
+
const expected = String(input.expectedConstraint || '');
|
|
76
|
+
const claims = splitClaims(answer);
|
|
77
|
+
const ctxTokens = uniqueTokens(context);
|
|
78
|
+
const ansTokens = uniqueTokens(answer);
|
|
79
|
+
|
|
80
|
+
let claimScores = [];
|
|
81
|
+
if (claims.length === 0) {
|
|
82
|
+
claimScores = [coverage(ansTokens, ctxTokens)];
|
|
83
|
+
} else {
|
|
84
|
+
claimScores = claims.map((c) => coverage(uniqueTokens(c), ctxTokens));
|
|
85
|
+
}
|
|
86
|
+
const meanClaim = claimScores.reduce((a, b) => a + b, 0) / claimScores.length;
|
|
87
|
+
|
|
88
|
+
// Expected constraint present in answer OR context when provided
|
|
89
|
+
let constraintHit = 1;
|
|
90
|
+
if (expected) {
|
|
91
|
+
const exp = expected.toLowerCase();
|
|
92
|
+
const hay = `${answer}\n${context}`.toLowerCase();
|
|
93
|
+
constraintHit = hay.includes(exp) || jaccard(uniqueTokens(expected), ansTokens) >= 0.35
|
|
94
|
+
? 1
|
|
95
|
+
: (jaccard(uniqueTokens(expected), ctxTokens) >= 0.35 ? 0.7 : 0.2);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Soft contradiction: answer has "always" where context says "never" for same stem
|
|
99
|
+
let contradictionPenalty = 0;
|
|
100
|
+
const ansLower = answer.toLowerCase();
|
|
101
|
+
const ctxLower = context.toLowerCase();
|
|
102
|
+
if (/\balways\b/.test(ansLower) && /\bnever\b/.test(ctxLower) && !/\bnever\b/.test(ansLower)) {
|
|
103
|
+
contradictionPenalty = 0.15;
|
|
104
|
+
}
|
|
105
|
+
if (/\bnever\b/.test(ansLower) && /\balways\b/.test(ctxLower) && !/\balways\b/.test(ansLower)) {
|
|
106
|
+
contradictionPenalty = Math.max(contradictionPenalty, 0.15);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const score = Math.max(0, Math.min(1, 0.65 * meanClaim + 0.35 * constraintHit - contradictionPenalty));
|
|
110
|
+
return {
|
|
111
|
+
score: Number(score.toFixed(4)),
|
|
112
|
+
details: {
|
|
113
|
+
meanClaimSupport: Number(meanClaim.toFixed(4)),
|
|
114
|
+
constraintHit,
|
|
115
|
+
contradictionPenalty,
|
|
116
|
+
claimCount: claims.length,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Groundedness: how much of the answer is attributable to context.
|
|
123
|
+
* Short policy answers may paraphrase; credit partial stem/overlap with context.
|
|
124
|
+
* @param {{ answer: string, context: string }} input
|
|
125
|
+
*/
|
|
126
|
+
function groundedness(input = {}) {
|
|
127
|
+
const ansTokens = uniqueTokens(input.answer);
|
|
128
|
+
const ctxTokens = uniqueTokens(input.context);
|
|
129
|
+
const cov = coverage(ansTokens, ctxTokens);
|
|
130
|
+
const jac = jaccard(ansTokens, ctxTokens);
|
|
131
|
+
// Soft stem: token prefix match (≥4 chars) counts as half a hit
|
|
132
|
+
let softHits = 0;
|
|
133
|
+
for (const a of ansTokens) {
|
|
134
|
+
if (ctxTokens.includes(a)) continue;
|
|
135
|
+
if (a.length >= 4 && ctxTokens.some((c) => c.startsWith(a.slice(0, 4)) || a.startsWith(c.slice(0, 4)))) {
|
|
136
|
+
softHits += 0.5;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const softCov = ansTokens.length
|
|
140
|
+
? Math.min(1, (ansTokens.filter((t) => ctxTokens.includes(t)).length + softHits) / ansTokens.length)
|
|
141
|
+
: 0;
|
|
142
|
+
const score = Math.max(0, Math.min(1, 0.55 * softCov + 0.25 * cov + 0.2 * jac));
|
|
143
|
+
return {
|
|
144
|
+
score: Number(score.toFixed(4)),
|
|
145
|
+
details: {
|
|
146
|
+
coverage: Number(cov.toFixed(4)),
|
|
147
|
+
softCoverage: Number(softCov.toFixed(4)),
|
|
148
|
+
jaccard: Number(jac.toFixed(4)),
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Answer relevance: does the answer address the query?
|
|
155
|
+
* @param {{ query: string, answer: string, expectedKeywords?: string[] }} input
|
|
156
|
+
*/
|
|
157
|
+
function answerRelevance(input = {}) {
|
|
158
|
+
const qTokens = uniqueTokens(input.query);
|
|
159
|
+
const aTokens = uniqueTokens(input.answer);
|
|
160
|
+
const base = jaccard(qTokens, aTokens);
|
|
161
|
+
const contentOverlap = coverage(qTokens, aTokens);
|
|
162
|
+
// Substring keyword hits in answer (handles multi-word expected keywords)
|
|
163
|
+
let keywordHit = 1;
|
|
164
|
+
const keywords = Array.isArray(input.expectedKeywords) ? input.expectedKeywords : [];
|
|
165
|
+
const hay = String(input.answer || '').toLowerCase();
|
|
166
|
+
if (keywords.length > 0) {
|
|
167
|
+
const hits = keywords.filter((k) => hay.includes(String(k).toLowerCase())).length;
|
|
168
|
+
keywordHit = hits / keywords.length;
|
|
169
|
+
}
|
|
170
|
+
// Query term substring presence in answer (e.g. "main" in both)
|
|
171
|
+
let qPresent = 0;
|
|
172
|
+
if (qTokens.length) {
|
|
173
|
+
for (const t of qTokens) {
|
|
174
|
+
if (hay.includes(t)) qPresent += 1;
|
|
175
|
+
}
|
|
176
|
+
qPresent /= qTokens.length;
|
|
177
|
+
}
|
|
178
|
+
const score = Math.max(
|
|
179
|
+
0,
|
|
180
|
+
Math.min(1, 0.25 * base + 0.2 * contentOverlap + 0.35 * keywordHit + 0.2 * qPresent),
|
|
181
|
+
);
|
|
182
|
+
return {
|
|
183
|
+
score: Number(score.toFixed(4)),
|
|
184
|
+
details: {
|
|
185
|
+
jaccard: Number(base.toFixed(4)),
|
|
186
|
+
queryCoverage: Number(contentOverlap.toFixed(4)),
|
|
187
|
+
keywordHit: Number(keywordHit.toFixed(4)),
|
|
188
|
+
queryTermPresence: Number(qPresent.toFixed(4)),
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Context precision: fraction of context chunks relevant to query (binary token overlap).
|
|
195
|
+
* @param {{ query: string, contexts: string[] }} input
|
|
196
|
+
*/
|
|
197
|
+
function contextPrecision(input = {}) {
|
|
198
|
+
const chunks = Array.isArray(input.contexts) ? input.contexts : [input.context].filter(Boolean);
|
|
199
|
+
if (chunks.length === 0) return { score: 0, details: { relevant: 0, total: 0 } };
|
|
200
|
+
const q = uniqueTokens(input.query);
|
|
201
|
+
let relevant = 0;
|
|
202
|
+
for (const c of chunks) {
|
|
203
|
+
if (jaccard(q, uniqueTokens(c)) >= 0.08 || coverage(q, uniqueTokens(c)) >= 0.2) {
|
|
204
|
+
relevant += 1;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
score: Number((relevant / chunks.length).toFixed(4)),
|
|
209
|
+
details: { relevant, total: chunks.length },
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Context recall: expected constraint / gold keywords found in context.
|
|
215
|
+
* @param {{ context: string, expectedConstraint?: string, goldKeywords?: string[] }} input
|
|
216
|
+
*/
|
|
217
|
+
function contextRecall(input = {}) {
|
|
218
|
+
const ctx = String(input.context || '').toLowerCase();
|
|
219
|
+
const keys = [];
|
|
220
|
+
if (input.expectedConstraint) keys.push(...uniqueTokens(input.expectedConstraint));
|
|
221
|
+
if (Array.isArray(input.goldKeywords)) {
|
|
222
|
+
for (const k of input.goldKeywords) keys.push(...uniqueTokens(k));
|
|
223
|
+
}
|
|
224
|
+
const uniq = [...new Set(keys)];
|
|
225
|
+
if (uniq.length === 0) {
|
|
226
|
+
return { score: ctx.length > 0 ? 1 : 0, details: { hits: 0, total: 0 } };
|
|
227
|
+
}
|
|
228
|
+
let hits = 0;
|
|
229
|
+
for (const k of uniq) {
|
|
230
|
+
if (ctx.includes(k)) hits += 1;
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
score: Number((hits / uniq.length).toFixed(4)),
|
|
234
|
+
details: { hits, total: uniq.length },
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Score a single generation case offline.
|
|
240
|
+
* @param {object} caseRow
|
|
241
|
+
* @returns {object}
|
|
242
|
+
*/
|
|
243
|
+
function scoreGenerationCase(caseRow = {}) {
|
|
244
|
+
const context = caseRow.context
|
|
245
|
+
|| (Array.isArray(caseRow.contexts) ? caseRow.contexts.join('\n') : '');
|
|
246
|
+
const answer = caseRow.answer || caseRow.generatedAnswer || '';
|
|
247
|
+
const query = caseRow.query || '';
|
|
248
|
+
|
|
249
|
+
const f = faithfulness({
|
|
250
|
+
answer,
|
|
251
|
+
context,
|
|
252
|
+
expectedConstraint: caseRow.expectedConstraint || caseRow.expectedRuleHit,
|
|
253
|
+
});
|
|
254
|
+
const g = groundedness({ answer, context });
|
|
255
|
+
const ar = answerRelevance({
|
|
256
|
+
query,
|
|
257
|
+
answer,
|
|
258
|
+
expectedKeywords: caseRow.expectedKeywords,
|
|
259
|
+
});
|
|
260
|
+
const cp = contextPrecision({
|
|
261
|
+
query,
|
|
262
|
+
contexts: caseRow.contexts || (context ? [context] : []),
|
|
263
|
+
});
|
|
264
|
+
const cr = contextRecall({
|
|
265
|
+
context,
|
|
266
|
+
expectedConstraint: caseRow.expectedConstraint || caseRow.expectedRuleHit,
|
|
267
|
+
goldKeywords: caseRow.goldKeywords || caseRow.expectedKeywords,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
id: caseRow.id || 'case',
|
|
272
|
+
faithfulness: f.score,
|
|
273
|
+
groundedness: g.score,
|
|
274
|
+
answer_relevance: ar.score,
|
|
275
|
+
context_precision: cp.score,
|
|
276
|
+
context_recall: cr.score,
|
|
277
|
+
details: { faithfulness: f.details, groundedness: g.details, answer_relevance: ar.details },
|
|
278
|
+
metricsVersion: METRICS_VERSION,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Aggregate generation cases + apply floors.
|
|
284
|
+
*/
|
|
285
|
+
function evaluateGenerationGolden(golden, options = {}) {
|
|
286
|
+
const cases = Array.isArray(golden?.cases) ? golden.cases : [];
|
|
287
|
+
const thresholds = {
|
|
288
|
+
minCases: 6,
|
|
289
|
+
minFaithfulness: 0.55,
|
|
290
|
+
minGroundedness: 0.5,
|
|
291
|
+
minAnswerRelevance: 0.45,
|
|
292
|
+
minContextRecall: 0.7,
|
|
293
|
+
...(golden?.thresholds || {}),
|
|
294
|
+
...(options.thresholds || {}),
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const rows = cases.map((c) => scoreGenerationCase(c));
|
|
298
|
+
const mean = (key) => (rows.length
|
|
299
|
+
? rows.reduce((s, r) => s + (Number(r[key]) || 0), 0) / rows.length
|
|
300
|
+
: 0);
|
|
301
|
+
|
|
302
|
+
const summary = {
|
|
303
|
+
metricsVersion: METRICS_VERSION,
|
|
304
|
+
cases: rows.length,
|
|
305
|
+
faithfulness: Number(mean('faithfulness').toFixed(4)),
|
|
306
|
+
groundedness: Number(mean('groundedness').toFixed(4)),
|
|
307
|
+
answer_relevance: Number(mean('answer_relevance').toFixed(4)),
|
|
308
|
+
context_precision: Number(mean('context_precision').toFixed(4)),
|
|
309
|
+
context_recall: Number(mean('context_recall').toFixed(4)),
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const failures = [];
|
|
313
|
+
if (summary.cases < thresholds.minCases) {
|
|
314
|
+
failures.push(`cases ${summary.cases} < ${thresholds.minCases}`);
|
|
315
|
+
}
|
|
316
|
+
if (summary.faithfulness < thresholds.minFaithfulness) {
|
|
317
|
+
failures.push(`faithfulness ${summary.faithfulness} < ${thresholds.minFaithfulness}`);
|
|
318
|
+
}
|
|
319
|
+
if (summary.groundedness < thresholds.minGroundedness) {
|
|
320
|
+
failures.push(`groundedness ${summary.groundedness} < ${thresholds.minGroundedness}`);
|
|
321
|
+
}
|
|
322
|
+
if (summary.answer_relevance < thresholds.minAnswerRelevance) {
|
|
323
|
+
failures.push(`answer_relevance ${summary.answer_relevance} < ${thresholds.minAnswerRelevance}`);
|
|
324
|
+
}
|
|
325
|
+
if (summary.context_recall < thresholds.minContextRecall) {
|
|
326
|
+
failures.push(`context_recall ${summary.context_recall} < ${thresholds.minContextRecall}`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
passed: failures.length === 0,
|
|
331
|
+
failures,
|
|
332
|
+
thresholds,
|
|
333
|
+
summary,
|
|
334
|
+
rows,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
module.exports = {
|
|
339
|
+
METRICS_VERSION,
|
|
340
|
+
tokenize,
|
|
341
|
+
uniqueTokens,
|
|
342
|
+
jaccard,
|
|
343
|
+
coverage,
|
|
344
|
+
faithfulness,
|
|
345
|
+
groundedness,
|
|
346
|
+
answerRelevance,
|
|
347
|
+
contextPrecision,
|
|
348
|
+
contextRecall,
|
|
349
|
+
scoreGenerationCase,
|
|
350
|
+
evaluateGenerationGolden,
|
|
351
|
+
};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Production request envelope — one schema for LLM + retrieval observability.
|
|
5
|
+
*
|
|
6
|
+
* Every dashboard chat / routed generation path should create an envelope at
|
|
7
|
+
* start and finalize it before return so latency, cost, retrieval, and
|
|
8
|
+
* structured-output status are greppable from one object.
|
|
9
|
+
*
|
|
10
|
+
* Privacy: never store full prompts/tool payloads here; use redacted previews only.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const crypto = require('node:crypto');
|
|
14
|
+
const {
|
|
15
|
+
buildAgentAuditSpan,
|
|
16
|
+
evaluateAgentAuditTrace,
|
|
17
|
+
} = require('./agent-audit-trace');
|
|
18
|
+
|
|
19
|
+
const ENVELOPE_VERSION = '2026-07-31.p0.1';
|
|
20
|
+
|
|
21
|
+
function newTraceId() {
|
|
22
|
+
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
|
|
23
|
+
return `tr_${Date.now().toString(36)}_${crypto.randomBytes(6).toString('hex')}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function hashSensitiveText(text) {
|
|
27
|
+
return crypto.createHash('sha256').update(String(text || '')).digest('hex');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {object} [seed]
|
|
32
|
+
* @returns {object}
|
|
33
|
+
*/
|
|
34
|
+
function createRequestEnvelope(seed = {}) {
|
|
35
|
+
const startedAt = Number.isFinite(seed.startedAt) ? seed.startedAt : Date.now();
|
|
36
|
+
const traceId = seed.traceId || newTraceId();
|
|
37
|
+
const auditTrace = seed.promptHash
|
|
38
|
+
? {
|
|
39
|
+
runId: traceId,
|
|
40
|
+
spans: [buildAgentAuditSpan({
|
|
41
|
+
runId: traceId,
|
|
42
|
+
spanId: `${traceId}:input`,
|
|
43
|
+
stage: 'input',
|
|
44
|
+
promptHash: seed.promptHash,
|
|
45
|
+
model: seed.model || null,
|
|
46
|
+
})],
|
|
47
|
+
}
|
|
48
|
+
: null;
|
|
49
|
+
return {
|
|
50
|
+
envelopeVersion: ENVELOPE_VERSION,
|
|
51
|
+
traceId,
|
|
52
|
+
startedAt,
|
|
53
|
+
endedAt: null,
|
|
54
|
+
latencyMs: null,
|
|
55
|
+
surface: seed.surface || 'unknown',
|
|
56
|
+
model: seed.model || null,
|
|
57
|
+
tier: seed.tier || null,
|
|
58
|
+
provider: seed.provider || null,
|
|
59
|
+
inputTokens: seed.inputTokens ?? null,
|
|
60
|
+
outputTokens: seed.outputTokens ?? null,
|
|
61
|
+
estimatedCostCents: seed.estimatedCostCents ?? null,
|
|
62
|
+
budget: seed.budget || null,
|
|
63
|
+
retrieval: seed.retrieval || null,
|
|
64
|
+
structured: seed.structured || null,
|
|
65
|
+
qualityTier: seed.qualityTier || null,
|
|
66
|
+
outcome: seed.outcome || 'pending',
|
|
67
|
+
error: seed.error || null,
|
|
68
|
+
auditTrace,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Finalize timing + optional fields. Pure-ish: returns a new object.
|
|
74
|
+
* @param {object} envelope
|
|
75
|
+
* @param {object} [patch]
|
|
76
|
+
*/
|
|
77
|
+
function finalizeRequestEnvelope(envelope, patch = {}) {
|
|
78
|
+
const endedAt = Number.isFinite(patch.endedAt) ? patch.endedAt : Date.now();
|
|
79
|
+
const startedAt = Number(envelope?.startedAt) || endedAt;
|
|
80
|
+
const finalized = {
|
|
81
|
+
...envelope,
|
|
82
|
+
...patch,
|
|
83
|
+
endedAt,
|
|
84
|
+
latencyMs: Math.max(0, endedAt - startedAt),
|
|
85
|
+
outcome: patch.outcome || envelope?.outcome || 'ok',
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
if (envelope?.auditTrace?.runId && Array.isArray(envelope.auditTrace.spans)) {
|
|
89
|
+
const evidenceIds = (patch.retrieval?.top || [])
|
|
90
|
+
.map((row) => row?.id)
|
|
91
|
+
.filter(Boolean);
|
|
92
|
+
const priorSpans = envelope.auditTrace.spans
|
|
93
|
+
.filter((span) => span?.stage !== 'decision');
|
|
94
|
+
const decisionSpan = buildAgentAuditSpan({
|
|
95
|
+
runId: envelope.auditTrace.runId,
|
|
96
|
+
spanId: `${envelope.auditTrace.runId}:decision`,
|
|
97
|
+
parentSpanId: priorSpans[0]?.spanId || null,
|
|
98
|
+
stage: 'decision',
|
|
99
|
+
model: finalized.model,
|
|
100
|
+
decision: finalized.outcome,
|
|
101
|
+
dataAccessed: evidenceIds.length ? ['retrieved_lessons'] : [],
|
|
102
|
+
evidenceIds,
|
|
103
|
+
safetyEvents: finalized.error ? [finalized.error] : [],
|
|
104
|
+
inputTokens: finalized.inputTokens,
|
|
105
|
+
outputTokens: finalized.outputTokens,
|
|
106
|
+
latencyMs: finalized.latencyMs,
|
|
107
|
+
});
|
|
108
|
+
const auditTrace = {
|
|
109
|
+
runId: envelope.auditTrace.runId,
|
|
110
|
+
spans: [...priorSpans, decisionSpan],
|
|
111
|
+
};
|
|
112
|
+
finalized.auditTrace = {
|
|
113
|
+
...auditTrace,
|
|
114
|
+
evaluation: evaluateAgentAuditTrace(auditTrace),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return finalized;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Compact retrieval summary for the envelope (no lesson bodies).
|
|
123
|
+
* @param {Array<object>} rows
|
|
124
|
+
* @param {object} [meta]
|
|
125
|
+
*/
|
|
126
|
+
function summarizeRetrieval(rows = [], meta = {}) {
|
|
127
|
+
const top = (rows || []).slice(0, 8).map((r, i) => {
|
|
128
|
+
const rawScore = r.rerankedScore ?? r.relevanceScore ?? r.score;
|
|
129
|
+
const numericScore = Number(rawScore);
|
|
130
|
+
return {
|
|
131
|
+
rank: i + 1,
|
|
132
|
+
id: r.id || r.memoryId || null,
|
|
133
|
+
score: rawScore == null || !Number.isFinite(numericScore) ? null : numericScore,
|
|
134
|
+
signal: r.signal || null,
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
return {
|
|
138
|
+
strategy: meta.strategy || meta.retrievalStrategy || null,
|
|
139
|
+
count: Array.isArray(rows) ? rows.length : 0,
|
|
140
|
+
qualityTier: meta.qualityTier || null,
|
|
141
|
+
degradedReasons: meta.degradedReasons || [],
|
|
142
|
+
top,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Estimate tokens from text length (rough, offline-safe).
|
|
148
|
+
* @param {string} text
|
|
149
|
+
*/
|
|
150
|
+
function estimateTokensFromText(text) {
|
|
151
|
+
const s = String(text || '');
|
|
152
|
+
if (!s) return 0;
|
|
153
|
+
return Math.max(1, Math.ceil(s.length / 4));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Rough USD cents from token counts using Sonnet-ish defaults (conservative).
|
|
158
|
+
* @param {{ inputTokens?: number, outputTokens?: number, inputPerM?: number, outputPerM?: number }} opts
|
|
159
|
+
*/
|
|
160
|
+
function estimateCostCents(opts = {}) {
|
|
161
|
+
const input = Number(opts.inputTokens) || 0;
|
|
162
|
+
const output = Number(opts.outputTokens) || 0;
|
|
163
|
+
const inputPerM = Number(opts.inputPerM) || 3;
|
|
164
|
+
const outputPerM = Number(opts.outputPerM) || 15;
|
|
165
|
+
const usd = (input / 1e6) * inputPerM + (output / 1e6) * outputPerM;
|
|
166
|
+
return Number((usd * 100).toFixed(4));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = {
|
|
170
|
+
ENVELOPE_VERSION,
|
|
171
|
+
newTraceId,
|
|
172
|
+
hashSensitiveText,
|
|
173
|
+
createRequestEnvelope,
|
|
174
|
+
finalizeRequestEnvelope,
|
|
175
|
+
summarizeRetrieval,
|
|
176
|
+
estimateTokensFromText,
|
|
177
|
+
estimateCostCents,
|
|
178
|
+
};
|