feat(listening): interactive listening comprehension trainer with neural voiceover and auto-evaluation
This commit is contained in:
1 parent
a6fb4349e9
commit
7893b353aa
8 files changed
+1026
-3
No files matched your search
@@ -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.")
|
||||
+64
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
+92
-1
@@ -233,7 +233,8 @@
|
||||
<button class="srs-tab" data-mode="quiz">🎯 4 Варианта</button>
|
||||
<button class="srs-tab" data-mode="typing">✍️ Набор слова</button>
|
||||
<button class="srs-tab" data-mode="cloze">🧩 Пропуски</button>
|
||||
<button class="srs-tab" data-mode="dictation" style="display: none;">🎧 Диктант</button>
|
||||
<button class="srs-tab" data-mode="dictation">🎧 Диктант</button>
|
||||
<button class="srs-tab" data-mode="listening">📻 Аудирование</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -492,6 +493,96 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MODE 6: Listening Comprehension Q&A -->
|
||||
<div id="srs-mode-listening" class="srs-mode-container hidden">
|
||||
<div class="trainer-card listening-card">
|
||||
<div class="trainer-header listening-header-row">
|
||||
<div class="listening-tag-wrap">
|
||||
<span class="mode-tag">📻 Аудирование & Вопросы</span>
|
||||
<span id="listening-level-badge" class="level-badge badge-b1">B1</span>
|
||||
<span id="listening-topic-badge" class="topic-badge">Tech</span>
|
||||
</div>
|
||||
<div class="listening-filters">
|
||||
<select id="listening-topic-select" class="select-styled select-xs" title="Выбрать тему">
|
||||
<option value="all">🌐 Все темы</option>
|
||||
<option value="tech">💻 Технологии & IT</option>
|
||||
<option value="daily">☕ Повседневная жизнь</option>
|
||||
<option value="travel">✈️ Путешествия</option>
|
||||
<option value="science">🔬 Наука</option>
|
||||
<option value="business">💼 Работа & Бизнес</option>
|
||||
<option value="cards">📚 Мои слова из словаря</option>
|
||||
</select>
|
||||
<select id="listening-speed-select" class="select-styled select-xs" title="Скорость речи">
|
||||
<option value="1.0">⚡ 1.0x</option>
|
||||
<option value="0.75">🐢 0.75x</option>
|
||||
<option value="1.25">🚀 1.25x</option>
|
||||
</select>
|
||||
<button type="button" id="listening-new-scenario-btn" class="btn btn-xs btn-outline" title="Загрузить другую ситуацию">
|
||||
🔄 Другая история
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="listening-hero">
|
||||
<div class="listening-title-box">
|
||||
<h3 id="listening-title" class="listening-scenario-title">Загрузка аудио-ситуации...</h3>
|
||||
<p class="listening-subtitle">Прослушайте аудио на английском и ответьте на вопрос диктора</p>
|
||||
</div>
|
||||
|
||||
<div class="listening-audio-controls">
|
||||
<div class="audio-control-card">
|
||||
<div class="audio-control-label">1. Аудио-история (контекст):</div>
|
||||
<button type="button" id="listening-play-story-btn" class="btn btn-primary btn-md listening-play-btn">
|
||||
🔊 Слушать историю <span class="kbd-sub"><kbd>Space</kbd></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="audio-control-card">
|
||||
<div class="audio-control-label">2. Вопрос от диктора:</div>
|
||||
<button type="button" id="listening-play-question-btn" class="btn btn-outline btn-md listening-play-btn">
|
||||
❓ Слушать вопрос
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="listening-hint-bar">
|
||||
<button type="button" id="listening-toggle-hint-btn" class="btn btn-xs btn-ghost">💡 Подсказка к вопросу</button>
|
||||
<span id="listening-hint-text" class="listening-hint-text hidden"></span>
|
||||
</div>
|
||||
|
||||
<form id="listening-form" class="trainer-form listening-form">
|
||||
<div class="input-action-row">
|
||||
<input type="text" id="listening-input" class="input-styled input-hero" placeholder="Напишите ответ на английском по услышанному..." autocomplete="off" spellcheck="false" autofocus>
|
||||
<button type="submit" id="listening-submit-btn" class="btn btn-primary">Проверить ответ</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="listening-feedback-box" class="trainer-feedback-box hidden"></div>
|
||||
|
||||
<div id="listening-revealed-box" class="listening-revealed-card hidden">
|
||||
<div class="revealed-section">
|
||||
<span class="revealed-label">🎯 Образцовый ответ:</span>
|
||||
<strong id="listening-ideal-answer" class="revealed-val text-success"></strong>
|
||||
</div>
|
||||
<div class="revealed-section">
|
||||
<span class="revealed-label">📖 Вопрос (текст):</span>
|
||||
<p id="listening-revealed-question" class="revealed-text"></p>
|
||||
<small id="listening-revealed-question-ru" class="text-muted"></small>
|
||||
</div>
|
||||
<div class="revealed-section">
|
||||
<span class="revealed-label">📜 Полный текст истории:</span>
|
||||
<p id="listening-revealed-story" class="revealed-text"></p>
|
||||
<small id="listening-revealed-story-ru" class="text-muted"></small>
|
||||
</div>
|
||||
<div class="listening-next-bar">
|
||||
<button type="button" id="listening-next-btn" class="btn btn-primary btn-md">
|
||||
Следующая ситуация ➔ <span class="kbd-sub"><kbd>Enter</kbd></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Complete Summary Screen -->
|
||||
<div id="srs-summary" class="srs-summary-card hidden">
|
||||
<div class="summary-trophy">🏆</div>
|
||||
|
||||
@@ -183,6 +183,29 @@ export const api = {
|
||||
}
|
||||
},
|
||||
|
||||
// Listening Comprehension Trainer
|
||||
listening: {
|
||||
async getExercise(topic = 'all', level = 'B1') {
|
||||
const resp = await fetch(`/api/listening/exercise?topic=${encodeURIComponent(topic)}&level=${encodeURIComponent(level)}`);
|
||||
if (resp.ok) return await resp.json();
|
||||
throw new Error(`Failed to load listening exercise: ${resp.status}`);
|
||||
},
|
||||
async checkAnswer(scenarioId, userAnswer, story = '', idealAnswer = '') {
|
||||
const resp = await fetch('/api/listening/check', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
scenario_id: scenarioId,
|
||||
user_answer: userAnswer,
|
||||
story: story,
|
||||
ideal_answer: idealAnswer
|
||||
})
|
||||
});
|
||||
if (resp.ok) return await resp.json();
|
||||
throw new Error(`Failed to check listening answer: ${resp.status}`);
|
||||
}
|
||||
},
|
||||
|
||||
// Bookshelf & Reader
|
||||
books: {
|
||||
async getAll() {
|
||||
|
||||
@@ -106,6 +106,34 @@ export function initSrs() {
|
||||
const dictationDetailsBox = document.getElementById('dictation-details-box');
|
||||
const dictationRatingBar = document.getElementById('dictation-rating-bar');
|
||||
|
||||
// Mode 6: Listening Comprehension
|
||||
const srsModeListening = document.getElementById('srs-mode-listening');
|
||||
const listeningLevelBadge = document.getElementById('listening-level-badge');
|
||||
const listeningTopicBadge = document.getElementById('listening-topic-badge');
|
||||
const listeningTopicSelect = document.getElementById('listening-topic-select');
|
||||
const listeningSpeedSelect = document.getElementById('listening-speed-select');
|
||||
const listeningNewScenarioBtn = document.getElementById('listening-new-scenario-btn');
|
||||
const listeningTitle = document.getElementById('listening-title');
|
||||
const listeningPlayStoryBtn = document.getElementById('listening-play-story-btn');
|
||||
const listeningPlayQuestionBtn = document.getElementById('listening-play-question-btn');
|
||||
const listeningToggleHintBtn = document.getElementById('listening-toggle-hint-btn');
|
||||
const listeningHintText = document.getElementById('listening-hint-text');
|
||||
const listeningForm = document.getElementById('listening-form');
|
||||
const listeningInput = document.getElementById('listening-input');
|
||||
const listeningSubmitBtn = document.getElementById('listening-submit-btn');
|
||||
const listeningFeedbackBox = document.getElementById('listening-feedback-box');
|
||||
const listeningRevealedBox = document.getElementById('listening-revealed-box');
|
||||
const listeningIdealAnswer = document.getElementById('listening-ideal-answer');
|
||||
const listeningRevealedQuestion = document.getElementById('listening-revealed-question');
|
||||
const listeningRevealedQuestionRu = document.getElementById('listening-revealed-question-ru');
|
||||
const listeningRevealedStory = document.getElementById('listening-revealed-story');
|
||||
const listeningRevealedStoryRu = document.getElementById('listening-revealed-story-ru');
|
||||
const listeningNextBtn = document.getElementById('listening-next-btn');
|
||||
|
||||
let currentListeningExercise = null;
|
||||
let listeningAnswered = false;
|
||||
let listeningCompletedCount = 0;
|
||||
|
||||
// Summary
|
||||
const srsSummary = document.getElementById('srs-summary');
|
||||
const sumCount = document.getElementById('sum-count');
|
||||
@@ -192,7 +220,7 @@ export function initSrs() {
|
||||
|
||||
srsLoading.classList.add('hidden');
|
||||
|
||||
if (queue.length === 0) {
|
||||
if (queue.length === 0 && srsCurrentMode !== 'listening') {
|
||||
srsEmpty.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
@@ -216,9 +244,18 @@ export function initSrs() {
|
||||
srsModeTyping.classList.add('hidden');
|
||||
srsModeCloze.classList.add('hidden');
|
||||
srsModeDictation.classList.add('hidden');
|
||||
if (srsModeListening) srsModeListening.classList.add('hidden');
|
||||
}
|
||||
|
||||
function renderSrsCurrentCard() {
|
||||
if (srsCurrentMode === 'listening') {
|
||||
srsEmpty.classList.add('hidden');
|
||||
srsSummary.classList.add('hidden');
|
||||
hideAllSrsModeContainers();
|
||||
renderListeningMode();
|
||||
return;
|
||||
}
|
||||
|
||||
if (srsCurrentIndex >= srsQueue.length) {
|
||||
showSrsSummary();
|
||||
return;
|
||||
@@ -644,6 +681,178 @@ export function initSrs() {
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Mode 6: Listening Comprehension
|
||||
// -----------------------------------------------------------------------
|
||||
function renderListeningMode() {
|
||||
if (!srsModeListening) return;
|
||||
srsModeListening.classList.remove('hidden');
|
||||
srsCounter.textContent = `📻 Аудирование: решено ${listeningCompletedCount}`;
|
||||
srsProgressFill.style.width = '100%';
|
||||
|
||||
if (!currentListeningExercise) {
|
||||
loadListeningExercise();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadListeningExercise(forceNew = false) {
|
||||
if (!srsModeListening) return;
|
||||
listeningAnswered = false;
|
||||
if (listeningFeedbackBox) {
|
||||
listeningFeedbackBox.classList.add('hidden');
|
||||
listeningFeedbackBox.innerHTML = '';
|
||||
}
|
||||
if (listeningRevealedBox) listeningRevealedBox.classList.add('hidden');
|
||||
if (listeningHintText) {
|
||||
listeningHintText.classList.add('hidden');
|
||||
listeningHintText.textContent = '';
|
||||
}
|
||||
if (listeningInput) {
|
||||
listeningInput.value = '';
|
||||
listeningInput.disabled = false;
|
||||
}
|
||||
if (listeningSubmitBtn) listeningSubmitBtn.disabled = false;
|
||||
if (listeningTitle) listeningTitle.textContent = 'Загрузка аудио-ситуации...';
|
||||
|
||||
const topic = listeningTopicSelect ? listeningTopicSelect.value : 'all';
|
||||
try {
|
||||
const data = await api.listening.getExercise(topic, 'B1');
|
||||
currentListeningExercise = data;
|
||||
|
||||
if (listeningTitle) listeningTitle.textContent = data.title || 'Ситуация для аудирования';
|
||||
if (listeningLevelBadge) {
|
||||
listeningLevelBadge.textContent = data.level || 'B1';
|
||||
listeningLevelBadge.className = `level-badge badge-${(data.level || 'b1').toLowerCase()}`;
|
||||
}
|
||||
if (listeningTopicBadge) {
|
||||
listeningTopicBadge.textContent = (data.topic || 'tech').toUpperCase();
|
||||
}
|
||||
|
||||
if (data.hint && listeningToggleHintBtn && listeningHintText) {
|
||||
listeningToggleHintBtn.classList.remove('hidden');
|
||||
listeningHintText.textContent = data.hint;
|
||||
} else if (listeningToggleHintBtn) {
|
||||
listeningToggleHintBtn.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Smooth focus on input
|
||||
setTimeout(() => {
|
||||
if (listeningInput && !srsModeListening.classList.contains('hidden')) {
|
||||
listeningInput.focus();
|
||||
}
|
||||
}, 50);
|
||||
|
||||
// Auto-play the story audio
|
||||
const speed = listeningSpeedSelect ? parseFloat(listeningSpeedSelect.value) || 1.0 : 1.0;
|
||||
if (data.story_audio_url) {
|
||||
playAudio(data.story_audio_url, speed);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load listening exercise:', e);
|
||||
if (listeningTitle) listeningTitle.textContent = 'Не удалось загрузить ситуацию. Нажмите "Другая история".';
|
||||
showToast('Ошибка загрузки аудирования', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
if (listeningPlayStoryBtn) {
|
||||
listeningPlayStoryBtn.addEventListener('click', () => {
|
||||
if (!currentListeningExercise || !currentListeningExercise.story_audio_url) return;
|
||||
const speed = listeningSpeedSelect ? parseFloat(listeningSpeedSelect.value) || 1.0 : 1.0;
|
||||
playAudio(currentListeningExercise.story_audio_url, speed);
|
||||
});
|
||||
}
|
||||
|
||||
if (listeningPlayQuestionBtn) {
|
||||
listeningPlayQuestionBtn.addEventListener('click', () => {
|
||||
if (!currentListeningExercise || !currentListeningExercise.question_audio_url) return;
|
||||
const speed = listeningSpeedSelect ? parseFloat(listeningSpeedSelect.value) || 1.0 : 1.0;
|
||||
playAudio(currentListeningExercise.question_audio_url, speed);
|
||||
});
|
||||
}
|
||||
|
||||
if (listeningToggleHintBtn) {
|
||||
listeningToggleHintBtn.addEventListener('click', () => {
|
||||
if (listeningHintText) listeningHintText.classList.toggle('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
if (listeningNewScenarioBtn) {
|
||||
listeningNewScenarioBtn.addEventListener('click', () => {
|
||||
loadListeningExercise(true);
|
||||
});
|
||||
}
|
||||
|
||||
if (listeningTopicSelect) {
|
||||
listeningTopicSelect.addEventListener('change', () => {
|
||||
loadListeningExercise(true);
|
||||
});
|
||||
}
|
||||
|
||||
if (listeningForm) {
|
||||
listeningForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (listeningAnswered || !currentListeningExercise) return;
|
||||
|
||||
const val = (listeningInput ? listeningInput.value : '').trim();
|
||||
if (!val) {
|
||||
showToast('Пожалуйста, напишите ваш ответ', 'info');
|
||||
if (listeningInput) listeningInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
listeningAnswered = true;
|
||||
if (listeningInput) listeningInput.disabled = true;
|
||||
if (listeningSubmitBtn) listeningSubmitBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await api.listening.checkAnswer(
|
||||
currentListeningExercise.id,
|
||||
val,
|
||||
currentListeningExercise.story,
|
||||
currentListeningExercise.ideal_answer || ''
|
||||
);
|
||||
|
||||
if (listeningFeedbackBox) {
|
||||
if (res.is_correct) {
|
||||
listeningFeedbackBox.className = 'trainer-feedback-box feedback-correct';
|
||||
listeningFeedbackBox.innerHTML = `🎉 <strong>${escapeHtml(res.feedback)}</strong>`;
|
||||
srsSessionRatings.push(4);
|
||||
} else {
|
||||
listeningFeedbackBox.className = 'trainer-feedback-box feedback-wrong';
|
||||
listeningFeedbackBox.innerHTML = `<strong>${escapeHtml(res.feedback)}</strong>`;
|
||||
srsSessionRatings.push(2);
|
||||
}
|
||||
listeningFeedbackBox.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Reveal details
|
||||
if (listeningIdealAnswer) listeningIdealAnswer.textContent = res.ideal_answer || '';
|
||||
if (listeningRevealedQuestion) listeningRevealedQuestion.textContent = res.question || currentListeningExercise.question || '';
|
||||
if (listeningRevealedQuestionRu) listeningRevealedQuestionRu.textContent = res.question_ru || currentListeningExercise.question_ru || '';
|
||||
if (listeningRevealedStory) listeningRevealedStory.textContent = res.story || currentListeningExercise.story || '';
|
||||
if (listeningRevealedStoryRu) listeningRevealedStoryRu.textContent = res.story_ru || currentListeningExercise.story_ru || '';
|
||||
if (listeningRevealedBox) listeningRevealedBox.classList.remove('hidden');
|
||||
|
||||
listeningCompletedCount++;
|
||||
srsCounter.textContent = `📻 Аудирование: решено ${listeningCompletedCount}`;
|
||||
|
||||
if (listeningNextBtn) listeningNextBtn.focus();
|
||||
} catch (err) {
|
||||
console.error('Listening answer check error:', err);
|
||||
if (listeningInput) listeningInput.disabled = false;
|
||||
if (listeningSubmitBtn) listeningSubmitBtn.disabled = false;
|
||||
listeningAnswered = false;
|
||||
showToast('Ошибка проверки ответа', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (listeningNextBtn) {
|
||||
listeningNextBtn.addEventListener('click', () => {
|
||||
loadListeningExercise(true);
|
||||
});
|
||||
}
|
||||
|
||||
function showSrsSummary() {
|
||||
hideAllSrsModeContainers();
|
||||
srsSummary.classList.remove('hidden');
|
||||
@@ -682,6 +891,18 @@ export function initSrs() {
|
||||
e.preventDefault();
|
||||
quizNextBtn.click();
|
||||
}
|
||||
} else if (srsCurrentMode === 'listening') {
|
||||
if (listeningAnswered) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (listeningNextBtn) listeningNextBtn.click();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement !== listeningInput && e.code === 'Space') {
|
||||
e.preventDefault();
|
||||
if (listeningPlayStoryBtn) listeningPlayStoryBtn.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+1
-1
@@ -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-v6.4';
|
||||
const CACHE_NAME = 'notes-v6.5';
|
||||
const OFFLINE_URL = '/';
|
||||
|
||||
const PRECACHE_URLS = [
|
||||
|
||||
@@ -2280,6 +2280,76 @@ def test_piper_tts_endpoints_and_caching():
|
||||
assert "/api/audio/" in card_data["context_audio_url"]
|
||||
|
||||
|
||||
def test_listening_exercise_and_check():
|
||||
login_resp = client.post("/api/login", json={"username": "testadmin", "password": "testpass12345"})
|
||||
assert login_resp.status_code == 200
|
||||
token = login_resp.cookies.get(config.SESSION_COOKIE_NAME)
|
||||
auth_headers = {"Cookie": f"{config.SESSION_COOKIE_NAME}={token}"}
|
||||
|
||||
# 1. Fetch listening exercise for tech topic
|
||||
resp = client.get("/api/listening/exercise?topic=tech&level=B1", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
ex = resp.json()
|
||||
assert "id" in ex
|
||||
assert ex["topic"] == "tech"
|
||||
assert "story" in ex
|
||||
assert "question" in ex
|
||||
assert "/api/audio/" in ex["story_audio_url"]
|
||||
assert "/api/audio/" in ex["question_audio_url"]
|
||||
# Check that client cannot inspect ideal_answer or keywords before submitting
|
||||
assert "ideal_answer" not in ex
|
||||
assert "keywords" not in ex
|
||||
|
||||
# 2. Check evaluation: correct answer for tech-01
|
||||
check_resp = client.post(
|
||||
"/api/listening/check",
|
||||
json={
|
||||
"scenario_id": "tech-01",
|
||||
"user_answer": "At midnight to minimize downtime",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert check_resp.status_code == 200
|
||||
res = check_resp.json()
|
||||
assert res["is_correct"] is True
|
||||
assert res["score"] == 100
|
||||
assert "midnight" in res["ideal_answer"].lower()
|
||||
assert "story" in res
|
||||
assert "story_ru" in res
|
||||
|
||||
# 3. Check evaluation: wrong answer for tech-01
|
||||
check_wrong = client.post(
|
||||
"/api/listening/check",
|
||||
json={
|
||||
"scenario_id": "tech-01",
|
||||
"user_answer": "Tomorrow morning at sunrise",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert check_wrong.status_code == 200
|
||||
res_wrong = check_wrong.json()
|
||||
assert res_wrong["is_correct"] is False
|
||||
assert res_wrong["score"] < 50
|
||||
|
||||
# 4. Check exercise from user's vocabulary cards
|
||||
# First ensure a card with context sentence exists
|
||||
client.post(
|
||||
"/api/cards",
|
||||
json={
|
||||
"term": "refactoring",
|
||||
"translation": "рефакторинг",
|
||||
"context_sentence": "Clean architecture requires continuous refactoring of legacy components.",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
card_ex_resp = client.get("/api/listening/exercise?topic=cards", headers=auth_headers)
|
||||
assert card_ex_resp.status_code == 200
|
||||
card_ex = card_ex_resp.json()
|
||||
assert card_ex["topic"] == "cards"
|
||||
assert "/api/audio/" in card_ex["story_audio_url"]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user