godcrm/backend/services/EmbeddingService.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

156 lines
5.1 KiB
JavaScript

/**
* EmbeddingService — Shared embedding generation and vector search
* ADR-110 AC9-AC10: Vector embedding for conversation summaries
*
* Extracted from ai-agents.js vector endpoints for reuse across services.
* Uses OpenAI-compatible embedding API.
*/
import { dbGet, isPostgres } from '../database/connection.js';
import { apiLogger } from '../utils/logger.js';
import { getSecret } from './secrets/getSecret.js';
const DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small';
const DEFAULT_EMBEDDING_DIMENSIONS = 1536;
/**
* Resolve embedding API configuration.
* Fallback chain: env OPENAI_API_KEY → AI API Keys table → null
*
* @param {number|null} spaceId - Optional space ID for space-specific key resolution
* @returns {Promise<{apiKey: string|null, model: string, baseUrl: string}>}
*/
export async function resolveEmbeddingConfig(spaceId = null) {
let apiKey = null;
let model = DEFAULT_EMBEDDING_MODEL;
let baseUrl = 'https://api.openai.com/v1';
// 1. Try vault first (ADR-0040 — was process.env.OPENAI_API_KEY)
apiKey = await getSecret('openai_api_key', 'OPENAI_API_KEY');
if (apiKey) {
return { apiKey, model, baseUrl };
}
// 2. Try AI API Keys table
try {
const keyRow = await dbGet(
isPostgres()
? `SELECT tr.data FROM table_rows tr
JOIN universal_tables ut ON tr.table_id = ut.id
WHERE ut.name LIKE '%API Keys%'
AND tr.data->>'status' = 'active'
AND (tr.data->>'name' ILIKE '%openai%' OR tr.data->>'name' ILIKE '%embedding%')
ORDER BY tr.created_at DESC LIMIT 1`
: `SELECT tr.data FROM table_rows tr
JOIN universal_tables ut ON tr.table_id = ut.id
WHERE ut.name LIKE '%API Keys%'
AND json_extract(tr.data, '$.status') = 'active'
AND (json_extract(tr.data, '$.name') LIKE '%OpenAI%' OR json_extract(tr.data, '$.name') LIKE '%Embedding%')
ORDER BY tr.created_at DESC LIMIT 1`,
[]
);
if (keyRow) {
const keyData = typeof keyRow.data === 'string' ? JSON.parse(keyRow.data) : keyRow.data;
if (keyData?.api_key) {
apiKey = keyData.api_key;
baseUrl = keyData.base_url || baseUrl;
}
}
} catch (err) {
apiLogger.warn({ err: err.message }, 'EmbeddingService: Failed to resolve API key from table');
}
return { apiKey, model, baseUrl };
}
/**
* Generate embedding vector for text using OpenAI-compatible API
*
* @param {string} text - Text to embed
* @param {string} apiKey - OpenAI API key
* @param {string} model - Embedding model name
* @param {string} baseUrl - API base URL
* @returns {Promise<number[]>} Embedding vector
*/
export async function generateEmbedding(text, apiKey, model = DEFAULT_EMBEDDING_MODEL, baseUrl = 'https://api.openai.com/v1') {
if (!apiKey) {
throw new Error('No API key configured for embedding generation');
}
if (!text || typeof text !== 'string' || text.trim().length === 0) {
throw new Error('Text is required for embedding generation');
}
const response = await fetch(`${baseUrl}/embeddings`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model,
input: text.slice(0, 8000) // Limit input to avoid token overflow
})
});
if (!response.ok) {
const errorText = await response.text();
apiLogger.error({ err: errorText, context: 'EmbeddingService' }, 'Embedding API error');
throw new Error(`Embedding API error: ${response.status}`);
}
const result = await response.json();
return result.data[0].embedding;
}
/**
* Compute cosine similarity between two vectors
*
* @param {number[]} vecA - First vector
* @param {number[]} vecB - Second vector
* @returns {number} Cosine similarity (0 to 1)
*/
export function cosineSimilarity(vecA, vecB) {
if (!vecA || !vecB || vecA.length !== vecB.length) return 0;
const dotProduct = vecA.reduce((sum, a, i) => sum + a * vecB[i], 0);
const normA = Math.sqrt(vecA.reduce((sum, a) => sum + a * a, 0));
const normB = Math.sqrt(vecB.reduce((sum, b) => sum + b * b, 0));
return normA && normB ? dotProduct / (normA * normB) : 0;
}
/**
* Embed text and return structured result
*
* @param {string} text - Text to embed
* @param {number|null} spaceId - Optional space ID
* @returns {Promise<{embedding: number[], model: string, dimensions: number}|null>}
*/
export async function embedText(text, spaceId = null) {
try {
const config = await resolveEmbeddingConfig(spaceId);
if (!config.apiKey) {
apiLogger.warn({ context: 'EmbeddingService' }, 'No API key available for embedding');
return null;
}
const embedding = await generateEmbedding(text, config.apiKey, config.model, config.baseUrl);
return {
embedding,
model: config.model,
dimensions: embedding.length
};
} catch (err) {
apiLogger.error({ err: err.message, context: 'EmbeddingService' }, 'Failed to generate embedding');
return null;
}
}
export default {
resolveEmbeddingConfig,
generateEmbedding,
cosineSimilarity,
embedText,
DEFAULT_EMBEDDING_MODEL,
DEFAULT_EMBEDDING_DIMENSIONS
};