From d047e896bebe0a10a8f28bb266cba8e864039e0e Mon Sep 17 00:00:00 2001 From: Risha Date: Thu, 25 Jun 2026 18:11:18 -0500 Subject: [PATCH] added multilang support --- realtime-factcheck/assets/icon128.png | Bin 4944 -> 6452 bytes realtime-factcheck/assets/icon16.png | Bin 480 -> 463 bytes realtime-factcheck/assets/icon48.png | Bin 1704 -> 1657 bytes realtime-factcheck/manifest.json | 3 +- .../src/background/service-worker-ex.js | 402 +++++++++++------- realtime-factcheck/src/content/overlay.js | 108 +++-- .../src/content/session-export.js | 3 +- .../src/offscreen/offscreen-ex.js | 31 +- realtime-factcheck/src/popup/popup.css | 32 ++ realtime-factcheck/src/popup/popup.html | 24 ++ realtime-factcheck/src/popup/popup.js | 29 +- 11 files changed, 436 insertions(+), 196 deletions(-) diff --git a/realtime-factcheck/assets/icon128.png b/realtime-factcheck/assets/icon128.png index 76b7d9ff272a3ca2bb9aa8702cf30762ebe94efc..7644f77b3e591aded76c4fd0df04763a0fa047ef 100644 GIT binary patch literal 6452 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 literal 4944 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 wMSHq)$07*qoM6N<$g4NH;JOBUy diff --git a/realtime-factcheck/assets/icon48.png b/realtime-factcheck/assets/icon48.png index 19c7c10ddd8415a2529d8b547322a11335107cb9..b9887c9e58df08a27c3a919b42a4ec30baf0f73d 100644 GIT binary patch delta 1640 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 i00006Nkl 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) {