differencial / app.py
duksa's picture
Update app.py
8976b6d verified
Raw
History Blame Contribute Delete
36.1 kB
"""
Генератор разноуровневых заданий по информатике (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"""
<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>
"""
@spaces.GPU
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("<", "&lt;").replace(">", "&gt;")
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()