Spaces:
Running on Zero
Running on Zero
| """ | |
| Генератор разноуровневых заданий по информатике (10 класс) | |
| Интерфейс: Gradio | |
| Модель: Groq API (openai/gpt-oss-120b, бесплатно) | |
| Версия 2: поддержка загрузки списка тем и учебника из .md файлов. | |
| """ | |
| import os | |
| import json | |
| import re | |
| import gradio as gr | |
| import requests | |
| from groq import Groq | |
| try: | |
| import gspread | |
| from google.oauth2.service_account import Credentials | |
| GSPREAD_AVAILABLE = True | |
| except ImportError: | |
| GSPREAD_AVAILABLE = False | |
| try: | |
| import pandas as pd | |
| import matplotlib | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt | |
| PANDAS_AVAILABLE = True | |
| except ImportError: | |
| PANDAS_AVAILABLE = False | |
| try: | |
| import spaces # доступно только внутри HuggingFace Spaces | |
| except ImportError: | |
| # Локальный запуск / среда без пакета spaces — создаём "пустышку", | |
| # чтобы декоратор @spaces.GPU ниже не ломал код вне Spaces. | |
| class _DummySpaces: | |
| def GPU(fn): | |
| return fn | |
| spaces = _DummySpaces() | |
| # --------------------------------------------------------------------------- | |
| # Конфигурация | |
| # --------------------------------------------------------------------------- | |
| MODEL_NAME = "openai/gpt-oss-120b" | |
| # ID таблицы с темами (из ссылки https://docs.google.com/spreadsheets/d/ЭТОТ_ID/edit) | |
| SPREADSHEET_ID = os.environ.get( | |
| "TOPICS_SPREADSHEET_ID", | |
| "1jlekQ-Ah3N1olWhDscZZ8s23t-g8gqkVwZSJYXAtY2U", | |
| ) | |
| # Ключ сервисного аккаунта передаётся как Secret на HuggingFace Spaces — | |
| # весь JSON-файл ключа целиком, одной строкой, в переменную GOOGLE_SERVICE_ACCOUNT_JSON | |
| GOOGLE_SERVICE_ACCOUNT_ENV = "GOOGLE_SERVICE_ACCOUNT_JSON" | |
| SHEETS_SCOPES = [ | |
| "https://www.googleapis.com/auth/spreadsheets", # чтение и запись | |
| "https://www.googleapis.com/auth/drive.readonly", | |
| ] | |
| # Раскладка столбцов на листе класса: A — тема (уже используется), | |
| # B/C/D — сгенерированные задания по уровням, E — счётчик генераций по теме | |
| RESULT_COLUMNS = {"базовый": 2, "средний": 3, "продвинутый": 4} # B, C, D | |
| GENERATION_COUNT_COLUMN = 5 # E | |
| # Максимальная длина раздела учебника, которая уходит в промпт. | |
| # Ограничивает расход токенов Groq API даже для очень длинных глав. | |
| MAX_TEXTBOOK_SECTION_CHARS = 6000 | |
| DEFAULT_TOPICS = [ | |
| "Системы счисления и перевод чисел", | |
| "Логические основы компьютера (алгебра логики)", | |
| "Кодирование текстовой, графической и звуковой информации", | |
| "Алгоритмизация и основные алгоритмические конструкции", | |
| "Основы программирования на Python", | |
| "Массивы и работа с данными", | |
| "Файлы и файловая система", | |
| "Базы данных: структура и SQL-запросы", | |
| "Компьютерные сети и передача данных", | |
| "Информационная безопасность", | |
| "Моделирование и формализация", | |
| "Электронные таблицы: формулы и функции", | |
| ] | |
| CUSTOM_TOPIC_LABEL = "Другая тема (ввести вручную)" | |
| LEVEL_LABELS = { | |
| "базовый": "🟢 Базовый уровень", | |
| "средний": "🟡 Средний уровень", | |
| "продвинутый": "🔴 Продвинутый уровень", | |
| } | |
| SYSTEM_PROMPT_TEMPLATE = """Ты — опытный методист по информатике, разрабатывающий разноуровневые учебные задания для учеников 10 класса. Твоя задача — сгенерировать задания по указанной теме урока в строгом соответствии с параметрами ниже. | |
| ОПРЕДЕЛЕНИЯ УРОВНЕЙ СЛОЖНОСТИ: | |
| - БАЗОВЫЙ: воспроизведение факта, определения или простого алгоритма. Одно действие, прямое применение изученного правила без дополнительных условий. | |
| - СРЕДНИЙ: применение знания в новой, но похожей ситуации. Требует 2-3 логических шага, комбинирования известных понятий. | |
| - ПРОДВИНУТЫЙ: анализ, синтез или обоснование решения в нестандартной ситуации. Требует самостоятельного выбора подхода, объяснения хода рассуждений, работы с неполными данными. | |
| ВХОДНЫЕ ПАРАМЕТРЫ: | |
| Тема урока: {topic} | |
| Количество заданий: | |
| - базовый уровень: {n_basic} | |
| - средний уровень: {n_medium} | |
| - продвинутый уровень: {n_advanced} | |
| {textbook_block} | |
| ТРЕБОВАНИЯ К КАЖДОМУ ЗАДАНИЮ: | |
| 1. Задание должно быть строго по указанной теме, соответствовать программе информатики 10 класса. | |
| 2. Формулировка — чёткая, однозначная, понятная ученику без дополнительных пояснений учителя. | |
| 3. К каждому заданию обязательно приложи: | |
| - правильный ответ или чёткий критерий проверки (по чему учитель поймёт, что ответ верный) | |
| - краткий методический комментарий (2-3 предложения): что именно проверяет задание и как его использовать на уроке | |
| 4. Не повторяй формулировки и контекст между заданиями одного уровня — каждое задание должно проверять разные аспекты темы. | |
| 5. Если для темы нужен код — используй Python и заключай код в тройные обратные кавычки. | |
| ФОРМАТ ОТВЕТА: | |
| Верни ТОЛЬКО валидный JSON без какого-либо текста до или после, без markdown-разметки вокруг JSON. Структура: | |
| {{ | |
| "тема": "название темы", | |
| "задания": [ | |
| {{ | |
| "уровень": "базовый", | |
| "задание": "текст задания", | |
| "ответ": "правильный ответ или критерий проверки", | |
| "комментарий": "методический комментарий" | |
| }} | |
| ] | |
| }} | |
| Если количество заданий для какого-то уровня равно 0 — не включай задания этого уровня в массив вообще. | |
| ВАЖНО: | |
| - Отвечай только на русском языке, без англицизмов и калек с английского. | |
| - Не придумывай факты, формулы или синтаксис языков программирования, в которых не уверен — если сомневаешься в деталях, выбери более простую и точно проверенную формулировку. | |
| - Не используй персональные данные учеников или реальных людей в примерах. | |
| """ | |
| TEXTBOOK_BLOCK_TEMPLATE = """ | |
| МАТЕРИАЛ ИЗ УЧЕБНИКА ПО ЭТОЙ ТЕМЕ: | |
| {section_text} | |
| Используй терминологию, примеры и уровень изложения из этого материала. Задания должны быть решаемы на основе именно этого текста, без выхода за его рамки. | |
| """ | |
| # --------------------------------------------------------------------------- | |
| # Парсинг текста (тем и учебника) — общая логика для файлов и Google Sheets/Drive | |
| # --------------------------------------------------------------------------- | |
| def parse_topics_from_text(content: str) -> list: | |
| """ | |
| Парсит текст со списком тем вида: | |
| # Заголовок предмета | |
| - Тема 1 | |
| - Тема 2 | |
| Возвращает список строк тем. | |
| """ | |
| if not content: | |
| return [] | |
| topics = [] | |
| for line in content.splitlines(): | |
| line = line.strip() | |
| match = re.match(r"^[-*]\s+(.+)$", line) | |
| if match: | |
| topics.append(match.group(1).strip()) | |
| return topics | |
| def parse_textbook_sections_from_text(content: str) -> dict: | |
| """ | |
| Парсит текст учебника вида: | |
| ## Тема 1 | |
| текст раздела... | |
| ## Тема 2 | |
| текст раздела... | |
| Возвращает словарь {название_темы: текст_раздела}. | |
| """ | |
| if not content: | |
| return {} | |
| sections = {} | |
| parts = re.split(r"(?m)^##\s+(.+)$", content) | |
| for i in range(1, len(parts), 2): | |
| heading = parts[i].strip() | |
| body = parts[i + 1].strip() if i + 1 < len(parts) else "" | |
| if heading and body: | |
| sections[heading] = body | |
| return sections | |
| def parse_topics_md(file_path: str) -> list: | |
| """Парсит .md файл со списком тем (загруженный через интерфейс).""" | |
| if not file_path: | |
| return [] | |
| try: | |
| with open(file_path, "r", encoding="utf-8") as f: | |
| content = f.read() | |
| except (OSError, UnicodeDecodeError): | |
| return [] | |
| return parse_topics_from_text(content) | |
| def parse_textbook_md(file_path: str) -> dict: | |
| """Парсит .md файл учебника (загруженный через интерфейс).""" | |
| if not file_path: | |
| return {} | |
| try: | |
| with open(file_path, "r", encoding="utf-8") as f: | |
| content = f.read() | |
| except (OSError, UnicodeDecodeError): | |
| return {} | |
| return parse_textbook_sections_from_text(content) | |
| def get_textbook_section(textbook_sections: dict, topic: str) -> str: | |
| """Возвращает текст раздела учебника по точному совпадению темы, с обрезкой по лимиту.""" | |
| if not textbook_sections or not topic: | |
| return "" | |
| section_text = textbook_sections.get(topic.strip()) | |
| if not section_text: | |
| return "" | |
| if len(section_text) > MAX_TEXTBOOK_SECTION_CHARS: | |
| section_text = section_text[:MAX_TEXTBOOK_SECTION_CHARS].rstrip() + "\n[…текст обрезан для экономии токенов…]" | |
| return section_text | |
| # --------------------------------------------------------------------------- | |
| # Google Sheets: список классов и тем + ссылка на учебник | |
| # --------------------------------------------------------------------------- | |
| def get_sheets_client(): | |
| """Создаёт авторизованный клиент gspread через сервисный аккаунт.""" | |
| if not GSPREAD_AVAILABLE: | |
| raise RuntimeError("Библиотеки gspread/google-auth не установлены.") | |
| raw_key = os.environ.get(GOOGLE_SERVICE_ACCOUNT_ENV) | |
| if not raw_key: | |
| raise RuntimeError( | |
| f"Не найден Secret {GOOGLE_SERVICE_ACCOUNT_ENV}. " | |
| "Добавьте в него содержимое JSON-ключа сервисного аккаунта целиком." | |
| ) | |
| try: | |
| key_dict = json.loads(raw_key) | |
| except json.JSONDecodeError as e: | |
| raise RuntimeError(f"{GOOGLE_SERVICE_ACCOUNT_ENV} содержит невалидный JSON: {e}") | |
| creds = Credentials.from_service_account_info(key_dict, scopes=SHEETS_SCOPES) | |
| return gspread.authorize(creds) | |
| def list_available_classes() -> list: | |
| """Возвращает список названий листов таблицы (= список классов).""" | |
| gc = get_sheets_client() | |
| sh = gc.open_by_key(SPREADSHEET_ID) | |
| return [ws.title for ws in sh.worksheets()] | |
| def extract_drive_file_id(url: str) -> str: | |
| """Извлекает ID файла из разных форматов ссылок Google Drive.""" | |
| if not url: | |
| return "" | |
| patterns = [ | |
| r"/d/([a-zA-Z0-9_-]+)", # .../file/d/ID/view | |
| r"[?&]id=([a-zA-Z0-9_-]+)", # .../uc?id=ID или open?id=ID | |
| ] | |
| for pattern in patterns: | |
| match = re.search(pattern, url) | |
| if match: | |
| return match.group(1) | |
| return "" | |
| def fetch_textbook_from_drive_url(url: str) -> str: | |
| """Скачивает содержимое публичного .md файла с Google Drive по ссылке из таблицы.""" | |
| file_id = extract_drive_file_id(url) | |
| if not file_id: | |
| return "" | |
| download_url = f"https://drive.google.com/uc?id={file_id}&export=download" | |
| try: | |
| response = requests.get(download_url, timeout=15) | |
| response.raise_for_status() | |
| return response.text | |
| except requests.RequestException: | |
| return "" | |
| def load_class_data(class_name: str): | |
| """ | |
| Читает столбец A выбранного листа (класса): | |
| - строка 1: ссылка на .md учебника на Google Drive | |
| - строки 2+: темы | |
| Возвращает (topics: list, textbook_sections: dict, status_message: str) | |
| """ | |
| if not class_name: | |
| return [], {}, "Класс не выбран." | |
| try: | |
| gc = get_sheets_client() | |
| sh = gc.open_by_key(SPREADSHEET_ID) | |
| ws = sh.worksheet(class_name) | |
| except Exception as e: | |
| return [], {}, f"⚠️ Не удалось прочитать таблицу: {e}" | |
| col_values = ws.col_values(1) | |
| if not col_values: | |
| return [], {}, "⚠️ Лист пустой." | |
| textbook_url = col_values[0].strip() | |
| topics = [t.strip() for t in col_values[1:] if t.strip()] | |
| if not topics: | |
| return [], {}, "⚠️ В листе не найдено ни одной темы (строки со 2-й)." | |
| textbook_sections = {} | |
| status_extra = "" | |
| if textbook_url: | |
| content = fetch_textbook_from_drive_url(textbook_url) | |
| if content: | |
| textbook_sections = parse_textbook_sections_from_text(content) | |
| if textbook_sections: | |
| status_extra = f", учебник загружен ({len(textbook_sections)} разделов)" | |
| else: | |
| status_extra = ", учебник скачан, но разделы не распознаны (проверьте заголовки ##)" | |
| else: | |
| status_extra = ", не удалось скачать учебник по ссылке из 1-й строки" | |
| status = f"✅ Класс «{class_name}»: тем — {len(topics)}{status_extra}" | |
| return topics, textbook_sections, status | |
| def format_tasks_for_cell(tasks: list) -> str: | |
| """Форматирует список заданий одного уровня в текст для одной ячейки таблицы.""" | |
| if not tasks: | |
| return "" | |
| blocks = [] | |
| for i, t in enumerate(tasks, start=1): | |
| blocks.append( | |
| f"Задание {i}: {t.get('задание', '—')}\n" | |
| f"Ответ: {t.get('ответ', '—')}\n" | |
| f"Комментарий: {t.get('комментарий', '—')}" | |
| ) | |
| return "\n\n".join(blocks) | |
| def write_result_to_sheet(class_name: str, topic: str, by_level: dict) -> str: | |
| """ | |
| Записывает (с перезаписью) сгенерированные задания в столбцы B/C/D строки темы | |
| и увеличивает счётчик генераций в столбце E. Работает только если тема реально | |
| взята из листа таблицы (найдена точным совпадением в столбце A) — для тем, | |
| введённых вручную, запись молча пропускается. | |
| """ | |
| if not class_name or not topic: | |
| return "" | |
| try: | |
| gc = get_sheets_client() | |
| sh = gc.open_by_key(SPREADSHEET_ID) | |
| ws = sh.worksheet(class_name) | |
| col_values = ws.col_values(1) | |
| except Exception as e: | |
| return f"⚠️ Не удалось записать результат в таблицу: {e}" | |
| row_index = None | |
| for i, value in enumerate(col_values, start=1): | |
| if value.strip() == topic.strip(): | |
| row_index = i | |
| break | |
| if row_index is None: | |
| # Тема введена вручную и её нет в листе — запись в таблицу не имеет смысла | |
| return "" | |
| try: | |
| for level, col in RESULT_COLUMNS.items(): | |
| cell_text = format_tasks_for_cell(by_level.get(level, [])) | |
| ws.update_cell(row_index, col, cell_text) | |
| current_count_values = ws.col_values(GENERATION_COUNT_COLUMN) | |
| current_count = 0 | |
| if len(current_count_values) >= row_index: | |
| try: | |
| current_count = int(current_count_values[row_index - 1]) | |
| except (ValueError, IndexError): | |
| current_count = 0 | |
| ws.update_cell(row_index, GENERATION_COUNT_COLUMN, current_count + 1) | |
| except Exception as e: | |
| return f"⚠️ Задания сгенерированы, но не удалось записать их в таблицу: {e}" | |
| return f"💾 Результат записан в таблицу (строка {row_index}, лист «{class_name}»)." | |
| def build_usage_stats(class_name: str): | |
| """ | |
| Читает столбцы A (темы) и E (счётчик генераций) выбранного листа через Pandas | |
| и строит график частоты запросов по темам (Matplotlib). | |
| Возвращает (figure, status_message); figure может быть None при ошибке/отсутствии данных. | |
| """ | |
| if not PANDAS_AVAILABLE: | |
| return None, "⚠️ Pandas/Matplotlib не установлены." | |
| if not class_name: | |
| return None, "Класс не выбран." | |
| try: | |
| gc = get_sheets_client() | |
| sh = gc.open_by_key(SPREADSHEET_ID) | |
| ws = sh.worksheet(class_name) | |
| topics_col = ws.col_values(1)[1:] # без 1-й строки (ссылка на учебник) | |
| counts_col = ws.col_values(GENERATION_COUNT_COLUMN)[1:] | |
| except Exception as e: | |
| return None, f"⚠️ Не удалось прочитать данные для статистики: {e}" | |
| # Выравниваем длины столбцов и приводим счётчик к числу через Pandas | |
| n = len(topics_col) | |
| counts_col = (counts_col + [""] * n)[:n] | |
| df = pd.DataFrame({"тема": topics_col, "генераций": counts_col}) | |
| df["генераций"] = pd.to_numeric(df["генераций"], errors="coerce").fillna(0).astype(int) | |
| df = df[df["тема"].str.strip() != ""] | |
| if df["генераций"].sum() == 0: | |
| return None, "Пока нет ни одной генерации по темам этого класса — статистика появится после первых запусков." | |
| df_sorted = df.sort_values("генераций", ascending=False).head(10) | |
| fig, ax = plt.subplots(figsize=(8, 4.5)) | |
| ax.barh(df_sorted["тема"], df_sorted["генераций"], color="#1E8F7A") | |
| ax.set_xlabel("Количество генераций") | |
| ax.set_title(f"Частота запросов по темам — {class_name}") | |
| ax.invert_yaxis() | |
| fig.tight_layout() | |
| total_generations = int(df["генераций"].sum()) | |
| return fig, f"Всего генераций по классу «{class_name}»: {total_generations}" | |
| # --------------------------------------------------------------------------- | |
| # Логика генерации | |
| # --------------------------------------------------------------------------- | |
| def extract_json(raw_text: str) -> dict: | |
| """Достаёт JSON из ответа модели, даже если он обёрнут в markdown-блок.""" | |
| text = raw_text.strip() | |
| fence_match = re.search(r"```(?:json)?\s*(.*?)\s*```", text, re.DOTALL) | |
| if fence_match: | |
| text = fence_match.group(1).strip() | |
| start = text.find("{") | |
| end = text.rfind("}") | |
| if start != -1 and end != -1 and end > start: | |
| text = text[start:end + 1] | |
| return json.loads(text) | |
| def build_topic(dropdown_value: str, custom_topic: str) -> str: | |
| """Определяет итоговую тему: из списка или введённую вручную.""" | |
| if dropdown_value == CUSTOM_TOPIC_LABEL or not dropdown_value: | |
| topic = custom_topic.strip() | |
| else: | |
| topic = dropdown_value.strip() | |
| if custom_topic.strip(): | |
| topic = custom_topic.strip() | |
| return topic | |
| def render_level_html(level_key: str, tasks: list) -> str: | |
| """Рендерит HTML-блок карточек для одного уровня сложности.""" | |
| if not tasks: | |
| return "" | |
| label = LEVEL_LABELS.get(level_key, level_key.capitalize()) | |
| cards = [] | |
| for i, task in enumerate(tasks, start=1): | |
| cards.append(f""" | |
| <div style="border:1px solid #e0e0e0; border-radius:10px; padding:16px; margin-bottom:12px; background:#fafafa;"> | |
| <div style="font-weight:600; margin-bottom:8px; color:#333;">Задание {i}</div> | |
| <div style="margin-bottom:10px; white-space:pre-wrap;">{task.get('задание', '—')}</div> | |
| <div style="margin-bottom:8px; padding:10px; background:#eef7ee; border-radius:6px;"> | |
| <b>Ответ / критерий проверки:</b><br> | |
| <span style="white-space:pre-wrap;">{task.get('ответ', '—')}</span> | |
| </div> | |
| <div style="padding:10px; background:#eef2f9; border-radius:6px;"> | |
| <b>Методический комментарий:</b><br> | |
| {task.get('комментарий', '—')} | |
| </div> | |
| </div> | |
| """) | |
| return f""" | |
| <div style="margin-bottom:24px;"> | |
| <h3 style="margin-bottom:10px;">{label}</h3> | |
| {''.join(cards)} | |
| </div> | |
| """ | |
| def generate_tasks(dropdown_value, custom_topic, n_basic, n_medium, n_advanced, textbook_sections_state, class_state): | |
| topic = build_topic(dropdown_value, custom_topic) | |
| if not topic: | |
| return "<p style='color:red;'>Пожалуйста, укажите тему урока — выберите из списка или введите свою.</p>" | |
| if n_basic == 0 and n_medium == 0 and n_advanced == 0: | |
| return "<p style='color:red;'>Укажите хотя бы одно задание хотя бы на одном уровне.</p>" | |
| if not os.environ.get("GROQ_API_KEY"): | |
| return "<p style='color:red;'>Не найден GROQ_API_KEY. Добавьте ключ в Secrets (HuggingFace Spaces) или переменные окружения.</p>" | |
| # Ищем раздел учебника по точному совпадению темы (молча, без ошибки если не найден) | |
| section_text = get_textbook_section(textbook_sections_state or {}, topic) | |
| textbook_block = "" | |
| if section_text: | |
| textbook_block = TEXTBOOK_BLOCK_TEMPLATE.format(section_text=section_text) | |
| prompt = SYSTEM_PROMPT_TEMPLATE.format( | |
| topic=topic, | |
| n_basic=n_basic, | |
| n_medium=n_medium, | |
| n_advanced=n_advanced, | |
| textbook_block=textbook_block, | |
| ) | |
| try: | |
| client = Groq(api_key=os.environ.get("GROQ_API_KEY")) | |
| response = client.chat.completions.create( | |
| model=MODEL_NAME, | |
| messages=[ | |
| {"role": "system", "content": prompt}, | |
| {"role": "user", "content": f"Сгенерируй задания по теме: {topic}"}, | |
| ], | |
| temperature=0.7, | |
| max_tokens=3000, | |
| ) | |
| raw_output = response.choices[0].message.content | |
| except Exception as e: | |
| return f"<p style='color:red;'>Ошибка при обращении к модели: {e}</p>" | |
| try: | |
| data = extract_json(raw_output) | |
| except (json.JSONDecodeError, ValueError): | |
| safe_text = raw_output.replace("<", "<").replace(">", ">") | |
| return f""" | |
| <p style='color:#b8860b;'>Не удалось разобрать ответ модели как JSON. | |
| Показан необработанный результат:</p> | |
| <pre style='white-space:pre-wrap; background:#f5f5f5; padding:12px; border-radius:8px;'>{safe_text}</pre> | |
| """ | |
| tasks = data.get("задания", []) | |
| by_level = {"базовый": [], "средний": [], "продвинутый": []} | |
| for t in tasks: | |
| level = t.get("уровень", "").strip().lower() | |
| if level in by_level: | |
| by_level[level].append(t) | |
| source_note = "📖 задания сгенерированы с опорой на загруженный учебник" if section_text else "" | |
| header = f"<h2>Тема: {data.get('тема', topic)}</h2>" | |
| if source_note: | |
| header += f"<p style='color:#2e7d32; font-size:0.9em;'>{source_note}</p>" | |
| # Запись результата обратно в таблицу (если тема пришла из листа выбранного класса) | |
| if tasks and class_state: | |
| write_status = write_result_to_sheet(class_state, topic, by_level) | |
| if write_status: | |
| color = "#2e7d32" if write_status.startswith("💾") else "#b8860b" | |
| header += f"<p style='color:{color}; font-size:0.9em;'>{write_status}</p>" | |
| html_parts = [header] | |
| for level_key in ["базовый", "средний", "продвинутый"]: | |
| html_parts.append(render_level_html(level_key, by_level[level_key])) | |
| if not tasks: | |
| html_parts.append("<p style='color:red;'>Модель не вернула ни одного задания. Попробуйте ещё раз.</p>") | |
| return "".join(html_parts) | |
| # --------------------------------------------------------------------------- | |
| # Обработчики загрузки файлов | |
| # --------------------------------------------------------------------------- | |
| def on_topics_file_upload(file): | |
| """При загрузке файла с темами — обновляет выпадающий список.""" | |
| if file is None: | |
| choices = DEFAULT_TOPICS + [CUSTOM_TOPIC_LABEL] | |
| return gr.update(choices=choices, value=choices[0]), "Используется список тем по умолчанию.", {} | |
| topics = parse_topics_md(file.name) | |
| if not topics: | |
| choices = DEFAULT_TOPICS + [CUSTOM_TOPIC_LABEL] | |
| return gr.update(choices=choices, value=choices[0]), "⚠️ Не удалось разобрать файл (нет строк вида «- Тема»). Используется список по умолчанию.", {} | |
| choices = topics + [CUSTOM_TOPIC_LABEL] | |
| return gr.update(choices=choices, value=choices[0]), f"✅ Загружено тем: {len(topics)}", {} | |
| def on_textbook_file_upload(file): | |
| """При загрузке файла учебника — парсит разделы и сохраняет в состояние.""" | |
| if file is None: | |
| return {}, "Учебник не загружен — задания будут генерироваться без привязки к тексту." | |
| sections = parse_textbook_md(file.name) | |
| if not sections: | |
| return {}, "⚠️ Не удалось найти разделы в файле (ожидались заголовки вида «## Название темы»)." | |
| return sections, f"✅ Загружено разделов учебника: {len(sections)}" | |
| def on_refresh_classes(): | |
| """Загружает список классов (листов таблицы) при нажатии кнопки обновления.""" | |
| try: | |
| classes = list_available_classes() | |
| except Exception as e: | |
| return gr.update(choices=[]), f"⚠️ Не удалось получить список классов: {e}" | |
| if not classes: | |
| return gr.update(choices=[]), "⚠️ В таблице не найдено ни одного листа." | |
| return gr.update(choices=classes, value=classes[0]), f"✅ Найдено классов: {len(classes)}" | |
| def on_class_selected(class_name): | |
| """При выборе класса — подгружает темы и учебник из соответствующего листа таблицы.""" | |
| topics, textbook_sections, status = load_class_data(class_name) | |
| if not topics: | |
| choices = DEFAULT_TOPICS + [CUSTOM_TOPIC_LABEL] | |
| return gr.update(choices=choices, value=choices[0]), {}, status, None | |
| choices = topics + [CUSTOM_TOPIC_LABEL] | |
| return gr.update(choices=choices, value=choices[0]), textbook_sections, status, class_name | |
| def on_refresh_stats(class_name): | |
| """Строит график статистики использования тем через Pandas/Matplotlib.""" | |
| fig, status = build_usage_stats(class_name) | |
| return fig, status | |
| # --------------------------------------------------------------------------- | |
| # Интерфейс Gradio | |
| # --------------------------------------------------------------------------- | |
| with gr.Blocks(title="Генератор разноуровневых заданий") as demo: | |
| gr.Markdown(""" | |
| # 🎓 Генератор разноуровневых заданий | |
| ### Информатика, 10 класс | |
| Выберите тему (или введите свою), укажите количество заданий на каждый уровень сложности — и получите готовый комплект с ответами и методическими комментариями. | |
| """) | |
| # Скрытое состояние — распарсенные разделы учебника {тема: текст} и текущий класс | |
| textbook_sections_state = gr.State({}) | |
| class_state = gr.State(None) | |
| with gr.Accordion("📊 Источник: Google Таблица с темами по классам", open=True): | |
| gr.Markdown( | |
| "Таблица содержит листы по классам. В каждом листе: 1-я строка — ссылка " | |
| "на .md учебника на Google Drive, остальные строки — темы. Столбцы B–D " | |
| "заполняются сгенерированными заданиями по уровням, столбец E — счётчик генераций." | |
| ) | |
| with gr.Row(): | |
| refresh_classes_btn = gr.Button("🔄 Загрузить список классов") | |
| class_dropdown = gr.Dropdown(choices=[], label="Класс", interactive=True) | |
| sheets_status = gr.Markdown("Нажмите «Загрузить список классов», чтобы подключиться к таблице.") | |
| with gr.Accordion("📁 Резервный источник: загрузка файлов вручную (если таблица недоступна)", open=False): | |
| with gr.Row(): | |
| with gr.Column(): | |
| topics_file = gr.File( | |
| label="Загрузить список тем (.md)", | |
| file_types=[".md", ".txt"], | |
| ) | |
| topics_status = gr.Markdown("Используется список тем по умолчанию.") | |
| with gr.Column(): | |
| textbook_file = gr.File( | |
| label="Загрузить учебник (.md)", | |
| file_types=[".md", ".txt"], | |
| ) | |
| textbook_status = gr.Markdown("Учебник не загружен — задания будут генерироваться без привязки к тексту.") | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| topic_dropdown = gr.Dropdown( | |
| choices=DEFAULT_TOPICS + [CUSTOM_TOPIC_LABEL], | |
| label="Тема урока", | |
| value=DEFAULT_TOPICS[0], | |
| ) | |
| custom_topic_input = gr.Textbox( | |
| label="Своя тема (если нужна тема не из списка — впишите здесь)", | |
| placeholder="Например: Рекурсивные алгоритмы", | |
| ) | |
| gr.Markdown("**Количество заданий по уровням:**") | |
| n_basic = gr.Slider(0, 5, value=2, step=1, label="🟢 Базовый уровень") | |
| n_medium = gr.Slider(0, 5, value=2, step=1, label="🟡 Средний уровень") | |
| n_advanced = gr.Slider(0, 5, value=1, step=1, label="🔴 Продвинутый уровень") | |
| generate_btn = gr.Button("Сгенерировать задания", variant="primary") | |
| with gr.Column(scale=2): | |
| output_html = gr.HTML(label="Результат") | |
| with gr.Accordion("📈 Статистика использования по темам (Pandas + Matplotlib)", open=False): | |
| gr.Markdown( | |
| "Читает столбец с количеством генераций по каждой теме выбранного класса " | |
| "и строит график самых часто запрашиваемых тем." | |
| ) | |
| refresh_stats_btn = gr.Button("Обновить статистику") | |
| stats_status = gr.Markdown("Выберите класс выше и нажмите «Обновить статистику».") | |
| stats_plot = gr.Plot(label="Частота запросов по темам") | |
| refresh_classes_btn.click( | |
| fn=on_refresh_classes, | |
| inputs=[], | |
| outputs=[class_dropdown, sheets_status], | |
| ) | |
| class_dropdown.change( | |
| fn=on_class_selected, | |
| inputs=[class_dropdown], | |
| outputs=[topic_dropdown, textbook_sections_state, sheets_status, class_state], | |
| ) | |
| topics_file.change( | |
| fn=on_topics_file_upload, | |
| inputs=[topics_file], | |
| outputs=[topic_dropdown, topics_status, textbook_sections_state], | |
| ) | |
| textbook_file.change( | |
| fn=on_textbook_file_upload, | |
| inputs=[textbook_file], | |
| outputs=[textbook_sections_state, textbook_status], | |
| ) | |
| generate_btn.click( | |
| fn=generate_tasks, | |
| inputs=[topic_dropdown, custom_topic_input, n_basic, n_medium, n_advanced, textbook_sections_state, class_state], | |
| outputs=output_html, | |
| ) | |
| refresh_stats_btn.click( | |
| fn=on_refresh_stats, | |
| inputs=[class_state], | |
| outputs=[stats_plot, stats_status], | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch() |