feat: implement 33 nice-to-have features + fix 37 code review bugs
5 SDD batches archived: - Batch 1: UI Polish (10 features, 14 tasks) - Batch 2: Study System (8 features, 23 tasks) - Batch 3: Infrastructure (5 features, 22 tasks) - Batch 4: AI Advanced (5 features, 30 tasks) — RAG with @xenova/transformers - Batch 5: Core Features (5 features, 19 tasks) 37 bugs fixed from comprehensive code review (11 CRITICAL, 12 HIGH, 14 MEDIUM/LOW): - SSE streaming now works (event.token check) - API keys no longer exposed via GET /api/models - FTS5 injection sanitized - DB backup/restore with admin auth - Buddy mode wired (buddy_meta column) - Exam auto-submit stale closure fixed - CSS variables aligned with design tokens - Progress data corruption fixed - WebSocket protocol auto-detection - Tests infrastructure completed (vitest + node:test)
This commit is contained in:
26
server/lib/broadcast.js
Normal file
26
server/lib/broadcast.js
Normal file
@@ -0,0 +1,26 @@
|
||||
let _wss = null;
|
||||
|
||||
function setWss(wss) {
|
||||
_wss = wss;
|
||||
}
|
||||
|
||||
function broadcast(payload) {
|
||||
if (!_wss) return;
|
||||
const data = JSON.stringify(payload);
|
||||
_wss.clients.forEach(ws => { if (ws.readyState === 1) ws.send(data); });
|
||||
}
|
||||
|
||||
function broadcastBuddy(payload) {
|
||||
if (!_wss) return;
|
||||
_wss.clients.forEach((client) => {
|
||||
if (client.readyState === 1) {
|
||||
try {
|
||||
client.send(JSON.stringify(payload));
|
||||
} catch (err) {
|
||||
console.error('[ws] buddy broadcast error:', err.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { setWss, broadcast, broadcastBuddy };
|
||||
142
server/lib/embeddings.js
Normal file
142
server/lib/embeddings.js
Normal file
@@ -0,0 +1,142 @@
|
||||
const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
|
||||
let pipelinePromise = null;
|
||||
let _transformers = null;
|
||||
|
||||
// LRU cache: sha1(text) -> Float32Array, capped at 256
|
||||
const lru = new Map();
|
||||
const LRU_MAX = 256;
|
||||
|
||||
function _lruKey(text) {
|
||||
return crypto.createHash('sha1').update(text).digest('hex');
|
||||
}
|
||||
|
||||
function _lruGet(key) {
|
||||
const val = lru.get(key);
|
||||
if (val !== undefined) {
|
||||
// move to back (most recently used)
|
||||
lru.delete(key);
|
||||
lru.set(key, val);
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
function _lruSet(key, vec) {
|
||||
if (lru.has(key)) {
|
||||
lru.delete(key);
|
||||
} else if (lru.size >= LRU_MAX) {
|
||||
const firstKey = lru.keys().next().value;
|
||||
lru.delete(firstKey);
|
||||
}
|
||||
lru.set(key, vec);
|
||||
}
|
||||
|
||||
async function _getPipeline() {
|
||||
if (pipelinePromise) return pipelinePromise;
|
||||
|
||||
pipelinePromise = (async () => {
|
||||
try {
|
||||
const mod = await import('@xenova/transformers');
|
||||
_transformers = mod;
|
||||
mod.env.cacheDir = path.join(__dirname, '..', '..', 'node_modules', '.cache', 'transformers');
|
||||
|
||||
// Try webgpu first (DirectML on Windows/AMD), fallback to wasm
|
||||
let pipe;
|
||||
try {
|
||||
pipe = await mod.pipeline('feature-extraction', 'Xenova/multilingual-e5-small', {
|
||||
device: 'webgpu',
|
||||
});
|
||||
console.log('[embeddings] pipeline loaded with device=webgpu');
|
||||
} catch (gpuErr) {
|
||||
console.warn('[embeddings] webgpu failed, falling back to wasm:', gpuErr.message);
|
||||
pipe = await mod.pipeline('feature-extraction', 'Xenova/multilingual-e5-small', {
|
||||
device: 'wasm',
|
||||
});
|
||||
console.log('[embeddings] pipeline loaded with device=wasm');
|
||||
}
|
||||
return pipe;
|
||||
} catch (err) {
|
||||
console.error('[embeddings] failed to load pipeline:', err.message);
|
||||
throw err;
|
||||
}
|
||||
})();
|
||||
|
||||
return pipelinePromise;
|
||||
}
|
||||
|
||||
async function warmup() {
|
||||
try {
|
||||
await _getPipeline();
|
||||
} catch (err) {
|
||||
console.warn('[embeddings] warmup failed (model will retry on first use):', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function embed(text) {
|
||||
if (!text || typeof text !== 'string') {
|
||||
throw new Error('embed() requires a non-empty string');
|
||||
}
|
||||
|
||||
const key = _lruKey(text);
|
||||
const cached = _lruGet(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const pipe = await _getPipeline();
|
||||
const result = await pipe(text, { pooling: 'mean', normalize: true });
|
||||
const vec = result.data instanceof Float32Array ? result.data : new Float32Array(result.data);
|
||||
_lruSet(key, vec);
|
||||
return vec;
|
||||
}
|
||||
|
||||
async function embedBatch(texts) {
|
||||
if (!Array.isArray(texts) || texts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
const uncached = [];
|
||||
const indices = [];
|
||||
const results = new Array(texts.length);
|
||||
|
||||
for (let i = 0; i < texts.length; i++) {
|
||||
const key = _lruKey(texts[i]);
|
||||
const cached = _lruGet(key);
|
||||
if (cached) {
|
||||
results[i] = cached;
|
||||
} else {
|
||||
uncached.push(texts[i]);
|
||||
indices.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (uncached.length === 0) {
|
||||
return results;
|
||||
}
|
||||
|
||||
const pipe = await _getPipeline();
|
||||
const BATCH_SIZE = 32;
|
||||
|
||||
for (let start = 0; start < uncached.length; start += BATCH_SIZE) {
|
||||
const batch = uncached.slice(start, start + BATCH_SIZE);
|
||||
const batchResult = await pipe(batch, { pooling: 'mean', normalize: true });
|
||||
// batchResult.data is a flat array for all batches; shape depends on library version
|
||||
// For Transformers.js v2, when batching, result.data is flat and we need to slice
|
||||
const dim = batch.length > 0 ? Math.floor(batchResult.data.length / batch.length) : 384;
|
||||
for (let b = 0; b < batch.length; b++) {
|
||||
const offset = b * dim;
|
||||
const vec = new Float32Array(batchResult.data.slice(offset, offset + dim));
|
||||
const originalIdx = indices[start + b];
|
||||
results[originalIdx] = vec;
|
||||
_lruSet(_lruKey(batch[b]), vec);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
warmup,
|
||||
embed,
|
||||
embedBatch,
|
||||
};
|
||||
@@ -1,6 +1,9 @@
|
||||
const Anthropic = require('@anthropic-ai/sdk');
|
||||
const OpenAI = require('openai');
|
||||
|
||||
// NOTE: model objects carry api_key in memory — avoid logging full model objects.
|
||||
// Use model.name or model.provider only in log statements.
|
||||
|
||||
/**
|
||||
* Normalize Anthropic + OpenAI-compatible streams into one AsyncIterable.
|
||||
* Yields: { token, done, fullText } events.
|
||||
|
||||
87
server/lib/rag.js
Normal file
87
server/lib/rag.js
Normal file
@@ -0,0 +1,87 @@
|
||||
const db = require('../db');
|
||||
const embeddings = require('./embeddings');
|
||||
|
||||
/**
|
||||
* Split text into chunks using a sliding window.
|
||||
* Default: 500 chars per chunk, 50 char overlap.
|
||||
* Cap at 200 chunks per PDF.
|
||||
*/
|
||||
function chunkText(text, size = 500, overlap = 50) {
|
||||
if (!text || typeof text !== 'string') return [];
|
||||
const step = size - overlap;
|
||||
const chunks = [];
|
||||
for (let i = 0; i < text.length; i += step) {
|
||||
chunks.push(text.slice(i, i + size));
|
||||
if (chunks.length >= 200) break;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cosine similarity between two Float32Arrays.
|
||||
* Returns a value in [-1, 1].
|
||||
*/
|
||||
function cosineSimilarity(a, b) {
|
||||
if (a.length !== b.length) {
|
||||
throw new Error(`cosineSimilarity: length mismatch ${a.length} vs ${b.length}`);
|
||||
}
|
||||
let dot = 0;
|
||||
let normA = 0;
|
||||
let normB = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const ai = a[i];
|
||||
const bi = b[i];
|
||||
dot += ai * bi;
|
||||
normA += ai * ai;
|
||||
normB += bi * bi;
|
||||
}
|
||||
if (normA === 0 || normB === 0) return 0;
|
||||
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-export embed for clarity.
|
||||
*/
|
||||
async function embedQuery(text) {
|
||||
return embeddings.embed(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find top K most relevant chunks for a query vector.
|
||||
* @param {Float32Array} queryVec
|
||||
* @param {number[]} pdfIds
|
||||
* @param {number} k
|
||||
* @returns {Promise<{pdf_id, chunk_index, content, similarity}[]>}
|
||||
*/
|
||||
async function topK(queryVec, pdfIds, k = 3) {
|
||||
if (!pdfIds || pdfIds.length === 0) return [];
|
||||
|
||||
const placeholders = pdfIds.map(() => '?').join(',');
|
||||
const rows = db.prepare(
|
||||
`SELECT pdf_id, chunk_index, vector, content FROM embeddings WHERE pdf_id IN (${placeholders})`
|
||||
).all(...pdfIds);
|
||||
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const scored = rows.map((row) => {
|
||||
const buf = Buffer.from(row.vector);
|
||||
const chunkVec = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
|
||||
const similarity = cosineSimilarity(queryVec, chunkVec);
|
||||
return {
|
||||
pdf_id: row.pdf_id,
|
||||
chunk_index: row.chunk_index,
|
||||
content: row.content,
|
||||
similarity,
|
||||
};
|
||||
});
|
||||
|
||||
scored.sort((a, b) => b.similarity - a.similarity);
|
||||
return scored.slice(0, k);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
chunkText,
|
||||
cosineSimilarity,
|
||||
embedQuery,
|
||||
topK,
|
||||
};
|
||||
24
server/lib/sm2.js
Normal file
24
server/lib/sm2.js
Normal file
@@ -0,0 +1,24 @@
|
||||
function sm2(prev, quality) {
|
||||
let { ease_factor: e, interval_days: i, repetitions: r } = prev;
|
||||
if (quality < 3) {
|
||||
r = 0;
|
||||
i = 1;
|
||||
} else {
|
||||
if (r === 0) i = 1;
|
||||
else if (r === 1) i = 6;
|
||||
else i = Math.round(i * e);
|
||||
r += 1;
|
||||
}
|
||||
e = Math.max(1.3, e + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02)));
|
||||
// next_review uses local timezone — acceptable for a personal study app
|
||||
const next = new Date();
|
||||
next.setDate(next.getDate() + i);
|
||||
return {
|
||||
ease_factor: +e.toFixed(2),
|
||||
interval_days: i,
|
||||
repetitions: r,
|
||||
next_review: next.toISOString().slice(0, 10),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { sm2 };
|
||||
Reference in New Issue
Block a user