added multilang support
This commit is contained in:
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,12 +1,11 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "InTruth",
|
"name": "InTruth",
|
||||||
"version": "1.1.0",
|
"version": "1.1.9",
|
||||||
"description": "Real-time fact-checking of political speech and debates",
|
"description": "Real-time fact-checking of political speech and debates",
|
||||||
|
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"activeTab",
|
"activeTab",
|
||||||
"scripting",
|
|
||||||
"storage",
|
"storage",
|
||||||
"tabCapture",
|
"tabCapture",
|
||||||
"offscreen"
|
"offscreen"
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
// service-worker.js --> now combined with fact-checker.js and claim-detector.js for import conflicts
|
// service-worker.js
|
||||||
// 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 = '';
|
let ANTHROPIC_KEY = '';
|
||||||
const SERPER_KEY = '';
|
const SERPER_KEY = '';
|
||||||
|
let TRANSCRIPT_LANGUAGE = 'en';
|
||||||
|
|
||||||
async function loadKeys() {
|
async function loadKeys() {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
chrome.storage.local.get(['anthropicKey'], (data) => {
|
chrome.storage.local.get(['anthropicKey', 'transcriptLanguage'], (data) => {
|
||||||
ANTHROPIC_KEY = data.anthropicKey || '';
|
ANTHROPIC_KEY = data.anthropicKey || '';
|
||||||
|
TRANSCRIPT_LANGUAGE = data.transcriptLanguage || 'en';
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -19,24 +15,50 @@ async function loadKeys() {
|
|||||||
|
|
||||||
const EVALUATE_PROMPT = ``;
|
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) {
|
function parseSpeakersFromTitle(title) {
|
||||||
if (!title) return [];
|
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) {
|
if (roleMatch) {
|
||||||
const cap = s => s.charAt(0).toUpperCase() + s.slice(1);
|
const cap = s => s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
return [cap(roleMatch[2]), cap(roleMatch[4])];
|
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) {
|
// 'Name vs N Description' — second side starts with digit, e.g. 'Dean Withers vs 20 MAGA Women'
|
||||||
const clean = name => name.trim().split(' ').pop();
|
const nameVsGroupMatch = clean.match(/([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)\s+(?:vs?\.?|versus)\s+(\d+)\s+(.+)/i);
|
||||||
return [clean(nameMatch[1]), clean(nameMatch[2])];
|
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 [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Serper ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const BLOCKED_DOMAINS = [
|
const BLOCKED_DOMAINS = [
|
||||||
'reddit.com', 'facebook.com', 'twitter.com', 'x.com',
|
'reddit.com', 'facebook.com', 'twitter.com', 'x.com',
|
||||||
@@ -48,30 +70,71 @@ const BLOCKED_DOMAINS = [
|
|||||||
'thefederalist.com', 'motherjones.com', 'nationalreview.com',
|
'thefederalist.com', 'motherjones.com', 'nationalreview.com',
|
||||||
'democrats-appropriations.house.gov', 'waysandmeans.house.gov',
|
'democrats-appropriations.house.gov', 'waysandmeans.house.gov',
|
||||||
'bostonkravmaga.com',
|
'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) {
|
async function searchWeb(query, retries = 2) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('https://google.serper.dev/search', {
|
const res = await fetch('https://google.serper.dev/search', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-API-KEY': SERPER_KEY },
|
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();
|
const data = await res.json();
|
||||||
return (data.organic ?? [])
|
|
||||||
.map(r => r.link)
|
const organic = (data.organic ?? [])
|
||||||
.filter(url => url && !BLOCKED_DOMAINS.some(d => url.includes(d)))
|
.filter(r => r.link && !BLOCKED_DOMAINS.some(d => r.link.includes(d)))
|
||||||
.slice(0, 3);
|
.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) {
|
} catch (err) {
|
||||||
if (retries > 0) {
|
if (retries > 0) {
|
||||||
await new Promise(r => setTimeout(r, 500));
|
await new Promise(r => setTimeout(r, 500));
|
||||||
return searchWeb(query, retries - 1);
|
return searchWeb(query, retries - 1);
|
||||||
}
|
}
|
||||||
console.error('[serper] error:', err);
|
console.error('[serper] error:', err);
|
||||||
return [];
|
return { organic: [], answerBox: null, knowledgeGraph: null };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Claude ────────────────────────────────────────────────────────────────────
|
// ── Claude ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function callClaude(userMessage, systemPrompt) {
|
async function callClaude(userMessage, systemPrompt) {
|
||||||
@@ -140,12 +203,12 @@ function extractLexical(text) {
|
|||||||
function buildLexicalSummary(f) {
|
function buildLexicalSummary(f) {
|
||||||
const r = f.rates || f;
|
const r = f.rates || f;
|
||||||
const notes = [];
|
const notes = [];
|
||||||
if (r.hedging > 8) notes.push(`hedging language (${r.hedging}%)`);
|
if (r.hedging > 5) notes.push(`hedging language (${r.hedging}%)`);
|
||||||
if (r.certainty > 8) notes.push(`certainty markers (${r.certainty}%)`);
|
if (r.certainty > 5) notes.push(`certainty markers (${r.certainty}%)`);
|
||||||
if (r.filler > 8) notes.push(`filler words (${r.filler}%)`);
|
if (r.filler > 5) notes.push(`filler words (${r.filler}%)`);
|
||||||
if (r.emotional > 8) notes.push(`emotional language (${r.emotional}%)`);
|
if (r.emotional > 5) notes.push(`emotional language (${r.emotional}%)`);
|
||||||
if (r.exclusive > 8) notes.push(`qualifying words (${r.exclusive}%)`);
|
if (r.exclusive > 5) notes.push(`qualifying words (${r.exclusive}%)`);
|
||||||
if (r.firstPersonSg > 8) notes.push(`first-person singular (${r.firstPersonSg}%)`);
|
if (r.firstPersonSg > 5) notes.push(`first-person singular (${r.firstPersonSg}%)`);
|
||||||
if (f.wordsPerSecond) {
|
if (f.wordsPerSecond) {
|
||||||
const pace = f.wordsPerSecond > 3.5 ? 'fast' : f.wordsPerSecond < 2 ? 'slow' : 'moderate';
|
const pace = f.wordsPerSecond > 3.5 ? 'fast' : f.wordsPerSecond < 2 ? 'slow' : 'moderate';
|
||||||
notes.push(`speech rate ${f.wordsPerSecond} w/s (${pace})`);
|
notes.push(`speech rate ${f.wordsPerSecond} w/s (${pace})`);
|
||||||
@@ -207,7 +270,7 @@ const WINDOW_KEEP = 15;
|
|||||||
// Each entry: { text, speakerId, speakerName }
|
// Each entry: { text, speakerId, speakerName }
|
||||||
let sentenceWindow = [];
|
let sentenceWindow = [];
|
||||||
let sentenceCount = 0;
|
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 windowStartTime = null;
|
||||||
let pageTitle = '';
|
let pageTitle = '';
|
||||||
let pageDate = '';
|
let pageDate = '';
|
||||||
@@ -218,7 +281,7 @@ let confirmedSpeakers = new Set(); // IDs that have been confirmed by user
|
|||||||
function resetWindow() {
|
function resetWindow() {
|
||||||
sentenceWindow = [];
|
sentenceWindow = [];
|
||||||
sentenceCount = 0;
|
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;
|
windowStartTime = null;
|
||||||
currentSpeakerId = null;
|
currentSpeakerId = null;
|
||||||
lastSpeakerId = null;
|
lastSpeakerId = null;
|
||||||
@@ -246,8 +309,16 @@ async function onNewSentence(text, speakerId) {
|
|||||||
: null;
|
: null;
|
||||||
const flushDominantSpeaker = flushDominantId !== null ? (speakerIdToName[flushDominantId] || null) : null;
|
const flushDominantSpeaker = flushDominantId !== null ? (speakerIdToName[flushDominantId] || null) : null;
|
||||||
const flushLexSnapshot = JSON.parse(JSON.stringify(windowLexical));
|
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);
|
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;
|
windowStartTime = null;
|
||||||
await evaluateClaims(flushText, pageTitle, flushLexSummary, flushLexSnapshot, flushDominantSpeaker, flushDominantId);
|
await evaluateClaims(flushText, pageTitle, flushLexSummary, flushLexSnapshot, flushDominantSpeaker, flushDominantId);
|
||||||
}
|
}
|
||||||
@@ -264,16 +335,17 @@ async function onNewSentence(text, speakerId) {
|
|||||||
|
|
||||||
if (!windowStartTime) windowStartTime = Date.now();
|
if (!windowStartTime) windowStartTime = Date.now();
|
||||||
|
|
||||||
// accumulate lexical
|
// accumulate lexical — running sum, divide by sentence count at snapshot time
|
||||||
const f = extractLexical(text);
|
const f = extractLexical(text);
|
||||||
const r = f.rates, wr = windowLexical.rates;
|
const r = f.rates, wr = windowLexical.rates;
|
||||||
wr.hedging = Math.round((wr.hedging + r.hedging) / 2);
|
wr.hedging += r.hedging;
|
||||||
wr.certainty = Math.round((wr.certainty + r.certainty) / 2);
|
wr.certainty += r.certainty;
|
||||||
wr.filler = Math.round((wr.filler + r.filler) / 2);
|
wr.filler += r.filler;
|
||||||
wr.emotional = Math.round((wr.emotional + r.emotional) / 2);
|
wr.emotional += r.emotional;
|
||||||
wr.exclusive = Math.round((wr.exclusive + r.exclusive) / 2);
|
wr.exclusive += r.exclusive;
|
||||||
wr.firstPersonSg = Math.round((wr.firstPersonSg + r.firstPersonSg) / 2);
|
wr.firstPersonSg += r.firstPersonSg;
|
||||||
windowLexical.wordCount += f.wordCount;
|
windowLexical.wordCount += f.wordCount;
|
||||||
|
windowLexical._sentenceCount = (windowLexical._sentenceCount || 0) + 1;
|
||||||
|
|
||||||
if (sentenceCount % WINDOW_SIZE === 0) {
|
if (sentenceCount % WINDOW_SIZE === 0) {
|
||||||
const contextText = sentenceWindow.map(s => s.text).join(' ');
|
const contextText = sentenceWindow.map(s => s.text).join(' ');
|
||||||
@@ -301,10 +373,19 @@ async function onNewSentence(text, speakerId) {
|
|||||||
windowStartTime = null;
|
windowStartTime = null;
|
||||||
|
|
||||||
const lexicalSnapshot = JSON.parse(JSON.stringify(windowLexical));
|
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);
|
const lexicalSummary = buildLexicalSummary(lexicalSnapshot);
|
||||||
|
|
||||||
// reset for next window
|
// 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;
|
windowStartTime = null;
|
||||||
|
|
||||||
try {
|
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.`
|
`\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".`;
|
: `\nIdentify speakers using first-person language, policy content, and speech patterns. Never output "Speaker N".`;
|
||||||
|
|
||||||
const titleContext = title
|
const languageInstruction = TRANSCRIPT_LANGUAGE && TRANSCRIPT_LANGUAGE !== 'en'
|
||||||
? `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`
|
? `\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}` : '';
|
const lexicalContext = lexicalSummary ? `\n\nLexical analysis: ${lexicalSummary}` : '';
|
||||||
|
|
||||||
// already-checked claims list for Claude
|
// 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`
|
? `\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(
|
const raw = await callClaude(
|
||||||
`${titleContext}Transcript: "${contextText}"${alreadyChecked}${lexicalContext}`,
|
`${titleContext}Transcript: "${contextText}"${alreadyChecked}${lexicalContext}`,
|
||||||
EVALUATE_PROMPT
|
EVALUATE_PROMPT
|
||||||
);
|
);
|
||||||
const results = parseArray(raw);
|
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;
|
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) {
|
if (activeTabId) {
|
||||||
chrome.tabs.sendMessage(activeTabId, {
|
chrome.tabs.sendMessage(activeTabId, {
|
||||||
type: 'NEW_VERDICT',
|
type: 'NEW_VERDICT',
|
||||||
@@ -365,54 +454,79 @@ async function evaluateClaims(contextText, title, lexicalSummary, lexicalSnapsho
|
|||||||
sources: [],
|
sources: [],
|
||||||
pending: true,
|
pending: true,
|
||||||
lexical: lexicalSnapshot,
|
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),
|
speaker: dominantSpeaker || (r.speaker && !r.speaker.match(/^Speaker\s*\d+$/i) ? r.speaker : null),
|
||||||
})),
|
})),
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
console.log('[pipeline] fast verdicts sent:', valid.length, '| speaker:', dominantSpeaker);
|
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) {
|
} catch (err) {
|
||||||
console.error('[pipeline] error:', 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 {
|
try {
|
||||||
const dateCtx = pageDate ? `\nDate: ${pageDate}` : '';
|
const dateCtx = pageDate ? `\nDate: ${pageDate}` : '';
|
||||||
const titleContext = title
|
const languageInstruction = TRANSCRIPT_LANGUAGE && TRANSCRIPT_LANGUAGE !== 'en'
|
||||||
? `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`
|
? `\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 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 {
|
try {
|
||||||
const urls = await searchWeb(fastResult.claim);
|
const searchData = claimSearchPromises
|
||||||
if (!urls.length) return null;
|
? 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(
|
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}`,
|
`${titleContext}Transcript: "${contextText}"\n\nClaim: "${fastResult.claim}"\nFast verdict: ${fastResult.verdict}\n\nWeb search evidence:\n${evidenceBlock}${lexicalContext}`,
|
||||||
EVALUATE_PROMPT
|
GROUNDED_PROMPT
|
||||||
);
|
);
|
||||||
const results = parseArray(raw);
|
const parsed = parseArray(raw);
|
||||||
const match = results.find(r => r.claim && r.verdict);
|
const match = parsed.find(r => r.claim && r.verdict);
|
||||||
if (!match) return null;
|
// drop UNVERIFIABLE from grounded pass — either it's checkable or it isn't shown
|
||||||
// re-resolve speaker at grounding time — user may have confirmed since fast pass
|
if (!match || match.verdict === 'UNVERIFIABLE') return null;
|
||||||
const lateResolved = dominantSpeakerId !== null && dominantSpeakerId !== undefined
|
const resolvedSpeaker = dominantSpeaker
|
||||||
? speakerIdToName[dominantSpeakerId] || null
|
|| (fastResult.speaker && !fastResult.speaker.match(/^Speaker\s*\d+$/i) ? fastResult.speaker : null)
|
||||||
: null;
|
|| (match.speaker && !match.speaker.match(/^Speaker\s*\d+$/i) ? match.speaker : 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
|
// 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 fastWasTrue = fastResult.verdict === 'TRUE' || fastResult.verdict === 'SUBSTANTIALLY TRUE';
|
||||||
const groundedIsMisleading = match.verdict === 'MISLEADING';
|
const groundedDowngrades = match.verdict === 'MISLEADING' || match.verdict === 'FALSE';
|
||||||
const finalVerdict = (fastWasTrue && groundedIsMisleading) ? fastResult.verdict : match.verdict;
|
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) {
|
} catch (err) {
|
||||||
console.error('[grounded] error:', fastResult.claim.slice(0, 40), err);
|
console.error('[grounded] error:', fastResult.claim.slice(0, 40), err);
|
||||||
return null;
|
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);
|
if (!response?.ok) throw new Error('Failed to start capture: ' + response?.error);
|
||||||
|
|
||||||
// reset BEFORE sending START_FACTCHECK — transcripts arrive immediately after
|
// reset BEFORE sending START_FACTCHECK — transcripts arrive immediately after
|
||||||
|
|||||||
@@ -64,19 +64,43 @@ function getSpeakerColor(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Speaker parsing ───────────────────────────────────────────────────────────
|
// ── 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) {
|
function parseSpeakersFromTitle(title) {
|
||||||
if (!title) return [];
|
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) {
|
if (roleMatch) {
|
||||||
const cap = s => s.charAt(0).toUpperCase() + s.slice(1);
|
const cap = s => s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
return [cap(roleMatch[2]), cap(roleMatch[4])];
|
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]+)?)/);
|
// 'Name vs N Description' — second side starts with digit, e.g. 'Dean Withers vs 20 MAGA Women'
|
||||||
if (nameMatch) {
|
const nameVsGroupMatch = clean.match(/([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)\s+(?:vs?\.?|versus)\s+(\d+)\s+(.+)/i);
|
||||||
const clean = name => name.trim().split(' ').pop();
|
if (nameVsGroupMatch) {
|
||||||
return [clean(nameMatch[1]), clean(nameMatch[2])];
|
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 [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,14 +144,15 @@ const confirmedSpeakerMap = {}; // { speakerId: 'Harris' }
|
|||||||
const pendingSpeakerIds = new Set(); // IDs waiting for confirmation
|
const pendingSpeakerIds = new Set(); // IDs waiting for confirmation
|
||||||
|
|
||||||
function showSpeakerBanner(speakerId, sample) {
|
function showSpeakerBanner(speakerId, sample) {
|
||||||
if (pendingSpeakerIds.has(speakerId)) return;
|
const sid = String(speakerId);
|
||||||
if (speakerId in confirmedSpeakerMap) return;
|
if (pendingSpeakerIds.has(sid)) return;
|
||||||
|
if (sid in confirmedSpeakerMap) return;
|
||||||
// if speakers not yet parsed from title, retry once after 1s
|
// if speakers not yet parsed from title, retry once after 1s
|
||||||
if (!speakers.length) {
|
if (!speakers.length) {
|
||||||
setTimeout(() => showSpeakerBanner(speakerId, sample), 1000);
|
setTimeout(() => showSpeakerBanner(speakerId, sample), 1000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pendingSpeakerIds.add(speakerId);
|
pendingSpeakerIds.add(sid);
|
||||||
|
|
||||||
const banner = document.createElement('div');
|
const banner = document.createElement('div');
|
||||||
banner.className = 'rtfc-speaker-banner';
|
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-sample">"' + escapeHtml(sample) + '..."</div>' +
|
||||||
'<div class="rtfc-speaker-banner-buttons">' +
|
'<div class="rtfc-speaker-banner-buttons">' +
|
||||||
speakers.map(name =>
|
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('') +
|
).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>';
|
'</div>';
|
||||||
|
|
||||||
banner.querySelectorAll('.rtfc-speaker-banner-btn').forEach(btn => {
|
banner.querySelectorAll('.rtfc-speaker-banner-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const name = btn.dataset.name;
|
const name = btn.dataset.name;
|
||||||
const id = parseInt(btn.dataset.id);
|
const id = btn.dataset.id; // already a string — matches confirmedSpeakerMap keys
|
||||||
if (name) {
|
if (name) {
|
||||||
confirmedSpeakerMap[id] = name;
|
confirmedSpeakerMap[id] = name;
|
||||||
chrome.runtime.sendMessage({
|
chrome.runtime.sendMessage({
|
||||||
@@ -168,13 +193,11 @@ function showSpeakerBanner(speakerId, sample) {
|
|||||||
// ── Speaker confirmation state ───────────────────────────────────────────────
|
// ── Speaker confirmation state ───────────────────────────────────────────────
|
||||||
|
|
||||||
function allSpeakersConfirmed() {
|
function allSpeakersConfirmed() {
|
||||||
// true when every speaker seen so far has been confirmed or skipped
|
// true only when every speaker parsed from the title has been confirmed (or skipped) by the user
|
||||||
// and at least one real name has been confirmed
|
// Deepgram assigns IDs 0, 1, 2... in order of first appearance — matches speakers array indices
|
||||||
const confirmedNames = Object.values(confirmedSpeakerMap).filter(v => v !== null);
|
if (!speakers.length) return false;
|
||||||
return confirmedNames.length >= Math.min(speakers.length, Object.keys(confirmedSpeakerMap).length)
|
return speakers.every((_, i) => String(i) in confirmedSpeakerMap);
|
||||||
&& Object.keys(confirmedSpeakerMap).length > 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function retryTagAllCards() {
|
function retryTagAllCards() {
|
||||||
// retroactively tag all grounded cards once speakers are confirmed
|
// retroactively tag all grounded cards once speakers are confirmed
|
||||||
if (!verdictListEl) return;
|
if (!verdictListEl) return;
|
||||||
@@ -468,21 +491,15 @@ function buildCard(result) {
|
|||||||
|
|
||||||
const lexicalRows = buildLexicalRows(result.lexical);
|
const lexicalRows = buildLexicalRows(result.lexical);
|
||||||
|
|
||||||
// speaker tag — only show on grounded cards AND only when all speakers confirmed
|
// speaker tag — only show when ALL speakers have been confirmed by user
|
||||||
// this prevents wrong tags from appearing before diarization stabilizes
|
// prevents wrong tags on cards detected before diarization stabilized
|
||||||
let speakerTag = '';
|
let speakerTag = '';
|
||||||
if (!result.pending && allSpeakersConfirmed()) {
|
if (!result.pending && allSpeakersConfirmed() && result.dominantSpeakerId !== null && result.dominantSpeakerId !== undefined) {
|
||||||
const confirmedName = (result.dominantSpeakerId !== null && result.dominantSpeakerId !== undefined)
|
const confirmedName = confirmedSpeakerMap[result.dominantSpeakerId];
|
||||||
? confirmedSpeakerMap[result.dominantSpeakerId]
|
if (confirmedName) {
|
||||||
: undefined;
|
const name = normalizeSpeakerName(confirmedName);
|
||||||
const rawSpeaker = (confirmedName !== undefined && confirmedName !== null)
|
const color = getSpeakerColor(name);
|
||||||
? confirmedName
|
speakerTag = '<div class="rtfc-speaker-tag" style="background:' + color + '">' + escapeHtml(name) + '</div>';
|
||||||
: 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>';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,7 +538,13 @@ function buildCard(result) {
|
|||||||
return card;
|
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);
|
const key = claim.toLowerCase().slice(0, 40);
|
||||||
if (pendingCards.has(key)) return pendingCards.get(key);
|
if (pendingCards.has(key)) return pendingCards.get(key);
|
||||||
|
|
||||||
@@ -566,6 +589,9 @@ function addVerdict(result) {
|
|||||||
verdictListEl.querySelector('.rtfc-empty')?.remove();
|
verdictListEl.querySelector('.rtfc-empty')?.remove();
|
||||||
applyVerdictToBullet(result.claim, result.verdict, result.confidence);
|
applyVerdictToBullet(result.claim, result.verdict, result.confidence);
|
||||||
if (!result._timestamp) result._timestamp = getClaimTimestamp(result.claim);
|
if (!result._timestamp) result._timestamp = getClaimTimestamp(result.claim);
|
||||||
|
if (result.dominantSpeakerId !== null && result.dominantSpeakerId !== undefined) {
|
||||||
|
result.dominantSpeakerId = String(result.dominantSpeakerId);
|
||||||
|
}
|
||||||
const card = buildCard(result);
|
const card = buildCard(result);
|
||||||
if (result.pending) {
|
if (result.pending) {
|
||||||
const key = result.claim.toLowerCase().slice(0, 40);
|
const key = result.claim.toLowerCase().slice(0, 40);
|
||||||
@@ -578,11 +604,17 @@ function addVerdict(result) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateVerdict(result) {
|
function updateVerdict(result) {
|
||||||
const existing = findPendingCard(result.claim);
|
const existing = findPendingCard(result.claim, result._fastClaim);
|
||||||
if (!result._timestamp) result._timestamp = getClaimTimestamp(result.claim);
|
// always inherit timestamp from the pending card — it was set at detection time
|
||||||
// inherit dominantSpeakerId from pending card if grounded result doesn't have one
|
// never re-derive from sentenceTimestamps which may no longer contain the original sentence
|
||||||
if (existing && existing.dataset.speakerid && !result.dominantSpeakerId) {
|
if (existing && existing._resultData?._timestamp) {
|
||||||
result.dominantSpeakerId = existing.dataset.speakerid;
|
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);
|
const newCard = buildCard(result);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// session-export.js
|
// session-export.js
|
||||||
// Handles session logging and PDF export.
|
// Handles session logging and PDF export.
|
||||||
// Loaded after overlay.js — exposes logVerdict(), startSession(), stopSession(), exportPDF() as globals.
|
// Loaded after overlay.js
|
||||||
// Reuses escapeHtml() defined in overlay.js (loaded first in manifest.json).
|
|
||||||
|
|
||||||
const sessionLog = [];
|
const sessionLog = [];
|
||||||
let sessionStartTime = null;
|
let sessionStartTime = null;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ let active = false;
|
|||||||
|
|
||||||
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
||||||
if (msg.type === 'START_CAPTURE') {
|
if (msg.type === 'START_CAPTURE') {
|
||||||
startCapture(msg.streamId)
|
startCapture(msg.streamId, msg.language || 'en')
|
||||||
.then(() => sendResponse({ ok: true }))
|
.then(() => sendResponse({ ok: true }))
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error('[offscreen] error:', err);
|
console.error('[offscreen] error:', err);
|
||||||
@@ -28,8 +28,9 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let utteranceBuffer = '';
|
let utteranceBuffer = '';
|
||||||
|
let utteranceSpeakerCounts = {}; // track speaker word counts across buffer chunks
|
||||||
|
|
||||||
async function startCapture(streamId) {
|
async function startCapture(streamId, language = 'en') {
|
||||||
if (active) stopCapture();
|
if (active) stopCapture();
|
||||||
active = true;
|
active = true;
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@ async function startCapture(streamId) {
|
|||||||
'sample_rate=16000',
|
'sample_rate=16000',
|
||||||
'channels=1',
|
'channels=1',
|
||||||
'model=nova-2',
|
'model=nova-2',
|
||||||
'language=en-US',
|
'language=' + language,
|
||||||
'punctuate=true',
|
'punctuate=true',
|
||||||
'interim_results=true',
|
'interim_results=true',
|
||||||
'utterance_end_ms=2500',
|
'utterance_end_ms=2500',
|
||||||
@@ -83,14 +84,31 @@ async function startCapture(streamId) {
|
|||||||
const text = result.transcript.trim();
|
const text = result.transcript.trim();
|
||||||
const isFinal = data.is_final;
|
const isFinal = data.is_final;
|
||||||
const speech = data.speech_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 (!text) return;
|
||||||
|
|
||||||
if (isFinal && speech) {
|
if (isFinal && speech) {
|
||||||
// speech_final = end of utterance — send full accumulated text as final
|
// speech_final = end of utterance — send full accumulated text as final
|
||||||
const fullText = utteranceBuffer ? utteranceBuffer + ' ' + text : text;
|
const fullText = utteranceBuffer ? utteranceBuffer + ' ' + text : text;
|
||||||
|
const speaker = getDominantSpeaker();
|
||||||
utteranceBuffer = '';
|
utteranceBuffer = '';
|
||||||
|
utteranceSpeakerCounts = {};
|
||||||
chrome.runtime.sendMessage({
|
chrome.runtime.sendMessage({
|
||||||
type: 'TRANSCRIPT_RESULT',
|
type: 'TRANSCRIPT_RESULT',
|
||||||
text: fullText.trim(),
|
text: fullText.trim(),
|
||||||
@@ -106,7 +124,7 @@ async function startCapture(streamId) {
|
|||||||
text: utteranceBuffer,
|
text: utteranceBuffer,
|
||||||
isFinal: false,
|
isFinal: false,
|
||||||
interim: true,
|
interim: true,
|
||||||
speaker,
|
speaker: getDominantSpeaker(),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// regular interim — show as-is
|
// regular interim — show as-is
|
||||||
@@ -115,7 +133,7 @@ async function startCapture(streamId) {
|
|||||||
text,
|
text,
|
||||||
isFinal: false,
|
isFinal: false,
|
||||||
interim: true,
|
interim: true,
|
||||||
speaker,
|
speaker: getDominantSpeaker(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +193,7 @@ function startAudioPipeline() {
|
|||||||
function stopCapture() {
|
function stopCapture() {
|
||||||
active = false;
|
active = false;
|
||||||
utteranceBuffer = '';
|
utteranceBuffer = '';
|
||||||
|
utteranceSpeakerCounts = {};
|
||||||
|
|
||||||
if (socket) {
|
if (socket) {
|
||||||
socket.close();
|
socket.close();
|
||||||
|
|||||||
@@ -81,6 +81,38 @@ body {
|
|||||||
color: #888;
|
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 {
|
.key-hint {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: #555;
|
color: #555;
|
||||||
|
|||||||
@@ -18,6 +18,30 @@
|
|||||||
<label class="key-label">Anthropic API Key</label>
|
<label class="key-label">Anthropic API Key</label>
|
||||||
<input class="key-input" id="anthropicKey" type="password" placeholder="sk-ant-..." autocomplete="off"/>
|
<input class="key-input" id="anthropicKey" type="password" placeholder="sk-ant-..." autocomplete="off"/>
|
||||||
</div>
|
</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 class="key-hint" id="keyHint"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,27 @@ const statusEl = document.getElementById('status');
|
|||||||
const anthropicEl = document.getElementById('anthropicKey');
|
const anthropicEl = document.getElementById('anthropicKey');
|
||||||
const keyHint = document.getElementById('keyHint');
|
const keyHint = document.getElementById('keyHint');
|
||||||
const keysSection = document.getElementById('keysSection');
|
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;
|
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.anthropicKey) { anthropicEl.value = data.anthropicKey; anthropicEl.classList.add('saved'); }
|
||||||
|
if (data.transcriptLanguage) languageEl.value = data.transcriptLanguage;
|
||||||
|
updateFlag();
|
||||||
updateHint();
|
updateHint();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -27,6 +41,13 @@ anthropicEl.addEventListener('change', () => {
|
|||||||
updateHint();
|
updateHint();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Save language on change ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
languageEl.addEventListener('change', () => {
|
||||||
|
chrome.storage.local.set({ transcriptLanguage: languageEl.value });
|
||||||
|
updateFlag();
|
||||||
|
});
|
||||||
|
|
||||||
function updateHint() {
|
function updateHint() {
|
||||||
if (!anthropicEl.value.trim()) {
|
if (!anthropicEl.value.trim()) {
|
||||||
keyHint.textContent = 'Enter your Anthropic API key to start.';
|
keyHint.textContent = 'Enter your Anthropic API key to start.';
|
||||||
@@ -73,8 +94,8 @@ toggleBtn.addEventListener('click', async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// save key then start
|
// save key and language then start
|
||||||
await new Promise(r => chrome.storage.local.set({ anthropicKey }, r));
|
await new Promise(r => chrome.storage.local.set({ anthropicKey, transcriptLanguage: languageEl.value }, r));
|
||||||
|
|
||||||
chrome.runtime.sendMessage({ type: 'START_FACTCHECK' }, (res) => {
|
chrome.runtime.sendMessage({ type: 'START_FACTCHECK' }, (res) => {
|
||||||
if (res?.ok) {
|
if (res?.ok) {
|
||||||
|
|||||||
Reference in New Issue
Block a user