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
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Design constraints:
|
|
14
14
|
* - Embedding the whole corpus on every tool call is too expensive. We cache
|
|
15
|
-
* document vectors keyed by `id +
|
|
15
|
+
* document vectors keyed by `id + sha256(text) + provider + dimension` in
|
|
16
|
+
* <feedbackDir>/lesson-embeddings.json.
|
|
16
17
|
* Only the query is embedded per call; only new/changed lessons re-embed.
|
|
17
18
|
* - The embedder is reused from vector-store.embed (Gemini -> local transformers
|
|
18
19
|
* -> stub). No new embedding dependency.
|
|
@@ -92,15 +93,12 @@ function cosineSimilarity(a, b) {
|
|
|
92
93
|
* WITHOUT loading a model. Returns true for the deterministic stub (test/CI safe).
|
|
93
94
|
*/
|
|
94
95
|
function isEmbedderAvailable() {
|
|
95
|
-
if (process.env.THUMBGATE_VECTOR_STUB_EMBED === 'true') return true;
|
|
96
|
-
// Managed Gemini path
|
|
97
96
|
try {
|
|
98
|
-
const {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return true;
|
|
97
|
+
const { hasSemanticEmbeddingProvider } = require('./vector-store');
|
|
98
|
+
return hasSemanticEmbeddingProvider();
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
104
102
|
}
|
|
105
103
|
|
|
106
104
|
function defaultEmbedder() {
|
|
@@ -122,12 +120,34 @@ function readCache(cachePath) {
|
|
|
122
120
|
function writeCache(cachePath, cache) {
|
|
123
121
|
try {
|
|
124
122
|
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
125
|
-
|
|
123
|
+
const tmpPath = `${cachePath}.${process.pid}.tmp`;
|
|
124
|
+
fs.writeFileSync(tmpPath, JSON.stringify(cache), { mode: 0o600 });
|
|
125
|
+
fs.renameSync(tmpPath, cachePath);
|
|
126
|
+
fs.chmodSync(cachePath, 0o600);
|
|
126
127
|
} catch {
|
|
127
128
|
/* cache is best-effort; never throw into the hot path */
|
|
128
129
|
}
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
function resolveProviderFingerprint(options, vector) {
|
|
133
|
+
if (options.embedder) {
|
|
134
|
+
return String(options.embedderId || options.embedder.providerId || 'injected-test');
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const { getLastEmbeddingProfile } = require('./vector-store');
|
|
138
|
+
const profile = getLastEmbeddingProfile();
|
|
139
|
+
const active = profile && profile.activeProfile;
|
|
140
|
+
return [
|
|
141
|
+
profile && profile.source,
|
|
142
|
+
active && active.id,
|
|
143
|
+
active && active.model,
|
|
144
|
+
Array.isArray(vector) ? vector.length : 0,
|
|
145
|
+
].filter(Boolean).join(':') || 'unknown';
|
|
146
|
+
} catch {
|
|
147
|
+
return 'unknown';
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
131
151
|
/**
|
|
132
152
|
* Rank lessons by dense (embedding) similarity to the query.
|
|
133
153
|
* Returns [{ id, score }] sorted descending. Embeds the query once; reuses cached
|
|
@@ -136,10 +156,17 @@ function writeCache(cachePath, cache) {
|
|
|
136
156
|
* @returns {Promise<Array<{id:string, score:number}>>}
|
|
137
157
|
*/
|
|
138
158
|
async function semanticRank(queryText, lessons = [], options = {}) {
|
|
139
|
-
const {
|
|
159
|
+
const {
|
|
160
|
+
feedbackDir,
|
|
161
|
+
embedder = defaultEmbedder(),
|
|
162
|
+
persist = true,
|
|
163
|
+
pruneCache = true,
|
|
164
|
+
truncateDimension = null,
|
|
165
|
+
cacheFile = CACHE_FILE,
|
|
166
|
+
} = options;
|
|
140
167
|
if (!queryText || !Array.isArray(lessons) || lessons.length === 0) return [];
|
|
141
168
|
|
|
142
|
-
const cachePath =
|
|
169
|
+
const cachePath = path.join(resolveFeedbackDir(feedbackDir), path.basename(cacheFile));
|
|
143
170
|
const cache = readCache(cachePath);
|
|
144
171
|
let cacheDirty = false;
|
|
145
172
|
|
|
@@ -149,6 +176,11 @@ async function semanticRank(queryText, lessons = [], options = {}) {
|
|
|
149
176
|
if (truncateDimension) {
|
|
150
177
|
queryVector = truncateVector(queryVector, truncateDimension);
|
|
151
178
|
}
|
|
179
|
+
const provider = resolveProviderFingerprint(options, queryVector);
|
|
180
|
+
const dimension = queryVector.length;
|
|
181
|
+
if (!options.embedder && /(?:built-in|feature-hash)/i.test(provider)) {
|
|
182
|
+
throw new Error('Semantic embedding provider degraded to feature hashing');
|
|
183
|
+
}
|
|
152
184
|
|
|
153
185
|
const scored = [];
|
|
154
186
|
for (const lesson of lessons) {
|
|
@@ -158,14 +190,22 @@ async function semanticRank(queryText, lessons = [], options = {}) {
|
|
|
158
190
|
const hash = hashText(text);
|
|
159
191
|
|
|
160
192
|
let entry = cache[lesson.id];
|
|
161
|
-
if (
|
|
193
|
+
if (
|
|
194
|
+
!entry
|
|
195
|
+
|| entry.hash !== hash
|
|
196
|
+
|| entry.provider !== provider
|
|
197
|
+
|| entry.dimension !== dimension
|
|
198
|
+
|| !Array.isArray(entry.vector)
|
|
199
|
+
|| entry.vector.length !== dimension
|
|
200
|
+
) {
|
|
162
201
|
const vector = await embedder(text, {
|
|
163
202
|
kind: 'document',
|
|
164
203
|
task: 'code retrieval',
|
|
165
204
|
title: lesson.title || undefined,
|
|
166
205
|
});
|
|
167
206
|
if (!Array.isArray(vector) || vector.length === 0) continue;
|
|
168
|
-
|
|
207
|
+
if (vector.length !== dimension) continue;
|
|
208
|
+
entry = { hash, provider, dimension, vector };
|
|
169
209
|
cache[lesson.id] = entry;
|
|
170
210
|
cacheDirty = true;
|
|
171
211
|
}
|
|
@@ -174,12 +214,16 @@ async function semanticRank(queryText, lessons = [], options = {}) {
|
|
|
174
214
|
scored.push({ id: lesson.id, score: cosineSimilarity(queryVector, docVector) });
|
|
175
215
|
}
|
|
176
216
|
|
|
177
|
-
// Prune
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
217
|
+
// Prune only when the caller supplied the complete corpus. Metadata-filtered
|
|
218
|
+
// searches pass pruneCache=false so alternating filters cannot evict and
|
|
219
|
+
// regenerate one another's vectors.
|
|
220
|
+
if (pruneCache) {
|
|
221
|
+
const liveIds = new Set(lessons.map((l) => l && l.id).filter(Boolean));
|
|
222
|
+
for (const id of Object.keys(cache)) {
|
|
223
|
+
if (!liveIds.has(id)) {
|
|
224
|
+
delete cache[id];
|
|
225
|
+
cacheDirty = true;
|
|
226
|
+
}
|
|
183
227
|
}
|
|
184
228
|
}
|
|
185
229
|
|
|
@@ -196,4 +240,7 @@ module.exports = {
|
|
|
196
240
|
lessonText,
|
|
197
241
|
hashText,
|
|
198
242
|
getCachePath,
|
|
243
|
+
readCache,
|
|
244
|
+
writeCache,
|
|
245
|
+
resolveProviderFingerprint,
|
|
199
246
|
};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const {
|
|
7
|
+
semanticRank,
|
|
8
|
+
isEmbedderAvailable,
|
|
9
|
+
lessonText,
|
|
10
|
+
hashText,
|
|
11
|
+
readCache,
|
|
12
|
+
} = require('./lesson-embedding-index');
|
|
13
|
+
const {
|
|
14
|
+
selectRetrievalMemories,
|
|
15
|
+
MAX_RETRIEVAL_MEMORY_LINES,
|
|
16
|
+
} = require('./lesson-retrieval');
|
|
17
|
+
|
|
18
|
+
function loadLessonCorpus(feedbackDir) {
|
|
19
|
+
const { readJSONL, getFeedbackPaths } = require('./feedback-loop');
|
|
20
|
+
const memoryPath = feedbackDir
|
|
21
|
+
? path.join(feedbackDir, 'memory-log.jsonl')
|
|
22
|
+
: getFeedbackPaths().MEMORY_LOG_PATH;
|
|
23
|
+
const lessons = selectRetrievalMemories(
|
|
24
|
+
readJSONL(memoryPath, { maxLines: MAX_RETRIEVAL_MEMORY_LINES }),
|
|
25
|
+
);
|
|
26
|
+
return { lessons, memoryPath };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function evaluateEmbeddingIndexDrift(options = {}) {
|
|
30
|
+
const feedbackDir = options.feedbackDir || process.env.THUMBGATE_FEEDBACK_DIR;
|
|
31
|
+
const { lessons, memoryPath } = loadLessonCorpus(feedbackDir);
|
|
32
|
+
const cachePath = path.join(
|
|
33
|
+
feedbackDir || path.dirname(memoryPath),
|
|
34
|
+
options.cacheFile || 'lesson-embeddings.json',
|
|
35
|
+
);
|
|
36
|
+
const cache = readCache(cachePath);
|
|
37
|
+
let indexedCount = 0;
|
|
38
|
+
const missingIds = [];
|
|
39
|
+
const staleIds = [];
|
|
40
|
+
const providers = {};
|
|
41
|
+
const dimensions = {};
|
|
42
|
+
const corpusIds = new Set(lessons.map((lesson) => lesson.id));
|
|
43
|
+
|
|
44
|
+
for (const lesson of lessons) {
|
|
45
|
+
const entry = cache[lesson.id];
|
|
46
|
+
const valid = entry
|
|
47
|
+
&& entry.hash === hashText(lessonText(lesson))
|
|
48
|
+
&& Array.isArray(entry.vector)
|
|
49
|
+
&& entry.vector.length > 0
|
|
50
|
+
&& entry.dimension === entry.vector.length
|
|
51
|
+
&& typeof entry.provider === 'string';
|
|
52
|
+
if (valid) {
|
|
53
|
+
indexedCount += 1;
|
|
54
|
+
providers[entry.provider] = (providers[entry.provider] || 0) + 1;
|
|
55
|
+
dimensions[entry.dimension] = (dimensions[entry.dimension] || 0) + 1;
|
|
56
|
+
} else if (entry) {
|
|
57
|
+
staleIds.push(lesson.id);
|
|
58
|
+
} else {
|
|
59
|
+
missingIds.push(lesson.id);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const orphanedIds = Object.keys(cache)
|
|
64
|
+
.filter((id) => !corpusIds.has(id))
|
|
65
|
+
.sort((left, right) => left.localeCompare(right));
|
|
66
|
+
missingIds.sort((left, right) => left.localeCompare(right));
|
|
67
|
+
staleIds.sort((left, right) => left.localeCompare(right));
|
|
68
|
+
|
|
69
|
+
const corpusCount = lessons.length;
|
|
70
|
+
const coverage = corpusCount ? indexedCount / corpusCount : 1;
|
|
71
|
+
const semanticProviderAvailable = options.embedder
|
|
72
|
+
? true
|
|
73
|
+
: isEmbedderAvailable();
|
|
74
|
+
const minCoverage = Math.max(0, Math.min(1, Number(options.minCoverage) || 0.95));
|
|
75
|
+
const requireExact = options.requireExact !== false;
|
|
76
|
+
const enabled = semanticProviderAvailable || Object.keys(cache).length > 0;
|
|
77
|
+
const exactIdSet = missingIds.length === 0
|
|
78
|
+
&& staleIds.length === 0
|
|
79
|
+
&& orphanedIds.length === 0;
|
|
80
|
+
const status = !enabled
|
|
81
|
+
? 'not_configured'
|
|
82
|
+
: coverage >= minCoverage
|
|
83
|
+
&& staleIds.length === 0
|
|
84
|
+
&& orphanedIds.length === 0
|
|
85
|
+
&& (!requireExact || exactIdSet)
|
|
86
|
+
? 'healthy'
|
|
87
|
+
: 'unhealthy';
|
|
88
|
+
return {
|
|
89
|
+
ok: status !== 'unhealthy',
|
|
90
|
+
status,
|
|
91
|
+
semanticProviderAvailable,
|
|
92
|
+
corpusCount,
|
|
93
|
+
indexedCount,
|
|
94
|
+
missingCount: missingIds.length,
|
|
95
|
+
missingIds,
|
|
96
|
+
staleCount: staleIds.length,
|
|
97
|
+
staleIds,
|
|
98
|
+
orphanedCount: orphanedIds.length,
|
|
99
|
+
orphanedIds,
|
|
100
|
+
exactIdSet,
|
|
101
|
+
requireExact,
|
|
102
|
+
coverage: Number(coverage.toFixed(4)),
|
|
103
|
+
minCoverage,
|
|
104
|
+
providers,
|
|
105
|
+
dimensions,
|
|
106
|
+
memoryPath,
|
|
107
|
+
cachePath,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function backfillLessonEmbeddings(options = {}) {
|
|
112
|
+
const feedbackDir = options.feedbackDir || process.env.THUMBGATE_FEEDBACK_DIR;
|
|
113
|
+
if (!options.embedder && !isEmbedderAvailable()) {
|
|
114
|
+
const error = new Error(
|
|
115
|
+
'No semantic embedding provider configured. Set THUMBGATE_OLLAMA_EMBED_MODEL or configure another real provider.',
|
|
116
|
+
);
|
|
117
|
+
error.code = 'THUMBGATE_SEMANTIC_PROVIDER_UNAVAILABLE';
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
const { lessons } = loadLessonCorpus(feedbackDir);
|
|
121
|
+
if (lessons.length > 0) {
|
|
122
|
+
await semanticRank('ThumbGate lesson embedding backfill health query', lessons, {
|
|
123
|
+
feedbackDir,
|
|
124
|
+
embedder: options.embedder,
|
|
125
|
+
embedderId: options.embedderId,
|
|
126
|
+
cacheFile: options.cacheFile,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return evaluateEmbeddingIndexDrift({
|
|
130
|
+
...options,
|
|
131
|
+
feedbackDir,
|
|
132
|
+
embedder: options.embedder,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function parseArgs(argv) {
|
|
137
|
+
const args = { json: false, backfill: false, requireSemantic: false };
|
|
138
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
139
|
+
const arg = argv[index];
|
|
140
|
+
if (arg === '--json') args.json = true;
|
|
141
|
+
else if (arg === '--backfill') args.backfill = true;
|
|
142
|
+
else if (arg === '--require-semantic') args.requireSemantic = true;
|
|
143
|
+
else if (arg === '--feedback-dir') args.feedbackDir = argv[++index];
|
|
144
|
+
else if (arg === '--min-coverage') args.minCoverage = Number(argv[++index]);
|
|
145
|
+
}
|
|
146
|
+
return args;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function runCli() {
|
|
150
|
+
const options = parseArgs(process.argv.slice(2));
|
|
151
|
+
let report;
|
|
152
|
+
try {
|
|
153
|
+
report = options.backfill
|
|
154
|
+
? await backfillLessonEmbeddings(options)
|
|
155
|
+
: evaluateEmbeddingIndexDrift(options);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
report = { ok: false, status: 'error', error: error.message, code: error.code || 'ERROR' };
|
|
158
|
+
}
|
|
159
|
+
const output = options.json
|
|
160
|
+
? JSON.stringify(report, null, 2)
|
|
161
|
+
: `Lesson embeddings: ${report.status}; ${report.indexedCount || 0}/${report.corpusCount || 0} indexed; coverage=${report.coverage || 0}`;
|
|
162
|
+
process.stdout.write(`${output}\n`);
|
|
163
|
+
if (!report.ok || (options.requireSemantic && !report.semanticProviderAvailable)) {
|
|
164
|
+
process.exitCode = 1;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!module.parent) {
|
|
169
|
+
runCli();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = {
|
|
173
|
+
loadLessonCorpus,
|
|
174
|
+
evaluateEmbeddingIndexDrift,
|
|
175
|
+
backfillLessonEmbeddings,
|
|
176
|
+
parseArgs,
|
|
177
|
+
};
|
|
@@ -170,6 +170,20 @@ function isPositiveSignal(signal) {
|
|
|
170
170
|
return signal === 'positive' || signal === 'up';
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
function isHumanReviewedLesson(lesson = {}) {
|
|
174
|
+
return (lesson.metadata?.reviewOrigin || lesson.reviewOrigin) === 'human';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function dedupeHumanReviewedLessons(lessons = []) {
|
|
178
|
+
const byFeedback = new Map();
|
|
179
|
+
for (const lesson of lessons) {
|
|
180
|
+
if (isHumanReviewedLesson(lesson) && lesson.feedbackId) {
|
|
181
|
+
byFeedback.set(lesson.feedbackId, lesson);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return [...byFeedback.values()];
|
|
185
|
+
}
|
|
186
|
+
|
|
173
187
|
function selectStatusbarLesson() {
|
|
174
188
|
const lessons = readJsonl(getLessonsPath())
|
|
175
189
|
.slice()
|
|
@@ -242,10 +256,14 @@ function searchLessons({ query = '', limit = 10, signal } = {}) {
|
|
|
242
256
|
*/
|
|
243
257
|
function getLessonStats() {
|
|
244
258
|
const lessons = readJsonl(getLessonsPath());
|
|
245
|
-
const
|
|
246
|
-
const
|
|
247
|
-
const
|
|
248
|
-
|
|
259
|
+
const humanReviewed = dedupeHumanReviewedLessons(lessons);
|
|
260
|
+
const positive = humanReviewed.filter((lesson) => isPositiveSignal(lesson.signal)).length;
|
|
261
|
+
const negative = humanReviewed.filter((lesson) => isNegativeSignal(lesson.signal)).length;
|
|
262
|
+
const avgConfidence = humanReviewed.length > 0
|
|
263
|
+
? Math.round(humanReviewed.reduce((sum, lesson) => sum + (lesson.confidence || 0), 0) / humanReviewed.length)
|
|
264
|
+
: 0;
|
|
265
|
+
return { total: positive + negative, positive, negative, avgConfidence,
|
|
266
|
+
rawTotal: lessons.length, excludedTotal: lessons.length - humanReviewed.length };
|
|
249
267
|
}
|
|
250
268
|
|
|
251
269
|
// ---------------------------------------------------------------------------
|
|
@@ -650,6 +668,7 @@ async function inferStructuredLessonLLM(conversationWindow, signal, context) {
|
|
|
650
668
|
module.exports = {
|
|
651
669
|
inferFromSurroundingMessages, createLesson, getRecentLesson,
|
|
652
670
|
searchLessons, getLessonStats, getStatusbarLessonData, getAllLessonsForContext,
|
|
671
|
+
isHumanReviewedLesson,
|
|
653
672
|
getLessonsPath, getRecentLessonPath,
|
|
654
673
|
selectStatusbarLesson, getLessonKind, stripLessonPrefix,
|
|
655
674
|
formatLessonTimestamp, buildStatusbarLessonLabel,
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Field-aware BM25F reranker for lesson retrieval.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* This is a deterministic lexical second stage, not a neural cross-encoder.
|
|
7
|
+
* It scores query terms against lesson fields jointly and catches relevance
|
|
8
|
+
* signals that the first-stage overlap score can miss:
|
|
9
9
|
*
|
|
10
10
|
* - Field-weighted BM25: a query term in `whatWentWrong` is worth more
|
|
11
11
|
* than the same term in `tags`
|
|
@@ -55,6 +55,11 @@ const SYNONYM_GROUPS = [
|
|
|
55
55
|
['auth', 'authentication', 'authorization', 'token', 'api key', 'credential'],
|
|
56
56
|
['delete', 'remove', 'rm', 'drop', 'destroy', 'wipe'],
|
|
57
57
|
['merge', 'pull request', 'pr', 'rebase', 'squash'],
|
|
58
|
+
['tablet', 'ipad', 'ios device'],
|
|
59
|
+
['overlay', 'tailscale', 'mesh network', 'vpn'],
|
|
60
|
+
['laptop', 'macbook', 'mac'],
|
|
61
|
+
['phone', 'mobile', 'android', 'device'],
|
|
62
|
+
['conversation', 'session', 'thread', 'chat'],
|
|
58
63
|
];
|
|
59
64
|
|
|
60
65
|
// Regex patterns that indicate the query is about a failure/mistake.
|
|
@@ -83,7 +88,13 @@ function expandTerms(terms) {
|
|
|
83
88
|
const expanded = new Set(terms);
|
|
84
89
|
for (const term of terms) {
|
|
85
90
|
for (const group of SYNONYM_GROUPS) {
|
|
86
|
-
if (group.some((syn) => syn
|
|
91
|
+
if (group.some((syn) => tokenize(syn).some((word) => (
|
|
92
|
+
word === term
|
|
93
|
+
|| (word.length >= 4 && term.length >= 4 && (
|
|
94
|
+
word.startsWith(term)
|
|
95
|
+
|| term.startsWith(word)
|
|
96
|
+
))
|
|
97
|
+
)))) {
|
|
87
98
|
group.forEach((syn) => tokenize(syn).forEach((t) => expanded.add(t)));
|
|
88
99
|
}
|
|
89
100
|
}
|
|
@@ -103,6 +114,28 @@ function getField(candidate, field) {
|
|
|
103
114
|
return String(val);
|
|
104
115
|
}
|
|
105
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Score exact entity-channel overlap separately from prose relevance.
|
|
119
|
+
*
|
|
120
|
+
* Tags, categories, and tool identities are low-cardinality metadata. Keeping
|
|
121
|
+
* this score visible prevents a tag-only hit from being swallowed by BM25
|
|
122
|
+
* field-length normalization and makes reranking traces explainable.
|
|
123
|
+
*/
|
|
124
|
+
function scoreEntityOverlap(queryTerms, candidate) {
|
|
125
|
+
const entityText = [
|
|
126
|
+
getField(candidate, 'tags'),
|
|
127
|
+
getField(candidate, 'category'),
|
|
128
|
+
getField(candidate, 'toolUsed'),
|
|
129
|
+
getField(candidate, 'toolName'),
|
|
130
|
+
...(candidate.metadata?.toolsUsed || []),
|
|
131
|
+
].filter(Boolean).join(' ');
|
|
132
|
+
const entityTokens = new Set(expandTerms(tokenize(entityText)));
|
|
133
|
+
const uniqueQueryTerms = [...new Set(queryTerms)];
|
|
134
|
+
if (uniqueQueryTerms.length === 0 || entityTokens.size === 0) return 0;
|
|
135
|
+
const hits = uniqueQueryTerms.filter((term) => entityTokens.has(term)).length;
|
|
136
|
+
return Number((hits / uniqueQueryTerms.length).toFixed(6));
|
|
137
|
+
}
|
|
138
|
+
|
|
106
139
|
/**
|
|
107
140
|
* Compute field-weighted BM25 scores for a list of candidates (BM25F variant).
|
|
108
141
|
*
|
|
@@ -189,7 +222,7 @@ function fieldWeightedBM25(queryTerms, candidates) {
|
|
|
189
222
|
}
|
|
190
223
|
|
|
191
224
|
/**
|
|
192
|
-
* Rerank a list of lesson candidates using
|
|
225
|
+
* Rerank a list of lesson candidates using field-aware BM25F.
|
|
193
226
|
*
|
|
194
227
|
* @param {string} query - The original retrieval query / action context
|
|
195
228
|
* @param {Array} candidates - Lesson objects from the bi-encoder stage
|
|
@@ -224,12 +257,15 @@ function rerankLessons(query, candidates, options = {}) {
|
|
|
224
257
|
|
|
225
258
|
const reranked = bm25Results.map(({ candidate, bm25Score }) => {
|
|
226
259
|
const normBm25 = bm25Score / maxBm25;
|
|
260
|
+
const entityScore = scoreEntityOverlap(queryTerms, candidate);
|
|
227
261
|
|
|
228
262
|
// Original bi-encoder score (field name differs between retrieval paths)
|
|
229
263
|
const origScore = candidate.relevanceScore ?? candidate.score ?? 0;
|
|
230
264
|
|
|
231
265
|
// Blend BM25 with original score
|
|
232
|
-
let finalScore =
|
|
266
|
+
let finalScore = 0.85 * (
|
|
267
|
+
blendWeight * normBm25 + (1 - blendWeight) * origScore
|
|
268
|
+
) + 0.15 * entityScore;
|
|
233
269
|
|
|
234
270
|
// Signal coherence bonus: failure queries → negative lessons rank higher
|
|
235
271
|
const candidateSignal =
|
|
@@ -252,7 +288,11 @@ function rerankLessons(query, candidates, options = {}) {
|
|
|
252
288
|
}
|
|
253
289
|
}
|
|
254
290
|
|
|
255
|
-
return {
|
|
291
|
+
return {
|
|
292
|
+
...candidate,
|
|
293
|
+
entityScore,
|
|
294
|
+
rerankedScore: Number(finalScore.toFixed(6)),
|
|
295
|
+
};
|
|
256
296
|
});
|
|
257
297
|
|
|
258
298
|
return reranked
|
|
@@ -260,4 +300,10 @@ function rerankLessons(query, candidates, options = {}) {
|
|
|
260
300
|
.slice(0, topK);
|
|
261
301
|
}
|
|
262
302
|
|
|
263
|
-
module.exports = {
|
|
303
|
+
module.exports = {
|
|
304
|
+
rerankLessons,
|
|
305
|
+
fieldWeightedBM25,
|
|
306
|
+
scoreEntityOverlap,
|
|
307
|
+
tokenize,
|
|
308
|
+
expandTerms,
|
|
309
|
+
};
|