feat: background tasks manager and book sentence mining for CEFR tests

This commit is contained in:
2026-10-06 16:36:39 +03:00
parent 332286b641
commit c9da5bd351
11 changed files with 1377 additions and 16 deletions
+36
View File
@@ -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}`);
}
}
};
+157
View File
@@ -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">