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.
- 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/forge/forge.yaml +3 -3
- package/adapters/mcp/server-stdio.js +71 -10
- package/adapters/opencode/opencode.json +1 -1
- package/config/gates/default.json +2 -1
- package/package.json +8 -4
- package/public/index.html +2 -2
- package/public/numbers.html +2 -2
- package/scripts/claude-feedback-sync.js +1 -1
- package/scripts/cli-feedback.js +6 -0
- package/scripts/cross-encoder-reranker.js +235 -0
- package/scripts/feedback-loop.js +67 -36
- 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 +38 -8
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
|
*
|
|
@@ -90,7 +94,9 @@ function emitAnonymousFeedbackPing(signal) {
|
|
|
90
94
|
return;
|
|
91
95
|
}
|
|
92
96
|
|
|
93
|
-
|
|
97
|
+
let normalizedSignal = null;
|
|
98
|
+
if (signal === 'positive') normalizedSignal = 'up';
|
|
99
|
+
if (signal === 'negative') normalizedSignal = 'down';
|
|
94
100
|
if (!normalizedSignal) return;
|
|
95
101
|
|
|
96
102
|
// Reuse the canonical installId from cli-telemetry.js (persisted at
|
|
@@ -101,24 +107,24 @@ function emitAnonymousFeedbackPing(signal) {
|
|
|
101
107
|
try {
|
|
102
108
|
const { getInstallId } = require('./cli-telemetry');
|
|
103
109
|
installId = getInstallId();
|
|
104
|
-
} catch
|
|
110
|
+
} catch {
|
|
111
|
+
installId = null;
|
|
112
|
+
}
|
|
105
113
|
if (!installId) {
|
|
106
|
-
|
|
107
|
-
installId = require('crypto').randomUUID();
|
|
108
|
-
} catch (_) {
|
|
109
|
-
return; // no crypto, no install id → drop silently
|
|
110
|
-
}
|
|
114
|
+
installId = crypto.randomUUID();
|
|
111
115
|
}
|
|
112
116
|
|
|
113
117
|
let tier = 'free';
|
|
114
118
|
try {
|
|
115
119
|
const { getStatuslineMeta } = require('./statusline-meta');
|
|
116
120
|
const meta = getStatuslineMeta({ env });
|
|
117
|
-
const rawTier = String(meta
|
|
121
|
+
const rawTier = String(meta?.tier || 'free').toLowerCase();
|
|
118
122
|
if (rawTier === 'pro' || rawTier === 'enterprise' || rawTier === 'free') {
|
|
119
123
|
tier = rawTier;
|
|
120
124
|
}
|
|
121
|
-
} catch
|
|
125
|
+
} catch {
|
|
126
|
+
tier = 'free';
|
|
127
|
+
}
|
|
122
128
|
|
|
123
129
|
const base = env.THUMBGATE_PUBLIC_APP_ORIGIN
|
|
124
130
|
|| env.THUMBGATE_API_URL
|
|
@@ -137,13 +143,17 @@ function emitAnonymousFeedbackPing(signal) {
|
|
|
137
143
|
if (typeof fetch !== 'function' || typeof AbortSignal === 'undefined' || typeof AbortSignal.timeout !== 'function') {
|
|
138
144
|
return;
|
|
139
145
|
}
|
|
140
|
-
|
|
146
|
+
let appOrigin = String(base);
|
|
147
|
+
while (appOrigin.endsWith('/')) appOrigin = appOrigin.slice(0, -1);
|
|
148
|
+
void fetch(`${appOrigin}/v1/telemetry/ping`, {
|
|
141
149
|
method: 'POST',
|
|
142
150
|
headers: { 'Content-Type': 'application/json' },
|
|
143
151
|
body,
|
|
144
152
|
signal: AbortSignal.timeout(2000),
|
|
145
|
-
}).catch(() =>
|
|
146
|
-
} catch
|
|
153
|
+
}).catch(() => undefined);
|
|
154
|
+
} catch {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
147
157
|
}
|
|
148
158
|
|
|
149
159
|
function isAuditTrailEntry(entry = {}) {
|
|
@@ -666,8 +676,11 @@ function parseFeedbackSourceTimestamp(value) {
|
|
|
666
676
|
*/
|
|
667
677
|
function buildFeedbackSourceIdentity(input = {}) {
|
|
668
678
|
const signal = normalizeSignal(input.signal);
|
|
669
|
-
|
|
670
|
-
|
|
679
|
+
if (!signal) return null;
|
|
680
|
+
// Emoji-only prompts normalize to no alphanumeric text. Preserve a stable,
|
|
681
|
+
// non-sensitive canonical value so duplicate 👍/👎 hook deliveries still
|
|
682
|
+
// share one source identity and cannot create duplicate feedback rows.
|
|
683
|
+
const normalizedText = normalizeFeedbackText(input.promptText || input.context) || `thumbs-${signal}`;
|
|
671
684
|
|
|
672
685
|
const sessionHash = hashFeedbackSourcePart(input.sessionId);
|
|
673
686
|
const promptIdHash = hashFeedbackSourcePart(input.promptId);
|
|
@@ -696,10 +709,10 @@ function buildFeedbackSourceIdentity(input = {}) {
|
|
|
696
709
|
function normalizeFeedbackSourceIdentity(identity, params = {}) {
|
|
697
710
|
if (!identity || typeof identity !== 'object' || typeof identity.key !== 'string') return null;
|
|
698
711
|
const signal = normalizeSignal(params.signal || identity.signal);
|
|
712
|
+
if (!signal) return null;
|
|
699
713
|
const normalizedText = normalizeFeedbackText(
|
|
700
714
|
identity.normalizedText || params.context || params.whatWentWrong || params.whatWorked,
|
|
701
|
-
)
|
|
702
|
-
if (!signal || !normalizedText) return null;
|
|
715
|
+
) || `thumbs-${signal}`;
|
|
703
716
|
|
|
704
717
|
return {
|
|
705
718
|
key: `fev_${hashFeedbackSourcePart(identity.key)}`,
|
|
@@ -781,6 +794,9 @@ function duplicateCaptureResult(receipt, paths, options = {}) {
|
|
|
781
794
|
const memoryRecord = findRecordById(paths.MEMORY_LOG_PATH, receipt?.memoryId);
|
|
782
795
|
return {
|
|
783
796
|
accepted: Boolean(receipt?.accepted),
|
|
797
|
+
captured: Boolean(receipt?.signalLogged),
|
|
798
|
+
promoted: Boolean(receipt?.promoted),
|
|
799
|
+
promotionAccepted: Boolean(receipt?.promoted),
|
|
784
800
|
signalLogged: Boolean(receipt?.signalLogged),
|
|
785
801
|
duplicate: true,
|
|
786
802
|
pending: Boolean(options.pending),
|
|
@@ -820,7 +836,8 @@ function receiptFromFeedbackEntry(entry, paths) {
|
|
|
820
836
|
status: 'complete',
|
|
821
837
|
feedbackId: entry.id,
|
|
822
838
|
memoryId: memory?.id,
|
|
823
|
-
accepted: entry.
|
|
839
|
+
accepted: !Array.isArray(entry.validationIssues) || entry.validationIssues.length === 0,
|
|
840
|
+
promoted: entry.actionType !== 'no-action'
|
|
824
841
|
&& (!Array.isArray(entry.validationIssues) || entry.validationIssues.length === 0),
|
|
825
842
|
signalLogged: true,
|
|
826
843
|
completedAtMs: Date.parse(entry.timestamp || '') || Date.now(),
|
|
@@ -888,6 +905,7 @@ function finalizeFeedbackEventClaim(claim, result) {
|
|
|
888
905
|
startedAtMs: Date.now(),
|
|
889
906
|
completedAtMs: Date.now(),
|
|
890
907
|
accepted: Boolean(result?.accepted),
|
|
908
|
+
promoted: Boolean(result?.promoted || result?.memoryRecord),
|
|
891
909
|
signalLogged: Boolean(result?.signalLogged || result?.feedbackEvent),
|
|
892
910
|
feedbackId: result?.feedbackEvent?.id || null,
|
|
893
911
|
memoryId: result?.memoryRecord?.id || null,
|
|
@@ -1481,12 +1499,15 @@ function captureFeedback(params) {
|
|
|
1481
1499
|
trainAndPersistInterventionPolicy(FEEDBACK_DIR);
|
|
1482
1500
|
} catch { /* non-critical */ }
|
|
1483
1501
|
updateStatuslineWithLesson({
|
|
1484
|
-
accepted:
|
|
1502
|
+
accepted: true,
|
|
1485
1503
|
signal,
|
|
1486
1504
|
feedbackId: feedbackEvent.id,
|
|
1487
1505
|
});
|
|
1488
1506
|
return {
|
|
1489
|
-
accepted:
|
|
1507
|
+
accepted: true,
|
|
1508
|
+
captured: true,
|
|
1509
|
+
promoted: false,
|
|
1510
|
+
promotionAccepted: false,
|
|
1490
1511
|
signalLogged: true,
|
|
1491
1512
|
status: clarification ? 'clarification_required' : 'rejected',
|
|
1492
1513
|
reason: action.reason,
|
|
@@ -1692,6 +1713,9 @@ function captureFeedback(params) {
|
|
|
1692
1713
|
// Build result immediately — all remaining side-effects are deferred
|
|
1693
1714
|
const result = {
|
|
1694
1715
|
accepted: true,
|
|
1716
|
+
captured: true,
|
|
1717
|
+
promoted: true,
|
|
1718
|
+
promotionAccepted: true,
|
|
1695
1719
|
status: 'promoted',
|
|
1696
1720
|
message: 'Feedback promoted to reusable memory.',
|
|
1697
1721
|
feedbackEvent,
|
|
@@ -1770,19 +1794,23 @@ function captureFeedback(params) {
|
|
|
1770
1794
|
});
|
|
1771
1795
|
} catch { /* activation telemetry is non-critical */ }
|
|
1772
1796
|
|
|
1773
|
-
//
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1797
|
+
// Prompt-file mutation and git commits are explicit opt-in side effects.
|
|
1798
|
+
// Feedback capture must never detach an unrequested process that edits
|
|
1799
|
+
// AGENTS.md/GEMINI.md merely because a negative rule was promoted.
|
|
1800
|
+
if (isSelfHarnessOptimizerEnabled()) {
|
|
1801
|
+
try {
|
|
1802
|
+
const { fork } = require('node:child_process');
|
|
1803
|
+
const localOptimizerPath = path.join(process.cwd(), 'scripts', 'self-harness-optimizer.js');
|
|
1804
|
+
const packageOptimizerPath = path.join(__dirname, 'self-harness-optimizer.js');
|
|
1805
|
+
|
|
1806
|
+
if (fs.existsSync(localOptimizerPath)) {
|
|
1807
|
+
fork(localOptimizerPath, [], { stdio: 'ignore', detached: true }).unref();
|
|
1808
|
+
} else if (fs.existsSync(packageOptimizerPath)) {
|
|
1809
|
+
fork(packageOptimizerPath, [], { stdio: 'ignore', detached: true }).unref();
|
|
1810
|
+
}
|
|
1811
|
+
} catch (err) {
|
|
1812
|
+
console.error('Failed to trigger self-harness optimizer:', err);
|
|
1783
1813
|
}
|
|
1784
|
-
} catch (err) {
|
|
1785
|
-
console.error('Failed to trigger self-harness optimizer:', err);
|
|
1786
1814
|
}
|
|
1787
1815
|
}
|
|
1788
1816
|
} catch { /* Gate promotion is non-critical */ }
|
|
@@ -1798,8 +1826,9 @@ function captureFeedback(params) {
|
|
|
1798
1826
|
feedbackDir: FEEDBACK_DIR,
|
|
1799
1827
|
outputDir: process.env.THUMBGATE_OBSIDIAN_VAULT_PATH,
|
|
1800
1828
|
});
|
|
1801
|
-
} catch
|
|
1802
|
-
|
|
1829
|
+
} catch {
|
|
1830
|
+
resolve();
|
|
1831
|
+
return;
|
|
1803
1832
|
}
|
|
1804
1833
|
resolve();
|
|
1805
1834
|
});
|
|
@@ -2416,10 +2445,10 @@ function runTests() {
|
|
|
2416
2445
|
budgetCompliant: true,
|
|
2417
2446
|
}),
|
|
2418
2447
|
});
|
|
2419
|
-
assert(
|
|
2448
|
+
assert(blocked.accepted && blocked.promoted === false, 'captureFeedback stores but does not promote unsafe positive feedback');
|
|
2420
2449
|
|
|
2421
2450
|
const bad = captureFeedback({ signal: 'down' });
|
|
2422
|
-
assert(
|
|
2451
|
+
assert(bad.accepted && bad.promoted === false, 'captureFeedback stores vague negative feedback without promotion');
|
|
2423
2452
|
assert(bad.needsClarification === true, 'captureFeedback requests clarification for vague negative feedback');
|
|
2424
2453
|
|
|
2425
2454
|
const summary = feedbackSummary(5);
|
|
@@ -2489,6 +2518,7 @@ function buildCorrectiveActionsReminder(correctiveActions = []) {
|
|
|
2489
2518
|
|
|
2490
2519
|
module.exports = {
|
|
2491
2520
|
captureFeedback: captureFeedbackIdempotent,
|
|
2521
|
+
emitAnonymousFeedbackPing,
|
|
2492
2522
|
buildFeedbackSourceIdentity,
|
|
2493
2523
|
compactMemories,
|
|
2494
2524
|
buildCorrectiveActionsReminder,
|
|
@@ -2508,6 +2538,7 @@ module.exports = {
|
|
|
2508
2538
|
updateStatuslineWithLesson,
|
|
2509
2539
|
waitForBackgroundSideEffects,
|
|
2510
2540
|
getPendingBackgroundSideEffectCount,
|
|
2541
|
+
isSelfHarnessOptimizerEnabled,
|
|
2511
2542
|
getFeedbackPaths,
|
|
2512
2543
|
get FEEDBACK_LOG_PATH() {
|
|
2513
2544
|
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
|
+
};
|