feat(lessons): implement comprehensive lesson configurator, session runner and AI debrief (Beta)

This commit is contained in:
Antigravity committed 2026-10-07 13:03:16 +03:00
1 parent 31e105b2c2
commit 718e9797eb
12 files changed
+4093 -1

No files matched your search

+28 -1
View File
@@ -416,7 +416,34 @@
- Викторина SRS (кнопка 🔊 озвучки слова).
- Читалка (кнопка 🔊 у слова в попапе, 🔊 у всего предложения, и 🔊 RU для русского перевода).
- Словарь (кнопка 🔊 в результатах поиска).
- [ ] **Второй этап**: интерактивный тренажёр аудирования (Listening & Q&A) с вопросами от нейросети и проверкой понимания на слух.
- [x] **Второй этап**: интерактивный тренажёр аудирования (Listening & Q&A) с ситуационными сценариями, вопросами диктора, проверкой понимания на слух и билингвальными транскриптами.
- [x] **Мульти-голосовой переключатель**: 6 моделей Piper TTS (3 EN: Paula, Ryan, Alan; 3 RU: Irina, Denis, Dmitri) с калибровкой темпа и глубоких пауз на тире/знаках препинания.
---
### 19. 🚀 Конструктор комплексного урока & Сессионный раннер (Beta) (Completed)
- [x] **Гибкий конфигуратор структуры урока**:
- Настройка порядка блоков (стрелки ▲/▼), включение/выключение этапов (Toggle-свитчеры), регулировка объёма (5/10/15/20 слов, 3/5/10 упражнений, 1/2/3 аудио-ситуации).
- Готовые встроенные шаблоны в 1 клик:
- ⚡ «Утренний экспресс (10 мин)» (слова + ретро-тест + 1 ситуация на слух).
- 🎯 «Сбалансированный урок (20 мин)» (слова + закрепление + новая теория Мерфи + 2 ситуации).
- 📖 «Глубокое погружение (30 мин)» (полный стек: слова, грамматика, аудирование и чтение статьи).
- 🎧 «Аудио + Чтение (15 мин)» (понимание на слух и работа с текстом).
- Возможность сохранить любой собственный план в базу SQLite под своим именем.
- [x] **Бесшовный сессионный раннер урока**:
- Единый режим урока с пошаговым хлебным прогресс-баром (`[1] Слова ➔ [2] Закрепление ➔ [3] Новая тема ➔ [4] Аудио ➔ [5] Чтение ➔ [6] ИИ-Итог`).
- Таймер сессии, мгновенный автопереход между блоками без кликов по меню.
- Оффлайн-база познавательных микро-статей (Наука, IT, Природа, Космос, История) с кликабельным переводом слов и кнопкой «+ В словарь».
- Умный адаптивный выбор темы грамматики для повторения: «Самая слабая тема (по ошибкам)», «Предыдущая тема» или выбор любого юнита вручную.
- [x] **Сквозная телеметрия ошибок & ИИ-дебрифинг (Qwen 2.5 3B)**:
- Фиксация ошибок на каждом этапе: забытые слова, нарушенные правила Мерфи, пропущенные детали аудирования.
- Обогащение историческим контекстом: сопоставление с прошлыми ошибками в `srs_logs` и `grammar_progress`.
- Итоговый педагогический разбор от Qwen 2.5 3B: итоговая оценка сессии (A+, A, B, C), разбор паттернов ошибок с формулами Мерфи, сильные стороны и персональный фокус на завтра.
- Мгновенный детерминированный fallback: если Ollama занята или выключена, урок завершается моментальным структурированным математическим отчётом без зависаний.
- [x] **Интеграция в интерфейс & тесты**:
- Отдельный пункт в боковом меню `🚀 Комплексный урок (Beta)` и хэш-маршрут `#lesson`.
- Модальное окно истории комплексных уроков с сохранением результатов сессий в `lesson_sessions`.
- 44/44 проходящих автоматизированных теста в `tests/test_api.py`.
---
+226
View File
@@ -350,3 +350,229 @@ async def warmup_ollama_model() -> bool:
err_msg = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
logger.warning(f"Ollama warmup deferred ({err_msg})")
return False
def generate_deterministic_debrief(
session_data: Dict[str, Any],
history_data: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Fast rule-based analytical debrief engine with Murphy pedagogic insight.
Ensures zero server delay and 100% reliable fallback if Ollama is offline.
"""
history_data = history_data or {}
total_score = int(session_data.get("total_score", 0))
errors = session_data.get("errors", [])
steps = session_data.get("steps", [])
if total_score >= 90:
grade = "A+"
headline = "Превосходная сессия! Отличная концентрация и высокая точность."
elif total_score >= 75:
grade = "A"
headline = "Отличная работа! Большинство заданий выполнено уверенно."
elif total_score >= 60:
grade = "B"
headline = "Хороший результат! Есть отдельные нюансы для закрепления."
else:
grade = "C"
headline = "Урок завершён! Рекомендуем уделить внимание повторению разобранных тем."
# Identify strengths
strengths = []
for s in steps:
st_type = s.get("type", "")
st_score = s.get("score", 100)
if st_score >= 80:
if st_type == "vocab":
strengths.append(f"Уверенное воспроизведение словарного запаса ({s.get('correct', 0)} из {s.get('total', 0)} слов).")
elif st_type == "grammar_review":
strengths.append("Успешное закрепление ранее изученных грамматических правил.")
elif st_type == "grammar_new":
strengths.append("Быстрое освоение новой грамматической темы Мерфи.")
elif st_type == "listening":
strengths.append("Отличное распознавание английской речи и ключевых деталей на слух.")
elif st_type == "reading":
words_saved = s.get("words_saved", 0)
strengths.append(f"Качественная словарная работа с текстом (+{words_saved} новых слов в словарь).")
if not strengths:
strengths.append("Вы успешно завершили все запланированные этапы комплексного урока.")
# Identify error patterns
error_patterns = []
chronic_vocab = set(history_data.get("chronic_vocab_errors", []))
past_grammar = set(history_data.get("past_grammar_errors", []))
for err in errors[:5]:
err_type = err.get("type", "")
if err_type == "grammar":
title = err.get("unit_title", "Грамматика")
unit_num = err.get("unit_num", 0)
formula = err.get("formula", "")
exp = err.get("explanation", "")
is_repeating = unit_num in past_grammar
repeat_tag = " [Повторяющаяся ошибка]" if is_repeating else ""
msg = f"{title}{repeat_tag}: {exp}" if exp else f"{title}{repeat_tag}: обратите внимание на формулу {formula}."
if msg not in error_patterns:
error_patterns.append(msg)
elif err_type == "vocab":
term = err.get("term", "")
trans = err.get("translation", "")
is_chronic = term.lower() in chronic_vocab
tag = " [Трудное слово — уже встречалась ошибка]" if is_chronic else ""
error_patterns.append(f"Слово «{term}» ({trans}){tag}: требует повторного прогона в тренажёре.")
elif err_type == "listening":
q = err.get("question", "")
ideal = err.get("ideal_answer", "")
error_patterns.append(f"Аудирование («{q}»): диктор обращал внимание на ответ «{ideal}».")
if not error_patterns:
error_patterns.append("Ошибок в этой сессии не зафиксировано! Все тестовые задания решены верно.")
# Tomorrow focus
tomorrow_focus = []
grammar_errors = [e for e in errors if e.get("type") == "grammar"]
vocab_errors = [e for e in errors if e.get("type") == "vocab"]
if grammar_errors:
u_name = grammar_errors[0].get("unit_title", "пройденную тему")
tomorrow_focus.append(f"Закрепить правило: {u_name}.")
if vocab_errors:
terms_str = ", ".join([f"«{e.get('term')}»" for e in vocab_errors[:3]])
tomorrow_focus.append(f"Повторить проблемные слова: {terms_str}.")
if not tomorrow_focus:
tomorrow_focus.append("Продолжить движение по учебнику Мерфи и расширять словарный запас.")
return {
"grade": grade,
"score": total_score,
"headline": headline,
"strengths": strengths[:3],
"error_patterns": error_patterns[:4],
"tomorrow_focus": tomorrow_focus[:3],
"source": "fallback:deterministic",
}
async def generate_lesson_ai_debrief(
session_data: Dict[str, Any],
history_data: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Generates a personalized pedagogic debrief using local LLM (Qwen 2.5 3B).
Blends deterministic fact verification with natural narrative mentorship.
Gracefully falls back to deterministic analysis if LLM is offline or delayed.
"""
history_data = history_data or {}
total_score = int(session_data.get("total_score", 0))
preset_name = session_data.get("preset_name", "Комплексный урок")
duration_min = round(session_data.get("duration_seconds", 0) / 60)
errors = session_data.get("errors", [])
steps = session_data.get("steps", [])
# Format steps summary
steps_lines = []
for s in steps:
steps_lines.append(f"- {s.get('title', s.get('type', 'Блок'))}: результат {s.get('score', 100)}%")
steps_summary_text = "\n".join(steps_lines) if steps_lines else "Все блоки пройдены"
# Format errors summary
errors_lines = []
for e in errors:
e_type = e.get("type", "")
if e_type == "grammar":
errors_lines.append(
f"- [Грамматика {e.get('unit_title', '')}]: ответ студента: '{e.get('user_answer', '')}', "
f"правильно: '{e.get('correct_answer', '')}'. Формула: {e.get('formula', '')}. {e.get('explanation', '')}"
)
elif e_type == "vocab":
errors_lines.append(
f"- [Слово]: '{e.get('term', '')}' ({e.get('translation', '')}) — ошибка в карточке."
)
elif e_type == "listening":
errors_lines.append(
f"- [Аудирование]: Вопрос '{e.get('question', '')}', ответ студента: '{e.get('user_answer', '')}', "
f"правильный ответ: '{e.get('ideal_answer', '')}'"
)
errors_text = "\n".join(errors_lines) if errors_lines else "Ошибок нет, 100% правильных ответов."
# Format historical context
chronic_vocab = history_data.get("chronic_vocab_errors", [])
past_grammar = history_data.get("past_grammar_errors", [])
history_lines = []
if chronic_vocab:
history_lines.append(f"- Слова с частыми ошибками в прошлом: {', '.join(chronic_vocab[:5])}")
if past_grammar:
history_lines.append(f"- Юниты грамматики с низкой точностью в прошлом: {', '.join(map(str, past_grammar[:3]))}")
history_text = "\n".join(history_lines) if history_lines else "История чистая или ошибок ранее не зафиксировано."
prompt = (
"Ты — чуткий, высококвалифицированный преподаватель английского языка по методике Raymond Murphy.\n"
"Студент только что завершил комплексный урок. Проанализируй его результаты, свяжи ошибки с грамматическими формулами "
"и составь лаконичный, мотивирующий и практически полезный педагогический разбор на русском языке.\n\n"
f"Урок: {preset_name}\n"
f"Длительность: {duration_min} мин\n"
f"Итоговый балл: {total_score}%\n\n"
f"Пройденные блоки:\n{steps_summary_text}\n\n"
f"Ошибки в этой сессии:\n{errors_text}\n\n"
f"История прошлых трудностей:\n{history_text}\n\n"
"Требования к ответу:\n"
"1. Ответь ИСКЛЮЧИТЕЛЬНО в формате JSON.\n"
"2. Поле 'headline': краткий, воодушевляющий заголовок (1 предложение).\n"
"3. Поле 'strengths': список из 1-3 пунктов, что получилось лучше всего.\n"
"4. Поле 'error_patterns': список из 1-3 пунктов с объяснением допущенных ошибок (если были) через контраст и формулы Мерфи. Если ошибок не было — похвали за точность.\n"
"5. Поле 'tomorrow_focus': список из 2-3 конкретных рекомендаций на завтра.\n\n"
"Формат JSON:\n"
"{\n"
f' "grade": "{"A+" if total_score >= 90 else ("A" if total_score >= 75 else ("B" if total_score >= 60 else "C"))}",\n'
f' "score": {total_score},\n'
' "headline": "заголовок",\n'
' "strengths": ["пункт 1", "пункт 2"],\n'
' "error_patterns": ["разбор ошибки 1", "разбор ошибки 2"],\n'
' "tomorrow_focus": ["совет 1", "совет 2"]\n'
"}"
)
payload = {
"model": config.OLLAMA_MODEL,
"prompt": prompt,
"stream": False,
"format": "json",
"keep_alive": "24h",
"options": {
"temperature": 0.3,
"num_predict": 450,
},
}
try:
async with httpx.AsyncClient(timeout=25.0) as client:
resp = await client.post(f"{config.OLLAMA_BASE_URL}/api/generate", json=payload)
if resp.status_code == 200:
raw_response = resp.json().get("response", "{}")
# Parse JSON
clean_raw = raw_response.strip()
if clean_raw.startswith("```json"):
clean_raw = clean_raw[7:]
if clean_raw.startswith("```"):
clean_raw = clean_raw[3:]
if clean_raw.endswith("```"):
clean_raw = clean_raw[:-3]
data = json.loads(clean_raw.strip())
return {
"grade": data.get("grade", "A" if total_score >= 75 else "B"),
"score": total_score,
"headline": data.get("headline", "Отличная сессия!"),
"strengths": data.get("strengths", ["Хорошая работа на уроке"]),
"error_patterns": data.get("error_patterns", []),
"tomorrow_focus": data.get("tomorrow_focus", ["Продолжайте ежедневные занятия"]),
"source": f"ollama:{config.OLLAMA_MODEL}",
}
except Exception as e:
logger.warning(f"Ollama debrief generation deferred to deterministic engine: {e}")
# Fallback to deterministic debrief
return generate_deterministic_debrief(session_data, history_data)
+34
View File
@@ -385,6 +385,40 @@ def _migrate(conn: sqlite3.Connection):
conn.execute("CREATE INDEX IF NOT EXISTS idx_bg_tasks_user_status ON background_tasks(user_id, status);")
conn.execute("CREATE INDEX IF NOT EXISTS idx_bg_tasks_status ON background_tasks(status);")
# Ensure lesson_presets and lesson_sessions tables exist (Lesson Configurator & Runner)
conn.execute("""
CREATE TABLE IF NOT EXISTS lesson_presets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
icon TEXT DEFAULT '🚀',
description TEXT DEFAULT '',
estimated_min INTEGER DEFAULT 15,
config_json TEXT NOT NULL,
is_default INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_lesson_presets_user ON lesson_presets(user_id);")
conn.execute("""
CREATE TABLE IF NOT EXISTS lesson_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
preset_name TEXT NOT NULL,
duration_seconds INTEGER DEFAULT 0,
total_score INTEGER DEFAULT 0,
grade TEXT DEFAULT 'A',
steps_summary_json TEXT NOT NULL,
errors_json TEXT NOT NULL,
ai_report_json TEXT NOT NULL,
completed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_lesson_sess_user_date ON lesson_sessions(user_id, completed_at DESC);")
# Repair legacy cards with placeholder blanks or mismatched context
repair_mismatched_cefr_contexts(conn)
+649
View File
@@ -0,0 +1,649 @@
"""
Comprehensive Lesson Builder & Runner Module (Beta).
Enables customizable, multi-step learning workflows (Words -> Grammar -> Listening -> Reading -> AI Debrief).
100% offline, zero-dependency, pure Python stdlib + SQLite.
"""
import json
import sqlite3
import random
from typing import Dict, Any, List, Optional
from datetime import datetime
# -----------------------------------------------------------------------------
# Curated High-Yield Bite-Sized Reading Articles & Facts
# -----------------------------------------------------------------------------
CURATED_ARTICLES: List[Dict[str, Any]] = [
{
"id": "art-01",
"title": "The James Webb Space Telescope: Peering into the Cosmic Dawn",
"title_ru": "Космический телескоп Джеймс Уэбб: взгляд в космический рассвет",
"topic": "Science",
"level": "B1",
"read_time_min": 2,
"paragraphs": [
"Operating in the cold vacuum of deep space, the James Webb Space Telescope observes the universe in high-resolution infrared light. Its massive gold-coated beryllium mirror captures faint photons emitted by the earliest galaxies over thirteen billion years ago.",
"Unlike optical telescopes, infrared astronomy allows scientists to pierce through dense cosmic dust clouds where stars and planetary systems are born. Recently, the observatory discovered complex organic molecules in distant stellar nurseries, challenging previous models of early cosmic chemistry."
],
"paragraphs_ru": [
"Работая в холодном вакууме глубокого космоса, космический телескоп «Джеймс Уэбб» наблюдает за Вселенной в инфракрасном диапазоне высокого разрешения. Его массивное бериллиевое зеркало с золотым покрытием улавливает слабые фотоны, испущенные самыми ранними галактиками более тринадцати миллиардов лет назад.",
"В отличие от оптических телескопов, инфракрасная астрономия позволяет учёным проникать сквозь плотные облака космической пыли, где рождаются звёзды и планетные системы. Недавно обсерватория обнаружила сложные органические молекулы в далёких звёздных колыбелях, что поставило под сомнение прежние модели ранней космической химии."
],
"vocabulary_highlights": [
{"term": "vacuum", "trans": "вакуум, пустота"},
{"term": "beryllium", "trans": "бериллий"},
{"term": "faint", "trans": "слабый, едва заметный"},
{"term": "photons", "trans": "фотоны"},
{"term": "stellar nurseries", "trans": "звёздные колыбели"},
{"term": "pierce through", "trans": "проникать сквозь, протыкать"}
]
},
{
"id": "art-02",
"title": "The Hidden Architecture of Neural Networks",
"title_ru": "Скрытая архитектура нейронных сетей",
"topic": "Technology",
"level": "B2",
"read_time_min": 2,
"paragraphs": [
"Modern artificial neural networks are loosely inspired by the biological connections in the human cortex. Each artificial neuron computes a weighted sum of its inputs and applies a non-linear activation function before propagating the signal onward.",
"During the backpropagation process, the network calculates gradient vectors to incrementally adjust billions of internal parameters. This iterative mathematical tuning enables language models to perceive subtle nuances of syntax, sentiment, and context without explicit hard-coded rules."
],
"paragraphs_ru": [
"Современные искусственные нейронные сети отдалённо вдохновлены биологическими связями в коре головного мозга человека. Каждый искусственный нейрон вычисляет взвешенную сумму своих входов и применяет нелинейную функцию активации перед передачей сигнала дальше.",
"В процессе обратного распространения ошибки сеть вычисляет векторы градиента, чтобы постепенно корректировать миллиарды внутренних параметров. Эта итеративная математическая настройка позволяет языковым моделям улавливать тонкие нюансы синтаксиса, тональности и контекста без явных жестко заданных правил."
],
"vocabulary_highlights": [
{"term": "loosely inspired", "trans": "отдалённо вдохновлённый"},
{"term": "cortex", "trans": "кора (головного мозга)"},
{"term": "weighted sum", "trans": "взвешенная сумма"},
{"term": "propagating", "trans": "распространяющий, передающий"},
{"term": "gradient vectors", "trans": "векторы градиента"},
{"term": "subtle nuances", "trans": "тонкие нюансы"}
]
},
{
"id": "art-03",
"title": "The Remarkable Intelligence of Crows and Ravens",
"title_ru": "Удивительный интеллект ворон и воронов",
"topic": "Nature",
"level": "B1",
"read_time_min": 2,
"paragraphs": [
"Corvids, the bird family that includes ravens and crows, display cognitive abilities rivaling those of primates. Laboratory experiments reveal that crows can fashion specialized hooks from wire to retrieve food containers placed out of beak reach.",
"Furthermore, field observations demonstrate that wild crows remember specific human faces for several years and communicate warnings about hostile individuals to the rest of the flock across generations."
],
"paragraphs_ru": [
"Врановые — семейство птиц, включающее воронов и ворон, демонстрируют когнитивные способности, соперничающие со способностями приматов. Лабораторные эксперименты показывают, что вороны могут изготавливать специальные крючки из проволоки, чтобы доставать контейнеры с едой, находящиеся вне досягаемости клюва.",
"Более того, полевые наблюдения доказывают, что дикие вороны помнят конкретные человеческие лица в течение нескольких лет и передают предупреждения о недружелюбных особях остальной стае через поколения."
],
"vocabulary_highlights": [
{"term": "corvids", "trans": "врановые (птицы)"},
{"term": "cognitive abilities", "trans": "когнитивные способности"},
{"term": "rivaling", "trans": "соперничающий с"},
{"term": "fashion hooks", "trans": "изготавливать крючки"},
{"term": "hostile individuals", "trans": "враждебные особи, недоброжелатели"},
{"term": "flock", "trans": "стая"}
]
},
{
"id": "art-04",
"title": "Why We Sleep: The Science of Memory Consolidation",
"title_ru": "Зачем мы спим: наука о консолидации памяти",
"topic": "Health",
"level": "B1",
"read_time_min": 2,
"paragraphs": [
"While the body rests during deep sleep, the brain is engaged in vigorous maintenance work. The hippocampus transfers newly acquired information into the neocortex for long-term storage, effectively converting fragile short-term memories into enduring knowledge.",
"Simultaneously, the glymphatic system flushes away metabolic waste and toxic proteins that accumulate between neurons during waking hours. Sleep deprivation severely impairs this cleaning cycle, making rapid vocabulary retention far more difficult."
],
"paragraphs_ru": [
"Пока тело отдыхает во время глубокого сна, мозг занят активной работой по техобслуживанию. Гиппокамп переносит вновь полученную информацию в неокортекс для долговременного хранения, эффективно преобразуя хрупкие кратковременные воспоминания в прочные знания.",
"Одновременно с этим глимфатическая система вымывает метаболические отходы и токсичные белки, которые накапливаются между нейронами в часы бодрствования. Недосыпание серьёзно нарушает этот цикл очистки, делая быстрое запоминание словарного запаса намного более трудным."
],
"vocabulary_highlights": [
{"term": "vigorous maintenance", "trans": "активное техобслуживание / поддержка"},
{"term": "fragile memories", "trans": "хрупкие воспоминания"},
{"term": "enduring knowledge", "trans": "прочные, долговечные знания"},
{"term": "flushes away", "trans": "смывает, вымывает"},
{"term": "sleep deprivation", "trans": "депривация сна, недосыпание"},
{"term": "vocabulary retention", "trans": "удержание словарного запаса"}
]
},
{
"id": "art-05",
"title": "The Antikythera Mechanism: Ancient Greek Analog Computer",
"title_ru": "Антикитерский механизм: древнегреческий аналоговый компьютер",
"topic": "History",
"level": "B2",
"read_time_min": 2,
"paragraphs": [
"Discovered by sponge divers inside a Roman shipwreck near the Greek island of Antikythera in 1901, this corroded bronze device bewildered historians for decades. Modern micro-computed tomography revealed an intricate clockwork mechanism consisting of over thirty precision gear wheels.",
"Constructed around the second century BC, the mechanism accurately tracked the astronomical positions of the Sun, Moon, and five known planets. It could even predict solar and lunar eclipses years in advance, showcasing a technological sophistication unmatched until fourteenth-century mechanical clocks."
],
"paragraphs_ru": [
"Обнаруженное ловцами губок внутри римского кораблекрушения возле греческого острова Антикитера в 1901 году, это корродированное бронзовое устройство приводило историков в замешательство на протяжении десятилетий. Современная микрокомпьютерная томография выявила сложнейший часовой механизм, состоящий из более чем тридцати прецизионных зубчатых колёс.",
"Созданный около второго века до нашей эры, механизм точно отслеживал астрономические положения Солнца, Луны и пяти известных планет. Он мог даже предсказывать солнечные и лунные затмения на годы вперёд, демонстрируя технологическую сложность, которой не было равных вплоть до механических часов XIV века."
],
"vocabulary_highlights": [
{"term": "sponge divers", "trans": "ловцы губок"},
{"term": "corroded bronze", "trans": "подвергшаяся коррозии бронза"},
{"term": "bewildered", "trans": "сбивал с толку, озадачивал"},
{"term": "gear wheels", "trans": "зубчатые колёса, шестерни"},
{"term": "lunar eclipses", "trans": "лунные затмения"},
{"term": "sophistication", "trans": "утончённость, сложность конструкции"}
]
},
{
"id": "art-06",
"title": "Underground Fungal Networks: The Wood Wide Web",
"title_ru": "Подземные грибные сети: лесная паутина",
"topic": "Nature",
"level": "B1",
"read_time_min": 2,
"paragraphs": [
"Beneath the floor of an ancient forest lies an astonishing symbiotic network of mycorrhizal fungi. These microscopic fungal threads, known as mycelium, weave through tree roots to exchange mineral nutrients and water for sugars generated through photosynthesis.",
"Remarkably, trees utilize this subterranean internet to transmit chemical distress signals when attacked by pests, allowing neighboring trees to synthesize defensive tannins before the infestation spreads."
],
"paragraphs_ru": [
"Под пологом древнего леса скрывается поразительная симбиотическая сеть микоризных грибов. Эти микроскопические грибные нити, известные как мицелий, оплетают корни деревьев, обменивая минеральные питательные вещества и воду на сахара, вырабатываемые в процессе фотосинтеза.",
"Примечательно, что деревья используют этот подземный интернет для передачи химических сигналов бедствия при нападении вредителей, позволяя соседним деревьям синтезировать защитные танины до того, как заражение распространится."
],
"vocabulary_highlights": [
{"term": "mycorrhizal fungi", "trans": "микоризные грибы"},
{"term": "mycelium", "trans": "мицелий, грибница"},
{"term": "subterranean", "trans": "подземный"},
{"term": "distress signals", "trans": "сигналы бедствия"},
{"term": "infestation", "trans": "заражение вредителями, нашествие"}
]
},
{
"id": "art-07",
"title": "Quantum Superposition and the Threshold of Reality",
"title_ru": "Квантовая суперпозиция и порог реальности",
"topic": "Science",
"level": "B2",
"read_time_min": 2,
"paragraphs": [
"In quantum mechanics, subatomic particles exist simultaneously in multiple probable states until an observation forces the wave function to collapse into a single definitive outcome. This fundamental counterintuitive principle is known as quantum superposition.",
"Quantum computers harness this phenomenon by employing qubits instead of classical binary bits. While a traditional bit can only register as a zero or a one, a qubit exists in a superposition of both states, granting exponential computational scaling for cryptographic and molecular simulation algorithms."
],
"paragraphs_ru": [
"В квантовой механике субатомные частицы существуют одновременно во множестве вероятностных состояний до тех пор, пока наблюдение не заставляет волновую функцию коллапсировать в один определённый исход. Этот фундаментальный парадоксальный принцип известен как квантовая суперпозиция.",
"Квантовые компьютеры используют этот феномен, применяя кубиты вместо классических двоичных битов. В то время как традиционный бит может принимать значение только ноль или единица, кубит существует в суперпозиции обоих состояний, обеспечивая экспоненциальное масштабирование вычислений для алгоритмов криптографии и моделирования молекул."
],
"vocabulary_highlights": [
{"term": "subatomic particles", "trans": "субатомные частицы"},
{"term": "counterintuitive", "trans": "парадоксальный, нелогичный на первый взгляд"},
{"term": "harness", "trans": "укрощать, эффективно использовать"},
{"term": "qubits", "trans": "кубиты"},
{"term": "exponential scaling", "trans": "экспоненциальное масштабирование"}
]
},
{
"id": "art-08",
"title": "The Evolution of Coffee Culture and the Age of Enlightenment",
"title_ru": "Эволюция кофейной культуры и Эпоха Просвещения",
"topic": "Culture",
"level": "A2",
"read_time_min": 2,
"paragraphs": [
"During the seventeenth century, European coffee houses emerged as lively hubs for intellectual debate, political discussion, and commercial trade. In London, these establishments were nicknamed 'penny universities' because for the price of a cup of coffee, any customer could participate in philosophical discourse.",
"Scientists like Isaac Newton and economists like Adam Smith regularly met in coffee houses to draft treatises and debate ideas that laid the foundations of the scientific revolution and modern commerce."
],
"paragraphs_ru": [
"В XVII веке европейские кофейни стали оживлёнными центрами интеллектуальных дебатов, политических дискуссий и коммерческой торговли. В Лондоне эти заведения называли «пенни-университетами», потому что по цене чашки кофе любой посетитель мог поучаствовать в философских рассуждениях.",
"Учёные вроде Исаака Ньютона и экономисты вроде Адама Смита регулярно собирались в кофейнях, чтобы составлять трактаты и обсуждать идеи, заложившие основы научной революции и современной коммерции."
],
"vocabulary_highlights": [
{"term": "lively hubs", "trans": "оживлённые центры"},
{"term": "philosophical discourse", "trans": "философские рассуждения / дискуссии"},
{"term": "draft treatises", "trans": "составлять трактаты"},
{"term": "laid the foundations", "trans": "заложили основы"}
]
}
]
# -----------------------------------------------------------------------------
# Default Lesson Presets (Ready-to-use Configurations)
# -----------------------------------------------------------------------------
DEFAULT_PRESETS: List[Dict[str, Any]] = [
{
"id": "express",
"name": "⚡ Утренний экспресс (10 мин)",
"icon": "⚡",
"description": "Быстрый разгон словарного запаса, ретро-тест грамматики и 1 аудио-ситуация.",
"estimated_min": 10,
"is_default": 1,
"steps": [
{
"id": "step_vocab",
"type": "vocab",
"title": "Вспоминаем слова (SRS)",
"enabled": True,
"count": 10,
"mode": "flashcards",
"filter": "due"
},
{
"id": "step_grammar_review",
"type": "grammar_review",
"title": "Закрепление грамматики",
"enabled": True,
"count": 5,
"topic_mode": "weakest"
},
{
"id": "step_listening",
"type": "listening",
"title": "Аудирование на слух",
"enabled": True,
"count": 1,
"topic": "all"
},
{
"id": "step_ai_debrief",
"type": "ai_debrief",
"title": "ИИ-Итог и рекомендации",
"enabled": True
}
]
},
{
"id": "balanced",
"name": "🎯 Сбалансированный урок (20 мин)",
"icon": "🎯",
"description": "Слова + закрепление прошлой темы + новая теория Мерфи + 2 ситуации на слух.",
"estimated_min": 20,
"is_default": 1,
"steps": [
{
"id": "step_vocab",
"type": "vocab",
"title": "Вспоминаем слова (SRS)",
"enabled": True,
"count": 10,
"mode": "quiz",
"filter": "due"
},
{
"id": "step_grammar_review",
"type": "grammar_review",
"title": "Закрепление грамматики",
"enabled": True,
"count": 5,
"topic_mode": "weakest"
},
{
"id": "step_grammar_new",
"type": "grammar_new",
"title": "Новая тема Мерфи",
"enabled": True,
"unit_mode": "auto_next",
"exercises_count": 5
},
{
"id": "step_listening",
"type": "listening",
"title": "Аудирование на слух",
"enabled": True,
"count": 2,
"topic": "all"
},
{
"id": "step_ai_debrief",
"type": "ai_debrief",
"title": "ИИ-Итог и рекомендации",
"enabled": True
}
]
},
{
"id": "deep",
"name": "📖 Глубокое погружение (30 мин)",
"icon": "📖",
"description": "Полный комплексный цикл: слова, закрепление, новая грамматика, аудирование и чтение.",
"estimated_min": 30,
"is_default": 1,
"steps": [
{
"id": "step_vocab",
"type": "vocab",
"title": "Вспоминаем слова (SRS)",
"enabled": True,
"count": 15,
"mode": "typing",
"filter": "due"
},
{
"id": "step_grammar_review",
"type": "grammar_review",
"title": "Закрепление грамматики",
"enabled": True,
"count": 5,
"topic_mode": "weakest"
},
{
"id": "step_grammar_new",
"type": "grammar_new",
"title": "Новая тема Мерфи",
"enabled": True,
"unit_mode": "auto_next",
"exercises_count": 5
},
{
"id": "step_listening",
"type": "listening",
"title": "Аудирование на слух",
"enabled": True,
"count": 2,
"topic": "all"
},
{
"id": "step_reading",
"type": "reading",
"title": "Чтение и словарная работа",
"enabled": True,
"source": "curated_article",
"article_id": "random"
},
{
"id": "step_ai_debrief",
"type": "ai_debrief",
"title": "ИИ-Итог и рекомендации",
"enabled": True
}
]
},
{
"id": "audio_read",
"name": "🎧 Аудио + Чтение (15 мин)",
"icon": "🎧",
"description": "Фокус на понимании на слух и чтении статьи с пополнением словаря.",
"estimated_min": 15,
"is_default": 1,
"steps": [
{
"id": "step_vocab",
"type": "vocab",
"title": "Вспоминаем слова (SRS)",
"enabled": True,
"count": 10,
"mode": "flashcards",
"filter": "due"
},
{
"id": "step_listening",
"type": "listening",
"title": "Аудирование на слух",
"enabled": True,
"count": 3,
"topic": "all"
},
{
"id": "step_reading",
"type": "reading",
"title": "Чтение и словарная работа",
"enabled": True,
"source": "curated_article",
"article_id": "random"
},
{
"id": "step_ai_debrief",
"type": "ai_debrief",
"title": "ИИ-Итог и рекомендации",
"enabled": True
}
]
}
]
# -----------------------------------------------------------------------------
# Database & Helper Functions
# -----------------------------------------------------------------------------
def get_user_presets(conn: sqlite3.Connection, user_id: int) -> List[Dict[str, Any]]:
"""Returns built-in system presets combined with user custom saved presets."""
result = []
# 1. System defaults
for p in DEFAULT_PRESETS:
item = dict(p)
item["is_custom"] = False
result.append(item)
# 2. User custom presets from SQLite
try:
rows = conn.execute(
"""
SELECT id, name, icon, description, estimated_min, config_json, created_at
FROM lesson_presets
WHERE user_id = ?
ORDER BY id ASC
""",
(user_id,),
).fetchall()
for r in rows:
cfg = json.loads(r["config_json"]) if r["config_json"] else []
result.append({
"id": f"custom_{r['id']}",
"custom_id": r["id"],
"name": r["name"],
"icon": r["icon"] or "🚀",
"description": r["description"] or "",
"estimated_min": r["estimated_min"] or 15,
"is_default": 0,
"is_custom": True,
"steps": cfg,
"created_at": r["created_at"],
})
except Exception:
pass
return result
def save_user_preset(
conn: sqlite3.Connection,
user_id: int,
name: str,
steps: List[Dict[str, Any]],
icon: str = "🚀",
description: str = "",
estimated_min: int = 15,
) -> Dict[str, Any]:
"""Saves a custom user lesson configuration to SQLite."""
config_json = json.dumps(steps, ensure_ascii=False)
cur = conn.execute(
"""
INSERT INTO lesson_presets (user_id, name, icon, description, estimated_min, config_json)
VALUES (?, ?, ?, ?, ?, ?)
""",
(user_id, name.strip(), icon, description.strip(), estimated_min, config_json),
)
new_id = cur.lastrowid
return {
"id": f"custom_{new_id}",
"custom_id": new_id,
"name": name.strip(),
"icon": icon,
"description": description.strip(),
"estimated_min": estimated_min,
"steps": steps,
"is_custom": True,
}
def delete_user_preset(conn: sqlite3.Connection, user_id: int, custom_id: int) -> bool:
"""Deletes a custom user lesson preset."""
cur = conn.execute(
"DELETE FROM lesson_presets WHERE id = ? AND user_id = ?",
(custom_id, user_id),
)
return cur.rowcount > 0
def get_grammar_recommendations(conn: sqlite3.Connection, user_id: int) -> Dict[str, Any]:
"""
Calculates intelligent grammar suggestions for the lesson:
- weakest_unit: unit with lowest score or most errors recorded in srs_logs
- previous_unit: the last unit completed by user
- next_unit: the first uncompleted unit in pedagogical sequence
- all_units: complete list of units for manual selection
"""
# 1. Fetch all grammar units
all_units_rows = conn.execute(
"""
SELECT u.id, u.unit_num, u.level, u.category, u.title, u.subtitle, u.golden_formula,
COALESCE(p.score, 0) as user_score,
COALESCE(p.is_completed, 0) as is_completed,
p.completed_at
FROM grammar_units u
LEFT JOIN grammar_progress p ON p.unit_id = u.id AND p.user_id = ?
ORDER BY u.unit_num ASC
""",
(user_id,),
).fetchall()
all_units = []
completed_units = []
uncompleted_units = []
for r in all_units_rows:
unit_info = {
"id": r["id"],
"unit_num": r["unit_num"],
"level": r["level"],
"category": r["category"],
"title": r["title"],
"subtitle": r["subtitle"] or "",
"golden_formula": r["golden_formula"] or "",
"score": r["user_score"],
"is_completed": bool(r["is_completed"]),
"completed_at": r["completed_at"],
}
all_units.append(unit_info)
if unit_info["is_completed"]:
completed_units.append(unit_info)
else:
uncompleted_units.append(unit_info)
# 2. Determine Weakest Unit (lowest score among completed units, or unit with lowest score)
weakest_unit = None
if completed_units:
weakest_unit = min(completed_units, key=lambda u: u["score"])
elif all_units:
weakest_unit = all_units[0]
# 3. Determine Previous Unit (most recently completed unit)
previous_unit = None
if completed_units:
# Sort by completed_at desc
sorted_completed = sorted(
completed_units,
key=lambda u: u["completed_at"] or "",
reverse=True,
)
previous_unit = sorted_completed[0]
elif all_units:
previous_unit = all_units[0]
# 4. Determine Next Unit (first uncompleted unit)
next_unit = uncompleted_units[0] if uncompleted_units else (all_units[0] if all_units else None)
return {
"weakest_unit": weakest_unit,
"previous_unit": previous_unit,
"next_unit": next_unit,
"all_units": all_units,
}
def get_curated_articles(topic: Optional[str] = None) -> List[Dict[str, Any]]:
"""Returns curated reading articles, optionally filtered by category."""
if not topic or topic.lower() == "all":
return CURATED_ARTICLES
return [a for a in CURATED_ARTICLES if a.get("topic", "").lower() == topic.lower()]
def record_lesson_session(
conn: sqlite3.Connection,
user_id: int,
preset_name: str,
duration_seconds: int,
total_score: int,
grade: str,
steps_summary: List[Dict[str, Any]],
errors_list: List[Dict[str, Any]],
ai_report: Dict[str, Any],
) -> int:
"""Stores the finished composite lesson session in SQLite."""
cur = conn.execute(
"""
INSERT INTO lesson_sessions (
user_id, preset_name, duration_seconds, total_score, grade,
steps_summary_json, errors_json, ai_report_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
user_id,
preset_name,
duration_seconds,
total_score,
grade,
json.dumps(steps_summary, ensure_ascii=False),
json.dumps(errors_list, ensure_ascii=False),
json.dumps(ai_report, ensure_ascii=False),
),
)
return cur.lastrowid
def get_lesson_history(conn: sqlite3.Connection, user_id: int, limit: int = 15) -> Dict[str, Any]:
"""Retrieves previous comprehensive lesson sessions for user analytics."""
rows = conn.execute(
"""
SELECT id, preset_name, duration_seconds, total_score, grade,
steps_summary_json, errors_json, ai_report_json, completed_at
FROM lesson_sessions
WHERE user_id = ?
ORDER BY completed_at DESC
LIMIT ?
""",
(user_id, limit),
).fetchall()
history = []
total_lessons = 0
total_time_sec = 0
avg_score = 0
score_sum = 0
for r in rows:
steps = json.loads(r["steps_summary_json"]) if r["steps_summary_json"] else []
errors = json.loads(r["errors_json"]) if r["errors_json"] else []
ai_rep = json.loads(r["ai_report_json"]) if r["ai_report_json"] else {}
history.append({
"id": r["id"],
"preset_name": r["preset_name"],
"duration_seconds": r["duration_seconds"],
"total_score": r["total_score"],
"grade": r["grade"],
"steps": steps,
"errors_count": len(errors),
"headline": ai_rep.get("headline", ""),
"completed_at": r["completed_at"],
})
total_lessons += 1
total_time_sec += r["duration_seconds"] or 0
score_sum += r["total_score"] or 0
if total_lessons > 0:
avg_score = round(score_sum / total_lessons)
return {
"total_lessons": total_lessons,
"total_time_minutes": round(total_time_sec / 60),
"avg_score": avg_score,
"history": history,
}
+191
View File
@@ -73,6 +73,17 @@ from app.listening import (
evaluate_listening_answer,
prewarm_all_listening_audio,
)
from app.lessons import (
get_user_presets,
save_user_preset,
delete_user_preset,
get_grammar_recommendations,
get_curated_articles,
record_lesson_session,
get_lesson_history,
)
from app.ai import generate_lesson_ai_debrief
@@ -2044,4 +2055,184 @@ async def api_tasks_cancel(
return {"status": "ok" if cancelled else "not_found", "cancelled": cancelled}
# -----------------------------------------------------------------------------
# Comprehensive Lesson Configurator & Runner Endpoints (Beta)
# -----------------------------------------------------------------------------
@app.get("/api/lessons/presets")
async def api_lessons_get_presets(user: dict = Depends(require_user)):
"""
Returns available lesson presets (defaults + user-custom).
"""
with get_db() as conn:
presets = get_user_presets(conn, user["id"])
return {"presets": presets}
class SavePresetRequest(BaseModel):
name: str
steps: List[Dict[str, Any]]
icon: Optional[str] = "🚀"
description: Optional[str] = ""
estimated_min: Optional[int] = 15
@app.post("/api/lessons/presets")
async def api_lessons_save_preset(
req: SavePresetRequest,
user: dict = Depends(require_user),
):
"""
Saves a custom user lesson configuration preset.
"""
if not req.name.strip():
raise HTTPException(status_code=400, detail="Название шаблона не может быть пустым")
with get_db() as conn:
saved = save_user_preset(
conn,
user_id=user["id"],
name=req.name,
steps=req.steps,
icon=req.icon or "🚀",
description=req.description or "",
estimated_min=req.estimated_min or 15,
)
return {"status": "ok", "preset": saved}
@app.delete("/api/lessons/presets/{preset_id}")
async def api_lessons_delete_preset(
preset_id: int,
user: dict = Depends(require_user),
):
"""
Deletes a custom user lesson preset.
"""
with get_db() as conn:
deleted = delete_user_preset(conn, user["id"], preset_id)
if not deleted:
raise HTTPException(status_code=404, detail="Пользовательский шаблон не найден")
return {"status": "ok", "deleted": True}
@app.get("/api/lessons/grammar-recommendations")
async def api_lessons_grammar_recommendations(user: dict = Depends(require_user)):
"""
Calculates intelligent grammar suggestions: weakest topic, last studied topic,
next in syllabus, and full units list.
"""
with get_db() as conn:
recs = get_grammar_recommendations(conn, user["id"])
return recs
@app.get("/api/lessons/articles")
async def api_lessons_articles(
topic: Optional[str] = None,
user: dict = Depends(require_user),
):
"""
Returns curated high-yield reading articles/facts for the lesson reading block.
"""
articles = get_curated_articles(topic=topic)
return {"articles": articles}
class LessonDebriefRequest(BaseModel):
preset_name: str
duration_seconds: int
total_score: int
steps: List[Dict[str, Any]]
errors: List[Dict[str, Any]]
@app.post("/api/lessons/debrief")
async def api_lessons_debrief(
req: LessonDebriefRequest,
user: dict = Depends(require_user),
):
"""
Generates a pedagogic AI debrief for the completed lesson session,
enriches with historical error context, and records the session.
"""
# 1. Fetch user error history for pedagogical pattern detection
with get_db() as conn:
chronic_rows = conn.execute(
"""
SELECT v.term, count(*) as err_cnt
FROM srs_logs l
JOIN vocabulary_cards v ON v.id = l.card_id
WHERE l.user_id = ? AND l.rating = 1
GROUP BY v.term
HAVING err_cnt > 1
ORDER BY err_cnt DESC
LIMIT 10
""",
(user["id"],),
).fetchall()
chronic_vocab = [r["term"] for r in chronic_rows]
prog_rows = conn.execute(
"""
SELECT u.unit_num
FROM grammar_progress p
JOIN grammar_units u ON u.id = p.unit_id
WHERE p.user_id = ? AND p.score < 80
""",
(user["id"],),
).fetchall()
past_grammar = [r["unit_num"] for r in prog_rows]
history_data = {
"chronic_vocab_errors": chronic_vocab,
"past_grammar_errors": past_grammar,
}
session_payload = {
"preset_name": req.preset_name,
"duration_seconds": req.duration_seconds,
"total_score": req.total_score,
"steps": req.steps,
"errors": req.errors,
}
# 2. Generate AI or deterministic debrief
ai_report = await generate_lesson_ai_debrief(session_payload, history_data)
# 3. Save completed session to SQLite
with get_db() as conn:
session_id = record_lesson_session(
conn=conn,
user_id=user["id"],
preset_name=req.preset_name,
duration_seconds=req.duration_seconds,
total_score=req.total_score,
grade=ai_report.get("grade", "A"),
steps_summary=req.steps,
errors_list=req.errors,
ai_report=ai_report,
)
return {
"session_id": session_id,
"ai_report": ai_report,
"total_score": req.total_score,
"grade": ai_report.get("grade", "A"),
}
@app.get("/api/lessons/history")
async def api_lessons_history(
limit: int = 15,
user: dict = Depends(require_user),
):
"""
Returns history of completed comprehensive lessons and overall learning metrics.
"""
with get_db() as conn:
data = get_lesson_history(conn, user["id"], limit=limit)
return data
+954
View File
@@ -7229,4 +7229,958 @@ body.drawer-open {
}
}
/* =======================================================================
Comprehensive Lesson Configurator & Session Runner (Beta)
======================================================================= */
.lesson-view, .lesson-runner-view {
padding: 24px 20px;
max-width: 960px;
margin: 0 auto;
width: 100%;
}
.lesson-configurator-container {
display: flex;
flex-direction: column;
gap: 20px;
}
.lesson-header-bar {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
border-bottom: 1px solid var(--border-color);
padding-bottom: 18px;
}
.lesson-title-wrap h2 {
font-size: 24px;
font-weight: 800;
margin: 0 0 6px 0;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.lesson-subtitle {
margin: 0;
font-size: 14px;
}
.badge-beta {
background: linear-gradient(135deg, #8b5cf6, #3b82f6);
color: #fff;
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge-ai {
background: rgba(139, 92, 246, 0.15);
color: #8b5cf6;
border: 1px solid rgba(139, 92, 246, 0.35);
font-size: 12px;
font-weight: 600;
padding: 4px 10px;
border-radius: var(--radius-sm);
}
.lesson-header-actions {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.pulse-btn {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.7);
animation: btnPulse 2s infinite;
}
@keyframes btnPulse {
0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4); }
70% { transform: scale(1.02); box-shadow: 0 0 0 10px rgba(59, 130, 246, 0); }
100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); }
}
/* Presets Chips Bar */
.lesson-presets-section {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 16px 20px;
}
.presets-section-title {
font-size: 14px;
font-weight: 700;
margin-bottom: 12px;
color: var(--text-main);
}
.lesson-presets-chips-row {
display: flex;
gap: 10px;
overflow-x: auto;
padding-bottom: 4px;
-webkit-overflow-scrolling: touch;
}
.lesson-preset-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 16px;
background: var(--bg-main);
border: 1px solid var(--border-color);
border-radius: 999px;
font-size: 13.5px;
font-weight: 600;
color: var(--text-main);
cursor: pointer;
white-space: nowrap;
transition: all 0.15s ease;
}
.lesson-preset-chip:hover {
border-color: var(--primary);
background: var(--bg-hover);
}
.lesson-preset-chip.active {
background: var(--primary);
color: #fff;
border-color: var(--primary);
}
.preset-del-btn {
margin-left: 6px;
opacity: 0.7;
font-size: 12px;
border-radius: 50%;
padding: 2px 4px;
}
.preset-del-btn:hover {
opacity: 1;
background: rgba(0, 0, 0, 0.2);
}
.preset-description-text {
font-size: 13px;
margin: 8px 0 0 0;
}
/* Blocks List & Cards */
.blocks-section-header {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.blocks-section-header h3 {
margin: 0;
font-size: 18px;
font-weight: 700;
}
.blocks-tip {
font-size: 12.5px;
}
.lesson-steps-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.lesson-step-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 16px 18px;
transition: all 0.2s ease;
}
.lesson-step-card:hover {
border-color: rgba(59, 130, 246, 0.4);
}
.lesson-step-card.step-disabled {
opacity: 0.55;
background: var(--bg-main);
}
.step-card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.step-card-title-wrap {
display: flex;
align-items: center;
gap: 10px;
}
.step-num {
width: 26px;
height: 26px;
background: var(--bg-main);
border: 1px solid var(--border-color);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 800;
}
.step-icon {
font-size: 18px;
}
.step-title {
font-size: 15px;
}
.step-card-actions {
display: flex;
align-items: center;
gap: 8px;
}
/* Switch Toggle */
.switch-toggle {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
}
.switch-toggle input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0; left: 0; right: 0; bottom: 0;
background-color: var(--border-color);
transition: .2s;
border-radius: 24px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .2s;
border-radius: 50%;
}
input:checked + .toggle-slider {
background-color: var(--success);
}
input:checked + .toggle-slider:before {
transform: translateX(20px);
}
/* Step Card Body & Params */
.step-card-body {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--border-color);
}
.step-param-row {
display: flex;
gap: 16px;
flex-wrap: wrap;
align-items: flex-end;
}
.param-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.param-group label {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
}
.param-group.flex-2 {
flex: 2;
min-width: 220px;
}
.segmented-control {
display: inline-flex;
background: var(--bg-main);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 2px;
gap: 2px;
}
.seg-btn {
border: none;
background: transparent;
padding: 6px 14px;
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
}
.seg-btn:hover {
color: var(--text-main);
}
.seg-btn.active {
background: var(--primary);
color: #fff;
}
.ai-debrief-info-box {
display: flex;
align-items: center;
gap: 12px;
background: rgba(139, 92, 246, 0.08);
border: 1px dashed rgba(139, 92, 246, 0.3);
padding: 12px 16px;
border-radius: var(--radius-md);
font-size: 13.5px;
color: var(--text-main);
}
.ai-box-icon {
font-size: 24px;
}
.lesson-bottom-cta {
padding: 10px 0;
}
.btn-xl {
padding: 14px 24px;
font-size: 17px;
font-weight: 800;
border-radius: var(--radius-md);
}
/* =======================================================================
Lesson Session Runner Stage
======================================================================= */
.lesson-runner-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 14px 0;
border-bottom: 1px solid var(--border-color);
flex-wrap: wrap;
}
.runner-nav-left {
display: flex;
align-items: center;
gap: 14px;
}
.runner-timer-wrap {
font-family: var(--font-mono, monospace);
font-size: 15px;
font-weight: 700;
color: var(--text-main);
background: var(--bg-card);
padding: 4px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-color);
}
.runner-breadcrumbs-row {
display: flex;
align-items: center;
gap: 8px;
overflow-x: auto;
max-width: 550px;
padding: 4px 0;
-webkit-overflow-scrolling: touch;
}
.runner-crumb {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
background: var(--bg-card);
border: 1px solid var(--border-color);
color: var(--text-muted);
white-space: nowrap;
}
.crumb-num {
font-size: 11px;
font-weight: 800;
}
.runner-crumb.step-done {
background: rgba(34, 197, 94, 0.15);
border-color: rgba(34, 197, 94, 0.4);
color: var(--success);
}
.runner-crumb.step-active {
background: var(--primary);
border-color: var(--primary);
color: #fff;
box-shadow: 0 0 10px rgba(59, 130, 246, 0.4);
}
.crumb-separator {
color: var(--text-dim);
font-size: 11px;
}
.lesson-runner-progress-track {
width: 100%;
height: 4px;
background: var(--bg-hover);
overflow: hidden;
margin-bottom: 24px;
}
.lesson-runner-progress-fill {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
transition: width 0.3s ease;
}
.step-card-box {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 28px 24px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
max-width: 760px;
margin: 0 auto;
}
.step-mini-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 18px;
gap: 10px;
flex-wrap: wrap;
}
.step-term-display {
text-align: center;
padding: 20px 0;
}
.display-term {
font-size: 36px;
font-weight: 800;
margin: 0 0 6px 0;
color: var(--text-main);
}
.display-phonetic {
font-size: 18px;
color: var(--text-muted);
}
.step-context-box {
background: var(--bg-main);
border-left: 3px solid var(--primary);
padding: 12px 16px;
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
margin-top: 14px;
}
.translation-reveal-banner {
background: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.3);
padding: 14px 18px;
border-radius: var(--radius-md);
text-align: center;
font-size: 20px;
color: var(--text-main);
}
.rating-buttons-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.srs-btn-rating {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 6px;
gap: 4px;
}
.rating-key {
font-size: 11px;
opacity: 0.7;
}
.rating-label {
font-size: 13px;
font-weight: 700;
}
/* Grammar Questions Stage */
.grammar-question-box {
background: var(--bg-main);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 20px 18px;
}
.grammar-sentence-prompt {
font-size: 20px;
font-weight: 600;
line-height: 1.5;
margin: 0 0 10px 0;
}
.clue-tag {
font-size: 13px;
color: var(--text-muted);
}
.grammar-options-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.grammar-opt-btn {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 18px;
background: var(--bg-main);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
cursor: pointer;
font-size: 15px;
font-weight: 600;
color: var(--text-main);
transition: all 0.15s ease;
text-align: left;
}
.grammar-opt-btn:hover:not(:disabled) {
border-color: var(--primary);
background: var(--bg-hover);
}
.grammar-opt-btn.opt-correct {
background: rgba(34, 197, 94, 0.18) !important;
border-color: var(--success) !important;
color: var(--success) !important;
}
.grammar-opt-btn.opt-wrong {
background: rgba(239, 68, 68, 0.18) !important;
border-color: var(--danger) !important;
color: var(--danger) !important;
}
.opt-key {
width: 22px;
height: 22px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
}
.feedback-banner {
padding: 16px 18px;
border-radius: var(--radius-md);
}
.feedback-banner.fb-success {
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(34, 197, 94, 0.35);
}
.feedback-banner.fb-danger {
background: rgba(239, 68, 68, 0.12);
border: 1px solid rgba(239, 68, 68, 0.35);
}
.fb-title {
font-size: 16px;
font-weight: 700;
margin-bottom: 6px;
}
.fb-text {
font-size: 14px;
line-height: 1.4;
}
/* Audio Player Card */
.audio-player-card {
background: var(--bg-main);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 20px;
text-align: center;
}
.audio-controls-row {
display: flex;
justify-content: center;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.answer-input-group {
display: flex;
gap: 10px;
}
.transcript-box {
background: var(--bg-main);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 12px 14px;
font-size: 13.5px;
}
/* Reading Article Interactive */
.article-paragraph-card {
background: var(--bg-main);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 16px 18px;
margin-bottom: 14px;
}
.article-para-en {
font-size: 17px;
line-height: 1.7;
margin: 0 0 10px 0;
}
.clickable-reader-word {
cursor: pointer;
border-radius: 3px;
padding: 1px 2px;
transition: background 0.15s;
}
.clickable-reader-word:hover {
background: rgba(59, 130, 246, 0.25);
color: var(--primary);
}
.article-para-ru {
font-size: 14px;
color: var(--text-muted);
border-top: 1px dashed var(--border-color);
padding-top: 8px;
margin: 8px 0 0 0;
}
.vocab-highlights-bar {
background: var(--bg-main);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 14px 16px;
}
.highlights-title {
font-size: 13px;
font-weight: 700;
margin-bottom: 8px;
display: block;
}
.highlights-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.vh-chip {
background: var(--bg-card);
border: 1px solid var(--border-color);
padding: 4px 10px;
border-radius: 999px;
font-size: 12.5px;
}
/* Word Popup in Lesson */
.lesson-popup {
position: fixed;
z-index: 1000;
width: 250px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 12px 14px;
box-shadow: 0 8px 30px rgba(0,0,0,0.18);
animation: fadeIn 0.15s ease-out;
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border-color);
padding-bottom: 6px;
margin-bottom: 6px;
}
.popup-close-btn {
border: none;
background: transparent;
cursor: pointer;
color: var(--text-muted);
}
.popup-trans {
font-size: 14px;
font-weight: 600;
color: var(--text-main);
}
/* Debrief Final Card */
.lesson-debrief-card {
max-width: 800px;
}
.debrief-banner-header {
display: flex;
align-items: center;
gap: 20px;
border-bottom: 1px solid var(--border-color);
padding-bottom: 20px;
flex-wrap: wrap;
}
.grade-badge-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 90px;
height: 90px;
background: var(--bg-main);
border: 3px solid var(--primary);
border-radius: 50%;
}
.grade-badge {
font-size: 32px;
font-weight: 900;
line-height: 1;
color: var(--primary);
}
.score-pct {
font-size: 13px;
font-weight: 700;
color: var(--text-muted);
}
.debrief-titles {
flex: 1;
}
.debrief-headline {
font-size: 22px;
font-weight: 800;
margin: 0 0 8px 0;
}
.blocks-scores-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}
.block-score-item {
background: var(--bg-main);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 10px 12px;
}
.bs-header {
display: flex;
justify-content: space-between;
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
}
.bs-bar {
width: 100%;
height: 6px;
background: var(--bg-card);
border-radius: 3px;
overflow: hidden;
}
.bs-bar-fill {
height: 100%;
background: var(--success);
}
.debrief-list, .tomorrow-focus-list {
margin: 8px 0 0 0;
padding-left: 20px;
font-size: 14.5px;
line-height: 1.6;
}
.tomorrow-focus-list {
list-style: none;
padding-left: 0;
}
.tomorrow-focus-list li {
padding: 6px 10px;
background: var(--bg-main);
border-left: 3px solid var(--primary);
margin-bottom: 6px;
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.error-cards-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.error-analysis-item {
display: flex;
align-items: flex-start;
gap: 10px;
background: rgba(234, 179, 8, 0.1);
border: 1px solid rgba(234, 179, 8, 0.3);
border-radius: var(--radius-md);
padding: 12px 14px;
font-size: 14px;
}
.celebrate-badge {
font-size: 48px;
margin-bottom: 12px;
}
.step-completed-box h3 {
font-size: 24px;
font-weight: 800;
margin-bottom: 8px;
}
.score-highlight {
font-size: 16px;
color: var(--text-muted);
}
/* History Modal items */
.lesson-history-list {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 65vh;
overflow-y: auto;
}
.lesson-history-item {
background: var(--bg-main);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 14px 16px;
}
.lh-header {
display: flex;
justify-content: space-between;
font-size: 15px;
font-weight: 700;
margin-bottom: 6px;
}
.lh-date {
font-size: 12px;
color: var(--text-muted);
font-weight: normal;
}
.lh-stats {
display: flex;
gap: 12px;
align-items: center;
font-size: 13px;
margin-bottom: 6px;
}
.lh-headline {
font-size: 13px;
font-style: italic;
color: var(--text-dim);
}
@media (max-width: 640px) {
.grammar-options-grid {
grid-template-columns: 1fr;
}
.rating-buttons-row {
grid-template-columns: repeat(2, 1fr);
}
.step-card-box {
padding: 18px 14px;
}
.display-term {
font-size: 26px;
}
}
+113
View File
@@ -1052,6 +1052,97 @@
</div>
</section>
<!-- VIEW 6: Comprehensive Lesson Configurator (Beta) -->
<section id="lesson-view" class="lesson-view hidden">
<div class="lesson-configurator-container">
<!-- Header with Title & Action -->
<div class="lesson-header-bar">
<div class="lesson-title-wrap">
<h2>🚀 Конструктор комплексного урока <span class="badge badge-beta">Beta</span></h2>
<p class="lesson-subtitle text-muted">Соберите персональный ежедневный воркфлоу или запустите готовый шаблон</p>
</div>
<div class="lesson-header-actions">
<button id="lesson-history-btn" class="btn btn-outline" title="История пройденных уроков">
📜 История уроков
</button>
<button id="lesson-save-preset-btn" class="btn btn-secondary" title="Сохранить текущую настройку в мои шаблоны">
💾 Сохранить шаблон
</button>
<button id="lesson-start-btn" class="btn btn-primary btn-lg pulse-btn">
🚀 Начать урок (<span id="lesson-preset-duration">~15 мин</span>)
</button>
</div>
</div>
<!-- Presets Carousel / Bar -->
<div class="lesson-presets-section mt-3">
<div class="presets-section-title">
<span>🎯 Готовые шаблоны уроков:</span>
</div>
<div id="lesson-presets-bar" class="lesson-presets-chips-row">
<!-- Loaded dynamically -->
</div>
<p id="lesson-preset-desc" class="preset-description-text text-muted mt-2"></p>
</div>
<!-- Steps List (Configurable Blocks) -->
<div class="lesson-blocks-section mt-4">
<div class="blocks-section-header">
<h3>🧩 Блоки вашего урока:</h3>
<span class="blocks-tip text-muted">Тасуйте порядок стрелками ▲/▼, меняйте объём и включайте нужные блоки</span>
</div>
<div id="lesson-steps-list" class="lesson-steps-list mt-3">
<!-- Rendered by lesson.js -->
</div>
</div>
<!-- Bottom Floating Start CTA -->
<div class="lesson-bottom-cta mt-4">
<button id="lesson-start-btn-bottom" class="btn btn-primary btn-xl w-100" onclick="document.getElementById('lesson-start-btn').click()">
🚀 Начать комплексный урок сейчас
</button>
</div>
</div>
</section>
<!-- VIEW 7: Active Lesson Session Runner -->
<section id="lesson-runner-view" class="lesson-runner-view hidden">
<!-- Top Sticky Control Bar -->
<header class="lesson-runner-header">
<div class="runner-nav-left">
<button id="lesson-runner-exit-btn" class="btn btn-sm btn-outline">✕ Выйти из урока</button>
<div class="runner-timer-wrap">
⏱️ <span id="lesson-runner-timer" class="runner-timer-val">00:00</span>
</div>
</div>
<div class="runner-nav-center">
<div id="lesson-runner-breadcrumbs" class="runner-breadcrumbs-row">
<!-- Filled by lesson.js -->
</div>
</div>
<div class="runner-nav-right">
<button id="lesson-next-step-btn" class="btn btn-sm btn-ghost">
Пропустить этап ⏭️
</button>
</div>
</header>
<!-- Runner Progress Bar -->
<div class="lesson-runner-progress-track">
<div id="lesson-runner-progress-fill" class="lesson-runner-progress-fill" style="width: 0%;"></div>
</div>
<!-- Dynamic Step Content Stage -->
<main class="lesson-runner-main">
<div id="lesson-step-content" class="lesson-step-content-container">
<!-- Injected dynamically by step runners -->
</div>
</main>
</section>
<!-- Sticky Bulk Action Bar for Batch Selection Mode -->
<div id="batch-action-bar" class="batch-action-bar hidden">
<div class="batch-left">
@@ -1155,6 +1246,13 @@
<span class="drawer-item-label">Грамматика Мерфи & Тетрадь</span>
</button>
</li>
<li>
<button class="drawer-nav-item" data-hash="#lesson" id="drawer-nav-lesson">
<span class="drawer-item-icon">🚀</span>
<span class="drawer-item-label">Комплексный урок</span>
<span class="drawer-badge badge-beta">Beta</span>
</button>
</li>
</ul>
<!-- Section 2: Analytics & Progress -->
@@ -1585,6 +1683,21 @@
</div>
</div>
<!-- Lesson History Modal -->
<div id="lesson-history-modal" class="modal-backdrop hidden">
<div class="modal-window modal-lg lesson-history-modal-window">
<div class="modal-header">
<h3>📜 История комплексных уроков</h3>
<button id="lesson-history-close-btn" class="icon-btn" title="Закрыть">✕</button>
</div>
<div class="modal-body">
<div id="lesson-history-list" class="lesson-history-list">
<!-- Populated dynamically by lesson.js -->
</div>
</div>
</div>
</div>
<!-- Move Collection Modal -->
<div id="move-col-modal" class="modal-backdrop hidden">
<div class="modal-window modal-sm">
+65
View File
@@ -10,6 +10,7 @@ import { initSrs, startSrsTrainingSession, openStatsDashboard } from './modules/
import { initReader, loadBookshelf, openBook } from './modules/reader.js';
import { initCefr, loadCefrOverview, startCefrQuiz } from './modules/cefr.js';
import { initGrammar, loadGrammarUnits, openGrammarUnit } from './modules/grammar.js';
import { initLessonModule, openLessonConfigurator } from './modules/lesson.js';
let currentUser = null;
let isRouting = false;
@@ -44,6 +45,7 @@ export function updateActiveNavState(viewName) {
'reader': 'drawer-nav-reader',
'cefr': 'drawer-nav-cefr',
'grammar': 'drawer-nav-grammar',
'lesson': 'drawer-nav-lesson',
};
const targetId = drawerMap[viewName];
if (targetId) {
@@ -73,6 +75,8 @@ export function applyInitialViewPreflight() {
hash = '#cefr';
} else if (lastView === 'grammar-view') {
hash = '#grammar';
} else if (lastView === 'lesson-view') {
hash = '#lesson';
}
} catch (e) {}
}
@@ -82,6 +86,8 @@ export function applyInitialViewPreflight() {
const readerView = document.getElementById('reader-view');
const cefrView = document.getElementById('cefr-view');
const grammarView = document.getElementById('grammar-view');
const lessonView = document.getElementById('lesson-view');
const lessonRunnerView = document.getElementById('lesson-runner-view');
const bookshelfContainer = document.getElementById('bookshelf-container');
const readerInterface = document.getElementById('reader-interface');
const readerBookTitle = document.getElementById('reader-book-title');
@@ -91,6 +97,8 @@ export function applyInitialViewPreflight() {
if (srsView) srsView.classList.add('hidden');
if (cefrView) cefrView.classList.add('hidden');
if (grammarView) grammarView.classList.add('hidden');
if (lessonView) lessonView.classList.add('hidden');
if (lessonRunnerView) lessonRunnerView.classList.add('hidden');
if (readerView) readerView.classList.remove('hidden');
updateActiveNavState('reader');
if (hash.includes('/book/')) {
@@ -103,6 +111,8 @@ export function applyInitialViewPreflight() {
if (readerView) readerView.classList.add('hidden');
if (cefrView) cefrView.classList.add('hidden');
if (grammarView) grammarView.classList.add('hidden');
if (lessonView) lessonView.classList.add('hidden');
if (lessonRunnerView) lessonRunnerView.classList.add('hidden');
if (srsView) srsView.classList.remove('hidden');
updateActiveNavState('srs');
} else if (hash.startsWith('#cefr')) {
@@ -110,6 +120,8 @@ export function applyInitialViewPreflight() {
if (readerView) readerView.classList.add('hidden');
if (srsView) srsView.classList.add('hidden');
if (grammarView) grammarView.classList.add('hidden');
if (lessonView) lessonView.classList.add('hidden');
if (lessonRunnerView) lessonRunnerView.classList.add('hidden');
if (cefrView) cefrView.classList.remove('hidden');
updateActiveNavState('cefr');
} else if (hash.startsWith('#grammar')) {
@@ -117,8 +129,19 @@ export function applyInitialViewPreflight() {
if (readerView) readerView.classList.add('hidden');
if (srsView) srsView.classList.add('hidden');
if (cefrView) cefrView.classList.add('hidden');
if (lessonView) lessonView.classList.add('hidden');
if (lessonRunnerView) lessonRunnerView.classList.add('hidden');
if (grammarView) grammarView.classList.remove('hidden');
updateActiveNavState('grammar');
} else if (hash.startsWith('#lesson')) {
if (dictionaryView) dictionaryView.classList.add('hidden');
if (readerView) readerView.classList.add('hidden');
if (srsView) srsView.classList.add('hidden');
if (cefrView) cefrView.classList.add('hidden');
if (grammarView) grammarView.classList.add('hidden');
if (lessonRunnerView) lessonRunnerView.classList.add('hidden');
if (lessonView) lessonView.classList.remove('hidden');
updateActiveNavState('lesson');
}
}
@@ -271,6 +294,36 @@ export function openGrammarView(unitId = null, updateHash = true) {
}
}
export function openLessonView(presetId = null, updateHash = true) {
const targetHash = presetId ? `#lesson/${presetId}` : '#lesson';
if (updateHash && !window.location.hash.startsWith('#lesson')) {
window.location.hash = targetHash;
return;
}
const dictionaryView = document.getElementById('dictionary-view');
const srsView = document.getElementById('srs-view');
const readerView = document.getElementById('reader-view');
const cefrView = document.getElementById('cefr-view');
const grammarView = document.getElementById('grammar-view');
const lessonView = document.getElementById('lesson-view');
const lessonRunnerView = document.getElementById('lesson-runner-view');
if (dictionaryView) dictionaryView.classList.add('hidden');
if (srsView) srsView.classList.add('hidden');
if (readerView) readerView.classList.add('hidden');
if (cefrView) cefrView.classList.add('hidden');
if (grammarView) grammarView.classList.add('hidden');
if (lessonRunnerView) lessonRunnerView.classList.add('hidden');
if (lessonView) lessonView.classList.remove('hidden');
updateActiveNavState('lesson');
try { localStorage.setItem('notes_active_view', 'lesson-view'); } catch (e) {}
openLessonConfigurator(presetId);
}
// -----------------------------------------------------------------------
// Route Handler
// -----------------------------------------------------------------------
@@ -337,6 +390,10 @@ export async function handleRouting() {
} else {
openBookshelfView(false);
}
} else if (cleanHash.startsWith('lesson')) {
const parts = cleanHash.split('/');
const presetId = parts[1] || null;
openLessonView(presetId, false);
} else {
openDictionaryView(false);
}
@@ -523,6 +580,13 @@ export function initNavigationDrawer() {
window.location.hash = '#grammar';
});
}
const drawerNavLesson = document.getElementById('drawer-nav-lesson');
if (drawerNavLesson) {
drawerNavLesson.addEventListener('click', () => {
closeDrawer();
window.location.hash = '#lesson';
});
}
if (drawerNavStats) {
drawerNavStats.addEventListener('click', () => {
closeDrawer();
@@ -721,6 +785,7 @@ document.addEventListener('DOMContentLoaded', () => {
initReader();
initCefr();
initGrammar();
initLessonModule();
// 5. Global Navigation & Header Actions
const navBrandHome = document.getElementById('nav-brand-home');
+52
View File
@@ -422,5 +422,57 @@ export const api = {
if (resp.ok) return await resp.json();
throw new Error(`Failed to cancel task: ${resp.status}`);
}
},
// Comprehensive Lessons Configurator & Runner (Beta)
lessons: {
async getPresets() {
const resp = await fetch('/api/lessons/presets');
if (resp.ok) return await resp.json();
throw new Error(`Failed to get presets: ${resp.status}`);
},
async savePreset(payload) {
const resp = await fetch('/api/lessons/presets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (resp.ok) return await resp.json();
throw new Error(`Failed to save preset: ${resp.status}`);
},
async deletePreset(presetId) {
const resp = await fetch(`/api/lessons/presets/${presetId}`, {
method: 'DELETE'
});
if (resp.ok) return await resp.json();
throw new Error(`Failed to delete preset: ${resp.status}`);
},
async getGrammarRecommendations() {
const resp = await fetch('/api/lessons/grammar-recommendations');
if (resp.ok) return await resp.json();
throw new Error(`Failed to get grammar recommendations: ${resp.status}`);
},
async getArticles(topic = null) {
let url = '/api/lessons/articles';
if (topic) url += `?topic=${encodeURIComponent(topic)}`;
const resp = await fetch(url);
if (resp.ok) return await resp.json();
throw new Error(`Failed to get articles: ${resp.status}`);
},
async submitDebrief(payload) {
const resp = await fetch('/api/lessons/debrief', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (resp.ok) return await resp.json();
throw new Error(`Failed to submit debrief: ${resp.status}`);
},
async getHistory(limit = 15) {
const resp = await fetch(`/api/lessons/history?limit=${limit}`);
if (resp.ok) return await resp.json();
throw new Error(`Failed to get lesson history: ${resp.status}`);
}
}
};
File diff suppressed because it is too large. Load diff
+1
View File
@@ -8,6 +8,7 @@ services:
volumes:
- ./data:/app/data
- ./app:/app/app
- ./tests:/app/tests
environment:
- HOST=0.0.0.0
- PORT=8002
+139
View File
@@ -2359,6 +2359,145 @@ def test_listening_exercise_and_check():
assert "/api/audio/" in card_ex["story_audio_url"]
def test_lessons_presets_crud():
# 1. Login user
login_resp = client.post(
"/api/login",
json={"username": config.ADMIN_USERNAME, "password": config.ADMIN_PASSWORD},
)
assert login_resp.status_code == 200
token = login_resp.json()["token"]
headers = {"Authorization": f"Bearer {token}"}
# 2. Get presets
resp = client.get("/api/lessons/presets", headers=headers)
assert resp.status_code == 200
presets = resp.json()["presets"]
assert len(presets) >= 4
preset_ids = [p["id"] for p in presets]
assert "express" in preset_ids
assert "balanced" in preset_ids
# 3. Create custom preset
new_preset_payload = {
"name": "Тестовый спринт",
"icon": "⚡",
"description": "Быстрый тест",
"estimated_min": 12,
"steps": [
{"id": "step_vocab", "type": "vocab", "enabled": True, "count": 5},
{"id": "step_ai_debrief", "type": "ai_debrief", "enabled": True},
],
}
save_resp = client.post("/api/lessons/presets", json=new_preset_payload, headers=headers)
assert save_resp.status_code == 200
created = save_resp.json()["preset"]
assert created["name"] == "Тестовый спринт"
assert created["is_custom"] is True
custom_id = created["custom_id"]
# Verify custom preset appears in list
resp2 = client.get("/api/lessons/presets", headers=headers)
assert resp2.status_code == 200
presets2 = resp2.json()["presets"]
assert any(p.get("custom_id") == custom_id for p in presets2)
# 4. Delete custom preset
del_resp = client.delete(f"/api/lessons/presets/{custom_id}", headers=headers)
assert del_resp.status_code == 200
assert del_resp.json()["deleted"] is True
def test_lessons_grammar_recommendations_and_articles():
login_resp = client.post(
"/api/login",
json={"username": config.ADMIN_USERNAME, "password": config.ADMIN_PASSWORD},
)
token = login_resp.json()["token"]
headers = {"Authorization": f"Bearer {token}"}
# 1. Grammar recommendations
rec_resp = client.get("/api/lessons/grammar-recommendations", headers=headers)
assert rec_resp.status_code == 200
recs = rec_resp.json()
assert "all_units" in recs
assert len(recs["all_units"]) >= 15
assert "weakest_unit" in recs
assert "previous_unit" in recs
assert "next_unit" in recs
# 2. Articles
art_resp = client.get("/api/lessons/articles", headers=headers)
assert art_resp.status_code == 200
articles = art_resp.json()["articles"]
assert len(articles) >= 8
first_art = articles[0]
assert "title" in first_art
assert "paragraphs" in first_art
assert "paragraphs_ru" in first_art
assert "vocabulary_highlights" in first_art
def test_lessons_debrief_and_history():
login_resp = client.post(
"/api/login",
json={"username": config.ADMIN_USERNAME, "password": config.ADMIN_PASSWORD},
)
token = login_resp.json()["token"]
headers = {"Authorization": f"Bearer {token}"}
# Post session debrief
debrief_payload = {
"preset_name": "⚡ Утренний экспресс (10 мин)",
"duration_seconds": 620,
"total_score": 85,
"steps": [
{"type": "vocab", "title": "Вспоминаем слова", "score": 90, "correct": 9, "total": 10},
{"type": "grammar_review", "title": "Закрепление грамматики", "score": 80, "correct": 4, "total": 5},
{"type": "listening", "title": "Аудирование", "score": 100, "correct": 1, "total": 1},
],
"errors": [
{
"type": "grammar",
"unit_num": 1,
"unit_title": "Present Continuous",
"formula": "S + am/is/are + V-ing",
"user_answer": "sing",
"correct_answer": "are singing",
"explanation": "Слово-маркер Listen указывает на момент речи",
},
{
"type": "vocab",
"term": "reluctant",
"translation": "неохотный",
},
],
}
debrief_resp = client.post("/api/lessons/debrief", json=debrief_payload, headers=headers)
assert debrief_resp.status_code == 200
data = debrief_resp.json()
assert "session_id" in data
assert "ai_report" in data
report = data["ai_report"]
assert report["grade"] in ["A+", "A", "B", "C"]
assert "headline" in report
assert "strengths" in report
assert "error_patterns" in report
assert "tomorrow_focus" in report
# Check history
hist_resp = client.get("/api/lessons/history", headers=headers)
assert hist_resp.status_code == 200
hist = hist_resp.json()
assert hist["total_lessons"] >= 1
assert len(hist["history"]) >= 1
last_session = hist["history"][0]
assert last_session["preset_name"] == "⚡ Утренний экспресс (10 мин)"
assert last_session["errors_count"] == 2