/** * Query-side embedding for the historical intelligence memory (#5694). * * Split out of intel-history-client.ts: turning free text into a vector is a * different upstream (a paid embeddings provider), a different failure mode, * and a different budget than reading the Convex store — and it is the half * that grows when the embedding model is versioned (#5742). * * EDGE-RUNTIME CONSTRAINT — the embedding call is reimplemented here rather * than reusing `embedBatch` from scripts/lib/brief-embedding.mjs. That module * imports `node:crypto` for its cache keys, and the intelligence gateway * (api/intelligence/v1/[rpc].ts) runs on the Vercel Edge runtime, which * rejects node: built-ins at runtime. Only the pure, dependency-free modules * are imported: the tunables that must not drift from the seed writer's, and * the outlet-suffix stripper that is half the normalization contract. The * other half (`normalizeQueryText`) is re-derived, and * tests/intel-history-endpoints.test.mts asserts it against the real * `normalizeForEmbedding` so the copy cannot drift silently. */ import { EMBED_DIMS, EMBED_MODEL, OPENROUTER_EMBEDDINGS_URL, } from '../../scripts/lib/brief-dedup-consts.mjs'; import { stripSourceSuffix } from '../../scripts/lib/brief-dedup-jaccard.mjs'; import { getCachedJson, setCachedJson } from './redis'; // @ts-expect-error — JS module, no declaration file import { captureSilentError } from '../../api/_sentry-edge.js'; /** * One embeddings call on a user-facing read path. Shorter than the seed * writer's 45s batch budget: this is a single input on an interactive * request, and a slow provider should degrade to `upstream_unavailable` * well inside the edge function's own limit. */ const EMBED_TIMEOUT_MS = 4_000; /** * Query-vector cache. Every miss spends one paid OpenRouter call on an * interactive request, and repeat traffic here is real: agents re-issue the * same MCP query, and dashboard phrases repeat verbatim. * * The seed writer's 14-day TTL is sized for a corpus that never changes once * written; a query vector only has to outlive a burst of repeats, so hours * are enough and a shorter window bounds how long a model change could serve * mismatched vectors. The key carries the model and dimension for that * reason — a model swap lands on a cold namespace instead of silently mixing * vector spaces. * * The query itself is HASHED into the key, never embedded verbatim. What * users search for is their business: a raw-text key would expose every * analyst's query to anything that can list Redis keys (dashboards, key * dumps, support tooling) and would let an unbounded input become an * unbounded key. */ const EMBED_CACHE_TTL_SECONDS = 6 * 60 * 60; const EMBED_CACHE_PREFIX = `intel-history:embed:v1:${EMBED_MODEL}:${EMBED_DIMS}:`; /** * SHA-256 via Web Crypto — available on the Edge runtime, unlike the * node:crypto hashing the seed-side cache uses. */ async function hashCacheInput(input: string): Promise { const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input)); return Array.from(new Uint8Array(digest)) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } let _didWarnMissingOpenRouterKey = false; /** * The query-side half of the normalization contract shared with * scripts/lib/brief-embedding.mjs:normalizeForEmbedding. The seed writer * embeds normalized text; a query normalized any differently ranks against a * subtly different vector space and degrades recall with nothing to point at. * * Kept byte-equivalent to that function — outlet-suffix strip (imported, so * the outlet list stays single-sourced), trim, whitespace collapse, lowercase. */ export function normalizeQueryText(text: string): string { if (typeof text !== 'string') return ''; return stripSourceSuffix(text).trim().replace(/\s+/g, ' ').toLowerCase(); } /** * A vector is only usable at exactly the index's dimension, all components * finite. Applied to cache hits as well as provider responses: a stale or * corrupt entry must be re-embedded, never forwarded — Convex would reject * it and the caller would report an outage that isn't one. */ function isUsableVector(value: unknown): value is number[] { return ( Array.isArray(value) && value.length === EMBED_DIMS && value.every((n) => typeof n === 'number' && Number.isFinite(n)) ); } /** * Embed one free-text query with the model and dimensions the stored vectors * were produced under. Returns null on any failure — missing key, provider * error, timeout, or a vector the store would reject anyway. * * A wrong-dimension or non-finite vector is treated as failure rather than * passed through: convex/intelHistory.ts would reject it, and a silently * substituted vector would return arbitrary rows presented as real matches. * * Results are cached on the normalized query text, so a repeated query costs * a Redis read instead of a paid provider call. Cache failures are never * fatal: a miss or a write error just means the provider is asked again. */ export async function embedQueryText(text: string): Promise { const apiKey = process.env.OPENROUTER_API_KEY ?? ''; if (!apiKey) { if (!_didWarnMissingOpenRouterKey) { _didWarnMissingOpenRouterKey = true; console.warn('[intel-history] OPENROUTER_API_KEY not set; semantic history search disabled'); } return null; } const input = normalizeQueryText(text); if (!input) return null; const cacheKey = `${EMBED_CACHE_PREFIX}${await hashCacheInput(input)}`; const cached = await getCachedJson(cacheKey); if (isUsableVector(cached)) return cached; try { const resp = await fetch(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-gateway/1.0', }, body: JSON.stringify({ model: EMBED_MODEL, input: [input], dimensions: EMBED_DIMS }), signal: AbortSignal.timeout(EMBED_TIMEOUT_MS), }); if (!resp.ok) { console.warn(`[intel-history] embeddings provider returned HTTP ${resp.status}`); return null; } const body = (await resp.json()) as { data?: Array<{ embedding?: unknown }> }; const vector = body?.data?.[0]?.embedding; if (!Array.isArray(vector) || vector.length !== EMBED_DIMS) { console.warn( `[intel-history] embeddings provider returned ${ Array.isArray(vector) ? `${vector.length} dims` : 'no vector' }, expected ${EMBED_DIMS}`, ); return null; } if (!vector.every((n) => typeof n === 'number' && Number.isFinite(n))) { console.warn('[intel-history] embeddings provider returned a non-finite component'); return null; } await setCachedJson(cacheKey, vector, EMBED_CACHE_TTL_SECONDS); return vector as number[]; } catch (err) { // Degrades to upstreamUnavailable rather than a 5xx, so the caller sees a // 200 with no records — report it, or a provider outage is indistinguishable // from an empty history in every dashboard we have. const msg = err instanceof Error ? err.message : String(err); console.warn(`[intel-history] embeddings call failed: ${msg}`); captureSilentError(err, { tags: { surface: 'server', component: 'intel-history', stage: 'embed' }, fingerprint: ['intel-history', 'embed-error'], }); return null; } }