feat: background tasks manager and book sentence mining for CEFR tests
This commit is contained in:
@@ -4276,7 +4276,89 @@ body {
|
||||
}
|
||||
|
||||
.cefr-header {
|
||||
margin-bottom: 28px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cefr-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Background Task Banner in CEFR Overview */
|
||||
.cefr-task-banner {
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.12), rgba(16, 185, 129, 0.08));
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.task-banner-inner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.task-banner-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.task-banner-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.task-banner-counts {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.task-banner-msg {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Book Source Badge in Question Card & Feedback */
|
||||
.cefr-book-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border: 1px solid rgba(59, 130, 246, 0.25);
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fb-source-citation {
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border-left: 3px solid #3b82f6;
|
||||
padding: 6px 10px;
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.cefr-title-wrap h2 {
|
||||
|
||||
+56
-2
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>Notes — Персональный тренажер и словарь английского языка</title>
|
||||
<meta name="description" content="Защищённый персональный тренажёр и словарь английского языка с интервальными повторениями SRS SM-2, контекстным поиском и озвучкой">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=5.4">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=5.5">
|
||||
|
||||
<!-- PWA meta and icons -->
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
@@ -608,8 +608,16 @@
|
||||
<h2>🎯 Уровневые тесты CEFR (A1 – B2)</h2>
|
||||
<p class="subtitle">Эталонные тесты по международной шкале Oxford 3000/5000. Ошибки автоматически добавляются в ваш личный тренажёр SRS со статусом «В процессе»!</p>
|
||||
</div>
|
||||
<div class="cefr-header-actions">
|
||||
<button type="button" id="cefr-mine-books-btn" class="btn btn-secondary">
|
||||
⚡ Собрать тесты из книг
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Background Task Banner -->
|
||||
<div id="cefr-task-banner" class="cefr-task-banner hidden"></div>
|
||||
|
||||
<div id="cefr-levels-grid" class="cefr-levels-grid">
|
||||
<!-- Dynamic level cards rendered by cefr.js -->
|
||||
</div>
|
||||
@@ -1226,6 +1234,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mine CEFR Questions from Books Modal -->
|
||||
<div id="mine-books-modal" class="modal-backdrop hidden">
|
||||
<div class="modal-window">
|
||||
<div class="modal-header">
|
||||
<h3>⚡ Сборка тестов из библиотеки книг</h3>
|
||||
<button type="button" id="close-mine-books-modal-btn" class="icon-btn">✕</button>
|
||||
</div>
|
||||
<form id="mine-books-form">
|
||||
<div class="modal-body">
|
||||
<p class="modal-desc" style="margin-bottom: 1rem; color: var(--text-muted); font-size: 0.9rem;">
|
||||
Сканирует ваши книги, находит аутентичные предложения с лексикой CEFR и создает тесты с выбором вариантов. Фоновая задача работает без задержек.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label for="mine-books-select">Книга для анализа:</label>
|
||||
<select id="mine-books-select" class="input-styled">
|
||||
<option value="">📚 Все книги библиотеки</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="mine-level-select">Целевой уровень CEFR:</label>
|
||||
<select id="mine-level-select" class="input-styled">
|
||||
<option value="ALL">Все уровни (A1 – B2)</option>
|
||||
<option value="A1">A1 – Начальный</option>
|
||||
<option value="A2">A2 – Элементарный</option>
|
||||
<option value="B1">B1 – Средний</option>
|
||||
<option value="B2">B2 – Выше среднего</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="mine-count-input">Количество вопросов для сборки:</label>
|
||||
<select id="mine-count-input" class="input-styled">
|
||||
<option value="15" selected>15 вопросов</option>
|
||||
<option value="25">25 вопросов</option>
|
||||
<option value="35">35 вопросов</option>
|
||||
<option value="50">50 вопросов</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" id="cancel-mine-books-btn" class="btn btn-outline">Отмена</button>
|
||||
<button type="submit" id="submit-mine-books-btn" class="btn btn-primary">⚡ Запустить сборку</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reader Appearance Settings Popover / Bottom Sheet -->
|
||||
<div id="reader-settings-panel" class="reader-settings-panel hidden">
|
||||
<div class="sheet-drag-handle mobile-only"></div>
|
||||
@@ -1383,7 +1437,7 @@
|
||||
<!-- Audio Element for native streaming -->
|
||||
<audio id="global-audio-player" preload="none"></audio>
|
||||
|
||||
<script type="module" src="/static/js/app.js?v=5.4"></script>
|
||||
<script type="module" src="/static/js/app.js?v=5.5"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -316,5 +316,41 @@ export const api = {
|
||||
if (resp.ok) return await resp.json();
|
||||
throw new Error(`Failed to update handwritten status: ${resp.status}`);
|
||||
}
|
||||
},
|
||||
|
||||
// Background Tasks & Sentence Mining
|
||||
tasks: {
|
||||
async mineBooks(bookId = null, level = 'A1', count = 15) {
|
||||
const resp = await fetch('/api/tasks/mine-books', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
book_id: bookId,
|
||||
level: level,
|
||||
count: count
|
||||
})
|
||||
});
|
||||
if (resp.ok) return await resp.json();
|
||||
throw new Error(`Failed to start mining task: ${resp.status}`);
|
||||
},
|
||||
async getActive(taskType = null) {
|
||||
let url = '/api/tasks/active';
|
||||
if (taskType) url += `?task_type=${encodeURIComponent(taskType)}`;
|
||||
const resp = await fetch(url);
|
||||
if (resp.ok) return await resp.json();
|
||||
throw new Error(`Failed to get active task: ${resp.status}`);
|
||||
},
|
||||
async getStatus(taskId) {
|
||||
const resp = await fetch(`/api/tasks/${taskId}`);
|
||||
if (resp.ok) return await resp.json();
|
||||
throw new Error(`Failed to get task status: ${resp.status}`);
|
||||
},
|
||||
async cancel(taskId) {
|
||||
const resp = await fetch(`/api/tasks/${taskId}/cancel`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (resp.ok) return await resp.json();
|
||||
throw new Error(`Failed to cancel task: ${resp.status}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -64,6 +64,59 @@ export function initCefr() {
|
||||
});
|
||||
}
|
||||
|
||||
// Mine books modal setup
|
||||
const mineBtn = document.getElementById('cefr-mine-books-btn');
|
||||
const mineModal = document.getElementById('mine-books-modal');
|
||||
const closeMineBtn = document.getElementById('close-mine-books-modal-btn');
|
||||
const cancelMineBtn = document.getElementById('cancel-mine-books-btn');
|
||||
const mineForm = document.getElementById('mine-books-form');
|
||||
const bookSelect = document.getElementById('mine-books-select');
|
||||
|
||||
if (mineBtn && mineModal) {
|
||||
mineBtn.addEventListener('click', async () => {
|
||||
if (bookSelect) {
|
||||
try {
|
||||
const books = await api.books.getAll();
|
||||
let optionsHtml = '<option value="">📚 Все книги библиотеки</option>';
|
||||
books.forEach(b => {
|
||||
optionsHtml += `<option value="${b.id}">📖 ${escapeHtml(b.title)}${b.author ? ` (${escapeHtml(b.author)})` : ''}</option>`;
|
||||
});
|
||||
bookSelect.innerHTML = optionsHtml;
|
||||
} catch (e) {
|
||||
console.warn('Failed to load books for select:', e);
|
||||
}
|
||||
}
|
||||
mineModal.classList.remove('hidden');
|
||||
});
|
||||
|
||||
const closeMineModal = () => mineModal.classList.add('hidden');
|
||||
if (closeMineBtn) closeMineBtn.addEventListener('click', closeMineModal);
|
||||
if (cancelMineBtn) cancelMineBtn.addEventListener('click', closeMineModal);
|
||||
|
||||
if (mineForm) {
|
||||
mineForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const bookIdVal = document.getElementById('mine-books-select')?.value;
|
||||
const bookId = bookIdVal ? parseInt(bookIdVal, 10) : null;
|
||||
const level = document.getElementById('mine-level-select')?.value || 'ALL';
|
||||
const count = parseInt(document.getElementById('mine-count-input')?.value || '15', 10);
|
||||
|
||||
closeMineModal();
|
||||
try {
|
||||
const res = await api.tasks.mineBooks(bookId, level, count);
|
||||
showToast(res.message || 'Сборка тестов запущена в фоне', 'info');
|
||||
if (res.task_id) {
|
||||
startTaskPolling(res.task_id);
|
||||
} else {
|
||||
checkActiveMiningTask();
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`Ошибка запуска: ${err.message}`, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard navigation (1-4 for options, Space/Enter for next)
|
||||
window.addEventListener('keydown', (e) => {
|
||||
const cefrView = document.getElementById('cefr-view');
|
||||
@@ -90,6 +143,106 @@ export function initCefr() {
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Background Tasks Polling & Banner Controls
|
||||
// -----------------------------------------------------------------------
|
||||
let activeTaskPollTimer = null;
|
||||
|
||||
export async function checkActiveMiningTask() {
|
||||
try {
|
||||
const res = await api.tasks.getActive('mine_books');
|
||||
const task = res.task;
|
||||
if (task && ['pending', 'running'].includes(task.status)) {
|
||||
renderTaskBanner(task);
|
||||
startTaskPolling(task.id);
|
||||
} else {
|
||||
hideTaskBanner();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to check active task:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function startTaskPolling(taskId) {
|
||||
if (activeTaskPollTimer) clearInterval(activeTaskPollTimer);
|
||||
activeTaskPollTimer = setInterval(async () => {
|
||||
try {
|
||||
const res = await api.tasks.getStatus(taskId);
|
||||
const task = res.task;
|
||||
if (!task) {
|
||||
clearInterval(activeTaskPollTimer);
|
||||
activeTaskPollTimer = null;
|
||||
hideTaskBanner();
|
||||
return;
|
||||
}
|
||||
|
||||
if (task.status === 'completed') {
|
||||
clearInterval(activeTaskPollTimer);
|
||||
activeTaskPollTimer = null;
|
||||
hideTaskBanner();
|
||||
showToast(task.message || 'Сборка тестов завершена!', 'success');
|
||||
loadCefrOverview();
|
||||
} else if (task.status === 'cancelled') {
|
||||
clearInterval(activeTaskPollTimer);
|
||||
activeTaskPollTimer = null;
|
||||
hideTaskBanner();
|
||||
showToast('Сборка тестов отменена', 'info');
|
||||
} else if (task.status === 'failed') {
|
||||
clearInterval(activeTaskPollTimer);
|
||||
activeTaskPollTimer = null;
|
||||
hideTaskBanner();
|
||||
showToast(`Ошибка сборки: ${task.error_message || task.message}`, 'error');
|
||||
} else {
|
||||
renderTaskBanner(task);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Poll error:', err);
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function renderTaskBanner(task) {
|
||||
const banner = document.getElementById('cefr-task-banner');
|
||||
if (!banner) return;
|
||||
const pct = task.progress_percent || 0;
|
||||
banner.innerHTML = `
|
||||
<div class="task-banner-inner">
|
||||
<div class="task-banner-content">
|
||||
<div class="task-banner-title">
|
||||
<span class="spinner-sm"></span>
|
||||
<strong>⚡ Фоновая сборка тестов из книг</strong>
|
||||
<span class="task-banner-counts">${task.progress_current} / ${task.progress_total} (${pct}%)</span>
|
||||
</div>
|
||||
<div class="task-banner-msg">${escapeHtml(task.message || 'Анализ предложений...')}</div>
|
||||
<div class="progress-bar-wrap" style="height: 6px; margin-top: 6px;">
|
||||
<div class="progress-bar-fill level-badge-b1" style="width: ${pct}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-banner-action">
|
||||
<button type="button" class="btn btn-xs btn-outline task-cancel-btn" data-task-id="${task.id}">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
banner.classList.remove('hidden');
|
||||
|
||||
banner.querySelector('.task-cancel-btn')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await api.tasks.cancel(task.id);
|
||||
showToast('Запрос на отмену отправлен...', 'info');
|
||||
} catch (e) {
|
||||
showToast('Не удалось отменить задачу', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function hideTaskBanner() {
|
||||
const banner = document.getElementById('cefr-task-banner');
|
||||
if (banner) {
|
||||
banner.classList.add('hidden');
|
||||
banner.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Screen Switcher inside #cefr-view
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -108,6 +261,7 @@ function showCefrScreen(screenName) {
|
||||
// -----------------------------------------------------------------------
|
||||
export async function loadCefrOverview() {
|
||||
showCefrScreen('overview');
|
||||
checkActiveMiningTask();
|
||||
const grid = document.getElementById('cefr-levels-grid');
|
||||
if (!grid) return;
|
||||
|
||||
@@ -312,6 +466,7 @@ function renderCurrentQuestion() {
|
||||
<div class="cefr-cloze-header">
|
||||
<span class="cefr-mode-tag">🧩 Cloze Tap (1-тап выбор)</span>
|
||||
${q.pos ? `<span class="badge-pos">${escapeHtml(q.pos)}</span>` : ''}
|
||||
${q.source_book_title ? `<span class="cefr-book-badge" title="${escapeHtml(q.source_chapter_title || '')}">📖 «${escapeHtml(q.source_book_title)}»${q.source_chapter_title ? ` (${escapeHtml(q.source_chapter_title)})` : ''}</span>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="cefr-sentence-wrap">
|
||||
@@ -383,6 +538,7 @@ async function handleOptionTap(questionId, selectedOption, clickedBtn) {
|
||||
<strong class="fb-term">${escapeHtml(res.correct_option)}</strong>
|
||||
${res.phonetic ? `<span class="res-phonetic">${escapeHtml(res.phonetic)}</span>` : ''}
|
||||
</div>
|
||||
${res.source_book_title ? `<div class="fb-source-citation">📖 Пример из книги «${escapeHtml(res.source_book_title)}»${res.source_chapter_title ? ` (${escapeHtml(res.source_chapter_title)})` : ''}</div>` : ''}
|
||||
${res.explanation ? `<p class="fb-explanation">${escapeHtml(res.explanation)}</p>` : ''}
|
||||
${renderOptionsBreakdown(res.options_breakdown)}
|
||||
`;
|
||||
@@ -416,6 +572,7 @@ async function handleOptionTap(questionId, selectedOption, clickedBtn) {
|
||||
<span class="fb-trans">${escapeHtml(res.translation || '')}</span>
|
||||
${res.phonetic ? `<span class="res-phonetic">${escapeHtml(res.phonetic)}</span>` : ''}
|
||||
</div>
|
||||
${res.source_book_title ? `<div class="fb-source-citation">📖 Пример из книги «${escapeHtml(res.source_book_title)}»${res.source_chapter_title ? ` (${escapeHtml(res.source_chapter_title)})` : ''}</div>` : ''}
|
||||
${res.explanation ? `<p class="fb-explanation">${escapeHtml(res.explanation)}</p>` : ''}
|
||||
<div class="cefr-dual-ingested-box">
|
||||
<div class="dual-ingested-header">
|
||||
|
||||
+1
-1
@@ -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-v5.4';
|
||||
const CACHE_NAME = 'notes-v5.5';
|
||||
const OFFLINE_URL = '/';
|
||||
|
||||
const PRECACHE_URLS = [
|
||||
|
||||
Reference in New Issue
Block a user