| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { createHash } from 'node:crypto'; |
|
|
| import { |
| deduplicateStoriesJaccard, |
| materializeCluster, |
| stripSourceSuffix, |
| } from './brief-dedup-jaccard.mjs'; |
| import { |
| completeLinkCluster, |
| shouldVeto, |
| singleLinkCluster, |
| } from './brief-dedup-embed.mjs'; |
| import { |
| embedBatch, |
| normalizeForEmbedding, |
| } from './brief-embedding.mjs'; |
| import { defaultRedisPipeline } from './_upstash-pipeline.mjs'; |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function readOrchestratorConfig(env = process.env) { |
| const modeRaw = (env.DIGEST_DEDUP_MODE ?? '').toLowerCase(); |
| let mode; |
| let invalidModeRaw = null; |
| if (modeRaw === '' || modeRaw === 'embed') { |
| mode = 'embed'; |
| } else if (modeRaw === 'jaccard') { |
| mode = 'jaccard'; |
| } else { |
| |
| |
| |
| |
| |
| |
| |
| mode = 'jaccard'; |
| invalidModeRaw = modeRaw; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const clusteringRaw = (env.DIGEST_DEDUP_CLUSTERING ?? '').toLowerCase(); |
| let clustering; |
| let invalidClusteringRaw = null; |
| if (clusteringRaw === '' || clusteringRaw === 'single') { |
| clustering = 'single'; |
| } else if (clusteringRaw === 'complete') { |
| clustering = 'complete'; |
| } else { |
| clustering = 'complete'; |
| invalidClusteringRaw = clusteringRaw; |
| } |
|
|
| const cosineRaw = Number.parseFloat(env.DIGEST_DEDUP_COSINE_THRESHOLD ?? ''); |
| const cosineThreshold = |
| Number.isFinite(cosineRaw) && cosineRaw > 0 && cosineRaw <= 1 ? cosineRaw : 0.60; |
|
|
| const wallClockRaw = Number.parseInt(env.DIGEST_DEDUP_WALL_CLOCK_MS ?? '', 10); |
| const wallClockMs = |
| Number.isInteger(wallClockRaw) && wallClockRaw > 0 ? wallClockRaw : 45_000; |
|
|
| |
| |
| const topicGroupingEnabled = env.DIGEST_DEDUP_TOPIC_GROUPING !== '0'; |
|
|
| |
| |
| |
| const topicThresholdRaw = Number.parseFloat(env.DIGEST_DEDUP_TOPIC_THRESHOLD ?? ''); |
| const topicThreshold = |
| Number.isFinite(topicThresholdRaw) && topicThresholdRaw > 0 && topicThresholdRaw <= 1 |
| ? topicThresholdRaw |
| : 0.45; |
|
|
| return { |
| mode, |
| clustering, |
| entityVetoEnabled: env.DIGEST_DEDUP_ENTITY_VETO_ENABLED !== '0', |
| cosineThreshold, |
| wallClockMs, |
| topicGroupingEnabled, |
| topicThreshold, |
| invalidModeRaw, |
| invalidClusteringRaw, |
| }; |
| } |
|
|
| |
|
|
| function titleHashHex(normalizedTitle) { |
| return createHash('sha256').update(normalizedTitle).digest('hex'); |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function deduplicateStories(stories, deps = {}) { |
| const cfg = readOrchestratorConfig(deps.env ?? process.env); |
| const jaccard = deps.jaccard ?? deduplicateStoriesJaccard; |
| const warn = deps.warn ?? ((line) => console.warn(line)); |
|
|
| if (cfg.invalidModeRaw !== null) { |
| warn( |
| `[digest] dedup unrecognised DIGEST_DEDUP_MODE=${cfg.invalidModeRaw} — ` + |
| 'falling back to jaccard (safe rollback path). Valid values: embed | jaccard.', |
| ); |
| } |
| if (cfg.invalidClusteringRaw !== null) { |
| warn( |
| `[digest] dedup unrecognised DIGEST_DEDUP_CLUSTERING=${cfg.invalidClusteringRaw} — ` + |
| 'falling back to complete-link (safe / conservative). Valid values: single | complete.', |
| ); |
| } |
|
|
| if (!Array.isArray(stories) || stories.length === 0) { |
| return { reps: [], embeddingByHash: new Map(), logSummary: '' }; |
| } |
|
|
| |
| |
| if (cfg.mode === 'jaccard') { |
| return { reps: jaccard(stories), embeddingByHash: new Map(), logSummary: '' }; |
| } |
|
|
| const embedImpl = deps.embedBatch ?? embedBatch; |
| const pipelineImpl = deps.redisPipeline ?? defaultRedisPipeline; |
| const nowImpl = deps.now ?? (() => Date.now()); |
| const started = nowImpl(); |
|
|
| try { |
| |
| |
| const prepared = stories.map((story, originalIndex) => { |
| const normalizedTitle = normalizeForEmbedding(story.title); |
| |
| |
| |
| |
| |
| const vetoTitle = stripSourceSuffix(story.title); |
| return { |
| story, |
| originalIndex, |
| hash: story.hash, |
| title: vetoTitle, |
| normalizedTitle, |
| titleHashHex: titleHashHex(normalizedTitle), |
| currentScore: Number(story.currentScore ?? 0), |
| mentionCount: Number(story.mentionCount ?? 1), |
| }; |
| }); |
| prepared.sort( |
| (a, b) => |
| b.currentScore - a.currentScore || |
| (a.titleHashHex < b.titleHashHex ? -1 : a.titleHashHex > b.titleHashHex ? 1 : 0), |
| ); |
|
|
| const embeddings = await embedImpl( |
| prepared.map((p) => p.normalizedTitle), |
| { |
| redisPipeline: pipelineImpl, |
| wallClockMs: cfg.wallClockMs, |
| now: nowImpl, |
| }, |
| ); |
| if (!Array.isArray(embeddings) || embeddings.length !== prepared.length) { |
| throw new Error('embedBatch returned unexpected result'); |
| } |
| const items = prepared.map((p, i) => ({ ...p, embedding: embeddings[i] })); |
|
|
| const vetoFn = cfg.entityVetoEnabled |
| ? (a, b) => shouldVeto(a.title, b.title) |
| : null; |
| const clusterFn = cfg.clustering === 'complete' ? completeLinkCluster : singleLinkCluster; |
| const clusterResult = clusterFn(items, { |
| cosineThreshold: cfg.cosineThreshold, |
| vetoFn, |
| }); |
|
|
| const embedClusters = clusterResult.clusters; |
| const embeddingByHash = new Map(); |
| const embedOutput = []; |
| for (const cluster of embedClusters) { |
| const rep = materializeCluster(cluster.map((i) => items[i].story)); |
| embedOutput.push(rep); |
| if (cfg.topicGroupingEnabled) { |
| |
| |
| |
| |
| const winningIdx = cluster.find((i) => items[i].story.hash === rep.hash); |
| if (winningIdx !== undefined) { |
| embeddingByHash.set(rep.hash, items[winningIdx].embedding); |
| } else { |
| |
| |
| |
| |
| warn(`[digest] dedup sidecar: materialized rep ${rep.hash} not found in its cluster — topic grouping will skip this rep`); |
| } |
| } |
| } |
|
|
| const logSummary = |
| `[digest] dedup mode=embed clustering=${cfg.clustering} stories=${items.length} clusters=${embedClusters.length} ` + |
| `veto_fires=${clusterResult.vetoFires} ms=${nowImpl() - started} ` + |
| `threshold=${cfg.cosineThreshold} fallback=false`; |
| return { reps: embedOutput, embeddingByHash, logSummary }; |
| } catch (err) { |
| const reason = |
| err instanceof Error && typeof err.name === 'string' && err.name !== 'Error' |
| ? err.name |
| : 'other'; |
| const msg = err instanceof Error ? err.message : String(err); |
| warn( |
| `[digest] dedup embed path failed, falling back to Jaccard reason=${reason} msg=${msg}`, |
| ); |
| return { reps: jaccard(stories), embeddingByHash: new Map(), logSummary: '' }; |
| } |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function groupTopicsPostDedup(top, cfg, embeddingByHash, deps = {}) { |
| if (!cfg.topicGroupingEnabled || !Array.isArray(top) || top.length <= 1) { |
| return { reps: Array.isArray(top) ? top : [], topicCount: Array.isArray(top) ? top.length : 0, error: null }; |
| } |
|
|
| const clusterFn = deps.clusterFn ?? singleLinkCluster; |
|
|
| try { |
| const items = top.map((rep) => ({ |
| title: rep.title, |
| embedding: embeddingByHash?.get(rep.hash), |
| })); |
|
|
| if (items.some((it) => !Array.isArray(it.embedding))) { |
| return { |
| reps: top, |
| topicCount: top.length, |
| error: new Error('topic grouping: missing embedding for at least one rep'), |
| }; |
| } |
|
|
| const { clusters } = clusterFn(items, { |
| cosineThreshold: cfg.topicThreshold, |
| |
| |
| |
| vetoFn: null, |
| }); |
|
|
| |
| |
| |
| |
| const topicOf = new Array(top.length).fill(-1); |
| clusters.forEach((members, tIdx) => { |
| for (const i of members) topicOf[i] = tIdx; |
| }); |
| for (let i = 0; i < topicOf.length; i++) { |
| if (topicOf[i] === -1) { |
| throw new Error(`topic grouping: clusterFn missed index ${i}`); |
| } |
| } |
|
|
| const hashOf = top.map((rep) => |
| titleHashHex(normalizeForEmbedding(rep.title ?? '')), |
| ); |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| const topicSize = new Array(clusters.length).fill(0); |
| const topicMax = new Array(clusters.length).fill(-Infinity); |
| const topicTieHash = new Array(clusters.length).fill(null); |
| top.forEach((rep, i) => { |
| const t = topicOf[i]; |
| topicSize[t] += 1; |
| const s = Number(rep.currentScore ?? 0); |
| if (s > topicMax[t]) topicMax[t] = s; |
| if (topicTieHash[t] === null || hashOf[i] < topicTieHash[t]) { |
| topicTieHash[t] = hashOf[i]; |
| } |
| }); |
|
|
| |
| const membersOf = Array.from({ length: clusters.length }, () => []); |
| for (let i = 0; i < top.length; i++) { |
| membersOf[topicOf[i]].push(i); |
| } |
|
|
| |
| |
| for (const members of membersOf) { |
| members.sort((a, b) => { |
| const sA = Number(top[a].currentScore ?? 0); |
| const sB = Number(top[b].currentScore ?? 0); |
| if (sA !== sB) return sB - sA; |
| return hashOf[a] < hashOf[b] ? -1 : hashOf[a] > hashOf[b] ? 1 : 0; |
| }); |
| } |
|
|
| |
| |
| |
| |
| const topicOrder = [...Array(clusters.length).keys()].sort((a, b) => { |
| if (topicSize[a] !== topicSize[b]) return topicSize[b] - topicSize[a]; |
| if (topicMax[a] !== topicMax[b]) return topicMax[b] - topicMax[a]; |
| return topicTieHash[a] < topicTieHash[b] ? -1 : topicTieHash[a] > topicTieHash[b] ? 1 : 0; |
| }); |
|
|
| |
| |
| |
| |
| |
| const order = []; |
| for (const t of topicOrder) { |
| const topicId = topicTieHash[t] ?? String(t); |
| for (const i of membersOf[t]) { |
| top[i].briefTopicId = topicId; |
| top[i].briefTopicSize = topicSize[t]; |
| top[i].briefTopicMaxScore = topicMax[t]; |
| order.push(i); |
| } |
| } |
|
|
| return { |
| reps: order.map((i) => top[i]), |
| topicCount: clusters.length, |
| error: null, |
| }; |
| } catch (err) { |
| return { |
| reps: top, |
| topicCount: top.length, |
| error: err instanceof Error ? err : new Error(String(err)), |
| }; |
| } |
| } |
|
|