""" 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 # ----------------------------------------------------------------------------- from pathlib import Path from app import config _CURATED_ARTICLES_CACHE = None def get_curated_articles_list(): global _CURATED_ARTICLES_CACHE if _CURATED_ARTICLES_CACHE is None: seeds_file = config.BASE_DIR / 'data' / 'seeds' / 'reading_articles.json' if seeds_file.is_file(): try: with open(seeds_file, 'r', encoding='utf-8') as f: _CURATED_ARTICLES_CACHE = json.load(f) except Exception: _CURATED_ARTICLES_CACHE = [] else: _CURATED_ARTICLES_CACHE = [] return _CURATED_ARTICLES_CACHE class _LazyArticlesList(list): def __iter__(self): return iter(get_curated_articles_list()) def __len__(self): return len(get_curated_articles_list()) def __getitem__(self, idx): return get_curated_articles_list()[idx] CURATED_ARTICLES = _LazyArticlesList() # ----------------------------------------------------------------------------- # 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, }