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
|
@@ -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,261 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* refresh-proof-pack.js — regenerate (or check) the public evaluation scorecard.
|
|
6
|
+
*
|
|
7
|
+
* Cadence (repo policy): GitHub Actions schedule is limited to CodeQL. Noncritical
|
|
8
|
+
* loops run via workflow_dispatch or local LaunchAgent:
|
|
9
|
+
* npm run proof-pack:refresh # write public/eval-scorecard.html
|
|
10
|
+
* npm run proof-pack:refresh:check # CI gate: metrics must still match
|
|
11
|
+
* npm run proof-pack:schedule # install daily local LaunchAgent
|
|
12
|
+
*
|
|
13
|
+
* Isolation: generation goes through generate-eval-scorecard → thumbgate-bench
|
|
14
|
+
* isolated runtime (strict enforcement pinned).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
const { spawnSync } = require('node:child_process');
|
|
20
|
+
|
|
21
|
+
const PROJECT_ROOT = path.resolve(__dirname, '..');
|
|
22
|
+
const SCORECARD_HTML = path.join(PROJECT_ROOT, 'public', 'eval-scorecard.html');
|
|
23
|
+
const SCORECARD_JSON = path.join(PROJECT_ROOT, 'public', 'eval-scorecard.json');
|
|
24
|
+
|
|
25
|
+
function parseArgs(argv = process.argv.slice(2)) {
|
|
26
|
+
const args = {
|
|
27
|
+
write: false,
|
|
28
|
+
check: false,
|
|
29
|
+
json: false,
|
|
30
|
+
help: false,
|
|
31
|
+
minScore: 90,
|
|
32
|
+
};
|
|
33
|
+
for (const arg of argv) {
|
|
34
|
+
if (arg === '--write') args.write = true;
|
|
35
|
+
else if (arg === '--check') args.check = true;
|
|
36
|
+
else if (arg === '--json') args.json = true;
|
|
37
|
+
else if (arg === '--help' || arg === '-h') args.help = true;
|
|
38
|
+
else if (arg.startsWith('--min-score=')) {
|
|
39
|
+
args.minScore = Number(arg.slice('--min-score='.length));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (!args.write && !args.check) {
|
|
43
|
+
// Default to write for operator cadence runs.
|
|
44
|
+
args.write = true;
|
|
45
|
+
}
|
|
46
|
+
return args;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function printHelp() {
|
|
50
|
+
console.log(`Usage: node scripts/refresh-proof-pack.js [--write] [--check] [--json] [--min-score=90]
|
|
51
|
+
|
|
52
|
+
--write Regenerate public/eval-scorecard.html (+ .json sidecar)
|
|
53
|
+
--check Fail if committed scorecard metrics diverge from a fresh bench run
|
|
54
|
+
--json Print machine-readable summary to stdout
|
|
55
|
+
--min-score=N Minimum composite score (default 90)
|
|
56
|
+
`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function extractMetricsFromHtml(html) {
|
|
60
|
+
const metrics = {};
|
|
61
|
+
// Prefer JSON-LD Dataset variableMeasured
|
|
62
|
+
const ldMatch = html.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/);
|
|
63
|
+
if (ldMatch) {
|
|
64
|
+
try {
|
|
65
|
+
const ld = JSON.parse(ldMatch[1]);
|
|
66
|
+
const vars = Array.isArray(ld.variableMeasured) ? ld.variableMeasured : [];
|
|
67
|
+
for (const item of vars) {
|
|
68
|
+
if (item && item.name != null) metrics[item.name] = item.value;
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// fall through to regex
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const scoreMatch = html.match(/composite score <strong>([^<]+)<\/strong>/i)
|
|
75
|
+
|| html.match(/composite score[^0-9]*([0-9]+)/i);
|
|
76
|
+
if (scoreMatch && metrics.score == null) {
|
|
77
|
+
metrics.score = Number(scoreMatch[1]);
|
|
78
|
+
}
|
|
79
|
+
const passMatch = html.match(/Overall:\s*<span class="(good|bad)">(PASSED|FAILED)<\/span>/i);
|
|
80
|
+
if (passMatch) {
|
|
81
|
+
metrics.passedLabel = passMatch[2].toUpperCase();
|
|
82
|
+
}
|
|
83
|
+
return metrics;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function normalizeMetrics(metrics = {}) {
|
|
87
|
+
const keys = [
|
|
88
|
+
'score',
|
|
89
|
+
'taskSuccessRate',
|
|
90
|
+
'unsafeActionRate',
|
|
91
|
+
'blockedUnsafeRate',
|
|
92
|
+
'capabilityRate',
|
|
93
|
+
'falseBlockRate',
|
|
94
|
+
'replayStability',
|
|
95
|
+
];
|
|
96
|
+
const out = {};
|
|
97
|
+
for (const key of keys) {
|
|
98
|
+
if (metrics[key] == null || metrics[key] === '') continue;
|
|
99
|
+
const n = Number(metrics[key]);
|
|
100
|
+
out[key] = Number.isFinite(n) ? Number(n.toFixed(4)) : metrics[key];
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function metricsEqual(a, b, options = {}) {
|
|
106
|
+
const left = normalizeMetrics(a);
|
|
107
|
+
const right = normalizeMetrics(b);
|
|
108
|
+
// When comparing a committed HTML extract to a fresh bench report, only
|
|
109
|
+
// assert keys present on the committed side (JSON-LD may omit some rates).
|
|
110
|
+
const keys = options.keys
|
|
111
|
+
|| (options.committedOnly
|
|
112
|
+
? Object.keys(left)
|
|
113
|
+
: [...new Set([...Object.keys(left), ...Object.keys(right)])]);
|
|
114
|
+
const diffs = [];
|
|
115
|
+
for (const key of keys) {
|
|
116
|
+
if (left[key] !== right[key]) {
|
|
117
|
+
diffs.push({ key, committed: left[key], fresh: right[key] });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { equal: diffs.length === 0, diffs, left, right };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function runFreshBench() {
|
|
124
|
+
const { generate, runBench } = require('./generate-eval-scorecard');
|
|
125
|
+
// Prefer direct bench for metrics; generate for write path.
|
|
126
|
+
let report;
|
|
127
|
+
try {
|
|
128
|
+
report = runBench();
|
|
129
|
+
} catch {
|
|
130
|
+
// generate also runs the bench
|
|
131
|
+
report = null;
|
|
132
|
+
}
|
|
133
|
+
return { generate, report };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function buildSidecar(report, version, nowIso) {
|
|
137
|
+
const metrics = report.metrics || report;
|
|
138
|
+
return {
|
|
139
|
+
generatedAt: nowIso,
|
|
140
|
+
version,
|
|
141
|
+
sourcePath: report.sourcePath || 'bench/thumbgate-bench.json',
|
|
142
|
+
passed: report.passed !== false,
|
|
143
|
+
isolatedRuntime: report.isolatedRuntime !== false,
|
|
144
|
+
metrics: normalizeMetrics(metrics),
|
|
145
|
+
scenarioCount: Array.isArray(report.scenarios) ? report.scenarios.length : null,
|
|
146
|
+
proofUrl: 'https://thumbgate.ai/eval-scorecard',
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function refreshWrite(options = {}) {
|
|
151
|
+
const { generate } = require('./generate-eval-scorecard');
|
|
152
|
+
const now = options.now instanceof Date ? options.now : new Date();
|
|
153
|
+
const result = generate({
|
|
154
|
+
now,
|
|
155
|
+
outputPath: options.outputPath || SCORECARD_HTML,
|
|
156
|
+
});
|
|
157
|
+
const version = options.version || require(path.join(PROJECT_ROOT, 'package.json')).version;
|
|
158
|
+
const sidecar = buildSidecar(result.report, version, now.toISOString());
|
|
159
|
+
const sidecarPath = options.sidecarPath || SCORECARD_JSON;
|
|
160
|
+
fs.writeFileSync(sidecarPath, `${JSON.stringify(sidecar, null, 2)}\n`, 'utf8');
|
|
161
|
+
return {
|
|
162
|
+
mode: 'write',
|
|
163
|
+
htmlPath: result.outPath,
|
|
164
|
+
sidecarPath,
|
|
165
|
+
passed: result.report.passed !== false,
|
|
166
|
+
metrics: sidecar.metrics,
|
|
167
|
+
score: sidecar.metrics.score,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function refreshCheck(options = {}) {
|
|
172
|
+
const htmlPath = options.htmlPath || SCORECARD_HTML;
|
|
173
|
+
if (!fs.existsSync(htmlPath)) {
|
|
174
|
+
throw new Error(`Missing committed scorecard: ${htmlPath}`);
|
|
175
|
+
}
|
|
176
|
+
const committedHtml = fs.readFileSync(htmlPath, 'utf8');
|
|
177
|
+
const committed = normalizeMetrics(extractMetricsFromHtml(committedHtml));
|
|
178
|
+
|
|
179
|
+
const { runBench } = require('./generate-eval-scorecard');
|
|
180
|
+
const report = options.report || runBench();
|
|
181
|
+
const fresh = normalizeMetrics(report.metrics || report);
|
|
182
|
+
const comparison = metricsEqual(committed, fresh, { committedOnly: true });
|
|
183
|
+
const score = Number(fresh.score ?? committed.score);
|
|
184
|
+
const minScore = options.minScore ?? 90;
|
|
185
|
+
const scoreOk = Number.isFinite(score) && score >= minScore;
|
|
186
|
+
const passed = report.passed !== false && scoreOk && comparison.equal;
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
mode: 'check',
|
|
190
|
+
passed,
|
|
191
|
+
scoreOk,
|
|
192
|
+
metricsMatch: comparison.equal,
|
|
193
|
+
diffs: comparison.diffs,
|
|
194
|
+
committed,
|
|
195
|
+
fresh,
|
|
196
|
+
minScore,
|
|
197
|
+
reportPassed: report.passed !== false,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function main(argv = process.argv.slice(2)) {
|
|
202
|
+
const args = parseArgs(argv);
|
|
203
|
+
if (args.help) {
|
|
204
|
+
printHelp();
|
|
205
|
+
return 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let summary;
|
|
209
|
+
if (args.check) {
|
|
210
|
+
summary = refreshCheck({ minScore: args.minScore });
|
|
211
|
+
} else {
|
|
212
|
+
summary = refreshWrite();
|
|
213
|
+
if (Number(summary.score) < args.minScore || summary.passed === false) {
|
|
214
|
+
summary.checkFailed = true;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (args.json) {
|
|
219
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
220
|
+
} else if (summary.mode === 'write') {
|
|
221
|
+
console.log(
|
|
222
|
+
`Proof pack scorecard written: ${summary.htmlPath} (score=${summary.score}, passed=${summary.passed})`,
|
|
223
|
+
);
|
|
224
|
+
console.log(`Sidecar: ${summary.sidecarPath}`);
|
|
225
|
+
} else {
|
|
226
|
+
console.log(
|
|
227
|
+
`Proof pack check: metricsMatch=${summary.metricsMatch} scoreOk=${summary.scoreOk} reportPassed=${summary.reportPassed}`,
|
|
228
|
+
);
|
|
229
|
+
if (summary.diffs.length) {
|
|
230
|
+
for (const diff of summary.diffs) {
|
|
231
|
+
console.log(` drift ${diff.key}: committed=${diff.committed} fresh=${diff.fresh}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (summary.mode === 'check' && !summary.passed) return 1;
|
|
237
|
+
if (summary.mode === 'write' && summary.checkFailed) return 1;
|
|
238
|
+
return 0;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (path.resolve(process.argv[1] || '') === path.resolve(__filename)) {
|
|
242
|
+
try {
|
|
243
|
+
process.exitCode = main();
|
|
244
|
+
} catch (err) {
|
|
245
|
+
console.error(err.message || err);
|
|
246
|
+
process.exitCode = 1;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
module.exports = {
|
|
251
|
+
parseArgs,
|
|
252
|
+
extractMetricsFromHtml,
|
|
253
|
+
normalizeMetrics,
|
|
254
|
+
metricsEqual,
|
|
255
|
+
refreshWrite,
|
|
256
|
+
refreshCheck,
|
|
257
|
+
buildSidecar,
|
|
258
|
+
main,
|
|
259
|
+
SCORECARD_HTML,
|
|
260
|
+
SCORECARD_JSON,
|
|
261
|
+
};
|