thumbgate 1.28.1 → 1.28.3
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/adapters/claude/.mcp.json +2 -2
- package/adapters/mcp/server-stdio.js +100 -4
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +30 -7
- package/config/gates/default.json +2 -1
- package/config/mcp-allowlists.json +5 -0
- package/package.json +9 -4
- package/public/index.html +2 -2
- package/public/numbers.html +2 -2
- package/scripts/auto-wire-hooks.js +58 -2
- package/scripts/claude-feedback-sync.js +1 -1
- package/scripts/cli-feedback.js +5 -0
- package/scripts/cross-encoder-reranker.js +235 -0
- package/scripts/feedback-history-distiller.js +385 -0
- package/scripts/feedback-loop.js +45 -22
- package/scripts/gates-engine.js +6 -1
- package/scripts/lesson-embedding-index.js +203 -0
- package/scripts/lesson-reranker.js +263 -0
- package/scripts/lesson-retrieval.js +485 -0
- package/scripts/llm-client.js +18 -1
- package/scripts/mcp-policy.js +7 -1
- package/scripts/workflow-sentinel.js +34 -6
package/scripts/feedback-loop.js
CHANGED
|
@@ -57,11 +57,15 @@ const {
|
|
|
57
57
|
}));
|
|
58
58
|
|
|
59
59
|
const AUDIT_TRAIL_TAG = 'audit-trail';
|
|
60
|
-
const FEEDBACK_EVENT_DEDUPE_WINDOW_MS =
|
|
60
|
+
const FEEDBACK_EVENT_DEDUPE_WINDOW_MS = 5 * 60 * 1000;
|
|
61
61
|
const FEEDBACK_EVENT_CLAIM_STALE_MS = 60 * 1000;
|
|
62
62
|
const FEEDBACK_EVENT_CLAIM_WAIT_MS = 15 * 1000;
|
|
63
63
|
const FEEDBACK_EVENT_CLAIM_POLL_MS = 25;
|
|
64
64
|
|
|
65
|
+
function isSelfHarnessOptimizerEnabled(env = process.env) {
|
|
66
|
+
return /^(?:1|true)$/i.test(String(env.THUMBGATE_SELF_HARNESS_OPTIMIZER || '').trim());
|
|
67
|
+
}
|
|
68
|
+
|
|
65
69
|
/**
|
|
66
70
|
* Anonymous fire-and-forget CLI feedback telemetry.
|
|
67
71
|
*
|
|
@@ -666,8 +670,11 @@ function parseFeedbackSourceTimestamp(value) {
|
|
|
666
670
|
*/
|
|
667
671
|
function buildFeedbackSourceIdentity(input = {}) {
|
|
668
672
|
const signal = normalizeSignal(input.signal);
|
|
669
|
-
|
|
670
|
-
|
|
673
|
+
if (!signal) return null;
|
|
674
|
+
// Emoji-only prompts normalize to no alphanumeric text. Preserve a stable,
|
|
675
|
+
// non-sensitive canonical value so duplicate 👍/👎 hook deliveries still
|
|
676
|
+
// share one source identity and cannot create duplicate feedback rows.
|
|
677
|
+
const normalizedText = normalizeFeedbackText(input.promptText || input.context) || `thumbs-${signal}`;
|
|
671
678
|
|
|
672
679
|
const sessionHash = hashFeedbackSourcePart(input.sessionId);
|
|
673
680
|
const promptIdHash = hashFeedbackSourcePart(input.promptId);
|
|
@@ -696,10 +703,10 @@ function buildFeedbackSourceIdentity(input = {}) {
|
|
|
696
703
|
function normalizeFeedbackSourceIdentity(identity, params = {}) {
|
|
697
704
|
if (!identity || typeof identity !== 'object' || typeof identity.key !== 'string') return null;
|
|
698
705
|
const signal = normalizeSignal(params.signal || identity.signal);
|
|
706
|
+
if (!signal) return null;
|
|
699
707
|
const normalizedText = normalizeFeedbackText(
|
|
700
708
|
identity.normalizedText || params.context || params.whatWentWrong || params.whatWorked,
|
|
701
|
-
)
|
|
702
|
-
if (!signal || !normalizedText) return null;
|
|
709
|
+
) || `thumbs-${signal}`;
|
|
703
710
|
|
|
704
711
|
return {
|
|
705
712
|
key: `fev_${hashFeedbackSourcePart(identity.key)}`,
|
|
@@ -781,6 +788,9 @@ function duplicateCaptureResult(receipt, paths, options = {}) {
|
|
|
781
788
|
const memoryRecord = findRecordById(paths.MEMORY_LOG_PATH, receipt?.memoryId);
|
|
782
789
|
return {
|
|
783
790
|
accepted: Boolean(receipt?.accepted),
|
|
791
|
+
captured: Boolean(receipt?.signalLogged),
|
|
792
|
+
promoted: Boolean(receipt?.promoted),
|
|
793
|
+
promotionAccepted: Boolean(receipt?.promoted),
|
|
784
794
|
signalLogged: Boolean(receipt?.signalLogged),
|
|
785
795
|
duplicate: true,
|
|
786
796
|
pending: Boolean(options.pending),
|
|
@@ -820,7 +830,8 @@ function receiptFromFeedbackEntry(entry, paths) {
|
|
|
820
830
|
status: 'complete',
|
|
821
831
|
feedbackId: entry.id,
|
|
822
832
|
memoryId: memory?.id,
|
|
823
|
-
accepted: entry.
|
|
833
|
+
accepted: !Array.isArray(entry.validationIssues) || entry.validationIssues.length === 0,
|
|
834
|
+
promoted: entry.actionType !== 'no-action'
|
|
824
835
|
&& (!Array.isArray(entry.validationIssues) || entry.validationIssues.length === 0),
|
|
825
836
|
signalLogged: true,
|
|
826
837
|
completedAtMs: Date.parse(entry.timestamp || '') || Date.now(),
|
|
@@ -888,6 +899,7 @@ function finalizeFeedbackEventClaim(claim, result) {
|
|
|
888
899
|
startedAtMs: Date.now(),
|
|
889
900
|
completedAtMs: Date.now(),
|
|
890
901
|
accepted: Boolean(result?.accepted),
|
|
902
|
+
promoted: Boolean(result?.promoted || result?.memoryRecord),
|
|
891
903
|
signalLogged: Boolean(result?.signalLogged || result?.feedbackEvent),
|
|
892
904
|
feedbackId: result?.feedbackEvent?.id || null,
|
|
893
905
|
memoryId: result?.memoryRecord?.id || null,
|
|
@@ -1481,12 +1493,15 @@ function captureFeedback(params) {
|
|
|
1481
1493
|
trainAndPersistInterventionPolicy(FEEDBACK_DIR);
|
|
1482
1494
|
} catch { /* non-critical */ }
|
|
1483
1495
|
updateStatuslineWithLesson({
|
|
1484
|
-
accepted:
|
|
1496
|
+
accepted: true,
|
|
1485
1497
|
signal,
|
|
1486
1498
|
feedbackId: feedbackEvent.id,
|
|
1487
1499
|
});
|
|
1488
1500
|
return {
|
|
1489
|
-
accepted:
|
|
1501
|
+
accepted: true,
|
|
1502
|
+
captured: true,
|
|
1503
|
+
promoted: false,
|
|
1504
|
+
promotionAccepted: false,
|
|
1490
1505
|
signalLogged: true,
|
|
1491
1506
|
status: clarification ? 'clarification_required' : 'rejected',
|
|
1492
1507
|
reason: action.reason,
|
|
@@ -1692,6 +1707,9 @@ function captureFeedback(params) {
|
|
|
1692
1707
|
// Build result immediately — all remaining side-effects are deferred
|
|
1693
1708
|
const result = {
|
|
1694
1709
|
accepted: true,
|
|
1710
|
+
captured: true,
|
|
1711
|
+
promoted: true,
|
|
1712
|
+
promotionAccepted: true,
|
|
1695
1713
|
status: 'promoted',
|
|
1696
1714
|
message: 'Feedback promoted to reusable memory.',
|
|
1697
1715
|
feedbackEvent,
|
|
@@ -1770,19 +1788,23 @@ function captureFeedback(params) {
|
|
|
1770
1788
|
});
|
|
1771
1789
|
} catch { /* activation telemetry is non-critical */ }
|
|
1772
1790
|
|
|
1773
|
-
//
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1791
|
+
// Prompt-file mutation and git commits are explicit opt-in side effects.
|
|
1792
|
+
// Feedback capture must never detach an unrequested process that edits
|
|
1793
|
+
// AGENTS.md/GEMINI.md merely because a negative rule was promoted.
|
|
1794
|
+
if (isSelfHarnessOptimizerEnabled()) {
|
|
1795
|
+
try {
|
|
1796
|
+
const { fork } = require('child_process');
|
|
1797
|
+
const localOptimizerPath = path.join(process.cwd(), 'scripts', 'self-harness-optimizer.js');
|
|
1798
|
+
const packageOptimizerPath = path.join(__dirname, 'self-harness-optimizer.js');
|
|
1799
|
+
|
|
1800
|
+
if (fs.existsSync(localOptimizerPath)) {
|
|
1801
|
+
fork(localOptimizerPath, [], { stdio: 'ignore', detached: true }).unref();
|
|
1802
|
+
} else if (fs.existsSync(packageOptimizerPath)) {
|
|
1803
|
+
fork(packageOptimizerPath, [], { stdio: 'ignore', detached: true }).unref();
|
|
1804
|
+
}
|
|
1805
|
+
} catch (err) {
|
|
1806
|
+
console.error('Failed to trigger self-harness optimizer:', err);
|
|
1783
1807
|
}
|
|
1784
|
-
} catch (err) {
|
|
1785
|
-
console.error('Failed to trigger self-harness optimizer:', err);
|
|
1786
1808
|
}
|
|
1787
1809
|
}
|
|
1788
1810
|
} catch { /* Gate promotion is non-critical */ }
|
|
@@ -2416,10 +2438,10 @@ function runTests() {
|
|
|
2416
2438
|
budgetCompliant: true,
|
|
2417
2439
|
}),
|
|
2418
2440
|
});
|
|
2419
|
-
assert(
|
|
2441
|
+
assert(blocked.accepted && blocked.promoted === false, 'captureFeedback stores but does not promote unsafe positive feedback');
|
|
2420
2442
|
|
|
2421
2443
|
const bad = captureFeedback({ signal: 'down' });
|
|
2422
|
-
assert(
|
|
2444
|
+
assert(bad.accepted && bad.promoted === false, 'captureFeedback stores vague negative feedback without promotion');
|
|
2423
2445
|
assert(bad.needsClarification === true, 'captureFeedback requests clarification for vague negative feedback');
|
|
2424
2446
|
|
|
2425
2447
|
const summary = feedbackSummary(5);
|
|
@@ -2508,6 +2530,7 @@ module.exports = {
|
|
|
2508
2530
|
updateStatuslineWithLesson,
|
|
2509
2531
|
waitForBackgroundSideEffects,
|
|
2510
2532
|
getPendingBackgroundSideEffectCount,
|
|
2533
|
+
isSelfHarnessOptimizerEnabled,
|
|
2511
2534
|
getFeedbackPaths,
|
|
2512
2535
|
get FEEDBACK_LOG_PATH() {
|
|
2513
2536
|
return getFeedbackPaths().FEEDBACK_LOG_PATH;
|
package/scripts/gates-engine.js
CHANGED
|
@@ -2003,7 +2003,12 @@ function evaluateMemoryGuard(toolName, toolInput = {}) {
|
|
|
2003
2003
|
} else {
|
|
2004
2004
|
guard = hybrid.evaluatePretool(toolName, serializedInput);
|
|
2005
2005
|
}
|
|
2006
|
-
if (!guard || guard.mode
|
|
2006
|
+
if (!guard || guard.mode !== 'block') {
|
|
2007
|
+
return null;
|
|
2008
|
+
}
|
|
2009
|
+
const guardReason = String(guard.reason || '');
|
|
2010
|
+
const contextualMatch = /^(?:Matched guard pattern|Recurring negative pattern)\b/i.test(guardReason);
|
|
2011
|
+
if (!contextualMatch) {
|
|
2007
2012
|
return null;
|
|
2008
2013
|
}
|
|
2009
2014
|
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Lesson Embedding Index — maintained dense index over the lesson corpus.
|
|
6
|
+
*
|
|
7
|
+
* Powers the dense half of hybrid retrieval in the per-action gating hot path.
|
|
8
|
+
* The gating path reads memory-log lessons and must surface past mistakes that
|
|
9
|
+
* are semantically related to the current action even when they share no
|
|
10
|
+
* keywords (paraphrase, synonym, different file path). Pure lexical scoring
|
|
11
|
+
* misses those; dense embeddings catch them.
|
|
12
|
+
*
|
|
13
|
+
* Design constraints:
|
|
14
|
+
* - Embedding the whole corpus on every tool call is too expensive. We cache
|
|
15
|
+
* document vectors keyed by `id + sha1(text)` in <feedbackDir>/lesson-embeddings.json.
|
|
16
|
+
* Only the query is embedded per call; only new/changed lessons re-embed.
|
|
17
|
+
* - The embedder is reused from vector-store.embed (Gemini -> local transformers
|
|
18
|
+
* -> stub). No new embedding dependency.
|
|
19
|
+
* - HONESTY: when no real embedder is available, callers must degrade to lexical.
|
|
20
|
+
* We never synthesize fake (e.g. hash-based) vectors — that would be overclaiming.
|
|
21
|
+
*
|
|
22
|
+
* Zero hard dependency on LanceDB: this module only needs the embed() function.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const crypto = require('crypto');
|
|
28
|
+
|
|
29
|
+
const CACHE_FILE = 'lesson-embeddings.json';
|
|
30
|
+
|
|
31
|
+
function resolveFeedbackDir(explicit) {
|
|
32
|
+
if (explicit) return explicit;
|
|
33
|
+
try {
|
|
34
|
+
const { resolveFeedbackDir: resolve } = require('./feedback-paths');
|
|
35
|
+
return resolve();
|
|
36
|
+
} catch {
|
|
37
|
+
return process.env.THUMBGATE_FEEDBACK_DIR || process.cwd();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getCachePath(feedbackDir) {
|
|
42
|
+
return path.join(resolveFeedbackDir(feedbackDir), CACHE_FILE);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Canonical text for a lesson record (title + content + tags). */
|
|
46
|
+
function lessonText(lesson) {
|
|
47
|
+
if (!lesson) return '';
|
|
48
|
+
return [
|
|
49
|
+
lesson.title || '',
|
|
50
|
+
lesson.content || '',
|
|
51
|
+
Array.isArray(lesson.tags) ? lesson.tags.join(' ') : '',
|
|
52
|
+
].filter(Boolean).join(' ').trim();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Truncate a vector to a specific dimension (Matryoshka Embedding Truncation).
|
|
57
|
+
* Assumes the model supports MRL (like text-embedding-3 or modern transformers).
|
|
58
|
+
*/
|
|
59
|
+
function truncateVector(vector, dimension) {
|
|
60
|
+
if (!Array.isArray(vector) || !dimension || vector.length <= dimension) {
|
|
61
|
+
return vector;
|
|
62
|
+
}
|
|
63
|
+
return vector.slice(0, dimension);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function hashText(text) {
|
|
67
|
+
// Content-change key for the embedding cache (not a security context). sha256
|
|
68
|
+
// matches the repo's standard non-security hashing and avoids the weak-hash flag.
|
|
69
|
+
return crypto.createHash('sha256').update(String(text || '')).digest('hex');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function cosineSimilarity(a, b) {
|
|
73
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length === 0 || a.length !== b.length) {
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
let dot = 0;
|
|
77
|
+
let normA = 0;
|
|
78
|
+
let normB = 0;
|
|
79
|
+
for (let i = 0; i < a.length; i++) {
|
|
80
|
+
const x = Number(a[i]) || 0;
|
|
81
|
+
const y = Number(b[i]) || 0;
|
|
82
|
+
dot += x * y;
|
|
83
|
+
normA += x * x;
|
|
84
|
+
normB += y * y;
|
|
85
|
+
}
|
|
86
|
+
if (normA === 0 || normB === 0) return 0;
|
|
87
|
+
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Is a real embedder available? Used so callers can decide hybrid vs lexical-only
|
|
92
|
+
* WITHOUT loading a model. Returns true for the deterministic stub (test/CI safe).
|
|
93
|
+
*/
|
|
94
|
+
function isEmbedderAvailable() {
|
|
95
|
+
if (process.env.THUMBGATE_VECTOR_STUB_EMBED === 'true') return true;
|
|
96
|
+
// Managed Gemini path
|
|
97
|
+
try {
|
|
98
|
+
const { resolveGeminiEmbeddingConfig } = require('./gemini-embedding-policy');
|
|
99
|
+
const cfg = resolveGeminiEmbeddingConfig();
|
|
100
|
+
if (cfg && cfg.enabled && cfg.apiKey) return true;
|
|
101
|
+
} catch { /* policy module unavailable */ }
|
|
102
|
+
// Local transformers path
|
|
103
|
+
try {
|
|
104
|
+
require.resolve('@huggingface/transformers');
|
|
105
|
+
return true;
|
|
106
|
+
} catch { /* not installed */ }
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function defaultEmbedder() {
|
|
111
|
+
// Lazy: do not pull in LanceDB/transformers at module require time.
|
|
112
|
+
const { embed } = require('./vector-store');
|
|
113
|
+
return embed;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function readCache(cachePath) {
|
|
117
|
+
try {
|
|
118
|
+
const raw = fs.readFileSync(cachePath, 'utf8');
|
|
119
|
+
const parsed = JSON.parse(raw);
|
|
120
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
121
|
+
} catch {
|
|
122
|
+
return {};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function writeCache(cachePath, cache) {
|
|
127
|
+
try {
|
|
128
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
129
|
+
fs.writeFileSync(cachePath, JSON.stringify(cache));
|
|
130
|
+
} catch {
|
|
131
|
+
/* cache is best-effort; never throw into the hot path */
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Rank lessons by dense (embedding) similarity to the query.
|
|
137
|
+
* Returns [{ id, score }] sorted descending. Embeds the query once; reuses cached
|
|
138
|
+
* document vectors and only embeds new/changed lessons.
|
|
139
|
+
*
|
|
140
|
+
* @returns {Promise<Array<{id:string, score:number}>>}
|
|
141
|
+
*/
|
|
142
|
+
async function semanticRank(queryText, lessons = [], options = {}) {
|
|
143
|
+
const { feedbackDir, embedder = defaultEmbedder(), persist = true, truncateDimension = null } = options;
|
|
144
|
+
if (!queryText || !Array.isArray(lessons) || lessons.length === 0) return [];
|
|
145
|
+
|
|
146
|
+
const cachePath = getCachePath(feedbackDir);
|
|
147
|
+
const cache = readCache(cachePath);
|
|
148
|
+
let cacheDirty = false;
|
|
149
|
+
|
|
150
|
+
let queryVector = await embedder(queryText, { kind: 'query', task: 'code retrieval' });
|
|
151
|
+
if (!Array.isArray(queryVector) || queryVector.length === 0) return [];
|
|
152
|
+
|
|
153
|
+
if (truncateDimension) {
|
|
154
|
+
queryVector = truncateVector(queryVector, truncateDimension);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const scored = [];
|
|
158
|
+
for (const lesson of lessons) {
|
|
159
|
+
if (!lesson || !lesson.id) continue;
|
|
160
|
+
const text = lessonText(lesson);
|
|
161
|
+
if (!text) continue;
|
|
162
|
+
const hash = hashText(text);
|
|
163
|
+
|
|
164
|
+
let entry = cache[lesson.id];
|
|
165
|
+
if (!entry || entry.hash !== hash || !Array.isArray(entry.vector)) {
|
|
166
|
+
const vector = await embedder(text, {
|
|
167
|
+
kind: 'document',
|
|
168
|
+
task: 'code retrieval',
|
|
169
|
+
title: lesson.title || undefined,
|
|
170
|
+
});
|
|
171
|
+
if (!Array.isArray(vector) || vector.length === 0) continue;
|
|
172
|
+
entry = { hash, vector };
|
|
173
|
+
cache[lesson.id] = entry;
|
|
174
|
+
cacheDirty = true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const docVector = truncateDimension ? truncateVector(entry.vector, truncateDimension) : entry.vector;
|
|
178
|
+
scored.push({ id: lesson.id, score: cosineSimilarity(queryVector, docVector) });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Prune cache entries for lessons no longer present (bounded growth).
|
|
182
|
+
const liveIds = new Set(lessons.map((l) => l && l.id).filter(Boolean));
|
|
183
|
+
for (const id of Object.keys(cache)) {
|
|
184
|
+
if (!liveIds.has(id)) {
|
|
185
|
+
delete cache[id];
|
|
186
|
+
cacheDirty = true;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (persist && cacheDirty) writeCache(cachePath, cache);
|
|
191
|
+
|
|
192
|
+
return scored.sort((a, b) => b.score - a.score);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
module.exports = {
|
|
196
|
+
semanticRank,
|
|
197
|
+
isEmbedderAvailable,
|
|
198
|
+
cosineSimilarity,
|
|
199
|
+
truncateVector,
|
|
200
|
+
lessonText,
|
|
201
|
+
hashText,
|
|
202
|
+
getCachePath,
|
|
203
|
+
};
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cross-encoder reranker for lesson retrieval.
|
|
5
|
+
*
|
|
6
|
+
* Unlike the bi-encoders already in use (Jaccard + bigram Jaccard), a
|
|
7
|
+
* cross-encoder processes the (query, lesson) pair jointly — so it can
|
|
8
|
+
* catch relevance signals that independent scoring misses:
|
|
9
|
+
*
|
|
10
|
+
* - Field-weighted BM25: a query term in `whatWentWrong` is worth more
|
|
11
|
+
* than the same term in `tags`
|
|
12
|
+
* - Synonym/alias expansion: "force-push" ↔ "push --force", "deploy" ↔
|
|
13
|
+
* "deployment", etc.
|
|
14
|
+
* - Signal coherence: failure-sounding queries boost negative-signal lessons
|
|
15
|
+
* - Tool name joint scoring: query toolName × lesson toolsUsed
|
|
16
|
+
* - Score blending: reranked score is blended with the original retrieval
|
|
17
|
+
* score so we never fully discard the bi-encoder's signal
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* const { rerankLessons } = require('./lesson-reranker');
|
|
21
|
+
* const reranked = rerankLessons(query, candidates, { topK: 5, toolName });
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// BM25 hyper-parameters
|
|
25
|
+
const BM25_K1 = 1.5; // term saturation
|
|
26
|
+
const BM25_B = 0.75; // length normalisation
|
|
27
|
+
|
|
28
|
+
// Weight given to each lesson field when scoring a (query, lesson) pair.
|
|
29
|
+
// Higher weight = query terms appearing in that field contribute more to score.
|
|
30
|
+
const FIELD_WEIGHTS = {
|
|
31
|
+
whatWentWrong: 3.0,
|
|
32
|
+
whatToChange: 2.5,
|
|
33
|
+
howToAvoid: 2.0,
|
|
34
|
+
whatWorked: 2.0,
|
|
35
|
+
summary: 1.5,
|
|
36
|
+
content: 1.5,
|
|
37
|
+
context: 1.2,
|
|
38
|
+
title: 1.0,
|
|
39
|
+
rootCause: 1.0,
|
|
40
|
+
reasoning: 0.8,
|
|
41
|
+
tags: 0.5,
|
|
42
|
+
category: 0.4,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Synonym clusters: any term in a group matches all others.
|
|
46
|
+
const SYNONYM_GROUPS = [
|
|
47
|
+
['force-push', 'force push', 'push --force', 'git push --force', 'force_push'],
|
|
48
|
+
['main', 'main branch', 'master', 'trunk', 'protected branch'],
|
|
49
|
+
['env', '.env', 'environment variable', 'env var', 'dotenv', 'secret'],
|
|
50
|
+
['deploy', 'deployment', 'ship', 'release', 'publish', 'rollout'],
|
|
51
|
+
['db', 'database', 'sqlite', 'postgres', 'postgresql', 'migration', 'migrate'],
|
|
52
|
+
['test', 'tests', 'test suite', 'spec', 'failing test', 'test failure'],
|
|
53
|
+
['ci', 'ci/cd', 'pipeline', 'github actions', 'workflow', 'build'],
|
|
54
|
+
['lint', 'linter', 'eslint', 'prettier', 'format'],
|
|
55
|
+
['auth', 'authentication', 'authorization', 'token', 'api key', 'credential'],
|
|
56
|
+
['delete', 'remove', 'rm', 'drop', 'destroy', 'wipe'],
|
|
57
|
+
['merge', 'pull request', 'pr', 'rebase', 'squash'],
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
// Regex patterns that indicate the query is about a failure/mistake.
|
|
61
|
+
const FAILURE_PATTERN = /fail|error|wrong|broken|mistake|bad|incorrect|problem|issue|bug|crash|broke|exception/i;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Tokenise text into lowercase word-like tokens of length >= 2.
|
|
65
|
+
* Hyphens and underscores are treated as delimiters so "force-push"
|
|
66
|
+
* becomes ["force", "push"].
|
|
67
|
+
* Exported so tests can verify expansion behaviour.
|
|
68
|
+
*/
|
|
69
|
+
function tokenize(text) {
|
|
70
|
+
if (!text) return [];
|
|
71
|
+
return text
|
|
72
|
+
.toLowerCase()
|
|
73
|
+
.replace(/[^\w\s]/g, ' ') // replace all non-word, non-space chars (incl. hyphens, dots) with space
|
|
74
|
+
.split(/[\s_]+/) // split on whitespace and underscores
|
|
75
|
+
.filter((t) => t.length >= 2);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Expand a set of query tokens with synonyms from SYNONYM_GROUPS.
|
|
80
|
+
* Returns a deduplicated array of all terms (originals + expansions).
|
|
81
|
+
*/
|
|
82
|
+
function expandTerms(terms) {
|
|
83
|
+
const expanded = new Set(terms);
|
|
84
|
+
for (const term of terms) {
|
|
85
|
+
for (const group of SYNONYM_GROUPS) {
|
|
86
|
+
if (group.some((syn) => syn.split(/\s+/).some((w) => w === term || term.includes(w)))) {
|
|
87
|
+
group.forEach((syn) => tokenize(syn).forEach((t) => expanded.add(t)));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return [...expanded];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Extract the text value of a named field from a lesson candidate.
|
|
96
|
+
* Handles both the flat structure from lesson-retrieval.js and the nested
|
|
97
|
+
* { lesson: { whatWentWrong, ... } } structure from lesson-search.js.
|
|
98
|
+
*/
|
|
99
|
+
function getField(candidate, field) {
|
|
100
|
+
const nested = candidate.lesson;
|
|
101
|
+
const val = (nested && nested[field]) || candidate[field] || '';
|
|
102
|
+
if (Array.isArray(val)) return val.join(' ');
|
|
103
|
+
return String(val);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Compute field-weighted BM25 scores for a list of candidates (BM25F variant).
|
|
108
|
+
*
|
|
109
|
+
* BM25F processes the (query, lesson) pair jointly: query terms are weighted
|
|
110
|
+
* differently depending on which lesson field they appear in (via FIELD_WEIGHTS).
|
|
111
|
+
* IDF is computed at document level (how many docs contain the term across any
|
|
112
|
+
* field) so it stays positive regardless of field weights.
|
|
113
|
+
*
|
|
114
|
+
* Returns an array of { candidate, bm25Score } objects in the same order
|
|
115
|
+
* as the input.
|
|
116
|
+
*/
|
|
117
|
+
function fieldWeightedBM25(queryTerms, candidates) {
|
|
118
|
+
const N = candidates.length;
|
|
119
|
+
if (N === 0) return [];
|
|
120
|
+
|
|
121
|
+
const fieldEntries = Object.entries(FIELD_WEIGHTS);
|
|
122
|
+
const fieldKeys = Object.keys(FIELD_WEIGHTS);
|
|
123
|
+
|
|
124
|
+
// Precompute per-document, per-field token arrays (avoid re-tokenising)
|
|
125
|
+
const docFieldTokens = candidates.map((candidate) => {
|
|
126
|
+
const fieldMap = {};
|
|
127
|
+
for (const field of fieldKeys) {
|
|
128
|
+
fieldMap[field] = tokenize(getField(candidate, field));
|
|
129
|
+
}
|
|
130
|
+
return fieldMap;
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Per-field average token lengths across all documents
|
|
134
|
+
const avgFieldLen = {};
|
|
135
|
+
for (const field of fieldKeys) {
|
|
136
|
+
const total = docFieldTokens.reduce((sum, d) => sum + d[field].length, 0);
|
|
137
|
+
avgFieldLen[field] = total / N || 1; // fallback to 1 to avoid /0
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Document-level df: count of documents that contain each term (any field).
|
|
141
|
+
// Keeping df as a plain count (not field-weighted) ensures IDF is always positive.
|
|
142
|
+
const df = new Map();
|
|
143
|
+
for (let i = 0; i < N; i++) {
|
|
144
|
+
const seenInDoc = new Set();
|
|
145
|
+
for (const field of fieldKeys) {
|
|
146
|
+
for (const tok of docFieldTokens[i][field]) {
|
|
147
|
+
if (!seenInDoc.has(tok)) {
|
|
148
|
+
df.set(tok, (df.get(tok) || 0) + 1);
|
|
149
|
+
seenInDoc.add(tok);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return candidates.map((candidate, i) => {
|
|
156
|
+
let score = 0;
|
|
157
|
+
|
|
158
|
+
for (const qTerm of queryTerms) {
|
|
159
|
+
const termDf = df.get(qTerm) || 0;
|
|
160
|
+
if (termDf === 0) continue;
|
|
161
|
+
|
|
162
|
+
// IDF is always positive because df ≤ N
|
|
163
|
+
const idf = Math.log((N - termDf + 0.5) / (termDf + 0.5) + 1);
|
|
164
|
+
if (idf <= 0) continue;
|
|
165
|
+
|
|
166
|
+
// BM25F: compute weighted sum of per-field normalised TF, then scale by IDF
|
|
167
|
+
let weightedTF = 0;
|
|
168
|
+
for (const [field, fieldWeight] of fieldEntries) {
|
|
169
|
+
const tokens = docFieldTokens[i][field];
|
|
170
|
+
const fieldLen = tokens.length;
|
|
171
|
+
if (fieldLen === 0) continue;
|
|
172
|
+
|
|
173
|
+
let tf = 0;
|
|
174
|
+
for (const t of tokens) {
|
|
175
|
+
if (t === qTerm) tf++;
|
|
176
|
+
}
|
|
177
|
+
if (tf === 0) continue;
|
|
178
|
+
|
|
179
|
+
const avgLen = avgFieldLen[field];
|
|
180
|
+
const normTF = tf / (tf + BM25_K1 * (1 - BM25_B + BM25_B * fieldLen / avgLen));
|
|
181
|
+
weightedTF += fieldWeight * normTF;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
score += idf * weightedTF;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { candidate, bm25Score: score };
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Rerank a list of lesson candidates using a cross-encoder approach.
|
|
193
|
+
*
|
|
194
|
+
* @param {string} query - The original retrieval query / action context
|
|
195
|
+
* @param {Array} candidates - Lesson objects from the bi-encoder stage
|
|
196
|
+
* @param {object} options
|
|
197
|
+
* @param {number} [options.topK=5] - How many results to return
|
|
198
|
+
* @param {string} [options.toolName] - Tool name from the triggering hook call
|
|
199
|
+
* @param {number} [options.blendWeight=0.7] - Weight given to BM25 score vs original
|
|
200
|
+
* retrieval score (0 = all original, 1 = all BM25)
|
|
201
|
+
* @returns {Array} Reranked candidates with `rerankedScore` field added
|
|
202
|
+
*/
|
|
203
|
+
function rerankLessons(query, candidates, options = {}) {
|
|
204
|
+
const {
|
|
205
|
+
topK = 5,
|
|
206
|
+
toolName = '',
|
|
207
|
+
blendWeight = 0.7,
|
|
208
|
+
} = options;
|
|
209
|
+
|
|
210
|
+
if (!candidates || candidates.length === 0) return [];
|
|
211
|
+
if (candidates.length === 1) return candidates.slice(0, topK);
|
|
212
|
+
|
|
213
|
+
// Build expanded query term set
|
|
214
|
+
const rawTerms = tokenize((query || '') + (toolName ? ' ' + toolName : ''));
|
|
215
|
+
const queryTerms = expandTerms(rawTerms);
|
|
216
|
+
|
|
217
|
+
const isFailureQuery = FAILURE_PATTERN.test(query || '');
|
|
218
|
+
|
|
219
|
+
// Compute BM25 scores for all candidates
|
|
220
|
+
const bm25Results = fieldWeightedBM25(queryTerms, candidates);
|
|
221
|
+
|
|
222
|
+
// Normalise BM25 scores to [0, 1]
|
|
223
|
+
const maxBm25 = Math.max(...bm25Results.map((r) => r.bm25Score), 1e-9);
|
|
224
|
+
|
|
225
|
+
const reranked = bm25Results.map(({ candidate, bm25Score }) => {
|
|
226
|
+
const normBm25 = bm25Score / maxBm25;
|
|
227
|
+
|
|
228
|
+
// Original bi-encoder score (field name differs between retrieval paths)
|
|
229
|
+
const origScore = candidate.relevanceScore ?? candidate.score ?? 0;
|
|
230
|
+
|
|
231
|
+
// Blend BM25 with original score
|
|
232
|
+
let finalScore = blendWeight * normBm25 + (1 - blendWeight) * origScore;
|
|
233
|
+
|
|
234
|
+
// Signal coherence bonus: failure queries → negative lessons rank higher
|
|
235
|
+
const candidateSignal =
|
|
236
|
+
candidate.signal ||
|
|
237
|
+
(candidate.tags && candidate.tags.includes('negative') ? 'negative' : null);
|
|
238
|
+
if (isFailureQuery && candidateSignal === 'negative') {
|
|
239
|
+
finalScore *= 1.2;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Tool name joint bonus: exact tool match between query context and lesson
|
|
243
|
+
if (toolName) {
|
|
244
|
+
const lessonTools = [
|
|
245
|
+
...(candidate.metadata?.toolsUsed || []),
|
|
246
|
+
getField(candidate, 'toolUsed'),
|
|
247
|
+
getField(candidate, 'toolName'),
|
|
248
|
+
].map((t) => (t || '').toLowerCase());
|
|
249
|
+
|
|
250
|
+
if (lessonTools.some((t) => t && t.includes(toolName.toLowerCase()))) {
|
|
251
|
+
finalScore *= 1.3;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return { ...candidate, rerankedScore: Number(finalScore.toFixed(6)) };
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
return reranked
|
|
259
|
+
.sort((a, b) => b.rerankedScore - a.rerankedScore)
|
|
260
|
+
.slice(0, topK);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
module.exports = { rerankLessons, fieldWeightedBM25, tokenize, expandTerms };
|