added multilang support

This commit is contained in:
Risha
2026-06-25 18:11:18 -05:00
parent c63a35dd94
commit d047e896be
11 changed files with 436 additions and 196 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 480 B

After

Width:  |  Height:  |  Size: 463 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

+1 -2
View File
@@ -1,12 +1,11 @@
{
"manifest_version": 3,
"name": "InTruth",
"version": "1.1.0",
"version": "1.1.9",
"description": "Real-time fact-checking of political speech and debates",
"permissions": [
"activeTab",
"scripting",
"storage",
"tabCapture",
"offscreen"
@@ -1,17 +1,13 @@
// service-worker.js --> now combined with fact-checker.js and claim-detector.js for import conflicts
// transcription runs in content script via web speech API;
// responsibile for starting / stopping content script, receiving transcripts,
// and routing them to claim detection
// 5.31.2026 -- serper call before claude call for more accurate verdicts
// 6.12.2026 -- switch to deepgram; too many conflicts w/ webaudio
// service-worker.js
let ANTHROPIC_KEY = '';
const SERPER_KEY = '';
let TRANSCRIPT_LANGUAGE = 'en';
async function loadKeys() {
return new Promise(resolve => {
chrome.storage.local.get(['anthropicKey'], (data) => {
chrome.storage.local.get(['anthropicKey', 'transcriptLanguage'], (data) => {
ANTHROPIC_KEY = data.anthropicKey || '';
TRANSCRIPT_LANGUAGE = data.transcriptLanguage || 'en';
resolve();
});
});
@@ -19,24 +15,50 @@ 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',
@@ -48,30 +70,71 @@ 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) {
@@ -140,12 +203,12 @@ function extractLexical(text) {
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})`);
@@ -207,7 +270,7 @@ 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 = '';
@@ -218,7 +281,7 @@ 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;
@@ -246,8 +309,16 @@ 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);
}
@@ -264,16 +335,17 @@ async function onNewSentence(text, speakerId) {
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(' ');
@@ -301,10 +373,19 @@ async function onNewSentence(text, speakerId) {
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 {
@@ -333,9 +414,13 @@ async function evaluateClaims(contextText, title, lexicalSummary, lexicalSnapsho
`\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
@@ -348,15 +433,19 @@ async function evaluateClaims(contextText, title, lexicalSummary, lexicalSnapsho
? `\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,54 +454,79 @@ 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);
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);
// never downgrade TRUE to MISLEADING in grounded pass — fast verdict had no sources to nitpick
// 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;
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 };
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;
@@ -566,7 +680,7 @@ async function startFactCheck() {
});
});
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
+70 -38
View File
@@ -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) {
'<div class="rtfc-speaker-banner-sample">"' + escapeHtml(sample) + '..."</div>' +
'<div class="rtfc-speaker-banner-buttons">' +
speakers.map(name =>
'<button class="rtfc-speaker-banner-btn" data-name="' + escapeHtml(name) + '" data-id="' + speakerId + '">' + escapeHtml(name) + '</button>'
'<button class="rtfc-speaker-banner-btn" data-name="' + escapeHtml(name) + '" data-id="' + sid + '">' + escapeHtml(name) + '</button>'
).join('') +
'<button class="rtfc-speaker-banner-btn rtfc-speaker-banner-btn--skip" data-id="' + speakerId + '">Skip</button>' +
'<button class="rtfc-speaker-banner-btn rtfc-speaker-banner-btn--skip" data-id="' + sid + '">Skip</button>' +
'</div>';
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 = '<div class="rtfc-speaker-tag" style="background:' + speakerColor + '">' + escapeHtml(speakerName) + '</div>';
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 = '<div class="rtfc-speaker-tag" style="background:' + color + '">' + escapeHtml(name) + '</div>';
}
}
@@ -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) {
@@ -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;
@@ -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();
+32
View File
@@ -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;
+24
View File
@@ -18,6 +18,30 @@
<label class="key-label">Anthropic API Key</label>
<input class="key-input" id="anthropicKey" type="password" placeholder="sk-ant-..." autocomplete="off"/>
</div>
<div class="key-field">
<label class="key-label">Language</label>
<div class="lang-row">
<span id="langFlag" class="lang-flag">🇺🇸</span>
<select class="key-input key-select" id="languageSelect">
<option value="en">en</option>
<option value="es">es</option>
<option value="fr">fr</option>
<option value="de">de</option>
<option value="it">it</option>
<option value="pt">pt</option>
<option value="nl">nl</option>
<option value="hi">hi</option>
<option value="ja">ja</option>
<option value="zh">zh</option>
<option value="ar">ar</option>
<option value="ko">ko</option>
<option value="ru">ru</option>
<option value="pl">pl</option>
<option value="sv">sv</option>
<option value="tr">tr</option>
</select>
</div>
</div>
<div class="key-hint" id="keyHint"></div>
</div>
+25 -4
View File
@@ -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) {