Spaces:
Running on Zero
Running on Zero
File size: 36,133 Bytes
afe301e bd5166f afe301e bd5166f afe301e bd5166f 8976b6d afe301e bd5166f 8976b6d bd5166f 8976b6d bd5166f afe301e bd5166f afe301e bd5166f afe301e bd5166f afe301e 8976b6d afe301e bd5166f afe301e 8976b6d afe301e bd5166f afe301e bd5166f afe301e bd5166f 8976b6d bd5166f afe301e bd5166f 8976b6d bd5166f 8976b6d bd5166f afe301e 8976b6d bd5166f 8976b6d bd5166f 8976b6d bd5166f afe301e bd5166f afe301e bd5166f afe301e 8976b6d bd5166f 8976b6d bd5166f afe301e 8976b6d afe301e 8976b6d afe301e bd5166f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 | """
Генератор разноуровневых заданий по информатике (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("<", "<").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() |