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,441 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Structured output for dashboard RAG answers.
|
|
6
|
+
* Schema: { answer, citations[], grounded, confidence, abstain_reason? }
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const STRUCTURED_ANSWER_SCHEMA = Object.freeze({
|
|
10
|
+
type: 'object',
|
|
11
|
+
additionalProperties: false,
|
|
12
|
+
required: ['answer', 'citations', 'grounded', 'confidence'],
|
|
13
|
+
properties: {
|
|
14
|
+
answer: { type: 'string' },
|
|
15
|
+
citations: {
|
|
16
|
+
type: 'array',
|
|
17
|
+
items: {
|
|
18
|
+
type: 'object',
|
|
19
|
+
additionalProperties: false,
|
|
20
|
+
required: ['id'],
|
|
21
|
+
properties: {
|
|
22
|
+
id: { type: 'string' },
|
|
23
|
+
title: { type: 'string' },
|
|
24
|
+
index: { type: 'number' },
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
grounded: { type: 'boolean' },
|
|
29
|
+
confidence: { type: 'number', minimum: 0, maximum: 1 },
|
|
30
|
+
abstain_reason: { type: 'string' },
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function clampConfidence(value) {
|
|
35
|
+
const n = Number(value);
|
|
36
|
+
if (!Number.isFinite(n)) return 0;
|
|
37
|
+
return Math.max(0, Math.min(1, n));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function extractJsonObject(text) {
|
|
41
|
+
const raw = String(text || '').trim();
|
|
42
|
+
if (!raw) return null;
|
|
43
|
+
// Strip markdown fences if present.
|
|
44
|
+
const openingFence = raw.indexOf('```');
|
|
45
|
+
let contentStart = openingFence >= 0 ? openingFence + 3 : -1;
|
|
46
|
+
if (contentStart >= 0 && raw.slice(contentStart, contentStart + 4).toLowerCase() === 'json') {
|
|
47
|
+
contentStart += 4;
|
|
48
|
+
}
|
|
49
|
+
while (contentStart >= 0 && contentStart < raw.length && /\s/.test(raw[contentStart])) {
|
|
50
|
+
contentStart += 1;
|
|
51
|
+
}
|
|
52
|
+
const closingFence = contentStart >= 0 ? raw.indexOf('```', contentStart) : -1;
|
|
53
|
+
const candidate = closingFence >= 0
|
|
54
|
+
? raw.slice(contentStart, closingFence).trim()
|
|
55
|
+
: raw;
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(candidate);
|
|
58
|
+
} catch {
|
|
59
|
+
const start = candidate.indexOf('{');
|
|
60
|
+
const end = candidate.lastIndexOf('}');
|
|
61
|
+
if (start >= 0 && end > start) {
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(candidate.slice(start, end + 1));
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Validate and normalise a structured RAG answer against retrieved sources.
|
|
74
|
+
*/
|
|
75
|
+
function validateStructuredAnswer(payload, sources = []) {
|
|
76
|
+
const errors = [];
|
|
77
|
+
if (!payload || typeof payload !== 'object') {
|
|
78
|
+
return { ok: false, errors: ['not_an_object'], value: null };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const answer = typeof payload.answer === 'string' ? payload.answer.trim() : '';
|
|
82
|
+
if (!answer) errors.push('missing_answer');
|
|
83
|
+
|
|
84
|
+
const citationsIn = Array.isArray(payload.citations) ? payload.citations : null;
|
|
85
|
+
if (!citationsIn) errors.push('missing_citations');
|
|
86
|
+
|
|
87
|
+
const sourceIds = new Set(
|
|
88
|
+
(sources || []).map((s) => String(s.id || s)).filter(Boolean),
|
|
89
|
+
);
|
|
90
|
+
const sourceIndexes = new Set(
|
|
91
|
+
(sources || []).map((_, i) => String(i + 1)),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
if (payload.confidence === undefined || payload.confidence === null) {
|
|
95
|
+
errors.push('missing_confidence');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const citations = [];
|
|
99
|
+
for (const c of citationsIn || []) {
|
|
100
|
+
if (!c || typeof c !== 'object') {
|
|
101
|
+
errors.push('citation_not_object');
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const id = String(c.id || c.sourceId || '').trim();
|
|
105
|
+
const index = c.index != null ? Number(c.index) : null;
|
|
106
|
+
if (!id && !Number.isFinite(index)) {
|
|
107
|
+
errors.push('citation_missing_id');
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
// Bracket form "[n]" is only valid when n is a real 1-based source index.
|
|
111
|
+
let bracketOk = false;
|
|
112
|
+
const bracketMatch = id && id.match(/^\[(\d+)\]$/);
|
|
113
|
+
if (bracketMatch) {
|
|
114
|
+
const n = Number(bracketMatch[1]);
|
|
115
|
+
bracketOk = Number.isFinite(n) && n >= 1 && n <= sources.length;
|
|
116
|
+
}
|
|
117
|
+
const idOk = Boolean(id && (sourceIds.has(id) || sourceIndexes.has(id) || bracketOk));
|
|
118
|
+
const indexOk = Number.isFinite(index) && index >= 1 && index <= sources.length;
|
|
119
|
+
if (sources.length > 0 && !idOk && !indexOk) {
|
|
120
|
+
errors.push(`citation_unknown:${id || index}`);
|
|
121
|
+
continue; // do not accept citations that point outside the retrieved set
|
|
122
|
+
}
|
|
123
|
+
citations.push({
|
|
124
|
+
id: id || String(index),
|
|
125
|
+
title: typeof c.title === 'string' ? c.title : undefined,
|
|
126
|
+
index: Number.isFinite(index) ? index : (bracketMatch ? Number(bracketMatch[1]) : undefined),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let grounded = payload.grounded;
|
|
131
|
+
if (typeof grounded !== 'boolean') {
|
|
132
|
+
grounded = citations.length > 0 && sources.length > 0;
|
|
133
|
+
errors.push('grounded_coerced');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Consistency: if no sources, grounded must be false.
|
|
137
|
+
if (sources.length === 0 && grounded === true) {
|
|
138
|
+
grounded = false;
|
|
139
|
+
errors.push('grounded_forced_false_empty_sources');
|
|
140
|
+
}
|
|
141
|
+
if (grounded === true && citations.length === 0) {
|
|
142
|
+
grounded = false;
|
|
143
|
+
errors.push('grounded_forced_false_no_valid_citations');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const confidence = clampConfidence(payload.confidence);
|
|
147
|
+
const abstain_reason = typeof payload.abstain_reason === 'string'
|
|
148
|
+
? payload.abstain_reason
|
|
149
|
+
: undefined;
|
|
150
|
+
|
|
151
|
+
const hardErrors = errors.filter((e) => !e.startsWith('grounded_') && e !== 'grounded_coerced');
|
|
152
|
+
const value = {
|
|
153
|
+
answer,
|
|
154
|
+
citations,
|
|
155
|
+
grounded,
|
|
156
|
+
confidence,
|
|
157
|
+
...(abstain_reason ? { abstain_reason } : {}),
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
ok: hardErrors.length === 0 && Boolean(answer),
|
|
162
|
+
errors,
|
|
163
|
+
value,
|
|
164
|
+
schema: STRUCTURED_ANSWER_SCHEMA,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Build a free-text answer into structured form when the model ignored JSON.
|
|
170
|
+
*/
|
|
171
|
+
function coerceFreeTextToStructured(text, sources = []) {
|
|
172
|
+
const answer = String(text || '').trim();
|
|
173
|
+
const citeMatches = [...answer.matchAll(/\[(\d+)\]/g)].map((m) => Number(m[1]));
|
|
174
|
+
const citations = [];
|
|
175
|
+
for (const idx of new Set(citeMatches)) {
|
|
176
|
+
const src = sources[idx - 1];
|
|
177
|
+
if (src) {
|
|
178
|
+
citations.push({ id: String(src.id || idx), title: src.title, index: idx });
|
|
179
|
+
} else {
|
|
180
|
+
citations.push({ id: String(idx), index: idx });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const grounded = citations.length > 0;
|
|
184
|
+
return validateStructuredAnswer({
|
|
185
|
+
answer: answer || 'No answer generated.',
|
|
186
|
+
citations,
|
|
187
|
+
grounded: Boolean(grounded && sources.length > 0),
|
|
188
|
+
confidence: citations.length ? 0.6 : (sources.length ? 0.4 : 0.2),
|
|
189
|
+
abstain_reason: sources.length
|
|
190
|
+
? (citations.length ? undefined : 'model_output_missing_valid_citation')
|
|
191
|
+
: 'no_sources_retrieved',
|
|
192
|
+
}, sources);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Parse model text into structured answer (JSON preferred, free-text fallback).
|
|
197
|
+
*/
|
|
198
|
+
function parseModelStructuredAnswer(text, sources = []) {
|
|
199
|
+
const json = extractJsonObject(text);
|
|
200
|
+
if (json) {
|
|
201
|
+
const validated = validateStructuredAnswer(json, sources);
|
|
202
|
+
if (validated.ok) return { ...validated, mode: 'json' };
|
|
203
|
+
// Partial JSON — still return validated value with mode
|
|
204
|
+
if (validated.value?.answer) return { ...validated, mode: 'json_partial' };
|
|
205
|
+
}
|
|
206
|
+
const coerced = coerceFreeTextToStructured(text, sources);
|
|
207
|
+
return { ...coerced, mode: 'free_text_coerced' };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function structuredOutputInstruction() {
|
|
211
|
+
return [
|
|
212
|
+
'Respond with ONLY valid JSON (no markdown fences) matching this schema:',
|
|
213
|
+
'{"answer": string, "citations": [{"id": string, "index"?: number, "title"?: string}],',
|
|
214
|
+
'"grounded": boolean, "confidence": number (0-1), "abstain_reason"?: string}.',
|
|
215
|
+
'citations[].index is the 1-based lesson number from the context block.',
|
|
216
|
+
'If the context is insufficient, set grounded=false, confidence low, and explain in answer.',
|
|
217
|
+
].join(' ');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// Deterministic answer-quality proxies for RAG regression gates.
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
const ANSWER_STOP_WORDS = new Set([
|
|
225
|
+
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'before', 'by', 'for', 'from',
|
|
226
|
+
'has', 'have', 'how', 'i', 'in', 'is', 'it', 'of', 'on', 'or', 'our',
|
|
227
|
+
'should', 'that', 'the', 'their', 'this', 'to', 'was', 'we', 'what', 'when',
|
|
228
|
+
'where', 'which', 'with', 'you', 'your',
|
|
229
|
+
]);
|
|
230
|
+
|
|
231
|
+
const ANSWER_NEGATION_PATTERN = /\b(?:no|not|never|avoid|without|cannot|can't|don't|do not|mustn't|prohibited|blocked)\b/i;
|
|
232
|
+
|
|
233
|
+
function roundMetric(value) {
|
|
234
|
+
return Number(Number(value || 0).toFixed(6));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function answerTokens(text) {
|
|
238
|
+
return String(text || '')
|
|
239
|
+
.toLowerCase()
|
|
240
|
+
.replace(/\[[a-z0-9:_-]+\]/gi, ' ')
|
|
241
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
242
|
+
.trim()
|
|
243
|
+
.split(/\s+/)
|
|
244
|
+
.filter((token) => token.length >= 2 && !ANSWER_STOP_WORDS.has(token));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function answerTokenSet(text) {
|
|
248
|
+
return new Set(answerTokens(text));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function tokenF1(left, right) {
|
|
252
|
+
const a = answerTokenSet(left);
|
|
253
|
+
const b = answerTokenSet(right);
|
|
254
|
+
if (!a.size || !b.size) return 0;
|
|
255
|
+
let overlap = 0;
|
|
256
|
+
for (const token of a) if (b.has(token)) overlap += 1;
|
|
257
|
+
if (!overlap) return 0;
|
|
258
|
+
const precision = overlap / a.size;
|
|
259
|
+
const recall = overlap / b.size;
|
|
260
|
+
return (2 * precision * recall) / (precision + recall);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function queryCoverage(query, answer) {
|
|
264
|
+
const queryTokens = answerTokenSet(query);
|
|
265
|
+
const answerSet = answerTokenSet(answer);
|
|
266
|
+
if (!queryTokens.size) return 0;
|
|
267
|
+
let covered = 0;
|
|
268
|
+
for (const token of queryTokens) if (answerSet.has(token)) covered += 1;
|
|
269
|
+
return covered / queryTokens.size;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function splitAnswerClaims(answer) {
|
|
273
|
+
return String(answer || '')
|
|
274
|
+
.split(/(?:\n+|(?<=[.!?])\s+)/)
|
|
275
|
+
.map((claim) => claim.replace(/^[-*]\s*/, '').trim())
|
|
276
|
+
.filter((claim) => answerTokens(claim).length > 0);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function normalizeAnswerContexts(contexts = []) {
|
|
280
|
+
return (Array.isArray(contexts) ? contexts : [])
|
|
281
|
+
.map((context, index) => ({
|
|
282
|
+
id: String(context?.id || context?.sourceId || context?.documentId || `context-${index}`),
|
|
283
|
+
text: String(context?.text || context?.content || context?.rawContent || ''),
|
|
284
|
+
}))
|
|
285
|
+
.filter((context) => context.text.trim());
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function numericTokens(text) {
|
|
289
|
+
return String(text || '')
|
|
290
|
+
.replace(/\[[a-z0-9:_-]+\]/gi, ' ')
|
|
291
|
+
.match(/\b\d+(?:\.\d+)?%?\b/g) || [];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function claimSupportScore(claim, contextText) {
|
|
295
|
+
const claimText = String(claim || '').trim();
|
|
296
|
+
const evidenceText = String(contextText || '').trim();
|
|
297
|
+
if (!claimText || !evidenceText) return 0;
|
|
298
|
+
|
|
299
|
+
const claimNumbers = numericTokens(claimText);
|
|
300
|
+
if (claimNumbers.some((number) => !numericTokens(evidenceText).includes(number))) return 0;
|
|
301
|
+
|
|
302
|
+
let score = tokenF1(claimText, evidenceText);
|
|
303
|
+
const normalizedClaim = claimText.toLowerCase().replace(/\s+/g, ' ');
|
|
304
|
+
const normalizedEvidence = evidenceText.toLowerCase().replace(/\s+/g, ' ');
|
|
305
|
+
if (normalizedEvidence.includes(normalizedClaim)) score = 1;
|
|
306
|
+
|
|
307
|
+
const claimNegated = ANSWER_NEGATION_PATTERN.test(claimText);
|
|
308
|
+
const evidenceNegated = ANSWER_NEGATION_PATTERN.test(evidenceText);
|
|
309
|
+
if (claimNegated !== evidenceNegated && score >= 0.35) score *= 0.2;
|
|
310
|
+
return roundMetric(Math.max(0, Math.min(1, score)));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function extractCitationIds(answer, citations = []) {
|
|
314
|
+
const ids = [];
|
|
315
|
+
for (const citation of Array.isArray(citations) ? citations : []) {
|
|
316
|
+
const id = typeof citation === 'string'
|
|
317
|
+
? citation
|
|
318
|
+
: citation?.id || citation?.sourceId || citation?.documentId;
|
|
319
|
+
if (id) ids.push(String(id));
|
|
320
|
+
}
|
|
321
|
+
const pattern = /\[([a-z0-9:_-]+)\]/gi;
|
|
322
|
+
let match;
|
|
323
|
+
while ((match = pattern.exec(String(answer || ''))) !== null) ids.push(match[1]);
|
|
324
|
+
return [...new Set(ids)];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function evaluateAnswerQuality(sample = {}, options = {}) {
|
|
328
|
+
const answer = String(sample.answer || sample.response || '');
|
|
329
|
+
const query = String(sample.query || sample.question || '');
|
|
330
|
+
const contexts = normalizeAnswerContexts(sample.contexts || sample.sources);
|
|
331
|
+
const claims = splitAnswerClaims(answer);
|
|
332
|
+
const supportThreshold = Number(options.supportThreshold ?? 0.4);
|
|
333
|
+
|
|
334
|
+
const claimResults = claims.map((claim) => {
|
|
335
|
+
const scored = contexts
|
|
336
|
+
.map((context) => ({
|
|
337
|
+
contextId: context.id,
|
|
338
|
+
score: claimSupportScore(claim, context.text),
|
|
339
|
+
}))
|
|
340
|
+
.sort((left, right) => right.score - left.score);
|
|
341
|
+
const best = scored[0] || { contextId: null, score: 0 };
|
|
342
|
+
return {
|
|
343
|
+
claim,
|
|
344
|
+
supported: best.score >= supportThreshold,
|
|
345
|
+
supportScore: best.score,
|
|
346
|
+
contextId: best.contextId,
|
|
347
|
+
};
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const faithfulness = claims.length
|
|
351
|
+
? claimResults.filter((claim) => claim.supported).length / claims.length
|
|
352
|
+
: 0;
|
|
353
|
+
const citationIds = extractCitationIds(answer, sample.citations);
|
|
354
|
+
const knownIds = new Set(contexts.map((context) => context.id));
|
|
355
|
+
const validCitationIds = citationIds.filter((id) => knownIds.has(id));
|
|
356
|
+
const citationPrecision = citationIds.length ? validCitationIds.length / citationIds.length : 0;
|
|
357
|
+
const groundedness = faithfulness * (0.7 + 0.3 * citationPrecision);
|
|
358
|
+
const referenceScore = sample.referenceAnswer
|
|
359
|
+
? tokenF1(answer, sample.referenceAnswer)
|
|
360
|
+
: null;
|
|
361
|
+
const answerRelevance = referenceScore === null
|
|
362
|
+
? queryCoverage(query, answer)
|
|
363
|
+
: (0.3 * queryCoverage(query, answer)) + (0.7 * referenceScore);
|
|
364
|
+
|
|
365
|
+
const thresholds = {
|
|
366
|
+
minFaithfulness: Number(options.minFaithfulness ?? 0.8),
|
|
367
|
+
minGroundedness: Number(options.minGroundedness ?? 0.75),
|
|
368
|
+
minAnswerRelevance: Number(options.minAnswerRelevance ?? 0.3),
|
|
369
|
+
};
|
|
370
|
+
const failures = [];
|
|
371
|
+
if (faithfulness < thresholds.minFaithfulness) failures.push('faithfulness');
|
|
372
|
+
if (groundedness < thresholds.minGroundedness) failures.push('groundedness');
|
|
373
|
+
if (answerRelevance < thresholds.minAnswerRelevance) failures.push('answer_relevance');
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
mode: 'deterministic-lexical-proxy',
|
|
377
|
+
limitations: [
|
|
378
|
+
'Lexical support is not semantic entailment.',
|
|
379
|
+
'A calibrated judge or human holdout is still required for nuanced claims.',
|
|
380
|
+
],
|
|
381
|
+
metrics: {
|
|
382
|
+
faithfulness: roundMetric(faithfulness),
|
|
383
|
+
groundedness: roundMetric(groundedness),
|
|
384
|
+
answerRelevance: roundMetric(answerRelevance),
|
|
385
|
+
citationPrecision: roundMetric(citationPrecision),
|
|
386
|
+
},
|
|
387
|
+
claims: claimResults,
|
|
388
|
+
citations: {
|
|
389
|
+
cited: citationIds,
|
|
390
|
+
valid: validCitationIds,
|
|
391
|
+
invalid: citationIds.filter((id) => !knownIds.has(id)),
|
|
392
|
+
},
|
|
393
|
+
thresholds,
|
|
394
|
+
failures,
|
|
395
|
+
passed: failures.length === 0,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function normalizeJudgeDiagnostic(result) {
|
|
400
|
+
const source = result?.metrics || result;
|
|
401
|
+
if (!source || typeof source !== 'object') return null;
|
|
402
|
+
const metrics = {
|
|
403
|
+
faithfulness: Number(source.faithfulness),
|
|
404
|
+
groundedness: Number(source.groundedness),
|
|
405
|
+
answerRelevance: Number(source.answerRelevance ?? source.answer_relevance),
|
|
406
|
+
};
|
|
407
|
+
if (Object.values(metrics).some((value) => !Number.isFinite(value) || value < 0 || value > 1)) return null;
|
|
408
|
+
return { metrics, rationale: String(result.rationale || '').slice(0, 500) };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function evaluateAnswerQualityWithJudge(sample = {}, options = {}) {
|
|
412
|
+
const deterministic = evaluateAnswerQuality(sample, options);
|
|
413
|
+
if (typeof options.judge !== 'function') {
|
|
414
|
+
return { ...deterministic, judgeDiagnostic: null };
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
const judgeDiagnostic = normalizeJudgeDiagnostic(await options.judge(sample));
|
|
418
|
+
return { ...deterministic, judgeDiagnostic };
|
|
419
|
+
} catch {
|
|
420
|
+
return { ...deterministic, judgeDiagnostic: null };
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
module.exports = {
|
|
425
|
+
STRUCTURED_ANSWER_SCHEMA,
|
|
426
|
+
extractJsonObject,
|
|
427
|
+
validateStructuredAnswer,
|
|
428
|
+
coerceFreeTextToStructured,
|
|
429
|
+
parseModelStructuredAnswer,
|
|
430
|
+
structuredOutputInstruction,
|
|
431
|
+
clampConfidence,
|
|
432
|
+
answerTokens,
|
|
433
|
+
tokenF1,
|
|
434
|
+
queryCoverage,
|
|
435
|
+
splitAnswerClaims,
|
|
436
|
+
claimSupportScore,
|
|
437
|
+
extractCitationIds,
|
|
438
|
+
evaluateAnswerQuality,
|
|
439
|
+
evaluateAnswerQualityWithJudge,
|
|
440
|
+
normalizeJudgeDiagnostic,
|
|
441
|
+
};
|