All remaining backend files renamed:
src/middleware/auth.ts, logging.ts (2 files)
src/utils/*.ts (20 files: ai, auditQueue, config, crypto,
embeddings, errors, fileType, logger, models,
notify, passwords, platform, promptSafe,
prompts, redact, sessions, transcribe*,
ttsGoogle)
src/db/database.ts, migrate.ts (2 files)
Spot-fixes to satisfy tsc (all within the spirit of 'no behavior
change' — added `: any` annotations where the original JS relied on
duck typing that tsc's default inference narrows too aggressively):
utils/ai.ts — body, converseParams, request literals + fallback
result object + err.code/model/message casts. AI client has lots
of provider-specific ad-hoc object shapes; Day 5 will replace the
`any`s with proper provider-response interfaces.
utils/embeddings.ts — payload + request as `any`; generateEmbedding
call sites pass `undefined as any` for the now-required second
arg (model) until we refactor the signature.
utils/prompts.ts — PROMPTS typed as Record<string, any> so
.loadFromDb / .updatePrompt / .getAllPrompts attachments after
the const literal compile.
utils/transcribeLocal.ts — buildArgs() has two `var args = [...]`
in the same function scope (var-hoisted); both now typed as
any[] so they don't type-clash across conditionals.
Backend is now 54 of 54 TypeScript files, permissive mode.
`npm run typecheck` EXIT 0. Prod container still running the old
JS image — no Dockerfile change yet.
Next: Day 5 flips strict: true, fixes every error tsc surfaces, adds
Vitest + Zod + Knip tooling.
265 lines
8.8 KiB
TypeScript
265 lines
8.8 KiB
TypeScript
// ============================================================
|
|
// EMBEDDINGS UTILITY — Generate & search with Vertex AI embeddings
|
|
// Supports: Vertex AI (direct), LiteLLM proxy, OpenAI fallback
|
|
// ============================================================
|
|
|
|
var axios = require('axios');
|
|
|
|
// Vertex AI embedding models (via LiteLLM or direct)
|
|
// gemini-embedding-001: 768 dims, multilingual + code, best quality
|
|
// text-embedding-005: 768 dims, English + code optimized
|
|
// text-multilingual-embedding-002: 768 dims, multilingual focus
|
|
var DEFAULT_MODEL = 'vertex_ai/text-embedding-005';
|
|
var DEFAULT_DIMS = 768;
|
|
|
|
/**
|
|
* Generate embedding for text using configured provider
|
|
* @param {string} text - Text to embed (max ~2000 tokens)
|
|
* @param {object} opts - Options: { model, dimensions }
|
|
* @returns {Promise<number[]>} - Embedding vector
|
|
*/
|
|
async function generateEmbedding(text, opts) {
|
|
opts = opts || {};
|
|
var dbModel, dbDims;
|
|
try {
|
|
var db = require('../db/database');
|
|
dbModel = await db.getSetting('embeddings.model') || '';
|
|
dbDims = await db.getSetting('embeddings.dimensions') || '';
|
|
} catch(e) { /* DB not available during startup */ }
|
|
var model = opts.model || dbModel || process.env.EMBEDDING_MODEL || DEFAULT_MODEL;
|
|
var dimensions = opts.dimensions || (dbDims ? parseInt(dbDims) : 0) || parseInt(process.env.EMBEDDING_DIMENSIONS) || DEFAULT_DIMS;
|
|
|
|
// Truncate text to ~2000 tokens (~8000 chars) to avoid API errors
|
|
// NOTE: Large PDFs (e.g., 100MB) will be truncated to first ~8000 chars for embedding.
|
|
// The full PDF content is still extracted and stored in the database body field.
|
|
// This is expected behavior - embeddings are semantic representations, not full-text storage.
|
|
var truncated = text.substring(0, 8000);
|
|
if (!truncated.trim()) {
|
|
throw new Error('Empty text provided for embedding');
|
|
}
|
|
|
|
// Try LiteLLM first if configured
|
|
if (process.env.LITELLM_API_BASE) {
|
|
return await generateEmbeddingLiteLLM(truncated, model, dimensions);
|
|
}
|
|
|
|
// Try Vertex AI direct if configured
|
|
if (process.env.GOOGLE_APPLICATION_CREDENTIALS || process.env.VERTEX_PROJECT) {
|
|
return await generateEmbeddingVertexDirect(truncated, model, dimensions);
|
|
}
|
|
|
|
// Fallback to OpenAI if configured
|
|
if (process.env.OPENAI_API_KEY) {
|
|
return await generateEmbeddingOpenAI(truncated, model, dimensions);
|
|
}
|
|
|
|
throw new Error('No embedding provider configured. Set LITELLM_API_BASE, VERTEX_PROJECT, or OPENAI_API_KEY');
|
|
}
|
|
|
|
/**
|
|
* Generate embedding via LiteLLM proxy
|
|
*/
|
|
async function generateEmbeddingLiteLLM(text, model, dimensions) {
|
|
try {
|
|
var base = process.env.LITELLM_API_BASE.replace(/\/+$/, '');
|
|
var headers = { 'Content-Type': 'application/json' };
|
|
if (process.env.LITELLM_API_KEY) {
|
|
headers['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY;
|
|
}
|
|
|
|
var payload: any = {
|
|
model: model,
|
|
input: text
|
|
};
|
|
|
|
// Only include dimensions if model supports it (some models have fixed dims)
|
|
if (dimensions && model.includes('text-embedding-005')) {
|
|
payload.dimensions = dimensions;
|
|
}
|
|
|
|
var response = await axios.post(base + '/embeddings', payload, {
|
|
headers: headers,
|
|
timeout: 30000
|
|
});
|
|
|
|
if (!response.data || !response.data.data || !response.data.data[0]) {
|
|
throw new Error('Invalid response from LiteLLM embeddings API');
|
|
}
|
|
|
|
return response.data.data[0].embedding;
|
|
} catch (err) {
|
|
console.error('[Embeddings] LiteLLM error:', err.response?.data || err.message);
|
|
throw new Error('LiteLLM embedding failed: ' + (err.response?.data?.error || err.message));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate embedding via Vertex AI direct (using @google-cloud/vertexai)
|
|
*/
|
|
async function generateEmbeddingVertexDirect(text, model, dimensions) {
|
|
try {
|
|
var { VertexAI } = require('@google-cloud/vertexai');
|
|
|
|
var project = process.env.VERTEX_PROJECT || process.env.GOOGLE_CLOUD_PROJECT;
|
|
var location = process.env.VERTEX_LOCATION || 'us-central1';
|
|
|
|
if (!project) {
|
|
throw new Error('VERTEX_PROJECT or GOOGLE_CLOUD_PROJECT not set');
|
|
}
|
|
|
|
var vertexAI = new VertexAI({ project: project, location: location });
|
|
|
|
// Extract model name (strip vertex_ai/ prefix if present)
|
|
var modelName = model.replace(/^vertex_ai\//, '');
|
|
|
|
// For text-embedding-005, we can specify output dimensions
|
|
var request: any = {
|
|
instances: [{ content: text }]
|
|
};
|
|
|
|
if (dimensions && modelName.includes('text-embedding-005')) {
|
|
request.parameters = { outputDimensionality: dimensions };
|
|
}
|
|
|
|
// Use predictText API for embeddings
|
|
var predictionClient = vertexAI.preview.getPredictionServiceClient();
|
|
var endpoint = `projects/${project}/locations/${location}/publishers/google/models/${modelName}`;
|
|
|
|
var [response] = await predictionClient.predict({
|
|
endpoint: endpoint,
|
|
instances: [{ content: text }],
|
|
parameters: request.parameters || {}
|
|
});
|
|
|
|
if (!response || !response.predictions || !response.predictions[0]) {
|
|
throw new Error('Invalid response from Vertex AI');
|
|
}
|
|
|
|
var prediction = response.predictions[0];
|
|
return prediction.embeddings?.values || prediction.values || prediction;
|
|
|
|
} catch (err) {
|
|
console.error('[Embeddings] Vertex AI direct error:', err.message);
|
|
throw new Error('Vertex AI embedding failed: ' + err.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate embedding via OpenAI (fallback)
|
|
*/
|
|
async function generateEmbeddingOpenAI(text, model, dimensions) {
|
|
try {
|
|
var openai = require('openai');
|
|
var client = new openai.OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
|
|
|
// Use OpenAI's text-embedding-3-small model (1536 dims by default)
|
|
var embModel = 'text-embedding-3-small';
|
|
var response = await client.embeddings.create({
|
|
model: embModel,
|
|
input: text,
|
|
dimensions: dimensions || 768 // OpenAI supports custom dimensions
|
|
});
|
|
|
|
return response.data[0].embedding;
|
|
} catch (err) {
|
|
console.error('[Embeddings] OpenAI error:', err.message);
|
|
throw new Error('OpenAI embedding failed: ' + err.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Search for similar content using cosine similarity
|
|
* @param {string} queryText - Search query
|
|
* @param {object} opts - Options: { limit, threshold, contentType }
|
|
* @returns {Promise<Array>} - Matching content with similarity scores
|
|
*/
|
|
async function searchSimilar(queryText, opts) {
|
|
opts = opts || {};
|
|
var limit = opts.limit || 10;
|
|
var threshold = opts.threshold || 0.5; // Cosine similarity threshold (0-1)
|
|
|
|
var db = require('../db/database');
|
|
|
|
// Generate embedding for query
|
|
var queryEmbedding = await generateEmbedding(queryText, undefined as any);
|
|
|
|
// Build WHERE clause for filtering
|
|
var whereClause = 'WHERE c.published = true AND c.embedding IS NOT NULL';
|
|
var params = [JSON.stringify(queryEmbedding), threshold, limit];
|
|
var paramIdx = 4;
|
|
|
|
if (opts.contentType) {
|
|
whereClause += ' AND c.content_type = $' + paramIdx;
|
|
params.push(opts.contentType);
|
|
paramIdx++;
|
|
}
|
|
|
|
if (opts.categoryId) {
|
|
whereClause += ' AND c.category_id = $' + paramIdx;
|
|
params.push(opts.categoryId);
|
|
}
|
|
|
|
// Query with cosine similarity using pgvector
|
|
// 1 - (a <=> b) converts distance to similarity (higher = more similar)
|
|
var sql = `
|
|
SELECT
|
|
c.id, c.title, c.slug, c.subject, c.content_type, c.created_at,
|
|
cat.name as category_name, cat.slug as category_slug,
|
|
1 - (c.embedding <=> $1::vector) as similarity,
|
|
(SELECT COUNT(*) FROM learning_questions q WHERE q.content_id = c.id) as question_count
|
|
FROM learning_content c
|
|
LEFT JOIN learning_categories cat ON c.category_id = cat.id
|
|
${whereClause}
|
|
AND 1 - (c.embedding <=> $1::vector) >= $2
|
|
ORDER BY c.embedding <=> $1::vector
|
|
LIMIT $3
|
|
`;
|
|
|
|
var results = await db.all(sql, params);
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Generate embedding for learning content (combines title + subject + body)
|
|
* @param {object} content - { title, subject, body }
|
|
* @returns {Promise<number[]>} - Embedding vector
|
|
*/
|
|
async function generateContentEmbedding(content) {
|
|
// Combine title, subject, and body (weighted toward title)
|
|
var text = [
|
|
content.title || '',
|
|
content.title || '', // Title twice for emphasis
|
|
content.subject || '',
|
|
stripHtml(content.body || '').substring(0, 6000)
|
|
].filter(Boolean).join('\n\n');
|
|
|
|
return await generateEmbedding(text, undefined as any);
|
|
}
|
|
|
|
/**
|
|
* Strip HTML tags from string
|
|
*/
|
|
function stripHtml(html) {
|
|
return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
/**
|
|
* Check if embeddings are available (provider configured)
|
|
*/
|
|
function isEmbeddingsAvailable() {
|
|
return !!(
|
|
process.env.LITELLM_API_BASE ||
|
|
process.env.VERTEX_PROJECT ||
|
|
process.env.GOOGLE_CLOUD_PROJECT ||
|
|
process.env.OPENAI_API_KEY
|
|
);
|
|
}
|
|
|
|
module.exports = {
|
|
generateEmbedding,
|
|
generateContentEmbedding,
|
|
searchSimilar,
|
|
isEmbeddingsAvailable,
|
|
DEFAULT_MODEL,
|
|
DEFAULT_DIMS
|
|
};
|