| |
| |
| 'use strict'; |
| const fs = require('fs'); |
| const path = require('path'); |
| const crypto = require('crypto'); |
| const { spawnSync, spawn } = require('child_process'); |
|
|
| const EMBED_PY = path.join(__dirname, 'embed.py'); |
|
|
| |
| |
| |
| |
| let _proc = null, _buf = '', _queue = [], _ready = null; |
| function ensureServer() { |
| if (_proc) return _ready; |
| _proc = spawn('python', [EMBED_PY, 'serve'], { stdio: ['pipe', 'pipe', 'ignore'] }); |
| _proc.stdout.setEncoding('utf8'); |
| _ready = new Promise(res => { |
| _proc.stdout.on('data', d => { |
| _buf += d; |
| let nl; |
| while ((nl = _buf.indexOf('\n')) >= 0) { |
| const line = _buf.slice(0, nl); _buf = _buf.slice(nl + 1); |
| if (line.trim() === 'READY') { res(true); continue; } |
| const r = _queue.shift(); |
| if (r) { try { r(line.trim() === 'null' ? null : Float32Array.from(JSON.parse(line))); } catch (_) { r(null); } } |
| } |
| }); |
| }); |
| _proc.on('exit', () => { _proc = null; const q = _queue; _queue = []; for (const r of q) r(null); }); |
| return _ready; |
| } |
| async function embedQueryAsync(text) { |
| await ensureServer(); |
| return new Promise(resolve => { _queue.push(resolve); _proc.stdin.write(String(text).replace(/[\r\n]+/g, ' ') + '\n'); }); |
| } |
| function shutdownEmbed() { if (_proc) { try { _proc.kill(); } catch (_) {} _proc = null; } } |
|
|
| function textsHash(texts) { |
| const h = crypto.createHash('sha1'); |
| for (const t of texts) h.update(t + '\n'); |
| return h.digest('hex').slice(0, 12); |
| } |
|
|
| |
| function ensureEmbeddings(texts, cacheDir, tag) { |
| fs.mkdirSync(cacheDir, { recursive: true }); |
| const prefix = path.join(cacheDir, tag + '-' + textsHash(texts)); |
| const metaPath = prefix + '.meta.json'; |
| if (!fs.existsSync(metaPath)) { |
| const textsPath = prefix + '.texts.json'; |
| fs.writeFileSync(textsPath, JSON.stringify(texts)); |
| process.stderr.write(`[semantic] embedding ${texts.length} ${tag} (one-time, CPU)...\n`); |
| const r = spawnSync('python', [EMBED_PY, 'cache', textsPath, prefix], { encoding: 'utf8', timeout: 900000 }); |
| if (r.status !== 0) { process.stderr.write('[semantic] cache build failed: ' + (r.stderr || '').slice(-300) + '\n'); return null; } |
| } |
| const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); |
| const buf = fs.readFileSync(prefix + '.f32'); |
| return { vectors: new Float32Array(buf.buffer, buf.byteOffset, meta.n * meta.d), n: meta.n, d: meta.d }; |
| } |
|
|
| function ensureFragmentEmbeddings(store, cacheDir) { |
| |
| |
| return ensureEmbeddings(store.fragments.map(f => f.embedText || f.text), cacheDir, 'frags'); |
| } |
| function ensurePromptEmbeddings(store, cacheDir) { |
| if (!store.prompts || !store.prompts.length) return null; |
| return ensureEmbeddings(store.prompts, cacheDir, 'prompts'); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const QCACHE_PATH = path.join(__dirname, '..', 'cache', 'qembed-cache.json'); |
| let _qcache = null; |
| function _loadQCache() { |
| if (_qcache) return _qcache; |
| try { _qcache = JSON.parse(fs.readFileSync(QCACHE_PATH, 'utf8')); } catch (_) { _qcache = {}; } |
| return _qcache; |
| } |
| function _qkey(text) { return crypto.createHash('sha1').update(String(text)).digest('hex').slice(0, 16); } |
|
|
| function embedQuery(text) { |
| const cache = _loadQCache(); |
| const key = _qkey(text); |
| if (cache[key]) return Float32Array.from(cache[key]); |
| |
| |
| |
| for (let attempt = 0; attempt < 3; attempt++) { |
| const r = spawnSync('python', [EMBED_PY, 'query', text], { encoding: 'utf8', timeout: 120000 }); |
| if (r.status === 0) { |
| try { |
| const v = Float32Array.from(JSON.parse(r.stdout.trim().split('\n').pop())); |
| cache[key] = Array.from(v); |
| try { fs.mkdirSync(path.dirname(QCACHE_PATH), { recursive: true }); fs.writeFileSync(QCACHE_PATH, JSON.stringify(cache)); } catch (_) {} |
| return v; |
| } catch (_) { } |
| } |
| } |
| return null; |
| } |
|
|
| |
| function sim(emb, i, q) { |
| const d = emb.d; |
| let s = 0; |
| const off = i * d; |
| for (let k = 0; k < d; k++) s += emb.vectors[off + k] * q[k]; |
| return s; |
| } |
|
|
| |
| function semanticRank(emb, q, k) { |
| const scores = []; |
| for (let i = 0; i < emb.n; i++) scores.push([i, sim(emb, i, q)]); |
| scores.sort((a, b) => b[1] - a[1]); |
| const top = scores.slice(0, k || 80); |
| const max = top[0][1], min = top[top.length - 1][1]; |
| const m = new Map(); |
| for (const [i, s] of top) m.set(i, max > min ? (s - min) / (max - min) : 1); |
| |
| |
| |
| |
| m.confidence = max; |
| return m; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| const _projCache = new Map(); |
| function loadProjection(tag, baseDir) { |
| if (!tag) return null; |
| if (_projCache.has(tag)) return _projCache.get(tag); |
| const p = path.join(baseDir || path.join(__dirname, '..', 'retrieval-train'), tag + '.proj.json'); |
| let proj = null; |
| try { |
| if (fs.existsSync(p)) { |
| const j = JSON.parse(fs.readFileSync(p, 'utf8')); |
| |
| const W = new Float32Array(j.d * j.r); |
| for (let a = 0; a < j.d; a++) for (let b = 0; b < j.r; b++) W[a * j.r + b] = j.W[a][b]; |
| proj = { d: j.d, r: j.r, W }; |
| } |
| } catch (_) { proj = null; } |
| _projCache.set(tag, proj); |
| return proj; |
| } |
| |
| function _projectVec(vec, off, proj) { |
| const { d, r, W } = proj; |
| const out = new Float32Array(r); |
| for (let k = 0; k < r; k++) { let s = 0; for (let j = 0; j < d; j++) s += vec[off + j] * W[j * r + k]; out[k] = s; } |
| let n = 0; for (let k = 0; k < r; k++) n += out[k] * out[k]; n = Math.sqrt(n) || 1; |
| for (let k = 0; k < r; k++) out[k] /= n; |
| return out; |
| } |
| |
| |
| function answerRank(emb, q, proj, k) { |
| if (!proj || !emb) return null; |
| const qp = _projectVec(q, 0, proj); |
| const r = proj.r; |
| const scores = []; |
| for (let i = 0; i < emb.n; i++) { |
| const fp = _projectVec(emb.vectors, i * emb.d, proj); |
| let v = 0; for (let t = 0; t < r; t++) v += fp[t] * qp[t]; |
| scores.push([i, v]); |
| } |
| scores.sort((a, b) => b[1] - a[1]); |
| const top = scores.slice(0, k || 80); |
| const max = top[0][1], min = top[top.length - 1][1]; |
| const m = new Map(); |
| for (const [i, s] of top) m.set(i, max > min ? (s - min) / (max - min) : 1); |
| m.confidence = max; |
| return m; |
| } |
|
|
| |
| |
| |
| |
| |
| function stimulusRank(promptEmb, q, fragments, k) { |
| if (!promptEmb) return null; |
| const pScore = []; |
| for (let i = 0; i < promptEmb.n; i++) pScore.push([i, sim(promptEmb, i, q)]); |
| pScore.sort((a, b) => b[1] - a[1]); |
| const top = pScore.slice(0, Math.min(k || 12, pScore.length)); |
| const confidence = top[0][1]; |
| const max = top[0][1], min = top[top.length - 1][1]; |
| const pNorm = new Map(); |
| for (const [i, s] of top) pNorm.set(i, max > min ? (s - min) / (max - min) : 1); |
| const m = new Map(); |
| fragments.forEach((f, fi) => { |
| if (f.promptIdx >= 0 && pNorm.has(f.promptIdx)) m.set(fi, pNorm.get(f.promptIdx)); |
| }); |
| return { map: m, confidence }; |
| } |
|
|
| |
| function eventness(query) { |
| const q = query.trim(); |
| if (/\?\s*$/.test(q)) return 0.2; |
| const pastShare = /\b(i|we|my|me)\b[^?]*\b(finished|talked|did|went|got|made|had|found|fixed|broke|lost|won|started|quit|saw|met|built|wrote)\b/i.test(q); |
| return pastShare ? 0.85 : 0.45; |
| } |
|
|
| |
| function pairSim(emb, i, j) { |
| const d = emb.d; |
| let s = 0; |
| const oi = i * d, oj = j * d; |
| for (let k = 0; k < d; k++) s += emb.vectors[oi + k] * emb.vectors[oj + k]; |
| return s; |
| } |
|
|
| module.exports = { ensureFragmentEmbeddings, ensurePromptEmbeddings, embedQuery, embedQueryAsync, shutdownEmbed, semanticRank, stimulusRank, eventness, pairSim, loadProjection, answerRank }; |
|
|