feat(listening): interactive listening comprehension trainer with neural voiceover and auto-evaluation

This commit is contained in:
Antigravity committed 2026-10-07 10:57:10 +03:00
1 parent a6fb4349e9
commit 7893b353aa
8 files changed
+1026 -3

No files matched your search

+215
View File
@@ -2216,6 +2216,221 @@ body.drawer-open {
font-style: italic;
}
/* Interactive Listening Comprehension Mode */
.listening-card {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 28px 32px;
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
gap: 20px;
}
.listening-header-row {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.listening-tag-wrap {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.level-badge {
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
border-radius: var(--radius-sm);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.level-badge.badge-a1 { background: rgba(59, 130, 246, 0.15); color: #60a5fa; border: 1px solid rgba(59, 130, 246, 0.3); }
.level-badge.badge-a2 { background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3); }
.level-badge.badge-b1 { background: rgba(245, 158, 11, 0.15); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.3); }
.level-badge.badge-b2 { background: rgba(249, 115, 22, 0.15); color: #fb923c; border: 1px solid rgba(249, 115, 22, 0.3); }
.level-badge.badge-c1 { background: rgba(168, 85, 247, 0.15); color: #c084fc; border: 1px solid rgba(168, 85, 247, 0.3); }
.topic-badge {
font-size: 11px;
font-weight: 600;
color: var(--text-dim);
background: var(--bg-main);
border: 1px solid var(--border-color);
padding: 2px 8px;
border-radius: var(--radius-sm);
}
.listening-filters {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.select-styled {
background-color: var(--bg-main);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-main);
padding: 6px 10px;
font-size: 13px;
outline: none;
cursor: pointer;
transition: var(--transition);
}
.select-styled:focus {
border-color: var(--primary);
box-shadow: 0 0 0 2px var(--primary-dim);
}
.select-xs {
font-size: 12px;
padding: 4px 8px;
}
.listening-hero {
background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg-main) 100%);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.listening-scenario-title {
font-size: 18px;
font-weight: 700;
color: var(--text-main);
margin: 0 0 4px 0;
}
.listening-subtitle {
font-size: 13px;
color: var(--text-muted);
margin: 0;
}
.listening-audio-controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 4px;
}
@media (max-width: 600px) {
.listening-audio-controls {
grid-template-columns: 1fr;
}
}
.audio-control-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 8px;
transition: var(--transition);
}
.audio-control-card:hover {
border-color: var(--primary-dim);
}
.audio-control-label {
font-size: 12px;
font-weight: 600;
color: var(--text-dim);
}
.listening-play-btn {
width: 100%;
justify-content: center;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 8px;
}
.listening-hint-bar {
display: flex;
align-items: center;
gap: 12px;
}
.listening-hint-text {
font-size: 13px;
color: var(--text-muted);
font-style: italic;
background: var(--bg-main);
border-left: 3px solid var(--primary);
padding: 4px 10px;
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.listening-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.listening-revealed-card {
background-color: var(--bg-main);
border: 1px solid var(--border-color);
border-left: 4px solid var(--primary);
border-radius: var(--radius-md);
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
animation: fadeIn 0.25s ease-out;
}
.revealed-section {
display: flex;
flex-direction: column;
gap: 4px;
}
.revealed-label {
font-size: 12px;
font-weight: 700;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.revealed-val {
font-size: 16px;
color: #34d399;
}
.revealed-text {
font-size: 15px;
line-height: 1.5;
color: var(--text-main);
margin: 0;
}
.listening-next-bar {
display: flex;
justify-content: flex-end;
margin-top: 8px;
padding-top: 12px;
border-top: 1px solid var(--border-color);
}
/* Summary Card */
.srs-summary-card {
background-color: var(--bg-card);
+92 -1
View File
@@ -233,7 +233,8 @@
<button class="srs-tab" data-mode="quiz">🎯 4 Варианта</button>
<button class="srs-tab" data-mode="typing">✍️ Набор слова</button>
<button class="srs-tab" data-mode="cloze">🧩 Пропуски</button>
<button class="srs-tab" data-mode="dictation" style="display: none;">🎧 Диктант</button>
<button class="srs-tab" data-mode="dictation">🎧 Диктант</button>
<button class="srs-tab" data-mode="listening">📻 Аудирование</button>
</div>
</div>
@@ -492,6 +493,96 @@
</div>
</div>
<!-- MODE 6: Listening Comprehension Q&A -->
<div id="srs-mode-listening" class="srs-mode-container hidden">
<div class="trainer-card listening-card">
<div class="trainer-header listening-header-row">
<div class="listening-tag-wrap">
<span class="mode-tag">📻 Аудирование & Вопросы</span>
<span id="listening-level-badge" class="level-badge badge-b1">B1</span>
<span id="listening-topic-badge" class="topic-badge">Tech</span>
</div>
<div class="listening-filters">
<select id="listening-topic-select" class="select-styled select-xs" title="Выбрать тему">
<option value="all">🌐 Все темы</option>
<option value="tech">💻 Технологии & IT</option>
<option value="daily">☕ Повседневная жизнь</option>
<option value="travel">✈️ Путешествия</option>
<option value="science">🔬 Наука</option>
<option value="business">💼 Работа & Бизнес</option>
<option value="cards">📚 Мои слова из словаря</option>
</select>
<select id="listening-speed-select" class="select-styled select-xs" title="Скорость речи">
<option value="1.0">⚡ 1.0x</option>
<option value="0.75">🐢 0.75x</option>
<option value="1.25">🚀 1.25x</option>
</select>
<button type="button" id="listening-new-scenario-btn" class="btn btn-xs btn-outline" title="Загрузить другую ситуацию">
🔄 Другая история
</button>
</div>
</div>
<div class="listening-hero">
<div class="listening-title-box">
<h3 id="listening-title" class="listening-scenario-title">Загрузка аудио-ситуации...</h3>
<p class="listening-subtitle">Прослушайте аудио на английском и ответьте на вопрос диктора</p>
</div>
<div class="listening-audio-controls">
<div class="audio-control-card">
<div class="audio-control-label">1. Аудио-история (контекст):</div>
<button type="button" id="listening-play-story-btn" class="btn btn-primary btn-md listening-play-btn">
🔊 Слушать историю <span class="kbd-sub"><kbd>Space</kbd></span>
</button>
</div>
<div class="audio-control-card">
<div class="audio-control-label">2. Вопрос от диктора:</div>
<button type="button" id="listening-play-question-btn" class="btn btn-outline btn-md listening-play-btn">
❓ Слушать вопрос
</button>
</div>
</div>
</div>
<div class="listening-hint-bar">
<button type="button" id="listening-toggle-hint-btn" class="btn btn-xs btn-ghost">💡 Подсказка к вопросу</button>
<span id="listening-hint-text" class="listening-hint-text hidden"></span>
</div>
<form id="listening-form" class="trainer-form listening-form">
<div class="input-action-row">
<input type="text" id="listening-input" class="input-styled input-hero" placeholder="Напишите ответ на английском по услышанному..." autocomplete="off" spellcheck="false" autofocus>
<button type="submit" id="listening-submit-btn" class="btn btn-primary">Проверить ответ</button>
</div>
</form>
<div id="listening-feedback-box" class="trainer-feedback-box hidden"></div>
<div id="listening-revealed-box" class="listening-revealed-card hidden">
<div class="revealed-section">
<span class="revealed-label">🎯 Образцовый ответ:</span>
<strong id="listening-ideal-answer" class="revealed-val text-success"></strong>
</div>
<div class="revealed-section">
<span class="revealed-label">📖 Вопрос (текст):</span>
<p id="listening-revealed-question" class="revealed-text"></p>
<small id="listening-revealed-question-ru" class="text-muted"></small>
</div>
<div class="revealed-section">
<span class="revealed-label">📜 Полный текст истории:</span>
<p id="listening-revealed-story" class="revealed-text"></p>
<small id="listening-revealed-story-ru" class="text-muted"></small>
</div>
<div class="listening-next-bar">
<button type="button" id="listening-next-btn" class="btn btn-primary btn-md">
Следующая ситуация ➔ <span class="kbd-sub"><kbd>Enter</kbd></span>
</button>
</div>
</div>
</div>
</div>
<!-- Session Complete Summary Screen -->
<div id="srs-summary" class="srs-summary-card hidden">
<div class="summary-trophy">🏆</div>
+23
View File
@@ -183,6 +183,29 @@ export const api = {
}
},
// Listening Comprehension Trainer
listening: {
async getExercise(topic = 'all', level = 'B1') {
const resp = await fetch(`/api/listening/exercise?topic=${encodeURIComponent(topic)}&level=${encodeURIComponent(level)}`);
if (resp.ok) return await resp.json();
throw new Error(`Failed to load listening exercise: ${resp.status}`);
},
async checkAnswer(scenarioId, userAnswer, story = '', idealAnswer = '') {
const resp = await fetch('/api/listening/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
scenario_id: scenarioId,
user_answer: userAnswer,
story: story,
ideal_answer: idealAnswer
})
});
if (resp.ok) return await resp.json();
throw new Error(`Failed to check listening answer: ${resp.status}`);
}
},
// Bookshelf & Reader
books: {
async getAll() {
+222 -1
View File
@@ -106,6 +106,34 @@ export function initSrs() {
const dictationDetailsBox = document.getElementById('dictation-details-box');
const dictationRatingBar = document.getElementById('dictation-rating-bar');
// Mode 6: Listening Comprehension
const srsModeListening = document.getElementById('srs-mode-listening');
const listeningLevelBadge = document.getElementById('listening-level-badge');
const listeningTopicBadge = document.getElementById('listening-topic-badge');
const listeningTopicSelect = document.getElementById('listening-topic-select');
const listeningSpeedSelect = document.getElementById('listening-speed-select');
const listeningNewScenarioBtn = document.getElementById('listening-new-scenario-btn');
const listeningTitle = document.getElementById('listening-title');
const listeningPlayStoryBtn = document.getElementById('listening-play-story-btn');
const listeningPlayQuestionBtn = document.getElementById('listening-play-question-btn');
const listeningToggleHintBtn = document.getElementById('listening-toggle-hint-btn');
const listeningHintText = document.getElementById('listening-hint-text');
const listeningForm = document.getElementById('listening-form');
const listeningInput = document.getElementById('listening-input');
const listeningSubmitBtn = document.getElementById('listening-submit-btn');
const listeningFeedbackBox = document.getElementById('listening-feedback-box');
const listeningRevealedBox = document.getElementById('listening-revealed-box');
const listeningIdealAnswer = document.getElementById('listening-ideal-answer');
const listeningRevealedQuestion = document.getElementById('listening-revealed-question');
const listeningRevealedQuestionRu = document.getElementById('listening-revealed-question-ru');
const listeningRevealedStory = document.getElementById('listening-revealed-story');
const listeningRevealedStoryRu = document.getElementById('listening-revealed-story-ru');
const listeningNextBtn = document.getElementById('listening-next-btn');
let currentListeningExercise = null;
let listeningAnswered = false;
let listeningCompletedCount = 0;
// Summary
const srsSummary = document.getElementById('srs-summary');
const sumCount = document.getElementById('sum-count');
@@ -192,7 +220,7 @@ export function initSrs() {
srsLoading.classList.add('hidden');
if (queue.length === 0) {
if (queue.length === 0 && srsCurrentMode !== 'listening') {
srsEmpty.classList.remove('hidden');
return;
}
@@ -216,9 +244,18 @@ export function initSrs() {
srsModeTyping.classList.add('hidden');
srsModeCloze.classList.add('hidden');
srsModeDictation.classList.add('hidden');
if (srsModeListening) srsModeListening.classList.add('hidden');
}
function renderSrsCurrentCard() {
if (srsCurrentMode === 'listening') {
srsEmpty.classList.add('hidden');
srsSummary.classList.add('hidden');
hideAllSrsModeContainers();
renderListeningMode();
return;
}
if (srsCurrentIndex >= srsQueue.length) {
showSrsSummary();
return;
@@ -644,6 +681,178 @@ export function initSrs() {
});
}
// -----------------------------------------------------------------------
// Mode 6: Listening Comprehension
// -----------------------------------------------------------------------
function renderListeningMode() {
if (!srsModeListening) return;
srsModeListening.classList.remove('hidden');
srsCounter.textContent = `📻 Аудирование: решено ${listeningCompletedCount}`;
srsProgressFill.style.width = '100%';
if (!currentListeningExercise) {
loadListeningExercise();
}
}
async function loadListeningExercise(forceNew = false) {
if (!srsModeListening) return;
listeningAnswered = false;
if (listeningFeedbackBox) {
listeningFeedbackBox.classList.add('hidden');
listeningFeedbackBox.innerHTML = '';
}
if (listeningRevealedBox) listeningRevealedBox.classList.add('hidden');
if (listeningHintText) {
listeningHintText.classList.add('hidden');
listeningHintText.textContent = '';
}
if (listeningInput) {
listeningInput.value = '';
listeningInput.disabled = false;
}
if (listeningSubmitBtn) listeningSubmitBtn.disabled = false;
if (listeningTitle) listeningTitle.textContent = 'Загрузка аудио-ситуации...';
const topic = listeningTopicSelect ? listeningTopicSelect.value : 'all';
try {
const data = await api.listening.getExercise(topic, 'B1');
currentListeningExercise = data;
if (listeningTitle) listeningTitle.textContent = data.title || 'Ситуация для аудирования';
if (listeningLevelBadge) {
listeningLevelBadge.textContent = data.level || 'B1';
listeningLevelBadge.className = `level-badge badge-${(data.level || 'b1').toLowerCase()}`;
}
if (listeningTopicBadge) {
listeningTopicBadge.textContent = (data.topic || 'tech').toUpperCase();
}
if (data.hint && listeningToggleHintBtn && listeningHintText) {
listeningToggleHintBtn.classList.remove('hidden');
listeningHintText.textContent = data.hint;
} else if (listeningToggleHintBtn) {
listeningToggleHintBtn.classList.add('hidden');
}
// Smooth focus on input
setTimeout(() => {
if (listeningInput && !srsModeListening.classList.contains('hidden')) {
listeningInput.focus();
}
}, 50);
// Auto-play the story audio
const speed = listeningSpeedSelect ? parseFloat(listeningSpeedSelect.value) || 1.0 : 1.0;
if (data.story_audio_url) {
playAudio(data.story_audio_url, speed);
}
} catch (e) {
console.error('Failed to load listening exercise:', e);
if (listeningTitle) listeningTitle.textContent = 'Не удалось загрузить ситуацию. Нажмите "Другая история".';
showToast('Ошибка загрузки аудирования', 'error');
}
}
if (listeningPlayStoryBtn) {
listeningPlayStoryBtn.addEventListener('click', () => {
if (!currentListeningExercise || !currentListeningExercise.story_audio_url) return;
const speed = listeningSpeedSelect ? parseFloat(listeningSpeedSelect.value) || 1.0 : 1.0;
playAudio(currentListeningExercise.story_audio_url, speed);
});
}
if (listeningPlayQuestionBtn) {
listeningPlayQuestionBtn.addEventListener('click', () => {
if (!currentListeningExercise || !currentListeningExercise.question_audio_url) return;
const speed = listeningSpeedSelect ? parseFloat(listeningSpeedSelect.value) || 1.0 : 1.0;
playAudio(currentListeningExercise.question_audio_url, speed);
});
}
if (listeningToggleHintBtn) {
listeningToggleHintBtn.addEventListener('click', () => {
if (listeningHintText) listeningHintText.classList.toggle('hidden');
});
}
if (listeningNewScenarioBtn) {
listeningNewScenarioBtn.addEventListener('click', () => {
loadListeningExercise(true);
});
}
if (listeningTopicSelect) {
listeningTopicSelect.addEventListener('change', () => {
loadListeningExercise(true);
});
}
if (listeningForm) {
listeningForm.addEventListener('submit', async (e) => {
e.preventDefault();
if (listeningAnswered || !currentListeningExercise) return;
const val = (listeningInput ? listeningInput.value : '').trim();
if (!val) {
showToast('Пожалуйста, напишите ваш ответ', 'info');
if (listeningInput) listeningInput.focus();
return;
}
listeningAnswered = true;
if (listeningInput) listeningInput.disabled = true;
if (listeningSubmitBtn) listeningSubmitBtn.disabled = true;
try {
const res = await api.listening.checkAnswer(
currentListeningExercise.id,
val,
currentListeningExercise.story,
currentListeningExercise.ideal_answer || ''
);
if (listeningFeedbackBox) {
if (res.is_correct) {
listeningFeedbackBox.className = 'trainer-feedback-box feedback-correct';
listeningFeedbackBox.innerHTML = `🎉 <strong>${escapeHtml(res.feedback)}</strong>`;
srsSessionRatings.push(4);
} else {
listeningFeedbackBox.className = 'trainer-feedback-box feedback-wrong';
listeningFeedbackBox.innerHTML = `<strong>${escapeHtml(res.feedback)}</strong>`;
srsSessionRatings.push(2);
}
listeningFeedbackBox.classList.remove('hidden');
}
// Reveal details
if (listeningIdealAnswer) listeningIdealAnswer.textContent = res.ideal_answer || '';
if (listeningRevealedQuestion) listeningRevealedQuestion.textContent = res.question || currentListeningExercise.question || '';
if (listeningRevealedQuestionRu) listeningRevealedQuestionRu.textContent = res.question_ru || currentListeningExercise.question_ru || '';
if (listeningRevealedStory) listeningRevealedStory.textContent = res.story || currentListeningExercise.story || '';
if (listeningRevealedStoryRu) listeningRevealedStoryRu.textContent = res.story_ru || currentListeningExercise.story_ru || '';
if (listeningRevealedBox) listeningRevealedBox.classList.remove('hidden');
listeningCompletedCount++;
srsCounter.textContent = `📻 Аудирование: решено ${listeningCompletedCount}`;
if (listeningNextBtn) listeningNextBtn.focus();
} catch (err) {
console.error('Listening answer check error:', err);
if (listeningInput) listeningInput.disabled = false;
if (listeningSubmitBtn) listeningSubmitBtn.disabled = false;
listeningAnswered = false;
showToast('Ошибка проверки ответа', 'error');
}
});
}
if (listeningNextBtn) {
listeningNextBtn.addEventListener('click', () => {
loadListeningExercise(true);
});
}
function showSrsSummary() {
hideAllSrsModeContainers();
srsSummary.classList.remove('hidden');
@@ -682,6 +891,18 @@ export function initSrs() {
e.preventDefault();
quizNextBtn.click();
}
} else if (srsCurrentMode === 'listening') {
if (listeningAnswered) {
if (e.key === 'Enter') {
e.preventDefault();
if (listeningNextBtn) listeningNextBtn.click();
}
} else {
if (document.activeElement !== listeningInput && e.code === 'Space') {
e.preventDefault();
if (listeningPlayStoryBtn) listeningPlayStoryBtn.click();
}
}
}
}
});
+1 -1
View File
@@ -1,7 +1,7 @@
// Service Worker for Notes English PWA (Stage 2)
// Strategy: Cache-first for static assets, Network-first for API calls.
const CACHE_NAME = 'notes-v6.4';
const CACHE_NAME = 'notes-v6.5';
const OFFLINE_URL = '/';
const PRECACHE_URLS = [