diff --git a/app/listening.py b/app/listening.py new file mode 100644 index 0000000..b7ccd3a --- /dev/null +++ b/app/listening.py @@ -0,0 +1,339 @@ +import re +import hashlib +import asyncio +import logging +from typing import List, Dict, Any, Optional + +from app.tts import synthesize_speech, get_audio_hash + +logger = logging.getLogger("notes_app.listening") + +# Pre-curated high-quality situational listening scenarios +CURATED_SCENARIOS = [ + # --- Tech & IT --- + { + "id": "tech-01", + "topic": "tech", + "level": "B1", + "title": "Database Migration", + "story": "The engineering team scheduled a critical database migration for midnight to minimize downtime. All user requests were temporarily routed to a read-only replica until the data verification was complete.", + "story_ru": "Инженерная команда запланировала важную миграцию базы данных на полночь, чтобы свести к минимуму время простоя. Все запросы пользователей временно перенаправлялись на реплику только для чтения, пока не завершилась проверка данных.", + "question": "When was the database migration scheduled to happen?", + "question_ru": "На какое время была запланирована миграция базы данных?", + "keywords": ["midnight", "at midnight", "night", "12", "zero"], + "ideal_answer": "At midnight (to minimize downtime).", + "hint": "Обратите внимание на время суток, упомянутое в первом предложении.", + }, + { + "id": "tech-02", + "topic": "tech", + "level": "A2", + "title": "Software Update", + "story": "Anna forgot to charge her laptop before the online presentation. Luckily, the meeting room had an extra power outlet right next to the podium.", + "story_ru": "Анна забыла зарядить ноутбук перед онлайн-презентацией. К счастью, в переговорной оказалась свободная розетка прямо возле трибуны.", + "question": "What did Anna forget to do before her presentation?", + "question_ru": "Что Анна забыла сделать перед своей презентацией?", + "keywords": ["charge", "charge her laptop", "charge laptop", "battery"], + "ideal_answer": "She forgot to charge her laptop.", + "hint": "Вспомните действие с батареей ноутбука в самом начале.", + }, + { + "id": "tech-03", + "topic": "tech", + "level": "B2", + "title": "Cybersecurity Alert", + "story": "Security analysts detected an unusual spike in outbound network traffic coming from the staging server. The compromised container was immediately isolated to prevent lateral movement across the internal cluster.", + "story_ru": "Аналитики безопасности зафиксировали необычный всплеск исходящего сетевого трафика со стейджинг-сервера. Скомпрометированный контейнер был немедленно изолирован для предотвращения распространения атаки по внутреннему кластеру.", + "question": "What action was taken with the compromised container?", + "question_ru": "Какое действие было предпринято в отношении скомпрометированного контейнера?", + "keywords": ["isolated", "isolate", "it was isolated", "quarantine", "separated"], + "ideal_answer": "It was immediately isolated to prevent spreading across the cluster.", + "hint": "Послушайте второе предложение: что сделали, чтобы вирус не распространился?", + }, + + # --- Science & Discovery --- + { + "id": "science-01", + "topic": "science", + "level": "B1", + "title": "Deep Sea Discovery", + "story": "Marine biologists using an autonomous submarine discovered a new hydrothermal vent at a depth of three thousand meters. The surrounding water was boiling hot, yet it supported thriving colonies of previously unknown shrimp.", + "story_ru": "Морские биологи с помощью автономной подводной лодки обнаружили новый гидротермальный источник на глубине трех тысяч метров. Окружающая вода была кипящей, но в ней процветали колонии ранее неизвестных креветок.", + "question": "At what depth was the hydrothermal vent discovered?", + "question_ru": "На какой глубине был обнаружен гидротермальный источник?", + "keywords": ["3000", "three thousand", "3,000", "three thousand meters", "3000 meters", "3000m"], + "ideal_answer": "At a depth of 3,000 meters.", + "hint": "Числительное глубины в первом предложении.", + }, + { + "id": "science-02", + "topic": "science", + "level": "B2", + "title": "Mars Rover Sample", + "story": "The robotic rover successfully sealed its fifteenth rock core sample inside an airtight titanium tube. Planetary scientists believe these ancient sedimentary layers might preserve chemical traces of microscopic life from billions of years ago.", + "story_ru": "Роботизированный марсоход успешно запечатал свой пятнадцатый образец керна породы в герметичную титановую трубку. Учёные-планетологи полагают, что эти древние осадочные слои могут сохранять химические следы микроскопической жизни миллиарды лет назад.", + "question": "What material is used for the tube that holds the rock sample?", + "question_ru": "Какой материал используется для трубки, хранящей образец породы?", + "keywords": ["titanium", "titanium tube", "metal titanium"], + "ideal_answer": "Titanium (an airtight titanium tube).", + "hint": "Какой прочный металл упоминается в конце первого предложения?", + }, + + # --- Travel & Daily Life --- + { + "id": "travel-01", + "topic": "travel", + "level": "A2", + "title": "Flight Boarding", + "story": "Flight BA 249 to London has been delayed by forty minutes due to heavy fog over the English Channel. Passengers are kindly requested to wait near gate twelve for further announcements.", + "story_ru": "Рейс BA 249 в Лондон задерживается на сорок минут из-за сильного тумана над проливом Ла-Манш. Пассажиров просят ожидать у выхода на посадку номер 12 дальнейших объявлений.", + "question": "Why was the flight to London delayed?", + "question_ru": "Почему рейс в Лондон был задержан?", + "keywords": ["fog", "heavy fog", "weather", "bad weather", "mist"], + "ideal_answer": "Due to heavy fog over the English Channel.", + "hint": "Погодное явление, упомянутое в первом предложении.", + }, + { + "id": "travel-02", + "topic": "travel", + "level": "B1", + "title": "Hotel Check-in", + "story": "Mark arrived at the boutique hotel two hours before standard check-in time. The receptionist kindly offered him a complimentary espresso and arranged to store his luggage while his room was being prepared.", + "story_ru": "Марк прибыл в бутик-отель за два часа до стандартного времени заселения. Администратор любезно предложил ему бесплатный эспрессо и организовал хранение багажа, пока готовился номер.", + "question": "What drink was Mark offered while waiting?", + "question_ru": "Какой напиток предложили Марку во время ожидания?", + "keywords": ["espresso", "coffee", "complimentary espresso"], + "ideal_answer": "A complimentary espresso (coffee).", + "hint": "Напиток, предложенный администратором.", + }, + { + "id": "daily-01", + "topic": "daily", + "level": "A2", + "title": "Morning Routine", + "story": "David always drinks a tall glass of warm water with lemon as soon as he wakes up. He claims this simple habit gives him more sustained energy than coffee.", + "story_ru": "Дэвид всегда выпивает большой стакан теплой воды с лимоном, как только просыпается. Он утверждает, что эта простая привычка дает ему больше стабильной энергии, чем кофе.", + "question": "What does David add to his morning water?", + "question_ru": "Что Дэвид добавляет в свою утреннюю воду?", + "keywords": ["lemon", "with lemon", "lemon juice"], + "ideal_answer": "Lemon (warm water with lemon).", + "hint": "Цитрусовый фрукт, упомянутый в первом предложении.", + }, + { + "id": "daily-02", + "topic": "daily", + "level": "B1", + "title": "Lost Wallet", + "story": "Sarah realized her leather wallet was missing while paying for groceries at the supermarket. Fortunately, an honest customer had handed it over to the customer service desk ten minutes earlier.", + "story_ru": "Сара поняла, что её кожаный кошелёк пропал, когда расплачивалась за продукты в супермаркете. К счастью, честный покупатель сдал его на стойку обслуживания клиентов за десять минут до этого.", + "question": "Where was Sarah when she noticed her wallet was missing?", + "question_ru": "Где была Сара, когда заметила пропажу кошелька?", + "keywords": ["supermarket", "grocery store", "market", "store", "at the supermarket"], + "ideal_answer": "At the supermarket (paying for groceries).", + "hint": "Место покупок, названное в первом предложении.", + }, + + # --- Business & Career --- + { + "id": "business-01", + "topic": "business", + "level": "B1", + "title": "Quarterly Review", + "story": "During the quarterly review, the product director announced that customer retention had increased by twelve percent. She attributed this growth to the newly introduced dark mode and faster page loading times.", + "story_ru": "В ходе квартального обзора директор по продукту объявила, что удержание клиентов выросло на двенадцать процентов. Она объяснила этот рост недавно внедрённой тёмной темой и ускорением загрузки страниц.", + "question": "By what percentage did customer retention increase?", + "question_ru": "На сколько процентов выросло удержание клиентов?", + "keywords": ["12", "12%", "twelve", "twelve percent", "12 percent"], + "ideal_answer": "By 12 percent (twelve percent).", + "hint": "Число процентов, названное директором.", + }, + { + "id": "business-02", + "topic": "business", + "level": "B2", + "title": "Remote Work Policy", + "story": "The board approved a flexible remote work policy allowing employees to work from anywhere within the same time zone. However, team leads emphasized that weekly sync meetings on Mondays remain mandatory for all staff.", + "story_ru": "Совет директоров утвердил гибкую политику удаленной работы, позволяющую сотрудникам работать из любой точки в том же часовом поясе. Однако тимлиды подчеркнули, что еженедельные синхронизационные встречи по понедельникам остаются обязательными для всех сотрудников.", + "question": "On which day of the week are sync meetings mandatory?", + "question_ru": "В какой день недели встречи команды обязательны?", + "keywords": ["monday", "mondays", "on monday", "on mondays"], + "ideal_answer": "On Mondays (weekly sync meetings on Mondays).", + "hint": "День недели, названный во втором предложении.", + }, +] + + +async def get_listening_exercise( + topic: Optional[str] = None, + level: Optional[str] = None, + user_cards: Optional[List[Dict[str, Any]]] = None, +) -> Dict[str, Any]: + """ + Selects or generates a listening exercise, ensuring audio is pre-synthesized. + """ + candidates = CURATED_SCENARIOS + + # Filter by topic if specified and not 'all' / 'random' + if topic and topic.lower() not in ["all", "random", "any", "cards"]: + filtered = [s for s in candidates if s["topic"].lower() == topic.lower()] + if filtered: + candidates = filtered + + # Filter by level if specified + if level and level.upper() in ["A1", "A2", "B1", "B2"]: + lvl_filtered = [s for s in candidates if s["level"].upper() == level.upper()] + if lvl_filtered: + candidates = lvl_filtered + + # If user wants cards and has cards with context sentence + if topic == "cards" and user_cards: + valid_cards = [c for c in user_cards if c.get("context_sentence") and len(c.get("context_sentence", "")) > 25] + if valid_cards: + import random + card = random.choice(valid_cards) + term = card.get("term", "") + ctx = card.get("context_sentence", "").strip().replace('"', '') + transl = card.get("translation", "") + item_id = f"card-{card.get('id', 0)}" + + # Pre-synthesize audio + story_hash = get_audio_hash(ctx, "en", 1.0) + question_text = f"Which key word or concept is highlighted in this context?" + question_hash = get_audio_hash(question_text, "en", 1.0) + + # Fire synthesis + asyncio.create_task(synthesize_speech(ctx, lang="en", speed=1.0)) + asyncio.create_task(synthesize_speech(question_text, lang="en", speed=1.0)) + + return { + "id": item_id, + "topic": "cards", + "level": "B1", + "title": f"Контекст слова: {term}", + "story": ctx, + "story_ru": f"Изучаемое слово: {term} ({transl})", + "story_audio_url": f"/api/audio/{story_hash}.wav", + "question": question_text, + "question_ru": f"Какое ключевое слово или понятие обсуждается в этом контексте?", + "question_audio_url": f"/api/audio/{question_hash}.wav", + "ideal_answer": term, + "keywords": [term.lower(), transl.lower()], + "hint": f"Вспомните сохраненное слово, перевод которого: {transl}", + } + + import random + scenario = random.choice(candidates) + + # Ensure audio is pre-synthesized + story_hash = get_audio_hash(scenario["story"], "en", 1.0) + question_hash = get_audio_hash(scenario["question"], "en", 1.0) + + asyncio.create_task(synthesize_speech(scenario["story"], lang="en", speed=1.0)) + asyncio.create_task(synthesize_speech(scenario["question"], lang="en", speed=1.0)) + + return { + "id": scenario["id"], + "topic": scenario["topic"], + "level": scenario["level"], + "title": scenario["title"], + "story": scenario["story"], + "story_ru": scenario["story_ru"], + "story_audio_url": f"/api/audio/{story_hash}.wav", + "question": scenario["question"], + "question_ru": scenario["question_ru"], + "question_audio_url": f"/api/audio/{question_hash}.wav", + "ideal_answer": scenario["ideal_answer"], + "keywords": scenario["keywords"], + "hint": scenario.get("hint", ""), + } + + +def evaluate_listening_answer( + scenario_id: str, + user_answer: str, + story: str = "", + ideal_answer: str = "", + keywords: Optional[List[str]] = None, +) -> Dict[str, Any]: + """ + Evaluates the user's answer to the listening question. + Fast, intelligent keyword and semantic analysis with zero server delay. + """ + clean_user = (user_answer or "").strip().lower() + clean_user_norm = re.sub(r"[^\w\s]", "", clean_user) + + # Find scenario if ID is curated + scenario = next((s for s in CURATED_SCENARIOS if s["id"] == scenario_id), None) + + kw_list = keywords or [] + if scenario and not kw_list: + kw_list = scenario.get("keywords", []) + expected = ideal_answer or (scenario.get("ideal_answer", "") if scenario else "") + story_text = story or (scenario.get("story", "") if scenario else "") + story_ru = scenario.get("story_ru", "") if scenario else "" + question_text = scenario.get("question", "") if scenario else "" + question_ru = scenario.get("question_ru", "") if scenario else "" + + if not clean_user_norm: + return { + "is_correct": False, + "score": 0, + "feedback": "Ответ не введён. Послушайте вопрос ещё раз и запишите то, что услышали.", + "ideal_answer": expected, + "story": story_text, + "story_ru": story_ru, + "question": question_text, + "question_ru": question_ru, + } + + # 1. Direct or partial keyword match + is_match = False + matched_keyword = "" + for kw in kw_list: + clean_kw = re.sub(r"[^\w\s]", "", kw.lower().strip()) + if clean_kw and (clean_kw in clean_user_norm or clean_user_norm in clean_kw): + is_match = True + matched_keyword = kw + break + + # 2. Check overlap with ideal answer words + if not is_match and expected: + exp_words = set(re.sub(r"[^\w\s]", "", expected.lower()).split()) + # Filter stopwords + stopwords = {"the", "a", "an", "at", "in", "on", "of", "to", "for", "with", "it", "was", "is", "by"} + meaningful_exp = exp_words - stopwords + user_words = set(clean_user_norm.split()) + overlap = meaningful_exp.intersection(user_words) + if len(overlap) >= max(1, len(meaningful_exp) // 2): + is_match = True + + if is_match: + score = 100 + feedback = "✅ Отлично! Вы абсолютно правильно поняли смысл на слух." + else: + score = 25 + feedback = f"❌ Не совсем точно. Обратите внимание на правильный ответ ниже и переслушайте фрагмент." + + return { + "is_correct": is_match, + "score": score, + "feedback": feedback, + "ideal_answer": expected, + "story": story_text, + "story_ru": story_ru, + "question": question_text, + "question_ru": question_ru, + "user_answer": user_answer, + } + + +async def prewarm_all_listening_audio(): + """Pre-synthesizes and caches audio for all curated scenarios in background.""" + logger.info("Pre-warming curated listening scenarios audio...") + for sc in CURATED_SCENARIOS: + try: + await synthesize_speech(sc["story"], lang="en", speed=1.0) + await synthesize_speech(sc["question"], lang="en", speed=1.0) + except Exception as e: + logger.warning(f"Error pre-warming listening {sc['id']}: {e}") + logger.info(f"Pre-warmed {len(CURATED_SCENARIOS)} listening scenarios audio.") diff --git a/app/main.py b/app/main.py index 44140cc..8e598e8 100644 --- a/app/main.py +++ b/app/main.py @@ -67,6 +67,11 @@ from app.tts import ( AUDIO_CACHE_DIR, is_piper_available, ) +from app.listening import ( + get_listening_exercise, + evaluate_listening_answer, + prewarm_all_listening_audio, +) @@ -77,6 +82,7 @@ async def lifespan(app: FastAPI): init_db() ensure_admin_user() asyncio.create_task(warmup_ollama_model()) + asyncio.create_task(prewarm_all_listening_audio()) yield await close_http_client() @@ -253,6 +259,13 @@ class MineBooksRequest(BaseModel): count: int = Field(15, ge=1, le=50) +class ListeningCheckRequest(BaseModel): + scenario_id: str + user_answer: str + story: Optional[str] = "" + ideal_answer: Optional[str] = "" + + @@ -540,6 +553,57 @@ async def api_tts_status(user: dict = Depends(require_user)): } +# --------------------------------------------------------------------------- +# Interactive Listening Comprehension Trainer (Stage 2) +# --------------------------------------------------------------------------- + +@app.get("/api/listening/exercise") +async def api_listening_exercise( + topic: Optional[str] = Query("all"), + level: Optional[str] = Query("B1"), + user: dict = Depends(require_user), +): + """ + Returns a listening comprehension exercise (topic / level / vocabulary context) + with pre-synthesized audio URLs for the story and question. + """ + user_cards = [] + if topic == "cards": + with get_db() as conn: + rows = conn.execute( + """ + SELECT id, term, translation, context_sentence + FROM vocabulary_cards + WHERE user_id = ? AND length(context_sentence) > 20 + ORDER BY RANDOM() LIMIT 20 + """, + (user["id"],), + ).fetchall() + user_cards = [dict(r) for r in rows] + + ex = await get_listening_exercise(topic=topic, level=level, user_cards=user_cards) + # Strip keywords and ideal_answer from client response to prevent inspection + safe_ex = {k: v for k, v in ex.items() if k not in ("keywords", "ideal_answer")} + return safe_ex + + +@app.post("/api/listening/check") +async def api_listening_check( + req: ListeningCheckRequest, + user: dict = Depends(require_user), +): + """ + Evaluates user's answer to the listening question. + Returns score, pedagogic feedback, full story transcription, and ideal answer. + """ + return evaluate_listening_answer( + scenario_id=req.scenario_id, + user_answer=req.user_answer, + story=req.story or "", + ideal_answer=req.ideal_answer or "", + ) + + # --------------------------------------------------------------------------- # Collections diff --git a/app/static/css/style.css b/app/static/css/style.css index 595d1a0..30a7701 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -2216,6 +2216,221 @@ body.drawer-open { font-style: italic; } +/* Interactive Listening Comprehension Mode */ +.listening-card { + background-color: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 28px 32px; + box-shadow: var(--shadow-lg); + display: flex; + flex-direction: column; + gap: 20px; +} + +.listening-header-row { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 12px; +} + +.listening-tag-wrap { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.level-badge { + font-size: 11px; + font-weight: 700; + padding: 2px 8px; + border-radius: var(--radius-sm); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.level-badge.badge-a1 { background: rgba(59, 130, 246, 0.15); color: #60a5fa; border: 1px solid rgba(59, 130, 246, 0.3); } +.level-badge.badge-a2 { background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3); } +.level-badge.badge-b1 { background: rgba(245, 158, 11, 0.15); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.3); } +.level-badge.badge-b2 { background: rgba(249, 115, 22, 0.15); color: #fb923c; border: 1px solid rgba(249, 115, 22, 0.3); } +.level-badge.badge-c1 { background: rgba(168, 85, 247, 0.15); color: #c084fc; border: 1px solid rgba(168, 85, 247, 0.3); } + +.topic-badge { + font-size: 11px; + font-weight: 600; + color: var(--text-dim); + background: var(--bg-main); + border: 1px solid var(--border-color); + padding: 2px 8px; + border-radius: var(--radius-sm); +} + +.listening-filters { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.select-styled { + background-color: var(--bg-main); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + color: var(--text-main); + padding: 6px 10px; + font-size: 13px; + outline: none; + cursor: pointer; + transition: var(--transition); +} + +.select-styled:focus { + border-color: var(--primary); + box-shadow: 0 0 0 2px var(--primary-dim); +} + +.select-xs { + font-size: 12px; + padding: 4px 8px; +} + +.listening-hero { + background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg-main) 100%); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 20px 24px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.listening-scenario-title { + font-size: 18px; + font-weight: 700; + color: var(--text-main); + margin: 0 0 4px 0; +} + +.listening-subtitle { + font-size: 13px; + color: var(--text-muted); + margin: 0; +} + +.listening-audio-controls { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-top: 4px; +} + +@media (max-width: 600px) { + .listening-audio-controls { + grid-template-columns: 1fr; + } +} + +.audio-control-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 8px; + transition: var(--transition); +} + +.audio-control-card:hover { + border-color: var(--primary-dim); +} + +.audio-control-label { + font-size: 12px; + font-weight: 600; + color: var(--text-dim); +} + +.listening-play-btn { + width: 100%; + justify-content: center; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 8px; +} + +.listening-hint-bar { + display: flex; + align-items: center; + gap: 12px; +} + +.listening-hint-text { + font-size: 13px; + color: var(--text-muted); + font-style: italic; + background: var(--bg-main); + border-left: 3px solid var(--primary); + padding: 4px 10px; + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; +} + +.listening-form { + display: flex; + flex-direction: column; + gap: 12px; +} + +.listening-revealed-card { + background-color: var(--bg-main); + border: 1px solid var(--border-color); + border-left: 4px solid var(--primary); + border-radius: var(--radius-md); + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; + animation: fadeIn 0.25s ease-out; +} + +.revealed-section { + display: flex; + flex-direction: column; + gap: 4px; +} + +.revealed-label { + font-size: 12px; + font-weight: 700; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.revealed-val { + font-size: 16px; + color: #34d399; +} + +.revealed-text { + font-size: 15px; + line-height: 1.5; + color: var(--text-main); + margin: 0; +} + +.listening-next-bar { + display: flex; + justify-content: flex-end; + margin-top: 8px; + padding-top: 12px; + border-top: 1px solid var(--border-color); +} + /* Summary Card */ .srs-summary-card { background-color: var(--bg-card); diff --git a/app/static/index.html b/app/static/index.html index e65f2b1..a46ea85 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -233,7 +233,8 @@ - + + @@ -492,6 +493,96 @@ + + +