722 lines
31 KiB
JavaScript
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',
|
|
});
|
|
} |