feat(cefr): CEFR level testing and automated gap ingestion engine (Task 15)
This commit is contained in:
1 parent
1c3f03a64c
commit
3fae321322
11 files changed
+2926
-10
No files matched your search
@@ -213,5 +213,40 @@ export const api = {
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// CEFR Level Tests & Gap Ingestion (Task 15)
|
||||
cefr: {
|
||||
async getLevels() {
|
||||
const resp = await fetch('/api/cefr/levels');
|
||||
if (resp.ok) return await resp.json();
|
||||
throw new Error(`Failed to load CEFR levels: ${resp.status}`);
|
||||
},
|
||||
async getQuiz(level = 'A1', count = 10, mode = 'all') {
|
||||
const resp = await fetch(`/api/cefr/quiz?level=${encodeURIComponent(level)}&count=${count}&mode=${encodeURIComponent(mode)}`);
|
||||
if (resp.ok) return await resp.json();
|
||||
throw new Error(`Failed to load CEFR quiz: ${resp.status}`);
|
||||
},
|
||||
async submitAnswer(questionId, selectedOption) {
|
||||
const resp = await fetch('/api/cefr/answer', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
question_id: questionId,
|
||||
selected_option: selectedOption
|
||||
})
|
||||
});
|
||||
if (resp.ok) return await resp.json();
|
||||
throw new Error(`Failed to submit answer: ${resp.status}`);
|
||||
},
|
||||
async resetLevel(level) {
|
||||
const resp = await fetch('/api/cefr/reset', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ level })
|
||||
});
|
||||
if (resp.ok) return await resp.json();
|
||||
throw new Error(`Failed to reset level: ${resp.status}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,480 @@
|
||||
// =======================================================================
|
||||
// CEFR Level Testing & Automated Gap Ingestion Module (Native ES Module)
|
||||
// Oxford 3000 / 5000 Benchmark Testing Engine
|
||||
// =======================================================================
|
||||
|
||||
import { api } from './api.js';
|
||||
import { showToast, escapeHtml } from './ui.js';
|
||||
|
||||
let currentLevel = 'A1';
|
||||
let currentMode = 'all';
|
||||
let currentQuiz = [];
|
||||
let currentQuestionIdx = 0;
|
||||
let isAnswered = false;
|
||||
|
||||
const sessionStats = {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
mistakes: 0,
|
||||
ingested: 0,
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Initializer
|
||||
// -----------------------------------------------------------------------
|
||||
export function initCefr() {
|
||||
const backBtn = document.getElementById('cefr-back-btn');
|
||||
const nextBtn = document.getElementById('cefr-next-btn');
|
||||
const sumRepeatBtn = document.getElementById('cefr-sum-repeat-btn');
|
||||
const sumLevelsBtn = document.getElementById('cefr-sum-levels-btn');
|
||||
const sumSrsBtn = document.getElementById('cefr-sum-srs-btn');
|
||||
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', () => {
|
||||
showCefrScreen('overview');
|
||||
window.location.hash = '#cefr';
|
||||
loadCefrOverview();
|
||||
});
|
||||
}
|
||||
|
||||
if (nextBtn) {
|
||||
nextBtn.addEventListener('click', () => {
|
||||
advanceQuestion();
|
||||
});
|
||||
}
|
||||
|
||||
if (sumRepeatBtn) {
|
||||
sumRepeatBtn.addEventListener('click', () => {
|
||||
startCefrQuiz(currentLevel, currentMode);
|
||||
});
|
||||
}
|
||||
|
||||
if (sumLevelsBtn) {
|
||||
sumLevelsBtn.addEventListener('click', () => {
|
||||
showCefrScreen('overview');
|
||||
window.location.hash = '#cefr';
|
||||
loadCefrOverview();
|
||||
});
|
||||
}
|
||||
|
||||
if (sumSrsBtn) {
|
||||
sumSrsBtn.addEventListener('click', () => {
|
||||
const levelTag = `level-${currentLevel.toLowerCase()}`;
|
||||
window.location.hash = `#srs/${encodeURIComponent(levelTag)}`;
|
||||
});
|
||||
}
|
||||
|
||||
// Keyboard navigation (1-4 for options, Space/Enter for next)
|
||||
window.addEventListener('keydown', (e) => {
|
||||
const cefrView = document.getElementById('cefr-view');
|
||||
const quizScreen = document.getElementById('cefr-quiz-screen');
|
||||
if (!cefrView || cefrView.classList.contains('hidden')) return;
|
||||
if (!quizScreen || quizScreen.classList.contains('hidden')) return;
|
||||
|
||||
// Ignore if user is inside an input/textarea
|
||||
if (['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName)) return;
|
||||
|
||||
if (!isAnswered) {
|
||||
if (['1', '2', '3', '4'].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
const idx = parseInt(e.key, 10) - 1;
|
||||
const btns = document.querySelectorAll('.cefr-opt-btn');
|
||||
if (btns[idx]) btns[idx].click();
|
||||
}
|
||||
} else {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
advanceQuestion();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Screen Switcher inside #cefr-view
|
||||
// -----------------------------------------------------------------------
|
||||
function showCefrScreen(screenName) {
|
||||
const overview = document.getElementById('cefr-overview-screen');
|
||||
const quiz = document.getElementById('cefr-quiz-screen');
|
||||
const summary = document.getElementById('cefr-summary-screen');
|
||||
|
||||
if (overview) overview.classList.toggle('hidden', screenName !== 'overview');
|
||||
if (quiz) quiz.classList.toggle('hidden', screenName !== 'quiz');
|
||||
if (summary) summary.classList.toggle('hidden', screenName !== 'summary');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Load & Render Overview
|
||||
// -----------------------------------------------------------------------
|
||||
export async function loadCefrOverview() {
|
||||
showCefrScreen('overview');
|
||||
const grid = document.getElementById('cefr-levels-grid');
|
||||
if (!grid) return;
|
||||
|
||||
grid.innerHTML = '<div class="cefr-loading-spinner"><span class="spinner-sm"></span> Загрузка уровней...</div>';
|
||||
|
||||
try {
|
||||
const data = await api.cefr.getLevels();
|
||||
const levels = data.levels || [];
|
||||
renderLevelsGrid(levels);
|
||||
} catch (err) {
|
||||
grid.innerHTML = `<div class="empty-state"><p>Ошибка загрузки: ${escapeHtml(err.message)}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderLevelsGrid(levels) {
|
||||
const grid = document.getElementById('cefr-levels-grid');
|
||||
if (!grid) return;
|
||||
|
||||
if (!levels.length) {
|
||||
grid.innerHTML = '<div class="empty-state"><p>Нет доступных уровней</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = levels.map(lvl => {
|
||||
const pct = lvl.progress_percent || 0;
|
||||
const hasMistakes = lvl.in_learning_count > 0;
|
||||
const colorClass = `level-badge-${lvl.level.toLowerCase()}`;
|
||||
|
||||
return `
|
||||
<div class="cefr-level-card" data-level="${lvl.level}">
|
||||
<div class="level-card-header">
|
||||
<div class="level-pill ${colorClass}">
|
||||
<span class="level-code">${escapeHtml(lvl.level)}</span>
|
||||
</div>
|
||||
<div class="level-header-text">
|
||||
<h3 class="level-title">${escapeHtml(lvl.title)}</h3>
|
||||
<span class="level-meta-sub">${lvl.total_questions} эталонных слов</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="level-card-progress">
|
||||
<div class="progress-bar-wrap">
|
||||
<div class="progress-bar-fill ${colorClass}" style="width: ${pct}%"></div>
|
||||
</div>
|
||||
<div class="progress-labels-row">
|
||||
<span class="pct-text">${pct}% освоено</span>
|
||||
<span class="counts-text">${lvl.mastered_count} / ${lvl.total_questions}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="level-card-metrics">
|
||||
<div class="mini-metric">
|
||||
<span class="m-val text-success">✓ ${lvl.mastered_count}</span>
|
||||
<span class="m-lbl">Освоено</span>
|
||||
</div>
|
||||
<div class="mini-metric">
|
||||
<span class="m-val text-warning">⚠️ ${lvl.in_learning_count}</span>
|
||||
<span class="m-lbl">Ошибок</span>
|
||||
</div>
|
||||
<div class="mini-metric">
|
||||
<span class="m-val text-primary">🎓 ${lvl.srs_cards_count}</span>
|
||||
<span class="m-lbl">В словаре SRS</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="level-card-actions">
|
||||
<button type="button" class="btn btn-primary btn-block cefr-start-btn" data-level="${lvl.level}" data-mode="all">
|
||||
🎯 Начать тест (10)
|
||||
</button>
|
||||
${hasMistakes ? `
|
||||
<button type="button" class="btn btn-outline btn-block cefr-mistakes-btn" data-level="${lvl.level}" data-mode="mistakes">
|
||||
⚠️ Работа над ошибками (${lvl.in_learning_count})
|
||||
</button>
|
||||
` : ''}
|
||||
<button type="button" class="btn btn-xs btn-ghost cefr-reset-btn" data-level="${lvl.level}" title="Сбросить прогресс уровня">
|
||||
🔄 Сброс
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Attach button listeners
|
||||
grid.querySelectorAll('.cefr-start-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const lvl = btn.dataset.level;
|
||||
const mode = btn.dataset.mode || 'all';
|
||||
window.location.hash = `#cefr/${encodeURIComponent(lvl)}`;
|
||||
startCefrQuiz(lvl, mode);
|
||||
});
|
||||
});
|
||||
|
||||
grid.querySelectorAll('.cefr-mistakes-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const lvl = btn.dataset.level;
|
||||
window.location.hash = `#cefr/${encodeURIComponent(lvl)}/mistakes`;
|
||||
startCefrQuiz(lvl, 'mistakes');
|
||||
});
|
||||
});
|
||||
|
||||
grid.querySelectorAll('.cefr-reset-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const lvl = btn.dataset.level;
|
||||
if (confirm(`Вы действительно хотите сбросить прогресс прохождения уровня ${lvl}?`)) {
|
||||
try {
|
||||
await api.cefr.resetLevel(lvl);
|
||||
showToast(`Прогресс уровня ${lvl} сброшен`, 'info');
|
||||
loadCefrOverview();
|
||||
} catch (e) {
|
||||
showToast('Ошибка сброса прогресса', 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Start Quiz Session
|
||||
// -----------------------------------------------------------------------
|
||||
export async function startCefrQuiz(level = 'A1', mode = 'all') {
|
||||
currentLevel = level.toUpperCase();
|
||||
currentMode = mode;
|
||||
currentQuestionIdx = 0;
|
||||
isAnswered = false;
|
||||
|
||||
sessionStats.total = 0;
|
||||
sessionStats.correct = 0;
|
||||
sessionStats.mistakes = 0;
|
||||
sessionStats.ingested = 0;
|
||||
|
||||
showCefrScreen('quiz');
|
||||
|
||||
const cardContainer = document.getElementById('cefr-cloze-card');
|
||||
if (cardContainer) {
|
||||
cardContainer.innerHTML = '<div class="cefr-loading-spinner"><span class="spinner-sm"></span> Загрузка теста...</div>';
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await api.cefr.getQuiz(currentLevel, 10, currentMode);
|
||||
currentQuiz = resp.quiz || [];
|
||||
sessionStats.total = currentQuiz.length;
|
||||
|
||||
if (!currentQuiz.length) {
|
||||
if (currentMode === 'mistakes') {
|
||||
showToast(`На уровне ${currentLevel} нет неисправленных ошибок! 🏆`, 'success');
|
||||
showCefrScreen('overview');
|
||||
loadCefrOverview();
|
||||
return;
|
||||
}
|
||||
showToast('Нет доступных вопросов для этого уровня', 'warning');
|
||||
showCefrScreen('overview');
|
||||
loadCefrOverview();
|
||||
return;
|
||||
}
|
||||
|
||||
renderCurrentQuestion();
|
||||
} catch (err) {
|
||||
showToast(`Ошибка загрузки вопросов: ${err.message}`, 'error');
|
||||
showCefrScreen('overview');
|
||||
loadCefrOverview();
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Render Active Question
|
||||
// -----------------------------------------------------------------------
|
||||
function renderCurrentQuestion() {
|
||||
if (currentQuestionIdx >= currentQuiz.length) {
|
||||
renderQuizSummary();
|
||||
return;
|
||||
}
|
||||
|
||||
isAnswered = false;
|
||||
const q = currentQuiz[currentQuestionIdx];
|
||||
|
||||
// Header info
|
||||
const levelBadge = document.getElementById('cefr-quiz-level-badge');
|
||||
const counterBadge = document.getElementById('cefr-quiz-counter');
|
||||
const progressBar = document.getElementById('cefr-quiz-progress-bar');
|
||||
const nextBar = document.getElementById('cefr-next-bar');
|
||||
|
||||
if (levelBadge) {
|
||||
levelBadge.textContent = `Уровень ${q.level}`;
|
||||
levelBadge.className = `level-pill level-badge-${q.level.toLowerCase()}`;
|
||||
}
|
||||
if (counterBadge) {
|
||||
counterBadge.textContent = `${currentQuestionIdx + 1} / ${currentQuiz.length}`;
|
||||
}
|
||||
if (progressBar) {
|
||||
const pct = ((currentQuestionIdx) / currentQuiz.length) * 100;
|
||||
progressBar.style.width = `${pct}%`;
|
||||
}
|
||||
if (nextBar) nextBar.classList.add('hidden');
|
||||
|
||||
const cardContainer = document.getElementById('cefr-cloze-card');
|
||||
if (!cardContainer) return;
|
||||
|
||||
// Stylize the sentence blank: replace ___ with a visible blank node
|
||||
const formattedSentence = escapeHtml(q.sentence).replace(/___/g, '<span class="cefr-blank" id="cefr-blank-target">_____</span>');
|
||||
|
||||
cardContainer.innerHTML = `
|
||||
<div class="cefr-cloze-header">
|
||||
<span class="cefr-mode-tag">🧩 Cloze Tap (1-тап выбор)</span>
|
||||
${q.pos ? `<span class="badge-pos">${escapeHtml(q.pos)}</span>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="cefr-sentence-wrap">
|
||||
<p class="cefr-sentence-text">${formattedSentence}</p>
|
||||
</div>
|
||||
|
||||
<div class="cefr-clue-box">
|
||||
<span class="clue-icon">💡</span>
|
||||
<span class="clue-label">Значение:</span>
|
||||
<strong class="clue-term">${escapeHtml(q.clue)}</strong>
|
||||
</div>
|
||||
|
||||
<div class="cefr-options-grid" id="cefr-options-grid">
|
||||
${q.options.map((opt, idx) => `
|
||||
<button type="button" class="cefr-opt-btn" data-option="${escapeHtml(opt)}">
|
||||
<span class="opt-num">[${idx + 1}]</span>
|
||||
<span class="opt-text">${escapeHtml(opt)}</span>
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
|
||||
<div id="cefr-feedback-box" class="cefr-feedback-box hidden"></div>
|
||||
`;
|
||||
|
||||
// Attach click listeners to option buttons
|
||||
cardContainer.querySelectorAll('.cefr-opt-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const opt = btn.dataset.option;
|
||||
handleOptionTap(q.id, opt, btn);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Handle Option Selection (1-tap Cloze Tap)
|
||||
// -----------------------------------------------------------------------
|
||||
async function handleOptionTap(questionId, selectedOption, clickedBtn) {
|
||||
if (isAnswered) return;
|
||||
isAnswered = true;
|
||||
|
||||
// Disable all options
|
||||
document.querySelectorAll('.cefr-opt-btn').forEach(b => {
|
||||
b.disabled = true;
|
||||
});
|
||||
|
||||
const feedbackBox = document.getElementById('cefr-feedback-box');
|
||||
const nextBar = document.getElementById('cefr-next-bar');
|
||||
const blankTarget = document.getElementById('cefr-blank-target');
|
||||
|
||||
try {
|
||||
const res = await api.cefr.submitAnswer(questionId, selectedOption);
|
||||
|
||||
if (res.correct) {
|
||||
sessionStats.correct++;
|
||||
clickedBtn.classList.add('opt-correct');
|
||||
if (blankTarget) {
|
||||
blankTarget.textContent = res.correct_option;
|
||||
blankTarget.classList.add('blank-correct');
|
||||
}
|
||||
|
||||
if (feedbackBox) {
|
||||
feedbackBox.className = 'cefr-feedback-box feedback-correct';
|
||||
feedbackBox.innerHTML = `
|
||||
<div class="fb-title">✓ Верно! Отличный выбор.</div>
|
||||
<div class="fb-meta">
|
||||
<strong class="fb-term">${escapeHtml(res.correct_option)}</strong>
|
||||
${res.phonetic ? `<span class="res-phonetic">${escapeHtml(res.phonetic)}</span>` : ''}
|
||||
</div>
|
||||
${res.explanation ? `<p class="fb-explanation">${escapeHtml(res.explanation)}</p>` : ''}
|
||||
`;
|
||||
feedbackBox.classList.remove('hidden');
|
||||
}
|
||||
} else {
|
||||
sessionStats.mistakes++;
|
||||
if (res.card_created || res.card_updated) {
|
||||
sessionStats.ingested++;
|
||||
}
|
||||
|
||||
clickedBtn.classList.add('opt-wrong');
|
||||
|
||||
// Highlight the true correct option
|
||||
document.querySelectorAll('.cefr-opt-btn').forEach(b => {
|
||||
if (b.dataset.option.toLowerCase() === res.correct_option.toLowerCase()) {
|
||||
b.classList.add('opt-correct-revealed');
|
||||
}
|
||||
});
|
||||
|
||||
if (blankTarget) {
|
||||
blankTarget.textContent = res.correct_option;
|
||||
blankTarget.classList.add('blank-wrong-revealed');
|
||||
}
|
||||
|
||||
if (feedbackBox) {
|
||||
feedbackBox.className = 'cefr-feedback-box feedback-wrong';
|
||||
feedbackBox.innerHTML = `
|
||||
<div class="fb-title">✗ Неверно. Правильное слово: <strong>${escapeHtml(res.correct_option)}</strong></div>
|
||||
<div class="fb-meta">
|
||||
<span class="fb-trans">${escapeHtml(res.translation || '')}</span>
|
||||
${res.phonetic ? `<span class="res-phonetic">${escapeHtml(res.phonetic)}</span>` : ''}
|
||||
</div>
|
||||
${res.explanation ? `<p class="fb-explanation">${escapeHtml(res.explanation)}</p>` : ''}
|
||||
<div class="cefr-gap-ingested-badge">
|
||||
<span class="badge-icon">📥</span>
|
||||
<div class="badge-text">
|
||||
<strong>Авто-захват в словарь:</strong> слово добавлено со статусом «В процессе» для закрепления в утреннем SRS!
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
feedbackBox.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
if (nextBar) nextBar.classList.remove('hidden');
|
||||
|
||||
} catch (err) {
|
||||
showToast(`Ошибка проверки ответа: ${err.message}`, 'error');
|
||||
if (nextBar) nextBar.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Advance to Next Question
|
||||
// -----------------------------------------------------------------------
|
||||
function advanceQuestion() {
|
||||
currentQuestionIdx++;
|
||||
renderCurrentQuestion();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Render Quiz Summary
|
||||
// -----------------------------------------------------------------------
|
||||
function renderQuizSummary() {
|
||||
showCefrScreen('summary');
|
||||
|
||||
const total = sessionStats.total || currentQuiz.length || 1;
|
||||
const correct = sessionStats.correct;
|
||||
const mistakes = sessionStats.mistakes;
|
||||
const ingested = sessionStats.ingested;
|
||||
const pct = Math.round((correct / total) * 100);
|
||||
|
||||
const sumScore = document.getElementById('cefr-sum-score');
|
||||
const sumLead = document.getElementById('cefr-sum-lead');
|
||||
const sumCorrect = document.getElementById('cefr-sum-correct');
|
||||
const sumMistakes = document.getElementById('cefr-sum-mistakes');
|
||||
const sumIngested = document.getElementById('cefr-sum-ingested');
|
||||
const sumSrsBtn = document.getElementById('cefr-sum-srs-btn');
|
||||
|
||||
if (sumScore) sumScore.textContent = `${pct}%`;
|
||||
if (sumCorrect) sumCorrect.textContent = `${correct} / ${total}`;
|
||||
if (sumMistakes) sumMistakes.textContent = mistakes;
|
||||
if (sumIngested) sumIngested.textContent = ingested;
|
||||
|
||||
if (sumLead) {
|
||||
if (pct >= 90) {
|
||||
sumLead.textContent = `Великолепно! Уровень ${currentLevel} освоен на высочайшем уровне! 🌟`;
|
||||
} else if (pct >= 70) {
|
||||
sumLead.textContent = `Хороший результат! Большинство ключевых слов уровня ${currentLevel} вам знакомы. 👍`;
|
||||
} else {
|
||||
sumLead.textContent = `Тренировка завершена. Все ошибки бережно сохранены в ваш SRS для закрепления. 💪`;
|
||||
}
|
||||
}
|
||||
|
||||
if (sumSrsBtn) {
|
||||
sumSrsBtn.classList.toggle('hidden', ingested === 0 && mistakes === 0);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user