feat(cefr): CEFR level testing and automated gap ingestion engine (Task 15)

This commit is contained in:
2026-10-06 15:24:33 +03:00
parent 1c3f03a64c
commit 3fae321322
11 changed files with 2926 additions and 10 deletions
+17 -8
View File
@@ -307,14 +307,23 @@
---
### 15. 🎯 Уровневые тесты CEFR (A1, A2, B1, B2) с авто-добавлением ошибок в активный словарь
- [ ] Эталонные списки лексики по международной шкале CEFR (на базе Oxford 3000/5000: A1 ~800 слов, A2 ~1200 слов, B1 ~1500 слов, B2 ~2000 слов).
- [ ] Быстрый и удобный для мобильных формат: **1-тап выбор ключевого узла (Cloze Tap)** без перетаскивания и долгой печати.
- [ ] **Автоматический захват пробелов (Gap Ingestion)**:
- Если ответ верный $\rightarrow$ слово помечается как освоенное.
- Если пользователь **ошибся или засомневался** $\rightarrow$ слово/оборот **автоматически создаётся как карточка со статусом `learning`** в личном словаре SRS с тегом уровня (`#level-a2`, `#level-b1`).
- [ ] Проблемные слова начинают чаще мелькать в ежедневных утренних повторениях SRS, пока не закрепятся в долговременной памяти.
- [ ] Наглядный прогресс-трекер: процент закрытия каждого уровня (*«Уровень A2: 95% закрыто, 12 слов в тренировке»*).
### 15. 🎯 Уровневые тесты CEFR (A1, A2, B1, B2) с авто-добавлением ошибок в активный словарь (Completed)
- [x] **Эталонная база вопросов Oxford 3000/5000** (160 отобранных тестовых единиц: по 40 на каждый уровень A1, A2, B1, B2) в модуле [`app/cefr.py`](file:///home/ai-projects/services/study.qpid.pro/app/cefr.py).
- [x] **Быстрый и удобный мобильный формат: 1-тап выбор ключевого узла (Cloze Tap)**:
- Естественные контекстные предложения с пропуском `___`.
- Подсказка значения слова на русском языке и часть речи.
- 4 сбалансированных варианта ответа одинаковой части речи и уровня сложности (исключены грамматические подсказки).
- Мгновенная тактильная реакция: зеленая/красная подсветка, автоподстановка правильного слова в пропуск, раскрытие транскрипции IPA и пояснения.
- [x] **Автоматический захват пробелов (Automated Gap Ingestion)**:
- При правильном ответе $\rightarrow$ вопрос отмечается как освоенный в `cefr_progress`.
- При ошибке $\rightarrow$ слово/оборот **автоматически создаётся (или обновляется) как карточка в личном словаре со статусом `learning`** и немедленным сроком повторения (`srs_next_review = now`), коллекцией `Уровни CEFR ({level})` и тегами `#level-{level}`, `#cefr-test`, `#test-mistake`.
- [x] Проблемные слова сразу попадают в утренний пул интервальных повторений SRS тренажера.
- [x] **Наглядный прогресс-трекер**:
- Экран обзора со шкалами освоения каждого уровня (A1 Beginner, A2 Elementary, B1 Intermediate, B2 Upper-Intermediate).
- Счётчики: процент закрытия уровня, освоено, ошибок, слов в тренировке SRS.
- Режим «Работа над ошибками» (быстрый прогон только ошибочных вопросов).
- Возможность сброса прогресса уровня.
- [x] Добавлен регрессионный тест `test_cefr_testing_and_gap_ingestion` в [`tests/test_api.py`](file:///home/ai-projects/services/study.qpid.pro/tests/test_api.py) (28/28 тестов успешно, 100% green).
---
+1510
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -202,6 +202,43 @@ def _migrate(conn: sqlite3.Connection):
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_sentence_trans_hash ON sentence_translations(sentence_hash);")
# Ensure cefr_questions and cefr_progress tables exist
conn.execute("""
CREATE TABLE IF NOT EXISTS cefr_questions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
level TEXT NOT NULL,
term TEXT NOT NULL COLLATE NOCASE,
translation TEXT NOT NULL,
pos TEXT DEFAULT '',
phonetic TEXT DEFAULT '',
sentence TEXT NOT NULL,
options_json TEXT NOT NULL,
explanation TEXT DEFAULT '',
frequency_rank INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(level, term COLLATE NOCASE)
);
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_cefr_q_level ON cefr_questions(level);")
conn.execute("CREATE INDEX IF NOT EXISTS idx_cefr_q_term ON cefr_questions(term COLLATE NOCASE);")
conn.execute("""
CREATE TABLE IF NOT EXISTS cefr_progress (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
question_id INTEGER NOT NULL,
level TEXT NOT NULL,
is_mastered INTEGER DEFAULT 0,
attempts INTEGER DEFAULT 0,
mistakes INTEGER DEFAULT 0,
last_tested_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (question_id) REFERENCES cefr_questions(id) ON DELETE CASCADE,
UNIQUE(user_id, question_id)
);
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_cefr_prog_user_lvl ON cefr_progress(user_id, level);")
# Auto-seed offline dictionary if empty
_seed_offline_dictionary(conn)
@@ -212,6 +249,13 @@ def _migrate(conn: sqlite3.Connection):
except Exception:
pass
# Seed curated CEFR questions
try:
from app.cefr import seed_cefr_questions
seed_cefr_questions(conn)
except Exception:
pass
def _seed_offline_dictionary(conn: sqlite3.Connection):
"""Seeds the offline dictionary from data/offline_dict.tsv.gz if the table is empty."""
+87
View File
@@ -31,6 +31,12 @@ from app.srs import calculate_sm2, generate_quiz_options, build_activity_heatmap
from app.reader import parse_epub, parse_text, parse_pdf, count_words
from app.ai import translate_sentence_ai, get_ollama_status
from app.phrasal import detect_phrasal_verb
from app.cefr import (
get_cefr_level_summaries,
get_cefr_quiz_batch,
process_cefr_answer,
reset_user_cefr_level,
)
@@ -162,6 +168,16 @@ class SentenceTranslateRequest(BaseModel):
target_word: Optional[str] = ""
class CefrAnswerRequest(BaseModel):
question_id: int
selected_option: str
class CefrResetRequest(BaseModel):
level: str
# ---------------------------------------------------------------------------
# PWA Static Endpoints
@@ -543,6 +559,18 @@ async def api_create_card(card: CardCreateRequest, user: dict = Depends(require_
@app.get("/api/cards/{card_id}")
async def api_get_card(card_id: int, user: dict = Depends(require_user)):
with get_db() as conn:
row = conn.execute(
"SELECT * FROM vocabulary_cards WHERE id = ? AND user_id = ?",
(card_id, user["id"]),
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Card not found")
return dict(row)
@app.put("/api/cards/{card_id}")
async def api_update_card(card_id: int, update: CardUpdateRequest, user: dict = Depends(require_user)):
with get_db() as conn:
@@ -1352,3 +1380,62 @@ async def api_delete_book(book_id: int, user: dict = Depends(require_user)):
if cursor.rowcount == 0:
raise HTTPException(status_code=404, detail="Book not found")
return {"status": "ok", "deleted_id": book_id}
# ---------------------------------------------------------------------------
# CEFR Level Testing & Automated Gap Ingestion Endpoints (Task 15)
# ---------------------------------------------------------------------------
@app.get("/api/cefr/levels")
async def api_cefr_levels(user: dict = Depends(require_user)):
"""
Returns progress summaries for all CEFR levels (A1, A2, B1, B2).
"""
with get_db() as conn:
summaries = get_cefr_level_summaries(conn, user["id"])
return {"levels": summaries}
@app.get("/api/cefr/quiz")
async def api_cefr_quiz(
level: str = Query("A1"),
count: int = Query(10, ge=1, le=30),
mode: str = Query("all"),
user: dict = Depends(require_user),
):
"""
Returns next batch of Cloze Tap test questions for level (A1..B2).
"""
with get_db() as conn:
quiz_items = get_cefr_quiz_batch(conn, user["id"], level, count, mode)
return {"level": level.upper(), "count": len(quiz_items), "quiz": quiz_items}
@app.post("/api/cefr/answer")
async def api_cefr_answer(
req: CefrAnswerRequest,
user: dict = Depends(require_user),
):
"""
Submits an answer for a CEFR question.
If wrong, automatically ingests into user's SRS active vocabulary with status 'learning'.
"""
with get_db() as conn:
result = process_cefr_answer(conn, user["id"], req.question_id, req.selected_option)
if "error" in result:
raise HTTPException(status_code=404, detail=result["error"])
return result
@app.post("/api/cefr/reset")
async def api_cefr_reset(
req: CefrResetRequest,
user: dict = Depends(require_user),
):
"""
Resets user testing progress for a given CEFR level.
"""
with get_db() as conn:
deleted = reset_user_cefr_level(conn, user["id"], req.level)
return {"status": "ok", "level": req.level.upper(), "reset_count": deleted}
+504
View File
@@ -4215,3 +4215,507 @@ body {
background: #0c0c0c;
border-color: #222;
}
/* ==========================================================================
VIEW 4: CEFR Level Testing & Automated Gap Ingestion Styles (Task 15)
========================================================================== */
.cefr-view {
max-width: 960px;
margin: 0 auto;
padding: 24px 16px 80px 16px;
width: 100%;
}
.cefr-header {
margin-bottom: 28px;
}
.cefr-title-wrap h2 {
font-size: 26px;
font-weight: 700;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 10px;
}
.cefr-title-wrap .subtitle {
font-size: 15px;
color: var(--text-muted);
line-height: 1.5;
}
/* Level Selection Grid */
.cefr-levels-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.cefr-level-card {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 24px;
display: flex;
flex-direction: column;
gap: 18px;
box-shadow: var(--shadow-sm);
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
.cefr-level-card:hover {
border-color: var(--primary);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.level-card-header {
display: flex;
align-items: center;
gap: 14px;
}
.level-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 14px;
border-radius: 9999px;
font-weight: 800;
font-size: 15px;
letter-spacing: 0.5px;
}
.level-badge-a1 {
background: rgba(16, 185, 129, 0.15);
color: #10b981;
border: 1px solid rgba(16, 185, 129, 0.35);
}
.level-badge-a2 {
background: rgba(56, 189, 248, 0.15);
color: #38bdf8;
border: 1px solid rgba(56, 189, 248, 0.35);
}
.level-badge-b1 {
background: rgba(129, 140, 248, 0.15);
color: #818cf8;
border: 1px solid rgba(129, 140, 248, 0.35);
}
.level-badge-b2 {
background: rgba(245, 158, 11, 0.15);
color: #fbbf24;
border: 1px solid rgba(245, 158, 11, 0.35);
}
.level-header-text {
flex: 1;
}
.level-title {
font-size: 17px;
font-weight: 600;
margin: 0 0 2px 0;
}
.level-meta-sub {
font-size: 13px;
color: var(--text-muted);
}
/* Progress bar inside card */
.level-card-progress {
display: flex;
flex-direction: column;
gap: 6px;
}
.progress-bar-wrap {
height: 8px;
background-color: var(--bg-hover);
border-radius: 9999px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
border-radius: 9999px;
transition: width 0.4s ease;
}
.progress-bar-fill.level-badge-a1 { background-color: #10b981; }
.progress-bar-fill.level-badge-a2 { background-color: #38bdf8; }
.progress-bar-fill.level-badge-b1 { background-color: #818cf8; }
.progress-bar-fill.level-badge-b2 { background-color: #f59e0b; }
.progress-labels-row {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--text-muted);
}
.progress-labels-row .pct-text {
font-weight: 600;
color: var(--text-main);
}
/* Mini metrics */
.level-card-metrics {
display: flex;
justify-content: space-between;
background-color: var(--bg-hover);
border-radius: var(--radius-md);
padding: 10px 12px;
}
.mini-metric {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.mini-metric .m-val {
font-size: 14px;
font-weight: 700;
}
.mini-metric .m-lbl {
font-size: 11px;
color: var(--text-muted);
}
.level-card-actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: auto;
}
/* Quiz Topbar */
.cefr-quiz-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
padding: 12px 18px;
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
}
.cefr-quiz-topbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.cefr-quiz-topbar-center {
display: flex;
align-items: center;
gap: 14px;
flex: 1;
max-width: 380px;
}
/* Cloze Card */
.cefr-quiz-card-wrapper {
display: flex;
flex-direction: column;
gap: 20px;
}
.cefr-cloze-card {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 32px 28px;
box-shadow: var(--shadow-md);
display: flex;
flex-direction: column;
gap: 20px;
animation: fadeIn 0.25s ease-out;
}
.cefr-cloze-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.cefr-mode-tag {
font-size: 13px;
font-weight: 600;
color: var(--primary);
background: rgba(56, 189, 248, 0.1);
padding: 4px 10px;
border-radius: var(--radius-sm);
}
/* Sentence & Blank */
.cefr-sentence-wrap {
padding: 18px 0;
}
.cefr-sentence-text {
font-size: 22px;
font-weight: 500;
line-height: 1.6;
color: var(--text-main);
margin: 0;
}
.cefr-blank {
display: inline-block;
min-width: 90px;
text-align: center;
background: rgba(56, 189, 248, 0.12);
border-bottom: 2px dashed var(--primary);
padding: 2px 12px;
border-radius: 4px;
font-weight: 700;
color: var(--primary);
transition: all 0.25s ease;
}
.cefr-blank.blank-correct {
background: rgba(16, 185, 129, 0.2);
border-bottom: 2px solid #10b981;
color: #10b981;
}
.cefr-blank.blank-wrong-revealed {
background: rgba(239, 68, 68, 0.2);
border-bottom: 2px solid #ef4444;
color: #f87171;
}
/* Clue Box */
.cefr-clue-box {
display: flex;
align-items: center;
gap: 8px;
background-color: var(--bg-hover);
padding: 10px 14px;
border-radius: var(--radius-md);
font-size: 14px;
color: var(--text-muted);
}
.cefr-clue-box .clue-term {
color: var(--text-main);
font-size: 15px;
}
/* Options Grid: 2x2 */
.cefr-options-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-top: 6px;
}
.cefr-opt-btn {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
background-color: var(--bg-surface);
border: 1.5px solid var(--border-color);
border-radius: var(--radius-md);
padding: 16px 20px;
min-height: 56px;
color: var(--text-main);
font-size: 17px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
outline: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.cefr-opt-btn:hover:not(:disabled) {
border-color: var(--primary);
background-color: var(--bg-hover);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(56, 189, 248, 0.15);
}
.cefr-opt-btn:active:not(:disabled) {
transform: scale(0.98);
}
.cefr-opt-btn .opt-num {
font-size: 13px;
color: var(--text-muted);
font-weight: 500;
}
.cefr-opt-btn .opt-text {
flex: 1;
text-align: left;
}
/* Option States */
.cefr-opt-btn.opt-correct {
background-color: #10b981 !important;
border-color: #10b981 !important;
color: #fff !important;
box-shadow: 0 0 16px rgba(16, 185, 129, 0.4);
}
.cefr-opt-btn.opt-correct .opt-num {
color: rgba(255, 255, 255, 0.8) !important;
}
.cefr-opt-btn.opt-wrong {
background-color: #ef4444 !important;
border-color: #ef4444 !important;
color: #fff !important;
box-shadow: 0 0 16px rgba(239, 68, 68, 0.4);
}
.cefr-opt-btn.opt-wrong .opt-num {
color: rgba(255, 255, 255, 0.8) !important;
}
.cefr-opt-btn.opt-correct-revealed {
border-color: #10b981 !important;
background-color: rgba(16, 185, 129, 0.15) !important;
color: #10b981 !important;
animation: pulseBorder 1.5s infinite ease-in-out;
}
@keyframes pulseBorder {
0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.5); }
50% { box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); }
}
/* Feedback Box */
.cefr-feedback-box {
border-radius: var(--radius-md);
padding: 18px 20px;
display: flex;
flex-direction: column;
gap: 8px;
animation: fadeIn 0.25s ease-out;
}
.cefr-feedback-box.feedback-correct {
background: rgba(16, 185, 129, 0.12);
border: 1px solid rgba(16, 185, 129, 0.35);
}
.cefr-feedback-box.feedback-wrong {
background: rgba(239, 68, 68, 0.12);
border: 1px solid rgba(239, 68, 68, 0.35);
}
.cefr-feedback-box .fb-title {
font-size: 16px;
font-weight: 700;
}
.cefr-feedback-box.feedback-correct .fb-title {
color: #10b981;
}
.cefr-feedback-box.feedback-wrong .fb-title {
color: #ef4444;
}
.cefr-feedback-box .fb-meta {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
}
.cefr-feedback-box .fb-explanation {
margin: 4px 0 0 0;
font-size: 14px;
line-height: 1.5;
color: var(--text-main);
}
/* Gap Ingestion Badge inside mistake feedback */
.cefr-gap-ingested-badge {
display: flex;
align-items: center;
gap: 10px;
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: var(--radius-md);
padding: 10px 14px;
margin-top: 6px;
font-size: 13px;
line-height: 1.4;
color: var(--text-main);
}
.cefr-gap-ingested-badge .badge-icon {
font-size: 18px;
}
/* Next Button Bar */
.cefr-next-bar {
margin-top: 10px;
animation: fadeIn 0.2s ease-out;
}
/* Summary Screen */
.cefr-summary-container {
max-width: 680px;
margin: 40px auto;
}
.cefr-summary-card {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 44px 32px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
box-shadow: var(--shadow-lg);
animation: fadeIn 0.3s ease-out;
}
.cefr-loading-spinner {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px;
color: var(--text-muted);
}
/* Mobile Responsive */
@media (max-width: 640px) {
.cefr-sentence-text {
font-size: 18px;
}
.cefr-options-grid {
grid-template-columns: 1fr;
}
.cefr-opt-btn {
min-height: 52px;
padding: 14px 16px;
}
.cefr-cloze-card {
padding: 20px 16px;
}
}
+85 -1
View File
@@ -70,6 +70,9 @@
<button id="nav-reader-btn" class="btn btn-outline btn-sm" title="Интерактивная читалка и библиотека книг">
<span>📚 Читалка</span>
</button>
<button id="nav-cefr-btn" class="btn btn-outline btn-sm" title="Уровневые тесты CEFR (A1, A2, B1, B2)">
<span>🎯 Тесты CEFR</span>
</button>
<button id="srs-trainer-btn" class="btn btn-trainer" title="Интервальный тренажер (Flashcards, 4 варианта, диктант, ввод)">
<span class="trainer-icon">🎓</span>
<span class="trainer-text">Тренажёр SRS</span>
@@ -250,6 +253,9 @@
</div>
<div class="srs-topbar-right">
<button id="srs-jump-cefr-btn" class="btn btn-sm btn-outline" title="Пройти уровневый тест CEFR (A1–B2)">
<span>🎯 Тесты CEFR</span>
</button>
<button id="srs-mix-btn" class="btn btn-sm btn-ghost" title="Умный микс дня: 10 новых + 10 повторов + 5 трудных слов">
<span id="srs-mix-label">🎲 Микс дня: ВКЛ</span>
</button>
@@ -589,6 +595,83 @@
</div>
</div>
</section>
<!-- VIEW 4: CEFR Level Tests View -->
<section id="cefr-view" class="cefr-view hidden">
<!-- Screen 1: Level Overview & Selection -->
<div id="cefr-overview-screen" class="cefr-overview-container">
<div class="cefr-header">
<div class="cefr-title-wrap">
<h2>🎯 Уровневые тесты CEFR (A1 – B2)</h2>
<p class="subtitle">Эталонные тесты по международной шкале Oxford 3000/5000. Ошибки автоматически добавляются в ваш личный тренажёр SRS со статусом «В процессе»!</p>
</div>
</div>
<div id="cefr-levels-grid" class="cefr-levels-grid">
<!-- Dynamic level cards rendered by cefr.js -->
</div>
</div>
<!-- Screen 2: Active Cloze Tap Quiz -->
<div id="cefr-quiz-screen" class="cefr-quiz-container hidden">
<header class="cefr-quiz-topbar">
<div class="cefr-quiz-topbar-left">
<button id="cefr-back-btn" class="btn btn-sm btn-outline">← К уровням</button>
<span id="cefr-quiz-level-badge" class="level-pill">Уровень A1</span>
</div>
<div class="cefr-quiz-topbar-center">
<div class="srs-progress-container">
<div id="cefr-quiz-progress-bar" class="srs-progress-fill" style="width: 0%;"></div>
</div>
<span id="cefr-quiz-counter" class="srs-counter">1 / 10</span>
</div>
</header>
<div class="cefr-quiz-card-wrapper">
<div id="cefr-cloze-card" class="cefr-cloze-card">
<!-- Dynamic question content rendered by cefr.js -->
</div>
<div id="cefr-next-bar" class="cefr-next-bar hidden">
<button id="cefr-next-btn" class="btn btn-primary btn-lg btn-block">Следующий вопрос → (Space)</button>
</div>
</div>
</div>
<!-- Screen 3: Quiz Session Summary -->
<div id="cefr-summary-screen" class="cefr-summary-container hidden">
<div class="cefr-summary-card">
<div class="summary-trophy">🏆</div>
<h2>Тест завершён!</h2>
<p id="cefr-sum-lead" class="summary-lead">Отличная работа по проверке словарного запаса.</p>
<div class="summary-metrics">
<div class="metric-card">
<span class="metric-val text-primary" id="cefr-sum-score">0%</span>
<span class="metric-desc">Точность</span>
</div>
<div class="metric-card">
<span class="metric-val text-success" id="cefr-sum-correct">0 / 0</span>
<span class="metric-desc">Правильно</span>
</div>
<div class="metric-card">
<span class="metric-val text-warning" id="cefr-sum-mistakes">0</span>
<span class="metric-desc">Ошибок</span>
</div>
<div class="metric-card">
<span class="metric-val text-danger" id="cefr-sum-ingested">0</span>
<span class="metric-desc">Добавлено в SRS</span>
</div>
</div>
<div class="summary-buttons">
<button id="cefr-sum-repeat-btn" class="btn btn-primary btn-lg">🔄 Ещё 10 вопросов</button>
<button id="cefr-sum-srs-btn" class="btn btn-outline btn-lg hidden">🎓 Закрепить ошибки в SRS</button>
<button id="cefr-sum-levels-btn" class="btn btn-ghost btn-lg">← К списку уровней</button>
</div>
</div>
</div>
</section>
<!-- Sticky Bulk Action Bar for Batch Selection Mode -->
<div id="batch-action-bar" class="batch-action-bar hidden">
@@ -648,6 +731,7 @@
</div>
</div>
<div class="mobile-menu-actions">
<button id="mobile-cefr-btn" class="btn btn-outline btn-block">🎯 Уровневые тесты CEFR (A1–B2)</button>
<button id="mobile-stats-btn" class="btn btn-outline btn-block">📊 Статистика & Календарь</button>
<button id="mobile-export-btn" class="btn btn-outline btn-block">📤 Экспорт словаря</button>
<button id="mobile-pwd-btn" class="btn btn-outline btn-block">🔑 Сменить пароль</button>
@@ -1114,7 +1198,7 @@
<!-- Audio Element for native streaming -->
<audio id="global-audio-player" preload="none"></audio>
<script type="module" src="/static/js/app.js?v=5.0"></script>
<script type="module" src="/static/js/app.js?v=5.1"></script>
</body>
</html>
+75
View File
@@ -8,6 +8,7 @@ import { initTheme, openModal, closeModal, showToast } from './modules/ui.js';
import { initDictionary, loadInitialData } from './modules/dictionary.js';
import { initSrs, startSrsTrainingSession, openStatsDashboard } from './modules/srs.js';
import { initReader, loadBookshelf, openBook } from './modules/reader.js';
import { initCefr, loadCefrOverview, startCefrQuiz } from './modules/cefr.js';
let currentUser = null;
let isRouting = false;
@@ -19,6 +20,7 @@ export function updateMobileActiveTab(tabName) {
document.querySelectorAll('.mobile-tab-bar .tab-item').forEach(btn => {
btn.classList.remove('active');
});
if (!tabName) return;
const target = document.getElementById(`tab-nav-${tabName}`);
if (target) target.classList.add('active');
}
@@ -36,6 +38,8 @@ export function applyInitialViewPreflight() {
hash = lastBookId ? `#reader/book/${lastBookId}` : '#reader';
} else if (lastView === 'srs-view') {
hash = '#srs';
} else if (lastView === 'cefr-view') {
hash = '#cefr';
}
} catch (e) {}
}
@@ -43,6 +47,7 @@ export function applyInitialViewPreflight() {
const dictionaryView = document.getElementById('dictionary-view');
const srsView = document.getElementById('srs-view');
const readerView = document.getElementById('reader-view');
const cefrView = document.getElementById('cefr-view');
const bookshelfContainer = document.getElementById('bookshelf-container');
const readerInterface = document.getElementById('reader-interface');
const readerBookTitle = document.getElementById('reader-book-title');
@@ -50,6 +55,7 @@ export function applyInitialViewPreflight() {
if (hash.startsWith('#reader') || hash === '#bookshelf') {
if (dictionaryView) dictionaryView.classList.add('hidden');
if (srsView) srsView.classList.add('hidden');
if (cefrView) cefrView.classList.add('hidden');
if (readerView) readerView.classList.remove('hidden');
updateMobileActiveTab('reader');
if (hash.includes('/book/')) {
@@ -60,8 +66,15 @@ export function applyInitialViewPreflight() {
} else if (hash.startsWith('#srs')) {
if (dictionaryView) dictionaryView.classList.add('hidden');
if (readerView) readerView.classList.add('hidden');
if (cefrView) cefrView.classList.add('hidden');
if (srsView) srsView.classList.remove('hidden');
updateMobileActiveTab('srs');
} else if (hash.startsWith('#cefr')) {
if (dictionaryView) dictionaryView.classList.add('hidden');
if (readerView) readerView.classList.add('hidden');
if (srsView) srsView.classList.add('hidden');
if (cefrView) cefrView.classList.remove('hidden');
updateMobileActiveTab('');
}
}
@@ -76,9 +89,11 @@ export function openDictionaryView(updateHash = true) {
const dictionaryView = document.getElementById('dictionary-view');
const srsView = document.getElementById('srs-view');
const readerView = document.getElementById('reader-view');
const cefrView = document.getElementById('cefr-view');
if (srsView) srsView.classList.add('hidden');
if (readerView) readerView.classList.add('hidden');
if (cefrView) cefrView.classList.add('hidden');
if (dictionaryView) dictionaryView.classList.remove('hidden');
updateMobileActiveTab('dict');
try { localStorage.setItem('notes_active_view', 'dictionary-view'); } catch (e) {}
@@ -94,9 +109,11 @@ export function openSrsView(tagFilter = null, updateHash = true) {
const dictionaryView = document.getElementById('dictionary-view');
const srsView = document.getElementById('srs-view');
const readerView = document.getElementById('reader-view');
const cefrView = document.getElementById('cefr-view');
if (dictionaryView) dictionaryView.classList.add('hidden');
if (readerView) readerView.classList.add('hidden');
if (cefrView) cefrView.classList.add('hidden');
if (srsView) srsView.classList.remove('hidden');
updateMobileActiveTab('srs');
try { localStorage.setItem('notes_active_view', 'srs-view'); } catch (e) {}
@@ -115,11 +132,13 @@ export function openBookshelfView(updateHash = true) {
const dictionaryView = document.getElementById('dictionary-view');
const srsView = document.getElementById('srs-view');
const readerView = document.getElementById('reader-view');
const cefrView = document.getElementById('cefr-view');
const bookshelfContainer = document.getElementById('bookshelf-container');
const readerInterface = document.getElementById('reader-interface');
if (dictionaryView) dictionaryView.classList.add('hidden');
if (srsView) srsView.classList.add('hidden');
if (cefrView) cefrView.classList.add('hidden');
if (readerView) readerView.classList.remove('hidden');
if (bookshelfContainer) bookshelfContainer.classList.remove('hidden');
if (readerInterface) readerInterface.classList.add('hidden');
@@ -140,6 +159,36 @@ export function openReaderView(updateHash = true) {
}
}
export function openCefrView(level = null, mode = 'all', updateHash = true) {
const targetHash = level
? (mode === 'mistakes' ? `#cefr/${encodeURIComponent(level)}/mistakes` : `#cefr/${encodeURIComponent(level)}`)
: '#cefr';
if (updateHash && window.location.hash !== targetHash) {
window.location.hash = targetHash;
return;
}
const dictionaryView = document.getElementById('dictionary-view');
const srsView = document.getElementById('srs-view');
const readerView = document.getElementById('reader-view');
const cefrView = document.getElementById('cefr-view');
if (dictionaryView) dictionaryView.classList.add('hidden');
if (srsView) srsView.classList.add('hidden');
if (readerView) readerView.classList.add('hidden');
if (cefrView) cefrView.classList.remove('hidden');
updateMobileActiveTab('');
try { localStorage.setItem('notes_active_view', 'cefr-view'); } catch (e) {}
if (level) {
startCefrQuiz(level, mode);
} else {
loadCefrOverview();
}
}
// -----------------------------------------------------------------------
// Route Handler
// -----------------------------------------------------------------------
@@ -160,6 +209,10 @@ export async function handleRouting() {
hash = '#srs';
window.location.hash = hash;
return;
} else if (lastView === 'cefr-view') {
hash = '#cefr';
window.location.hash = hash;
return;
} else {
hash = '#dictionary';
}
@@ -176,6 +229,11 @@ export async function handleRouting() {
const parts = cleanHash.split('/');
const tag = parts.length > 1 ? decodeURIComponent(parts.slice(1).join('/')) : null;
openSrsView(tag, false);
} else if (cleanHash.startsWith('cefr')) {
const parts = cleanHash.split('/');
const level = parts[1] || null;
const mode = parts[2] === 'mistakes' ? 'mistakes' : 'all';
openCefrView(level, mode, false);
} else if (cleanHash.startsWith('reader') || cleanHash === 'bookshelf') {
const parts = cleanHash.split('/');
if (parts[1] === 'book' && parts[2]) {
@@ -297,6 +355,14 @@ function initMobileNavigation() {
});
}
const mobileCefrBtn = document.getElementById('mobile-cefr-btn');
if (mobileCefrBtn) {
mobileCefrBtn.addEventListener('click', () => {
closeMobileMenu();
openCefrView(null, 'all', true);
});
}
if (mobileStatsBtn) {
mobileStatsBtn.addEventListener('click', () => {
closeMobileMenu();
@@ -432,11 +498,14 @@ document.addEventListener('DOMContentLoaded', () => {
initDictionary();
initSrs();
initReader();
initCefr();
// 5. Global Navigation & Header Actions
const navBrandHome = document.getElementById('nav-brand-home');
const navReaderBtn = document.getElementById('nav-reader-btn');
const navCefrBtn = document.getElementById('nav-cefr-btn');
const srsTrainerBtn = document.getElementById('srs-trainer-btn');
const srsJumpCefrBtn = document.getElementById('srs-jump-cefr-btn');
const loginForm = document.getElementById('login-form');
const loginUsername = document.getElementById('login-username');
const loginPassword = document.getElementById('login-password');
@@ -449,9 +518,15 @@ document.addEventListener('DOMContentLoaded', () => {
if (navReaderBtn) {
navReaderBtn.addEventListener('click', () => { window.location.hash = '#reader'; });
}
if (navCefrBtn) {
navCefrBtn.addEventListener('click', () => { window.location.hash = '#cefr'; });
}
if (srsTrainerBtn) {
srsTrainerBtn.addEventListener('click', () => { window.location.hash = '#srs'; });
}
if (srsJumpCefrBtn) {
srsJumpCefrBtn.addEventListener('click', () => { window.location.hash = '#cefr'; });
}
// 6. Mobile Tab Bar & Drawer
initMobileNavigation();
+35
View File
@@ -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}`);
}
}
};
+480
View File
@@ -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);
}
}
+2 -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-v5.0';
const CACHE_NAME = 'notes-v5.1';
const OFFLINE_URL = '/';
const PRECACHE_URLS = [
@@ -13,6 +13,7 @@ const PRECACHE_URLS = [
'/static/js/modules/dictionary.js',
'/static/js/modules/srs.js',
'/static/js/modules/reader.js',
'/static/js/modules/cefr.js',
'/manifest.json',
];
+87
View File
@@ -1359,5 +1359,92 @@ def test_phrasal_verb_detector_unit_and_integration():
assert after_data["phrasal_verb"]["library_card"] is not None
def test_cefr_testing_and_gap_ingestion():
# 1. Unauthenticated check
client.cookies.clear()
unauth = client.get("/api/cefr/levels")
assert unauth.status_code == 401
# 2. Authenticated user login
login_res = client.post("/api/login", json={"username": "testadmin", "password": "testpass12345"})
assert login_res.status_code == 200
# 3. Check level summaries
levels_res = client.get("/api/cefr/levels")
assert levels_res.status_code == 200
levels = levels_res.json()["levels"]
assert len(levels) == 4
level_map = {l["level"]: l for l in levels}
assert "A1" in level_map and "A2" in level_map and "B1" in level_map and "B2" in level_map
assert level_map["A1"]["total_questions"] == 40
assert level_map["A2"]["total_questions"] == 40
assert level_map["B1"]["total_questions"] == 40
assert level_map["B2"]["total_questions"] == 40
# 4. Fetch quiz batch
quiz_res = client.get("/api/cefr/quiz?level=A2&count=5")
assert quiz_res.status_code == 200
quiz_data = quiz_res.json()
assert quiz_data["level"] == "A2"
assert len(quiz_data["quiz"]) == 5
q1 = quiz_data["quiz"][0]
assert "id" in q1
assert "sentence" in q1
assert "clue" in q1
assert len(q1["options"]) == 4
# 5. Answer INCORRECTLY -> Automated Gap Ingestion into SRS!
wrong_opt = "__completely_wrong_option__"
ans_wrong = client.post("/api/cefr/answer", json={
"question_id": q1["id"],
"selected_option": wrong_opt
})
assert ans_wrong.status_code == 200
w_data = ans_wrong.json()
assert w_data["correct"] is False
assert w_data["card_created"] is True
assert w_data["card_id"] is not None
assert w_data["correct_option"] != ""
assert w_data["explanation"] != ""
# Verify the card was actually created in vocabulary_cards with state 'learning' and level tag
card_res = client.get(f"/api/cards/{w_data['card_id']}")
assert card_res.status_code == 200
ingested_card = card_res.json()
assert ingested_card["srs_state"] == "learning"
assert "level-a2" in ingested_card["tags"]
assert "cefr-test" in ingested_card["tags"]
# 6. Check that mode='mistakes' returns this question
mistakes_res = client.get("/api/cefr/quiz?level=A2&mode=mistakes")
assert mistakes_res.status_code == 200
m_data = mistakes_res.json()
assert any(q["id"] == q1["id"] for q in m_data["quiz"])
# 7. Now answer CORRECTLY
ans_correct = client.post("/api/cefr/answer", json={
"question_id": q1["id"],
"selected_option": w_data["correct_option"]
})
assert ans_correct.status_code == 200
c_data = ans_correct.json()
assert c_data["correct"] is True
assert c_data["card_created"] is False
# 8. Check level summary reflects mastered count
levels_res2 = client.get("/api/cefr/levels")
l2_map = {l["level"]: l for l in levels_res2.json()["levels"]}
assert l2_map["A2"]["mastered_count"] >= 1
assert l2_map["A2"]["srs_cards_count"] >= 1
# 9. Test level reset
reset_res = client.post("/api/cefr/reset", json={"level": "A2"})
assert reset_res.status_code == 200
levels_res3 = client.get("/api/cefr/levels")
l3_map = {l["level"]: l for l in levels_res3.json()["levels"]}
assert l3_map["A2"]["mastered_count"] == 0