GenerAI / worldmonitor /server /_shared /intel-history-embed.ts
amogaddy's picture
Integra World Monitor (AGPL-3.0, self-hosted) nello Space: pagina, menu, e arricchimento notizie per la AI (part 5)
20f83d9 verified
Raw
History Blame Contribute Delete
7.62 kB
/**
* 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<string> {
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<number[] | null> {
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;
}
}