File size: 7,618 Bytes
20f83d9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 | /**
* 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;
}
}
|