<!-- Gemini-style chat bar for subject input -->
<div id="chat-container" style="display: none;">
<input type="text" id="subject-input" placeholder="Choose a subject.">
<button id="mic-button">
<div class="mic-icon-bar"></div>
<div class="mic-icon-bar"></div>
<div class="mic-icon-bar"></div>
</button>
</div>
<!-- Hint box that appears after a subject is chosen -->
<div id="hint-container" class="ui-bottom-left" style="display: none;">
<strong id="hint-label">Hint:</strong> <span id="hint-text">Start by explaining the basics.</span>
</div>
<!-- Settings and Help Button -->
<div id="settings-container" class="ui-bottom-left" style="display: none; bottom: 80px;">
<button id="settings-button">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M9.405 1.05c-.413-1.4-2.397-1.4-2.81 0l-.1.34a1.464 1.464 0 0 1-2.105.872l-.31-.17c-1.283-.698-2.686.705-1.987 1.987l.169.311a1.464 1.464 0 0 1-.872 2.105l-.34.1c-1.4.413-1.4 2.397 0 2.81l.34.1a1.464 1.464 0 0 1 .872 2.105l-.17.31c-.698 1.283.705 2.686 1.987 1.987l.311-.169a1.464 1.464 0 0 1 2.105.872l.1.34c.413 1.4 2.397 1.4 2.81 0l.1-.34a1.464 1.464 0 0 1 2.105-.872l.31.17c1.283.698 2.686-.705 1.987-1.987l-.169-.311a1.464 1.464 0 0 1 .872-2.105l.34-.1c-1.4-.413-1.4-2.397 0-2.81l-.34-.1a1.464 1.464 0 0 1-.872-2.105l.17-.31c.698-1.283-.705-2.686-1.987-1.987l-.311.169a1.464 1.464 0 0 1-2.105-.872l-.1-.34zM8 10.93a2.929 2.929 0 1 1 0-5.86 2.929 2.929 0 0 1 0 5.858z"/>
</svg>
<span id="settings-label">Settings & Help</span>
</button>
</div>
<!-- Subject and Expertise Display -->
<div id="subject-container" class="ui-top-left" style="display: none;">
<strong>Subject:</strong> <span id="subject-text">None</span>
<button id="exit-subject-button" style="display: none;">[Exit Subject]</button><br>
<strong>Expertise Level:</strong> <span id="expertise-text">Not yet assessed</span>
</div>
<div id="kb-summary" class="ui-left-summary" style="display:none;">
<strong>KB Summary</strong>
<ul id="kb-summary-list" style="margin:8px 0 0 16px;"></ul>
</div>
<!-- Settings Modal -->
<div id="settings-modal" class="ui-container" style="display: none;">
<button id="close-settings">×</button>
<h2 id="settings-title">Settings & Help</h2>
<div class="settings-section">
<label for="app-language" id="app-lang-label">App Language</label>
<select id="app-language">
<option value="en">English</option>
<option value="es">Español</option>
</select>
</div>
<div class="settings-section">
<label for="transcription-language" id="trans-lang-label">Transcription Language</label>
<select id="transcription-language">
<option value="en-US">English (Default)</option>
<option value="es-ES">Spanish</option>
<option value="fr-FR">French</option>
<option value="de-DE">German</option>
<option value="ja-JP">Japanese</option>
</select>
</div>
<div class="settings-section">
<label for="mastery-level" id="mastery-label">Mastery Level</label>
<select id="mastery-level">
<option value="default">Default</option>
<option value="Pre-K/Early Childhood">Pre-K/Early Childhood</option>
<option value="Elementary School">Elementary School</option>
<option value="Middle School">Middle School</option>
<option value="High School">High School</option>
<option value="Undergraduate">Undergraduate</option>
<option value="Graduate Level">Graduate Level</option>
<option value="Doctoral Level">Doctoral Level</option>
<option value="Post-Doctoral">Post-Doctoral</option>
</select>
<button id="apply-mastery" class="apply-button" disabled>Apply</button>
</div>
<div class="settings-section">
<label for="gemini-api-key">Gemini API Key</label>
<input id="gemini-api-key" type="password" placeholder="AIza..." style="width:100%;padding:8px;background:#333;border:1px solid #555;border-radius:5px;color:#fff;">
<div style="display:flex;gap:10px;align-items:center;margin-top:8px;">
<button id="save-gemini-key" class="apply-button">Save Key</button>
<small id="key-status" style="color:#8aa;">Not set</small>
</div>
</div>
</div>
<script>
// --- Configuration ---
const NUM_PARTICLES = 8000;
const NOISE_SCALE = 0.007;
const BASE_PARTICLE_SPEED = 0.8;
const GRAMMAR_CORRECTION_DELAY = 3000;
let time = 0;
const MAX_KB_PAGES = 3;
const APPROX_WORDS_PER_PAGE = 350;
const MAX_KB_WORDS = MAX_KB_PAGES * APPROX_WORDS_PER_PAGE;
let kbSummary = "";
// --- Global Variables ---
let particles = [];
let mic, fft;
let isAudioActive = false;
let lastClickPosition = null;
let speechRec;
let fullTranscript = "";
let interimTranscript = "";
let speechBurst = 0;
let grammarCorrectionTimer = null;
let eyeballPos, eyeballTarget, eyeballWanderNoiseX, eyeballWanderNoiseY;
let eyeballJumpFrames = 0;
// AI Learning Assistant variables
let subject = "";
let knowledgeBase = "";
let understandingScore = 0;
let hint = "Start by explaining the basics.";
let expertiseLevel = "Not yet assessed";
let currentExpertiseIndex = -1;
let appLanguage = 'en';
let transcriptionLanguage = 'en-US';
let masteryLevel = 'default';
let isMicOn = false;
const REPLACE_TRANSCRIPT_WITH_LAST_CORRECTION = false;
const PROFESSOR_CORRECTION_INSTRUCTION = `
You are a meticulous university professor of linguistics and rhetoric.
TASK: Given RAW_INPUT, return EXACTLY ONE sentence that is fully grammatical and correctly punctuated
(accurate capitalization, commas, apostrophes, agreement, tense consistency, and a final terminal mark),
preserving the original meaning and language. If the input is a question, end the sentence with a question mark (?).
RESPONSE RULES:
- Output ONLY the corrected sentence text.
- NO explanations, labels, quotes, brackets, markdown, code fences, or JSON.
RAW_INPUT:
`;
const PROFESSOR_CORRECTION_INSTRUCTION_ALT = `
You are a university PhD professor copy-editing a single sentence.
Rewrite RAW_INPUT as ONE grammatically impeccable sentence with the correct terminal mark:
use ? if it is a question, otherwise use . (or ! if clearly exclamatory). Preserve meaning and language.
Output the sentence only — no extra words or formatting.
RAW_INPUT:
`;
let lastFinalChunk = "";
let transcriptSegments = [];
let correctionQueue = [];
let isCorrecting = false;
let hintUpdateTimer = null;
const HINT_UPDATE_DEBOUNCE = 1200;
let pendingMasteryLevel = 'default';
let currentTargetLevel = 'default';
let poolingPulse = 0;
const POOLING_DECAY = 0.985;
const POOLING_STRENGTH = 0.18;
let GEMINI_API_KEY = localStorage.getItem('GEMINI_API_KEY') || '';
const GEMINI_MODEL_CANDIDATES = [
'gemini-2.5-flash-preview-05-20',
'gemini-1.5-flash',
'gemini-1.5-flash-8b'
];
const MASTERY_LEVELS = [
"Pre-K/Early Childhood", "Elementary School", "Middle School", "High School",
"Undergraduate", "Graduate Level", "Doctoral Level", "Post-Doctoral"
];
const MASTERY_THRESHOLDS = {
'default': { green: 0.80, yellow: 0.50 },
'Pre-K/Early Childhood': { green: 0.60, yellow: 0.35 },
'Elementary School': { green: 0.65, yellow: 0.40 },
'Middle School': { green: 0.70, yellow: 0.50 },
'High School': { green: 0.75, yellow: 0.55 },
'Undergraduate': { green: 0.80, yellow: 0.60 },
'Graduate Level': { green: 0.85, yellow: 0.65 },
'Doctoral Level': { green: 0.90, yellow: 0.70 },
'Post-Doctoral': { green: 0.93, yellow: 0.75 }
};
const BAND_HUES = {
red: { start: 0, end: 40 },
yellow: { start: 40, end: 80 },
green: { start: 80, end: 120 }
};
const UI_TEXT = {
en: {
hint: "Hint:",
settings: "Settings & Help",
appLang: "App Language",
transLang: "Transcription Language",
mastery: "Mastery Level"
},
es: {
hint: "Pista:",
settings: "Ajustes y Ayuda",
appLang: "Idioma de la App",
transLang: "Idioma de Transcripción",
mastery: "Nivel de Maestría"
}
};
const IDLE_HUE = 210;
const IDLE_SAT = 90;
const IDLE_BRI = 100;
const AMP_SPEAK_THRESHOLD = 0.02;
const SPEAK_FLASH_MS = 2000;
let speakFlashUntil = 0;
let currentSpeakFlash = 0;
// --- p5.js Setup Function ---
function setup() {
createCanvas(windowWidth, windowHeight);
colorMode(HSB, 360, 100, 100, 100);
const centerX = width / 2;
const centerY = height / 2;
const radius = min(width, height) / 2.2;
for (let i = 0; i < NUM_PARTICLES; i++) {
particles.push(new Particle(centerX, centerY, radius));
}
eyeballPos = createVector(centerX, centerY);
eyeballTarget = createVector(centerX, centerY);
eyeballWanderNoiseX = random(1000);
eyeballWanderNoiseY = random(1000);
document.getElementById('subject-input').addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
setSubject(this.value);
this.value = '';
document.getElementById('chat-container').style.display = 'none';
}
});
document.getElementById('settings-button').addEventListener('click', () => {
document.getElementById('settings-modal').style.display = 'block';
});
document.getElementById('close-settings').addEventListener('click', () => {
document.getElementById('settings-modal').style.display = 'none';
});
document.getElementById('app-language').addEventListener('change', (e) => {
appLanguage = e.target.value;
updateUIText();
});
document.getElementById('transcription-language').addEventListener('change', (e) => {
transcriptionLanguage = e.target.value;
if (isAudioActive && speechRec) {
speechRec.stop();
setupSpeechRecognition();
}
});
document.getElementById('mastery-level').addEventListener('change', (e) => {
pendingMasteryLevel = e.target.value;
const btn = document.getElementById('apply-mastery');
btn.disabled = false;
btn.textContent = 'Apply';
});
document.getElementById('apply-mastery').addEventListener('click', async () => {
masteryLevel = pendingMasteryLevel;
currentTargetLevel = masteryLevel;
const btn = document.getElementById('apply-mastery');
btn.disabled = true;
btn.textContent = 'Applied';
setTimeout(() => { poolingPulse = Math.max(poolingPulse, 1); }, 200);
if (subject) {
generateKnowledgeBase().then(() => scheduleHintUpdate(0)).catch(() => {});
}
});
document.getElementById('exit-subject-button').addEventListener('click', exitSubjectMode);
document.getElementById('mic-button').addEventListener('click', toggleMic);
document.getElementById('save-gemini-key').addEventListener('click', () => {
const v = document.getElementById('gemini-api-key').value;
setGeminiKey(v);
});
window.addEventListener('load', () => {
const input = document.getElementById('gemini-api-key');
if (input) input.value = GEMINI_API_KEY;
updateKeyStatus();
});
makeDraggable(document.getElementById('hint-container'));
makeDraggable(document.getElementById('settings-container'));
makeDraggable(document.getElementById('subject-container'));
makeDraggable(document.getElementById('settings-modal'), document.getElementById('settings-title'));
makeDraggable(document.getElementById('kb-summary'));
background(0);
}
// --- p5.js Draw Function ---
function draw() {
background(0, 25);
if (!isAudioActive) return;
fft.analyze();
let amplitude = mic.getLevel();
if (amplitude > AMP_SPEAK_THRESHOLD) {
speakFlashUntil = millis() + SPEAK_FLASH_MS;
}
currentSpeakFlash = constrain((speakFlashUntil - millis()) / SPEAK_FLASH_MS, 0, 1);
const centerX = width / 2;
const centerY = height / 2;
const radius = min(width, height) / 2.2;
updateEyeball(centerX, centerY, radius);
drawPulsatingSphere(centerX, centerY, radius, amplitude, eyeballPos);
if (lastClickPosition) {
for (let p of particles) p.applyClickForce(lastClickPosition);
lastClickPosition = null;
}
speechBurst = max(0, speechBurst * 0.92);
for (let p of particles) {
p.updateAudioReactive(amplitude);
p.applySpeechBurst(centerX, centerY, speechBurst);
p.moveInFlowField(amplitude);
p.checkBoundary(centerX, centerY, radius);
p.displayTrail(centerX, centerY, radius);
}
displayTranscription(centerX, centerY, radius);
time += 0.005 + (amplitude * 0.05);
poolingPulse *= POOLING_DECAY;
}
function updateEyeball(cx, cy, r) {
const isFocusing = interimTranscript.trim().length > 0;
if (eyeballJumpFrames > 0) {
const jumpHeight = 20;
const jumpProgress = map(eyeballJumpFrames, 30, 0, 0, PI);
const jumpOffset = sin(jumpProgress) * jumpHeight;
eyeballTarget.set(cx, cy - jumpOffset);
eyeballJumpFrames--;
} else if (isFocusing) {
eyeballTarget.set(cx, cy);
} else {
eyeballWanderNoiseX += 0.015;
eyeballWanderNoiseY += 0.015;
let wanderX = noise(eyeballWanderNoiseX);
let wanderY = noise(eyeballWanderNoiseY);
let targetX = map(wanderX, 0, 1, cx - r * 0.5, cx + r * 0.5);
let targetY = map(wanderY, 0, 1, cy - r * 0.5, cy + r * 0.5);
eyeballTarget.set(targetX, targetY);
}
eyeballPos.lerp(eyeballTarget, 0.08);
}
function drawPulsatingSphere(cx, cy, r, amp, eyePos) {
noFill();
let basePulse = sin(time * 4);
let mappedBasePulse = map(basePulse, -1, 1, 15, 40);
let voicePulse = map(amp, 0, 0.3, 0, 150, true);
const glowAlpha = mappedBasePulse + voicePulse;
const maxGlowSize = 25;
for (let i = maxGlowSize; i > 0; i -= 3) {
let currentAlpha = map(i, maxGlowSize, 0, 0, glowAlpha);
stroke(255, currentAlpha);
strokeWeight(3);
ellipse(cx, cy, (r * 2) + i);
}
stroke(200, glowAlpha);
strokeWeight(4);
ellipse(cx, cy, r * 2);
stroke(255, glowAlpha * 1.5);
strokeWeight(1.5);
ellipse(cx, cy, r * 2 - 6);
noStroke();
fill(255, 20);
ellipse(eyePos.x, eyePos.y, r * 0.8);
fill(255, 30);
ellipse(eyePos.x + r * 0.1, eyePos.y - r * 0.1, r * 0.4);
}
function displayTranscription(cx, cy, r) {
const boxSize = r * 1.4;
const displayText = fullTranscript + interimTranscript;
let dynamicTextSize = map(displayText.length, 0, 300, 32, 12, true);
fill(255, 200);
textSize(dynamicTextSize);
textAlign(CENTER, CENTER);
textStyle(BOLD);
text(displayText, cx, cy, boxSize, boxSize);
textStyle(NORMAL);
}
// --- Particle Class ---
class Particle {
constructor(cx, cy, r) {
const angle = random(TWO_PI);
const rad = r * sqrt(random());
const x = cx + rad * cos(angle);
const y = cy + rad * sin(angle);
this.pos = createVector(x, y);
this.vel = createVector(0, 0);
this.acc = createVector(0, 0);
this.maxSpeed = 4;
this.hue = 0;
this.saturation = 90;
}
updateAudioReactive(amp) {
this.maxSpeed = 4 + amp * 20;
this.hue = IDLE_HUE;
this.saturation = IDLE_SAT;
}
applyClickForce(clickPos) {
const clickRadius = 150;
const clickStrength = 6;
let d = p5.Vector.dist(this.pos, clickPos);
if (d < clickRadius) {
let force = p5.Vector.sub(this.pos, clickPos);
let forceMagnitude = map(d, 0, clickRadius, clickStrength, 0);
force.setMag(forceMagnitude);
this.acc.add(force);
}
}
applySpeechBurst(cx, cy, burstStrength) {
if (burstStrength > 0) {
let force = p5.Vector.sub(this.pos, createVector(cx, cy));
let distance = force.mag();
let forceMagnitude = map(distance, 0, width / 2, 1, 0.5);
force.setMag(burstStrength * 60 * forceMagnitude);
this.acc.add(force);
}
}
moveInFlowField(amp) {
let angle = noise(this.pos.x * NOISE_SCALE, this.pos.y * NOISE_SCALE, time) * TWO_PI * 4;
let flowForce = p5.Vector.fromAngle(angle);
flowForce.mult(0.1);
this.acc.add(flowForce);
if (amp > 0.01) {
let jerk = p5.Vector.random2D();
jerk.mult(amp * 8);
this.acc.add(jerk);
}
if (poolingPulse > 0.001) {
const center = createVector(width / 2, height / 2);
let toCenter = p5.Vector.sub(center, this.pos);
const dist = toCenter.mag();
const mag = POOLING_STRENGTH * poolingPulse * map(dist, 0, min(width, height) / 2, 0.5, 1.0, true);
toCenter.setMag(mag);
this.acc.add(toCenter);
}
this.vel.add(this.acc);
this.vel.limit(this.maxSpeed);
this.pos.add(this.vel);
this.acc.mult(0);
}
displayTrail(cx, cy, r) {
let vec = p5.Vector.sub(this.pos, createVector(cx, cy));
let dist = vec.mag();
let distortionStrength = 0.15;
let distortionAmount = map(dist, 0, r, 1, 1 - distortionStrength);
vec.mult(distortionAmount);
let distortedPos = p5.Vector.add(createVector(cx, cy), vec);
const sat = lerp(0, IDLE_SAT, 1 - currentSpeakFlash);
strokeWeight(1.5);
stroke(this.hue, sat, IDLE_BRI, 80);
point(distortedPos.x, distortedPos.y);
}
checkBoundary(cx, cy, r) {
let v = p5.Vector.sub(this.pos, createVector(cx, cy));
if (v.mag() > r) {
v.setMag(r);
this.pos.set(cx + v.x, cy + v.y);
this.vel.mult(-0.5);
}
}
}
// --- Event Handlers ---
function mousePressed() {
if (!isAudioActive) {
mic = new p5.AudioIn();
fft = new p5.FFT(0.8, 128);
fft.setInput(mic);
setupSpeechRecognition();
isAudioActive = true;
document.getElementById('instruction').style.display = 'none';
document.getElementById('chat-container').style.display = 'flex';
document.getElementById('settings-container').style.display = 'block';
document.getElementById('subject-container').style.display = 'block';
toggleMic();
}
lastClickPosition = createVector(mouseX, mouseY);
}
function restartSpeechRec() {
if (isAudioActive && isMicOn) speechRec.start();
}
function gotSpeech(event) {
clearTimeout(grammarCorrectionTimer);
interimTranscript = '';
let final_transcript_part = '';
for (let i = event.resultIndex; i < event.results.length; ++i) {
let transcript = event.results[i][0].transcript;
if (event.results[i].isFinal) {
final_transcript_part += transcript.trim() + ' ';
speechBurst = 1.0;
} else {
interimTranscript += transcript;
}
}
if (final_transcript_part) {
const chunk = final_transcript_part.trim();
lastFinalChunk = chunk;
transcriptSegments.push({ raw: chunk, corrected: null });
recomputeFullTranscript();
scheduleHintUpdate();
setTimeout(() => enqueueCorrection(chunk), GRAMMAR_CORRECTION_DELAY);
}
}
// --- Centralized Speech Rec Setup ---
function setupSpeechRecognition() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (SpeechRecognition) {
speechRec = new SpeechRecognition();
speechRec.continuous = true;
speechRec.interimResults = true;
speechRec.lang = transcriptionLanguage;
speechRec.onresult = gotSpeech;
speechRec.onend = restartSpeechRec;
} else {
console.error("Speech Recognition not supported by this browser.");
}
}
// --- Mic Toggle Function ---
function toggleMic() {
if (!mic) return;
isMicOn = !isMicOn;
if (isMicOn) {
mic.start();
if (speechRec) speechRec.start();
document.getElementById('mic-button').classList.add('recording');
} else {
mic.stop();
if (speechRec) speechRec.stop();
document.getElementById('mic-button').classList.remove('recording');
}
}
// --- Gemini API Learning Assistant Logic ---
async function setSubject(newSubject) {
if (newSubject && newSubject.trim() !== "") {
subject = newSubject.trim();
document.getElementById('subject-text').textContent = subject;
document.getElementById('exit-subject-button').style.display = 'inline-block';
fullTranscript = "";
transcriptSegments = [];
interimTranscript = "";
understandingScore = 0;
expertiseLevel = "Not yet assessed";
currentExpertiseIndex = -1;
document.getElementById('expertise-text').textContent = expertiseLevel;
document.getElementById('hint-container').style.display = 'block';
hint = "Generating knowledge base...";
document.getElementById('hint-text').textContent = hint;
generateKnowledgeBase().then(() => scheduleHintUpdate(0)).catch(() => {});
}
}
function exitSubjectMode() {
subject = "";
knowledgeBase = "";
document.getElementById('subject-text').textContent = "None";
document.getElementById('exit-subject-button').style.display = 'none';
document.getElementById('hint-container').style.display = 'none';
document.getElementById('chat-container').style.display = 'flex';
fullTranscript = "";
transcriptSegments = [];
interimTranscript = "";
understandingScore = 0;
expertiseLevel = "Not yet assessed";
document.getElementById('expertise-text').textContent = expertiseLevel;
}
async function generateKnowledgeBase() {
if (!GEMINI_API_KEY) {
knowledgeBase = buildLocalFallbackKB(subject, currentTargetLevel);
document.getElementById('hint-container').style.display = 'block';
document.getElementById('hint-text').textContent =
'Using local fallback KB. Add a Gemini API key in Settings for richer content.';
return;
}
if (location.protocol === 'file:') {
document.getElementById('hint-container').style.display = 'block';
document.getElementById('hint-text').textContent =
'If your Gemini key is referrer-restricted, run this over http(s) (e.g., "npx serve") or allow this origin.';
}
const kbKey = `KB::${subject}::${currentTargetLevel}`;
const cached = localStorage.getItem(kbKey);
if (cached) {
knowledgeBase = enforceKBLength(cached);
const cachedSum = localStorage.getItem(`KBSUM::${subject}::${currentTargetLevel}`);
if (cachedSum) kbSummary = cachedSum;
else kbSummary = await computeSummaryFromKB(knowledgeBase);
renderKBSummary(kbSummary);
document.getElementById('hint-container').style.display = 'block';
document.getElementById('hint-text').textContent = 'Knowledge base ready — start explaining.';
return;
}
const kbPrompt = `
You are a master curriculum designer.
Subject: "${subject}". ${masteryTag()}
Write a compact knowledge base tailored to the target level that will be used to:
(1) evaluate learner explanations and (2) generate next-step hints.
Format: plain text (no markdown). Aim to print to ≤ ${MAX_KB_PAGES} pages.
Hard length cap: about ${MAX_KB_WORDS} words. Keep it concise and level-appropriate.
Include:
• Core concepts and crisp definitions (brief)
• Minimal prerequisites (one sentence)
• Typical misconceptions at THIS level
• One sentence on what “mastery at this level” looks like
`;
const kb = await callGemini(kbPrompt, { temperature: 0.2, maxOutputTokens: 800 });
let text = (kb && typeof kb === 'string') ? kb.trim() : '';
if (!text) {
text = buildLocalFallbackKB(subject, currentTargetLevel);
document.getElementById('hint-text').textContent =
'Using local fallback KB (Gemini did not return text).';
}
knowledgeBase = enforceKBLength(text);
kbSummary = await computeSummaryFromKB(knowledgeBase);
renderKBSummary(kbSummary);
localStorage.setItem(kbKey, knowledgeBase);
localStorage.setItem(`KBSUM::${subject}::${currentTargetLevel}`, kbSummary);
document.getElementById('hint-container').style.display = 'block';
document.getElementById('hint-text').textContent = 'Knowledge base ready — start explaining.';
}
async function analyzeAndCorrect(rawInput) {
if (!rawInput || !rawInput.trim()) return;
const originalText = rawInput.trim();
let correctedText = originalText;
try {
correctedText = await correctWithRedundancyProfessor(originalText);
correctSegment(originalText, correctedText);
} catch (error) {
console.error("Grammar correction pipeline error:", error);
const fallback = enforceSingleSentence(
applyCommaHeuristics(
ensurePunctuationCaseAndTerminal(normalizeQuotesAndSpaces(originalText), originalText)
)
);
correctSegment(originalText, fallback);
}
if (knowledgeBase) {
try {
let analysisPrompt =
`Knowledge Base: "${knowledgeBase}". ` +
`User's Explanation: "${fullTranscript}". ` +
`${masteryTag()} ` +
`Evaluate understanding on a 0.0 to 1.0 scale. ` +
`Then choose a single expertise level from [${MASTERY_LEVELS.join(", ")}]. ` +
`Format as JSON: {"score":number,"expertise":string}.`;
let analysisResult = await callGemini(analysisPrompt);
if (analysisResult && analysisResult.includes('```json')) {
analysisResult = analysisResult.replace(/```json\n?|```/g, '').trim();
}
if (!analysisResult) throw new Error("Analysis returned null");
const resultObj = JSON.parse(analysisResult);
if (typeof resultObj.score === 'number') understandingScore = resultObj.score;
if (resultObj.expertise) {
const newIndex = MASTERY_LEVELS.indexOf(resultObj.expertise);
if (newIndex > currentExpertiseIndex) {
currentExpertiseIndex = newIndex;
expertiseLevel = resultObj.expertise;
document.getElementById('expertise-text').textContent = expertiseLevel;
}
}
} catch (e) {
console.error("Could not parse Gemini analysis response:", e);
}
}
eyeballJumpFrames = 30;
lastFinalChunk = ""; // clear after use
}
// --- NEW: Redundancy Helpers ---
function recomputeFullTranscript() {
fullTranscript = transcriptSegments
.map(seg => (seg.corrected || seg.raw))
.join(' ');
if (fullTranscript && !/\s$/.test(fullTranscript)) fullTranscript += ' ';
updateTranscriptDisplay();
}
function correctSegment(raw, corrected) {
for (let i = transcriptSegments.length - 1; i >= 0; i--) {
const seg = transcriptSegments[i];
if (!seg.corrected && seg.raw === raw) {
seg.corrected = corrected;
break;
}
}
recomputeFullTranscript();
scheduleHintUpdate();
}
function enqueueCorrection(text) {
correctionQueue.push(text);
if (!isCorrecting) processNextCorrection();
}
async function processNextCorrection() {
if (correctionQueue.length === 0) return;
isCorrecting = true;
const raw = correctionQueue.shift();
await analyzeAndCorrect(raw);
isCorrecting = false;
if (correctionQueue.length > 0) processNextCorrection();
}
function isLikelySingleSentence(s) {
if (!s) return false;
const t = s.trim();
if (t.length < 2) return false;
if (!/[.!?…]$/.test(t)) return false;
if (/```|^\{|^\[|^<|^#+\s/.test(t)) return false;
const internal = t.slice(0, -1).match(/[.!?…]/g);
if (internal && internal.length > 0) return false;
return true;
}
function normalizeQuotesAndSpaces(s) {
if (!s) return s;
return s
.replace(/[“”]/g, '"')
.replace(/[‘’]/g, "'")
.replace(/\s+([,.;:!?])/g, '$1')
.replace(/([,.;:!?])(?!\s|$)/g, '$1 ')
.replace(/\s{2,}/g, ' ')
.trim();
}
function applyCommaHeuristics(s) {
if (!s) return s;
const introWords = [
'however','therefore','meanwhile','moreover','furthermore',
'consequently','nevertheless','instead','nonetheless',
'yes','no','well','actually','in fact','for example','for instance'
];
let t = s;
introWords.forEach(w => {
const re = new RegExp(`^(${w})\\b\\s+(?=[a-zA-Z])`, 'i');
t = t.replace(re, (m, g1) => `${g1.charAt(0).toUpperCase()}${g1.slice(1)}, `);
});
t = t.replace(/(\b[^,]+,\s+[^,]+)\s+and\s+([^,]+)\./i, '$1, and $2.');
return t;
}
function enforceSingleSentence(s) {
if (!s) return s;
const m = s.match(/(.+?[.!?…])(?:\s|$)/);
return m ? m[1].trim() : s.trim();
}
async function correctWithRedundancyProfessor(rawInput) {
const original = rawInput.trim();
let a1 = await callGemini(`${PROFESSOR_CORRECTION_INSTRUCTION}\n${original}`);
a1 = sanitizeModelOutput(a1);
a1 = ensurePunctuationCaseAndTerminal(normalizeQuotesAndSpaces(a1 || original), original);
a1 = enforceSingleSentence(a1);
if (isLikelySingleSentence(a1)) return a1;
let a2 = await callGemini(`${PROFESSOR_CORRECTION_INSTRUCTION_ALT}\n${original}`);
a2 = sanitizeModelOutput(a2);
a2 = ensurePunctuationCaseAndTerminal(normalizeQuotesAndSpaces(a2 || original), original);
a2 = enforceSingleSentence(a2);
if (isLikelySingleSentence(a2)) return a2;
let local = ensurePunctuationCaseAndTerminal(normalizeQuotesAndSpaces(original), original);
local = applyCommaHeuristics(local);
local = enforceSingleSentence(local);
return local;
}
function sanitizeModelOutput(s) {
if (!s) return "";
s = s.replace(/^```(?:json)?\s*|\s*```$/g, "");
s = s.replace(/^"|"$/g, "");
s = s.trim().replace(/[\r\n]+/g, " ").replace(/\s{2,}/g, " ");
return s;
}
function fixCommonContractions(t) {
return t
.replace(/\bim\b/gi, "I'm")
.replace(/\bdont\b/gi, "don't")
.replace(/\bcant\b/gi, "can't")
.replace(/\bwont\b/gi, "won't")
.replace(/\bive\b/gi, "I've")
.replace(/\bill\b/gi, "I'll")
.replace(/\bid\b/gi, "I'd")
.replace(/\bisnt\b/gi, "isn't")
.replace(/\baren't\b/gi, "aren't")
.replace(/\bwasnt\b/gi, "wasn't")
.replace(/\bwerent\b/gi, "weren't")
.replace(/\bshouldnt\b/gi, "shouldn't")
.replace(/\bcouldnt\b/gi, "couldn't")
.replace(/\bwouldnt\b/gi, "wouldn't")
.replace(/\bdidnt\b/gi, "didn't")
.replace(/\bdoesnt\b/gi, "doesn't")
.replace(/\bhavent\b/gi, "haven't")
.replace(/\bhasnt\b/gi, "hasn't");
}
function ensurePunctuationCaseAndTerminal(s, rawForHeuristics = "") {
if (!s) return s;
let t = s.trim();
t = t.replace(/^\s*([a-z])/, (_, c) => c.toUpperCase());
t = t.replace(/\bi\b/g, "I");
t = fixCommonContractions(t);
t = t.replace(/\s*([,.;:!?])\s*/g, "$1 ")
.replace(/\s{2,}/g, " ")
.trim();
if (!/[.!?…]$/.test(t)) {
const q = (rawForHeuristics || t).trim().toLowerCase();
const seemsQuestion = isLikelyQuestion(q);
t += seemsQuestion ? "?" : ".";
}
return t;
}
function isLikelyQuestion(s) {
if (!s) return false;
const t = s.trim().toLowerCase();
if (t.includes('?')) return true;
const cleaned = t.replace(/^(uh+|um+|well|so|like|okay|ok|hey|yo)[, ]+\s*/i, '');
if (/^(who|whom|whose|which|what|'?what?s|when|where|why|how|'?how?s)\b/.test(cleaned)) return true;
if (/^(do|does|did|is|are|am|was|were|have|has|had|can|could|should|would|will|shall|may|might|must|ought)\b/.test(cleaned)) return true;
if (/^(could|can|would|will|may|might|should)\s+(you|we|i|he|she|they|it)\b/.test(cleaned)) return true;
if (/^(is there|are there|do you think|any chance|would it be possible)\b/.test(cleaned)) return true;
return false;
}
async function callGemini(prompt, { maxOutputTokens = 512, temperature = 0 } = {}) {
try {
if (!GEMINI_API_KEY) {
console.warn('Missing Gemini API key. Set it in Settings.');
return null;
}
const payload = {
contents: [{ role: "user", parts: [{ text: prompt }]}],
generationConfig: { temperature, topP: 1, topK: 1, maxOutputTokens }
};
const apiUrl = `