From 3fae321322361c8d6e1d9d40ea78a2617cb65dca Mon Sep 17 00:00:00 2001 From: Antigravity Date: Tue, 6 Oct 2026 15:24:33 +0300 Subject: [PATCH] feat(cefr): CEFR level testing and automated gap ingestion engine (Task 15) --- TODO.md | 25 +- app/cefr.py | 1510 +++++++++++++++++++++++++++++++++ app/database.py | 44 + app/main.py | 87 ++ app/static/css/style.css | 504 +++++++++++ app/static/index.html | 86 +- app/static/js/app.js | 75 ++ app/static/js/modules/api.js | 35 + app/static/js/modules/cefr.js | 480 +++++++++++ app/static/sw.js | 3 +- tests/test_api.py | 87 ++ 11 files changed, 2926 insertions(+), 10 deletions(-) create mode 100644 app/cefr.py create mode 100644 app/static/js/modules/cefr.js diff --git a/TODO.md b/TODO.md index a765d67..5d68130 100644 --- a/TODO.md +++ b/TODO.md @@ -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). --- diff --git a/app/cefr.py b/app/cefr.py new file mode 100644 index 0000000..a78a7eb --- /dev/null +++ b/app/cefr.py @@ -0,0 +1,1510 @@ +""" +CEFR Level Vocabulary Tests & Assessment Engine (A1, A2, B1, B2). +Oxford 3000 / 5000 based curated benchmark Cloze Tap questions. +100% offline, zero-dependency, pure Python stdlib + SQLite. +Features: +- Fast 1-tap Cloze Tap quiz format. +- Automated Gap Ingestion: mistakes automatically create/update vocabulary cards + with status 'learning' in the user's SRS active deck with level tags. +- Per-level progress tracking (mastered, learning, percentage). +""" + +import json +import sqlite3 +import random +from typing import Dict, Any, List, Optional +from datetime import datetime + +CEFR_LEVELS = ["A1", "A2", "B1", "B2"] + +# Curated High-Yield CEFR Question Bank (Oxford 3000/5000) +# 40 items per level = 160 questions total +CURATED_CEFR_QUESTIONS: List[Dict[str, Any]] = [ + # ========================================================================= + # LEVEL A1 (Beginner: Daily life, basic items, common verbs & adjectives) + # ========================================================================= + { + "level": "A1", "term": "expensive", "pos": "adjective", "phonetic": "/ɪkˈspen.sɪv/", + "translation": "дорогой (по цене)", + "sentence": "This watch is too ___ for me to buy right now.", + "options": ["expensive", "cheap", "simple", "clean"], + "explanation": "Expensive (дорогой) — имеет высокую стоимость. В контексте: слишком дорого, чтобы купить сейчас.", + }, + { + "level": "A1", "term": "weather", "pos": "noun", "phonetic": "/ˈweð.ər/", + "translation": "погода", + "sentence": "The ___ today is sunny and wonderfully warm.", + "options": ["weather", "season", "journey", "holiday"], + "explanation": "Weather (погода) — состояние атмосферы на данный момент (солнечно, тепло).", + }, + { + "level": "A1", "term": "remember", "pos": "verb", "phonetic": "/rɪˈmem.bər/", + "translation": "помнить, вспоминать", + "sentence": "I cannot ___ his phone number without looking at my phone.", + "options": ["remember", "forget", "decide", "repeat"], + "explanation": "Remember (помнить) — удерживать информацию в памяти.", + }, + { + "level": "A1", "term": "careful", "pos": "adjective", "phonetic": "/ˈkeə.fəl/", + "translation": "осторожный, внимательный", + "sentence": "Be ___ when you cross the busy street!", + "options": ["careful", "boring", "dangerous", "wrong"], + "explanation": "Careful (осторожный) — обращающий внимание на безопасность.", + }, + { + "level": "A1", "term": "breakfast", "pos": "noun", "phonetic": "/ˈbrek.fəst/", + "translation": "завтрак", + "sentence": "She always eats eggs and toast for ___.", + "options": ["breakfast", "dinner", "kitchen", "uniform"], + "explanation": "Breakfast (завтрак) — первый утренний прием пищи.", + }, + { + "level": "A1", "term": "decide", "pos": "verb", "phonetic": "/dɪˈsaɪd/", + "translation": "решать, принимать решение", + "sentence": "We need to ___ where to go for our summer vacation.", + "options": ["decide", "invite", "listen", "travel"], + "explanation": "Decide (решать) — делать выбор между вариантами.", + }, + { + "level": "A1", "term": "village", "pos": "noun", "phonetic": "/ˈvɪl.ɪdʒ/", + "translation": "деревня, село", + "sentence": "My grandparents live in a quiet ___ in the mountains.", + "options": ["village", "airport", "hospital", "library"], + "explanation": "Village (деревня) — небольшой населенный пункт за городом.", + }, + { + "level": "A1", "term": "dangerous", "pos": "adjective", "phonetic": "/ˈdeɪn.dʒər.əs/", + "translation": "опасный", + "sentence": "Swimming in this stormy river is very ___.", + "options": ["dangerous", "exciting", "healthy", "wonderful"], + "explanation": "Dangerous (опасный) — способный причинить вред или травму.", + }, + { + "level": "A1", "term": "invite", "pos": "verb", "phonetic": "/ɪnˈvaɪt/", + "translation": "приглашать", + "sentence": "They want to ___ all their classmates to the birthday party.", + "options": ["invite", "address", "listen", "welcome"], + "explanation": "Invite (приглашать) — звать кого-то на мероприятие.", + }, + { + "level": "A1", "term": "borrow", "pos": "verb", "phonetic": "/ˈbɒr.əʊ/", + "translation": "одалживать, брать взаймы", + "sentence": "Can I ___ your pencil for a minute, please?", + "options": ["borrow", "lend", "spend", "repeat"], + "explanation": "Borrow (брать взаймы) — брать вещь во временное пользование.", + }, + { + "level": "A1", "term": "healthy", "pos": "adjective", "phonetic": "/ˈhel.θi/", + "translation": "здоровый, полезный для здоровья", + "sentence": "Eating fresh fruit and vegetables keeps your body ___.", + "options": ["healthy", "boring", "wrong", "famous"], + "explanation": "Healthy (здоровый) — способствующий хорошему самочувствию.", + }, + { + "level": "A1", "term": "journey", "pos": "noun", "phonetic": "/ˈdʒɜː.ni/", + "translation": "путешествие, поездка", + "sentence": "It was a long ___ by train across the entire country.", + "options": ["journey", "ticket", "minute", "station"], + "explanation": "Journey (путешествие, поездка) — передвижение из одного места в другое.", + }, + { + "level": "A1", "term": "simple", "pos": "adjective", "phonetic": "/ˈsɪm.pəl/", + "translation": "простой, несложный", + "sentence": "The recipe is very ___ and only takes ten minutes.", + "options": ["simple", "expensive", "dangerous", "careful"], + "explanation": "Simple (простой) — легкий для понимания или выполнения.", + }, + { + "level": "A1", "term": "foreign", "pos": "adjective", "phonetic": "/ˈfɒr.ən/", + "translation": "иностранный, зарубежный", + "sentence": "She enjoys learning ___ languages and studying their grammar.", + "options": ["foreign", "famous", "careful", "boring"], + "explanation": "Foreign (иностранный) — принадлежащий другой стране.", + }, + { + "level": "A1", "term": "neighbor", "pos": "noun", "phonetic": "/ˈneɪ.bər/", + "translation": "сосед", + "sentence": "Our next-door ___ has three friendly dogs.", + "options": ["neighbor", "student", "parent", "person"], + "explanation": "Neighbor (сосед) — человек, живущий рядом.", + }, + { + "level": "A1", "term": "exciting", "pos": "adjective", "phonetic": "/ɪkˈsaɪ.tɪŋ/", + "translation": "захватывающий, увлекательный", + "sentence": "Watching the championship final was really ___!", + "options": ["exciting", "boring", "wrong", "cheap"], + "explanation": "Exciting (захватывающий) — вызывающий сильные положительные эмоции.", + }, + { + "level": "A1", "term": "listen", "pos": "verb", "phonetic": "/ˈlɪs.ən/", + "translation": "слушать", + "sentence": "You should ___ carefully to the teacher's instructions.", + "options": ["listen", "invite", "decide", "repeat"], + "explanation": "Listen (слушать) — обращать слуховое внимание на речь или звук.", + }, + { + "level": "A1", "term": "hospital", "pos": "noun", "phonetic": "/ˈhɒs.pɪ.təl/", + "translation": "больница, госпиталь", + "sentence": "The doctor works in the largest city ___.", + "options": ["hospital", "airport", "station", "library"], + "explanation": "Hospital (больница) — медицинское учреждение для лечения больных.", + }, + { + "level": "A1", "term": "ticket", "pos": "noun", "phonetic": "/ˈtɪk.ɪt/", + "translation": "билет", + "sentence": "Don't forget to buy a metro ___ before entering.", + "options": ["ticket", "package", "message", "question"], + "explanation": "Ticket (билет) — документ на право проезда или посещения.", + }, + { + "level": "A1", "term": "boring", "pos": "adjective", "phonetic": "/ˈbɔː.rɪŋ/", + "translation": "скучный", + "sentence": "The lecture was so ___ that half the audience fell asleep.", + "options": ["boring", "exciting", "wonderful", "healthy"], + "explanation": "Boring (скучный) — не вызывающий интереса.", + }, + { + "level": "A1", "term": "airport", "pos": "noun", "phonetic": "/ˈeə.pɔːt/", + "translation": "аэропорт", + "sentence": "We arrived at the ___ two hours before our departure.", + "options": ["airport", "hospital", "station", "village"], + "explanation": "Airport (аэропорт) — комплекс для взлета, посадки и обслуживания самолетов.", + }, + { + "level": "A1", "term": "clothes", "pos": "noun", "phonetic": "/kləʊðz/", + "translation": "одежда", + "sentence": "He bought warm winter ___ because it was snowing outside.", + "options": ["clothes", "luggage", "tickets", "packages"], + "explanation": "Clothes (одежда) — предметы гардероба, надеваемые на тело.", + }, + { + "level": "A1", "term": "repeat", "pos": "verb", "phonetic": "/rɪˈpiːt/", + "translation": "повторять", + "sentence": "Could you please ___ that question? I didn't hear you.", + "options": ["repeat", "decide", "invite", "borrow"], + "explanation": "Repeat (повторять) — сказать или сделать что-то еще раз.", + }, + { + "level": "A1", "term": "famous", "pos": "adjective", "phonetic": "/ˈfeɪ.məs/", + "translation": "знаменитый, известный", + "sentence": "She is a very ___ actress known around the globe.", + "options": ["famous", "careful", "wrong", "simple"], + "explanation": "Famous (знаменитый) — широко известный многим людям.", + }, + { + "level": "A1", "term": "message", "pos": "noun", "phonetic": "/ˈmes.ɪdʒ/", + "translation": "сообщение", + "sentence": "He sent me a text ___ to confirm the meeting time.", + "options": ["message", "question", "address", "ticket"], + "explanation": "Message (сообщение) — переданная информация в письменном или голосовом виде.", + }, + { + "level": "A1", "term": "wonderful", "pos": "adjective", "phonetic": "/ˈwʌn.də.fəl/", + "translation": "замечательный, чудесный", + "sentence": "We had a ___ weekend walking by the lake.", + "options": ["wonderful", "dangerous", "boring", "wrong"], + "explanation": "Wonderful (чудесный) — приносящий огромное удовольствие или восхищение.", + }, + { + "level": "A1", "term": "station", "pos": "noun", "phonetic": "/ˈsteɪ.ʃən/", + "translation": "станция, вокзал", + "sentence": "The train arrived at the central railway ___ on schedule.", + "options": ["station", "airport", "hospital", "village"], + "explanation": "Station (станция, вокзал) — остановочный пункт поездов или автобусов.", + }, + { + "level": "A1", "term": "library", "pos": "noun", "phonetic": "/ˈlaɪ.brər.i/", + "translation": "библиотека", + "sentence": "Students go to the quiet ___ to read textbooks.", + "options": ["library", "hospital", "station", "airport"], + "explanation": "Library (библиотека) — учреждение для хранения и выдачи книг.", + }, + { + "level": "A1", "term": "package", "pos": "noun", "phonetic": "/ˈpæk.ɪdʒ/", + "translation": "посылка, пакет", + "sentence": "The courier delivered a heavy cardboard ___ to my door.", + "options": ["package", "ticket", "message", "minute"], + "explanation": "Package (посылка, сверток) — упакованный груз для доставки.", + }, + { + "level": "A1", "term": "travel", "pos": "verb", "phonetic": "/ˈtræv.əl/", + "translation": "путешествовать", + "sentence": "They plan to ___ across Europe during their holidays.", + "options": ["travel", "decide", "listen", "repeat"], + "explanation": "Travel (путешествовать) — совершать поездки в другие города или страны.", + }, + { + "level": "A1", "term": "address", "pos": "noun", "phonetic": "/əˈdres/", + "translation": "адрес", + "sentence": "Please write down your postal ___ on the delivery form.", + "options": ["address", "message", "question", "ticket"], + "explanation": "Address (адрес) — указание места жительства или нахождения здания.", + }, + { + "level": "A1", "term": "wrong", "pos": "adjective", "phonetic": "/rɒŋ/", + "translation": "неправильный, неверный", + "sentence": "I took the ___ bus and ended up in another neighborhood.", + "options": ["wrong", "simple", "healthy", "famous"], + "explanation": "Wrong (неправильный) — несоответствующий правде или правильному направлению.", + }, + { + "level": "A1", "term": "yesterday", "pos": "adverb", "phonetic": "/ˈjes.tə.deɪ/", + "translation": "вчера", + "sentence": "I bought some new shoes ___ afternoon.", + "options": ["yesterday", "tomorrow", "always", "together"], + "explanation": "Yesterday (вчера) — в день, предшествующий сегодняшнему.", + }, + { + "level": "A1", "term": "tomorrow", "pos": "adverb", "phonetic": "/təˈmɒr.əʊ/", + "translation": "завтра", + "sentence": "The bank will open again ___ morning at nine.", + "options": ["tomorrow", "yesterday", "always", "together"], + "explanation": "Tomorrow (завтра) — в день, следующий за сегодняшним.", + }, + { + "level": "A1", "term": "holiday", "pos": "noun", "phonetic": "/ˈhɒl.ə.deɪ/", + "translation": "праздник, отпуск", + "sentence": "We booked a sunny two-week ___ by the Mediterranean sea.", + "options": ["holiday", "journey", "station", "airport"], + "explanation": "Holiday (отпуск, праздник) — период отдыха от работы или учебы.", + }, + { + "level": "A1", "term": "uniform", "pos": "noun", "phonetic": "/ˈjuː.nɪ.fɔːm/", + "translation": "форма, униформа", + "sentence": "Police officers must wear a neat ___ on duty.", + "options": ["uniform", "clothes", "package", "ticket"], + "explanation": "Uniform (униформа) — установленная служебная форма одежды.", + }, + { + "level": "A1", "term": "between", "pos": "preposition", "phonetic": "/bɪˈtwiːn/", + "translation": "между", + "sentence": "The pharmacy is situated ___ the bakery and the bank.", + "options": ["between", "together", "always", "because"], + "explanation": "Between (между) — в пространстве, разделяющем два объекта.", + }, + { + "level": "A1", "term": "together", "pos": "adverb", "phonetic": "/təˈɡeð.ər/", + "translation": "вместе", + "sentence": "Let's work ___ to complete the project faster.", + "options": ["together", "between", "yesterday", "tomorrow"], + "explanation": "Together (вместе) — совместно с кем-либо.", + }, + { + "level": "A1", "term": "welcome", "pos": "noun", "phonetic": "/ˈwel.kəm/", + "translation": "теплый прием, приветствие", + "sentence": "The host gave a cheerful ___ to all the guests.", + "options": ["welcome", "address", "message", "journey"], + "explanation": "Welcome (приветствие, радушный прием) — доброе отношение к прибывшим.", + }, + { + "level": "A1", "term": "question", "pos": "noun", "phonetic": "/ˈkwes.tʃən/", + "translation": "вопрос", + "sentence": "If you are confused, raise your hand and ask a ___.", + "options": ["question", "message", "address", "ticket"], + "explanation": "Question (вопрос) — обращение с целью получить ответ.", + }, + + # ========================================================================= + # LEVEL A2 (Elementary: Practical everyday conversation, work, actions) + # ========================================================================= + { + "level": "A2", "term": "necessary", "pos": "adjective", "phonetic": "/ˈnes.ə.ser.i/", + "translation": "необходимый, нужный", + "sentence": "Good sleep is ___ for maintaining proper physical health.", + "options": ["necessary", "temporary", "available", "convenient"], + "explanation": "Necessary (необходимый) — требуется или обязательно для чего-либо.", + }, + { + "level": "A2", "term": "improve", "pos": "verb", "phonetic": "/ɪmˈpruːv/", + "translation": "улучшать, совершенствовать", + "sentence": "Reading articles in English helps to ___ your vocabulary.", + "options": ["improve", "damage", "refuse", "doubt"], + "explanation": "Improve (улучшать) — делать что-то лучше или качественнее.", + }, + { + "level": "A2", "term": "environment", "pos": "noun", "phonetic": "/ɪnˈvaɪ.rən.mənt/", + "translation": "окружающая среда", + "sentence": "Solar energy is clean and protects the ___.", + "options": ["environment", "culture", "condition", "experience"], + "explanation": "Environment (окружающая среда) — природный мир вокруг нас.", + }, + { + "level": "A2", "term": "believe", "pos": "verb", "phonetic": "/bɪˈliːv/", + "translation": "верить, полагать", + "sentence": "I truly ___ that consistent effort brings great results.", + "options": ["believe", "remind", "avoid", "refuse"], + "explanation": "Believe (верить) — быть уверенным в правдивости чего-либо.", + }, + { + "level": "A2", "term": "experience", "pos": "noun", "phonetic": "/ɪkˈspɪə.ri.əns/", + "translation": "опыт", + "sentence": "The applicant has four years of direct practical ___.", + "options": ["experience", "opinion", "solution", "culture"], + "explanation": "Experience (опыт) — знания и навыки, полученные в процессе деятельности.", + }, + { + "level": "A2", "term": "describe", "pos": "verb", "phonetic": "/dɪˈskraɪb/", + "translation": "описывать", + "sentence": "Can you ___ what the missing backpack looks like?", + "options": ["describe", "connect", "afford", "damage"], + "explanation": "Describe (описывать) — рассказывать о признаках или свойствах предмета.", + }, + { + "level": "A2", "term": "choice", "pos": "noun", "phonetic": "/tʃɔɪs/", + "translation": "выбор", + "sentence": "Customers have a wide ___ of fresh dishes on the menu.", + "options": ["choice", "condition", "reason", "degree"], + "explanation": "Choice (выбор) — возможность предпочесть один вариант другому.", + }, + { + "level": "A2", "term": "condition", "pos": "noun", "phonetic": "/kənˈdɪʃ.ən/", + "translation": "состояние, условие", + "sentence": "The used bicycle is still in nearly perfect ___.", + "options": ["condition", "solution", "opinion", "culture"], + "explanation": "Condition (состояние) — физическая форма или сохранность объекта.", + }, + { + "level": "A2", "term": "explain", "pos": "verb", "phonetic": "/ɪkˈspleɪn/", + "translation": "объяснять", + "sentence": "The engineer will ___ how to configure the router.", + "options": ["explain", "refuse", "avoid", "doubt"], + "explanation": "Explain (объяснять) — делать понятным логику или механизм.", + }, + { + "level": "A2", "term": "reason", "pos": "noun", "phonetic": "/ˈriː.zən/", + "translation": "причина, повод", + "sentence": "The main ___ for his absence was a sudden fever.", + "options": ["reason", "effect", "degree", "habit"], + "explanation": "Reason (причина) — основание или повод для какого-либо события.", + }, + { + "level": "A2", "term": "succeed", "pos": "verb", "phonetic": "/səkˈsiːd/", + "translation": "преуспевать, добиваться успеха", + "sentence": "If you prepare thoroughly, you will ___ in your exam.", + "options": ["succeed", "fail", "refuse", "avoid"], + "explanation": "Succeed (преуспевать) — достигать желаемой цели.", + }, + { + "level": "A2", "term": "afford", "pos": "verb", "phonetic": "/əˈfɔːd/", + "translation": "позволить себе (финансово)", + "sentence": "We cannot ___ to rent such an expensive apartment right now.", + "options": ["afford", "connect", "damage", "manage"], + "explanation": "Afford (позволить себе) — иметь достаточно денег или ресурсов.", + }, + { + "level": "A2", "term": "arrange", "pos": "verb", "phonetic": "/əˈreɪndʒ/", + "translation": "организовывать, договариваться", + "sentence": "Let's ___ a video call for Friday morning.", + "options": ["arrange", "refuse", "damage", "doubt"], + "explanation": "Arrange (организовывать) — согласовать план или встречу.", + }, + { + "level": "A2", "term": "avoid", "pos": "verb", "phonetic": "/əˈvɔɪd/", + "translation": "избегать, уклоняться", + "sentence": "Drivers should always ___ speeding in residential areas.", + "options": ["avoid", "afford", "protect", "support"], + "explanation": "Avoid (избегать) — стараться не допустить нежелательного действия.", + }, + { + "level": "A2", "term": "compare", "pos": "verb", "phonetic": "/kəmˈpeər/", + "translation": "сравнивать", + "sentence": "It is useful to ___ prices before buying airline tickets.", + "options": ["compare", "connect", "damage", "refuse"], + "explanation": "Compare (сравнивать) — сопоставлять объекты для выявления различий.", + }, + { + "level": "A2", "term": "complete", "pos": "verb", "phonetic": "/kəmˈpliːt/", + "translation": "завершать, заполнять", + "sentence": "Please ___ the online registration form to continue.", + "options": ["complete", "remind", "manage", "avoid"], + "explanation": "Complete (завершать, заполнять) — доводить дело или форму до конца.", + }, + { + "level": "A2", "term": "connect", "pos": "verb", "phonetic": "/kəˈnekt/", + "translation": "соединять, подключать", + "sentence": "Plug in the cable to ___ your phone to the computer.", + "options": ["connect", "damage", "refuse", "doubt"], + "explanation": "Connect (подключать) — устанавливать физическую или логическую связь.", + }, + { + "level": "A2", "term": "contain", "pos": "verb", "phonetic": "/kənˈteɪn/", + "translation": "содержать в себе", + "sentence": "These citrus fruits ___ large quantities of vitamin C.", + "options": ["contain", "expect", "manage", "refuse"], + "explanation": "Contain (содержать) — включать в свой состав.", + }, + { + "level": "A2", "term": "crowd", "pos": "noun", "phonetic": "/kraʊd/", + "translation": "толпа людей", + "sentence": "A cheering ___ filled the stadium during the game.", + "options": ["crowd", "flight", "future", "habit"], + "explanation": "Crowd (толпа) — большое скопление людей в одном месте.", + }, + { + "level": "A2", "term": "culture", "pos": "noun", "phonetic": "/ˈkʌl.tʃər/", + "translation": "культура", + "sentence": "Living abroad exposes you to a totally different ___.", + "options": ["culture", "condition", "degree", "solution"], + "explanation": "Culture (культура) — традиции, обычаи и образ жизни народа.", + }, + { + "level": "A2", "term": "damage", "pos": "noun", "phonetic": "/ˈdæm.ɪdʒ/", + "translation": "ущерб, повреждение", + "sentence": "The hailstorm caused severe ___ to the parked cars.", + "options": ["damage", "effect", "reason", "choice"], + "explanation": "Damage (ущерб) — физический вред или разрушение предметов.", + }, + { + "level": "A2", "term": "degree", "pos": "noun", "phonetic": "/dɪˈɡriː/", + "translation": "градус, степень", + "sentence": "The temperature dropped by five ___ during the night.", + "options": ["degree", "condition", "reason", "choice"], + "explanation": "Degree (градус) — единица измерения температуры или угла.", + }, + { + "level": "A2", "term": "depend", "pos": "verb", "phonetic": "/dɪˈpend/", + "translation": "зависеть", + "sentence": "Whether we go hiking will ___ on the weather tomorrow.", + "options": ["depend", "expect", "manage", "refuse"], + "explanation": "Depend (зависеть) — определяться внешними факторами.", + }, + { + "level": "A2", "term": "discover", "pos": "verb", "phonetic": "/dɪˈskʌv.ər/", + "translation": "открывать, обнаруживать", + "sentence": "Researchers hope to ___ cleaner methods of energy production.", + "options": ["discover", "damage", "avoid", "refuse"], + "explanation": "Discover (обнаруживать) — находить ранее неизвестное.", + }, + { + "level": "A2", "term": "discuss", "pos": "verb", "phonetic": "/dɪˈskʌs/", + "translation": "обсуждать", + "sentence": "Let's gather in the conference room to ___ the roadmap.", + "options": ["discuss", "refuse", "damage", "avoid"], + "explanation": "Discuss (обсуждать) — обмениваться мнениями по вопросу.", + }, + { + "level": "A2", "term": "doubt", "pos": "noun", "phonetic": "/daʊt/", + "translation": "сомнение", + "sentence": "There is no ___ that he is telling the absolute truth.", + "options": ["doubt", "habit", "crowd", "flight"], + "explanation": "Doubt (сомнение) — отсутствие твердой уверенности.", + }, + { + "level": "A2", "term": "effect", "pos": "noun", "phonetic": "/ɪˈfekt/", + "translation": "эффект, следствие", + "sentence": "The new law had a noticeable positive ___ on the economy.", + "options": ["effect", "reason", "degree", "choice"], + "explanation": "Effect (эффект) — результат или последствие определенного действия.", + }, + { + "level": "A2", "term": "energy", "pos": "noun", "phonetic": "/ˈen.ə.dʒi/", + "translation": "энергия, силы", + "sentence": "Running every morning gives me a lot of positive ___.", + "options": ["energy", "culture", "condition", "solution"], + "explanation": "Energy (энергия) — физическая или моральная сила для действий.", + }, + { + "level": "A2", "term": "expect", "pos": "verb", "phonetic": "/ɪkˈspekt/", + "translation": "ожидать, рассчитывать", + "sentence": "We ___ the parcel to arrive before five in the afternoon.", + "options": ["expect", "refuse", "damage", "avoid"], + "explanation": "Expect (ожидать) — рассчитывать на наступление события.", + }, + { + "level": "A2", "term": "fail", "pos": "verb", "phonetic": "/feɪl/", + "translation": "потерпеть неудачу, провалить", + "sentence": "If students do not study, they may ___ the final test.", + "options": ["fail", "succeed", "manage", "support"], + "explanation": "Fail (провалить) — не справиться с поставленной задачей.", + }, + { + "level": "A2", "term": "flight", "pos": "noun", "phonetic": "/flaɪt/", + "translation": "рейс, полет", + "sentence": "Their transatlantic ___ was smooth and arrived on time.", + "options": ["flight", "crowd", "habit", "future"], + "explanation": "Flight (рейс, полет) — путешествие на воздушном судне.", + }, + { + "level": "A2", "term": "future", "pos": "noun", "phonetic": "/ˈfjuː.tʃər/", + "translation": "будущее", + "sentence": "Artificial intelligence will transform the ___ of work.", + "options": ["future", "culture", "flight", "condition"], + "explanation": "Future (будущее) — время, которое еще предстоит пережить.", + }, + { + "level": "A2", "term": "guess", "pos": "noun", "phonetic": "/ɡes/", + "translation": "догадка, предположение", + "sentence": "If you don't know the exact answer, make an educated ___.", + "options": ["guess", "choice", "reason", "degree"], + "explanation": "Guess (догадка) — предположение без точных данных.", + }, + { + "level": "A2", "term": "habit", "pos": "noun", "phonetic": "/ˈhæb.ɪt/", + "translation": "привычка", + "sentence": "Brushing your teeth before bed is a healthy personal ___.", + "options": ["habit", "crowd", "flight", "future"], + "explanation": "Habit (привычка) — регулярное автоматическое действие.", + }, + { + "level": "A2", "term": "happen", "pos": "verb", "phonetic": "/ˈhæp.ən/", + "translation": "случаться, происходить", + "sentence": "Nobody predicted that such an accident could ___.", + "options": ["happen", "refuse", "avoid", "damage"], + "explanation": "Happen (происходить) — иметь место, совершаться.", + }, + { + "level": "A2", "term": "imagine", "pos": "verb", "phonetic": "/ɪˈmædʒ.ɪn/", + "translation": "представлять, воображать", + "sentence": "It is hard to ___ modern life without high-speed internet.", + "options": ["imagine", "refuse", "damage", "avoid"], + "explanation": "Imagine (воображать) — создавать мысленный образ в голове.", + }, + { + "level": "A2", "term": "manage", "pos": "verb", "phonetic": "/ˈmæn.ɪdʒ/", + "translation": "справляться, суметь сделать", + "sentence": "Despite heavy traffic, she did ___ to reach the office on time.", + "options": ["manage", "refuse", "avoid", "damage"], + "explanation": "Manage (справляться) — успешно преодолеть трудность и выполнить дело.", + }, + { + "level": "A2", "term": "nature", "pos": "noun", "phonetic": "/ˈneɪ.tʃər/", + "translation": "природа", + "sentence": "A weekend hike in the woods lets you enjoy pure ___.", + "options": ["nature", "culture", "crowd", "flight"], + "explanation": "Nature (природа) — естественный мир растений, животных и ландшафтов.", + }, + { + "level": "A2", "term": "notice", "pos": "verb", "phonetic": "/ˈnəʊ.tɪs/", + "translation": "замечать, обращать внимание", + "sentence": "Did you ___ that the bookstore moved to another street?", + "options": ["notice", "refuse", "damage", "avoid"], + "explanation": "Notice (замечать) — зафиксировать что-то зрением или вниманием.", + }, + { + "level": "A2", "term": "opinion", "pos": "noun", "phonetic": "/əˈpɪn.jən/", + "translation": "мнение, точка зрения", + "sentence": "In my humble ___, this is the tastiest coffee in town.", + "options": ["opinion", "condition", "solution", "reason"], + "explanation": "Opinion (мнение) — личная оценка или суждение о предмете.", + }, + + # ========================================================================= + # LEVEL B1 (Intermediate: Abstract concepts, professional life, reasoning) + # ========================================================================= + { + "level": "B1", "term": "decision", "pos": "noun", "phonetic": "/dɪˈsɪʒ.ən/", + "translation": "решение", + "sentence": "Choosing a career path is a major life ___.", + "options": ["decision", "opportunity", "relationship", "behavior"], + "explanation": "Decision (решение) — сделанный выбор или заключение после размышлений.", + }, + { + "level": "B1", "term": "achieve", "pos": "verb", "phonetic": "/əˈtʃiːv/", + "translation": "достигать (цели)", + "sentence": "With persistent practice, you can ___ fluency in English.", + "options": ["achieve", "interrupt", "hesitate", "negotiate"], + "explanation": "Achieve (достигать) — успешно завершить запланированное упорным трудом.", + }, + { + "level": "B1", "term": "community", "pos": "noun", "phonetic": "/kəˈmjuː.nə.ti/", + "translation": "сообщество, общественность", + "sentence": "The local ___ gathered to build a new playground.", + "options": ["community", "relationship", "behavior", "mystery"], + "explanation": "Community (сообщество) — группа людей, объединенных общим местом или интересами.", + }, + { + "level": "B1", "term": "convenient", "pos": "adjective", "phonetic": "/kənˈviː.ni.ənt/", + "translation": "удобный, подходящий", + "sentence": "Living near a subway station is extremely ___ for commuting.", + "options": ["convenient", "generous", "jealous", "temporary"], + "explanation": "Convenient (удобный) — подходящий по расположению или времени, экономящий силы.", + }, + { + "level": "B1", "term": "develop", "pos": "verb", "phonetic": "/dɪˈvel.əp/", + "translation": "развивать, разрабатывать", + "sentence": "Our engineering squad will ___ a modern backend service.", + "options": ["develop", "hesitate", "interrupt", "negotiate"], + "explanation": "Develop (разрабатывать) — создавать, улучшать и развивать продукт.", + }, + { + "level": "B1", "term": "opportunity", "pos": "noun", "phonetic": "/ˌɒp.əˈtʃuː.nə.ti/", + "translation": "возможность, шанс", + "sentence": "An internship abroad provides a rare ___ to gain global experience.", + "options": ["opportunity", "consequence", "requirement", "behavior"], + "explanation": "Opportunity (возможность) — благоприятное стечение обстоятельств.", + }, + { + "level": "B1", "term": "relationship", "pos": "noun", "phonetic": "/rɪˈleɪ.ʃən.ʃɪp/", + "translation": "отношения, взаимосвязь", + "sentence": "Mutual trust is essential for building a lasting professional ___.", + "options": ["relationship", "community", "behavior", "mystery"], + "explanation": "Relationship (отношения) — связь и взаимодействие между людьми.", + }, + { + "level": "B1", "term": "behavior", "pos": "noun", "phonetic": "/bɪˈheɪ.vjər/", + "translation": "поведение", + "sentence": "The teacher commended the student for respectful classroom ___.", + "options": ["behavior", "community", "decision", "mystery"], + "explanation": "Behavior (поведение) — манера вести себя и действовать.", + }, + { + "level": "B1", "term": "challenge", "pos": "noun", "phonetic": "/ˈtʃæl.ɪndʒ/", + "translation": "сложная задача, вызов", + "sentence": "Migrating a legacy database is always a serious technical ___.", + "options": ["challenge", "opportunity", "consequence", "requirement"], + "explanation": "Challenge (вызов) — сложная задача, требующая усилий.", + }, + { + "level": "B1", "term": "consequence", "pos": "noun", "phonetic": "/ˈkɒn.sɪ.kwəns/", + "translation": "последствие", + "sentence": "Losing your data is a direct ___ of not keeping backups.", + "options": ["consequence", "community", "behavior", "opportunity"], + "explanation": "Consequence (последствие) — результат, вытекающий из действия.", + }, + { + "level": "B1", "term": "consider", "pos": "verb", "phonetic": "/kənˈsɪd.ər/", + "translation": "обдумывать, учитывать", + "sentence": "You should carefully ___ all pros and cons before signing.", + "options": ["consider", "hesitate", "interrupt", "negotiate"], + "explanation": "Consider (обдумывать) — взвешивать факторы перед решением.", + }, + { + "level": "B1", "term": "definitely", "pos": "adverb", "phonetic": "/ˈdef.ɪ.nət.li/", + "translation": "определенно, точно", + "sentence": "I will ___ finish this pull request before leaving today.", + "options": ["definitely", "fluently", "generously", "logically"], + "explanation": "Definitely (определенно) — без малейших сомнений.", + }, + { + "level": "B1", "term": "disappointed", "pos": "adjective", "phonetic": "/ˌdɪs.əˈpɔɪn.tɪd/", + "translation": "разочарованный", + "sentence": "We felt ___ when our favorite restaurant was closed.", + "options": ["disappointed", "confident", "generous", "sensible"], + "explanation": "Disappointed (разочарованный) — огорченный несбывшимися ожиданиями.", + }, + { + "level": "B1", "term": "encourage", "pos": "verb", "phonetic": "/ɪnˈkʌr.ɪdʒ/", + "translation": "поощрять, подбадривать", + "sentence": "Mentors should always ___ beginners when learning new skills.", + "options": ["encourage", "interrupt", "hesitate", "negotiate"], + "explanation": "Encourage (поощрять) — вселять уверенность и поддерживать.", + }, + { + "level": "B1", "term": "essential", "pos": "adjective", "phonetic": "/ɪˈsen.ʃəl/", + "translation": "существенный, неотъемлемый", + "sentence": "Writing tests is an ___ part of production software engineering.", + "options": ["essential", "temporary", "jealous", "modest"], + "explanation": "Essential (существенный) — фундаментально важный.", + }, + { + "level": "B1", "term": "fluent", "pos": "adjective", "phonetic": "/ˈfluː.ənt/", + "translation": "беглый, свободно говорящий", + "sentence": "After years in London, she became completely ___ in English.", + "options": ["fluent", "generous", "modest", "logical"], + "explanation": "Fluent (беглый) — способный свободно и без запинок говорить на языке.", + }, + { + "level": "B1", "term": "generous", "pos": "adjective", "phonetic": "/ˈdʒen.ər.əs/", + "translation": "щедрый, великодушный", + "sentence": "The sponsor made a very ___ contribution to the charity.", + "options": ["generous", "jealous", "temporary", "sensible"], + "explanation": "Generous (щедрый) — готовый охотно делиться ресурсами.", + }, + { + "level": "B1", "term": "hesitate", "pos": "verb", "phonetic": "/ˈhez.ɪ.teɪt/", + "translation": "колебаться, сомневаться", + "sentence": "Please do not ___ to ask if any requirement is unclear.", + "options": ["hesitate", "negotiate", "achieve", "interrupt"], + "explanation": "Hesitate (колебаться) — медлить из-за сомнений или нерешительности.", + }, + { + "level": "B1", "term": "influence", "pos": "noun", "phonetic": "/ˈɪn.flu.əns/", + "translation": "влияние, воздействие", + "sentence": "Good books have a deep positive ___ on personal growth.", + "options": ["influence", "behavior", "mystery", "challenge"], + "explanation": "Influence (влияние) — сила воздействия на мысли или действия других.", + }, + { + "level": "B1", "term": "interrupt", "pos": "verb", "phonetic": "/ˌɪn.təˈrʌpt/", + "translation": "перебивать, прерывать", + "sentence": "It is impolite to ___ colleagues while they present their ideas.", + "options": ["interrupt", "hesitate", "encourage", "negotiate"], + "explanation": "Interrupt (перебивать) — нарушать ход чьей-то речи или действия.", + }, + { + "level": "B1", "term": "jealous", "pos": "adjective", "phonetic": "/ˈdʒel.əs/", + "translation": "ревнивый, завистливый", + "sentence": "He was slightly ___ of his friend's new sports car.", + "options": ["jealous", "generous", "sensible", "modest"], + "explanation": "Jealous (завистливый) — испытывающий досаду от чужого успеха или имущества.", + }, + { + "level": "B1", "term": "maintain", "pos": "verb", "phonetic": "/meɪnˈteɪn/", + "translation": "поддерживать, сохранять", + "sentence": "It takes daily habit to ___ high code quality in a repo.", + "options": ["maintain", "interrupt", "hesitate", "negotiate"], + "explanation": "Maintain (поддерживать) — сохранять объект в надлежащем состоянии.", + }, + { + "level": "B1", "term": "mystery", "pos": "noun", "phonetic": "/ˈmɪs.tər.i/", + "translation": "тайна, загадка", + "sentence": "The origin of that ancient ruined temple remains an unsolved ___.", + "options": ["mystery", "behavior", "relationship", "decision"], + "explanation": "Mystery (загадка) — то, что трудно объяснить или понять.", + }, + { + "level": "B1", "term": "negotiate", "pos": "verb", "phonetic": "/nəˈɡəʊ.ʃi.eɪt/", + "translation": "вести переговоры, договариваться", + "sentence": "The founders met to ___ the terms of their partnership.", + "options": ["negotiate", "interrupt", "hesitate", "encourage"], + "explanation": "Negotiate (вести переговоры) — обсуждать условия для достижения соглашения.", + }, + { + "level": "B1", "term": "obvious", "pos": "adjective", "phonetic": "/ˈɒb.vi.əs/", + "translation": "очевидный, явный", + "sentence": "It is ___ that system security must be prioritized from day one.", + "options": ["obvious", "modest", "temporary", "jealous"], + "explanation": "Obvious (очевидный) — легко заметный и понятный без объяснений.", + }, + { + "level": "B1", "term": "particular", "pos": "adjective", "phonetic": "/pəˈtɪk.jə.lər/", + "translation": "конкретный, определенный", + "sentence": "Was there any ___ reason why the service crashed?", + "options": ["particular", "fluent", "generous", "jealous"], + "explanation": "Particular (конкретный) — относящийся именно к данному случаю или лицу.", + }, + { + "level": "B1", "term": "persuade", "pos": "verb", "phonetic": "/pəˈsweɪd/", + "translation": "убеждать", + "sentence": "She was able to ___ the manager with well-prepared statistics.", + "options": ["persuade", "interrupt", "hesitate", "maintain"], + "explanation": "Persuade (убеждать) — склонять к своей точке зрения доводами.", + }, + { + "level": "B1", "term": "reliable", "pos": "adjective", "phonetic": "/rɪˈlaɪ.ə.bəl/", + "translation": "надежный", + "sentence": "We need a ___ database that does not lose data on restart.", + "options": ["reliable", "jealous", "temporary", "disappointed"], + "explanation": "Reliable (надежный) — верный, заслуживающий доверия.", + }, + { + "level": "B1", "term": "requirement", "pos": "noun", "phonetic": "/rɪˈkwaɪə.mənt/", + "translation": "требование, условие", + "sentence": "Having Python installed is a mandatory ___ for this script.", + "options": ["requirement", "consequence", "community", "behavior"], + "explanation": "Requirement (требование) — обязательное условие.", + }, + { + "level": "B1", "term": "sensible", "pos": "adjective", "phonetic": "/ˈsen.sə.bəl/", + "translation": "благоразумный, разумный", + "sentence": "Wearing a warm jacket in sub-zero weather is a very ___ choice.", + "options": ["sensible", "jealous", "temporary", "disappointed"], + "explanation": "Sensible (благоразумный) — продиктованный здравым смыслом.", + }, + { + "level": "B1", "term": "suitable", "pos": "adjective", "phonetic": "/ˈsuː.tə.bəl/", + "translation": "подходящий, годный", + "sentence": "This quiet study room is very ___ for reading long texts.", + "options": ["suitable", "jealous", "disappointed", "fluent"], + "explanation": "Suitable (подходящий) — соответствующий целям и потребностям.", + }, + { + "level": "B1", "term": "temporary", "pos": "adjective", "phonetic": "/ˈtem.pər.ər.i/", + "translation": "временный", + "sentence": "The detour is only ___ while the bridge is repaired.", + "options": ["temporary", "essential", "reliable", "fluent"], + "explanation": "Temporary (временный) — действующий лишь ограниченный период времени.", + }, + { + "level": "B1", "term": "universal", "pos": "adjective", "phonetic": "/ˌjuː.nɪˈvɜː.səl/", + "translation": "всеобщий, универсальный", + "sentence": "Curiosity is a ___ characteristic of human nature.", + "options": ["universal", "temporary", "jealous", "modest"], + "explanation": "Universal (универсальный) — применимый повсеместно.", + }, + { + "level": "B1", "term": "valuable", "pos": "adjective", "phonetic": "/ˈvæl.jə.bəl/", + "translation": "ценный, полезный", + "sentence": "Her feedback on our design provided ___ insights.", + "options": ["valuable", "jealous", "temporary", "disappointed"], + "explanation": "Valuable (ценный) — обладающий большой пользой или ценностью.", + }, + { + "level": "B1", "term": "vulnerable", "pos": "adjective", "phonetic": "/ˈvʌl.nər.ə.bəl/", + "translation": "уязвимый", + "sentence": "Unpatched systems are highly ___ to automated cyber attacks.", + "options": ["vulnerable", "generous", "fluent", "sensible"], + "explanation": "Vulnerable (уязвимый) — слабо защищенный от вредоносных факторов.", + }, + { + "level": "B1", "term": "ambitious", "pos": "adjective", "phonetic": "/æmˈbɪʃ.əs/", + "translation": "амбициозный, целеустремленный", + "sentence": "The startup announced an ___ plan to launch ten satellites.", + "options": ["ambitious", "jealous", "disappointed", "temporary"], + "explanation": "Ambitious (амбициозный) — ставящий смелые, масштабные цели.", + }, + { + "level": "B1", "term": "confident", "pos": "adjective", "phonetic": "/ˈkɒn.fɪ.dənt/", + "translation": "уверенный в себе", + "sentence": "He gave a clear presentation because he felt ___ in his results.", + "options": ["confident", "disappointed", "jealous", "temporary"], + "explanation": "Confident (уверенный) — не сомневающийся в своих силах.", + }, + { + "level": "B1", "term": "curious", "pos": "adjective", "phonetic": "/ˈkjʊə.ri.əs/", + "translation": "любознательный, любопытный", + "sentence": "Young children are endlessly ___ about the world around them.", + "options": ["curious", "disappointed", "jealous", "fluent"], + "explanation": "Curious (любознательный) — стремящийся узнать что-то новое.", + }, + { + "level": "B1", "term": "efficient", "pos": "adjective", "phonetic": "/ɪˈfɪʃ.ənt/", + "translation": "эффективный, продуктивный", + "sentence": "Using keyboard shortcuts makes editing text much more ___.", + "options": ["efficient", "jealous", "temporary", "modest"], + "explanation": "Efficient (эффективный) — дающий максимальный результат при минимуме затрат.", + }, + { + "level": "B1", "term": "reasonable", "pos": "adjective", "phonetic": "/ˈriː.zən.ə.bəl/", + "translation": "разумный, приемлемый", + "sentence": "The hotel offered comfortable clean rooms at a very ___ price.", + "options": ["reasonable", "jealous", "disappointed", "temporary"], + "explanation": "Reasonable (разумный, приемлемый) — адекватный, справедливый по цене или требованиям.", + }, + + # ========================================================================= + # LEVEL B2 (Upper-Intermediate: Academic, nuanced, debate, precision) + # ========================================================================= + { + "level": "B2", "term": "compromise", "pos": "noun", "phonetic": "/ˈkɒm.prə.maɪz/", + "translation": "компромисс, взаимная уступка", + "sentence": "After intense negotiations, both sides reached a workable ___.", + "options": ["compromise", "consensus", "ambiguity", "contradiction"], + "explanation": "Compromise (компромисс) — соглашение на основе взаимных уступок.", + }, + { + "level": "B2", "term": "inevitable", "pos": "adjective", "phonetic": "/ɪnˈev.ɪ.tə.bəl/", + "translation": "неизбежный, неотвратимый", + "sentence": "Some degree of change is ___ in any growing organization.", + "options": ["inevitable", "plausible", "arbitrary", "versatile"], + "explanation": "Inevitable (неизбежный) — то, что невозможно предотвратить.", + }, + { + "level": "B2", "term": "elaborate", "pos": "verb", "phonetic": "/ɪˈlæb.ə.reɪt/", + "translation": "вдаваться в детали, подробно разъяснять", + "sentence": "Could you please ___ on how this algorithm handles edge cases?", + "options": ["elaborate", "diminish", "facilitate", "terminate"], + "explanation": "Elaborate (подробно разъяснять) — раскрывать тему с дополнительными деталями.", + }, + { + "level": "B2", "term": "coherent", "pos": "adjective", "phonetic": "/kəʊˈhɪə.rənt/", + "translation": "связный, логичный, последовательный", + "sentence": "She outlined a remarkably clear and ___ architectural design.", + "options": ["coherent", "arbitrary", "reluctant", "ambiguous"], + "explanation": "Coherent (связный, последовательный) — логически выстроенный без внутренних противоречий.", + }, + { + "level": "B2", "term": "sophisticated", "pos": "adjective", "phonetic": "/səˈfɪs.tɪ.keɪ.tɪd/", + "translation": "сложный, утонченный, высокотехнологичный", + "sentence": "Modern network firewalls utilize ___ pattern detection.", + "options": ["sophisticated", "arbitrary", "preliminary", "reluctant"], + "explanation": "Sophisticated (высокотехнологичный) — обладающий высоким уровнем сложности и развития.", + }, + { + "level": "B2", "term": "resilient", "pos": "adjective", "phonetic": "/rɪˈzɪl.jənt/", + "translation": "устойчивый к сбоям, жизнестойкий", + "sentence": "The distributed cluster proved ___ even when two nodes went down.", + "options": ["resilient", "reluctant", "arbitrary", "plausible"], + "explanation": "Resilient (жизнестойкий, отказоустойчивый) — способный быстро восстанавливаться после потрясений.", + }, + { + "level": "B2", "term": "ambiguity", "pos": "noun", "phonetic": "/ˌæm.bɪˈɡjuː.ə.ti/", + "translation": "двусмысленность, неопределенность", + "sentence": "Good API specifications avoid any semantic ___.", + "options": ["ambiguity", "consensus", "compromise", "versatility"], + "explanation": "Ambiguity (двусмысленность) — наличие нескольких противоречивых трактовок.", + }, + { + "level": "B2", "term": "comprehensive", "pos": "adjective", "phonetic": "/ˌkɒm.prɪˈhen.sɪv/", + "translation": "всесторонний, исчерпывающий", + "sentence": "The documentation provides a ___ guide to all SDK methods.", + "options": ["comprehensive", "reluctant", "arbitrary", "preliminary"], + "explanation": "Comprehensive (исчерпывающий) — охватывающий предмет целиком и во всех деталях.", + }, + { + "level": "B2", "term": "profound", "pos": "adjective", "phonetic": "/prəˈfaʊnd/", + "translation": "глубокий, фундаментальный", + "sentence": "The discovery of antibiotics had a ___ impact on human longevity.", + "options": ["profound", "reluctant", "arbitrary", "plausible"], + "explanation": "Profound (глубокий) — оказывающий огромное и фундаментальное влияние.", + }, + { + "level": "B2", "term": "contradictory", "pos": "adjective", "phonetic": "/ˌkɒn.trəˈdɪk.tər.i/", + "translation": "противоречивый", + "sentence": "The witness gave two completely ___ accounts of what occurred.", + "options": ["contradictory", "versatile", "preliminary", "resilient"], + "explanation": "Contradictory (противоречивый) — взаимоисключающий.", + }, + { + "level": "B2", "term": "enhance", "pos": "verb", "phonetic": "/ɪnˈhɑːns/", + "translation": "усиливать, повышать качество", + "sentence": "Adding indexes to foreign keys will significantly ___ query speed.", + "options": ["enhance", "diminish", "terminate", "undermine"], + "explanation": "Enhance (усиливать) — увеличивать качество, силу или ценность.", + }, + { + "level": "B2", "term": "facilitate", "pos": "verb", "phonetic": "/fəˈsɪl.ɪ.teɪt/", + "translation": "облегчать, способствовать", + "sentence": "Clean APIs ___ integration with third-party mobile applications.", + "options": ["facilitate", "undermine", "fluctuate", "terminate"], + "explanation": "Facilitate (способствовать) — делать процесс проще и быстрее.", + }, + { + "level": "B2", "term": "advocate", "pos": "verb", "phonetic": "/ˈæd.və.keɪt/", + "translation": "выступать в защиту, отстаивать", + "sentence": "Senior developers strongly ___ for automated testing pipelines.", + "options": ["advocate", "terminate", "undermine", "fluctuate"], + "explanation": "Advocate (отстаивать) — публично поддерживать идею или принцип.", + }, + { + "level": "B2", "term": "diminish", "pos": "verb", "phonetic": "/dɪˈmɪn.ɪʃ/", + "translation": "уменьшаться, ослабевать", + "sentence": "Regular backups help to ___ the risks of sudden data loss.", + "options": ["diminish", "enhance", "facilitate", "advocate"], + "explanation": "Diminish (уменьшать) — сокращать величину, масштаб или силу.", + }, + { + "level": "B2", "term": "feasible", "pos": "adjective", "phonetic": "/ˈfiː.zə.bəl/", + "translation": "осуществимый, реальный", + "sentence": "Completing this migration in one sprint is technically ___.", + "options": ["feasible", "reluctant", "arbitrary", "ambiguous"], + "explanation": "Feasible (осуществимый) — вполне выполнимый при имеющихся ресурсах.", + }, + { + "level": "B2", "term": "substantial", "pos": "adjective", "phonetic": "/səbˈstæn.ʃəl/", + "translation": "существенный, значительный", + "sentence": "We noticed a ___ reduction in server memory usage after tuning.", + "options": ["substantial", "preliminary", "reluctant", "arbitrary"], + "explanation": "Substantial (значительный) — весомый по объему или значению.", + }, + { + "level": "B2", "term": "subsequent", "pos": "adjective", "phonetic": "/ˈsʌb.sɪ.kwənt/", + "translation": "последующий", + "sentence": "The initial build broke, but all ___ builds passed cleanly.", + "options": ["subsequent", "reluctant", "arbitrary", "versatile"], + "explanation": "Subsequent (последующий) — происходящий после чего-либо.", + }, + { + "level": "B2", "term": "perceive", "pos": "verb", "phonetic": "/pəˈsiːv/", + "translation": "воспринимать, осознавать", + "sentence": "Different users ___ software latency in very distinct ways.", + "options": ["perceive", "allocate", "terminate", "undermine"], + "explanation": "Perceive (воспринимать) — осознавать через органы чувств или разум.", + }, + { + "level": "B2", "term": "convey", "pos": "verb", "phonetic": "/kənˈveɪ/", + "translation": "передавать, выражать (мысль)", + "sentence": "Clear charts can ___ complex statistical insights at a glance.", + "options": ["convey", "allocate", "terminate", "fluctuate"], + "explanation": "Convey (передавать) — делать мысль или значение понятными другим.", + }, + { + "level": "B2", "term": "crucial", "pos": "adjective", "phonetic": "/ˈkruː.ʃəl/", + "translation": "решающий, критически важный", + "sentence": "Code reviews are ___ for maintaining high quality in shared repos.", + "options": ["crucial", "arbitrary", "reluctant", "plausible"], + "explanation": "Crucial (критически важный) — определяющий успех или провал.", + }, + { + "level": "B2", "term": "deduce", "pos": "verb", "phonetic": "/dɪˈdʒuːs/", + "translation": "делать логический вывод, выводить", + "sentence": "From the error stack trace, we can easily ___ the exact root cause.", + "options": ["deduce", "allocate", "undermine", "fluctuate"], + "explanation": "Deduce (делать вывод) — приходить к умозаключению на основе фактов.", + }, + { + "level": "B2", "term": "discrete", "pos": "adjective", "phonetic": "/dɪˈskriːt/", + "translation": "дискретный, раздельный, обособленный", + "sentence": "The protocol breaks streaming data into small ___ chunks.", + "options": ["discrete", "reluctant", "arbitrary", "plausible"], + "explanation": "Discrete (дискретный) — состоящий из отдельных четких единиц.", + }, + { + "level": "B2", "term": "explicit", "pos": "adjective", "phonetic": "/ɪkˈsplɪs.ɪt/", + "translation": "явный, четко выраженный", + "sentence": "The configuration provides ___ timeout limits for network calls.", + "options": ["explicit", "ambiguous", "reluctant", "arbitrary"], + "explanation": "Explicit (явный, точный) — сформулированный прямо и без двусмысленности.", + }, + { + "level": "B2", "term": "fundamental", "pos": "adjective", "phonetic": "/ˌfʌn.dəˈmen.təl/", + "translation": "фундаментальный, основополагающий", + "sentence": "Understanding pointers is a ___ concept in systems programming.", + "options": ["fundamental", "arbitrary", "reluctant", "preliminary"], + "explanation": "Fundamental (основополагающий) — служащий базисом для всего остального.", + }, + { + "level": "B2", "term": "hierarchical", "pos": "adjective", "phonetic": "/ˌhaɪəˈrɑː.kɪ.kəl/", + "translation": "иерархический", + "sentence": "Filesystems organize documents in a strict ___ folder tree.", + "options": ["hierarchical", "arbitrary", "reluctant", "plausible"], + "explanation": "Hierarchical (иерархический) — построенный по принципу соподчинения уровней.", + }, + { + "level": "B2", "term": "implicit", "pos": "adjective", "phonetic": "/ɪmˈplɪs.ɪt/", + "translation": "неявный, подразумеваемый", + "sentence": "There is an ___ trust assumption between these internal services.", + "options": ["implicit", "explicit", "arbitrary", "versatile"], + "explanation": "Implicit (неявный, подразумеваемый) — не выраженный прямо, но очевидный по смыслу.", + }, + { + "level": "B2", "term": "inherent", "pos": "adjective", "phonetic": "/ɪnˈher.ənt/", + "translation": "присущий, неотъемлемый", + "sentence": "Network latency is an ___ property of distributed cloud systems.", + "options": ["inherent", "preliminary", "reluctant", "arbitrary"], + "explanation": "Inherent (присущий) — составляющий неотъемлемую природную черту объекта.", + }, + { + "level": "B2", "term": "integrate", "pos": "verb", "phonetic": "/ˈɪn.tɪ.ɡreɪt/", + "translation": "интегрировать, объединять", + "sentence": "We want to ___ the payment gateway seamlessly into the checkout.", + "options": ["integrate", "undermine", "terminate", "fluctuate"], + "explanation": "Integrate (интегрировать) — соединять части в единое целое.", + }, + { + "level": "B2", "term": "justify", "pos": "verb", "phonetic": "/ˈdʒʌs.tɪ.faɪ/", + "translation": "оправдывать, обосновывать", + "sentence": "You should bring performance metrics to ___ the rewrite.", + "options": ["justify", "undermine", "terminate", "fluctuate"], + "explanation": "Justify (обосновывать) — приводить разумные доводы в защиту решения.", + }, + { + "level": "B2", "term": "preliminary", "pos": "adjective", "phonetic": "/prɪˈlɪm.ɪ.nər.i/", + "translation": "предварительный", + "sentence": "These are only the ___ benchmark results; full tests run tonight.", + "options": ["preliminary", "reluctant", "arbitrary", "versatile"], + "explanation": "Preliminary (предварительный) — предшествующий окончательному этапу.", + }, + { + "level": "B2", "term": "random", "pos": "adjective", "phonetic": "/ˈræn.dəm/", + "translation": "случайный, произвольный", + "sentence": "The program selects test samples using a uniform ___ distribution.", + "options": ["random", "reluctant", "plausible", "resilient"], + "explanation": "Random (случайный) — выбранный без закономерности или предвзятости.", + }, + { + "level": "B2", "term": "reluctant", "pos": "adjective", "phonetic": "/rɪˈlʌk.tənt/", + "translation": "неохотный, сомневающийся", + "sentence": "He was ___ to rewrite working code without clear justification.", + "options": ["reluctant", "resilient", "versatile", "plausible"], + "explanation": "Reluctant (неохотный) — делающий что-либо с неохотой или сомнением.", + }, + { + "level": "B2", "term": "simultaneous", "pos": "adjective", "phonetic": "/ˌsɪm.əlˈteɪ.ni.əs/", + "translation": "одновременный", + "sentence": "The database engine easily handles hundreds of ___ read queries.", + "options": ["simultaneous", "reluctant", "arbitrary", "preliminary"], + "explanation": "Simultaneous (одновременный) — происходящий в одно и то же мгновение.", + }, + { + "level": "B2", "term": "undermine", "pos": "verb", "phonetic": "/ˌʌn.dəˈmaɪn/", + "translation": "подрывать, ослаблять", + "sentence": "Skipping validation steps will inevitably ___ system reliability.", + "options": ["undermine", "facilitate", "enhance", "advocate"], + "explanation": "Undermine (подрывать) — постепенно разрушать или ослаблять основы.", + }, + { + "level": "B2", "term": "accommodate", "pos": "verb", "phonetic": "/əˈkɒm.ə.deɪt/", + "translation": "вмещать, приспосабливать", + "sentence": "The new server cluster can easily ___ millions of daily visitors.", + "options": ["accommodate", "undermine", "terminate", "fluctuate"], + "explanation": "Accommodate (вмещать) — иметь достаточно места или ресурсов для кого-либо.", + }, + { + "level": "B2", "term": "allocate", "pos": "verb", "phonetic": "/ˈæl.ə.keɪt/", + "translation": "выделять (ресурсы, память)", + "sentence": "We need to ___ sufficient RAM for the in-memory cache.", + "options": ["allocate", "undermine", "terminate", "fluctuate"], + "explanation": "Allocate (выделять) — распределять или закреплять ресурсы под задачу.", + }, + { + "level": "B2", "term": "arbitrary", "pos": "adjective", "phonetic": "/ˈɑː.bɪ.trər.i/", + "translation": "произвольный, случайный (без четкого правила)", + "sentence": "Do not pick an ___ timeout value; measure actual latencies.", + "options": ["arbitrary", "resilient", "coherent", "versatile"], + "explanation": "Arbitrary (произвольный) — основанный на случайном выборе, а не на правиле.", + }, + { + "level": "B2", "term": "consensus", "pos": "noun", "phonetic": "/kənˈsen.səs/", + "translation": "консенсус, общее согласие", + "sentence": "The architecture committee finally reached a general ___.", + "options": ["consensus", "ambiguity", "contradiction", "compromise"], + "explanation": "Consensus (консенсус) — общее единогласие мнений всех сторон.", + }, + { + "level": "B2", "term": "fluctuate", "pos": "verb", "phonetic": "/ˈflʌk.tʃu.eɪt/", + "translation": "колебаться, меняться", + "sentence": "Internet speeds can ___ depending on peak network hours.", + "options": ["fluctuate", "undermine", "terminate", "advocate"], + "explanation": "Fluctuate (колебаться) — непрерывно изменяться вверх и вниз.", + }, + { + "level": "B2", "term": "plausible", "pos": "adjective", "phonetic": "/ˈplɔː.zə.bəl/", + "translation": "правдоподобный, вероятный", + "sentence": "His explanation for the memory leak sounded completely ___.", + "options": ["plausible", "reluctant", "arbitrary", "preliminary"], + "explanation": "Plausible (правдоподобный) — заслуживающий доверия, вероятный.", + }, +] + + +def seed_cefr_questions(conn: sqlite3.Connection) -> int: + """ + Populates cefr_questions table with curated Oxford 3000/5000 benchmark questions. + Safe and idempotent (using INSERT OR IGNORE). + """ + inserted = 0 + for q in CURATED_CEFR_QUESTIONS: + options_str = json.dumps(q["options"], ensure_ascii=False) + cursor = conn.execute( + """ + INSERT OR IGNORE INTO cefr_questions ( + level, term, translation, pos, phonetic, sentence, options_json, explanation + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + q["level"], + q["term"], + q["translation"], + q.get("pos", ""), + q.get("phonetic", ""), + q["sentence"], + options_str, + q.get("explanation", ""), + ), + ) + if cursor.rowcount > 0: + inserted += 1 + return inserted + + +def get_cefr_level_summaries(conn: sqlite3.Connection, user_id: int) -> List[Dict[str, Any]]: + """ + Computes real-time progress metrics for each CEFR level (A1, A2, B1, B2) for user. + """ + summaries = [] + level_titles = { + "A1": "Beginner (Начальный)", + "A2": "Elementary (Базовый)", + "B1": "Intermediate (Средний)", + "B2": "Upper-Intermediate (Продвинутый)", + } + + for lvl in CEFR_LEVELS: + total_q = conn.execute( + "SELECT COUNT(*) FROM cefr_questions WHERE level = ?", (lvl,) + ).fetchone()[0] + + row = conn.execute( + """ + SELECT + SUM(CASE WHEN is_mastered = 1 THEN 1 ELSE 0 END) as mastered_cnt, + SUM(CASE WHEN mistakes > 0 AND is_mastered = 0 THEN 1 ELSE 0 END) as in_learning_cnt, + COUNT(*) as attempted_cnt + FROM cefr_progress + WHERE user_id = ? AND level = ? + """, + (user_id, lvl), + ).fetchone() + + mastered = row["mastered_cnt"] or 0 + in_learning = row["in_learning_cnt"] or 0 + attempted = row["attempted_cnt"] or 0 + + # Also count active SRS cards with this level tag + srs_tag = f"level-{lvl.lower()}" + active_vocab_cards = conn.execute( + """ + SELECT COUNT(*) FROM vocabulary_cards + WHERE user_id = ? AND tags LIKE ? + """, + (user_id, f"%{srs_tag}%"), + ).fetchone()[0] + + pct = int(round((mastered / total_q) * 100)) if total_q > 0 else 0 + + summaries.append({ + "level": lvl, + "title": level_titles.get(lvl, lvl), + "total_questions": total_q, + "mastered_count": mastered, + "in_learning_count": in_learning, + "attempted_count": attempted, + "srs_cards_count": active_vocab_cards, + "progress_percent": pct, + }) + + return summaries + + +def get_cefr_quiz_batch( + conn: sqlite3.Connection, + user_id: int, + level: str, + count: int = 10, + mode: str = "all", +) -> List[Dict[str, Any]]: + """ + Fetches next batch of Cloze Tap questions for the selected level. + If mode == 'mistakes', returns questions where user made mistakes and hasn't mastered yet. + Otherwise prioritizes unattempted questions, then review of mistakes. + Options are randomized per question. Correct answer is kept hidden from client. + """ + clean_level = level.upper().strip() + if clean_level not in CEFR_LEVELS: + clean_level = "A1" + count = max(1, min(count, 30)) + + if mode == "mistakes": + rows = conn.execute( + """ + SELECT q.id, q.level, q.sentence, q.translation, q.pos, q.options_json + FROM cefr_questions q + JOIN cefr_progress p ON q.id = p.question_id + WHERE p.user_id = ? AND q.level = ? AND p.is_mastered = 0 AND p.mistakes > 0 + ORDER BY p.last_tested_at ASC + LIMIT ? + """, + (user_id, clean_level, count), + ).fetchall() + else: + # 1. Unattempted questions + rows = conn.execute( + """ + SELECT q.id, q.level, q.sentence, q.translation, q.pos, q.options_json + FROM cefr_questions q + LEFT JOIN cefr_progress p ON q.id = p.question_id AND p.user_id = ? + WHERE q.level = ? AND (p.question_id IS NULL OR p.is_mastered = 0) + ORDER BY CASE WHEN p.question_id IS NULL THEN 0 ELSE 1 END, + p.last_tested_at ASC, + q.id ASC + LIMIT ? + """, + (user_id, clean_level, count), + ).fetchall() + + # If not enough, fill with any from this level + if len(rows) < count: + needed = count - len(rows) + existing_ids = [r["id"] for r in rows] + placeholders = ",".join(["?"] * len(existing_ids)) if existing_ids else "-1" + extra = conn.execute( + f""" + SELECT q.id, q.level, q.sentence, q.translation, q.pos, q.options_json + FROM cefr_questions q + WHERE q.level = ? AND q.id NOT IN ({placeholders}) + ORDER BY q.id ASC + LIMIT ? + """, + [clean_level] + existing_ids + [needed], + ).fetchall() + rows = list(rows) + list(extra) + + quiz_items = [] + for r in rows: + try: + options = json.loads(r["options_json"]) + except Exception: + options = [] + # Randomize options order + shuffled_options = list(options) + random.shuffle(shuffled_options) + + quiz_items.append({ + "id": r["id"], + "level": r["level"], + "sentence": r["sentence"], + "clue": r["translation"], + "pos": r["pos"] or "", + "options": shuffled_options, + }) + + return quiz_items + + +def process_cefr_answer( + conn: sqlite3.Connection, + user_id: int, + question_id: int, + selected_option: str, +) -> Dict[str, Any]: + """ + Validates user's Cloze Tap choice. + - If correct: marks question mastered. + - If mistake: + - marks mistake in cefr_progress + - AUTOMATIC GAP INGESTION: creates or updates vocabulary_card in user's dictionary + with srs_state='learning', interval=0, next_review=now, level tags so it appears + in daily morning SRS training! + """ + q_row = conn.execute( + "SELECT * FROM cefr_questions WHERE id = ?", (question_id,) + ).fetchone() + + if not q_row: + return {"error": "Question not found"} + + target_term = q_row["term"].strip() + selected_clean = (selected_option or "").strip() + is_correct = selected_clean.lower() == target_term.lower() + explanation = q_row["explanation"] or "" + level = q_row["level"] + phonetic = q_row["phonetic"] or "" + pos = q_row["pos"] or "" + translation = q_row["translation"] or "" + sentence_with_term = q_row["sentence"].replace("___", target_term) + + # 1. Update or Insert into cefr_progress + prog_row = conn.execute( + "SELECT * FROM cefr_progress WHERE user_id = ? AND question_id = ?", + (user_id, question_id), + ).fetchone() + + if prog_row: + new_attempts = prog_row["attempts"] + 1 + new_mistakes = prog_row["mistakes"] + (0 if is_correct else 1) + new_mastered = 1 if is_correct else 0 + conn.execute( + """ + UPDATE cefr_progress + SET is_mastered = ?, attempts = ?, mistakes = ?, last_tested_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (new_mastered, new_attempts, new_mistakes, prog_row["id"]), + ) + else: + conn.execute( + """ + INSERT INTO cefr_progress (user_id, question_id, level, is_mastered, attempts, mistakes, last_tested_at) + VALUES (?, ?, ?, ?, 1, ?, CURRENT_TIMESTAMP) + """, + (user_id, question_id, level, 1 if is_correct else 0, 0 if is_correct else 1), + ) + + # 2. Activity log entry for heatmap streak calculation + # We log to srs_logs if card exists or placeholder so streak increases + card_created = False + card_updated = False + target_card_id = None + + if not is_correct: + # ===================================================================== + # AUTOMATED GAP INGESTION (Crucial Pedagogical Requirement) + # ===================================================================== + existing_card = conn.execute( + "SELECT * FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = ?", + (user_id, target_term.lower()), + ).fetchone() + + level_tag = f"level-{level.lower()}" + col_name = f"Уровни CEFR ({level})" + + if not existing_card: + # Create fresh card in 'learning' state for daily SRS + cur = conn.execute( + """ + INSERT INTO vocabulary_cards ( + user_id, term, translation, context_sentence, phonetic, audio_url, + part_of_speech, definition_en, tags, notes, collection, + srs_reps, srs_interval, srs_ease, srs_state, error_count, + srs_next_review + ) VALUES (?, ?, ?, ?, ?, '', ?, ?, ?, ?, ?, 0, 0, 2.5, 'learning', 1, CURRENT_TIMESTAMP) + """, + ( + user_id, + target_term, + translation, + sentence_with_term, + phonetic, + pos, + explanation, + f"{level_tag}, cefr-test, test-mistake", + f"Добавлено из теста уровня {level}", + col_name, + ), + ) + target_card_id = cur.lastrowid + card_created = True + else: + # Card already in library: adjust state and bring into today's SRS queue + target_card_id = existing_card["id"] + existing_tags = set([t.strip() for t in (existing_card["tags"] or "").split(",") if t.strip()]) + existing_tags.add(level_tag) + existing_tags.add("cefr-test") + updated_tags = ", ".join(sorted(existing_tags)) + + updated_context = existing_card["context_sentence"] or "" + if sentence_with_term and sentence_with_term not in updated_context: + updated_context = f"{updated_context}\n\n{sentence_with_term}".strip() + + conn.execute( + """ + UPDATE vocabulary_cards + SET srs_state = 'learning', + srs_next_review = CURRENT_TIMESTAMP, + error_count = error_count + 1, + tags = ?, + context_sentence = ? + WHERE id = ? + """, + (updated_tags, updated_context, target_card_id), + ) + card_updated = True + + # Log mistake in srs_logs + if target_card_id: + conn.execute( + """ + INSERT INTO srs_logs (user_id, card_id, rating, interval, ease, reviewed_at) + VALUES (?, ?, 1, 0, 2.5, CURRENT_TIMESTAMP) + """, + (user_id, target_card_id), + ) + else: + # On correct answer, if user has this card in library, record a positive review log + existing_card = conn.execute( + "SELECT id FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = ?", + (user_id, target_term.lower()), + ).fetchone() + if existing_card: + conn.execute( + """ + INSERT INTO srs_logs (user_id, card_id, rating, interval, ease, reviewed_at) + VALUES (?, ?, 4, 1, 2.5, CURRENT_TIMESTAMP) + """, + (user_id, existing_card["id"]), + ) + + return { + "correct": is_correct, + "correct_option": target_term, + "phonetic": phonetic, + "translation": translation, + "explanation": explanation, + "card_created": card_created, + "card_updated": card_updated, + "card_id": target_card_id, + "card_term": target_term, + } + + +def reset_user_cefr_level(conn: sqlite3.Connection, user_id: int, level: str) -> int: + """ + Resets progress for a specific CEFR level for user. + """ + clean_level = level.upper().strip() + cursor = conn.execute( + "DELETE FROM cefr_progress WHERE user_id = ? AND level = ?", + (user_id, clean_level), + ) + return cursor.rowcount diff --git a/app/database.py b/app/database.py index 7a2f909..34a3986 100644 --- a/app/database.py +++ b/app/database.py @@ -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.""" diff --git a/app/main.py b/app/main.py index 126528e..caf78a8 100644 --- a/app/main.py +++ b/app/main.py @@ -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} + diff --git a/app/static/css/style.css b/app/static/css/style.css index 5cbb35f..420e5bd 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -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; + } +} + diff --git a/app/static/index.html b/app/static/index.html index f56d224..5695b5c 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -70,6 +70,9 @@ + @@ -589,6 +595,83 @@ + + +
+ @@ -1114,7 +1198,7 @@ - + diff --git a/app/static/js/app.js b/app/static/js/app.js index 2b72fdb..2ec6210 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -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(); diff --git a/app/static/js/modules/api.js b/app/static/js/modules/api.js index 332d023..7200b09 100644 --- a/app/static/js/modules/api.js +++ b/app/static/js/modules/api.js @@ -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}`); + } } }; diff --git a/app/static/js/modules/cefr.js b/app/static/js/modules/cefr.js new file mode 100644 index 0000000..7833c84 --- /dev/null +++ b/app/static/js/modules/cefr.js @@ -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 = '
Загрузка уровней...
'; + + try { + const data = await api.cefr.getLevels(); + const levels = data.levels || []; + renderLevelsGrid(levels); + } catch (err) { + grid.innerHTML = `

Ошибка загрузки: ${escapeHtml(err.message)}

`; + } +} + +function renderLevelsGrid(levels) { + const grid = document.getElementById('cefr-levels-grid'); + if (!grid) return; + + if (!levels.length) { + grid.innerHTML = '

Нет доступных уровней

'; + 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 ` +
+
+
+ ${escapeHtml(lvl.level)} +
+
+

${escapeHtml(lvl.title)}

+ ${lvl.total_questions} эталонных слов +
+
+ +
+
+
+
+
+ ${pct}% освоено + ${lvl.mastered_count} / ${lvl.total_questions} +
+
+ +
+
+ ✓ ${lvl.mastered_count} + Освоено +
+
+ ⚠️ ${lvl.in_learning_count} + Ошибок +
+
+ 🎓 ${lvl.srs_cards_count} + В словаре SRS +
+
+ +
+ + ${hasMistakes ? ` + + ` : ''} + +
+
+ `; + }).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 = '
Загрузка теста...
'; + } + + 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, '_____'); + + cardContainer.innerHTML = ` +
+ 🧩 Cloze Tap (1-тап выбор) + ${q.pos ? `${escapeHtml(q.pos)}` : ''} +
+ +
+

${formattedSentence}

+
+ +
+ 💡 + Значение: + ${escapeHtml(q.clue)} +
+ +
+ ${q.options.map((opt, idx) => ` + + `).join('')} +
+ + + `; + + // 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 = ` +
✓ Верно! Отличный выбор.
+
+ ${escapeHtml(res.correct_option)} + ${res.phonetic ? `${escapeHtml(res.phonetic)}` : ''} +
+ ${res.explanation ? `

${escapeHtml(res.explanation)}

` : ''} + `; + 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 = ` +
✗ Неверно. Правильное слово: ${escapeHtml(res.correct_option)}
+
+ ${escapeHtml(res.translation || '')} + ${res.phonetic ? `${escapeHtml(res.phonetic)}` : ''} +
+ ${res.explanation ? `

${escapeHtml(res.explanation)}

` : ''} +
+ 📥 +
+ Авто-захват в словарь: слово добавлено со статусом «В процессе» для закрепления в утреннем SRS! +
+
+ `; + 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); + } +} diff --git a/app/static/sw.js b/app/static/sw.js index 113ee52..ce1f2e1 100644 --- a/app/static/sw.js +++ b/app/static/sw.js @@ -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', ]; diff --git a/tests/test_api.py b/tests/test_api.py index e474c5e..b97c8d6 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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 + + +