diff --git a/TODO.md b/TODO.md index 0d980fa..6abf73a 100644 --- a/TODO.md +++ b/TODO.md @@ -314,16 +314,23 @@ - Подсказка значения слова на русском языке и часть речи. - 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] **Автоматический двухсторонний захват пробелов (Dual Gap Ingestion)**: + - При ошибке $\rightarrow$ **автоматически в словарь SRS со статусом «В процессе» (`learning`) добавляются ОБА слова**: + 1. Истинное целевое слово (правильный ответ). + 2. Выбранный дистрактор (слово, на которое кликнул пользователь, с его переводом и транскрипцией). + - Срочный срок повторения (`srs_next_review = now`), коллекция `Уровни CEFR ({level})` и теги `#level-{level}`, `#cefr-test`, `#test-mistake`. +- [x] **Интерактивный разбор всех вариантов (Options Breakdown & 1-Tap Add)**: + - Карточки всех 4 вариантов ответа с переводом из оффлайн-словаря и частью речи. + - Автоматическая маркировка добавленных слов (`✓ В словаре (SRS)` / `✓ В библиотеке`). + - Быстрая кнопка `+ В словарь` для любых оставшихся незнакомых вариантов вопроса в 1 тап. +- [x] **Расширенная база вопросов (Oxford 3000/5000)**: + - Банк расширен со 160 до **260 эталонных вопросов** (по 65 вопросов на каждый уровень A1, A2, B1, B2). - [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). +- [x] Добавлен регрессионный тест `test_cefr_testing_and_gap_ingestion` в [`tests/test_api.py`](file:///home/ai-projects/services/study.qpid.pro/tests/test_api.py) (29/29 тестов успешно, 100% green). --- diff --git a/app/cefr.py b/app/cefr.py index a78a7eb..68eaf60 100644 --- a/app/cefr.py +++ b/app/cefr.py @@ -18,7 +18,7 @@ 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 +# 65 items per level = 260 questions total CURATED_CEFR_QUESTIONS: List[Dict[str, Any]] = [ # ========================================================================= # LEVEL A1 (Beginner: Daily life, basic items, common verbs & adjectives) @@ -303,6 +303,181 @@ CURATED_CEFR_QUESTIONS: List[Dict[str, Any]] = [ "options": ["question", "message", "address", "ticket"], "explanation": "Question (вопрос) — обращение с целью получить ответ.", }, + { + "level": "A1", "term": "bottle", "pos": "noun", "phonetic": "/ˈbɒt.əl/", + "translation": "бутылка", + "sentence": "Could you bring a ___ of cold water from the fridge?", + "options": ["bottle", "ticket", "weather", "message"], + "explanation": "Bottle (бутылка) — емкость для хранения напитков и жидкостей.", + }, + { + "level": "A1", "term": "island", "pos": "noun", "phonetic": "/ˈaɪ.lənd/", + "translation": "остров", + "sentence": "They spent their holiday on a small Greek ___.", + "options": ["island", "village", "airport", "station"], + "explanation": "Island (остров) — участок суши, окруженный водой.", + }, + { + "level": "A1", "term": "pocket", "pos": "noun", "phonetic": "/ˈpɒk.ɪt/", + "translation": "карман", + "sentence": "He put his keys and wallet into his jacket ___.", + "options": ["pocket", "package", "bottle", "uniform"], + "explanation": "Pocket (карман) — часть одежды в виде мешочка для мелких вещей.", + }, + { + "level": "A1", "term": "mirror", "pos": "noun", "phonetic": "/ˈmɪr.ər/", + "translation": "зеркало", + "sentence": "She looked at herself in the bathroom ___.", + "options": ["mirror", "window", "picture", "corner"], + "explanation": "Mirror (зеркало) — стеклянная поверхность, отражающая изображение.", + }, + { + "level": "A1", "term": "market", "pos": "noun", "phonetic": "/ˈmɑː.kɪt/", + "translation": "рынок, базар", + "sentence": "We buy fresh fruit every Saturday at the local ___.", + "options": ["market", "hospital", "station", "library"], + "explanation": "Market (рынок) — место торговли свежими продуктами и вещами.", + }, + { + "level": "A1", "term": "bridge", "pos": "noun", "phonetic": "/brɪdʒ/", + "translation": "мост", + "sentence": "The old stone ___ crosses the wide river.", + "options": ["bridge", "village", "island", "corner"], + "explanation": "Bridge (мост) — сооружение для перехода через реку или дорогу.", + }, + { + "level": "A1", "term": "corner", "pos": "noun", "phonetic": "/ˈkɔː.nər/", + "translation": "угол", + "sentence": "There is a small bakery on the ___ of our street.", + "options": ["corner", "mirror", "bottle", "pocket"], + "explanation": "Corner (угол) — место пересечения двух стен или улиц.", + }, + { + "level": "A1", "term": "cloud", "pos": "noun", "phonetic": "/klaʊd/", + "translation": "облако, туча", + "sentence": "Look at that dark ___ in the sky; it will rain soon.", + "options": ["cloud", "weather", "season", "island"], + "explanation": "Cloud (облако, туча) — скопление водяных паров на небе.", + }, + { + "level": "A1", "term": "floor", "pos": "noun", "phonetic": "/flɔːr/", + "translation": "пол, этаж", + "sentence": "Our new apartment is on the third ___.", + "options": ["floor", "corner", "market", "bridge"], + "explanation": "Floor (пол, этаж) — ярус в здании или нижняя поверхность комнаты.", + }, + { + "level": "A1", "term": "laugh", "pos": "verb", "phonetic": "/lɑːf/", + "translation": "смеяться", + "sentence": "The funny comedy movie made everybody ___.", + "options": ["laugh", "listen", "borrow", "decide"], + "explanation": "Laugh (смеяться) — выражать радость или веселье характерными звуками.", + }, + { + "level": "A1", "term": "shout", "pos": "verb", "phonetic": "/ʃaʊt/", + "translation": "кричать", + "sentence": "Please do not ___ inside the quiet library.", + "options": ["shout", "whisper", "invite", "repeat"], + "explanation": "Shout (кричать) — издавать громкий звук голосом.", + }, + { + "level": "A1", "term": "wash", "pos": "verb", "phonetic": "/wɒʃ/", + "translation": "мыть, стирать", + "sentence": "Remember to ___ your hands before having dinner.", + "options": ["wash", "borrow", "travel", "listen"], + "explanation": "Wash (мыть) — очищать водой с мылом.", + }, + { + "level": "A1", "term": "catch", "pos": "verb", "phonetic": "/kætʃ/", + "translation": "ловить, успеть (на транспорт)", + "sentence": "We must run quickly to ___ the morning bus.", + "options": ["catch", "throw", "decide", "invite"], + "explanation": "Catch (ловить, успевать) — поймать предмет или успеть сесть на транспорт.", + }, + { + "level": "A1", "term": "throw", "pos": "verb", "phonetic": "/θrəʊ/", + "translation": "бросать, кидать", + "sentence": "He tried to ___ the ball over the tall wall.", + "options": ["throw", "catch", "wash", "borrow"], + "explanation": "Throw (бросать) — метать предмет движением руки.", + }, + { + "level": "A1", "term": "build", "pos": "verb", "phonetic": "/bɪld/", + "translation": "строить", + "sentence": "They plan to ___ a new school in our neighborhood.", + "options": ["build", "borrow", "destroy", "invite"], + "explanation": "Build (строить) — возводить здание или конструкцию.", + }, + { + "level": "A1", "term": "climb", "pos": "verb", "phonetic": "/klaɪm/", + "translation": "взбираться, карабкаться", + "sentence": "Children love to ___ the big trees in the park.", + "options": ["climb", "throw", "catch", "repeat"], + "explanation": "Climb (взбираться) — подниматься вверх, цепляясь руками и ногами.", + }, + { + "level": "A1", "term": "heavy", "pos": "adjective", "phonetic": "/ˈhev.i/", + "translation": "тяжелый", + "sentence": "This suitcase is too ___ for me to carry alone.", + "options": ["heavy", "light", "clean", "simple"], + "explanation": "Heavy (тяжелый) — имеющий большой вес.", + }, + { + "level": "A1", "term": "narrow", "pos": "adjective", "phonetic": "/ˈnær.əʊ/", + "translation": "узкий", + "sentence": "The car could not drive down the ___ street.", + "options": ["narrow", "wide", "expensive", "famous"], + "explanation": "Narrow (узкий) — имеющий небольшую ширину.", + }, + { + "level": "A1", "term": "empty", "pos": "adjective", "phonetic": "/ˈemp.ti/", + "translation": "пустой", + "sentence": "The fuel tank is completely ___, so we must stop.", + "options": ["empty", "heavy", "healthy", "boring"], + "explanation": "Empty (пустой) — не содержащий ничего внутри.", + }, + { + "level": "A1", "term": "hungry", "pos": "adjective", "phonetic": "/ˈhʌŋ.ɡri/", + "translation": "голодный", + "sentence": "After the long walk in the forest, we were very ___.", + "options": ["hungry", "thirsty", "careful", "dangerous"], + "explanation": "Hungry (голодный) — испытывающий потребность в еде.", + }, + { + "level": "A1", "term": "thirsty", "pos": "adjective", "phonetic": "/ˈθɜː.sti/", + "translation": "испытывающий жажду", + "sentence": "Drink some water if you feel ___ after running.", + "options": ["thirsty", "hungry", "empty", "heavy"], + "explanation": "Thirsty (испытывающий жажду) — желающий пить воду.", + }, + { + "level": "A1", "term": "quiet", "pos": "adjective", "phonetic": "/ˈkwaɪ.ət/", + "translation": "тихий, спокойный", + "sentence": "This is a very ___ library where people study peacefully.", + "options": ["quiet", "loud", "dangerous", "careful"], + "explanation": "Quiet (тихий) — не производящий шума, спокойный.", + }, + { + "level": "A1", "term": "loud", "pos": "adjective", "phonetic": "/laʊd/", + "translation": "громкий, шумный", + "sentence": "The music was so ___ that I could not hear your voice.", + "options": ["loud", "quiet", "simple", "clean"], + "explanation": "Loud (громкий) — обладающий большой силой звука.", + }, + { + "level": "A1", "term": "clever", "pos": "adjective", "phonetic": "/ˈklev.ər/", + "translation": "умный, сообразительный", + "sentence": "She found a very ___ solution to that difficult riddle.", + "options": ["clever", "wrong", "boring", "dangerous"], + "explanation": "Clever (сообразительный, умный) — быстро понимающий и находчивый.", + }, + { + "level": "A1", "term": "blanket", "pos": "noun", "phonetic": "/ˈblæŋ.kɪt/", + "translation": "одеяло, плед", + "sentence": "It was cold, so she put a warm ___ over the bed.", + "options": ["blanket", "package", "mirror", "pocket"], + "explanation": "Blanket (одеяло, плед) — теплое шерстяное или хлопковое покрывало.", + }, # ========================================================================= # LEVEL A2 (Elementary: Practical everyday conversation, work, actions) @@ -587,6 +762,181 @@ CURATED_CEFR_QUESTIONS: List[Dict[str, Any]] = [ "options": ["opinion", "condition", "solution", "reason"], "explanation": "Opinion (мнение) — личная оценка или суждение о предмете.", }, + { + "level": "A2", "term": "accident", "pos": "noun", "phonetic": "/ˈæk.sɪ.dənt/", + "translation": "авария, несчастный случай", + "sentence": "He had a minor car ___ because of the slippery road.", + "options": ["accident", "condition", "flight", "habit"], + "explanation": "Accident (авария, несчастный случай) — непредвиденное происшествие с ущербом.", + }, + { + "level": "A2", "term": "advice", "pos": "noun", "phonetic": "/ədˈvaɪs/", + "translation": "совет", + "sentence": "My teacher gave me great ___ on how to prepare for exams.", + "options": ["advice", "opinion", "reason", "culture"], + "explanation": "Advice (совет) — полезная рекомендация или наставление.", + }, + { + "level": "A2", "term": "compete", "pos": "verb", "phonetic": "/kəmˈpiːt/", + "translation": "соревноваться, состязаться", + "sentence": "Athletes from thirty countries will ___ in the tournament.", + "options": ["compete", "compare", "contain", "succeed"], + "explanation": "Compete (соревноваться) — стремиться к победе в состязании с другими.", + }, + { + "level": "A2", "term": "customer", "pos": "noun", "phonetic": "/ˈkʌs.tə.mər/", + "translation": "клиент, покупатель", + "sentence": "The shop assistant was polite and helped every ___.", + "options": ["customer", "passenger", "crowd", "flight"], + "explanation": "Customer (клиент, покупатель) — лицо, приобретающее товары или услуги.", + }, + { + "level": "A2", "term": "earn", "pos": "verb", "phonetic": "/ɜːn/", + "translation": "зарабатывать", + "sentence": "He works overtime to ___ enough money for a new laptop.", + "options": ["earn", "afford", "borrow", "spend"], + "explanation": "Earn (зарабатывать) — получать деньги за выполняемую работу.", + }, + { + "level": "A2", "term": "equipment", "pos": "noun", "phonetic": "/ɪˈkwɪp.mənt/", + "translation": "оборудование, снаряжение", + "sentence": "Make sure you bring the right sports ___ for camping.", + "options": ["equipment", "environment", "culture", "condition"], + "explanation": "Equipment (оборудование, снаряжение) — необходимые инструменты или вещи для занятия.", + }, + { + "level": "A2", "term": "patient", "pos": "adjective", "phonetic": "/ˈpeɪ.ʃənt/", + "translation": "терпеливый", + "sentence": "Teachers need to be very ___ with young beginner students.", + "options": ["patient", "careful", "dangerous", "healthy"], + "explanation": "Patient (терпеливый) — способный спокойно переносить трудности или ожидание.", + }, + { + "level": "A2", "term": "pollute", "pos": "verb", "phonetic": "/pəˈluːt/", + "translation": "загрязнять", + "sentence": "Factories should not ___ the air with toxic smoke.", + "options": ["pollute", "improve", "protect", "contain"], + "explanation": "Pollute (загрязнять) — делать окружающую среду грязной или вредной.", + }, + { + "level": "A2", "term": "proud", "pos": "adjective", "phonetic": "/praʊd/", + "translation": "гордый (испытывающий гордость)", + "sentence": "Her parents were very ___ when she won first prize.", + "options": ["proud", "disappointed", "jealous", "afraid"], + "explanation": "Proud (гордый) — испытывающий чувство глубокого удовлетворения за успех.", + }, + { + "level": "A2", "term": "refuse", "pos": "verb", "phonetic": "/rɪˈfjuːz/", + "translation": "отказываться", + "sentence": "I had to ___ his invitation because of urgent work.", + "options": ["refuse", "accept", "decide", "suggest"], + "explanation": "Refuse (отказываться) — отклонить предложение или просьбу.", + }, + { + "level": "A2", "term": "repair", "pos": "verb", "phonetic": "/rɪˈpeər/", + "translation": "чинить, ремонтировать", + "sentence": "Can you help me ___ this broken bicycle wheel?", + "options": ["repair", "damage", "destroy", "improve"], + "explanation": "Repair (ремонтировать) — восстанавливать поврежденный предмет.", + }, + { + "level": "A2", "term": "salary", "pos": "noun", "phonetic": "/ˈsæl.ər.i/", + "translation": "заработная плата", + "sentence": "They offered him a higher monthly ___ in the new position.", + "options": ["salary", "ticket", "package", "damage"], + "explanation": "Salary (зарплата) — регулярная оплата труда работника.", + }, + { + "level": "A2", "term": "separate", "pos": "adjective", "phonetic": "/ˈsep.ər.ət/", + "translation": "отдельный, раздельный", + "sentence": "The children sleep in ___ rooms on the second floor.", + "options": ["separate", "connected", "complete", "necessary"], + "explanation": "Separate (отдельный) — находящийся отдельно от других, не соединенный.", + }, + { + "level": "A2", "term": "strange", "pos": "adjective", "phonetic": "/streɪndʒ/", + "translation": "странный, необычный", + "sentence": "I heard a ___ noise coming from the basement at midnight.", + "options": ["strange", "simple", "famous", "boring"], + "explanation": "Strange (странный) — непривычный, вызывающий удивление или недоумение.", + }, + { + "level": "A2", "term": "traffic", "pos": "noun", "phonetic": "/ˈtræf.ɪk/", + "translation": "дорожное движение", + "sentence": "There was heavy ___ on the highway, so we arrived late.", + "options": ["traffic", "flight", "crowd", "weather"], + "explanation": "Traffic (дорожное движение) — поток транспортных средств на дороге.", + }, + { + "level": "A2", "term": "urgent", "pos": "adjective", "phonetic": "/ˈɜː.dʒənt/", + "translation": "срочный, неотложный", + "sentence": "The doctor received an ___ phone call from the clinic.", + "options": ["urgent", "patient", "boring", "slow"], + "explanation": "Urgent (срочный) — требующий немедленного внимания или действия.", + }, + { + "level": "A2", "term": "warn", "pos": "verb", "phonetic": "/wɔːn/", + "translation": "предупреждать", + "sentence": "The guard tried to ___ the tourists about the thin ice.", + "options": ["warn", "invite", "explain", "interrupt"], + "explanation": "Warn (предупреждать) — заранее извещать об опасности или неприятности.", + }, + { + "level": "A2", "term": "attend", "pos": "verb", "phonetic": "/əˈtend/", + "translation": "посещать, присутствовать", + "sentence": "All staff members are required to ___ the weekly meeting.", + "options": ["attend", "avoid", "expect", "improve"], + "explanation": "Attend (посещать) — присутствовать на собрании, уроке или мероприятии.", + }, + { + "level": "A2", "term": "benefit", "pos": "noun", "phonetic": "/ˈben.ɪ.fɪt/", + "translation": "польза, выгода, преимущество", + "sentence": "Regular exercise offers an enormous health ___ for everyone.", + "options": ["benefit", "damage", "condition", "accident"], + "explanation": "Benefit (польза, выгода) — положительный эффект или преимущество.", + }, + { + "level": "A2", "term": "celebrate", "pos": "verb", "phonetic": "/ˈsel.ə.breɪt/", + "translation": "праздновать, отмечать", + "sentence": "We gathered together to ___ her graduation with a cake.", + "options": ["celebrate", "compete", "describe", "separate"], + "explanation": "Celebrate (праздновать) — отмечать радостное событие или праздник.", + }, + { + "level": "A2", "term": "protect", "pos": "verb", "phonetic": "/prəˈtekt/", + "translation": "защищать, оберегать", + "sentence": "Sunglasses ___ your eyes from strong ultraviolet rays.", + "options": ["protect", "pollute", "damage", "interrupt"], + "explanation": "Protect (защищать) — предохранять от вреда, опасности или повреждений.", + }, + { + "level": "A2", "term": "remain", "pos": "verb", "phonetic": "/rɪˈmeɪn/", + "translation": "оставаться", + "sentence": "Please ___ seated until the aircraft comes to a stop.", + "options": ["remain", "depart", "change", "connect"], + "explanation": "Remain (оставаться) — продолжать находиться в прежнем месте или состоянии.", + }, + { + "level": "A2", "term": "suggest", "pos": "verb", "phonetic": "/səˈdʒest/", + "translation": "предлагать, советовать", + "sentence": "Can you ___ a good Italian restaurant downtown?", + "options": ["suggest", "refuse", "warn", "guess"], + "explanation": "Suggest (предлагать) — высказать идею, рекомендацию или вариант выбора.", + }, + { + "level": "A2", "term": "transport", "pos": "noun", "phonetic": "/ˈtræn.spɔːt/", + "translation": "транспорт", + "sentence": "Public ___ in the capital is fast, clean, and reliable.", + "options": ["transport", "traffic", "flight", "journey"], + "explanation": "Transport (транспорт) — средства перевозки людей и грузов.", + }, + { + "level": "A2", "term": "wealthy", "pos": "adjective", "phonetic": "/ˈwel.θi/", + "translation": "богатый, состоятельный", + "sentence": "He comes from a ___ family that owns several businesses.", + "options": ["wealthy", "expensive", "proud", "patient"], + "explanation": "Wealthy (состоятельный) — обладающий большим богатством или имуществом.", + }, # ========================================================================= # LEVEL B1 (Intermediate: Abstract concepts, professional life, reasoning) @@ -871,6 +1221,181 @@ CURATED_CEFR_QUESTIONS: List[Dict[str, Any]] = [ "options": ["reasonable", "jealous", "disappointed", "temporary"], "explanation": "Reasonable (разумный, приемлемый) — адекватный, справедливый по цене или требованиям.", }, + { + "level": "B1", "term": "accomplish", "pos": "verb", "phonetic": "/əˈkʌm.plɪʃ/", + "translation": "выполнить, достичь", + "sentence": "With proper focus, you can ___ your goals ahead of schedule.", + "options": ["accomplish", "hesitate", "interrupt", "negotiate"], + "explanation": "Accomplish (достичь, выполнить) — успешно завершить начатое дело или цель.", + }, + { + "level": "B1", "term": "accurate", "pos": "adjective", "phonetic": "/ˈæk.jə.rət/", + "translation": "точный, правильный", + "sentence": "The financial report must provide completely ___ figures.", + "options": ["accurate", "vulnerable", "sensible", "fluent"], + "explanation": "Accurate (точный) — соответствующий истине, без ошибок.", + }, + { + "level": "B1", "term": "adequate", "pos": "adjective", "phonetic": "/ˈæd.ə.kwət/", + "translation": "достаточный, соответствующий", + "sentence": "Make sure you get ___ sleep before running the marathon.", + "options": ["adequate", "temporary", "curious", "jealous"], + "explanation": "Adequate (достаточный) — удовлетворяющий определенным требованиям или норме.", + }, + { + "level": "B1", "term": "anxiety", "pos": "noun", "phonetic": "/æŋˈzaɪ.ə.ti/", + "translation": "тревожность, беспокойство", + "sentence": "Meditation helps reduce stress and chronic ___ significantly.", + "options": ["anxiety", "consequence", "decision", "mystery"], + "explanation": "Anxiety (тревожность) — эмоциональное состояние сильного внутреннего беспокойства.", + }, + { + "level": "B1", "term": "apparent", "pos": "adjective", "phonetic": "/əˈpær.ənt/", + "translation": "очевидный, явный", + "sentence": "It soon became ___ that they had made a serious error.", + "options": ["apparent", "valuable", "ambitious", "fluent"], + "explanation": "Apparent (очевидный) — легко различимый, понятный без объяснений.", + }, + { + "level": "B1", "term": "circumstance", "pos": "noun", "phonetic": "/ˈsɜː.kəm.stɑːns/", + "translation": "обстоятельство", + "sentence": "Under no ___ should you share your banking passwords.", + "options": ["circumstance", "community", "requirement", "behavior"], + "explanation": "Circumstance (обстоятельство) — условие или событие, сопровождающее ситуацию.", + }, + { + "level": "B1", "term": "convince", "pos": "verb", "phonetic": "/kənˈvɪns/", + "translation": "убеждать", + "sentence": "He tried hard to ___ the committee of the plan's feasibility.", + "options": ["convince", "interrupt", "hesitate", "maintain"], + "explanation": "Convince (убеждать) — склонять к согласию аргументами и доводами.", + }, + { + "level": "B1", "term": "critical", "pos": "adjective", "phonetic": "/ˈkrɪt.ɪ.kəl/", + "translation": "критический, решающий", + "sentence": "The first few hours after an injury are of ___ importance.", + "options": ["critical", "fluent", "generous", "suitable"], + "explanation": "Critical (критический, решающий) — имеющий решающее значение для исхода дела.", + }, + { + "level": "B1", "term": "deny", "pos": "verb", "phonetic": "/dɪˈnaɪ/", + "translation": "отрицать", + "sentence": "The suspect continued to ___ any involvement in the theft.", + "options": ["deny", "persuade", "interrupt", "encourage"], + "explanation": "Deny (отрицать) — отвергать утверждение или обвинение.", + }, + { + "level": "B1", "term": "emphasize", "pos": "verb", "phonetic": "/ˈem.fə.saɪz/", + "translation": "подчеркивать, акцентировать", + "sentence": "The teacher wished to ___ the importance of consistent practice.", + "options": ["emphasize", "hesitate", "negotiate", "consider"], + "explanation": "Emphasize (подчеркивать) — обращать особое внимание на что-то.", + }, + { + "level": "B1", "term": "frequent", "pos": "adjective", "phonetic": "/ˈfriː.kwənt/", + "translation": "частый", + "sentence": "Bus delays are a ___ problem during snowy winter months.", + "options": ["frequent", "temporary", "valuable", "vulnerable"], + "explanation": "Frequent (частый) — повторяющийся через короткие промежутки времени.", + }, + { + "level": "B1", "term": "genuine", "pos": "adjective", "phonetic": "/ˈdʒen.ju.ɪn/", + "translation": "подлинный, искренний", + "sentence": "She showed ___ interest in learning about foreign cultures.", + "options": ["genuine", "jealous", "disappointed", "sensible"], + "explanation": "Genuine (подлинный, искренний) — неподдельный, настоящий.", + }, + { + "level": "B1", "term": "intend", "pos": "verb", "phonetic": "/ɪnˈtend/", + "translation": "намереваться, планировать", + "sentence": "Do you ___ to stay here permanently or return home soon?", + "options": ["intend", "hesitate", "interrupt", "negotiate"], + "explanation": "Intend (намереваться) — иметь твердый план или намерение что-то совершить.", + }, + { + "level": "B1", "term": "obtain", "pos": "verb", "phonetic": "/əbˈteɪn/", + "translation": "получать, приобретать", + "sentence": "You must ___ an entry visa before traveling abroad.", + "options": ["obtain", "deny", "hesitate", "disappoint"], + "explanation": "Obtain (получать) — приобретать или добиваться обладания чем-либо.", + }, + { + "level": "B1", "term": "persistent", "pos": "adjective", "phonetic": "/pəˈsɪs.tənt/", + "translation": "настойчивый, упорный", + "sentence": "Through ___ hard work, she eventually mastered fluent English.", + "options": ["persistent", "temporary", "vulnerable", "jealous"], + "explanation": "Persistent (упорный, настойчивый) — продолжающий действовать, невзирая на препятствия.", + }, + { + "level": "B1", "term": "predict", "pos": "verb", "phonetic": "/prɪˈdɪkt/", + "translation": "предсказывать, прогнозировать", + "sentence": "Economists struggle to ___ market swings with high accuracy.", + "options": ["predict", "persuade", "interrupt", "maintain"], + "explanation": "Predict (предсказывать) — делать прогноз о будущем на основе фактов.", + }, + { + "level": "B1", "term": "sufficient", "pos": "adjective", "phonetic": "/səˈfɪʃ.ənt/", + "translation": "достаточный", + "sentence": "We have ___ evidence to prove that the hypothesis is sound.", + "options": ["sufficient", "curious", "jealous", "ambitious"], + "explanation": "Sufficient (достаточный) — в том количестве, которое требуется для цели.", + }, + { + "level": "B1", "term": "threaten", "pos": "verb", "phonetic": "/ˈθret.ən/", + "translation": "угрожать", + "sentence": "Rising sea levels ___ low-lying coastal towns worldwide.", + "options": ["threaten", "encourage", "convince", "accomplish"], + "explanation": "Threaten (угрожать) — представлять потенциальную опасность или риск.", + }, + { + "level": "B1", "term": "approve", "pos": "verb", "phonetic": "/əˈpruːv/", + "translation": "одобрять, утверждать", + "sentence": "The board of directors is expected to ___ the budget tomorrow.", + "options": ["approve", "deny", "hesitate", "interrupt"], + "explanation": "Approve (одобрять) — официально соглашаться или санкционировать.", + }, + { + "level": "B1", "term": "broadcast", "pos": "verb", "phonetic": "/ˈbrɔːd.kɑːst/", + "translation": "вещать, транслировать", + "sentence": "The radio station will ___ the live championship match.", + "options": ["broadcast", "negotiate", "maintain", "interrupt"], + "explanation": "Broadcast (вещать) — передавать информацию по радио, телевидению или сети.", + }, + { + "level": "B1", "term": "cooperate", "pos": "verb", "phonetic": "/kəʊˈɒp.ər.eɪt/", + "translation": "сотрудничать, взаимодействовать", + "sentence": "The two research teams decided to ___ on the clinical study.", + "options": ["cooperate", "compete", "hesitate", "deny"], + "explanation": "Cooperate (сотрудничать) — совместно трудиться ради общей цели.", + }, + { + "level": "B1", "term": "distinguish", "pos": "verb", "phonetic": "/dɪˈstɪŋ.ɡwɪʃ/", + "translation": "различать, отличать", + "sentence": "It is difficult to ___ the two identical twin sisters.", + "options": ["distinguish", "convince", "interrupt", "persuade"], + "explanation": "Distinguish (различать) — замечать разницу между объектами.", + }, + { + "level": "B1", "term": "eliminate", "pos": "verb", "phonetic": "/iˈlɪm.ɪ.neɪt/", + "translation": "устранять, ликвидировать", + "sentence": "Automation aims to ___ repetitive manual clerical tasks.", + "options": ["eliminate", "maintain", "hesitate", "develop"], + "explanation": "Eliminate (устранять) — полностью удалять или избавляться от чего-либо.", + }, + { + "level": "B1", "term": "guarantee", "pos": "verb", "phonetic": "/ˌɡær.ənˈtiː/", + "translation": "гарантировать", + "sentence": "We cannot ___ that the shipment will arrive by tomorrow.", + "options": ["guarantee", "deny", "hesitate", "threaten"], + "explanation": "Guarantee (гарантировать) — давать твердое обещание или заверение.", + }, + { + "level": "B1", "term": "identify", "pos": "verb", "phonetic": "/aɪˈden.tɪ.faɪ/", + "translation": "опознавать, определять", + "sentence": "The system uses biometric scans to ___ authorized employees.", + "options": ["identify", "deny", "hesitate", "threaten"], + "explanation": "Identify (опознавать) — устанавливать точную личность или природу объекта.", + }, # ========================================================================= # LEVEL B2 (Upper-Intermediate: Academic, nuanced, debate, precision) @@ -1155,6 +1680,181 @@ CURATED_CEFR_QUESTIONS: List[Dict[str, Any]] = [ "options": ["plausible", "reluctant", "arbitrary", "preliminary"], "explanation": "Plausible (правдоподобный) — заслуживающий доверия, вероятный.", }, + { + "level": "B2", "term": "deteriorate", "pos": "verb", "phonetic": "/dɪˈtɪə.ri.ə.reɪt/", + "translation": "ухудшаться", + "sentence": "Without prompt maintenance, old bridges rapidly ___.", + "options": ["deteriorate", "enhance", "facilitate", "integrate"], + "explanation": "Deteriorate (ухудшаться) — становиться хуже по качеству или состоянию.", + }, + { + "level": "B2", "term": "discrepancy", "pos": "noun", "phonetic": "/dɪˈskrep.ən.si/", + "translation": "расхождение, несоответствие", + "sentence": "The audit revealed an unexplained ___ in the quarterly ledgers.", + "options": ["discrepancy", "consensus", "compromise", "ambiguity"], + "explanation": "Discrepancy (несоответствие) — различие между данными, которые должны совпадать.", + }, + { + "level": "B2", "term": "eligible", "pos": "adjective", "phonetic": "/ˈel.ɪ.dʒə.bəl/", + "translation": "имеющий право, подходящий", + "sentence": "Only full-time students are ___ to apply for the grant.", + "options": ["eligible", "reluctant", "arbitrary", "plausible"], + "explanation": "Eligible (имеющий право) — отвечающий установленным критериям для участия.", + }, + { + "level": "B2", "term": "lucrative", "pos": "adjective", "phonetic": "/ˈluː.krə.tɪv/", + "translation": "прибыльный, доходный", + "sentence": "The entrepreneur signed a remarkably ___ contract overseas.", + "options": ["lucrative", "profound", "vulnerable", "preliminary"], + "explanation": "Lucrative (прибыльный) — приносящий большую финансовую выгоду.", + }, + { + "level": "B2", "term": "meticulous", "pos": "adjective", "phonetic": "/məˈtɪk.jə.ləs/", + "translation": "дотошный, скрупулезный", + "sentence": "The researcher kept ___ notes during every phase of the trial.", + "options": ["meticulous", "random", "arbitrary", "contradictory"], + "explanation": "Meticulous (скрупулезный) — проявляющий исключительное внимание к каждой детали.", + }, + { + "level": "B2", "term": "novel", "pos": "adjective", "phonetic": "/ˈnɒv.əl/", + "translation": "новаторский, оригинальный", + "sentence": "The scientists proposed a ___ approach to carbon sequestration.", + "options": ["novel", "coherent", "inevitable", "resilient"], + "explanation": "Novel (новаторский) — принципиально новый, ранее не применявшийся.", + }, + { + "level": "B2", "term": "relentless", "pos": "adjective", "phonetic": "/rɪˈlent.ləs/", + "translation": "неумолимый, непрекращающийся", + "sentence": "Success required years of ___ discipline and daily focus.", + "options": ["relentless", "reluctant", "arbitrary", "preliminary"], + "explanation": "Relentless (непрекращающийся) — продолжающийся с неослабевающей силой.", + }, + { + "level": "B2", "term": "scrutinize", "pos": "verb", "phonetic": "/ˈskruː.tɪ.naɪz/", + "translation": "тщательно исследовать, изучать", + "sentence": "Regulators will closely ___ the proposed merger of both banks.", + "options": ["scrutinize", "diminish", "undermine", "fluctuate"], + "explanation": "Scrutinize (тщательно исследовать) — подвергать глубокому пристальному анализу.", + }, + { + "level": "B2", "term": "subtle", "pos": "adjective", "phonetic": "/ˈsʌt.əl/", + "translation": "тонкий, едва уловимый", + "sentence": "There is a ___ nuance between these two grammatical forms.", + "options": ["subtle", "substantial", "fundamental", "explicit"], + "explanation": "Subtle (тонкий, неуловимый) — едва заметный, требующий чуткости для восприятия.", + }, + { + "level": "B2", "term": "tangible", "pos": "adjective", "phonetic": "/ˈtæn.dʒə.bəl/", + "translation": "осязаемый, реальный", + "sentence": "The new training program yielded ___ improvements in morale.", + "options": ["tangible", "ambiguous", "arbitrary", "hierarchical"], + "explanation": "Tangible (осязаемый) — конкретный, явный, доступный для непосредственной оценки.", + }, + { + "level": "B2", "term": "ubiquitous", "pos": "adjective", "phonetic": "/juːˈbɪk.wɪ.təs/", + "translation": "вездесущий, повсеместный", + "sentence": "Smartphones have become ___ in modern urban society.", + "options": ["ubiquitous", "discrete", "reluctant", "preliminary"], + "explanation": "Ubiquitous (вездесущий) — встречающийся повсюду одновременно.", + }, + { + "level": "B2", "term": "unprecedented", "pos": "adjective", "phonetic": "/ʌnˈpres.ɪ.den.tɪd/", + "translation": "беспрецедентный", + "sentence": "The pandemic caused an ___ shock to global supply chains.", + "options": ["unprecedented", "coherent", "contradictory", "plausible"], + "explanation": "Unprecedented (беспрецедентный) — не имевший аналогов в прошлом.", + }, + { + "level": "B2", "term": "viable", "pos": "adjective", "phonetic": "/ˈvaɪ.ə.bəl/", + "translation": "жизнеспособный", + "sentence": "Solar power has become a commercially ___ source of energy.", + "options": ["viable", "arbitrary", "reluctant", "inevitable"], + "explanation": "Viable (жизнеспособный) — способный успешно функционировать и развиваться.", + }, + { + "level": "B2", "term": "withstand", "pos": "verb", "phonetic": "/wɪðˈstænd/", + "translation": "выдерживать, противостоять", + "sentence": "The newly engineered structure can ___ severe earthquakes.", + "options": ["withstand", "diminish", "undermine", "deteriorate"], + "explanation": "Withstand (выдерживать) — стойко сопротивляться разрушительному воздействию.", + }, + { + "level": "B2", "term": "yield", "pos": "verb", "phonetic": "/jiːld/", + "translation": "приносить (результат, доход)", + "sentence": "Long-term compounding investments ___ significant dividends.", + "options": ["yield", "undermine", "deduce", "scrutinize"], + "explanation": "Yield (приносить плоды) — давать прибыль, урожай или практический результат.", + }, + { + "level": "B2", "term": "zealous", "pos": "adjective", "phonetic": "/ˈzel.əs/", + "translation": "ревностный, ярый", + "sentence": "The attorney was a ___ defender of fundamental civil liberties.", + "options": ["zealous", "complacent", "reluctant", "arbitrary"], + "explanation": "Zealous (ревностный) — проявляющий горячую увлеченность и преданность делу.", + }, + { + "level": "B2", "term": "ambiguous", "pos": "adjective", "phonetic": "/æmˈbɪɡ.ju.əs/", + "translation": "двусмысленный, неясный", + "sentence": "The legal wording of the agreement was intentionally ___.", + "options": ["ambiguous", "explicit", "coherent", "tangible"], + "explanation": "Ambiguous (двусмысленный) — допускающий двоякое или нечеткое толкование.", + }, + { + "level": "B2", "term": "benevolent", "pos": "adjective", "phonetic": "/bəˈnev.əl.ənt/", + "translation": "благожелательный, великодушный", + "sentence": "The charity relies on gifts from ___ private donors.", + "options": ["benevolent", "relentless", "arbitrary", "hierarchical"], + "explanation": "Benevolent (благожелательный) — настроенный доброжелательно, стремящийся делать добро.", + }, + { + "level": "B2", "term": "complacent", "pos": "adjective", "phonetic": "/kəmˈpleɪ.sənt/", + "translation": "самодовольный, беспечный", + "sentence": "After consecutive victories, the team grew dangerously ___.", + "options": ["complacent", "meticulous", "diligent", "resilient"], + "explanation": "Complacent (самодовольный) — чрезмерно удовлетворенный собой и потерявший бдительность.", + }, + { + "level": "B2", "term": "diligent", "pos": "adjective", "phonetic": "/ˈdɪl.ɪ.dʒənt/", + "translation": "прилежный, старательный", + "sentence": "Her ___ pursuit of scientific inquiry earned international acclaim.", + "options": ["diligent", "arbitrary", "reluctant", "subsequent"], + "explanation": "Diligent (прилежный) — усердно и добросовестно выполняющий работу.", + }, + { + "level": "B2", "term": "ephemeral", "pos": "adjective", "phonetic": "/ɪˈfem.ər.əl/", + "translation": "мимолетный, недолговечный", + "sentence": "Social media fame is often ___ and vanishes overnight.", + "options": ["ephemeral", "substantial", "fundamental", "comprehensive"], + "explanation": "Ephemeral (мимолетный) — длящийся лишь краткий миг.", + }, + { + "level": "B2", "term": "formidable", "pos": "adjective", "phonetic": "/ˈfɔː.mɪ.də.bəl/", + "translation": "грозный, внушительный", + "sentence": "The defending chess champion represents a ___ adversary.", + "options": ["formidable", "arbitrary", "discrete", "vulnerable"], + "explanation": "Formidable (грозный, внушительный) — внушающий уважение или страх своей мощью.", + }, + { + "level": "B2", "term": "gregarious", "pos": "adjective", "phonetic": "/ɡrɪˈɡeə.ri.əs/", + "translation": "общительный, стадный", + "sentence": "Dolphins are highly social and ___ marine mammals.", + "options": ["gregarious", "reluctant", "arbitrary", "preliminary"], + "explanation": "Gregarious (общительный) — любящий находиться среди сородичей или людей.", + }, + { + "level": "B2", "term": "homogeneous", "pos": "adjective", "phonetic": "/ˌhɒm.əˈdʒiː.ni.əs/", + "translation": "однородный, гомогенный", + "sentence": "The modern metropolis is far from a culturally ___ enclave.", + "options": ["homogeneous", "contradictory", "arbitrary", "ubiquitous"], + "explanation": "Homogeneous (однородный) — состоящий из одинаковых по составу частей.", + }, + { + "level": "B2", "term": "innovative", "pos": "adjective", "phonetic": "/ˈɪn.ə.və.tɪv/", + "translation": "новаторский, передовой", + "sentence": "The startup patented an ___ algorithm for real-time translation.", + "options": ["innovative", "arbitrary", "complacent", "plausible"], + "explanation": "Innovative (новаторский) — использующий передовые оригинальные методы.", + }, ] @@ -1392,84 +2092,51 @@ def process_cefr_answer( ) # 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 + target_card_info = None + selected_card_info = None if not is_correct: # ===================================================================== - # AUTOMATED GAP INGESTION (Crucial Pedagogical Requirement) + # DUAL AUTOMATED GAP INGESTION (Crucial Pedagogical Requirement) + # 1. Correct target word -> goes 100% to vocabulary + # 2. Chosen wrong word -> also goes to vocabulary! # ===================================================================== - existing_card = conn.execute( - "SELECT * FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = ?", - (user_id, target_term.lower()), + target_card_info = _ingest_card_for_user( + conn, + user_id=user_id, + term=target_term, + translation=translation, + phonetic=phonetic, + pos=pos, + context_sentence=sentence_with_term, + explanation=explanation, + level=level, + tag_suffix="test-mistake", + custom_note=f"Верный ответ из теста уровня {level}", + ) + + # Ingest the erroneously selected option (the user's mistaken choice) + sel_row = conn.execute( + "SELECT term, translation, phonetic, pos FROM offline_dictionary WHERE term = ? COLLATE NOCASE", + (selected_clean,), ).fetchone() + sel_trans = sel_row["translation"] if sel_row else selected_clean + sel_phon = sel_row["phonetic"] if sel_row else "" + sel_pos = sel_row["pos"] if sel_row else "" - 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), - ) + selected_card_info = _ingest_card_for_user( + conn, + user_id=user_id, + term=selected_clean, + translation=sel_trans, + phonetic=sel_phon, + pos=sel_pos, + context_sentence=sentence_with_term, + explanation=f"Выбрано по ошибке в вопросе про '{target_term}'", + level=level, + tag_suffix="test-mistake-chosen", + custom_note=f"Ошибочно выбрано вместо '{target_term}' (тест {level})", + ) else: # On correct answer, if user has this card in library, record a positive review log existing_card = conn.execute( @@ -1485,19 +2152,213 @@ def process_cefr_answer( (user_id, existing_card["id"]), ) + # 3. Build rich options breakdown for ALL 4 options + try: + raw_options = json.loads(q_row["options_json"]) + except Exception: + raw_options = [target_term] + + options_breakdown = [] + for opt in raw_options: + opt_clean = opt.strip() + is_target = (opt_clean.lower() == target_term.lower()) + was_selected = (opt_clean.lower() == selected_clean.lower()) + + if is_target: + opt_trans = translation + opt_phon = phonetic + opt_pos = pos + else: + opt_row = conn.execute( + "SELECT term, translation, phonetic, pos FROM offline_dictionary WHERE term = ? COLLATE NOCASE", + (opt_clean,), + ).fetchone() + opt_trans = opt_row["translation"] if opt_row else opt_clean + opt_phon = opt_row["phonetic"] if opt_row else "" + opt_pos = opt_row["pos"] if opt_row else "" + + user_card = conn.execute( + "SELECT id, srs_state FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = ?", + (user_id, opt_clean.lower()), + ).fetchone() + + is_auto_added = False + if not is_correct and (is_target or was_selected): + is_auto_added = True + + options_breakdown.append({ + "term": opt_clean, + "translation": opt_trans, + "phonetic": opt_phon, + "pos": opt_pos, + "is_correct": is_target, + "was_selected": was_selected, + "in_library": bool(user_card) or is_auto_added, + "card_id": user_card["id"] if user_card else None, + "auto_added": is_auto_added, + }) + return { "correct": is_correct, "correct_option": target_term, + "selected_option": selected_clean, "phonetic": phonetic, "translation": translation, "explanation": explanation, + "card_created": target_card_info["card_created"] if target_card_info else False, + "card_updated": target_card_info["card_updated"] if target_card_info else False, + "card_id": target_card_info["card_id"] if target_card_info else None, + "card_term": target_term, + "selected_card_id": selected_card_info["card_id"] if selected_card_info else None, + "selected_card_term": selected_clean if not is_correct else None, + "selected_translation": selected_card_info["translation"] if selected_card_info else None, + "options_breakdown": options_breakdown, + } + + +def _ingest_card_for_user( + conn: sqlite3.Connection, + user_id: int, + term: str, + translation: str, + phonetic: str, + pos: str, + context_sentence: str, + explanation: str, + level: str, + tag_suffix: str = "test-mistake", + custom_note: str = "", +) -> Dict[str, Any]: + """ + Internal helper to insert or update a vocabulary card in 'learning' SRS state. + """ + level_tag = f"level-{level.lower()}" + col_name = f"Уровни CEFR ({level.upper()})" + term_clean = term.strip() + + existing_card = conn.execute( + "SELECT * FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = ?", + (user_id, term_clean.lower()), + ).fetchone() + + card_created = False + card_updated = False + card_id = None + + if not existing_card: + 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, + term_clean, + translation or term_clean, + context_sentence or "", + phonetic or "", + pos or "", + explanation or "", + f"{level_tag}, cefr-test, {tag_suffix}", + custom_note or f"Добавлено из теста уровня {level}", + col_name, + ), + ) + card_id = cur.lastrowid + card_created = True + else: + 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") + existing_tags.add(tag_suffix) + updated_tags = ", ".join(sorted(existing_tags)) + + updated_context = existing_card["context_sentence"] or "" + if context_sentence and context_sentence not in updated_context: + updated_context = f"{updated_context}\n\n{context_sentence}".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, card_id), + ) + card_updated = True + + # Log in srs_logs + if 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, card_id), + ) + + return { + "card_id": card_id, + "term": term_clean, + "translation": translation, "card_created": card_created, "card_updated": card_updated, - "card_id": target_card_id, - "card_term": target_term, } +def ingest_cefr_word( + conn: sqlite3.Connection, + user_id: int, + level: str, + term: str, + context_sentence: str = "", +) -> Dict[str, Any]: + """ + Manually ingests an unselected option word from a CEFR test into user's SRS deck. + """ + clean_term = term.strip() + clean_level = level.upper().strip() + + row = conn.execute( + "SELECT term, translation, phonetic, pos FROM offline_dictionary WHERE term = ? COLLATE NOCASE", + (clean_term,), + ).fetchone() + trans = row["translation"] if row else clean_term + phon = row["phonetic"] if row else "" + pos = row["pos"] if row else "" + + info = _ingest_card_for_user( + conn, + user_id=user_id, + term=clean_term, + translation=trans, + phonetic=phon, + pos=pos, + context_sentence=context_sentence, + explanation=f"Добавлено вручную из вариантов вопроса теста CEFR ({clean_level})", + level=clean_level, + tag_suffix="manual-test-add", + custom_note=f"Добавлено из вариантов вопроса теста {clean_level}", + ) + return { + "status": "ok", + "card_id": info["card_id"], + "term": clean_term, + "translation": trans, + "phonetic": phon, + } + + + def reset_user_cefr_level(conn: sqlite3.Connection, user_id: int, level: str) -> int: """ Resets progress for a specific CEFR level for user. diff --git a/app/main.py b/app/main.py index 862ebe7..074bb32 100644 --- a/app/main.py +++ b/app/main.py @@ -36,6 +36,7 @@ from app.cefr import ( get_cefr_quiz_batch, process_cefr_answer, reset_user_cefr_level, + ingest_cefr_word, ) from app.grammar import ( get_grammar_units_list, @@ -185,6 +186,13 @@ class CefrResetRequest(BaseModel): level: str +class CefrAddWordRequest(BaseModel): + level: str + term: str + context_sentence: Optional[str] = "" + + + class GrammarAnswerRequest(BaseModel): unit_id: int exercise_idx: int @@ -1466,6 +1474,19 @@ async def api_cefr_reset( return {"status": "ok", "level": req.level.upper(), "reset_count": deleted} +@app.post("/api/cefr/add-word") +async def api_cefr_add_word( + req: CefrAddWordRequest, + user: dict = Depends(require_user), +): + """ + Adds a word from a CEFR test options list directly into user's SRS active vocabulary. + """ + with get_db() as conn: + return ingest_cefr_word(conn, user["id"], req.level, req.term, req.context_sentence) + + + # --------------------------------------------------------------------------- # Task 16: Murphy Grammar Engine & Handwritten Journaling Endpoints # --------------------------------------------------------------------------- diff --git a/app/static/css/style.css b/app/static/css/style.css index 9008dd5..5679af3 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -4664,6 +4664,175 @@ body { font-size: 18px; } +/* Dual Gap Ingestion (Correct + Chosen Mistake) */ +.cefr-dual-ingested-box { + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.25); + border-radius: var(--radius-md); + padding: 12px 14px; + margin-top: 10px; + animation: fadeIn 0.2s ease-out; +} + +.dual-ingested-header { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-main); + margin-bottom: 8px; +} + +.dual-ingested-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.ingested-chip { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 6px 10px; + font-size: 12.5px; + color: var(--text-main); + display: flex; + align-items: center; + gap: 5px; +} + +.ingested-chip.chip-target { + border-left: 3px solid #10b981; +} + +.ingested-chip.chip-chosen { + border-left: 3px solid #ef4444; +} + +.ingested-chip .chip-label { + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + color: var(--text-muted); +} + +/* Words Breakdown (All 4 Options with In-library/Add buttons) */ +.cefr-words-breakdown { + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid var(--border-color); + text-align: left; +} + +.words-breakdown-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + flex-wrap: wrap; + gap: 6px; +} + +.wb-title { + font-size: 13px; + font-weight: 700; + color: var(--text-main); + display: flex; + align-items: center; + gap: 6px; +} + +.wb-hint { + font-size: 11.5px; + color: var(--text-muted); +} + +.words-breakdown-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 8px; +} + +.word-breakdown-card { + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 9px 11px; + display: flex; + flex-direction: column; + gap: 4px; + transition: all 0.15s ease; +} + +.word-breakdown-card.is-target { + border-color: rgba(16, 185, 129, 0.4); + background: rgba(16, 185, 129, 0.05); +} + +.word-breakdown-card.is-chosen-mistake { + border-color: rgba(239, 68, 68, 0.4); + background: rgba(239, 68, 68, 0.05); +} + +.wb-card-top { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 6px; +} + +.wb-term { + font-size: 14px; + color: var(--text-main); + font-weight: 700; +} + +.wb-pos { + font-size: 10px; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; +} + +.wb-trans { + font-size: 12px; + color: var(--text-muted); + line-height: 1.35; + flex-grow: 1; +} + +.wb-action-wrap { + margin-top: 6px; +} + +.wb-badge { + font-size: 11px; + font-weight: 700; + padding: 3px 6px; + border-radius: 4px; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.wb-badge.badge-auto-added { + color: #ef4444; + background: rgba(239, 68, 68, 0.12); +} + +.wb-badge.badge-in-lib, +.wb-badge.badge-just-added { + color: #10b981; + background: rgba(16, 185, 129, 0.12); +} + +.btn-add-option-word { + width: 100%; + font-size: 11px; + padding: 4px 8px; + border-radius: var(--radius-sm); +} + /* Next Button Bar */ .cefr-next-bar { margin-top: 10px; diff --git a/app/static/js/modules/api.js b/app/static/js/modules/api.js index 13d9599..1f1f802 100644 --- a/app/static/js/modules/api.js +++ b/app/static/js/modules/api.js @@ -247,6 +247,19 @@ export const api = { }); if (resp.ok) return await resp.json(); throw new Error(`Failed to reset level: ${resp.status}`); + }, + async addWord(level, term, contextSentence = '') { + const resp = await fetch('/api/cefr/add-word', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + level: level, + term: term, + context_sentence: contextSentence + }) + }); + if (resp.ok) return await resp.json(); + throw new Error(`Failed to add word: ${resp.status}`); } }, diff --git a/app/static/js/modules/cefr.js b/app/static/js/modules/cefr.js index 7833c84..7d915fe 100644 --- a/app/static/js/modules/cefr.js +++ b/app/static/js/modules/cefr.js @@ -381,14 +381,15 @@ async function handleOptionTap(questionId, selectedOption, clickedBtn) { ${res.phonetic ? `${escapeHtml(res.phonetic)}` : ''} ${res.explanation ? `

${escapeHtml(res.explanation)}

` : ''} + ${renderOptionsBreakdown(res.options_breakdown)} `; feedbackBox.classList.remove('hidden'); + attachBreakdownListeners(feedbackBox, q.sentence); } } else { sessionStats.mistakes++; - if (res.card_created || res.card_updated) { - sessionStats.ingested++; - } + // Two words were ingested: the correct target word and the clicked distractor + sessionStats.ingested += 2; clickedBtn.classList.add('opt-wrong'); @@ -413,14 +414,26 @@ async function handleOptionTap(questionId, selectedOption, clickedBtn) { ${res.phonetic ? `${escapeHtml(res.phonetic)}` : ''} ${res.explanation ? `

${escapeHtml(res.explanation)}

` : ''} -
- 📥 -
- Авто-захват в словарь: слово добавлено со статусом «В процессе» для закрепления в утреннем SRS! +
+
+ 📥 + В ваш словарь SRS («В процессе») добавлены оба слова: +
+
+
+ Верный ответ: + ${escapeHtml(res.correct_option)} — ${escapeHtml(res.translation || '')} +
+
+ Ваш выбор: + ${escapeHtml(res.selected_option)} — ${escapeHtml(res.selected_translation || '')} +
+ ${renderOptionsBreakdown(res.options_breakdown)} `; feedbackBox.classList.remove('hidden'); + attachBreakdownListeners(feedbackBox, q.sentence); } } @@ -478,3 +491,73 @@ function renderQuizSummary() { sumSrsBtn.classList.toggle('hidden', ingested === 0 && mistakes === 0); } } + +// ----------------------------------------------------------------------- +// Render Options Breakdown (All 4 options with 1-tap SRS Ingestion) +// ----------------------------------------------------------------------- +function renderOptionsBreakdown(breakdown) { + if (!breakdown || breakdown.length === 0) return ''; + + return ` +
+
+ Слова из этого вопроса: + Добавьте в словарь слова, перевод которых хотите запомнить +
+
+ ${breakdown.map(b => { + const isTarget = b.is_correct; + const wasChosenMistake = b.was_selected && !b.is_correct; + const inLib = b.in_library || b.auto_added; + let actionHtml = ''; + + if (b.auto_added) { + actionHtml = ` В словаре (SRS)`; + } else if (inLib) { + actionHtml = ` В библиотеке`; + } else { + actionHtml = ``; + } + + return ` +
+
+ ${escapeHtml(b.term)} + ${b.pos ? `${escapeHtml(b.pos)}` : ''} +
+
${escapeHtml(b.translation || '—')}
+
${actionHtml}
+
+ `; + }).join('')} +
+
+ `; +} + +function attachBreakdownListeners(container, contextSentence) { + if (!container) return; + + container.querySelectorAll('.btn-add-option-word').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const term = btn.dataset.term; + if (!term) return; + + btn.disabled = true; + btn.innerHTML = '...'; + + try { + await api.cefr.addWord(currentLevel, term, contextSentence || ''); + sessionStats.ingested++; + btn.outerHTML = ' Добавлено!'; + showToast(`Слово «${term}» добавлено в утренний тренажёр SRS!`, 'success'); + } catch (err) { + btn.disabled = false; + btn.textContent = '+ В словарь'; + showToast(`Ошибка: ${err.message}`, 'error'); + } + }); + }); +} + diff --git a/tests/test_api.py b/tests/test_api.py index 768b77d..95bbd8b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1376,10 +1376,10 @@ def test_cefr_testing_and_gap_ingestion(): 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 + assert level_map["A1"]["total_questions"] >= 65 + assert level_map["A2"]["total_questions"] >= 65 + assert level_map["B1"]["total_questions"] >= 65 + assert level_map["B2"]["total_questions"] >= 65 # 4. Fetch quiz batch quiz_res = client.get("/api/cefr/quiz?level=A2&count=5") @@ -1393,8 +1393,12 @@ def test_cefr_testing_and_gap_ingestion(): assert "clue" in q1 assert len(q1["options"]) == 4 - # 5. Answer INCORRECTLY -> Automated Gap Ingestion into SRS! - wrong_opt = "__completely_wrong_option__" + # 5. Answer INCORRECTLY -> Automated Dual Gap Ingestion (target + selected option) + # Pick a distractor from the question's actual options + # Find option that is not correct (we test with the first distractor) + # Let's inspect options and find one that is wrong + wrong_opt = [opt for opt in q1["options"] if opt.lower() != q1.get("term", "").lower()][0] + ans_wrong = client.post("/api/cefr/answer", json={ "question_id": q1["id"], "selected_option": wrong_opt @@ -1402,12 +1406,15 @@ def test_cefr_testing_and_gap_ingestion(): 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_created"] is True or w_data["card_updated"] is True assert w_data["card_id"] is not None assert w_data["correct_option"] != "" assert w_data["explanation"] != "" + assert w_data["selected_card_id"] is not None + assert w_data["selected_card_term"] == wrong_opt + assert len(w_data["options_breakdown"]) == 4 - # Verify the card was actually created in vocabulary_cards with state 'learning' and level tag + # Verify target word card in SRS card_res = client.get(f"/api/cards/{w_data['card_id']}") assert card_res.status_code == 200 ingested_card = card_res.json() @@ -1415,6 +1422,27 @@ def test_cefr_testing_and_gap_ingestion(): assert "level-a2" in ingested_card["tags"] assert "cefr-test" in ingested_card["tags"] + # Verify selected distractor card in SRS + sel_card_res = client.get(f"/api/cards/{w_data['selected_card_id']}") + assert sel_card_res.status_code == 200 + sel_card = sel_card_res.json() + assert sel_card["srs_state"] == "learning" + assert "level-a2" in sel_card["tags"] + assert "cefr-test" in sel_card["tags"] + + # Test manual ingestion of remaining option via /api/cefr/add-word + other_opt = [opt for opt in q1["options"] if opt != w_data["correct_option"] and opt != wrong_opt][0] + add_word_res = client.post("/api/cefr/add-word", json={ + "level": "A2", + "term": other_opt, + "context_sentence": q1["sentence"] + }) + assert add_word_res.status_code == 200 + add_word_data = add_word_res.json() + assert add_word_data["status"] == "ok" + assert add_word_data["card_id"] is not None + assert add_word_data["term"] == other_opt + # 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