Spaces:
Running on Zero
Running on Zero
Update app.py
Browse files
app.py
CHANGED
|
@@ -20,6 +20,15 @@ try:
|
|
| 20 |
except ImportError:
|
| 21 |
GSPREAD_AVAILABLE = False
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
try:
|
| 24 |
import spaces # доступно только внутри HuggingFace Spaces
|
| 25 |
except ImportError:
|
|
@@ -48,10 +57,15 @@ SPREADSHEET_ID = os.environ.get(
|
|
| 48 |
GOOGLE_SERVICE_ACCOUNT_ENV = "GOOGLE_SERVICE_ACCOUNT_JSON"
|
| 49 |
|
| 50 |
SHEETS_SCOPES = [
|
| 51 |
-
"https://www.googleapis.com/auth/spreadsheets
|
| 52 |
"https://www.googleapis.com/auth/drive.readonly",
|
| 53 |
]
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
# Максимальная длина раздела учебника, которая уходит в промпт.
|
| 56 |
# Ограничивает расход токенов Groq API даже для очень длинных глав.
|
| 57 |
MAX_TEXTBOOK_SECTION_CHARS = 6000
|
|
@@ -327,6 +341,113 @@ def load_class_data(class_name: str):
|
|
| 327 |
return topics, textbook_sections, status
|
| 328 |
|
| 329 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
# ---------------------------------------------------------------------------
|
| 331 |
# Логика генерации
|
| 332 |
# ---------------------------------------------------------------------------
|
|
@@ -390,7 +511,7 @@ def render_level_html(level_key: str, tasks: list) -> str:
|
|
| 390 |
|
| 391 |
|
| 392 |
@spaces.GPU
|
| 393 |
-
def generate_tasks(dropdown_value, custom_topic, n_basic, n_medium, n_advanced, textbook_sections_state):
|
| 394 |
topic = build_topic(dropdown_value, custom_topic)
|
| 395 |
|
| 396 |
if not topic:
|
|
@@ -453,6 +574,13 @@ def generate_tasks(dropdown_value, custom_topic, n_basic, n_medium, n_advanced,
|
|
| 453 |
if source_note:
|
| 454 |
header += f"<p style='color:#2e7d32; font-size:0.9em;'>{source_note}</p>"
|
| 455 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 456 |
html_parts = [header]
|
| 457 |
for level_key in ["базовый", "средний", "продвинутый"]:
|
| 458 |
html_parts.append(render_level_html(level_key, by_level[level_key]))
|
|
@@ -513,10 +641,16 @@ def on_class_selected(class_name):
|
|
| 513 |
|
| 514 |
if not topics:
|
| 515 |
choices = DEFAULT_TOPICS + [CUSTOM_TOPIC_LABEL]
|
| 516 |
-
return gr.update(choices=choices, value=choices[0]), {}, status
|
| 517 |
|
| 518 |
choices = topics + [CUSTOM_TOPIC_LABEL]
|
| 519 |
-
return gr.update(choices=choices, value=choices[0]), textbook_sections, status
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 520 |
|
| 521 |
|
| 522 |
# ---------------------------------------------------------------------------
|
|
@@ -530,13 +664,15 @@ with gr.Blocks(title="Генератор разноуровневых задан
|
|
| 530 |
Выберите тему (или введите свою), укажите количество заданий на каждый уровень сложности — и получите готовый комплект с ответами и методическими комментариями.
|
| 531 |
""")
|
| 532 |
|
| 533 |
-
# Скрытое состояние — распарсенные разделы учебника {тема: текст}
|
| 534 |
textbook_sections_state = gr.State({})
|
|
|
|
| 535 |
|
| 536 |
with gr.Accordion("📊 Источник: Google Таблица с темами по классам", open=True):
|
| 537 |
gr.Markdown(
|
| 538 |
"Таблица содержит листы по классам. В каждом листе: 1-я строка — ссылка "
|
| 539 |
-
"на .md учебника на Google Drive, остальные строки — темы."
|
|
|
|
| 540 |
)
|
| 541 |
with gr.Row():
|
| 542 |
refresh_classes_btn = gr.Button("🔄 Загрузить список классов")
|
|
@@ -580,6 +716,15 @@ with gr.Blocks(title="Генератор разноуровневых задан
|
|
| 580 |
with gr.Column(scale=2):
|
| 581 |
output_html = gr.HTML(label="Результат")
|
| 582 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 583 |
refresh_classes_btn.click(
|
| 584 |
fn=on_refresh_classes,
|
| 585 |
inputs=[],
|
|
@@ -589,7 +734,7 @@ with gr.Blocks(title="Генератор разноуровневых задан
|
|
| 589 |
class_dropdown.change(
|
| 590 |
fn=on_class_selected,
|
| 591 |
inputs=[class_dropdown],
|
| 592 |
-
outputs=[topic_dropdown, textbook_sections_state, sheets_status],
|
| 593 |
)
|
| 594 |
|
| 595 |
topics_file.change(
|
|
@@ -606,9 +751,15 @@ with gr.Blocks(title="Генератор разноуровневых задан
|
|
| 606 |
|
| 607 |
generate_btn.click(
|
| 608 |
fn=generate_tasks,
|
| 609 |
-
inputs=[topic_dropdown, custom_topic_input, n_basic, n_medium, n_advanced, textbook_sections_state],
|
| 610 |
outputs=output_html,
|
| 611 |
)
|
| 612 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 613 |
if __name__ == "__main__":
|
| 614 |
demo.launch()
|
|
|
|
| 20 |
except ImportError:
|
| 21 |
GSPREAD_AVAILABLE = False
|
| 22 |
|
| 23 |
+
try:
|
| 24 |
+
import pandas as pd
|
| 25 |
+
import matplotlib
|
| 26 |
+
matplotlib.use("Agg")
|
| 27 |
+
import matplotlib.pyplot as plt
|
| 28 |
+
PANDAS_AVAILABLE = True
|
| 29 |
+
except ImportError:
|
| 30 |
+
PANDAS_AVAILABLE = False
|
| 31 |
+
|
| 32 |
try:
|
| 33 |
import spaces # доступно только внутри HuggingFace Spaces
|
| 34 |
except ImportError:
|
|
|
|
| 57 |
GOOGLE_SERVICE_ACCOUNT_ENV = "GOOGLE_SERVICE_ACCOUNT_JSON"
|
| 58 |
|
| 59 |
SHEETS_SCOPES = [
|
| 60 |
+
"https://www.googleapis.com/auth/spreadsheets", # чтение и запись
|
| 61 |
"https://www.googleapis.com/auth/drive.readonly",
|
| 62 |
]
|
| 63 |
|
| 64 |
+
# Раскладка столбцов на листе класса: A — тема (уже используется),
|
| 65 |
+
# B/C/D — сгенерированные задания по уровням, E — счётчик генераций по теме
|
| 66 |
+
RESULT_COLUMNS = {"базовый": 2, "средний": 3, "продвинутый": 4} # B, C, D
|
| 67 |
+
GENERATION_COUNT_COLUMN = 5 # E
|
| 68 |
+
|
| 69 |
# Максимальная длина раздела учебника, которая уходит в промпт.
|
| 70 |
# Ограничивает расход токенов Groq API даже для очень длинных глав.
|
| 71 |
MAX_TEXTBOOK_SECTION_CHARS = 6000
|
|
|
|
| 341 |
return topics, textbook_sections, status
|
| 342 |
|
| 343 |
|
| 344 |
+
def format_tasks_for_cell(tasks: list) -> str:
|
| 345 |
+
"""Форматирует список заданий одного уровня в текст для одной ячейки таблицы."""
|
| 346 |
+
if not tasks:
|
| 347 |
+
return ""
|
| 348 |
+
|
| 349 |
+
blocks = []
|
| 350 |
+
for i, t in enumerate(tasks, start=1):
|
| 351 |
+
blocks.append(
|
| 352 |
+
f"Задание {i}: {t.get('задание', '—')}\n"
|
| 353 |
+
f"Ответ: {t.get('ответ', '—')}\n"
|
| 354 |
+
f"Комментарий: {t.get('комментарий', '—')}"
|
| 355 |
+
)
|
| 356 |
+
return "\n\n".join(blocks)
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
def write_result_to_sheet(class_name: str, topic: str, by_level: dict) -> str:
|
| 360 |
+
"""
|
| 361 |
+
Записывает (с перезаписью) сгенерированные задания в столбцы B/C/D строки темы
|
| 362 |
+
и увеличивает счётчик генераций в столбце E. Работает только если тема реально
|
| 363 |
+
взята из листа таблицы (найдена точным совпадением в столбце A) — для тем,
|
| 364 |
+
введённых вручную, запись молча пропускается.
|
| 365 |
+
"""
|
| 366 |
+
if not class_name or not topic:
|
| 367 |
+
return ""
|
| 368 |
+
|
| 369 |
+
try:
|
| 370 |
+
gc = get_sheets_client()
|
| 371 |
+
sh = gc.open_by_key(SPREADSHEET_ID)
|
| 372 |
+
ws = sh.worksheet(class_name)
|
| 373 |
+
col_values = ws.col_values(1)
|
| 374 |
+
except Exception as e:
|
| 375 |
+
return f"⚠️ Не удалось записать результат в таблицу: {e}"
|
| 376 |
+
|
| 377 |
+
row_index = None
|
| 378 |
+
for i, value in enumerate(col_values, start=1):
|
| 379 |
+
if value.strip() == topic.strip():
|
| 380 |
+
row_index = i
|
| 381 |
+
break
|
| 382 |
+
|
| 383 |
+
if row_index is None:
|
| 384 |
+
# Тема введена вручную и её нет в листе — запись в таблицу не имеет смысла
|
| 385 |
+
return ""
|
| 386 |
+
|
| 387 |
+
try:
|
| 388 |
+
for level, col in RESULT_COLUMNS.items():
|
| 389 |
+
cell_text = format_tasks_for_cell(by_level.get(level, []))
|
| 390 |
+
ws.update_cell(row_index, col, cell_text)
|
| 391 |
+
|
| 392 |
+
current_count_values = ws.col_values(GENERATION_COUNT_COLUMN)
|
| 393 |
+
current_count = 0
|
| 394 |
+
if len(current_count_values) >= row_index:
|
| 395 |
+
try:
|
| 396 |
+
current_count = int(current_count_values[row_index - 1])
|
| 397 |
+
except (ValueError, IndexError):
|
| 398 |
+
current_count = 0
|
| 399 |
+
ws.update_cell(row_index, GENERATION_COUNT_COLUMN, current_count + 1)
|
| 400 |
+
except Exception as e:
|
| 401 |
+
return f"⚠️ Задания сгенерированы, но не удалось записать их в таблицу: {e}"
|
| 402 |
+
|
| 403 |
+
return f"💾 Результат записан в таблицу (строка {row_index}, лист «{class_name}»)."
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
def build_usage_stats(class_name: str):
|
| 407 |
+
"""
|
| 408 |
+
Читает столбцы A (темы) и E (счётчик генераций) выбранного листа через Pandas
|
| 409 |
+
и строит график частоты запросов по темам (Matplotlib).
|
| 410 |
+
Возвращает (figure, status_message); figure может быть None при ошибке/отсутствии данных.
|
| 411 |
+
"""
|
| 412 |
+
if not PANDAS_AVAILABLE:
|
| 413 |
+
return None, "⚠️ Pandas/Matplotlib не установлены."
|
| 414 |
+
|
| 415 |
+
if not class_name:
|
| 416 |
+
return None, "Класс не выбран."
|
| 417 |
+
|
| 418 |
+
try:
|
| 419 |
+
gc = get_sheets_client()
|
| 420 |
+
sh = gc.open_by_key(SPREADSHEET_ID)
|
| 421 |
+
ws = sh.worksheet(class_name)
|
| 422 |
+
topics_col = ws.col_values(1)[1:] # без 1-й строки (ссылка на учебник)
|
| 423 |
+
counts_col = ws.col_values(GENERATION_COUNT_COLUMN)[1:]
|
| 424 |
+
except Exception as e:
|
| 425 |
+
return None, f"⚠️ Не удалось прочитать данные для статистики: {e}"
|
| 426 |
+
|
| 427 |
+
# Выравниваем длины столбцов и приводим счётчик к числу через Pandas
|
| 428 |
+
n = len(topics_col)
|
| 429 |
+
counts_col = (counts_col + [""] * n)[:n]
|
| 430 |
+
|
| 431 |
+
df = pd.DataFrame({"тема": topics_col, "генераций": counts_col})
|
| 432 |
+
df["генераций"] = pd.to_numeric(df["генераций"], errors="coerce").fillna(0).astype(int)
|
| 433 |
+
df = df[df["тема"].str.strip() != ""]
|
| 434 |
+
|
| 435 |
+
if df["генераций"].sum() == 0:
|
| 436 |
+
return None, "Пока нет ни одной генерации по темам этого класса — статистика появится после первых запусков."
|
| 437 |
+
|
| 438 |
+
df_sorted = df.sort_values("генераций", ascending=False).head(10)
|
| 439 |
+
|
| 440 |
+
fig, ax = plt.subplots(figsize=(8, 4.5))
|
| 441 |
+
ax.barh(df_sorted["тема"], df_sorted["генераций"], color="#1E8F7A")
|
| 442 |
+
ax.set_xlabel("Количество генераций")
|
| 443 |
+
ax.set_title(f"Частота запросов по темам — {class_name}")
|
| 444 |
+
ax.invert_yaxis()
|
| 445 |
+
fig.tight_layout()
|
| 446 |
+
|
| 447 |
+
total_generations = int(df["генераций"].sum())
|
| 448 |
+
return fig, f"Всего генераций по классу «{class_name}»: {total_generations}"
|
| 449 |
+
|
| 450 |
+
|
| 451 |
# ---------------------------------------------------------------------------
|
| 452 |
# Логика генерации
|
| 453 |
# ---------------------------------------------------------------------------
|
|
|
|
| 511 |
|
| 512 |
|
| 513 |
@spaces.GPU
|
| 514 |
+
def generate_tasks(dropdown_value, custom_topic, n_basic, n_medium, n_advanced, textbook_sections_state, class_state):
|
| 515 |
topic = build_topic(dropdown_value, custom_topic)
|
| 516 |
|
| 517 |
if not topic:
|
|
|
|
| 574 |
if source_note:
|
| 575 |
header += f"<p style='color:#2e7d32; font-size:0.9em;'>{source_note}</p>"
|
| 576 |
|
| 577 |
+
# Запись результата обратно в таблицу (если тема пришла из листа выбранного класса)
|
| 578 |
+
if tasks and class_state:
|
| 579 |
+
write_status = write_result_to_sheet(class_state, topic, by_level)
|
| 580 |
+
if write_status:
|
| 581 |
+
color = "#2e7d32" if write_status.startswith("💾") else "#b8860b"
|
| 582 |
+
header += f"<p style='color:{color}; font-size:0.9em;'>{write_status}</p>"
|
| 583 |
+
|
| 584 |
html_parts = [header]
|
| 585 |
for level_key in ["базовый", "средний", "продвинутый"]:
|
| 586 |
html_parts.append(render_level_html(level_key, by_level[level_key]))
|
|
|
|
| 641 |
|
| 642 |
if not topics:
|
| 643 |
choices = DEFAULT_TOPICS + [CUSTOM_TOPIC_LABEL]
|
| 644 |
+
return gr.update(choices=choices, value=choices[0]), {}, status, None
|
| 645 |
|
| 646 |
choices = topics + [CUSTOM_TOPIC_LABEL]
|
| 647 |
+
return gr.update(choices=choices, value=choices[0]), textbook_sections, status, class_name
|
| 648 |
+
|
| 649 |
+
|
| 650 |
+
def on_refresh_stats(class_name):
|
| 651 |
+
"""Строит график статистики использования тем через Pandas/Matplotlib."""
|
| 652 |
+
fig, status = build_usage_stats(class_name)
|
| 653 |
+
return fig, status
|
| 654 |
|
| 655 |
|
| 656 |
# ---------------------------------------------------------------------------
|
|
|
|
| 664 |
Выберите тему (или введите свою), укажите количество заданий на каждый уровень сложности — и получите готовый комплект с ответами и методическими комментариями.
|
| 665 |
""")
|
| 666 |
|
| 667 |
+
# Скрытое состояние — распарсенные разделы учебника {тема: текст} и текущий класс
|
| 668 |
textbook_sections_state = gr.State({})
|
| 669 |
+
class_state = gr.State(None)
|
| 670 |
|
| 671 |
with gr.Accordion("📊 Источник: Google Таблица с темами по классам", open=True):
|
| 672 |
gr.Markdown(
|
| 673 |
"Таблица содержит листы по классам. В каждом листе: 1-я строка — ссылка "
|
| 674 |
+
"на .md учебника на Google Drive, остальные строки — темы. Столбцы B–D "
|
| 675 |
+
"��аполняются сгенерированными заданиями по уровням, столбец E — счётчик генераций."
|
| 676 |
)
|
| 677 |
with gr.Row():
|
| 678 |
refresh_classes_btn = gr.Button("🔄 Загрузить список классов")
|
|
|
|
| 716 |
with gr.Column(scale=2):
|
| 717 |
output_html = gr.HTML(label="Результат")
|
| 718 |
|
| 719 |
+
with gr.Accordion("📈 Статистика использования по темам (Pandas + Matplotlib)", open=False):
|
| 720 |
+
gr.Markdown(
|
| 721 |
+
"Читает столбец с количеством генераций по каждой теме выбранного класса "
|
| 722 |
+
"и строит график самых часто запрашиваемых тем."
|
| 723 |
+
)
|
| 724 |
+
refresh_stats_btn = gr.Button("Обновить статистику")
|
| 725 |
+
stats_status = gr.Markdown("Выберите класс выше и нажмите «Обновить статистику».")
|
| 726 |
+
stats_plot = gr.Plot(label="Частота запросов по темам")
|
| 727 |
+
|
| 728 |
refresh_classes_btn.click(
|
| 729 |
fn=on_refresh_classes,
|
| 730 |
inputs=[],
|
|
|
|
| 734 |
class_dropdown.change(
|
| 735 |
fn=on_class_selected,
|
| 736 |
inputs=[class_dropdown],
|
| 737 |
+
outputs=[topic_dropdown, textbook_sections_state, sheets_status, class_state],
|
| 738 |
)
|
| 739 |
|
| 740 |
topics_file.change(
|
|
|
|
| 751 |
|
| 752 |
generate_btn.click(
|
| 753 |
fn=generate_tasks,
|
| 754 |
+
inputs=[topic_dropdown, custom_topic_input, n_basic, n_medium, n_advanced, textbook_sections_state, class_state],
|
| 755 |
outputs=output_html,
|
| 756 |
)
|
| 757 |
|
| 758 |
+
refresh_stats_btn.click(
|
| 759 |
+
fn=on_refresh_stats,
|
| 760 |
+
inputs=[class_state],
|
| 761 |
+
outputs=[stats_plot, stats_status],
|
| 762 |
+
)
|
| 763 |
+
|
| 764 |
if __name__ == "__main__":
|
| 765 |
demo.launch()
|