thumbgate 1.28.2 → 1.28.4

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.
@@ -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 };