| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { createHash } from 'node:crypto'; |
|
|
| import { |
| CACHE_KEY_PREFIX, |
| CACHE_TTL_SECONDS, |
| EMBED_DIMS, |
| EMBED_MODEL, |
| OPENROUTER_EMBEDDINGS_URL, |
| } from './brief-dedup-consts.mjs'; |
| import { stripSourceSuffix } from './brief-dedup-jaccard.mjs'; |
| import { defaultRedisPipeline } from './_upstash-pipeline.mjs'; |
|
|
| export class EmbeddingProviderError extends Error { |
| constructor(message, { status, cause } = {}) { |
| super(message); |
| this.name = 'EmbeddingProviderError'; |
| if (status !== undefined) this.status = status; |
| if (cause !== undefined) this.cause = cause; |
| } |
| } |
|
|
| export class EmbeddingTimeoutError extends Error { |
| constructor(message = 'Embedding wall-clock budget exceeded') { |
| super(message); |
| this.name = 'EmbeddingTimeoutError'; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function normalizeForEmbedding(title) { |
| if (typeof title !== 'string') return ''; |
| return stripSourceSuffix(title).trim().replace(/\s+/g, ' ').toLowerCase(); |
| } |
|
|
| export function cacheKeyFor(normalizedTitle) { |
| const hash = createHash('sha256').update(normalizedTitle).digest('hex'); |
| return `${CACHE_KEY_PREFIX}:${hash}`; |
| } |
|
|
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| const CACHE_GET_FLUSH = 500; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function cacheGetBatched(uniqueKeys, pipelineImpl, deadline = Infinity, nowImpl = Date.now) { |
| const hits = new Map(); |
| if (uniqueKeys.length === 0) return hits; |
|
|
| for (let start = 0; start < uniqueKeys.length; start += CACHE_GET_FLUSH) { |
| if (nowImpl() > deadline) return hits; |
| const chunk = uniqueKeys.slice(start, start + CACHE_GET_FLUSH); |
| const getResults = await pipelineImpl(chunk.map((k) => ['GET', k])); |
| |
| |
| |
| |
| if (!Array.isArray(getResults) || getResults.length !== chunk.length) return hits; |
|
|
| for (let i = 0; i < chunk.length; i++) { |
| const cell = getResults[i]; |
| const raw = cell && typeof cell === 'object' && 'result' in cell ? cell.result : null; |
| if (typeof raw !== 'string') continue; |
| try { |
| const parsed = JSON.parse(raw); |
| if (Array.isArray(parsed) && parsed.length === EMBED_DIMS) { |
| hits.set(chunk[i], parsed); |
| } |
| } catch { |
| |
| |
| } |
| } |
| } |
| return hits; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async function callEmbeddingsApi({ fetchImpl, apiKey, missingTitles, timeoutMs }) { |
| |
| |
| |
| if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { |
| throw new EmbeddingTimeoutError(); |
| } |
| let resp; |
| try { |
| resp = await fetchImpl(OPENROUTER_EMBEDDINGS_URL, { |
| method: 'POST', |
| headers: { |
| Authorization: `Bearer ${apiKey}`, |
| 'Content-Type': 'application/json', |
| 'HTTP-Referer': 'https://worldmonitor.app', |
| 'X-Title': 'World Monitor', |
| 'User-Agent': 'worldmonitor-digest/1.0', |
| }, |
| body: JSON.stringify({ |
| model: EMBED_MODEL, |
| input: missingTitles, |
| dimensions: EMBED_DIMS, |
| }), |
| signal: AbortSignal.timeout(timeoutMs), |
| }); |
| } catch (err) { |
| if (err && (err.name === 'TimeoutError' || err.name === 'AbortError')) { |
| throw new EmbeddingTimeoutError(); |
| } |
| throw new EmbeddingProviderError( |
| `embedBatch: fetch failed — ${err instanceof Error ? err.message : String(err)}`, |
| { cause: err }, |
| ); |
| } |
| if (!resp.ok) { |
| throw new EmbeddingProviderError( |
| `embedBatch: OpenRouter returned HTTP ${resp.status}`, |
| { status: resp.status }, |
| ); |
| } |
| let body; |
| try { |
| body = await resp.json(); |
| } catch (err) { |
| throw new EmbeddingProviderError( |
| `embedBatch: response JSON parse failed — ${err instanceof Error ? err.message : String(err)}`, |
| { cause: err }, |
| ); |
| } |
| const data = Array.isArray(body?.data) ? body.data : null; |
| if (!data || data.length !== missingTitles.length) { |
| throw new EmbeddingProviderError( |
| `embedBatch: expected ${missingTitles.length} embeddings, got ${data?.length ?? 'none'}`, |
| ); |
| } |
| |
| const out = new Array(missingTitles.length); |
| for (let i = 0; i < data.length; i++) { |
| const entry = data[i]; |
| const idx = typeof entry?.index === 'number' ? entry.index : i; |
| const vector = entry?.embedding; |
| if (!Array.isArray(vector) || vector.length !== EMBED_DIMS) { |
| throw new EmbeddingProviderError( |
| `embedBatch: embedding[${idx}] has unexpected length ${vector?.length ?? 'n/a'}`, |
| ); |
| } |
| out[idx] = vector; |
| } |
| return out; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function embedBatch(normalizedTitles, deps = {}) { |
| if (!Array.isArray(normalizedTitles)) { |
| throw new EmbeddingProviderError('embedBatch: normalizedTitles must be an array'); |
| } |
| if (normalizedTitles.length === 0) return []; |
|
|
| |
| |
| |
| |
| const fetchImpl = deps.fetch ?? ((...args) => globalThis.fetch(...args)); |
| const pipelineImpl = deps.redisPipeline ?? defaultRedisPipeline; |
| const nowImpl = deps.now ?? (() => Date.now()); |
| const wallClockMs = deps.wallClockMs ?? 45_000; |
| const apiKey = deps._apiKey ?? process.env.OPENROUTER_API_KEY ?? ''; |
|
|
| if (!apiKey) { |
| |
| |
| throw new EmbeddingProviderError('OPENROUTER_API_KEY not configured'); |
| } |
|
|
| const deadline = nowImpl() + wallClockMs; |
|
|
| |
| const keyByIndex = normalizedTitles.map((t) => cacheKeyFor(t)); |
| const uniqueKeys = [...new Set(keyByIndex)]; |
|
|
| const vectorByKey = await cacheGetBatched(uniqueKeys, pipelineImpl, deadline, nowImpl); |
| if (nowImpl() > deadline) throw new EmbeddingTimeoutError(); |
|
|
| |
| |
| const missingKeys = uniqueKeys.filter((k) => !vectorByKey.has(k)); |
| if (missingKeys.length > 0) { |
| const missingTitleByKey = new Map(); |
| for (let i = 0; i < normalizedTitles.length; i++) { |
| if (!vectorByKey.has(keyByIndex[i]) && !missingTitleByKey.has(keyByIndex[i])) { |
| missingTitleByKey.set(keyByIndex[i], normalizedTitles[i]); |
| } |
| } |
| const missingTitles = missingKeys.map((k) => missingTitleByKey.get(k) ?? ''); |
| const freshVectors = await callEmbeddingsApi({ |
| fetchImpl, |
| apiKey, |
| missingTitles, |
| timeoutMs: deadline - nowImpl(), |
| }); |
| const cacheWrites = []; |
| for (let i = 0; i < freshVectors.length; i++) { |
| const key = missingKeys[i]; |
| vectorByKey.set(key, freshVectors[i]); |
| cacheWrites.push(['SET', key, JSON.stringify(freshVectors[i]), 'EX', String(CACHE_TTL_SECONDS)]); |
| } |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| try { |
| const FLUSH = 200; |
| for (let i = 0; i < cacheWrites.length; i += FLUSH) { |
| if (nowImpl() > deadline) break; |
| const result = await pipelineImpl(cacheWrites.slice(i, i + FLUSH)); |
| if (!Array.isArray(result) || result.length !== Math.min(FLUSH, cacheWrites.length - i)) break; |
| } |
| } catch { |
| |
| } |
| } |
|
|
| |
| const out = new Array(normalizedTitles.length); |
| for (let i = 0; i < normalizedTitles.length; i++) { |
| const v = vectorByKey.get(keyByIndex[i]); |
| if (!v) { |
| throw new EmbeddingProviderError( |
| `embedBatch: missing vector for index ${i} after API call`, |
| ); |
| } |
| out[i] = v; |
| } |
| return out; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export function cosineSimilarity(a, b) { |
| if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length || a.length === 0) { |
| return 0; |
| } |
| 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)); |
| } |
|
|