""" Генератор разноуровневых заданий по информатике (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: @staticmethod 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"""
Пожалуйста, укажите тему урока — выберите из списка или введите свою.
" if n_basic == 0 and n_medium == 0 and n_advanced == 0: return "Укажите хотя бы одно задание хотя бы на одном уровне.
" if not os.environ.get("GROQ_API_KEY"): return "Не найден GROQ_API_KEY. Добавьте ключ в Secrets (HuggingFace Spaces) или переменные окружения.
" # Ищем раздел учебника по точному совпадению темы (молча, без ошибки если не найден) 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"Ошибка при обращении к модели: {e}
" try: data = extract_json(raw_output) except (json.JSONDecodeError, ValueError): safe_text = raw_output.replace("<", "<").replace(">", ">") return f"""Не удалось разобрать ответ модели как JSON. Показан необработанный результат:
{safe_text}
"""
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"{source_note}
" # Запись результата обратно в таблицу (если тема пришла из листа выбранного класса) 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"{write_status}
" 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("Модель не вернула ни одного задания. Попробуйте ещё раз.
") 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()