promptgraph-mcp 2.4.8 → 2.6.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/api.js +4 -2
- package/bundle-counts.js +3 -2
- package/config.js +7 -0
- package/db.js +125 -83
- package/github-import.js +69 -7
- package/index.js +20 -598
- package/indexer.js +5 -9
- package/marketplace.js +83 -2
- package/package.json +2 -2
- package/search.js +22 -20
- package/src/reranker/reranker.js +28 -0
- package/src/utils/rate-limiter.js +33 -0
- package/validator.js +24 -0
- package/src/filter/cluster.js +0 -52
- package/src/filter/dedup.js +0 -36
package/indexer.js
CHANGED
|
@@ -3,7 +3,8 @@ import { createHash } from 'crypto';
|
|
|
3
3
|
import fs from 'fs';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import { parseSkillFile, isSkillFile, filterWithClassifier } from './parser.js';
|
|
6
|
-
import { embedBatch, cosineSimilarity
|
|
6
|
+
import { embedBatch, cosineSimilarity } from './embedder.js';
|
|
7
|
+
import { BATCH_SIZE } from './config.js';
|
|
7
8
|
import { getDb, skillId, vecToBlob } from './db.js';
|
|
8
9
|
import { loadConfig } from './config.js';
|
|
9
10
|
import { chunkText } from './chunker.js';
|
|
@@ -18,7 +19,7 @@ function sanitizePath(filePath) {
|
|
|
18
19
|
return path.resolve(filePath);
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
async function indexBatch(db, skills, { fast = false } = {}) {
|
|
22
|
+
export async function indexBatch(db, skills, { fast = false } = {}) {
|
|
22
23
|
const upsertSkill = db.prepare(`
|
|
23
24
|
INSERT INTO skills (id, name, description, path, source, content, hash, version, author, license, updated_at, downloads, verified)
|
|
24
25
|
VALUES (@id, @name, @description, @path, @source, @content, @hash, @version, @author, @license, @updated_at, @downloads, @verified)
|
|
@@ -106,21 +107,15 @@ export async function indexAll({ fast = false } = {}) {
|
|
|
106
107
|
normDir: path.resolve(s.dir),
|
|
107
108
|
})).sort((a, b) => b.normDir.length - a.normDir.length); // longest first
|
|
108
109
|
|
|
109
|
-
console.error('DEBUG normalizedSources count:', normalizedSources.length);
|
|
110
|
-
console.error('DEBUG first dir:', normalizedSources[0]?.dir);
|
|
111
|
-
|
|
112
110
|
const seenFiles = new Set();
|
|
113
111
|
const allFiles = [];
|
|
114
112
|
for (const { dir, source } of normalizedSources) {
|
|
115
113
|
const files = globSync(`${dir}/**/*.md`);
|
|
116
|
-
console.error('DEBUG source:', source, 'dir:', dir, 'count:', files.length);
|
|
117
114
|
for (const f of files) {
|
|
118
115
|
const norm = sanitizePath(f);
|
|
119
116
|
if (!seenFiles.has(norm)) {
|
|
120
117
|
seenFiles.add(norm);
|
|
121
118
|
allFiles.push({ file: norm, source });
|
|
122
|
-
} else {
|
|
123
|
-
console.error('DEBUG duplicate:', norm);
|
|
124
119
|
}
|
|
125
120
|
}
|
|
126
121
|
if (allFiles.length > MAX_FILE_COUNT) {
|
|
@@ -129,7 +124,6 @@ export async function indexAll({ fast = false } = {}) {
|
|
|
129
124
|
}
|
|
130
125
|
}
|
|
131
126
|
const total = allFiles.length;
|
|
132
|
-
console.error('DEBUG total files found:', total);
|
|
133
127
|
info(`Found ${chalk.white.bold(total)} files`);
|
|
134
128
|
|
|
135
129
|
// reconcile: remove skills whose files no longer exist OR whose name changed
|
|
@@ -210,6 +204,7 @@ export async function indexAll({ fast = false } = {}) {
|
|
|
210
204
|
batch = [];
|
|
211
205
|
const eta = count > 0 ? Math.round((total - count) * (Date.now() - start) / count / 1000) : '?';
|
|
212
206
|
progress(count, total, { skipped, eta, errors });
|
|
207
|
+
await new Promise(r => setImmediate ? setImmediate(r) : setTimeout(r, 0));
|
|
213
208
|
}
|
|
214
209
|
} catch (e) {
|
|
215
210
|
errors++;
|
|
@@ -301,6 +296,7 @@ export async function indexSource(dir, sourceName) {
|
|
|
301
296
|
await indexBatch(db, batch);
|
|
302
297
|
count += batch.length; batch = [];
|
|
303
298
|
progress(count, total, { skipped, errors });
|
|
299
|
+
await new Promise(r => setImmediate ? setImmediate(r) : setTimeout(r, 0));
|
|
304
300
|
}
|
|
305
301
|
} catch (e) { errors++; count++; }
|
|
306
302
|
}
|
package/marketplace.js
CHANGED
|
@@ -61,6 +61,16 @@ function validateRegistryEntry(item) {
|
|
|
61
61
|
}
|
|
62
62
|
if (item.downloads !== undefined && typeof item.downloads !== 'number') errors.push('downloads must be a number');
|
|
63
63
|
if (item.verified !== undefined && typeof item.verified !== 'boolean') errors.push('verified must be a boolean');
|
|
64
|
+
if (item.trustLevel !== undefined && typeof item.trustLevel !== 'string') errors.push('trustLevel must be a string');
|
|
65
|
+
if (item.rating !== undefined) {
|
|
66
|
+
if (typeof item.rating !== 'number') errors.push('rating must be a number');
|
|
67
|
+
else if (item.rating < 0 || item.rating > 5) errors.push('rating must be between 0 and 5');
|
|
68
|
+
}
|
|
69
|
+
if (item.popularity !== undefined && typeof item.popularity !== 'number') errors.push('popularity must be a number');
|
|
70
|
+
if (item.lastUpdate !== undefined) {
|
|
71
|
+
if (typeof item.lastUpdate !== 'string') errors.push('lastUpdate must be a string');
|
|
72
|
+
else if (isNaN(Date.parse(item.lastUpdate))) errors.push(`lastUpdate "${item.lastUpdate}" is not a valid ISO date`);
|
|
73
|
+
}
|
|
64
74
|
if (errors.length > 0) {
|
|
65
75
|
console.warn(`[PromptGraph] Skipping invalid registry entry "${item.id || item.name || '(unnamed)'}": ${errors.join('; ')}`);
|
|
66
76
|
return { ok: false };
|
|
@@ -162,7 +172,7 @@ export async function browseMarketplace(topK = 20) {
|
|
|
162
172
|
const registry = JSON.parse(text);
|
|
163
173
|
if (!Array.isArray(registry.skills)) return { error: 'Invalid registry format' };
|
|
164
174
|
return registry.skills
|
|
165
|
-
.map(s => (
|
|
175
|
+
.map(s => Object.assign(Object.create(null), s, { code: s.code || codeFor(s.id) }))
|
|
166
176
|
.filter(s => validateRegistryEntry(s).ok)
|
|
167
177
|
.filter(s => s.raw_url)
|
|
168
178
|
.sort((a, b) => (b.stars || 0) - (a.stars || 0))
|
|
@@ -287,7 +297,7 @@ export async function browseBundles(topK = 20) {
|
|
|
287
297
|
|
|
288
298
|
return bundles
|
|
289
299
|
.filter(b => !b.repo_url || b.skillCount > 0)
|
|
290
|
-
.map(b => (
|
|
300
|
+
.map(b => Object.assign(Object.create(null), b, { code: b.code || codeFor(b.id) }))
|
|
291
301
|
.sort((a, b) => (b.stars || 0) - (a.stars || 0))
|
|
292
302
|
.slice(0, topK);
|
|
293
303
|
} catch (e) {
|
|
@@ -548,3 +558,74 @@ export function pruneInvalidRepos() {
|
|
|
548
558
|
saveConfig(config);
|
|
549
559
|
return { removed, kept, errors };
|
|
550
560
|
}
|
|
561
|
+
|
|
562
|
+
// ── Trust level system ─────────────────────────────────────────────────────────
|
|
563
|
+
const VALID_TRUST_LEVELS = ['verified', 'official', 'community', 'trusted', 'unknown']
|
|
564
|
+
|
|
565
|
+
function calcPopularity(downloads = 0, rating = 0) {
|
|
566
|
+
return Math.round((downloads || 0) * ((rating || 0) + 1) * 100) / 100
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export async function setTrustLevel(name, level) {
|
|
570
|
+
const db = getDb()
|
|
571
|
+
if (!VALID_TRUST_LEVELS.includes(level)) {
|
|
572
|
+
return { ok: false, error: `Invalid trust level "${level}". Must be one of: ${VALID_TRUST_LEVELS.join(', ')}` }
|
|
573
|
+
}
|
|
574
|
+
const id = String(name)
|
|
575
|
+
db.prepare(`
|
|
576
|
+
INSERT INTO registry_entries (id, trust_level, last_update)
|
|
577
|
+
VALUES (?, ?, datetime('now'))
|
|
578
|
+
ON CONFLICT(id) DO UPDATE SET trust_level = excluded.trust_level, last_update = excluded.last_update
|
|
579
|
+
`).run(id, level)
|
|
580
|
+
return { ok: true }
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
export async function getByTrustLevel(level) {
|
|
584
|
+
const db = getDb()
|
|
585
|
+
if (level && !VALID_TRUST_LEVELS.includes(level)) {
|
|
586
|
+
return { error: `Invalid trust level "${level}". Must be one of: ${VALID_TRUST_LEVELS.join(', ')}` }
|
|
587
|
+
}
|
|
588
|
+
if (level) {
|
|
589
|
+
return db.prepare('SELECT * FROM registry_entries WHERE trust_level = ? ORDER BY id').all(level)
|
|
590
|
+
}
|
|
591
|
+
return db.prepare('SELECT * FROM registry_entries ORDER BY id').all()
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export async function incrementDownloads(name) {
|
|
595
|
+
const db = getDb()
|
|
596
|
+
const id = String(name)
|
|
597
|
+
const existing = db.prepare('SELECT downloads, rating FROM registry_entries WHERE id = ?').get(id)
|
|
598
|
+
if (existing) {
|
|
599
|
+
const newDownloads = (existing.downloads || 0) + 1
|
|
600
|
+
const pop = calcPopularity(newDownloads, existing.rating || 0)
|
|
601
|
+
db.prepare('UPDATE registry_entries SET downloads = ?, popularity = ?, last_update = datetime(\'now\') WHERE id = ?')
|
|
602
|
+
.run(newDownloads, pop, id)
|
|
603
|
+
} else {
|
|
604
|
+
const pop = calcPopularity(1, 0)
|
|
605
|
+
db.prepare('INSERT INTO registry_entries (id, downloads, popularity, last_update) VALUES (?, 1, ?, datetime(\'now\'))')
|
|
606
|
+
.run(id, pop)
|
|
607
|
+
}
|
|
608
|
+
return { ok: true }
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export async function rateSkill(name, rating) {
|
|
612
|
+
const db = getDb()
|
|
613
|
+
if (typeof rating !== 'number' || rating < 0 || rating > 5) {
|
|
614
|
+
return { ok: false, error: 'Rating must be a number between 0 and 5' }
|
|
615
|
+
}
|
|
616
|
+
const id = String(name)
|
|
617
|
+
const existing = db.prepare('SELECT rating, rating_count, downloads FROM registry_entries WHERE id = ?').get(id)
|
|
618
|
+
if (existing) {
|
|
619
|
+
const count = existing.rating_count || 0
|
|
620
|
+
const newRating = Math.round(((existing.rating || 0) * count + rating) / (count + 1) * 100) / 100
|
|
621
|
+
const newCount = count + 1
|
|
622
|
+
const pop = calcPopularity(existing.downloads || 0, newRating)
|
|
623
|
+
db.prepare('UPDATE registry_entries SET rating = ?, rating_count = ?, popularity = ?, last_update = datetime(\'now\') WHERE id = ?')
|
|
624
|
+
.run(newRating, newCount, pop, id)
|
|
625
|
+
} else {
|
|
626
|
+
const pop = calcPopularity(0, rating)
|
|
627
|
+
db.prepare('INSERT INTO registry_entries (id, rating, rating_count, popularity, last_update) VALUES (?, ?, 1, ?, datetime(\'now\'))')
|
|
628
|
+
.run(id, rating, pop)
|
|
629
|
+
}
|
|
630
|
+
return { ok: true }
|
|
631
|
+
}
|
package/package.json
CHANGED
package/search.js
CHANGED
|
@@ -2,24 +2,10 @@ import { embed, cosineSimilarity } from './embedder.js';
|
|
|
2
2
|
import { getDb, blobToVec } from './db.js';
|
|
3
3
|
import { annSearch } from './ann.js';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
let bm25Weight = 0.3;
|
|
7
|
-
|
|
8
|
-
export function setHybridWeights(ew, bw) {
|
|
9
|
-
embedWeight = ew;
|
|
10
|
-
bm25Weight = bw;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function adaptWeights(query) {
|
|
14
|
-
// Technical terms (caps, digits) → BM25 matters more
|
|
5
|
+
function getHybridWeights(query) {
|
|
15
6
|
const hasTechTerms = /[A-Z]|\d/.test(query);
|
|
16
|
-
if (hasTechTerms) {
|
|
17
|
-
|
|
18
|
-
bm25Weight = 0.5;
|
|
19
|
-
} else {
|
|
20
|
-
embedWeight = 0.7;
|
|
21
|
-
bm25Weight = 0.3;
|
|
22
|
-
}
|
|
7
|
+
if (hasTechTerms) return { embedWeight: 0.5, bm25Weight: 0.5 }
|
|
8
|
+
return { embedWeight: 0.7, bm25Weight: 0.3 }
|
|
23
9
|
}
|
|
24
10
|
|
|
25
11
|
function applyRatingBoost(db, id, score) {
|
|
@@ -82,7 +68,7 @@ function runBM25Search(db, query, topK) {
|
|
|
82
68
|
|
|
83
69
|
export async function search(query, topK = 5) {
|
|
84
70
|
const db = getDb();
|
|
85
|
-
|
|
71
|
+
const { embedWeight, bm25Weight } = getHybridWeights(query);
|
|
86
72
|
const queryVec = await embed(query);
|
|
87
73
|
|
|
88
74
|
const embedResults = await runEmbeddingSearch(db, queryVec, topK * 4);
|
|
@@ -116,13 +102,29 @@ export async function search(query, topK = 5) {
|
|
|
116
102
|
}
|
|
117
103
|
}
|
|
118
104
|
|
|
119
|
-
|
|
105
|
+
const ordered = [...combined.entries()]
|
|
120
106
|
.map(([id, s]) => ({
|
|
121
107
|
id,
|
|
122
108
|
score: embedWeight * s.embedScore + bm25Weight * s.bm25Score,
|
|
123
109
|
}))
|
|
124
110
|
.sort((a, b) => b.score - a.score)
|
|
125
|
-
|
|
111
|
+
|
|
112
|
+
const rerankerEnabled = process.env.PG_RERANKER !== '0'
|
|
113
|
+
|
|
114
|
+
if (rerankerEnabled) {
|
|
115
|
+
const { Reranker } = await import('./src/reranker/reranker.js')
|
|
116
|
+
const reranker = new Reranker()
|
|
117
|
+
const topN = ordered.slice(0, 20)
|
|
118
|
+
.map(({ id, score }) => {
|
|
119
|
+
const s = skillWithSnippet(db, id, score)
|
|
120
|
+
return s ? { id, text: s.snippet, score } : null
|
|
121
|
+
})
|
|
122
|
+
.filter(Boolean)
|
|
123
|
+
const reranked = await reranker.rerank(query, topN)
|
|
124
|
+
return reranked.map(r => skillWithSnippet(db, r.id, applyRatingBoost(db, r.id, r.score))).filter(Boolean)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return ordered.slice(0, topK)
|
|
126
128
|
.map(({ id, score }) => skillWithSnippet(db, id, applyRatingBoost(db, id, score)))
|
|
127
129
|
.filter(Boolean);
|
|
128
130
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export class Reranker {
|
|
2
|
+
constructor(modelName = 'default', device = 'cpu') {
|
|
3
|
+
this.modelName = modelName
|
|
4
|
+
this.device = device
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// Term-overlap boost on top of hybrid search scores
|
|
8
|
+
// This is a lightweight reranker that ranks by term overlap ratio.
|
|
9
|
+
// Replace with BGE Reranker / MiniLM cross-encoder via ONNX for deeper semantic reranking.
|
|
10
|
+
async rerank(query, results) {
|
|
11
|
+
if (!results || results.length === 0) return []
|
|
12
|
+
|
|
13
|
+
const queryTerms = (query.toLowerCase().match(/\w+/g) || []).slice(0, 50)
|
|
14
|
+
const queryTermCount = queryTerms.length || 1
|
|
15
|
+
|
|
16
|
+
const scored = results.map(r => {
|
|
17
|
+
const text = (r.text || '').toLowerCase()
|
|
18
|
+
const termOverlap = queryTerms.filter(t => text.includes(t)).length / queryTermCount
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
...r,
|
|
22
|
+
score: 0.8 * (r.score || 0) + 0.2 * termOverlap,
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
return scored.sort((a, b) => b.score - a.score).slice(0, 5)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export class RateLimiter {
|
|
2
|
+
constructor({ maxRequests, windowMs }) {
|
|
3
|
+
this.maxRequests = maxRequests
|
|
4
|
+
this.windowMs = windowMs
|
|
5
|
+
this.timestamps = []
|
|
6
|
+
this._chain = Promise.resolve()
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async acquire() {
|
|
10
|
+
const prev = this._chain
|
|
11
|
+
this._chain = prev.then(() => this._doAcquire())
|
|
12
|
+
return this._chain
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async _doAcquire() {
|
|
16
|
+
while (!this.tryAcquire()) {
|
|
17
|
+
const oldest = this.timestamps[0]
|
|
18
|
+
const waitMs = oldest
|
|
19
|
+
? Math.max(1, oldest + this.windowMs - Date.now())
|
|
20
|
+
: 100
|
|
21
|
+
await new Promise(r => setTimeout(r, Math.min(waitMs, this.windowMs)))
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
tryAcquire() {
|
|
26
|
+
const now = Date.now()
|
|
27
|
+
const cutoff = now - this.windowMs
|
|
28
|
+
this.timestamps = this.timestamps.filter(t => t > cutoff)
|
|
29
|
+
if (this.timestamps.length >= this.maxRequests) return false
|
|
30
|
+
this.timestamps.push(now)
|
|
31
|
+
return true
|
|
32
|
+
}
|
|
33
|
+
}
|
package/validator.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
2
3
|
import matter from 'gray-matter';
|
|
4
|
+
import { MAX_DOWNLOAD_SIZE } from './config.js';
|
|
3
5
|
|
|
4
6
|
// patterns that indicate malicious or junk skills
|
|
5
7
|
const DANGEROUS_PATTERNS = [
|
|
@@ -86,6 +88,19 @@ export function validateSkill(filePath) {
|
|
|
86
88
|
if (pathParts.includes('..')) {
|
|
87
89
|
errors.push('Path traversal detected: file path contains ".."');
|
|
88
90
|
}
|
|
91
|
+
|
|
92
|
+
// extension whitelist — reject unexpected file types
|
|
93
|
+
const ALLOWED_EXTENSIONS = new Set(['.md', '.json', '.yaml', '.yml', '.txt', '.js', '.ts', '.py', '.rs', '.toml']);
|
|
94
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
95
|
+
if (!ALLOWED_EXTENSIONS.has(ext)) {
|
|
96
|
+
errors.push(`File extension "${ext}" not allowed. Allowed: ${[...ALLOWED_EXTENSIONS].join(', ')}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// binary content detection — must be valid UTF-8 with no null bytes
|
|
100
|
+
if (raw.includes('\0')) {
|
|
101
|
+
errors.push('File contains null bytes (binary content)');
|
|
102
|
+
}
|
|
103
|
+
|
|
89
104
|
if (['readme.md', 'changelog.md', 'license.md', 'contributing.md'].includes(base)) {
|
|
90
105
|
warnings.push('Filename looks like a docs file, not a skill.');
|
|
91
106
|
}
|
|
@@ -142,6 +157,15 @@ export function validateBundle(def) {
|
|
|
142
157
|
return { ok: errors.length === 0, errors, warnings };
|
|
143
158
|
}
|
|
144
159
|
|
|
160
|
+
export function sanitizeExternalContent(content) {
|
|
161
|
+
if (typeof content !== 'string') return ''
|
|
162
|
+
let sanitized = content.replace(/\0/g, '')
|
|
163
|
+
if (Buffer.byteLength(sanitized, 'utf8') > MAX_DOWNLOAD_SIZE) {
|
|
164
|
+
sanitized = Buffer.from(sanitized, 'utf8').subarray(0, MAX_DOWNLOAD_SIZE).toString('utf8')
|
|
165
|
+
}
|
|
166
|
+
return sanitized
|
|
167
|
+
}
|
|
168
|
+
|
|
145
169
|
// CLI: node validator.js <file>
|
|
146
170
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
147
171
|
const file = process.argv[2];
|
package/src/filter/cluster.js
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import { cosineSimilarity } from '../../embedder.js';
|
|
2
|
-
|
|
3
|
-
const INFERENCE_THRESHOLD = 0.35;
|
|
4
|
-
|
|
5
|
-
export function findClusters(skills, embeddings) {
|
|
6
|
-
if (!skills.length || !embeddings) return [];
|
|
7
|
-
const clusters = [];
|
|
8
|
-
const assigned = new Set();
|
|
9
|
-
|
|
10
|
-
for (let i = 0; i < skills.length; i++) {
|
|
11
|
-
if (assigned.has(i)) continue;
|
|
12
|
-
const cluster = { centroid: embeddings[i], members: [skills[i]], indices: [i] };
|
|
13
|
-
assigned.add(i);
|
|
14
|
-
|
|
15
|
-
for (let j = i + 1; j < skills.length; j++) {
|
|
16
|
-
if (assigned.has(j)) continue;
|
|
17
|
-
if (cosineSimilarity(embeddings[i], embeddings[j]) >= INFERENCE_THRESHOLD) {
|
|
18
|
-
cluster.members.push(skills[j]);
|
|
19
|
-
cluster.indices.push(j);
|
|
20
|
-
assigned.add(j);
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
clusters.push(cluster);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
return clusters;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function expandResults(annResults, db) {
|
|
31
|
-
if (!annResults || annResults.length === 0) return annResults;
|
|
32
|
-
|
|
33
|
-
const seenIds = new Set();
|
|
34
|
-
const expanded = [];
|
|
35
|
-
|
|
36
|
-
for (const r of annResults) {
|
|
37
|
-
if (!seenIds.has(r.skill_id)) {
|
|
38
|
-
seenIds.add(r.skill_id);
|
|
39
|
-
expanded.push(r);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const callees = db.prepare('SELECT to_skill FROM edges WHERE from_skill = ?').all(r.skill_id);
|
|
43
|
-
for (const c of callees) {
|
|
44
|
-
if (!seenIds.has(c.to_skill)) {
|
|
45
|
-
seenIds.add(c.to_skill);
|
|
46
|
-
expanded.push({ skill_id: c.to_skill, score: r.score * 0.85 });
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
return expanded.sort((a, b) => b.score - a.score);
|
|
52
|
-
}
|
package/src/filter/dedup.js
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { cosineSimilarity } from '../../embedder.js';
|
|
2
|
-
|
|
3
|
-
const DEDUP_THRESHOLD = 0.97;
|
|
4
|
-
|
|
5
|
-
export function dedup(skills, embeddings) {
|
|
6
|
-
if (!skills.length) return { skills: [], embeddings: [] };
|
|
7
|
-
if (embeddings && embeddings.length !== skills.length) {
|
|
8
|
-
throw new Error('embeddings length must match skills length');
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const keep = [true]; // first skill always kept
|
|
12
|
-
const kept = [0];
|
|
13
|
-
|
|
14
|
-
for (let i = 1; i < skills.length; i++) {
|
|
15
|
-
let dup = false;
|
|
16
|
-
if (embeddings) {
|
|
17
|
-
for (const j of kept) {
|
|
18
|
-
if (cosineSimilarity(embeddings[i], embeddings[j]) >= DEDUP_THRESHOLD) {
|
|
19
|
-
dup = true;
|
|
20
|
-
break;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
if (dup) {
|
|
25
|
-
keep.push(false);
|
|
26
|
-
} else {
|
|
27
|
-
keep.push(true);
|
|
28
|
-
kept.push(i);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
return {
|
|
33
|
-
skills: skills.filter((_, i) => keep[i]),
|
|
34
|
-
embeddings: embeddings ? embeddings.filter((_, i) => keep[i]) : undefined,
|
|
35
|
-
};
|
|
36
|
-
}
|