From ee766929615a916d06e6773ecaf18dea42400f2b Mon Sep 17 00:00:00 2001 From: Risha Date: Thu, 18 Jun 2026 21:59:20 -0500 Subject: [PATCH] clean up history --- LICENSE | 21 + README.md | 122 ++++ realtime-factcheck/.gitignore | 11 + realtime-factcheck/assets/icon128.png | Bin 0 -> 4944 bytes realtime-factcheck/assets/icon16.png | Bin 0 -> 480 bytes realtime-factcheck/assets/icon48.png | Bin 0 -> 1704 bytes realtime-factcheck/manifest.json | 57 ++ .../src/background/service-worker-ex.js | 608 ++++++++++++++++ .../src/content/lexical-features.js | 201 ++++++ realtime-factcheck/src/content/overlay.css | 551 ++++++++++++++ realtime-factcheck/src/content/overlay.js | 674 ++++++++++++++++++ .../src/content/session-export.js | 169 +++++ .../src/offscreen/offscreen-ex.js | 200 ++++++ .../src/offscreen/offscreen.html | 5 + realtime-factcheck/src/popup/popup.css | 113 +++ realtime-factcheck/src/popup/popup.html | 28 + realtime-factcheck/src/popup/popup.js | 87 +++ 17 files changed, 2847 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 realtime-factcheck/.gitignore create mode 100644 realtime-factcheck/assets/icon128.png create mode 100644 realtime-factcheck/assets/icon16.png create mode 100644 realtime-factcheck/assets/icon48.png create mode 100644 realtime-factcheck/manifest.json create mode 100644 realtime-factcheck/src/background/service-worker-ex.js create mode 100644 realtime-factcheck/src/content/lexical-features.js create mode 100644 realtime-factcheck/src/content/overlay.css create mode 100644 realtime-factcheck/src/content/overlay.js create mode 100644 realtime-factcheck/src/content/session-export.js create mode 100644 realtime-factcheck/src/offscreen/offscreen-ex.js create mode 100644 realtime-factcheck/src/offscreen/offscreen.html create mode 100644 realtime-factcheck/src/popup/popup.css create mode 100644 realtime-factcheck/src/popup/popup.html create mode 100644 realtime-factcheck/src/popup/popup.js diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7dca53d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 risha panigrahi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b4adb49 --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# intruth + +hi everyone! + +https://chromewebstore.google.com/detail/InTruth/ikmpglbpcdoapfelcbfpoaddmhmaaocg?hl=en&authuser=0 + +built a real-time factchecker called intruth for live debates, speeches, interviews, press conferences, and political events! + + +image + +it listens to audio from the active browser tab, identifies factual claims as they are made, and provides instant evidence-based verdicts using AI analysis and web research; most fact-checking docs come out days after debates, but now users can evaluate claims as they're made. + +this is part of a bigger research project so more to come! + +## features + +- live claim detection: continuously monitors speech from the active tab and identifies check-worthy factual claims in real time + +- live claim evaluation: analyzes claim veracity using large language models and external sources to determine whether a statement is: + +* TRUE +* SUBSTANTIALLY TRUE +* FALSE +* MISLEADING +* UNVERIFIABLE + +- speaker attribution: tracks speakers throughout a discussion and attributes claims to the correct participant whenever possible + +- context analysis: uses surrounding conversation and event context to improve claim identification and reduce false positives + +- real-time verdicts: veracity checks and sources appear while the debate or interview is still in progress + +- bring-your-own-key: users provide their own anthropic API key + +## how to use intruth: + +1. open a video, livestream, debate, interview, or speech. +2. start the extension, and assign speakers w/ the press of a button +3. audio from the active tab is captured +4. speech transcribed +5. check-worthy, factual claims are extracted +6. claims evaluated against authoritative sources +7. verdicts, explanations are displayed to the user!!! + +## what's check-worthy?? + +check-worthy claims in this context are: + +* specific factual statements +* statistics and numerical claims +* historical events +* government actions and policies +* scientific and medical claims +* public records and documented events + +i.e. +* "inflation peaked at 9.1% in 2022." +* "the bill passed the Senate in 2021." +* "the unemployment rate is currently below 5%." + +NOT: +* opinions +* predictions / future promises +* rhetorical questions +* value judgments +* subjective descriptions + +i.e. +* "This policy will destroy the economy." +* "I have the best plan." +* "If my opponent wins, disaster will follow." + +## privacy details + +users provide their own API credentials, i have no access to that + +transcript data may be sent directly to the AI service configured by the user in order to generate fact-check results + +see privacy policy on web store for complete details! + +## permissions + +tabcapture: extracts audio from the active browser tab after the user explicitly starts a fact-checking session. + +activetab: allows the extension to interact with the currently selected tab + +scripting: injects the fact-checking interface into supported pages. + +storage: stores user preferences and API configuration LOCALLY + +offscreen: supports background audio processing and transcription workflows. + +## limitations and warnings + +fact-checking is inherently imperfect! generated verdicts may occasionally be incorrect, incomplete, or based on outdated information. if you're unsure about something, independently evaluate it and consult original sources when making decisions! + +this extension is as an informational tool and NOT a definitive authority ! + +### requirements + +* Chrome Manifest V3 +* User-provided AI API key +* Modern Chromium-based browser + +### recipe + +1. clone repo +2. open Chrome Extensions +3. enable Developer Mode +4. load unpacked +5. choose project dir +6. configure your anthropic API key +7. press startttt + +## Contributing + +would love advice, any features you'd like, and any edge cases you've found! haiku can be overly pedantic so i expect many .... + +## license + +MIT License diff --git a/realtime-factcheck/.gitignore b/realtime-factcheck/.gitignore new file mode 100644 index 0000000..d33cf04 --- /dev/null +++ b/realtime-factcheck/.gitignore @@ -0,0 +1,11 @@ +# Service worker contains API keys +src/background/service-worker.js +src/offscreen/offscreen.js + +# OS +.DS_Store +Thumbs.db + +# Editor +.vscode/ +.idea/ \ No newline at end of file diff --git a/realtime-factcheck/assets/icon128.png b/realtime-factcheck/assets/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..76b7d9ff272a3ca2bb9aa8702cf30762ebe94efc GIT binary patch literal 4944 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 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 + +let ANTHROPIC_KEY = ''; +const SERPER_KEY = ''; + +async function loadKeys() { + return new Promise(resolve => { + chrome.storage.local.get(['anthropicKey'], (data) => { + ANTHROPIC_KEY = data.anthropicKey || ''; + resolve(); + }); + }); +} + +const EVALUATE_PROMPT = ``; + +// ── Speaker parsing (mirrors overlay.js) ───────────────────────────────────── + +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); + 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])]; + } + return []; +} + +// ── Serper ──────────────────────────────────────────────────────────────────── + +const BLOCKED_DOMAINS = [ + 'reddit.com', 'facebook.com', 'twitter.com', 'x.com', + 'tiktok.com', 'instagram.com', 'pinterest.com', 'quora.com', + 'yelp.com', 'tripadvisor.com', 'youtube.com', + 'democrats.org', 'republicans.org', 'gop.com', 'dnc.org', + 'afscme.org', 'ntu.org', 'americanprogress.org', 'heritage.org', + 'breitbart.com', 'dailykos.com', 'mediamatters.org', 'newsmax.com', + 'thefederalist.com', 'motherjones.com', 'nationalreview.com', + 'democrats-appropriations.house.gov', 'waysandmeans.house.gov', + 'bostonkravmaga.com', +]; + +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 }), + }); + 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); + } catch (err) { + if (retries > 0) { + await new Promise(r => setTimeout(r, 500)); + return searchWeb(query, retries - 1); + } + console.error('[serper] error:', err); + return []; + } +} + +// ── Claude ──────────────────────────────────────────────────────────────────── + +async function callClaude(userMessage, systemPrompt) { + const res = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': ANTHROPIC_KEY, + 'anthropic-version': '2023-06-01', + 'anthropic-dangerous-direct-browser-access': 'true', + }, + body: JSON.stringify({ + model: 'claude-haiku-4-5-20251001', + max_tokens: 768, + temperature: 0, + system: systemPrompt, + messages: [{ role: 'user', content: userMessage }], + }), + }); + const data = await res.json(); + if (data.error) { + const msg = data.error.message || 'Unknown API error'; + console.error('[claude] API error:', msg); + if (activeTabId) chrome.tabs.sendMessage(activeTabId, { type: 'PIPELINE_ERROR', message: msg }).catch(() => {}); + return ''; + } + const raw = data.content?.[0]?.text?.trim() || ''; + return raw.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim(); +} + +function parseArray(str) { + const start = str.indexOf('['); + const end = str.lastIndexOf(']'); + if (start === -1 || end === -1) return []; + try { return JSON.parse(str.slice(start, end + 1)); } + catch { return []; } +} + +// ── Lexical features ────────────────────────────────────────────────────────── + +const HEDGING_WORDS = ['think','believe','maybe','perhaps','probably','might','could','seem','appears','guess','suppose','somewhat']; +const CERTAINTY_WORDS = ['definitely','certainly','absolutely','always','never','clearly','obviously','undoubtedly','exactly','proven']; +const FILLER_WORDS = ['um','uh','like','basically','actually','literally','right','okay']; +const EMOTIONAL_WORDS = ['disaster','terrible','horrible','amazing','incredible','great','awful','fantastic','disgusting','wonderful','worst','best']; +const EXCLUSIVE_WORDS = ['but','except','however','although','unless','without','exclude']; +const FP_SINGULAR = ['i','me','my','mine','myself']; + +function extractLexical(text) { + const words = text.toLowerCase().split(/\s+/).filter(Boolean); + const total = words.length || 1; + const rate = (list) => Math.round(words.filter(w => list.some(h => w.includes(h))).length / total * 100); + return { + rates: { + hedging: rate(HEDGING_WORDS), + certainty: rate(CERTAINTY_WORDS), + filler: rate(FILLER_WORDS), + emotional: rate(EMOTIONAL_WORDS), + exclusive: rate(EXCLUSIVE_WORDS), + firstPersonSg: Math.round(words.filter(w => FP_SINGULAR.includes(w)).length / total * 100), + }, + wordsPerSecond: null, + wordCount: total, + }; +} + +function buildLexicalSummary(f) { + const r = f.rates || f; + const notes = []; + if (r.hedging > 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 (f.wordsPerSecond) { + const pace = f.wordsPerSecond > 3.5 ? 'fast' : f.wordsPerSecond < 2 ? 'slow' : 'moderate'; + notes.push(`speech rate ${f.wordsPerSecond} w/s (${pace})`); + } + return notes.length ? `Features detected: ${notes.join(', ')}.` : 'Neutral delivery.'; +} + +// ── Claim deduplication ─────────────────────────────────────────────────────── + +const recentClaims = new Map(); // key → [timestamp, originalClaim] +const CLAIM_DEDUP_MS = 200000; + +function normalizeClaimKey(claim) { + return claim.toLowerCase() + .replace(/[^a-z0-9\s]/g, '') + .split(/\s+/) + .filter(w => w.length >= 4) + .sort() + .join(' '); +} + +function isDuplicate(claim) { + const key = normalizeClaimKey(claim); + const now = Date.now(); + + for (const [k, v] of recentClaims) { + const t = Array.isArray(v) ? v[0] : v; + if (now - t > CLAIM_DEDUP_MS) recentClaims.delete(k); + } + + if (recentClaims.has(key)) return true; + + const keyWords = new Set(key.split(' ').filter(Boolean)); + const figures = (claim.match(/\$[\d,.]+(?:\s*(?:trillion|billion|million|thousand))?/gi) || []) + .map(d => d.replace(/[,\s]/g, '').toLowerCase()); + + for (const [k, v] of recentClaims) { + const kWords = k.split(' ').filter(Boolean); + if (kWords.filter(w => keyWords.has(w)).length / Math.max(keyWords.size, kWords.length) >= 0.35) return true; + if (figures.length) { + const origClaim = Array.isArray(v) ? v[1] : ''; + if (origClaim) { + const origFigures = (origClaim.match(/\$[\d,.]+(?:\s*(?:trillion|billion|million|thousand))?/gi) || []) + .map(d => d.replace(/[,\s]/g, '').toLowerCase()); + if (figures.some(f => origFigures.includes(f))) return true; + } + } + } + + recentClaims.set(key, [now, claim]); + return false; +} + +// ── Rolling window ──────────────────────────────────────────────────────────── + +const WINDOW_SIZE = 4; +const WINDOW_KEEP = 15; + +// Each entry: { text, speakerId, speakerName } +let sentenceWindow = []; +let sentenceCount = 0; +let windowLexical = { rates: { hedging: 0, certainty: 0, filler: 0, emotional: 0, exclusive: 0, firstPersonSg: 0 }, wordsPerSecond: null, wordCount: 0 }; +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 }; + windowStartTime = null; + currentSpeakerId = null; + lastSpeakerId = null; + speakerIdToName = {}; + confirmedSpeakers = new Set(); +} + +async function onNewSentence(text, speakerId) { + // flush window early on speaker change (mid-window turn transition) + if (lastSpeakerId !== null && + speakerId !== null && + speakerId !== undefined && + speakerId !== lastSpeakerId && + sentenceCount % WINDOW_SIZE !== 0 && + sentenceWindow.length >= 2) { + // fire evaluation for the previous speaker's sentences before processing this one + const flushText = sentenceWindow.map(s => s.text).join(' '); + const flushCounts = {}; + sentenceWindow.slice(-WINDOW_SIZE).forEach(s => { + if (s.speakerId !== null && s.speakerId !== undefined) + flushCounts[s.speakerId] = (flushCounts[s.speakerId] || 0) + 1; + }); + const flushDominantId = Object.keys(flushCounts).length + ? Object.entries(flushCounts).sort((a,b) => b[1]-a[1])[0][0] + : null; + const flushDominantSpeaker = flushDominantId !== null ? (speakerIdToName[flushDominantId] || null) : null; + const flushLexSnapshot = JSON.parse(JSON.stringify(windowLexical)); + const flushLexSummary = buildLexicalSummary(flushLexSnapshot); + windowLexical = { rates: { hedging: 0, certainty: 0, filler: 0, emotional: 0, exclusive: 0, firstPersonSg: 0 }, wordsPerSecond: null, wordCount: 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 + 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); + windowLexical.wordCount += f.wordCount; + + if (sentenceCount % WINDOW_SIZE === 0) { + const contextText = sentenceWindow.map(s => s.text).join(' '); + + // dominant speaker ID = whoever appears most in this window + // count only the CURRENT window's sentences (last WINDOW_SIZE), not full rolling buffer + const currentWindowSentences = sentenceWindow.slice(-WINDOW_SIZE); + const counts = {}; + currentWindowSentences.forEach(s => { + if (s.speakerId !== null && s.speakerId !== undefined) { + counts[s.speakerId] = (counts[s.speakerId] || 0) + 1; + } + }); + const dominantSpeakerId = Object.keys(counts).length + ? Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0] + : null; + // use confirmed name from speakerIdToName — ground truth from Deepgram + user confirmation + const dominantSpeaker = dominantSpeakerId !== null + ? (speakerIdToName[dominantSpeakerId] || null) + : null; + + // speech rate + const elapsed = windowStartTime ? (Date.now() - windowStartTime) / 1000 : null; + if (elapsed && elapsed > 0) windowLexical.wordsPerSecond = Math.round(windowLexical.wordCount / elapsed * 10) / 10; + windowStartTime = null; + + const lexicalSnapshot = JSON.parse(JSON.stringify(windowLexical)); + 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 }; + windowStartTime = null; + + try { + await evaluateClaims(contextText, pageTitle, lexicalSummary, lexicalSnapshot, dominantSpeaker, dominantSpeakerId); + } catch (e) { + } + } +} + +// ── Evaluation pipeline ─────────────────────────────────────────────────────── + +async function evaluateClaims(contextText, title, lexicalSummary, lexicalSnapshot, dominantSpeaker, dominantSpeakerId) { + try { + const dateContext = pageDate ? `\nDate: ${pageDate}` : ''; + + // build speaker legend from title names for Claude + const titleNames = parseSpeakersFromTitle(title || ''); + const nameList = titleNames.join(' and '); + const speakerLegend = titleNames.length + ? `\nDebate participants: ${nameList}.` + + `\nSpeaker attribution rules:` + + `\n- [Speaker N] labels indicate turn order only — do NOT map Speaker 0 to the first name listed.` + + `\n- Identify speakers using: (1) first-person language — when someone says "I", "my plan", "I intend to", they ARE the speaker — attribute the claim to the known participant whose policies match; (2) policy content — match stated positions to each participant's known platform; (3) cross-references — participants typically refer to each other by name.` + + `\n- Use your knowledge of each named participant's background, policies, and public record to attribute correctly.` + + `\n- If a moderator or third party is speaking, attribute to them if identifiable, otherwise use "Unknown".` + + `\n- NEVER output "Speaker N" or any [Speaker N] format in any field.` + : `\nIdentify speakers using first-person language, policy content, and speech patterns. Never output "Speaker N".`; + + const 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 lexicalContext = lexicalSummary ? `\n\nLexical analysis: ${lexicalSummary}` : ''; + + // already-checked claims list for Claude + const checkedList = [...recentClaims.values()] + .filter(v => Array.isArray(v) && v[1]) + .map(v => v[1]) + .slice(-15) + .join('\n- '); + const alreadyChecked = checkedList + ? `\n\nClaims already fact-checked this session — do NOT re-evaluate these or close variants:\n- ${checkedList}\n` + : ''; + + 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)); + + if (!valid.length) return; + + if (activeTabId) { + chrome.tabs.sendMessage(activeTabId, { + type: 'NEW_VERDICT', + results: valid.map(r => ({ + ...r, + sources: [], + pending: true, + lexical: lexicalSnapshot, + dominantSpeakerId, // raw Deepgram ID — overlay resolves to name at render time + 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); + + } catch (err) { + console.error('[pipeline] error:', err); + } +} + +async function groundAndUpdate(contextText, fastResults, title, lexicalSummary, lexicalSnapshot, dominantSpeaker, dominantSpeakerId) { + 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 lexicalContext = lexicalSummary ? `\n\nLexical analysis: ${lexicalSummary}` : ''; + + const groundedAll = await Promise.all(fastResults.map(async (fastResult) => { + try { + const urls = await searchWeb(fastResult.claim); + if (!urls.length) return null; + 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 + ); + 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 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 }; + } catch (err) { + console.error('[grounded] error:', fastResult.claim.slice(0, 40), err); + return null; + } + })); + + const valid = groundedAll.filter(Boolean); + if (valid.length && activeTabId) { + chrome.tabs.sendMessage(activeTabId, { type: 'UPDATE_VERDICTS', results: valid }).catch(() => {}); + console.log('[pipeline] grounded verdicts sent:', valid.length); + } + } catch (err) { + console.error('[grounded] error:', err); + } +} + +// ── State ───────────────────────────────────────────────────────────────────── + +let activeTabId = null; +let isCapturing = false; +let keepAliveInterval = null; + +function startKeepAlive() { + keepAliveInterval = setInterval(() => chrome.runtime.getPlatformInfo(() => {}), 20000); +} + +function stopKeepAlive() { + clearInterval(keepAliveInterval); + keepAliveInterval = null; +} + +// ── Messages ────────────────────────────────────────────────────────────────── + +chrome.runtime.onConnect.addListener(() => console.log('[service-worker] woken by port connect')); + +// notify overlay if service worker was killed and restarted mid-session +chrome.runtime.onStartup.addListener(() => { + isCapturing = false; + activeTabId = null; +}); + +chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + switch (msg.type) { + + case 'START_FACTCHECK': + startFactCheck() + .then(() => sendResponse({ ok: true })) + .catch(err => sendResponse({ ok: false, error: err.message })); + return true; + + case 'STOP_FACTCHECK': + stopFactCheck(); + sendResponse({ ok: true }); + break; + + case 'TRANSCRIPT_RESULT': + // always process transcript for pipeline — activeTabId only needed for forwarding to overlay + if (msg.isFinal) { + if (msg.speaker !== null && msg.speaker !== undefined) { + currentSpeakerId = msg.speaker; + if (activeTabId && !confirmedSpeakers.has(currentSpeakerId) && !speakerIdToName[currentSpeakerId]) { + chrome.tabs.sendMessage(activeTabId, { + type: 'NEW_SPEAKER', + speakerId: currentSpeakerId, + sample: msg.text.slice(0, 80), + }).catch(() => {}); + } + } + onNewSentence(msg.text, currentSpeakerId); + } + if (activeTabId) { + chrome.tabs.sendMessage(activeTabId, { + type: 'TRANSCRIPT_RESULT', text: msg.text, isFinal: msg.isFinal, interim: msg.interim, + }).catch(() => {}); + } + break; + + case 'SPEAKER_NAMES': + // merge incoming confirmed entries — never overwrite already-confirmed IDs + if (msg.speakerIdToName) { + Object.entries(msg.speakerIdToName).forEach(([id, name]) => { + const numId = parseInt(id); + if (!confirmedSpeakers.has(numId)) { + speakerIdToName[numId] = name; + confirmedSpeakers.add(numId); + } + }); + console.log('[service-worker] speaker map updated:', speakerIdToName); + } + break; + + case 'PAGE_TITLE': + pageTitle = msg.title || ''; + pageDate = msg.date || ''; + console.log('[service-worker] page title:', pageTitle.slice(0, 60)); + console.log('[service-worker] page date:', pageDate); + // speaker names passed to Claude as context — Claude resolves attribution + break; + + case 'PIPELINE_ERROR': + // forward from offscreen doc to overlay + if (activeTabId) { + chrome.tabs.sendMessage(activeTabId, { type: 'PIPELINE_ERROR', message: msg.message }).catch(() => {}); + } + break; + + case 'REQUEST_NEW_STREAM': + // offscreen doc lost its stream — get a fresh tabCapture stream ID + if (activeTabId && isCapturing) { + chrome.tabCapture.getMediaStreamId({ targetTabId: activeTabId }, (streamId) => { + if (chrome.runtime.lastError) { + console.error('[service-worker] failed to get new stream:', chrome.runtime.lastError.message); + return; + } + chrome.runtime.sendMessage({ type: 'START_CAPTURE', streamId }).catch(() => {}); + }); + } + break; + + case 'GET_STATUS': + sendResponse({ isCapturing }); + break; + } +}); + +// ── Start / stop ────────────────────────────────────────────────────────────── + +async function startFactCheck() { + if (isCapturing) return; + + await loadKeys(); + if (!ANTHROPIC_KEY) { + throw new Error('Anthropic API key not set. Please enter it in the extension popup.'); + } + + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab) throw new Error('No active tab found.'); + activeTabId = tab.id; + + try { + await ensureOffscreenDocument(); + console.log('[service-worker] offscreen document created'); + } catch (err) { + console.error('[service-worker] offscreen creation failed:', err); + } + + const streamId = await new Promise((resolve, reject) => { + chrome.tabCapture.getMediaStreamId({ targetTabId: activeTabId }, id => { + if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message)); + else resolve(id); + }); + }); + + const response = await chrome.runtime.sendMessage({ type: 'START_CAPTURE', streamId }); + if (!response?.ok) throw new Error('Failed to start capture: ' + response?.error); + + // reset BEFORE sending START_FACTCHECK — transcripts arrive immediately after + isCapturing = true; + resetWindow(); + recentClaims.clear(); + startKeepAlive(); + + await chrome.tabs.sendMessage(activeTabId, { type: 'START_FACTCHECK' }); + console.log('[service-worker] started on tab', activeTabId); +} + +function stopFactCheck() { + resetWindow(); + recentClaims.clear(); + pageTitle = ''; + pageDate = ''; + + if (!isCapturing) return; + + chrome.runtime.sendMessage({ type: 'STOP_CAPTURE' }).catch(() => {}); + chrome.offscreen.closeDocument().catch(() => {}); + if (activeTabId) chrome.tabs.sendMessage(activeTabId, { type: 'STOP_FACTCHECK' }).catch(() => {}); + + activeTabId = null; + isCapturing = false; + stopKeepAlive(); + console.log('[service-worker] stopped'); +} + +async function ensureOffscreenDocument() { + const existing = await chrome.runtime.getContexts({ contextTypes: ['OFFSCREEN_DOCUMENT'] }); + if (existing.length > 0) return; + await chrome.offscreen.createDocument({ + url: chrome.runtime.getURL('src/offscreen/offscreen.html'), + reasons: ['USER_MEDIA'], + justification: 'Capture tab audio for Deepgram transcription', + }); +} \ No newline at end of file diff --git a/realtime-factcheck/src/content/lexical-features.js b/realtime-factcheck/src/content/lexical-features.js new file mode 100644 index 0000000..90784ad --- /dev/null +++ b/realtime-factcheck/src/content/lexical-features.js @@ -0,0 +1,201 @@ +// lexical-features.js +// extracts speaker commitment to an utterance from transcript text alone + +// -- word lists + +const EXCLUSIVE_WORDS = new Set([ + 'but', 'except', 'without', 'exclude', 'excluding', 'however', + 'although', 'unless', 'despite', 'rather', 'instead', 'yet', + 'nonetheless', 'nevertheless', 'notwithstanding', 'whereas' +]); + +const HEDGING_WORDS = new Set([ + 'maybe', 'perhaps', 'possibly', 'probably', 'might', 'could', + 'seems', 'appear', 'appears', 'apparently', 'roughly', 'around', + 'approximately', 'somewhat', 'kind of', 'sort of', 'i think', + 'i believe', 'i feel', 'i guess', 'i suppose', 'not sure', + 'uncertain', 'unclear', 'allegedly', 'supposedly', 'purportedly' +]); + +const CERTAINTY_WORDS = new Set([ + 'always', 'never', 'definitely', 'certainly', 'absolutely', + 'clearly', 'obviously', 'undoubtedly', 'without question', + 'proven', 'fact', 'facts', 'evidence', 'study', 'studies', + 'research', 'data', 'statistics', 'percent', '%', 'million', + 'billion', 'every', 'all', 'none', 'guaranteed' +]); + +const EMOTIONAL_WORDS = new Set([ + 'terrible', 'horrible', 'awful', 'great', 'amazing', 'fantastic', + 'disaster', 'catastrophe', 'wonderful', 'incredible', 'unbelievable', + 'disgrace', 'shameful', 'outrageous', 'ridiculous', 'pathetic', + 'love', 'hate', 'fear', 'angry', 'anger', 'sad', 'happy', + 'excited', 'disgusted', 'afraid', 'scared', 'worried', 'proud', + 'ashamed', 'brilliant', 'stupid', 'evil', 'corrupt' +]); + +const FILLER_WORDS = new Set([ + 'uh', 'um', 'uhh', 'umm', 'er', 'err', 'like', 'you know', + 'i mean', 'basically', 'literally', 'actually', 'honestly', + 'frankly', 'right', 'okay', 'so', 'well', 'anyway' +]); + +const FIRST_PERSON_SINGULAR = new Set([ + 'i', 'me', 'my', 'mine', 'myself' +]); + +const FIRST_PERSON_PLURAL = new Set([ + 'we', 'us', 'our', 'ours', 'ourselves' +]); + +const THIRD_PERSON = new Set([ + 'they', 'them', 'their', 'theirs', 'he', 'she', 'his', 'her', + 'it', 'its', 'him' +]); + +// -- main extractor + +/** + * extract lexical commitment features from a transcript string + * @param {string} text - the transcript chunk + * @param {number} durationSeconds - approximate duration (optional, for speech rate) + * @returns {object} feature object + */ +function extractLexicalFeatures(text, durationSeconds) { + const lower = text.toLowerCase(); + const words = lower.match(/\b\w+\b/g) || []; + const wordCount = words.length; + + if (wordCount === 0) return null; + + let exclusiveCount = 0; + let hedgingCount = 0; + let certaintyCount = 0; + let emotionalCount = 0; + let fillerCount = 0; + let firstPersonSing = 0; + let firstPersonPlur = 0; + let thirdPerson = 0; + + for (const word of words) { + if (EXCLUSIVE_WORDS.has(word)) exclusiveCount++; + if (HEDGING_WORDS.has(word)) hedgingCount++; + if (CERTAINTY_WORDS.has(word)) certaintyCount++; + if (EMOTIONAL_WORDS.has(word)) emotionalCount++; + if (FILLER_WORDS.has(word)) fillerCount++; + if (FIRST_PERSON_SINGULAR.has(word)) firstPersonSing++; + if (FIRST_PERSON_PLURAL.has(word)) firstPersonPlur++; + if (THIRD_PERSON.has(word)) thirdPerson++; + } + + // also check multi-word phrases + if (lower.includes('i think')) hedgingCount++; + if (lower.includes('i believe')) hedgingCount++; + if (lower.includes('i guess')) hedgingCount++; + if (lower.includes('i suppose')) hedgingCount++; + if (lower.includes('sort of')) hedgingCount++; + if (lower.includes('kind of')) hedgingCount++; + if (lower.includes('you know')) fillerCount++; + if (lower.includes('i mean')) fillerCount++; + + // -- rates (per 100 words for normalization) + + const per100 = (n) => parseFloat(((n / wordCount) * 100).toFixed(1)); + + // -- rough speech rate calculation + + const wordsPerSecond = durationSeconds && durationSeconds > 0 + ? parseFloat((wordCount / durationSeconds).toFixed(1)) + : null; + + // -- avg word length (proxy for phoneme density) + + const avgWordLength = parseFloat( + (words.reduce((sum, w) => sum + w.length, 0) / wordCount).toFixed(1) + ); + + // -- commitment score (heuristic -1 to +1) + // positive = high commitment, negative = low commitment + // certainty words and first-person singular push positive + // hedging words and filler push negative + // emotional words push negative (overconfidence is a form of low precision) + + const commitmentScore = parseFloat(( + (certaintyCount * 0.3) + + (firstPersonSing * 0.15) + - (hedgingCount * 0.4) + - (fillerCount * 0.25) + - (emotionalCount * 0.1) + + (exclusiveCount * 0.1) // exclusive words signal careful qualification + ).toFixed(2)); + + const commitmentLabel = + commitmentScore > 0.3 ? 'HIGH' : + commitmentScore < -0.3 ? 'LOW' : + 'MEDIUM'; + + // -- result + + return { + wordCount, + wordsPerSecond, + avgWordLength, + rates: { + hedging: per100(hedgingCount), + certainty: per100(certaintyCount), + emotional: per100(emotionalCount), + filler: per100(fillerCount), + exclusive: per100(exclusiveCount), + firstPersonSg: per100(firstPersonSing), + firstPersonPl: per100(firstPersonPlur), + thirdPerson: per100(thirdPerson), + }, + commitmentScore, + commitmentLabel, + // human-readable summary for Claude + summary: buildSummary({ + wordCount, wordsPerSecond, hedgingCount, certaintyCount, + emotionalCount, fillerCount, exclusiveCount, + firstPersonSing, firstPersonPlur, commitmentLabel + }) + }; +} + +function buildSummary({ wordCount, wordsPerSecond, hedgingCount, certaintyCount, + emotionalCount, fillerCount, exclusiveCount, firstPersonSing, + firstPersonPlur, commitmentLabel }) { + + const parts = []; + + if (wordsPerSecond !== null) { + const rateDesc = wordsPerSecond > 3.5 ? 'fast' : wordsPerSecond < 2 ? 'slow' : 'moderate'; + parts.push(`speech rate: ${wordsPerSecond} words/sec (${rateDesc})`); + } + + if (hedgingCount > 0) + parts.push(`${hedgingCount} hedging expression${hedgingCount > 1 ? 's' : ''} (e.g. "maybe", "I think")`); + + if (fillerCount > 0) + parts.push(`${fillerCount} filler word${fillerCount > 1 ? 's' : ''} (e.g. "um", "you know")`); + + if (certaintyCount > 0) + parts.push(`${certaintyCount} certainty marker${certaintyCount > 1 ? 's' : ''} (e.g. "always", "definitely", statistics)`); + + if (emotionalCount > 0) + parts.push(`${emotionalCount} emotional word${emotionalCount > 1 ? 's' : ''}`); + + if (exclusiveCount > 0) + parts.push(`${exclusiveCount} exclusive/qualifying word${exclusiveCount > 1 ? 's' : ''} (e.g. "but", "except")`); + + if (firstPersonSing > 0) + parts.push(`${firstPersonSing} first-person singular pronoun${firstPersonSing > 1 ? 's' : ''} (I/me/my)`); + + if (firstPersonPlur > 0) + parts.push(`${firstPersonPlur} first-person plural pronoun${firstPersonPlur > 1 ? 's' : ''} (we/our)`); + + const summary = parts.length + ? `Lexical features: ${parts.join(', ')}. Overall commitment: ${commitmentLabel}.` + : `No strong commitment signals detected. Overall commitment: ${commitmentLabel}.`; + + return summary; +} \ No newline at end of file diff --git a/realtime-factcheck/src/content/overlay.css b/realtime-factcheck/src/content/overlay.css new file mode 100644 index 0000000..c9f0033 --- /dev/null +++ b/realtime-factcheck/src/content/overlay.css @@ -0,0 +1,551 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap'); + +#rtfc-panel { + position: fixed; + top: 72px; + right: 16px; + width: 320px; + max-height: 620px; + background: rgba(15, 15, 15, 0.88); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 0.5px solid rgba(255,255,255,0.08); + border-radius: 14px; + z-index: 9999; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 12px; + color: #e8e8e8; + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: 0 4px 24px rgba(0,0,0,0.4); +} + +/* ── Header ── */ + +#rtfc-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 11px 14px 10px; + border-bottom: 0.5px solid rgba(255,255,255,0.07); + flex-shrink: 0; + cursor: grab; + user-select: none; +} + +#rtfc-header:active { cursor: grabbing; } + +#rtfc-header > span { + font-size: 12px; + font-weight: 600; + letter-spacing: -0.01em; + display: flex; + align-items: center; + gap: 7px; +} + +.rtfc-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #4ade80; + animation: rtfc-blink 2s ease-in-out infinite; + flex-shrink: 0; +} + +@keyframes rtfc-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} + +.rtfc-header-actions { + display: flex; + align-items: center; + gap: 8px; +} + +#rtfc-export { + background: none; + border: 0.5px solid rgba(255,255,255,0.15); + border-radius: 4px; + color: #888; + font-size: 10px; + font-family: 'Inter', sans-serif; + font-weight: 500; + padding: 3px 7px; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; + letter-spacing: 0.02em; +} + +#rtfc-export:hover { + color: #e8e8e8; + border-color: rgba(255,255,255,0.35); +} + +#rtfc-close { + background: none; + border: none; + color: #555; + font-size: 15px; + cursor: pointer; + padding: 0; + transition: color 0.15s; + font-family: inherit; +} + +#rtfc-close:hover { color: #e8e8e8; } + +/* ── Body ── */ + +#rtfc-body { + display: flex; + flex-direction: column; + overflow-y: auto; + flex: 1; +} + +#rtfc-body::-webkit-scrollbar { width: 3px; } +#rtfc-body::-webkit-scrollbar-track { background: transparent; } +#rtfc-body::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; } + +/* ── Section headers ── */ + +.rtfc-section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 14px 4px; +} + +.rtfc-section-label { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #555; +} + +.rtfc-toggle-btn { + background: none; + border: none; + color: #555; + font-size: 12px; + cursor: pointer; + padding: 0; + line-height: 1; + transition: color 0.15s; + font-family: inherit; +} + +.rtfc-toggle-btn:hover { color: #aaa; } + +/* ── Transcript ── */ + +#rtfc-transcript-section { + border-bottom: 0.5px solid rgba(255,255,255,0.06); + padding-bottom: 8px; +} + +#rtfc-transcript-feed { + max-height: 100px; + overflow-y: auto; + padding: 4px 14px; + font-size: 11.5px; + color: #888; + line-height: 1.7; + word-wrap: break-word; +} + +#rtfc-transcript-feed::-webkit-scrollbar { width: 3px; } +#rtfc-transcript-feed::-webkit-scrollbar-track { background: transparent; } +#rtfc-transcript-feed::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; } + +.rtfc-transcript-word { display: inline; } + +#rtfc-interim { + font-size: 11px; + color: #444; + font-style: italic; + padding: 0 14px; + min-height: 16px; + line-height: 1.5; +} + +/* ── Claims ── */ + +#rtfc-claims-section { + border-bottom: 0.5px solid rgba(255,255,255,0.06); + padding-bottom: 8px; +} + +#rtfc-claim-feed { + list-style: disc; + padding: 4px 14px 0 28px; + margin: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.rtfc-claim-bullet { + font-size: 11.5px; + line-height: 1.5; + padding-bottom: 1px; + transition: color 0.3s; +} + +.rtfc-claim-bullet--pending { color: #888; border-bottom: 1.5px solid #333; } +.rtfc-claim-bullet--green { color: #e8e8e8; border-bottom: 2px solid #4ade80; } +.rtfc-claim-bullet--teal { color: #e8e8e8; border-bottom: 2px solid #2dd4bf; } +.rtfc-claim-bullet--red { color: #e8e8e8; border-bottom: 2px solid #f87171; } +.rtfc-claim-bullet--yellow { color: #e8e8e8; border-bottom: 2px solid #fbbf24; } +.rtfc-claim-bullet--grey { color: #777; border-bottom: 2px solid #555; } + +/* ── Verdicts ── */ + +#rtfc-verdicts-section { + padding-bottom: 8px; +} + +#rtfc-verdicts { + padding: 4px 12px 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.rtfc-empty { + font-size: 11px; + color: #444; + text-align: center; + padding: 12px 0; + font-weight: 500; +} + +.rtfc-verdict { + border-radius: 10px; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 4px; + background: rgba(255,255,255,0.04); + border: 0.5px solid rgba(255,255,255,0.07); + animation: rtfc-in 0.18s ease; + overflow: hidden; +} + +@keyframes rtfc-in { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +.rtfc-verdict--green { border-color: rgba(74,222,128,0.2); } +.rtfc-verdict--teal { border-color: rgba(45,212,191,0.2); } +.rtfc-verdict--red { border-color: rgba(248,113,113,0.2); } +.rtfc-verdict--yellow { border-color: rgba(251,191,36,0.15); } +.rtfc-verdict--grey { border-color: rgba(255,255,255,0.07); } + +.rtfc-verdict--pending { opacity: 0.75; } + +.rtfc-verdict-header { + display: flex; + align-items: center; + gap: 6px; +} + +.rtfc-badge { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.rtfc-badge--green { color: #4ade80; } +.rtfc-badge--teal { color: #2dd4bf; } +.rtfc-badge--red { color: #f87171; } +.rtfc-badge--yellow { color: #fbbf24; } +.rtfc-badge--grey { color: #888; } + +.rtfc-verifying { + font-size: 9px; + color: #444; + font-style: italic; + animation: rtfc-pulse 1.2s ease-in-out infinite; +} + +@keyframes rtfc-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +.rtfc-confidence-right { + font-size: 10px; + color: #555; + font-weight: 500; + margin-left: auto; + margin-right: 4px; +} + +.rtfc-timestamp { + font-size: 10px; + color: #444; + font-weight: 500; + font-variant-numeric: tabular-nums; +} + +.rtfc-claim { + font-size: 11.5px; + color: #bbb; + font-style: italic; + line-height: 1.45; +} + +.rtfc-explanation { + font-size: 11px; + color: #777; + line-height: 1.5; +} + +.rtfc-sources { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 2px; +} + +.rtfc-sources a { + font-size: 10px; + color: #3b82f6; + text-decoration: none; + font-weight: 500; +} + +.rtfc-sources a:hover { text-decoration: underline; } + +.rtfc-source-text { + font-size: 10px; + color: #555; + font-weight: 500; +} + +/* ── Speaker confidence ── */ + +.rtfc-speaker-confidence { + margin-top: 4px; +} + +.rtfc-speaker-toggle { + background: none; + border: none; + padding: 0; + font-family: 'Inter', sans-serif; + font-size: 10px; + color: #555; + cursor: pointer; + display: flex; + align-items: center; + gap: 5px; + font-weight: 500; +} + +.rtfc-speaker-toggle:hover { color: #aaa; } + +.rtfc-speaker-dot { + width: 5px; + height: 5px; + border-radius: 50%; + flex-shrink: 0; +} + +.rtfc-speaker-dot--green { background: #4ade80; } +.rtfc-speaker-dot--red { background: #f87171; } +.rtfc-speaker-dot--yellow { background: #fbbf24; } + +.rtfc-speaker-arrow { font-size: 9px; color: #444; } + +.rtfc-speaker-explanation { + font-size: 10.5px; + color: #666; + line-height: 1.5; + margin-top: 4px; + padding-left: 10px; + border-left: 1.5px solid #333; +} + +/* ── Conviction breakdown ── */ + +.rtfc-conviction-row { + font-size: 10.5px; + color: #666; + line-height: 1.5; + margin-bottom: 3px; +} + +.rtfc-conviction-label { + font-weight: 600; + color: #555; + margin-right: 4px; +} + +.rtfc-conviction-summary { + font-size: 10.5px; + color: #555; + line-height: 1.5; + margin-top: 6px; + padding-top: 6px; + border-top: 0.5px solid #222; +} + +/* ── Speaker tag ── */ + +.rtfc-speaker-tag { + display: inline-block; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #fff; + padding: 2px 8px; + border-radius: 3px 3px 0 0; + margin-bottom: -1px; + align-self: flex-start; +} + +/* ── Acoustic features ── */ + +.rtfc-acoustic { + display: flex; + align-items: center; + gap: 8px; + margin-top: 2px; + margin-bottom: 2px; +} + +.rtfc-acoustic-label { + font-size: 10px; + color: #666; + font-weight: 500; +} + +.rtfc-acoustic-rate { + font-size: 10px; + color: #444; + font-variant-numeric: tabular-nums; +} + +/* ── Speaker editor chips ── */ + +#rtfc-speaker-editor { + display: flex; + gap: 4px; + align-items: center; +} + +.rtfc-speaker-chip { + display: inline-flex; + align-items: center; + border: 1px solid; + border-radius: 4px; + padding: 1px 4px; +} + +.rtfc-speaker-chip-input { + background: transparent; + border: none; + outline: none; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + width: 52px; + font-family: 'Inter', sans-serif; + cursor: text; + padding: 0; +} + +/* ── Speaker confirmation banner ── */ + +.rtfc-speaker-banner { + margin: 6px 12px; + padding: 10px 12px; + background: rgba(59, 130, 246, 0.08); + border: 0.5px solid rgba(59, 130, 246, 0.3); + border-radius: 8px; + animation: rtfc-in 0.18s ease; +} + +.rtfc-speaker-banner-text { + font-size: 11px; + font-weight: 600; + color: #aaa; + margin-bottom: 4px; +} + +.rtfc-speaker-banner-sample { + font-size: 10px; + color: #555; + font-style: italic; + margin-bottom: 8px; + line-height: 1.4; +} + +.rtfc-speaker-banner-buttons { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.rtfc-speaker-banner-btn { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + padding: 4px 10px; + border-radius: 4px; + border: 1px solid rgba(255,255,255,0.15); + background: rgba(255,255,255,0.06); + color: #e8e8e8; + cursor: pointer; + font-family: 'Inter', sans-serif; + transition: background 0.15s; +} + +.rtfc-speaker-banner-btn:hover { + background: rgba(255,255,255,0.12); +} + +.rtfc-speaker-banner-btn--skip { + color: #555; + border-color: transparent; +} + +/* ── Error toast ── */ + +.rtfc-error-toast { + display: flex; + align-items: center; + gap: 7px; + padding: 8px 12px; + background: rgba(248, 113, 113, 0.12); + border-bottom: 0.5px solid rgba(248, 113, 113, 0.3); + font-size: 11px; + color: #f87171; + flex-shrink: 0; +} + +.rtfc-error-icon { font-size: 12px; flex-shrink: 0; } + +.rtfc-error-msg { flex: 1; line-height: 1.4; } + +.rtfc-error-close { + background: none; + border: none; + color: #f87171; + cursor: pointer; + font-size: 11px; + padding: 0; + opacity: 0.6; + flex-shrink: 0; +} +.rtfc-error-close:hover { opacity: 1; } \ No newline at end of file diff --git a/realtime-factcheck/src/content/overlay.js b/realtime-factcheck/src/content/overlay.js new file mode 100644 index 0000000..558249a --- /dev/null +++ b/realtime-factcheck/src/content/overlay.js @@ -0,0 +1,674 @@ +// overlay.js + +console.log('[overlay] content script loaded'); + +let panel = null; +let transcriptFeedEl = null; +let interimEl = null; +let claimFeedEl = null; +let verdictListEl = null; +let transcriptCollapsed = false; +const pendingCards = new Map(); +const pendingCardTimes = new Map(); + +// expire pending cards after 90 seconds +setInterval(() => { + const now = Date.now(); + for (const [key, time] of pendingCardTimes) { + if (now - time > 90000) { + const card = pendingCards.get(key); + if (card) { + card.classList.remove('rtfc-verdict--pending'); + const verifying = card.querySelector('.rtfc-verifying'); + if (verifying) verifying.textContent = '⚠ unverified'; + } + pendingCards.delete(key); + pendingCardTimes.delete(key); + } + } +}, 15000); + +let lastTranscriptTimestamp = ''; +const sentenceTimestamps = []; +const MAX_TIMESTAMP_BUFFER = 10; + +// ── Speaker state ──────────────────────────────────────────────────────────── +let speakers = []; + +// ── Speaker colors ──────────────────────────────────────────────────────────── +const SPEAKER_COLORS = [ + '#3b82f6', + '#ef4444', + '#f59e0b', + '#10b981', + '#8b5cf6', + '#f97316', +]; +const speakerColorMap = new Map(); + +function getSpeakerColor(name) { + if (!speakerColorMap.has(name)) { + const idx = speakerColorMap.size % SPEAKER_COLORS.length; + speakerColorMap.set(name, SPEAKER_COLORS[idx]); + } + return speakerColorMap.get(name); +} + +// ── Speaker parsing ─────────────────────────────────────────────────────────── +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); + 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])]; + } + return []; +} + +let lastActiveSpeaker = null; // track most recently labeled speaker + +function getClaimSpeaker(claimText) { + if (!speakers.length) return 'Other'; + const lower = claimText.toLowerCase(); + + // direct name match + for (const speaker of speakers) { + if (lower.includes(speaker.toLowerCase())) return speaker; + } + + // partial name match (handles "Vice President Harris" → "Harris") + for (const speaker of speakers) { + const parts = speaker.toLowerCase().split(' '); + if (parts.some(p => p.length > 3 && lower.includes(p))) return speaker; + } + + // fallback: use last active speaker for vague references + if (lastActiveSpeaker) return lastActiveSpeaker; + + return 'Other'; +} + +// ── Speaker ID confirmation ────────────────────────────────────────────────── + +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; + // if speakers not yet parsed from title, retry once after 1s + if (!speakers.length) { + setTimeout(() => showSpeakerBanner(speakerId, sample), 1000); + return; + } + pendingSpeakerIds.add(speakerId); + + const banner = document.createElement('div'); + banner.className = 'rtfc-speaker-banner'; + banner.innerHTML = + '
New speaker detected — who is this?
' + + '
"' + 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); + if (name) { + confirmedSpeakerMap[id] = name; + chrome.runtime.sendMessage({ + type: 'SPEAKER_NAMES', + speakerIdToName: { [id]: name }, + }); + } + pendingSpeakerIds.delete(id); + if (!name) confirmedSpeakerMap[id] = null; + banner.remove(); + // retroactively tag all existing grounded cards now that we have more info + retryTagAllCards(); + }); + }); + + // insert above verdicts + const verdictsSection = panel?.querySelector('#rtfc-verdicts-section'); + if (verdictsSection) verdictsSection.insertAdjacentElement('beforebegin', banner); +} + +// ── 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; +} + +function retryTagAllCards() { + // retroactively tag all grounded cards once speakers are confirmed + if (!verdictListEl) return; + verdictListEl.querySelectorAll('.rtfc-verdict:not(.rtfc-verdict--pending)').forEach(card => { + const sid = card.dataset.speakerid; + if (sid === undefined) return; + const rawName = confirmedSpeakerMap[sid]; + if (!rawName) return; // skipped or not confirmed + const name = normalizeSpeakerName(rawName); + // add or update tag + let tag = card.querySelector('.rtfc-speaker-tag'); + if (tag) { + tag.textContent = name; + tag.style.background = getSpeakerColor(name); + } else { + const color = getSpeakerColor(name); + tag = document.createElement('div'); + tag.className = 'rtfc-speaker-tag'; + tag.style.background = color; + tag.textContent = name; + card.insertBefore(tag, card.firstChild); + } + }); +} + +// ── Speaker editor ─────────────────────────────────────────────────────────── + +function sendSpeakerMap() { + // Deepgram speaker IDs are assigned in order of first appearance + // We map ID 0 → speakers[0], ID 1 → speakers[1], etc. + const speakerIdToName = {}; + speakers.forEach((name, i) => { speakerIdToName[i] = name; }); + chrome.runtime.sendMessage({ type: 'SPEAKER_NAMES', speakerIdToName }); +} + +function renderSpeakerEditor() { + const el = panel?.querySelector('#rtfc-speaker-editor'); + if (!el || !speakers.length) return; + + el.innerHTML = speakers.map((name, i) => { + const color = getSpeakerColor(name); + return '' + + '' + + ''; + }).join(''); + + el.querySelectorAll('.rtfc-speaker-chip-input').forEach(input => { + input.addEventListener('change', (e) => { + const idx = parseInt(e.target.dataset.idx); + const oldName = speakers[idx]; + const newName = e.target.value.trim() || oldName; + if (newName === oldName) return; + + // update color map + if (speakerColorMap.has(oldName)) { + speakerColorMap.set(newName, speakerColorMap.get(oldName)); + speakerColorMap.delete(oldName); + } + + speakers[idx] = newName; + e.target.style.color = getSpeakerColor(newName); + e.target.closest('.rtfc-speaker-chip').style.borderColor = getSpeakerColor(newName); + e.target.closest('.rtfc-speaker-chip').style.color = getSpeakerColor(newName); + sendSpeakerMap(); // update service worker with new names + + // re-render all verdict cards to update speaker tags + const cards = verdictListEl?.querySelectorAll('.rtfc-speaker-tag'); + if (cards) { + cards.forEach(tag => { + if (tag.textContent === oldName) { + tag.textContent = newName; + tag.style.background = getSpeakerColor(newName); + } + }); + } + }); + // select all on focus for easy editing + input.addEventListener('focus', e => e.target.select()); + }); +} + +// ── Error toast ────────────────────────────────────────────────────────────── + +function showError(message) { + if (!panel) return; + const existing = panel.querySelector('.rtfc-error-toast'); + if (existing) existing.remove(); + + const toast = document.createElement('div'); + toast.className = 'rtfc-error-toast'; + toast.innerHTML = + '⚠' + + '' + message + '' + + ''; + + toast.querySelector('.rtfc-error-close').addEventListener('click', () => toast.remove()); + panel.querySelector('#rtfc-header').insertAdjacentElement('afterend', toast); + + // auto-dismiss after 8 seconds unless it's a fatal error + if (!message.includes('failed') && !message.includes('key')) { + setTimeout(() => toast.remove(), 8000); + } +} + +// ── Panel ───────────────────────────────────────────────────────────────────── +function createPanel() { + if (panel) return; + + panel = document.createElement('div'); + panel.id = 'rtfc-panel'; + panel.innerHTML = [ + '
', + 'InTruth', + '
', + '', + '', + '
', + '
', + '
', + '
', + '
', + '', + '', + '
', + '
', + '

', + '
', + '
', + '
', + '', + '
', + '
    ', + '
    ', + '
    ', + '
    ', + '', + '
    ', + '
    ', + '
    ', + '

    Verdicts will appear here...

    ', + '
    ', + '
    ', + '
    ', + ].join(''); + + document.body.appendChild(panel); + + transcriptFeedEl = panel.querySelector('#rtfc-transcript-feed'); + interimEl = panel.querySelector('#rtfc-interim'); + claimFeedEl = panel.querySelector('#rtfc-claim-feed'); + verdictListEl = panel.querySelector('#rtfc-verdicts'); + + panel.querySelector('#rtfc-close').addEventListener('click', () => { + chrome.runtime.sendMessage({ type: 'STOP_FACTCHECK' }); + removePanel(); + }); + + panel.querySelector('#rtfc-export').addEventListener('click', () => exportPDF()); + + makeDraggable(panel); + + panel.querySelector('#rtfc-transcript-toggle').addEventListener('click', () => { + transcriptCollapsed = !transcriptCollapsed; + transcriptFeedEl.style.display = transcriptCollapsed ? 'none' : ''; + interimEl.style.display = transcriptCollapsed ? 'none' : ''; + panel.querySelector('#rtfc-transcript-toggle').textContent = transcriptCollapsed ? '▸' : '▾'; + }); +} + +function removePanel() { + panel?.remove(); + panel = null; + transcriptFeedEl = null; + interimEl = null; + claimFeedEl = null; + verdictListEl = null; + transcriptCollapsed = false; + pendingCards.clear(); + pendingCardTimes.clear(); + speakers = []; + speakerColorMap.clear(); + sentenceTimestamps.length = 0; + lastTranscriptTimestamp = ''; + lastActiveSpeaker = null; + Object.keys(confirmedSpeakerMap).forEach(k => delete confirmedSpeakerMap[k]); + pendingSpeakerIds.clear(); +} + +// ── Transcript ──────────────────────────────────────────────────────────────── +function addTranscriptText(text) { + if (!transcriptFeedEl) return; + const span = document.createElement('span'); + span.textContent = text + ' '; + span.className = 'rtfc-transcript-word'; + transcriptFeedEl.appendChild(span); + transcriptFeedEl.scrollTop = transcriptFeedEl.scrollHeight; +} + +function updateInterim(text) { + if (!interimEl) return; + interimEl.textContent = text; +} + +function clearInterim() { + if (!interimEl) return; + interimEl.textContent = ''; +} + +// ── Claims ──────────────────────────────────────────────────────────────────── +function addClaimBullet(claim) { + if (!claimFeedEl) return; + const li = document.createElement('li'); + li.className = 'rtfc-claim-bullet rtfc-claim-bullet--pending'; + li.dataset.claim = claim.toLowerCase().slice(0, 40); + li.textContent = claim; + claimFeedEl.appendChild(li); + return li; +} + +function applyVerdictToBullet(claim, verdict, confidence) { + if (!claimFeedEl) return; + const color = colorForVerdict(verdict, confidence); + const claimWords = new Set(claim.toLowerCase().split(/\s+/).filter(w => w.length >= 4)); + const bullets = claimFeedEl.querySelectorAll('.rtfc-claim-bullet'); + let bestLi = null, bestScore = 0; + for (const li of bullets) { + const bulletWords = (li.textContent || '').toLowerCase().split(/\s+/).filter(w => w.length >= 4); + const overlap = bulletWords.filter(w => claimWords.has(w)).length; + const score = overlap / Math.max(claimWords.size, bulletWords.length); + if (score > bestScore) { bestScore = score; bestLi = li; } + } + if (bestLi && bestScore >= 0.3) { + bestLi.className = 'rtfc-claim-bullet rtfc-claim-bullet--' + color; + } +} + +// ── Verdicts ────────────────────────────────────────────────────────────────── +function colorForVerdict(verdict, confidence) { + if (confidence === 'LOW') return 'yellow'; + if (verdict === 'TRUE') return 'green'; + if (verdict === 'SUBSTANTIALLY TRUE') return 'teal'; + if (verdict === 'FALSE') return 'red'; + if (verdict === 'MISLEADING') return 'yellow'; + if (verdict === 'UNVERIFIABLE') return 'grey'; + return 'grey'; +} + +function buildLexicalRows(lexical) { + if (!lexical) return ''; + const rows = []; + const r = lexical.rates || {}; + if (r.hedging > 0) + rows.push('
    Hedging language: ' + r.hedging + '% rate — e.g. "I think", "maybe", "probably"
    '); + if (r.certainty > 0) + rows.push('
    Certainty markers: ' + r.certainty + '% rate — e.g. "definitely", "always"
    '); + if (r.filler > 0) + rows.push('
    Filler words: ' + r.filler + '% rate — e.g. "um", "like", "you know"
    '); + if (r.emotional > 0) + rows.push('
    Emotional language: ' + r.emotional + '% rate
    '); + if (r.exclusive > 0) + rows.push('
    Qualifying words: ' + r.exclusive + '% rate — e.g. "but", "except"
    '); + if (r.firstPersonSg > 0) + rows.push('
    First-person singular: ' + r.firstPersonSg + '% rate
    '); + if (lexical.wordsPerSecond != null) { + const rateDesc = lexical.wordsPerSecond > 3.5 ? 'fast' : lexical.wordsPerSecond < 2 ? 'slow' : 'moderate'; + rows.push('
    Speech rate: ' + lexical.wordsPerSecond + ' w/s (' + rateDesc + ')
    '); + } + return rows.join(''); +} + +function buildCard(result) { + const color = colorForVerdict(result.verdict, result.confidence); + const convictionColor = result.speaker_confidence === 'HIGH' ? 'green' + : result.speaker_confidence === 'LOW' ? 'red' + : 'yellow'; + + const card = document.createElement('div'); + card.className = 'rtfc-verdict rtfc-verdict--' + color + (result.pending ? ' rtfc-verdict--pending' : ''); + card.dataset.claim = result.claim.toLowerCase().slice(0, 40); + if (result.dominantSpeakerId !== null && result.dominantSpeakerId !== undefined) { + card.dataset.speakerid = String(result.dominantSpeakerId); + } + card._resultData = result; + + const sourcesHTML = (result.sources ?? []).map((url, i) => { + const isUrl = url.startsWith('http://') || url.startsWith('https://'); + return isUrl + ? 'Source ' + (i + 1) + '' + : '' + url + ''; + }).join(''); + + 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 + 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 = '
    ' + speakerName + '
    '; + } + } + + card.innerHTML = [ + speakerTag, + '
    ', + '' + result.verdict + '', + result.pending ? '⟳ verifying...' : '', + '' + result.confidence + ' certainty', + '' + (result._timestamp || '') + '', + '
    ', + '

    "' + result.claim + '"

    ', + '

    ' + result.explanation + '

    ', + '
    ', + '', + '', + '
    ', + (sourcesHTML && sourcesHTML.trim()) ? '
    ' + sourcesHTML + '
    ' : '', + ].join(''); + + const toggleBtn = card.querySelector('.rtfc-speaker-toggle'); + const reasons = card.querySelector('.rtfc-speaker-explanation'); + const arrow = card.querySelector('.rtfc-speaker-arrow'); + toggleBtn.addEventListener('click', () => { + const open = reasons.style.display === 'none'; + reasons.style.display = open ? 'block' : 'none'; + arrow.textContent = open ? '▴' : '▾'; + }); + + return card; +} + +function findPendingCard(claim) { + const key = claim.toLowerCase().slice(0, 40); + if (pendingCards.has(key)) return pendingCards.get(key); + + const claimWords = new Set(claim.toLowerCase().split(/\s+/).filter(w => w.length >= 4)); + let bestCard = null, bestScore = 0; + for (const [cardKey, card] of pendingCards) { + const cardWords = cardKey.split(/\s+/).filter(w => w.length >= 4); + const overlap = cardWords.filter(w => claimWords.has(w)).length; + const score = overlap / Math.max(claimWords.size, cardWords.length); + if (score > bestScore) { bestScore = score; bestCard = card; } + } + if (bestScore >= 0.4) return bestCard; + return verdictListEl?.querySelector('.rtfc-verdict--pending'); +} + +function getVideoTimestamp() { + const video = document.querySelector('video'); + if (!video) return ''; + const s = Math.floor(video.currentTime); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + if (h > 0) return h + ':' + String(m).padStart(2,'0') + ':' + String(sec).padStart(2,'0'); + return String(m).padStart(2,'0') + ':' + String(sec).padStart(2,'0'); +} + +function getClaimTimestamp(claim) { + if (!sentenceTimestamps.length) return lastTranscriptTimestamp || getVideoTimestamp(); + const claimWords = new Set(claim.toLowerCase().split(/\s+/).filter(w => w.length >= 4)); + let bestMatch = null, bestScore = 0; + for (const entry of sentenceTimestamps) { + const sentWords = entry.text.toLowerCase().split(/\s+/).filter(w => w.length >= 4); + const overlap = sentWords.filter(w => claimWords.has(w)).length; + const score = overlap / Math.max(claimWords.size, sentWords.length); + if (score > bestScore) { bestScore = score; bestMatch = entry; } + } + return bestScore >= 0.3 ? bestMatch.timestamp : (lastTranscriptTimestamp || getVideoTimestamp()); +} + +function addVerdict(result) { + if (!verdictListEl) return; + verdictListEl.querySelector('.rtfc-empty')?.remove(); + applyVerdictToBullet(result.claim, result.verdict, result.confidence); + if (!result._timestamp) result._timestamp = getClaimTimestamp(result.claim); + const card = buildCard(result); + if (result.pending) { + const key = result.claim.toLowerCase().slice(0, 40); + pendingCards.set(key, card); + pendingCardTimes.set(key, Date.now()); + } else { + logVerdict(result); + } + verdictListEl.prepend(card); +} + +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 newCard = buildCard(result); + if (existing) { + existing.replaceWith(newCard); + for (const [k, v] of pendingCards) { + if (v === existing) { pendingCards.delete(k); pendingCardTimes.delete(k); break; } + } + } else { + verdictListEl?.querySelector('.rtfc-empty')?.remove(); + verdictListEl?.prepend(newCard); + } + applyVerdictToBullet(result.claim, result.verdict, result.confidence); + logVerdict(result); +} + +function makeDraggable(panel) { + const header = panel.querySelector('#rtfc-header'); + let isDragging = false, startX, startY, startLeft, startTop; + header.addEventListener('mousedown', (e) => { + if (e.target.id === 'rtfc-close' || e.target.id === 'rtfc-export') return; + isDragging = true; + startX = e.clientX; startY = e.clientY; + const rect = panel.getBoundingClientRect(); + startLeft = rect.left; startTop = rect.top; + header.style.cursor = 'grabbing'; + e.preventDefault(); + }); + document.addEventListener('mousemove', (e) => { + if (!isDragging) return; + panel.style.right = 'unset'; + panel.style.left = Math.max(0, startLeft + e.clientX - startX) + 'px'; + panel.style.top = Math.max(0, startTop + e.clientY - startY) + 'px'; + }); + document.addEventListener('mouseup', () => { isDragging = false; header.style.cursor = 'grab'; }); +} + +// ── Messages ────────────────────────────────────────────────────────────────── +chrome.runtime.onMessage.addListener((msg) => { + console.log('[overlay] message received:', msg.type); + switch (msg.type) { + + case 'START_FACTCHECK': + createPanel(); + startSession(); + speakers = parseSpeakersFromTitle(document.title || ''); + speakerColorMap.clear(); + chrome.runtime.sendMessage({ + type: 'PAGE_TITLE', + title: document.title || '', + date: (() => { + const el = document.querySelector('meta[itemprop="uploadDate"]') || + document.querySelector('meta[property="og:updated_time"]'); + return el ? new Date(el.content).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : ''; + })(), + }); + renderSpeakerEditor(); + break; + + case 'STOP_FACTCHECK': + stopSession(); + removePanel(); + break; + + case 'TRANSCRIPT_RESULT': + if (msg.interim) { + updateInterim(msg.text); + } else if (msg.isFinal) { + const ts = getVideoTimestamp(); + lastTranscriptTimestamp = ts; + sentenceTimestamps.push({ text: msg.text, timestamp: ts }); + if (sentenceTimestamps.length > MAX_TIMESTAMP_BUFFER) sentenceTimestamps.shift(); + clearInterim(); + // strip [Speaker N] prefix before displaying + const displayText = msg.text.replace(/^\[.*?\]\s*/, ''); + addTranscriptText(displayText); + // track which speaker is active from label + const labelMatch = msg.text.match(/^\[(.+?)\]/); + if (labelMatch && speakers.includes(labelMatch[1])) { + lastActiveSpeaker = labelMatch[1]; + } + } + break; + + case 'NEW_SPEAKER': + if (panel) showSpeakerBanner(msg.speakerId, msg.sample || ''); + break; + + case 'PIPELINE_ERROR': + showError(msg.message || 'An error occurred in the fact-checking pipeline.'); + break; + + case 'NEW_VERDICT': + if (msg.results) { + for (const result of msg.results) { + addClaimBullet(result.claim); + addVerdict(result); + } + } + break; + + case 'UPDATE_VERDICTS': + if (msg.results) { + for (const result of msg.results) { + updateVerdict(result); + } + } + break; + } +}); \ No newline at end of file diff --git a/realtime-factcheck/src/content/session-export.js b/realtime-factcheck/src/content/session-export.js new file mode 100644 index 0000000..678a5da --- /dev/null +++ b/realtime-factcheck/src/content/session-export.js @@ -0,0 +1,169 @@ +// session-export.js +// Handles session logging and PDF export. +// Loaded after overlay.js — exposes logVerdict(), startSession(), stopSession(), exportPDF() as globals. + +const sessionLog = []; +let sessionStartTime = null; + +function logVerdict(result) { + sessionLog.push({ + timestamp: new Date().toISOString(), + secondsElapsed: sessionStartTime ? Math.round((Date.now() - sessionStartTime) / 1000) : 0, + claim: result.claim, + verdict: result.verdict, + confidence: result.confidence, + explanation: result.explanation, + speakerConfidence: result.speaker_confidence, + speakerExplanation: result.speaker_confidence_explanation, + speakerName: result.speaker || null, + sources: result.sources ?? [], + }); +} + +function startSession() { + sessionLog.length = 0; + sessionStartTime = Date.now(); +} + +function stopSession() { + sessionStartTime = null; +} + +function exportPDF() { + if (!sessionLog.length) { + alert('No claims detected in this session yet.'); + return; + } + + const pageTitle = document.title || 'Fact Check Session'; + const exportDate = new Date().toLocaleString(); + + const verdictColor = (v, c) => { + if (c === 'LOW') return '#b45309'; + if (v === 'TRUE') return '#15803d'; + if (v === 'SUBSTANTIALLY TRUE') return '#0d9488'; + if (v === 'FALSE') return '#b91c1c'; + if (v === 'MISLEADING') return '#b45309'; + return '#6b7280'; + }; + + // group by speaker + const speakerGroups = {}; + const speakerOrder = []; + sessionLog.forEach((entry, i) => { + // filter out unresolved Speaker N labels — group under Unknown + const rawSpk = entry.speakerName; + const spk = (rawSpk && !rawSpk.match(/^Speaker\s*\d+$/i) && rawSpk !== 'Other') + ? rawSpk + : 'Unknown'; + if (!speakerGroups[spk]) { speakerGroups[spk] = []; speakerOrder.push(spk); } + speakerGroups[spk].push({ entry, i }); + }); + + const speakerColors = ['#3b82f6', '#ef4444', '#f59e0b', '#10b981', '#8b5cf6', '#f97316']; + + const claimsHTML = speakerOrder.map((spk, spkIdx) => { + const color = spk !== 'Other' ? speakerColors[spkIdx % speakerColors.length] : '#888'; + const headerHTML = '
    ' + + '' + spk + '' + + '' + speakerGroups[spk].length + ' claim' + (speakerGroups[spk].length !== 1 ? 's' : '') + '' + + '
    '; + + const cardsHTML = speakerGroups[spk].map(({ entry, i }) => { + const minutes = Math.floor(entry.secondsElapsed / 60); + const seconds = entry.secondsElapsed % 60; + const timestamp = String(minutes).padStart(2, '0') + ':' + String(seconds).padStart(2, '0'); + const vcolor = verdictColor(entry.verdict, entry.confidence); + + const sourcesHTML = entry.sources.length + ? '
    Sources:' + + entry.sources.map((url, j) => + 'Source ' + (j + 1) + '' + ).join('') + '
    ' + : ''; + + return '
    ' + + '
    ' + + '#' + (i + 1) + '' + + '' + entry.verdict + '' + + '' + entry.confidence + ' certainty' + + '' + timestamp + '' + + '
    ' + + '
    "' + entry.claim + '"
    ' + + '
    ' + entry.explanation + '
    ' + + '
    Speaker conviction: ' + + (entry.speakerConfidence || 'N/A') + + '
    ' + + sourcesHTML + + '
    '; + }).join(''); + + return headerHTML + cardsHTML; + }).join(''); + + const trueCount = sessionLog.filter(e => e.verdict === 'TRUE').length; + const subTrueCount = sessionLog.filter(e => e.verdict === 'SUBSTANTIALLY TRUE').length; + const falseCount = sessionLog.filter(e => e.verdict === 'FALSE').length; + const misleadingCount = sessionLog.filter(e => e.verdict === 'MISLEADING').length; + const unverifiableCount = sessionLog.filter(e => e.verdict === 'UNVERIFIABLE').length; + + const html = '' + + 'Fact Check Report' + + '
    ' + + '
    Fact Check Report
    ' + + '
    ' + + '📺 ' + pageTitle + '' + + '🕐 ' + exportDate + '' + + '📋 ' + sessionLog.length + ' claim' + (sessionLog.length !== 1 ? 's' : '') + ' detected' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + trueCount + 'True
    ' + + '
    ' + subTrueCount + 'Substantially True
    ' + + '
    ' + falseCount + 'False
    ' + + '
    ' + misleadingCount + 'Misleading
    ' + + '
    ' + unverifiableCount + 'Unverifiable
    ' + + '
    ' + + '
    Claims (' + sessionLog.length + ')
    ' + + claimsHTML + + ''; + + // window.open is blocked in extensions — use blob URL instead + const blob = new Blob([html], { type: 'text/html' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'factcheck-report.html'; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} \ No newline at end of file diff --git a/realtime-factcheck/src/offscreen/offscreen-ex.js b/realtime-factcheck/src/offscreen/offscreen-ex.js new file mode 100644 index 0000000..65ec098 --- /dev/null +++ b/realtime-factcheck/src/offscreen/offscreen-ex.js @@ -0,0 +1,200 @@ +// offscreen.js +// Captures tab audio via tabCapture and streams to Deepgram WebSocket. +// Deepgram handles transcription — no Web Speech API. + +const DEEPGRAM_KEY = ''; + +let mediaStream = null; +let audioContext = null; +let processor = null; +let socket = null; +let active = false; + +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg.type === 'START_CAPTURE') { + startCapture(msg.streamId) + .then(() => sendResponse({ ok: true })) + .catch(err => { + console.error('[offscreen] error:', err); + sendResponse({ ok: false, error: err.message }); + }); + return true; + } + + if (msg.type === 'STOP_CAPTURE') { + stopCapture(); + sendResponse({ ok: true }); + } +}); + +let utteranceBuffer = ''; + +async function startCapture(streamId) { + if (active) stopCapture(); + active = true; + + // get tab audio stream + mediaStream = await navigator.mediaDevices.getUserMedia({ + audio: { + mandatory: { + chromeMediaSource: 'tab', + chromeMediaSourceId: streamId, + }, + }, + video: false, + }); + + // connect deepgram websocket + socket = new WebSocket( + 'wss://api.deepgram.com/v1/listen?' + [ + 'encoding=linear16', + 'sample_rate=16000', + 'channels=1', + 'model=nova-2', + 'language=en-US', + 'punctuate=true', + 'interim_results=true', + 'utterance_end_ms=2500', + 'smart_format=true', + 'vad_events=true', + 'diarize=true', + ].join('&'), + ['token', DEEPGRAM_KEY] + ); + + socket.onopen = () => { + console.log('[offscreen] deepgram connected'); + startAudioPipeline(); + }; + + socket.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + // handle utterance end event + if (data.type === 'UtteranceEnd') { + chrome.runtime.sendMessage({ type: 'UTTERANCE_END' }); + return; + } + + const result = data.channel?.alternatives?.[0]; + if (!result || !result.transcript) return; + + const text = result.transcript.trim(); + const isFinal = data.is_final; + const speech = data.speech_final; + const speaker = result.words?.[0]?.speaker ?? null; + + if (!text) return; + + if (isFinal && speech) { + // speech_final = end of utterance — send full accumulated text as final + const fullText = utteranceBuffer ? utteranceBuffer + ' ' + text : text; + utteranceBuffer = ''; + chrome.runtime.sendMessage({ + type: 'TRANSCRIPT_RESULT', + text: fullText.trim(), + isFinal: true, + interim: false, + speaker, + }); + } else if (isFinal && !speech) { + // is_final but not speech_final — accumulate and show as interim + utteranceBuffer += (utteranceBuffer ? ' ' : '') + text; + chrome.runtime.sendMessage({ + type: 'TRANSCRIPT_RESULT', + text: utteranceBuffer, + isFinal: false, + interim: true, + speaker, + }); + } else { + // regular interim — show as-is + chrome.runtime.sendMessage({ + type: 'TRANSCRIPT_RESULT', + text, + isFinal: false, + interim: true, + speaker, + }); + } + + } catch (err) { + console.error('[offscreen] message parse error:', err); + } + }; + + socket.onerror = (err) => { + console.error('[offscreen] deepgram error:', err); + chrome.runtime.sendMessage({ type: 'PIPELINE_ERROR', message: 'Transcription error — check your Deepgram key.' }).catch(() => {}); + }; + socket.onclose = (e) => { + console.log('[offscreen] deepgram closed:', e.code, e.reason); + if (e.code === 1008 || e.code === 1011) { + // auth error or server error — don't reconnect, notify user + chrome.runtime.sendMessage({ type: 'PIPELINE_ERROR', message: 'Deepgram connection failed (code ' + e.code + '). Check your API key.' }).catch(() => {}); + return; + } + if (active) { + chrome.runtime.sendMessage({ type: 'PIPELINE_ERROR', message: 'Transcription disconnected — reconnecting...' }).catch(() => {}); + // request a fresh stream ID from service worker instead of reusing expired one + setTimeout(() => { + chrome.runtime.sendMessage({ type: 'REQUEST_NEW_STREAM' }).catch(() => {}); + }, 1000); + } + }; +} + +function startAudioPipeline() { + audioContext = new AudioContext({ sampleRate: 16000 }); + const source = audioContext.createMediaStreamSource(mediaStream); + + // reconnect so user still hears video + source.connect(audioContext.destination); + + processor = audioContext.createScriptProcessor(4096, 1, 1); + processor.onaudioprocess = (e) => { + if (socket?.readyState !== WebSocket.OPEN) return; + + const float32 = e.inputBuffer.getChannelData(0); + + // convert float32 to int16 for Deepgram + const int16 = new Int16Array(float32.length); + for (let i = 0; i < float32.length; i++) { + int16[i] = Math.max(-32768, Math.min(32767, float32[i] * 32768)); + } + + socket.send(int16.buffer); + }; + + source.connect(processor); + processor.connect(audioContext.destination); + console.log('[offscreen] audio pipeline started'); +} + +function stopCapture() { + active = false; + utteranceBuffer = ''; + + if (socket) { + socket.close(); + socket = null; + } + + if (processor) { + processor.disconnect(); + processor = null; + } + + if (mediaStream) { + mediaStream.getTracks().forEach(t => t.stop()); + mediaStream = null; + } + + if (audioContext) { + audioContext.close(); + audioContext = null; + } + + console.log('[offscreen] stopped'); +} \ No newline at end of file diff --git a/realtime-factcheck/src/offscreen/offscreen.html b/realtime-factcheck/src/offscreen/offscreen.html new file mode 100644 index 0000000..155dca7 --- /dev/null +++ b/realtime-factcheck/src/offscreen/offscreen.html @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/realtime-factcheck/src/popup/popup.css b/realtime-factcheck/src/popup/popup.css new file mode 100644 index 0000000..6fbdbf5 --- /dev/null +++ b/realtime-factcheck/src/popup/popup.css @@ -0,0 +1,113 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap'); + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 13px; + background: rgba(15, 15, 15, 0.82); + color: #e8e8e8; + width: 260px; +} + +.popup { + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.header { + display: flex; + align-items: center; + gap: 7px; + font-size: 13px; + font-weight: 600; + letter-spacing: -0.01em; + color: #e8e8e8; +} + +.logo { font-size: 16px; opacity: 0.7; } + +.status { + font-size: 11px; + font-weight: 500; + color: #666; + letter-spacing: 0.01em; +} + +.status.active { color: #4ade80; } + +/* ── Key fields ── */ + +.keys-section { + display: flex; + flex-direction: column; + gap: 7px; +} + +.key-field { + display: flex; + flex-direction: column; + gap: 3px; +} + +.key-label { + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: #555; +} + +.key-input { + background: rgba(255,255,255,0.05); + border: 0.5px solid rgba(255,255,255,0.1); + border-radius: 5px; + color: #e8e8e8; + font-family: 'Inter', monospace; + font-size: 10px; + padding: 5px 8px; + width: 100%; + outline: none; + transition: border-color 0.15s; +} + +.key-input::placeholder { color: #444; } +.key-input:focus { border-color: rgba(255,255,255,0.25); } + +.key-input.saved { + border-color: rgba(74, 222, 128, 0.3); + color: #888; +} + +.key-hint { + font-size: 10px; + color: #555; + min-height: 14px; +} + +.key-hint.error { color: #f87171; } +.key-hint.ok { color: #4ade80; } + +/* ── Toggle button ── */ + +.toggle-btn { + background: rgba(30,30,30,0.9); + color: #f5f5f3; + border: none; + border-radius: 8px; + padding: 9px 14px; + font-family: 'Inter', sans-serif; + font-size: 12px; + font-weight: 500; + letter-spacing: -0.01em; + cursor: pointer; + transition: opacity 0.15s; + text-align: left; + width: 100%; +} + +.toggle-btn:hover { opacity: 0.85; } +.toggle-btn.active { background: #166534; } +.toggle-btn:disabled { opacity: 0.4; cursor: not-allowed; } \ No newline at end of file diff --git a/realtime-factcheck/src/popup/popup.html b/realtime-factcheck/src/popup/popup.html new file mode 100644 index 0000000..8c3e45e --- /dev/null +++ b/realtime-factcheck/src/popup/popup.html @@ -0,0 +1,28 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/realtime-factcheck/src/popup/popup.js b/realtime-factcheck/src/popup/popup.js new file mode 100644 index 0000000..9c26b65 --- /dev/null +++ b/realtime-factcheck/src/popup/popup.js @@ -0,0 +1,87 @@ +// popup.js + +const toggleBtn = document.getElementById('toggleBtn'); +const statusEl = document.getElementById('status'); +const anthropicEl = document.getElementById('anthropicKey'); +const keyHint = document.getElementById('keyHint'); +const keysSection = document.getElementById('keysSection'); + +let isActive = false; + +// ── Load saved key ──────────────────────────────────────────────────────────── + +chrome.storage.local.get(['anthropicKey'], (data) => { + if (data.anthropicKey) { anthropicEl.value = data.anthropicKey; anthropicEl.classList.add('saved'); } + updateHint(); +}); + +// ── Save key on change ──────────────────────────────────────────────────────── + +anthropicEl.addEventListener('input', () => { + anthropicEl.classList.remove('saved'); + updateHint(); +}); +anthropicEl.addEventListener('change', () => { + chrome.storage.local.set({ anthropicKey: anthropicEl.value.trim() }); + anthropicEl.classList.add('saved'); + updateHint(); +}); + +function updateHint() { + if (!anthropicEl.value.trim()) { + keyHint.textContent = 'Enter your Anthropic API key to start.'; + keyHint.className = 'key-hint'; + toggleBtn.disabled = isActive ? false : true; + } else { + keyHint.textContent = 'Key saved.'; + keyHint.className = 'key-hint ok'; + toggleBtn.disabled = false; + } +} + +// ── Status ──────────────────────────────────────────────────────────────────── + +chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => { + if (res?.isCapturing) setActive(true); +}); + +function setActive(active) { + isActive = active; + toggleBtn.textContent = active ? 'Stop Fact-Checking' : 'Start Fact-Checking'; + toggleBtn.className = 'toggle-btn' + (active ? ' active' : ''); + statusEl.textContent = active ? 'Live • Fact-checking active' : 'Inactive'; + statusEl.className = 'status' + (active ? ' active' : ''); + // hide key fields while active + keysSection.style.display = active ? 'none' : 'flex'; + if (!active) updateHint(); +} + +// ── Toggle ──────────────────────────────────────────────────────────────────── + +toggleBtn.addEventListener('click', async () => { + if (isActive) { + chrome.runtime.sendMessage({ type: 'STOP_FACTCHECK' }); + setActive(false); + return; + } + + const anthropicKey = anthropicEl.value.trim(); + + if (!anthropicKey) { + keyHint.textContent = 'Please enter your Anthropic API key.'; + keyHint.className = 'key-hint error'; + return; + } + + // save key then start + await new Promise(r => chrome.storage.local.set({ anthropicKey }, r)); + + chrome.runtime.sendMessage({ type: 'START_FACTCHECK' }, (res) => { + if (res?.ok) { + setActive(true); + } else { + keyHint.textContent = 'Failed to start: ' + (res?.error || 'unknown error'); + keyHint.className = 'key-hint error'; + } + }); +}); \ No newline at end of file