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
|
@@ -169,7 +169,7 @@ function writeJsonl(filePath, records) {
|
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
function normalizeText(value) {
|
|
172
|
-
const withoutBom = String(value || '').
|
|
172
|
+
const withoutBom = String(value || '').replaceAll('\uFEFF', '');
|
|
173
173
|
const normalizedNewlines = normalizeNewlines(withoutBom);
|
|
174
174
|
const trimmedLines = normalizedNewlines
|
|
175
175
|
.split('\n')
|
|
@@ -189,6 +189,70 @@ function normalizeTags(tags) {
|
|
|
189
189
|
.filter(Boolean)));
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
+
function normalizeAccessIdentifier(value) {
|
|
193
|
+
const normalized = String(value || '').trim();
|
|
194
|
+
if (!normalized) return null;
|
|
195
|
+
return normalized.slice(0, 160);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function normalizeAccessContext(context = {}) {
|
|
199
|
+
const source = context && typeof context === 'object' ? context : {};
|
|
200
|
+
return {
|
|
201
|
+
tenantId: normalizeAccessIdentifier(source.tenantId),
|
|
202
|
+
principalId: normalizeAccessIdentifier(source.principalId),
|
|
203
|
+
isAdmin: source.isAdmin === true,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function buildDocumentAccess(options = {}) {
|
|
208
|
+
const context = normalizeAccessContext(options.accessContext);
|
|
209
|
+
if (!context.tenantId && !context.principalId) return null;
|
|
210
|
+
const visibility = options.visibility === 'private' ? 'private' : 'tenant';
|
|
211
|
+
const allowedPrincipals = safeArray(options.allowedPrincipals)
|
|
212
|
+
.map(normalizeAccessIdentifier)
|
|
213
|
+
.filter(Boolean);
|
|
214
|
+
return {
|
|
215
|
+
tenantId: context.tenantId,
|
|
216
|
+
ownerId: context.principalId,
|
|
217
|
+
visibility,
|
|
218
|
+
allowedPrincipals: [...new Set(allowedPrincipals)],
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Fail-closed authorization for protected imported documents.
|
|
224
|
+
*
|
|
225
|
+
* Legacy local documents without access metadata stay readable for backwards
|
|
226
|
+
* compatibility. Once a document carries a tenant, owner, or principal ACL,
|
|
227
|
+
* callers must present a matching authorization context on every read path.
|
|
228
|
+
*/
|
|
229
|
+
function documentAccessAllowed(document = {}, accessContext = {}) {
|
|
230
|
+
const access = document.access;
|
|
231
|
+
const protectedDocument = access && typeof access === 'object' && (
|
|
232
|
+
normalizeAccessIdentifier(access.tenantId)
|
|
233
|
+
|| normalizeAccessIdentifier(access.ownerId)
|
|
234
|
+
|| safeArray(access.allowedPrincipals).length > 0
|
|
235
|
+
);
|
|
236
|
+
if (!protectedDocument) return true;
|
|
237
|
+
|
|
238
|
+
const caller = normalizeAccessContext(accessContext);
|
|
239
|
+
if (caller.isAdmin) return true;
|
|
240
|
+
|
|
241
|
+
const tenantId = normalizeAccessIdentifier(access.tenantId);
|
|
242
|
+
if (tenantId && (!caller.tenantId || caller.tenantId !== tenantId)) return false;
|
|
243
|
+
|
|
244
|
+
if (access.visibility === 'tenant') {
|
|
245
|
+
return Boolean(caller.tenantId && caller.tenantId === tenantId);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (!caller.principalId) return false;
|
|
249
|
+
const allowed = new Set([
|
|
250
|
+
normalizeAccessIdentifier(access.ownerId),
|
|
251
|
+
...safeArray(access.allowedPrincipals).map(normalizeAccessIdentifier),
|
|
252
|
+
].filter(Boolean));
|
|
253
|
+
return allowed.has(caller.principalId);
|
|
254
|
+
}
|
|
255
|
+
|
|
192
256
|
function normalizeNewlines(value) {
|
|
193
257
|
let result = '';
|
|
194
258
|
const text = String(value || '');
|
|
@@ -716,6 +780,7 @@ function buildDocumentSummary(document) {
|
|
|
716
780
|
proposalCount: safeArray(document.proposals).length,
|
|
717
781
|
matchedTemplateIds: safeArray(document.matchedTemplateIds),
|
|
718
782
|
fingerprint: document.fingerprint,
|
|
783
|
+
access: document.access || null,
|
|
719
784
|
};
|
|
720
785
|
}
|
|
721
786
|
|
|
@@ -723,7 +788,8 @@ function readImportedDocument(documentId, options = {}) {
|
|
|
723
788
|
const filePath = getDocumentPath(String(documentId || '').trim(), options);
|
|
724
789
|
if (!fs.existsSync(filePath)) return null;
|
|
725
790
|
try {
|
|
726
|
-
|
|
791
|
+
const document = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
792
|
+
return documentAccessAllowed(document, options.accessContext) ? document : null;
|
|
727
793
|
} catch {
|
|
728
794
|
return null;
|
|
729
795
|
}
|
|
@@ -739,6 +805,7 @@ function listImportedDocuments(options = {}) {
|
|
|
739
805
|
const documents = readJsonl(catalogPath);
|
|
740
806
|
|
|
741
807
|
const filtered = documents.filter((document) => {
|
|
808
|
+
if (!documentAccessAllowed(document, options.accessContext)) return false;
|
|
742
809
|
const tags = safeArray(document.tags).map((tag) => String(tag).toLowerCase());
|
|
743
810
|
const matchedTemplateIds = safeArray(document.matchedTemplateIds).map((tag) => String(tag).toLowerCase());
|
|
744
811
|
if (requestedTag && !tags.includes(requestedTag) && !matchedTemplateIds.includes(requestedTag)) {
|
|
@@ -765,10 +832,11 @@ function persistDocument(document, options = {}) {
|
|
|
765
832
|
const paths = getDocumentStorePaths(options);
|
|
766
833
|
ensureDir(paths.documentsDir);
|
|
767
834
|
writeJson(getDocumentPath(document.documentId, options), document);
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
835
|
+
// Persistence must retain entries the current principal cannot read; using
|
|
836
|
+
// the public list function here would silently delete other principals'
|
|
837
|
+
// catalog rows during an import.
|
|
838
|
+
const summaries = readJsonl(paths.catalogPath)
|
|
839
|
+
.filter((entry) => entry.documentId !== document.documentId);
|
|
772
840
|
const nextSummaries = [
|
|
773
841
|
buildDocumentSummary(document),
|
|
774
842
|
...summaries,
|
|
@@ -849,6 +917,188 @@ function searchImportedDocuments(options = {}) {
|
|
|
849
917
|
return docs.slice(0, limit);
|
|
850
918
|
}
|
|
851
919
|
|
|
920
|
+
function importedDocumentMatchesFilters(document, filters = {}) {
|
|
921
|
+
if (!filters || typeof filters !== 'object') return true;
|
|
922
|
+
const normalize = (value) => (
|
|
923
|
+
(Array.isArray(value) ? value : value == null ? [] : [value])
|
|
924
|
+
.map((item) => String(item).trim().toLowerCase())
|
|
925
|
+
.filter(Boolean)
|
|
926
|
+
);
|
|
927
|
+
for (const [field, actualValue] of [
|
|
928
|
+
['sourceFormat', document.sourceFormat],
|
|
929
|
+
['sourceType', document.sourceType],
|
|
930
|
+
]) {
|
|
931
|
+
const required = normalize(filters[field]);
|
|
932
|
+
if (required.length > 0 && !required.includes(String(actualValue || '').toLowerCase())) {
|
|
933
|
+
return false;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
const requiredTags = normalize(filters.tags);
|
|
937
|
+
if (requiredTags.length > 0) {
|
|
938
|
+
const actualTags = normalize(document.tags);
|
|
939
|
+
const matches = filters.requireAllTags === false
|
|
940
|
+
? requiredTags.some((tag) => actualTags.includes(tag))
|
|
941
|
+
: requiredTags.every((tag) => actualTags.includes(tag));
|
|
942
|
+
if (!matches) return false;
|
|
943
|
+
}
|
|
944
|
+
return true;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Production document retrieval: rank child chunks, then hydrate their parent
|
|
949
|
+
* documents. Whole-file results stay bounded while evidence can come from any
|
|
950
|
+
* position in a long document.
|
|
951
|
+
*/
|
|
952
|
+
async function searchImportedDocumentsAsync(options = {}) {
|
|
953
|
+
const query = String(options.query || '').trim();
|
|
954
|
+
if (!query) throw new Error('query is required');
|
|
955
|
+
const limit = Number.isFinite(Number(options.limit))
|
|
956
|
+
? Math.max(1, Math.min(50, Number(options.limit)))
|
|
957
|
+
: 10;
|
|
958
|
+
const documents = listImportedDocuments({
|
|
959
|
+
...options,
|
|
960
|
+
limit: MAX_SEARCH_SCAN,
|
|
961
|
+
query: '',
|
|
962
|
+
}).documents
|
|
963
|
+
.map((summary) => readImportedDocument(summary.documentId, options))
|
|
964
|
+
.filter(Boolean)
|
|
965
|
+
.filter((document) => importedDocumentMatchesFilters(
|
|
966
|
+
document,
|
|
967
|
+
options.metadataFilters || options.filters,
|
|
968
|
+
));
|
|
969
|
+
if (documents.length === 0) return [];
|
|
970
|
+
|
|
971
|
+
const { runDocumentPipeline } = require('./rag-document-pipeline');
|
|
972
|
+
const { pragmaticHybridSearch } = require('./pragmatic-hybrid-search');
|
|
973
|
+
const { buildQueryPlan, reciprocalRankFusion } = require('./lesson-retrieval');
|
|
974
|
+
const pipeline = runDocumentPipeline(documents.map((document) => ({
|
|
975
|
+
type: 'text',
|
|
976
|
+
id: document.documentId,
|
|
977
|
+
title: document.title,
|
|
978
|
+
content: document.content,
|
|
979
|
+
tags: document.tags,
|
|
980
|
+
source: 'imported_document',
|
|
981
|
+
metadata: {
|
|
982
|
+
documentId: document.documentId,
|
|
983
|
+
sourceFormat: document.sourceFormat,
|
|
984
|
+
sourceType: document.sourceType,
|
|
985
|
+
importedAt: document.importedAt,
|
|
986
|
+
},
|
|
987
|
+
})), {
|
|
988
|
+
maxChars: options.maxChunkChars || 900,
|
|
989
|
+
overlap: options.chunkOverlap || 120,
|
|
990
|
+
});
|
|
991
|
+
const chunks = pipeline.chunks;
|
|
992
|
+
if (chunks.length === 0) return [];
|
|
993
|
+
|
|
994
|
+
const queryPlan = await buildQueryPlan(query, options);
|
|
995
|
+
const queryVariants = queryPlan.variants;
|
|
996
|
+
let denseRankedIds = [];
|
|
997
|
+
let semanticProvider = null;
|
|
998
|
+
try {
|
|
999
|
+
const embeddingIndex = require('./lesson-embedding-index');
|
|
1000
|
+
if (options.embedder || embeddingIndex.isEmbedderAvailable()) {
|
|
1001
|
+
const denseLists = [];
|
|
1002
|
+
for (const variant of queryVariants) {
|
|
1003
|
+
const dense = await embeddingIndex.semanticRank(variant, chunks, {
|
|
1004
|
+
feedbackDir: options.feedbackDir,
|
|
1005
|
+
embedder: options.embedder,
|
|
1006
|
+
embedderId: options.embedderId,
|
|
1007
|
+
cacheFile: 'document-chunk-embeddings.json',
|
|
1008
|
+
// `chunks` is metadata-filtered. Pruning this shared cache against a
|
|
1009
|
+
// subset would make alternating filters evict and re-embed each other.
|
|
1010
|
+
pruneCache: false,
|
|
1011
|
+
});
|
|
1012
|
+
const topScore = dense[0]?.score ?? 0;
|
|
1013
|
+
const minimum = Math.max(
|
|
1014
|
+
Number(options.minSemanticScore) || 0.15,
|
|
1015
|
+
topScore - (Number(options.semanticScoreWindow) || 0.2),
|
|
1016
|
+
);
|
|
1017
|
+
denseLists.push(
|
|
1018
|
+
dense.filter((entry) => entry.score >= minimum).map((entry) => entry.id),
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
denseRankedIds = reciprocalRankFusion(denseLists).map((entry) => entry.id);
|
|
1022
|
+
semanticProvider = options.embedderId || 'configured';
|
|
1023
|
+
}
|
|
1024
|
+
} catch {
|
|
1025
|
+
denseRankedIds = [];
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
const { results: rankedChunks, meta } = pragmaticHybridSearch({
|
|
1029
|
+
corpus: chunks,
|
|
1030
|
+
query,
|
|
1031
|
+
toolName: options.toolName || 'Read',
|
|
1032
|
+
options: {
|
|
1033
|
+
topK: Math.min(chunks.length, Math.max(limit * 4, 20)),
|
|
1034
|
+
pool: Math.min(chunks.length, Math.max(limit * 10, 50)),
|
|
1035
|
+
queryVariants,
|
|
1036
|
+
denseRankedIds,
|
|
1037
|
+
diversify: false,
|
|
1038
|
+
},
|
|
1039
|
+
});
|
|
1040
|
+
const lexicalEvidenceTokens = Array.from(new Set(
|
|
1041
|
+
queryVariants.flatMap((variant) => tokenize(variant)),
|
|
1042
|
+
));
|
|
1043
|
+
const denseEvidenceIds = new Set(denseRankedIds);
|
|
1044
|
+
const byDocumentId = new Map(documents.map((document) => [document.documentId, document]));
|
|
1045
|
+
const grouped = new Map();
|
|
1046
|
+
for (const chunk of rankedChunks) {
|
|
1047
|
+
const documentId = chunk.metadata?.documentId;
|
|
1048
|
+
if (!documentId || !byDocumentId.has(documentId)) continue;
|
|
1049
|
+
const lexicalHaystack = [
|
|
1050
|
+
chunk.title,
|
|
1051
|
+
chunk.content,
|
|
1052
|
+
safeArray(chunk.tags).join(' '),
|
|
1053
|
+
].join(' ').toLowerCase();
|
|
1054
|
+
const hasQueryOverlap = lexicalEvidenceTokens.some((token) => lexicalHaystack.includes(token));
|
|
1055
|
+
const hasDenseEvidence = denseEvidenceIds.has(chunk.id);
|
|
1056
|
+
// A synthetic tool name such as `Read` can contribute to scoreRelevance,
|
|
1057
|
+
// but it is not query evidence. Require lexical overlap or a dense hit.
|
|
1058
|
+
if (!hasQueryOverlap && !hasDenseEvidence) continue;
|
|
1059
|
+
const score = Number(chunk.rerankedScore ?? chunk.relevanceScore ?? 0);
|
|
1060
|
+
const group = grouped.get(documentId) || { score, chunks: [] };
|
|
1061
|
+
group.score = Math.max(group.score, score);
|
|
1062
|
+
if (group.chunks.length < 3) {
|
|
1063
|
+
group.chunks.push({
|
|
1064
|
+
chunkId: chunk.id,
|
|
1065
|
+
chunkIndex: chunk.metadata?.chunkIndex ?? 0,
|
|
1066
|
+
startChar: chunk.metadata?.startChar ?? null,
|
|
1067
|
+
endChar: chunk.metadata?.endChar ?? null,
|
|
1068
|
+
content: String(chunk.content || '').slice(0, 700),
|
|
1069
|
+
score: Number(score.toFixed(4)),
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
grouped.set(documentId, group);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const queryTokens = tokenize(query);
|
|
1076
|
+
return [...grouped.entries()]
|
|
1077
|
+
.sort((left, right) => right[1].score - left[1].score)
|
|
1078
|
+
.slice(0, limit)
|
|
1079
|
+
.map(([documentId, group]) => {
|
|
1080
|
+
const document = byDocumentId.get(documentId);
|
|
1081
|
+
return {
|
|
1082
|
+
...document,
|
|
1083
|
+
excerpt: group.chunks[0]?.content || document.excerpt,
|
|
1084
|
+
_score: Number(group.score.toFixed(4)),
|
|
1085
|
+
_matchedTokens: queryTokens.filter((token) => (
|
|
1086
|
+
group.chunks.some((chunk) => chunk.content.toLowerCase().includes(token))
|
|
1087
|
+
)),
|
|
1088
|
+
_matchedChunks: group.chunks,
|
|
1089
|
+
_retrieval: {
|
|
1090
|
+
strategy: meta.strategy,
|
|
1091
|
+
parentChild: true,
|
|
1092
|
+
chunkCount: chunks.length,
|
|
1093
|
+
queryVariants,
|
|
1094
|
+
queryTransformation: queryPlan,
|
|
1095
|
+
densePool: meta.densePool,
|
|
1096
|
+
semanticProvider,
|
|
1097
|
+
},
|
|
1098
|
+
};
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
|
|
852
1102
|
function importDocument(options = {}) {
|
|
853
1103
|
const hasFilePath = Boolean(options.filePath);
|
|
854
1104
|
const hasContent = typeof options.content === 'string' && options.content.trim().length > 0;
|
|
@@ -881,10 +1131,30 @@ function importDocument(options = {}) {
|
|
|
881
1131
|
normalizedContent,
|
|
882
1132
|
sourceFormat,
|
|
883
1133
|
});
|
|
1134
|
+
const access = buildDocumentAccess(options);
|
|
884
1135
|
const fingerprint = sha256(`${title}\n${normalizedContent}`);
|
|
1136
|
+
// Protected documents need a storage identity that includes their owning
|
|
1137
|
+
// scope. Otherwise identical content imported by two private principals (or
|
|
1138
|
+
// as both tenant-visible and private) would overwrite the first document and
|
|
1139
|
+
// silently replace its ACL. Keep legacy local IDs unchanged.
|
|
1140
|
+
const storageScope = !access
|
|
1141
|
+
? null
|
|
1142
|
+
: access.visibility === 'private'
|
|
1143
|
+
? JSON.stringify({
|
|
1144
|
+
tenantId: access.tenantId,
|
|
1145
|
+
visibility: 'private',
|
|
1146
|
+
ownerId: access.ownerId,
|
|
1147
|
+
})
|
|
1148
|
+
: JSON.stringify({
|
|
1149
|
+
tenantId: access.tenantId,
|
|
1150
|
+
visibility: 'tenant',
|
|
1151
|
+
});
|
|
1152
|
+
const storageFingerprint = storageScope
|
|
1153
|
+
? sha256(`${fingerprint}\n${storageScope}`)
|
|
1154
|
+
: fingerprint;
|
|
885
1155
|
const importedAt = nowIso();
|
|
886
1156
|
const sourceName = sourcePath ? path.basename(sourcePath) : null;
|
|
887
|
-
const documentId = `doc_${slugify(title || sourceName || 'document').slice(0, 24) || 'document'}_${
|
|
1157
|
+
const documentId = `doc_${slugify(title || sourceName || 'document').slice(0, 24) || 'document'}_${storageFingerprint.slice(0, 12)}`;
|
|
888
1158
|
const document = {
|
|
889
1159
|
documentId,
|
|
890
1160
|
title,
|
|
@@ -902,6 +1172,7 @@ function importDocument(options = {}) {
|
|
|
902
1172
|
lineCount: normalizedContent.split('\n').filter(Boolean).length,
|
|
903
1173
|
headings: extractHeadings(normalizedContent),
|
|
904
1174
|
};
|
|
1175
|
+
if (access) document.access = access;
|
|
905
1176
|
document.proposals = options.proposeGates === false
|
|
906
1177
|
? []
|
|
907
1178
|
: proposeGatesFromDocument(document, options);
|
|
@@ -924,4 +1195,9 @@ module.exports = {
|
|
|
924
1195
|
proposeGatesFromDocument,
|
|
925
1196
|
readImportedDocument,
|
|
926
1197
|
searchImportedDocuments,
|
|
1198
|
+
searchImportedDocumentsAsync,
|
|
1199
|
+
importedDocumentMatchesFilters,
|
|
1200
|
+
buildDocumentAccess,
|
|
1201
|
+
documentAccessAllowed,
|
|
1202
|
+
normalizeAccessContext,
|
|
927
1203
|
};
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Unified bounded offline evaluation suite:
|
|
6
|
+
* 1) IR ranking — Recall@k / MRR / nDCG / Precision@k (retrieval-ranking-golden)
|
|
7
|
+
* 2) Generation quality — faithfulness / groundedness / answer_relevance (offline)
|
|
8
|
+
*
|
|
9
|
+
* Exit 0 only when both floors pass. Offline by default (no API key required).
|
|
10
|
+
*
|
|
11
|
+
* npm run eval:quality
|
|
12
|
+
* node scripts/eval-quality-suite.js --json
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
const {
|
|
18
|
+
evaluateRankingGolden,
|
|
19
|
+
formatRankingReport,
|
|
20
|
+
} = require('./retrieval-ranking-eval');
|
|
21
|
+
const {
|
|
22
|
+
evaluateGenerationGolden,
|
|
23
|
+
METRICS_VERSION,
|
|
24
|
+
} = require('./ragas-style-metrics');
|
|
25
|
+
|
|
26
|
+
const ROOT = path.join(__dirname, '..');
|
|
27
|
+
const GEN_GOLDEN = path.join(ROOT, 'config', 'evals', 'generation-quality-golden.json');
|
|
28
|
+
const REPORT_MD = path.join(ROOT, 'reports', 'eval-quality-suite.md');
|
|
29
|
+
const REPORT_JSON = path.join(ROOT, 'reports', 'eval-quality-suite.json');
|
|
30
|
+
const SUITE_VERSION = '2026-07-31.a-plus.1';
|
|
31
|
+
|
|
32
|
+
function loadGenerationGolden() {
|
|
33
|
+
return JSON.parse(fs.readFileSync(GEN_GOLDEN, 'utf8'));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Drop expectFail / known-bad demos from floor means (still reported in rows).
|
|
38
|
+
*/
|
|
39
|
+
function filterGenerationCases(golden) {
|
|
40
|
+
const cases = (golden.cases || []).filter((c) => !c.expectFail);
|
|
41
|
+
return { ...golden, cases };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function runSuite(options = {}) {
|
|
45
|
+
const ranking = evaluateRankingGolden({
|
|
46
|
+
goldenPath: options.rankingGoldenPath,
|
|
47
|
+
thresholds: options.rankingThresholds,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const genGoldenRaw = options.generationGolden || loadGenerationGolden();
|
|
51
|
+
const genGolden = filterGenerationCases(genGoldenRaw);
|
|
52
|
+
const generation = evaluateGenerationGolden(genGolden, {
|
|
53
|
+
thresholds: options.generationThresholds,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Also score known-bad cases for diagnostics (must score worse on faithfulness)
|
|
57
|
+
const badCases = (genGoldenRaw.cases || []).filter((c) => c.expectFail || c.id === 'ungrounded-contradiction');
|
|
58
|
+
const badScores = badCases.map((c) => require('./ragas-style-metrics').scoreGenerationCase(c));
|
|
59
|
+
|
|
60
|
+
const failures = [
|
|
61
|
+
...(ranking.passed ? [] : ranking.failures.map((f) => `ranking: ${f}`)),
|
|
62
|
+
...(generation.passed ? [] : generation.failures.map((f) => `generation: ${f}`)),
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
// Integrity: bad contradiction sample should not beat good faithfulness mean
|
|
66
|
+
if (badScores.length > 0 && generation.summary.faithfulness > 0) {
|
|
67
|
+
const badFaith = badScores.reduce((s, r) => s + r.faithfulness, 0) / badScores.length;
|
|
68
|
+
if (badFaith >= generation.summary.faithfulness) {
|
|
69
|
+
failures.push(
|
|
70
|
+
`integrity: bad-case faithfulness ${badFaith.toFixed(3)} >= good mean ${generation.summary.faithfulness}`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const report = {
|
|
76
|
+
suiteVersion: SUITE_VERSION,
|
|
77
|
+
metricsVersion: METRICS_VERSION,
|
|
78
|
+
passed: failures.length === 0,
|
|
79
|
+
failures,
|
|
80
|
+
ranking: {
|
|
81
|
+
passed: ranking.passed,
|
|
82
|
+
failures: ranking.failures,
|
|
83
|
+
summary: ranking.summary,
|
|
84
|
+
thresholds: ranking.thresholds,
|
|
85
|
+
queryCount: ranking.perQuery?.length || 0,
|
|
86
|
+
},
|
|
87
|
+
generation: {
|
|
88
|
+
passed: generation.passed,
|
|
89
|
+
failures: generation.failures,
|
|
90
|
+
summary: generation.summary,
|
|
91
|
+
thresholds: generation.thresholds,
|
|
92
|
+
caseCount: generation.rows?.length || 0,
|
|
93
|
+
badCaseDiagnostics: badScores,
|
|
94
|
+
},
|
|
95
|
+
grades: {
|
|
96
|
+
recallAtK: ranking.passed ? 'A+' : 'fail',
|
|
97
|
+
mrr: ranking.passed ? 'A+' : 'fail',
|
|
98
|
+
ndcg: ranking.passed ? 'A+' : 'fail',
|
|
99
|
+
precisionAtK: ranking.passed ? 'A+' : 'fail',
|
|
100
|
+
faithfulness: generation.passed ? 'A+' : 'fail',
|
|
101
|
+
groundedness: generation.passed ? 'A+' : 'fail',
|
|
102
|
+
answerRelevance: generation.passed ? 'A+' : 'fail',
|
|
103
|
+
overall: failures.length === 0 ? 'A+' : 'fail',
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
return { report, ranking, generation };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function formatMarkdown(report) {
|
|
111
|
+
const r = report.ranking.summary || {};
|
|
112
|
+
const g = report.generation.summary || {};
|
|
113
|
+
const lines = [
|
|
114
|
+
'# Evaluation quality suite (bounded offline)',
|
|
115
|
+
'',
|
|
116
|
+
`Suite version: \`${report.suiteVersion}\``,
|
|
117
|
+
`Overall: **${report.passed ? 'PASS' : 'FAIL'}** · grade **${report.grades.overall}**`,
|
|
118
|
+
'',
|
|
119
|
+
'## IR ranking (Recall@k / MRR / nDCG / Precision@k)',
|
|
120
|
+
'',
|
|
121
|
+
`| Metric | Value |`,
|
|
122
|
+
`|--------|------:|`,
|
|
123
|
+
`| Queries | ${report.ranking.queryCount} |`,
|
|
124
|
+
`| MRR | ${((r.mrr || 0) * 100).toFixed(1)}% |`,
|
|
125
|
+
`| Recall@5 | ${((r['recall@5'] || 0) * 100).toFixed(1)}% |`,
|
|
126
|
+
`| Precision@5 | ${((r['precision@5'] || 0) * 100).toFixed(1)}% |`,
|
|
127
|
+
`| nDCG@5 | ${((r['ndcg@5'] || 0) * 100).toFixed(1)}% |`,
|
|
128
|
+
`| Ranking gate | ${report.ranking.passed ? 'PASS' : 'FAIL'} |`,
|
|
129
|
+
'',
|
|
130
|
+
'## Generation quality (offline Ragas-style)',
|
|
131
|
+
'',
|
|
132
|
+
`| Metric | Value |`,
|
|
133
|
+
`|--------|------:|`,
|
|
134
|
+
`| Cases | ${report.generation.caseCount} |`,
|
|
135
|
+
`| Faithfulness | ${((g.faithfulness || 0) * 100).toFixed(1)}% |`,
|
|
136
|
+
`| Groundedness | ${((g.groundedness || 0) * 100).toFixed(1)}% |`,
|
|
137
|
+
`| Answer relevance | ${((g.answer_relevance || 0) * 100).toFixed(1)}% |`,
|
|
138
|
+
`| Context recall | ${((g.context_recall || 0) * 100).toFixed(1)}% |`,
|
|
139
|
+
`| Context precision | ${((g.context_precision || 0) * 100).toFixed(1)}% |`,
|
|
140
|
+
`| Generation gate | ${report.generation.passed ? 'PASS' : 'FAIL'} |`,
|
|
141
|
+
'',
|
|
142
|
+
'## Grades',
|
|
143
|
+
'',
|
|
144
|
+
Object.entries(report.grades).map(([k, v]) => `- **${k}**: ${v}`).join('\n'),
|
|
145
|
+
'',
|
|
146
|
+
];
|
|
147
|
+
if (report.failures.length) {
|
|
148
|
+
lines.push('## Failures', '', ...report.failures.map((f) => `- ${f}`), '');
|
|
149
|
+
}
|
|
150
|
+
lines.push(
|
|
151
|
+
'## Honesty',
|
|
152
|
+
'',
|
|
153
|
+
'- IR metrics use graded qrels on a self-contained golden corpus.',
|
|
154
|
+
'- Generation metrics are offline lexical/claim proxies (not neural Ragas).',
|
|
155
|
+
'- Optional LLM judges may refine scores but cannot invent a pass when floors fail.',
|
|
156
|
+
'',
|
|
157
|
+
);
|
|
158
|
+
return lines.join('\n');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function writeReports(report) {
|
|
162
|
+
fs.mkdirSync(path.dirname(REPORT_MD), { recursive: true });
|
|
163
|
+
fs.writeFileSync(REPORT_MD, formatMarkdown(report));
|
|
164
|
+
fs.writeFileSync(REPORT_JSON, JSON.stringify(report, null, 2) + '\n');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function main() {
|
|
168
|
+
const json = process.argv.includes('--json');
|
|
169
|
+
const { report, ranking } = runSuite();
|
|
170
|
+
writeReports(report);
|
|
171
|
+
|
|
172
|
+
if (json) {
|
|
173
|
+
console.log(JSON.stringify(report, null, 2));
|
|
174
|
+
} else {
|
|
175
|
+
console.log(formatMarkdown(report));
|
|
176
|
+
// Also print ranking detail when verbose
|
|
177
|
+
if (process.argv.includes('--verbose') && ranking) {
|
|
178
|
+
console.log('\n' + formatRankingReport(ranking));
|
|
179
|
+
}
|
|
180
|
+
console.error(report.passed
|
|
181
|
+
? 'eval-quality-suite: PASS (bounded offline floors met)'
|
|
182
|
+
: `eval-quality-suite: FAIL — ${report.failures.join('; ')}`);
|
|
183
|
+
}
|
|
184
|
+
process.exit(report.passed ? 0 : 1);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isCliEntrypoint(argv = process.argv) {
|
|
188
|
+
return Boolean(argv[1]) && path.resolve(argv[1]) === path.resolve(__filename);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (isCliEntrypoint()) {
|
|
192
|
+
main();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
module.exports = {
|
|
196
|
+
SUITE_VERSION,
|
|
197
|
+
runSuite,
|
|
198
|
+
formatMarkdown,
|
|
199
|
+
loadGenerationGolden,
|
|
200
|
+
GEN_GOLDEN,
|
|
201
|
+
REPORT_MD,
|
|
202
|
+
REPORT_JSON,
|
|
203
|
+
isCliEntrypoint,
|
|
204
|
+
};
|