File size: 13,887 Bytes
ee888e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
/**
 * Embedding client for brief-dedup.
 *
 * Exports:
 *   - normalizeForEmbedding(title): the SINGLE function that produces
 *     both the embedded string and the cache-key input. No aliasing
 *     possible (plan's "normalization contract").
 *   - embedBatch(normalizedTitles, deps): batched, cached, all-or-
 *     nothing. Throws EmbeddingTimeoutError on wall-clock overrun and
 *     EmbeddingProviderError on any upstream failure. Never returns a
 *     partial result.
 *
 * Contract details:
 *   - Cache: brief:emb:v1:text-3-small-512:<sha256(normalized)>,
 *     14-day TTL, JSON array of 512 numbers.
 *   - Deterministic: same input β†’ same output vectors (cache hits)
 *     or same OpenRouter call (cache misses).
 *   - `deps` is for tests β€” prod callers pass nothing and get the
 *     real fetch / Upstash / AbortSignal wired in.
 */

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';
  }
}

/**
 * The ONE normalisation function. Cache-key input = embed-request
 * input. Any caller that embeds outside this function will drift.
 *
 *   1. Strip wire-service suffixes (" - Reuters", " | AP News", etc.)
 *      via the shared stripSourceSuffix so the outlet allow-list is
 *      single-sourced with the Jaccard fallback. Adding a new outlet
 *      updates both paths at once.
 *   2. Trim.
 *   3. Collapse internal whitespace.
 *   4. Lowercase.
 */
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}`;
}

// Default (production) deps wiring lives in ./_upstash-pipeline.mjs so
// the orchestrator and the embedding client share one implementation.

// Symmetric to the cache-write FLUSH knob: a 512-dim vector
// serialises to ~9.4KB, so an unbatched GET pipeline RESPONSE for
// N unique titles is NΓ—9.4KB. With ~8K cached titles in production
// (live brief:emb:v1:* count), a cold-tick pipeline-GET response
// would already be 75MB β€” well past Upstash's per-request limit
// and likely to time out the 10s pipeline budget. 500 GETs Γ—
// ~9.4KB = ~4.7MB per chunk response keeps the symmetric read
// path under the same budget the writes target.
const CACHE_GET_FLUSH = 500;

/**
 * Look up a set of cache keys via the redis pipeline and return a
 * Map of key β†’ vector for the hits. Misses, corrupt cells, pipeline
 * failures are all treated as "not in cache" β€” the caller falls
 * through to the API.
 *
 * Kept as a helper so embedBatch's cognitive complexity stays
 * reviewable; there's no other caller.
 *
 * Chunked + bail-on-failure for parity with the cache-write path:
 * the response body for a single GET pipeline scales linearly with
 * uniqueKeys.length, and an outage would otherwise spend the full
 * embed deadline on N Γ— 10s timeouts inside this helper before the
 * caller's deadline check fires. Per-chunk index alignment is
 * preserved because each chunk reads its own contiguous
 * uniqueKeys.slice(...) β€” no cross-chunk position arithmetic.
 */
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]));
    // Outage / short-response: treat the rest as misses. The caller
    // will hit the API for them β€” strict optimisation only, never
    // correctness. Don't keep iterating; remaining chunks would
    // almost certainly hit the same outage and burn the deadline.
    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 {
        // Corrupt cache cell: treat as miss. Don't error β€” next
        // successful API call will overwrite.
      }
    }
  }
  return hits;
}

/**
 * Single batched OpenRouter /embeddings call for `missingTitles`.
 * Returns a number[N] where N = missingTitles.length. Throws
 * EmbeddingTimeoutError on abort/timeout, EmbeddingProviderError on
 * any other upstream failure. NEVER returns a partial result.
 */
async function callEmbeddingsApi({ fetchImpl, apiKey, missingTitles, timeoutMs }) {
  // Negative / zero remaining-budget means the deadline is already past.
  // Bail to the orchestrator's all-or-nothing fallback rather than open a
  // doomed HTTP connection that blows the wall-clock cap by the floor.
  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'}`,
    );
  }
  // Honour entry.index if the provider re-orders; fall back to i.
  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;
}

/**
 * Embed a batch of already-normalised titles with cache look-through.
 *
 * @param {string[]} normalizedTitles  output of normalizeForEmbedding for each title
 * @param {object} [deps]
 * @param {typeof fetch} [deps.fetch]
 * @param {(commands: Array<unknown[]>) => Promise<Array<{result: unknown}> | null>} [deps.redisPipeline]
 * @param {() => number} [deps.now]
 * @param {number} [deps.wallClockMs]
 * @param {string} [deps._apiKey]  OPENROUTER_API_KEY override (tests only;
 *   prefixed to discourage accidental spread from user-controlled objects)
 * @returns {Promise<number[][]>}  one 512-dim vector per input, in order
 *
 * Throws EmbeddingTimeoutError on wall-clock overrun.
 * Throws EmbeddingProviderError on any upstream / parse failure.
 * NEVER returns a partial batch β€” the orchestrator relies on this to
 * collapse the entire run to Jaccard on any failure.
 */
export async function embedBatch(normalizedTitles, deps = {}) {
  if (!Array.isArray(normalizedTitles)) {
    throw new EmbeddingProviderError('embedBatch: normalizedTitles must be an array');
  }
  if (normalizedTitles.length === 0) return [];

  // Wrap rather than assign: bare `fetch` captures the current global
  // binding at lookup time, so later monkey-patches (instrumentation,
  // Edge-runtime shims) don't see the wrapper. See AGENTS.md's
  // "fetch.bind(globalThis) is BANNED" rule β€” same class of bug.
  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) {
    // Provider failure so the orchestrator falls back to Jaccard rather
    // than silently embedding with no auth.
    throw new EmbeddingProviderError('OPENROUTER_API_KEY not configured');
  }

  const deadline = nowImpl() + wallClockMs;

  // Deduped cache-key table. Same normalised title β†’ same cache cell.
  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();

  // Build the miss list, preserving the first normalised title we
  // saw for each unique key.
  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)]);
    }
    // Cache writes are best-effort β€” a failure costs us a re-embed
    // on the next run, never a correctness bug. Chunked because the
    // 512-dim vector serialises to ~9.4KB per SET command; an unbatched
    // pipeline of N misses sends one HTTP body of NΓ—9.4KB to Upstash
    // REST `/pipeline`, which trips the per-request body limit (50MB on
    // our plan) at ~5,300 misses. Real ticks rarely approach that, but
    // a cold cache on a high-volume language tick (or a future tick-
    // size growth) would silently exceed it. 200 Γ— 9.4KB β‰ˆ 1.9MB per
    // request matches the chunking pattern used by sibling seeders
    // (PIPE_BATCH=50 in seed-resilience-scores.mjs / seed-comtrade-
    // bilateral-hs4.mjs, SET_BATCH=30 in resilience/v1/_shared.ts).
    //
    // Outage break: defaultRedisPipeline returns null on HTTP error
    // (does NOT throw), so the try/catch alone won't stop the loop.
    // On a sustained Upstash outage with 5K misses, that would mean
    // 27 chunks Γ— ~10s timeout each β‰ˆ 270s β€” well past the 45s
    // wall-clock budget for dedup. Break on any non-array (null /
    // short) chunk result, and on remaining-deadline exhaustion, so
    // the caller stays inside its budget even on outage.
    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 {
      // swallow
    }
  }

  // Map back to input order; duplicated titles share a vector.
  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;
}

/**
 * Cosine similarity for two equal-length vectors. Returns a value
 * in [-1, 1]; 1 = identical direction.
 *
 * Exported so the clusterer and tests share one implementation.
 */
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));
}