Files
livecheck/realtime-factcheck/src/background/service-worker-ex.js
T
2026-06-25 18:11:18 -05:00

722 lines
31 KiB
JavaScript

// service-worker.js
let ANTHROPIC_KEY = '';
const SERPER_KEY = '';
let TRANSCRIPT_LANGUAGE = 'en';
async function loadKeys() {
return new Promise(resolve => {
chrome.storage.local.get(['anthropicKey', 'transcriptLanguage'], (data) => {
ANTHROPIC_KEY = data.anthropicKey || '';
TRANSCRIPT_LANGUAGE = data.transcriptLanguage || 'en';
resolve();
});
});
}
const EVALUATE_PROMPT = ``;
const GROUNDED_PROMPT = ``;
const SPEAKER_PARSE_NOISE = new Set(['debate','presidential','vp','vice','2024','2023','2022','2021','2020','2019','2016','surrounded','tonight','live','full','official']);
function parseSpeakersFromTitle(title) {
if (!title) return [];
const clean = title.split('|')[0].trim();
// 'N role vs N role' — e.g. '1 Liberal vs 20 Conservatives'
const roleMatch = clean.match(/(\d+)\s+([a-z]+(?:\s+[a-z]+)?)\s+(?:vs?\.?|versus)\s+(\d+)\s+([a-z]+(?:\s+[a-z]+)?)/i);
if (roleMatch) {
const cap = s => s.charAt(0).toUpperCase() + s.slice(1);
return [cap(roleMatch[2]), cap(roleMatch[4])];
}
// 'Name vs N Description' — second side starts with digit, e.g. 'Dean Withers vs 20 MAGA Women'
const nameVsGroupMatch = clean.match(/([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)\s+(?:vs?\.?|versus)\s+(\d+)\s+(.+)/i);
if (nameVsGroupMatch) {
const name = nameVsGroupMatch[1].trim().split(' ').pop();
const groupWords = nameVsGroupMatch[3].trim().split(/\s+/);
const group = groupWords.filter(w => !SPEAKER_PARSE_NOISE.has(w.toLowerCase())).pop() || groupWords.pop();
return [name, group];
}
// split on vs/and — take last non-noise capitalized word from each side
const vsSplit = clean.split(/\s+(?:vs?\.?|versus|and|&)\s+/i);
if (vsSplit.length >= 2) {
const lastName = part => {
const words = part.trim().split(/\s+/);
for (let i = words.length - 1; i >= 0; i--) {
if (/^[A-Z]/.test(words[i]) && !SPEAKER_PARSE_NOISE.has(words[i].toLowerCase())) return words[i];
}
return null;
};
const a = lastName(vsSplit[0]);
const b = lastName(vsSplit[1]);
if (a && b) return [a, b];
}
return [];
}
const BLOCKED_DOMAINS = [
'reddit.com', 'facebook.com', 'twitter.com', 'x.com',
'tiktok.com', 'instagram.com', 'pinterest.com', 'quora.com',
'yelp.com', 'tripadvisor.com', 'youtube.com',
'democrats.org', 'republicans.org', 'gop.com', 'dnc.org',
'afscme.org', 'ntu.org', 'americanprogress.org', 'heritage.org',
'breitbart.com', 'dailykos.com', 'mediamatters.org', 'newsmax.com',
'thefederalist.com', 'motherjones.com', 'nationalreview.com',
'democrats-appropriations.house.gov', 'waysandmeans.house.gov',
'bostonkravmaga.com',
'israelpolicyforum.org',
];
const LANGUAGE_LOCALE = {
en: { gl: 'us', hl: 'en' },
es: { gl: 'es', hl: 'es' },
fr: { gl: 'fr', hl: 'fr' },
de: { gl: 'de', hl: 'de' },
it: { gl: 'it', hl: 'it' },
pt: { gl: 'br', hl: 'pt' },
nl: { gl: 'nl', hl: 'nl' },
hi: { gl: 'in', hl: 'hi' },
ja: { gl: 'jp', hl: 'ja' },
zh: { gl: 'cn', hl: 'zh-cn' },
ar: { gl: 'sa', hl: 'ar' },
ko: { gl: 'kr', hl: 'ko' },
ru: { gl: 'ru', hl: 'ru' },
pl: { gl: 'pl', hl: 'pl' },
sv: { gl: 'se', hl: 'sv' },
tr: { gl: 'tr', hl: 'tr' },
};
async function searchWeb(query, retries = 2) {
try {
const res = await fetch('https://google.serper.dev/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-KEY': SERPER_KEY },
body: JSON.stringify({ q: query, num: 6, ...(LANGUAGE_LOCALE[TRANSCRIPT_LANGUAGE] || LANGUAGE_LOCALE.en) }),
});
const data = await res.json();
const organic = (data.organic ?? [])
.filter(r => r.link && !BLOCKED_DOMAINS.some(d => r.link.includes(d)))
.slice(0, 3)
.map(r => ({ url: r.link, title: r.title || '', snippet: r.snippet || '', date: r.date || '' }));
// answerBox — Google's direct factual answer, highest quality signal
const answerBox = data.answerBox
? {
answer: data.answerBox.answer || data.answerBox.snippet || '',
title: data.answerBox.title || '',
url: data.answerBox.link || '',
}
: null;
// knowledgeGraph — structured entity data
const knowledgeGraph = data.knowledgeGraph
? {
description: data.knowledgeGraph.description || '',
title: data.knowledgeGraph.title || '',
}
: null;
return { organic, answerBox, knowledgeGraph };
} catch (err) {
if (retries > 0) {
await new Promise(r => setTimeout(r, 500));
return searchWeb(query, retries - 1);
}
console.error('[serper] error:', err);
return { organic: [], answerBox: null, knowledgeGraph: null };
}
}
// ── Claude ────────────────────────────────────────────────────────────────────
async function callClaude(userMessage, systemPrompt) {
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': ANTHROPIC_KEY,
'anthropic-version': '2023-06-01',
'anthropic-dangerous-direct-browser-access': 'true',
},
body: JSON.stringify({
model: 'claude-haiku-4-5-20251001',
max_tokens: 768,
temperature: 0,
system: systemPrompt,
messages: [{ role: 'user', content: userMessage }],
}),
});
const data = await res.json();
if (data.error) {
const msg = data.error.message || 'Unknown API error';
console.error('[claude] API error:', msg);
if (activeTabId) chrome.tabs.sendMessage(activeTabId, { type: 'PIPELINE_ERROR', message: msg }).catch(() => {});
return '';
}
const raw = data.content?.[0]?.text?.trim() || '';
return raw.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
}
function parseArray(str) {
const start = str.indexOf('[');
const end = str.lastIndexOf(']');
if (start === -1 || end === -1) return [];
try { return JSON.parse(str.slice(start, end + 1)); }
catch { return []; }
}
// ── Lexical features ──────────────────────────────────────────────────────────
const HEDGING_WORDS = ['think','believe','maybe','perhaps','probably','might','could','seem','appears','guess','suppose','somewhat'];
const CERTAINTY_WORDS = ['definitely','certainly','absolutely','always','never','clearly','obviously','undoubtedly','exactly','proven'];
const FILLER_WORDS = ['um','uh','like','basically','actually','literally','right','okay'];
const EMOTIONAL_WORDS = ['disaster','terrible','horrible','amazing','incredible','great','awful','fantastic','disgusting','wonderful','worst','best'];
const EXCLUSIVE_WORDS = ['but','except','however','although','unless','without','exclude'];
const FP_SINGULAR = ['i','me','my','mine','myself'];
function extractLexical(text) {
const words = text.toLowerCase().split(/\s+/).filter(Boolean);
const total = words.length || 1;
const rate = (list) => Math.round(words.filter(w => list.some(h => w.includes(h))).length / total * 100);
return {
rates: {
hedging: rate(HEDGING_WORDS),
certainty: rate(CERTAINTY_WORDS),
filler: rate(FILLER_WORDS),
emotional: rate(EMOTIONAL_WORDS),
exclusive: rate(EXCLUSIVE_WORDS),
firstPersonSg: Math.round(words.filter(w => FP_SINGULAR.includes(w)).length / total * 100),
},
wordsPerSecond: null,
wordCount: total,
};
}
function buildLexicalSummary(f) {
const r = f.rates || f;
const notes = [];
if (r.hedging > 5) notes.push(`hedging language (${r.hedging}%)`);
if (r.certainty > 5) notes.push(`certainty markers (${r.certainty}%)`);
if (r.filler > 5) notes.push(`filler words (${r.filler}%)`);
if (r.emotional > 5) notes.push(`emotional language (${r.emotional}%)`);
if (r.exclusive > 5) notes.push(`qualifying words (${r.exclusive}%)`);
if (r.firstPersonSg > 5) notes.push(`first-person singular (${r.firstPersonSg}%)`);
if (f.wordsPerSecond) {
const pace = f.wordsPerSecond > 3.5 ? 'fast' : f.wordsPerSecond < 2 ? 'slow' : 'moderate';
notes.push(`speech rate ${f.wordsPerSecond} w/s (${pace})`);
}
return notes.length ? `Features detected: ${notes.join(', ')}.` : 'Neutral delivery.';
}
// ── Claim deduplication ───────────────────────────────────────────────────────
const recentClaims = new Map(); // key → [timestamp, originalClaim]
const CLAIM_DEDUP_MS = 200000;
function normalizeClaimKey(claim) {
return claim.toLowerCase()
.replace(/[^a-z0-9\s]/g, '')
.split(/\s+/)
.filter(w => w.length >= 4)
.sort()
.join(' ');
}
function isDuplicate(claim) {
const key = normalizeClaimKey(claim);
const now = Date.now();
for (const [k, v] of recentClaims) {
const t = Array.isArray(v) ? v[0] : v;
if (now - t > CLAIM_DEDUP_MS) recentClaims.delete(k);
}
if (recentClaims.has(key)) return true;
const keyWords = new Set(key.split(' ').filter(Boolean));
const figures = (claim.match(/\$[\d,.]+(?:\s*(?:trillion|billion|million|thousand))?/gi) || [])
.map(d => d.replace(/[,\s]/g, '').toLowerCase());
for (const [k, v] of recentClaims) {
const kWords = k.split(' ').filter(Boolean);
if (kWords.filter(w => keyWords.has(w)).length / Math.max(keyWords.size, kWords.length) >= 0.35) return true;
if (figures.length) {
const origClaim = Array.isArray(v) ? v[1] : '';
if (origClaim) {
const origFigures = (origClaim.match(/\$[\d,.]+(?:\s*(?:trillion|billion|million|thousand))?/gi) || [])
.map(d => d.replace(/[,\s]/g, '').toLowerCase());
if (figures.some(f => origFigures.includes(f))) return true;
}
}
}
recentClaims.set(key, [now, claim]);
return false;
}
// ── Rolling window ────────────────────────────────────────────────────────────
const WINDOW_SIZE = 4;
const WINDOW_KEEP = 15;
// Each entry: { text, speakerId, speakerName }
let sentenceWindow = [];
let sentenceCount = 0;
let windowLexical = { rates: { hedging: 0, certainty: 0, filler: 0, emotional: 0, exclusive: 0, firstPersonSg: 0 }, wordsPerSecond: null, wordCount: 0, _sentenceCount: 0 };
let windowStartTime = null;
let pageTitle = '';
let pageDate = '';
let currentSpeakerId = null;
let speakerIdToName = {}; // confirmed: { 0: 'Harris', 1: 'Trump' }
let confirmedSpeakers = new Set(); // IDs that have been confirmed by user
function resetWindow() {
sentenceWindow = [];
sentenceCount = 0;
windowLexical = { rates: { hedging: 0, certainty: 0, filler: 0, emotional: 0, exclusive: 0, firstPersonSg: 0 }, wordsPerSecond: null, wordCount: 0, _sentenceCount: 0 };
windowStartTime = null;
currentSpeakerId = null;
lastSpeakerId = null;
speakerIdToName = {};
confirmedSpeakers = new Set();
}
async function onNewSentence(text, speakerId) {
// flush window early on speaker change (mid-window turn transition)
if (lastSpeakerId !== null &&
speakerId !== null &&
speakerId !== undefined &&
speakerId !== lastSpeakerId &&
sentenceCount % WINDOW_SIZE !== 0 &&
sentenceWindow.length >= 2) {
// fire evaluation for the previous speaker's sentences before processing this one
const flushText = sentenceWindow.map(s => s.text).join(' ');
const flushCounts = {};
sentenceWindow.slice(-WINDOW_SIZE).forEach(s => {
if (s.speakerId !== null && s.speakerId !== undefined)
flushCounts[s.speakerId] = (flushCounts[s.speakerId] || 0) + 1;
});
const flushDominantId = Object.keys(flushCounts).length
? Object.entries(flushCounts).sort((a,b) => b[1]-a[1])[0][0]
: null;
const flushDominantSpeaker = flushDominantId !== null ? (speakerIdToName[flushDominantId] || null) : null;
const flushLexSnapshot = JSON.parse(JSON.stringify(windowLexical));
const fsc = flushLexSnapshot._sentenceCount || 1;
const flr = flushLexSnapshot.rates;
flr.hedging = Math.round(flr.hedging / fsc);
flr.certainty = Math.round(flr.certainty / fsc);
flr.filler = Math.round(flr.filler / fsc);
flr.emotional = Math.round(flr.emotional / fsc);
flr.exclusive = Math.round(flr.exclusive / fsc);
flr.firstPersonSg = Math.round(flr.firstPersonSg / fsc);
const flushLexSummary = buildLexicalSummary(flushLexSnapshot);
windowLexical = { rates: { hedging: 0, certainty: 0, filler: 0, emotional: 0, exclusive: 0, firstPersonSg: 0 }, wordsPerSecond: null, wordCount: 0, _sentenceCount: 0 };
windowStartTime = null;
await evaluateClaims(flushText, pageTitle, flushLexSummary, flushLexSnapshot, flushDominantSpeaker, flushDominantId);
}
lastSpeakerId = speakerId;
// label with confirmed name if available, else Speaker N for Claude to infer
const confirmedName = (speakerId !== null && speakerId !== undefined) ? speakerIdToName[speakerId] : null;
const label = confirmedName ? `[${confirmedName}]` : (speakerId !== null && speakerId !== undefined ? `[Speaker ${speakerId}]` : null);
const labeledText = label ? `${label} ${text}` : text;
sentenceWindow.push({ text: labeledText, speakerId, speakerName: confirmedName });
if (sentenceWindow.length > WINDOW_KEEP) sentenceWindow.shift();
sentenceCount++;
if (!windowStartTime) windowStartTime = Date.now();
// accumulate lexical — running sum, divide by sentence count at snapshot time
const f = extractLexical(text);
const r = f.rates, wr = windowLexical.rates;
wr.hedging += r.hedging;
wr.certainty += r.certainty;
wr.filler += r.filler;
wr.emotional += r.emotional;
wr.exclusive += r.exclusive;
wr.firstPersonSg += r.firstPersonSg;
windowLexical.wordCount += f.wordCount;
windowLexical._sentenceCount = (windowLexical._sentenceCount || 0) + 1;
if (sentenceCount % WINDOW_SIZE === 0) {
const contextText = sentenceWindow.map(s => s.text).join(' ');
// dominant speaker ID = whoever appears most in this window
// count only the CURRENT window's sentences (last WINDOW_SIZE), not full rolling buffer
const currentWindowSentences = sentenceWindow.slice(-WINDOW_SIZE);
const counts = {};
currentWindowSentences.forEach(s => {
if (s.speakerId !== null && s.speakerId !== undefined) {
counts[s.speakerId] = (counts[s.speakerId] || 0) + 1;
}
});
const dominantSpeakerId = Object.keys(counts).length
? Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0]
: null;
// use confirmed name from speakerIdToName — ground truth from Deepgram + user confirmation
const dominantSpeaker = dominantSpeakerId !== null
? (speakerIdToName[dominantSpeakerId] || null)
: null;
// speech rate
const elapsed = windowStartTime ? (Date.now() - windowStartTime) / 1000 : null;
if (elapsed && elapsed > 0) windowLexical.wordsPerSecond = Math.round(windowLexical.wordCount / elapsed * 10) / 10;
windowStartTime = null;
const lexicalSnapshot = JSON.parse(JSON.stringify(windowLexical));
// average the accumulated sums now that we have the full window
const sc = lexicalSnapshot._sentenceCount || 1;
const lr = lexicalSnapshot.rates;
lr.hedging = Math.round(lr.hedging / sc);
lr.certainty = Math.round(lr.certainty / sc);
lr.filler = Math.round(lr.filler / sc);
lr.emotional = Math.round(lr.emotional / sc);
lr.exclusive = Math.round(lr.exclusive / sc);
lr.firstPersonSg = Math.round(lr.firstPersonSg / sc);
const lexicalSummary = buildLexicalSummary(lexicalSnapshot);
// reset for next window
windowLexical = { rates: { hedging: 0, certainty: 0, filler: 0, emotional: 0, exclusive: 0, firstPersonSg: 0 }, wordsPerSecond: null, wordCount: 0, _sentenceCount: 0 };
windowStartTime = null;
try {
await evaluateClaims(contextText, pageTitle, lexicalSummary, lexicalSnapshot, dominantSpeaker, dominantSpeakerId);
} catch (e) {
}
}
}
// ── Evaluation pipeline ───────────────────────────────────────────────────────
async function evaluateClaims(contextText, title, lexicalSummary, lexicalSnapshot, dominantSpeaker, dominantSpeakerId) {
try {
const dateContext = pageDate ? `\nDate: ${pageDate}` : '';
// build speaker legend from title names for Claude
const titleNames = parseSpeakersFromTitle(title || '');
const nameList = titleNames.join(' and ');
const speakerLegend = titleNames.length
? `\nDebate participants: ${nameList}.` +
`\nSpeaker attribution rules:` +
`\n- [Speaker N] labels indicate turn order only — do NOT map Speaker 0 to the first name listed.` +
`\n- Identify speakers using: (1) first-person language — when someone says "I", "my plan", "I intend to", they ARE the speaker — attribute the claim to the known participant whose policies match; (2) policy content — match stated positions to each participant's known platform; (3) cross-references — participants typically refer to each other by name.` +
`\n- Use your knowledge of each named participant's background, policies, and public record to attribute correctly.` +
`\n- If a moderator or third party is speaking, attribute to them if identifiable, otherwise use "Unknown".` +
`\n- NEVER output "Speaker N" or any [Speaker N] format in any field.`
: `\nIdentify speakers using first-person language, policy content, and speech patterns. Never output "Speaker N".`;
const languageInstruction = TRANSCRIPT_LANGUAGE && TRANSCRIPT_LANGUAGE !== 'en'
? `\nLANGUAGE REQUIREMENT: You MUST write the "claim" and "explanation" fields in ${TRANSCRIPT_LANGUAGE}. This is mandatory regardless of what language your sources are in. Only the verdict values (TRUE, FALSE, etc) stay in English.`
: '';
const titleContext = title
? `Video: "${title}"${dateContext}${speakerLegend}\n\nEvaluate claims as they were made at the time of this recording. Do not apply knowledge of events after this date.${languageInstruction}\n\n`
: languageInstruction ? `${languageInstruction}\n\n` : '';
const lexicalContext = lexicalSummary ? `\n\nLexical analysis: ${lexicalSummary}` : '';
// already-checked claims list for Claude
const checkedList = [...recentClaims.values()]
.filter(v => Array.isArray(v) && v[1])
.map(v => v[1])
.slice(-15)
.join('\n- ');
const alreadyChecked = checkedList
? `\n\nClaims already fact-checked this session — do NOT re-evaluate these or close variants:\n- ${checkedList}\n`
: '';
// fast Claude call — Serper searches fire immediately after on the returned claims
const raw = await callClaude(
`${titleContext}Transcript: "${contextText}"${alreadyChecked}${lexicalContext}`,
EVALUATE_PROMPT
);
const results = parseArray(raw);
const valid = results.filter(r => r.claim && r.verdict && r.verdict !== 'UNVERIFIABLE' && !isDuplicate(r.claim));
if (!valid.length) return;
// kick off per-claim Serper searches in parallel with sending fast cards to overlay
const claimSearchPromises = valid.map(r => searchWeb(r.claim));
if (activeTabId) {
chrome.tabs.sendMessage(activeTabId, {
type: 'NEW_VERDICT',
results: valid.map(r => ({
...r,
sources: [],
pending: true,
lexical: lexicalSnapshot,
dominantSpeakerId,
speaker: dominantSpeaker || (r.speaker && !r.speaker.match(/^Speaker\s*\d+$/i) ? r.speaker : null),
})),
}).catch(() => {});
console.log('[pipeline] fast verdicts sent:', valid.length, '| speaker:', dominantSpeaker);
}
groundAndUpdate(contextText, valid, title, lexicalSummary, lexicalSnapshot, dominantSpeaker, dominantSpeakerId, claimSearchPromises);
} catch (err) {
console.error('[pipeline] error:', err);
}
}
async function groundAndUpdate(contextText, fastResults, title, lexicalSummary, lexicalSnapshot, dominantSpeaker, dominantSpeakerId, claimSearchPromises = null) {
try {
const dateCtx = pageDate ? `\nDate: ${pageDate}` : '';
const languageInstruction = TRANSCRIPT_LANGUAGE && TRANSCRIPT_LANGUAGE !== 'en'
? `\nLANGUAGE REQUIREMENT: You MUST write the "claim" and "explanation" fields in ${TRANSCRIPT_LANGUAGE}. This is mandatory regardless of what language your sources are in. Only the verdict values (TRUE, FALSE, etc) stay in English.`
: '';
const titleContext = title
? `Video: "${title}"${dateCtx}\nEvaluate claims as they were made at the time of this recording. Web search results may include articles published after the debate date — ignore any information that was not publicly known at the time of the debate.${languageInstruction}\n\n`
: languageInstruction ? `${languageInstruction}\n\n` : '';
const lexicalContext = lexicalSummary ? `\n\nLexical analysis: ${lexicalSummary}` : '';
const groundedAll = await Promise.all(fastResults.map(async (fastResult, i) => {
try {
const searchData = claimSearchPromises
? await claimSearchPromises[i]
: await searchWeb(fastResult.claim);
if (!searchData.organic?.length && !searchData.answerBox && !searchData.knowledgeGraph) {
// no search results — finalize with fast verdict so card doesn't hang as pending
const resolvedSpeaker = dominantSpeaker || (fastResult.speaker && !fastResult.speaker.match(/^Speaker\s*\d+$/i) ? fastResult.speaker : null);
return { ...fastResult, sources: [], pending: false, lexical: lexicalSnapshot, speaker: resolvedSpeaker, dominantSpeakerId, _fastClaim: fastResult.claim };
}
const urls = searchData.organic.map(r => r.url);
// build evidence block — answerBox first (highest quality), then knowledgeGraph, then organic
const parts = [];
if (searchData.answerBox?.answer) {
parts.push(`[Direct Answer] ${searchData.answerBox.title ? searchData.answerBox.title + ': ' : ''}${searchData.answerBox.answer}${searchData.answerBox.url ? '\n' + searchData.answerBox.url : ''}`);
}
if (searchData.knowledgeGraph?.description) {
parts.push(`[Knowledge Panel] ${searchData.knowledgeGraph.title ? searchData.knowledgeGraph.title + ': ' : ''}${searchData.knowledgeGraph.description}`);
}
searchData.organic.forEach((r, idx) => {
const datePart = r.date ? ` (${r.date})` : '';
parts.push(`[${idx+1}] ${r.title}${datePart}\n${r.url}\n${r.snippet}`);
});
const evidenceBlock = parts.join('\n\n');
const raw = await callClaude(
`${titleContext}Transcript: "${contextText}"\n\nClaim: "${fastResult.claim}"\nFast verdict: ${fastResult.verdict}\n\nWeb search evidence:\n${evidenceBlock}${lexicalContext}`,
GROUNDED_PROMPT
);
const parsed = parseArray(raw);
const match = parsed.find(r => r.claim && r.verdict);
// drop UNVERIFIABLE from grounded pass — either it's checkable or it isn't shown
if (!match || match.verdict === 'UNVERIFIABLE') return null;
const resolvedSpeaker = dominantSpeaker
|| (fastResult.speaker && !fastResult.speaker.match(/^Speaker\s*\d+$/i) ? fastResult.speaker : null)
|| (match.speaker && !match.speaker.match(/^Speaker\s*\d+$/i) ? match.speaker : null);
// code-level protection: never downgrade TRUE/SUBSTANTIALLY TRUE to MISLEADING or FALSE
// the grounded prompt repeatedly violates this rule by reasoning from snippets
// fast pass has full training knowledge; grounded pass has 1-2 sentence snippets
// only the grounded pass can upgrade verdicts or add SUBSTANTIALLY TRUE context
const fastWasTrue = fastResult.verdict === 'TRUE' || fastResult.verdict === 'SUBSTANTIALLY TRUE';
const groundedDowngrades = match.verdict === 'MISLEADING' || match.verdict === 'FALSE';
const finalVerdict = (fastWasTrue && groundedDowngrades) ? fastResult.verdict : match.verdict;
return { ...match, verdict: finalVerdict, sources: urls, pending: false, lexical: lexicalSnapshot, speaker: resolvedSpeaker, dominantSpeakerId, _fastClaim: fastResult.claim };
} catch (err) {
console.error('[grounded] error:', fastResult.claim.slice(0, 40), err);
return null;
}
}));
const valid = groundedAll.filter(Boolean);
if (valid.length && activeTabId) {
chrome.tabs.sendMessage(activeTabId, { type: 'UPDATE_VERDICTS', results: valid }).catch(() => {});
console.log('[pipeline] grounded verdicts sent:', valid.length);
}
} catch (err) {
console.error('[grounded] error:', err);
}
}
// ── State ─────────────────────────────────────────────────────────────────────
let activeTabId = null;
let isCapturing = false;
let keepAliveInterval = null;
function startKeepAlive() {
keepAliveInterval = setInterval(() => chrome.runtime.getPlatformInfo(() => {}), 20000);
}
function stopKeepAlive() {
clearInterval(keepAliveInterval);
keepAliveInterval = null;
}
// ── Messages ──────────────────────────────────────────────────────────────────
chrome.runtime.onConnect.addListener(() => console.log('[service-worker] woken by port connect'));
// notify overlay if service worker was killed and restarted mid-session
chrome.runtime.onStartup.addListener(() => {
isCapturing = false;
activeTabId = null;
});
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
switch (msg.type) {
case 'START_FACTCHECK':
startFactCheck()
.then(() => sendResponse({ ok: true }))
.catch(err => sendResponse({ ok: false, error: err.message }));
return true;
case 'STOP_FACTCHECK':
stopFactCheck();
sendResponse({ ok: true });
break;
case 'TRANSCRIPT_RESULT':
// always process transcript for pipeline — activeTabId only needed for forwarding to overlay
if (msg.isFinal) {
if (msg.speaker !== null && msg.speaker !== undefined) {
currentSpeakerId = msg.speaker;
if (activeTabId && !confirmedSpeakers.has(currentSpeakerId) && !speakerIdToName[currentSpeakerId]) {
chrome.tabs.sendMessage(activeTabId, {
type: 'NEW_SPEAKER',
speakerId: currentSpeakerId,
sample: msg.text.slice(0, 80),
}).catch(() => {});
}
}
onNewSentence(msg.text, currentSpeakerId);
}
if (activeTabId) {
chrome.tabs.sendMessage(activeTabId, {
type: 'TRANSCRIPT_RESULT', text: msg.text, isFinal: msg.isFinal, interim: msg.interim,
}).catch(() => {});
}
break;
case 'SPEAKER_NAMES':
// merge incoming confirmed entries — never overwrite already-confirmed IDs
if (msg.speakerIdToName) {
Object.entries(msg.speakerIdToName).forEach(([id, name]) => {
const numId = parseInt(id);
if (!confirmedSpeakers.has(numId)) {
speakerIdToName[numId] = name;
confirmedSpeakers.add(numId);
}
});
console.log('[service-worker] speaker map updated:', speakerIdToName);
}
break;
case 'PAGE_TITLE':
pageTitle = msg.title || '';
pageDate = msg.date || '';
console.log('[service-worker] page title:', pageTitle.slice(0, 60));
console.log('[service-worker] page date:', pageDate);
// speaker names passed to Claude as context — Claude resolves attribution
break;
case 'PIPELINE_ERROR':
// forward from offscreen doc to overlay
if (activeTabId) {
chrome.tabs.sendMessage(activeTabId, { type: 'PIPELINE_ERROR', message: msg.message }).catch(() => {});
}
break;
case 'REQUEST_NEW_STREAM':
// offscreen doc lost its stream — get a fresh tabCapture stream ID
if (activeTabId && isCapturing) {
chrome.tabCapture.getMediaStreamId({ targetTabId: activeTabId }, (streamId) => {
if (chrome.runtime.lastError) {
console.error('[service-worker] failed to get new stream:', chrome.runtime.lastError.message);
return;
}
chrome.runtime.sendMessage({ type: 'START_CAPTURE', streamId }).catch(() => {});
});
}
break;
case 'GET_STATUS':
sendResponse({ isCapturing });
break;
}
});
// ── Start / stop ──────────────────────────────────────────────────────────────
async function startFactCheck() {
if (isCapturing) return;
await loadKeys();
if (!ANTHROPIC_KEY) {
throw new Error('Anthropic API key not set. Please enter it in the extension popup.');
}
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab) throw new Error('No active tab found.');
activeTabId = tab.id;
try {
await ensureOffscreenDocument();
console.log('[service-worker] offscreen document created');
} catch (err) {
console.error('[service-worker] offscreen creation failed:', err);
}
const streamId = await new Promise((resolve, reject) => {
chrome.tabCapture.getMediaStreamId({ targetTabId: activeTabId }, id => {
if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message));
else resolve(id);
});
});
const response = await chrome.runtime.sendMessage({ type: 'START_CAPTURE', streamId, language: TRANSCRIPT_LANGUAGE });
if (!response?.ok) throw new Error('Failed to start capture: ' + response?.error);
// reset BEFORE sending START_FACTCHECK — transcripts arrive immediately after
isCapturing = true;
resetWindow();
recentClaims.clear();
startKeepAlive();
await chrome.tabs.sendMessage(activeTabId, { type: 'START_FACTCHECK' });
console.log('[service-worker] started on tab', activeTabId);
}
function stopFactCheck() {
resetWindow();
recentClaims.clear();
pageTitle = '';
pageDate = '';
if (!isCapturing) return;
chrome.runtime.sendMessage({ type: 'STOP_CAPTURE' }).catch(() => {});
chrome.offscreen.closeDocument().catch(() => {});
if (activeTabId) chrome.tabs.sendMessage(activeTabId, { type: 'STOP_FACTCHECK' }).catch(() => {});
activeTabId = null;
isCapturing = false;
stopKeepAlive();
console.log('[service-worker] stopped');
}
async function ensureOffscreenDocument() {
const existing = await chrome.runtime.getContexts({ contextTypes: ['OFFSCREEN_DOCUMENT'] });
if (existing.length > 0) return;
await chrome.offscreen.createDocument({
url: chrome.runtime.getURL('src/offscreen/offscreen.html'),
reasons: ['USER_MEDIA'],
justification: 'Capture tab audio for Deepgram transcription',
});
}