diff --git a/realtime-factcheck/assets/icon128.png b/realtime-factcheck/assets/icon128.png index 76b7d9f..7644f77 100644 Binary files a/realtime-factcheck/assets/icon128.png and b/realtime-factcheck/assets/icon128.png differ diff --git a/realtime-factcheck/assets/icon16.png b/realtime-factcheck/assets/icon16.png index becfa83..b48175e 100644 Binary files a/realtime-factcheck/assets/icon16.png and b/realtime-factcheck/assets/icon16.png differ diff --git a/realtime-factcheck/assets/icon48.png b/realtime-factcheck/assets/icon48.png index 19c7c10..b9887c9 100644 Binary files a/realtime-factcheck/assets/icon48.png and b/realtime-factcheck/assets/icon48.png differ diff --git a/realtime-factcheck/manifest.json b/realtime-factcheck/manifest.json index 7976b75..8fc2d7a 100644 --- a/realtime-factcheck/manifest.json +++ b/realtime-factcheck/manifest.json @@ -1,12 +1,11 @@ { "manifest_version": 3, "name": "InTruth", - "version": "1.1.0", + "version": "1.1.9", "description": "Real-time fact-checking of political speech and debates", "permissions": [ "activeTab", - "scripting", "storage", "tabCapture", "offscreen" diff --git a/realtime-factcheck/src/background/service-worker-ex.js b/realtime-factcheck/src/background/service-worker-ex.js index c0b2585..8002ed4 100644 --- a/realtime-factcheck/src/background/service-worker-ex.js +++ b/realtime-factcheck/src/background/service-worker-ex.js @@ -1,17 +1,13 @@ -// service-worker.js --> now combined with fact-checker.js and claim-detector.js for import conflicts -// transcription runs in content script via web speech API; -// responsibile for starting / stopping content script, receiving transcripts, -// and routing them to claim detection -// 5.31.2026 -- serper call before claude call for more accurate verdicts -// 6.12.2026 -- switch to deepgram; too many conflicts w/ webaudio - +// 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'], (data) => { + chrome.storage.local.get(['anthropicKey', 'transcriptLanguage'], (data) => { ANTHROPIC_KEY = data.anthropicKey || ''; + TRANSCRIPT_LANGUAGE = data.transcriptLanguage || 'en'; resolve(); }); }); @@ -19,25 +15,51 @@ async function loadKeys() { const EVALUATE_PROMPT = ``; -// ── Speaker parsing (mirrors overlay.js) ───────────────────────────────────── +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 roleMatch = title.match(/(\d+)\s+([a-z]+(?:\s+[a-z]+)?)\s+(?:vs?\.?|versus)\s+(\d+)\s+([a-z]+(?:\s+[a-z]+)?)/i); + 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])]; } - const nameMatch = title.match(/([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)\s+(?:and|vs\.?|versus|&)\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)/); - if (nameMatch) { - const clean = name => name.trim().split(' ').pop(); - return [clean(nameMatch[1]), clean(nameMatch[2])]; + + // '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 []; } - -// ── Serper ──────────────────────────────────────────────────────────────────── - + + const BLOCKED_DOMAINS = [ 'reddit.com', 'facebook.com', 'twitter.com', 'x.com', 'tiktok.com', 'instagram.com', 'pinterest.com', 'quora.com', @@ -48,32 +70,73 @@ const BLOCKED_DOMAINS = [ '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 }), + body: JSON.stringify({ q: query, num: 6, ...(LANGUAGE_LOCALE[TRANSCRIPT_LANGUAGE] || LANGUAGE_LOCALE.en) }), }); const data = await res.json(); - return (data.organic ?? []) - .map(r => r.link) - .filter(url => url && !BLOCKED_DOMAINS.some(d => url.includes(d))) - .slice(0, 3); + + 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 []; + return { organic: [], answerBox: null, knowledgeGraph: null }; } } + // ── Claude ──────────────────────────────────────────────────────────────────── - + async function callClaude(userMessage, systemPrompt) { const res = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', @@ -101,7 +164,7 @@ async function callClaude(userMessage, systemPrompt) { 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(']'); @@ -109,16 +172,16 @@ function parseArray(str) { 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; @@ -136,28 +199,28 @@ function extractLexical(text) { wordCount: total, }; } - + function buildLexicalSummary(f) { const r = f.rates || f; const notes = []; - if (r.hedging > 8) notes.push(`hedging language (${r.hedging}%)`); - if (r.certainty > 8) notes.push(`certainty markers (${r.certainty}%)`); - if (r.filler > 8) notes.push(`filler words (${r.filler}%)`); - if (r.emotional > 8) notes.push(`emotional language (${r.emotional}%)`); - if (r.exclusive > 8) notes.push(`qualifying words (${r.exclusive}%)`); - if (r.firstPersonSg > 8) notes.push(`first-person singular (${r.firstPersonSg}%)`); + 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, '') @@ -166,22 +229,22 @@ function normalizeClaimKey(claim) { .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; @@ -194,38 +257,38 @@ function isDuplicate(claim) { } } } - + 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 }; +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 }; + 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 && @@ -246,38 +309,47 @@ async function onNewSentence(text, speakerId) { : 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 }; + 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 + + // accumulate lexical — running sum, divide by sentence count at snapshot time const f = extractLexical(text); const r = f.rates, wr = windowLexical.rates; - wr.hedging = Math.round((wr.hedging + r.hedging) / 2); - wr.certainty = Math.round((wr.certainty + r.certainty) / 2); - wr.filler = Math.round((wr.filler + r.filler) / 2); - wr.emotional = Math.round((wr.emotional + r.emotional) / 2); - wr.exclusive = Math.round((wr.exclusive + r.exclusive) / 2); - wr.firstPersonSg = Math.round((wr.firstPersonSg + r.firstPersonSg) / 2); + 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); @@ -294,32 +366,41 @@ async function onNewSentence(text, speakerId) { 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 }; + 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 '); @@ -332,12 +413,16 @@ async function evaluateClaims(contextText, title, lexicalSummary, lexicalSnapsho `\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 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.\n\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]) @@ -347,16 +432,20 @@ async function evaluateClaims(contextText, title, lexicalSummary, lexicalSnapsho 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 && !isDuplicate(r.claim)); - + 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', @@ -365,60 +454,85 @@ async function evaluateClaims(contextText, title, lexicalSummary, lexicalSnapsho sources: [], pending: true, lexical: lexicalSnapshot, - dominantSpeakerId, // raw Deepgram ID — overlay resolves to name at render time + 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); - + + 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) { + +async function groundAndUpdate(contextText, fastResults, title, lexicalSummary, lexicalSnapshot, dominantSpeaker, dominantSpeakerId, claimSearchPromises = null) { try { const dateCtx = pageDate ? `\nDate: ${pageDate}` : ''; - 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.\n\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}"${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) => { + + const groundedAll = await Promise.all(fastResults.map(async (fastResult, i) => { try { - const urls = await searchWeb(fastResult.claim); - if (!urls.length) return null; + 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\nEvaluate ONLY this specific claim:\n1. ${fastResult.claim}\n\nWeb search results:\n${urls.join('\n')}${lexicalContext}`, - EVALUATE_PROMPT + `${titleContext}Transcript: "${contextText}"\n\nClaim: "${fastResult.claim}"\nFast verdict: ${fastResult.verdict}\n\nWeb search evidence:\n${evidenceBlock}${lexicalContext}`, + GROUNDED_PROMPT ); - const results = parseArray(raw); - const match = results.find(r => r.claim && r.verdict); - if (!match) return null; - // re-resolve speaker at grounding time — user may have confirmed since fast pass - const lateResolved = dominantSpeakerId !== null && dominantSpeakerId !== undefined - ? speakerIdToName[dominantSpeakerId] || null - : null; - const resolvedSpeaker = lateResolved - || dominantSpeaker - || (match.speaker && !match.speaker.match(/^Speaker\s*\d+$/i) ? match.speaker : null) - || (fastResult.speaker && !fastResult.speaker.match(/^Speaker\s*\d+$/i) ? fastResult.speaker : null); - - // never downgrade TRUE to MISLEADING in grounded pass — fast verdict had no sources to nitpick + 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 groundedIsMisleading = match.verdict === 'MISLEADING'; - const finalVerdict = (fastWasTrue && groundedIsMisleading) ? fastResult.verdict : match.verdict; - - return { ...match, verdict: finalVerdict, sources: urls, pending: false, lexical: lexicalSnapshot, speaker: resolvedSpeaker, dominantSpeakerId }; + 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(() => {}); @@ -428,46 +542,46 @@ async function groundAndUpdate(contextText, fastResults, title, lexicalSummary, 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) { @@ -489,7 +603,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { }).catch(() => {}); } break; - + case 'SPEAKER_NAMES': // merge incoming confirmed entries — never overwrite already-confirmed IDs if (msg.speakerIdToName) { @@ -503,7 +617,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { console.log('[service-worker] speaker map updated:', speakerIdToName); } break; - + case 'PAGE_TITLE': pageTitle = msg.title || ''; pageDate = msg.date || ''; @@ -511,14 +625,14 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { 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) { @@ -531,72 +645,72 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { }); } 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 }); + + 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; diff --git a/realtime-factcheck/src/content/overlay.js b/realtime-factcheck/src/content/overlay.js index 2220482..eec1059 100644 --- a/realtime-factcheck/src/content/overlay.js +++ b/realtime-factcheck/src/content/overlay.js @@ -64,19 +64,43 @@ function getSpeakerColor(name) { } // ── Speaker parsing ─────────────────────────────────────────────────────────── +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 roleMatch = title.match(/(\d+)\s+([a-z]+(?:\s+[a-z]+)?)\s+(?:vs?\.?|versus)\s+(\d+)\s+([a-z]+(?:\s+[a-z]+)?)/i); + 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])]; } - // only match capitalized proper names (not lowercase words like "in", "the", etc.) - const nameMatch = title.match(/([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)\s+(?:and|vs\.?|versus|&)\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)/); - if (nameMatch) { - const clean = name => name.trim().split(' ').pop(); - return [clean(nameMatch[1]), clean(nameMatch[2])]; + + // '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 []; } @@ -120,14 +144,15 @@ const confirmedSpeakerMap = {}; // { speakerId: 'Harris' } const pendingSpeakerIds = new Set(); // IDs waiting for confirmation function showSpeakerBanner(speakerId, sample) { - if (pendingSpeakerIds.has(speakerId)) return; - if (speakerId in confirmedSpeakerMap) return; + const sid = String(speakerId); + if (pendingSpeakerIds.has(sid)) return; + if (sid in confirmedSpeakerMap) return; // if speakers not yet parsed from title, retry once after 1s if (!speakers.length) { setTimeout(() => showSpeakerBanner(speakerId, sample), 1000); return; } - pendingSpeakerIds.add(speakerId); + pendingSpeakerIds.add(sid); const banner = document.createElement('div'); banner.className = 'rtfc-speaker-banner'; @@ -136,15 +161,15 @@ function showSpeakerBanner(speakerId, sample) { '
"' + escapeHtml(sample) + '..."
' + '
' + speakers.map(name => - '' + '' ).join('') + - '' + + '' + '
'; banner.querySelectorAll('.rtfc-speaker-banner-btn').forEach(btn => { btn.addEventListener('click', () => { const name = btn.dataset.name; - const id = parseInt(btn.dataset.id); + const id = btn.dataset.id; // already a string — matches confirmedSpeakerMap keys if (name) { confirmedSpeakerMap[id] = name; chrome.runtime.sendMessage({ @@ -168,13 +193,11 @@ function showSpeakerBanner(speakerId, sample) { // ── Speaker confirmation state ─────────────────────────────────────────────── function allSpeakersConfirmed() { - // true when every speaker seen so far has been confirmed or skipped - // and at least one real name has been confirmed - const confirmedNames = Object.values(confirmedSpeakerMap).filter(v => v !== null); - return confirmedNames.length >= Math.min(speakers.length, Object.keys(confirmedSpeakerMap).length) - && Object.keys(confirmedSpeakerMap).length > 0; + // true only when every speaker parsed from the title has been confirmed (or skipped) by the user + // Deepgram assigns IDs 0, 1, 2... in order of first appearance — matches speakers array indices + if (!speakers.length) return false; + return speakers.every((_, i) => String(i) in confirmedSpeakerMap); } - function retryTagAllCards() { // retroactively tag all grounded cards once speakers are confirmed if (!verdictListEl) return; @@ -468,21 +491,15 @@ function buildCard(result) { const lexicalRows = buildLexicalRows(result.lexical); - // speaker tag — only show on grounded cards AND only when all speakers confirmed - // this prevents wrong tags from appearing before diarization stabilizes + // speaker tag — only show when ALL speakers have been confirmed by user + // prevents wrong tags on cards detected before diarization stabilized let speakerTag = ''; - if (!result.pending && allSpeakersConfirmed()) { - const confirmedName = (result.dominantSpeakerId !== null && result.dominantSpeakerId !== undefined) - ? confirmedSpeakerMap[result.dominantSpeakerId] - : undefined; - const rawSpeaker = (confirmedName !== undefined && confirmedName !== null) - ? confirmedName - : result.speaker || null; - const normalizedName = rawSpeaker ? normalizeSpeakerName(rawSpeaker) : null; - const speakerName = (normalizedName && !normalizedName.match(/^Speaker\s*\d+$/i)) ? normalizedName : null; - const speakerColor = speakerName ? getSpeakerColor(speakerName) : null; - if (speakerColor) { - speakerTag = '
' + escapeHtml(speakerName) + '
'; + if (!result.pending && allSpeakersConfirmed() && result.dominantSpeakerId !== null && result.dominantSpeakerId !== undefined) { + const confirmedName = confirmedSpeakerMap[result.dominantSpeakerId]; + if (confirmedName) { + const name = normalizeSpeakerName(confirmedName); + const color = getSpeakerColor(name); + speakerTag = '
' + escapeHtml(name) + '
'; } } @@ -521,7 +538,13 @@ function buildCard(result) { return card; } -function findPendingCard(claim) { +function findPendingCard(claim, fastClaim) { + // try exact match on fast claim first — grounded claim text may differ from fast pass + if (fastClaim) { + const fastKey = fastClaim.toLowerCase().slice(0, 40); + if (pendingCards.has(fastKey)) return pendingCards.get(fastKey); + } + const key = claim.toLowerCase().slice(0, 40); if (pendingCards.has(key)) return pendingCards.get(key); @@ -566,6 +589,9 @@ function addVerdict(result) { verdictListEl.querySelector('.rtfc-empty')?.remove(); applyVerdictToBullet(result.claim, result.verdict, result.confidence); if (!result._timestamp) result._timestamp = getClaimTimestamp(result.claim); + if (result.dominantSpeakerId !== null && result.dominantSpeakerId !== undefined) { + result.dominantSpeakerId = String(result.dominantSpeakerId); + } const card = buildCard(result); if (result.pending) { const key = result.claim.toLowerCase().slice(0, 40); @@ -578,11 +604,17 @@ function addVerdict(result) { } function updateVerdict(result) { - const existing = findPendingCard(result.claim); - if (!result._timestamp) result._timestamp = getClaimTimestamp(result.claim); - // inherit dominantSpeakerId from pending card if grounded result doesn't have one - if (existing && existing.dataset.speakerid && !result.dominantSpeakerId) { - result.dominantSpeakerId = existing.dataset.speakerid; + const existing = findPendingCard(result.claim, result._fastClaim); + // always inherit timestamp from the pending card — it was set at detection time + // never re-derive from sentenceTimestamps which may no longer contain the original sentence + if (existing && existing._resultData?._timestamp) { + result._timestamp = existing._resultData._timestamp; + } else if (!result._timestamp) { + result._timestamp = getClaimTimestamp(result.claim); + } + // normalize dominantSpeakerId to string for consistent confirmedSpeakerMap lookup + if (result.dominantSpeakerId !== null && result.dominantSpeakerId !== undefined) { + result.dominantSpeakerId = String(result.dominantSpeakerId); } const newCard = buildCard(result); if (existing) { diff --git a/realtime-factcheck/src/content/session-export.js b/realtime-factcheck/src/content/session-export.js index 4685aeb..917e101 100644 --- a/realtime-factcheck/src/content/session-export.js +++ b/realtime-factcheck/src/content/session-export.js @@ -1,7 +1,6 @@ // session-export.js // Handles session logging and PDF export. -// Loaded after overlay.js — exposes logVerdict(), startSession(), stopSession(), exportPDF() as globals. -// Reuses escapeHtml() defined in overlay.js (loaded first in manifest.json). +// Loaded after overlay.js const sessionLog = []; let sessionStartTime = null; diff --git a/realtime-factcheck/src/offscreen/offscreen-ex.js b/realtime-factcheck/src/offscreen/offscreen-ex.js index 65ec098..963f764 100644 --- a/realtime-factcheck/src/offscreen/offscreen-ex.js +++ b/realtime-factcheck/src/offscreen/offscreen-ex.js @@ -12,7 +12,7 @@ let active = false; chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (msg.type === 'START_CAPTURE') { - startCapture(msg.streamId) + startCapture(msg.streamId, msg.language || 'en') .then(() => sendResponse({ ok: true })) .catch(err => { console.error('[offscreen] error:', err); @@ -28,8 +28,9 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { }); let utteranceBuffer = ''; +let utteranceSpeakerCounts = {}; // track speaker word counts across buffer chunks -async function startCapture(streamId) { +async function startCapture(streamId, language = 'en') { if (active) stopCapture(); active = true; @@ -51,7 +52,7 @@ async function startCapture(streamId) { 'sample_rate=16000', 'channels=1', 'model=nova-2', - 'language=en-US', + 'language=' + language, 'punctuate=true', 'interim_results=true', 'utterance_end_ms=2500', @@ -83,14 +84,31 @@ async function startCapture(streamId) { const text = result.transcript.trim(); const isFinal = data.is_final; const speech = data.speech_final; - const speaker = result.words?.[0]?.speaker ?? null; + + // accumulate speaker word counts from every chunk + if (result.words?.length) { + result.words.forEach(w => { + if (w.speaker !== null && w.speaker !== undefined) { + utteranceSpeakerCounts[w.speaker] = (utteranceSpeakerCounts[w.speaker] || 0) + 1; + } + }); + } + + // dominant speaker = whoever had the most words in this utterance so far + function getDominantSpeaker() { + const entries = Object.entries(utteranceSpeakerCounts); + if (!entries.length) return null; + return parseInt(entries.sort((a, b) => b[1] - a[1])[0][0]); + } if (!text) return; if (isFinal && speech) { // speech_final = end of utterance — send full accumulated text as final const fullText = utteranceBuffer ? utteranceBuffer + ' ' + text : text; + const speaker = getDominantSpeaker(); utteranceBuffer = ''; + utteranceSpeakerCounts = {}; chrome.runtime.sendMessage({ type: 'TRANSCRIPT_RESULT', text: fullText.trim(), @@ -106,7 +124,7 @@ async function startCapture(streamId) { text: utteranceBuffer, isFinal: false, interim: true, - speaker, + speaker: getDominantSpeaker(), }); } else { // regular interim — show as-is @@ -115,7 +133,7 @@ async function startCapture(streamId) { text, isFinal: false, interim: true, - speaker, + speaker: getDominantSpeaker(), }); } @@ -175,6 +193,7 @@ function startAudioPipeline() { function stopCapture() { active = false; utteranceBuffer = ''; + utteranceSpeakerCounts = {}; if (socket) { socket.close(); diff --git a/realtime-factcheck/src/popup/popup.css b/realtime-factcheck/src/popup/popup.css index 6fbdbf5..bbb22d1 100644 --- a/realtime-factcheck/src/popup/popup.css +++ b/realtime-factcheck/src/popup/popup.css @@ -81,6 +81,38 @@ body { color: #888; } +.key-select { + appearance: none; + cursor: pointer; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; + padding-right: 24px; +} + +.key-select option { + background: #1a1a1a; + color: #e8e8e8; +} + +.lang-row { + display: flex; + align-items: center; + gap: 8px; +} + +.lang-flag { + font-size: 18px; + line-height: 1; + flex-shrink: 0; +} + +.lang-row .key-select { + flex: 1; +} + + + .key-hint { font-size: 10px; color: #555; diff --git a/realtime-factcheck/src/popup/popup.html b/realtime-factcheck/src/popup/popup.html index 8c3e45e..264d3a7 100644 --- a/realtime-factcheck/src/popup/popup.html +++ b/realtime-factcheck/src/popup/popup.html @@ -18,6 +18,30 @@ +
+ +
+ 🇺🇸 + +
+
diff --git a/realtime-factcheck/src/popup/popup.js b/realtime-factcheck/src/popup/popup.js index 9c26b65..9f9f60a 100644 --- a/realtime-factcheck/src/popup/popup.js +++ b/realtime-factcheck/src/popup/popup.js @@ -5,13 +5,27 @@ const statusEl = document.getElementById('status'); const anthropicEl = document.getElementById('anthropicKey'); const keyHint = document.getElementById('keyHint'); const keysSection = document.getElementById('keysSection'); +const languageEl = document.getElementById('languageSelect'); +const langFlagEl = document.getElementById('langFlag'); + +const LANG_FLAGS = { + en: '🇺🇸', es: '🇪🇸', fr: '🇫🇷', de: '🇩🇪', it: '🇮🇹', + pt: '🇧🇷', nl: '🇳🇱', hi: '🇮🇳', ja: '🇯🇵', zh: '🇨🇳', + ar: '🇸🇦', ko: '🇰🇷', ru: '🇷🇺', pl: '🇵🇱', sv: '🇸🇪', tr: '🇹🇷', +}; + +function updateFlag() { + langFlagEl.textContent = LANG_FLAGS[languageEl.value] || '🌐'; +} let isActive = false; -// ── Load saved key ──────────────────────────────────────────────────────────── +// ── Load saved key and language ─────────────────────────────────────────────── -chrome.storage.local.get(['anthropicKey'], (data) => { +chrome.storage.local.get(['anthropicKey', 'transcriptLanguage'], (data) => { if (data.anthropicKey) { anthropicEl.value = data.anthropicKey; anthropicEl.classList.add('saved'); } + if (data.transcriptLanguage) languageEl.value = data.transcriptLanguage; + updateFlag(); updateHint(); }); @@ -27,6 +41,13 @@ anthropicEl.addEventListener('change', () => { updateHint(); }); +// ── Save language on change ─────────────────────────────────────────────────── + +languageEl.addEventListener('change', () => { + chrome.storage.local.set({ transcriptLanguage: languageEl.value }); + updateFlag(); +}); + function updateHint() { if (!anthropicEl.value.trim()) { keyHint.textContent = 'Enter your Anthropic API key to start.'; @@ -73,8 +94,8 @@ toggleBtn.addEventListener('click', async () => { return; } - // save key then start - await new Promise(r => chrome.storage.local.set({ anthropicKey }, r)); + // save key and language then start + await new Promise(r => chrome.storage.local.set({ anthropicKey, transcriptLanguage: languageEl.value }, r)); chrome.runtime.sendMessage({ type: 'START_FACTCHECK' }, (res) => { if (res?.ok) {