import os import logging import tempfile from datetime import datetime from dotenv import load_dotenv from openai import OpenAI import gradio as gr from docx import Document import requests from typing import Optional import random from app.quotes import quotes from app.drawings import drawings from app.knowledge_base.textbooks import TEXTBOOKS # --- Настройка --- logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') load_dotenv() api_key = os.getenv("API_KEY_openai") client = OpenAI(api_key=api_key) if api_key else None VS_ID = os.getenv("VECTOR_STORE_ID", "") # vector store for file_search if needed # --- Утилиты --- def upload_to_catbox(file_bytes: bytes) -> str: """Загружает файл на catbox.moe и возвращает URL""" try: response = requests.post( "https://catbox.moe/user/api.php", files={"fileToUpload": file_bytes}, data={"reqtype": "fileupload"}, timeout=10 ) response.raise_for_status() return response.text.strip() except Exception as e: logging.error(f"Ошибка загрузки изображения: {e}") raise gr.Error("Не удалось загрузить изображение") def generate_docx(text: str) -> str: """Сохраняет текст в .docx и возвращает путь""" doc = Document() for line in text.split("\n"): doc.add_paragraph(line) tmp = tempfile.gettempdir() path = os.path.join(tmp, f"lesson_plan_{datetime.now().strftime('%Y%m%d%H%M%S')}.docx") doc.save(path) return path # названия textbooks для автозаполнения поля Учебник import json ## Сохраняем отзыв через POST в google-таблицу GOOGLE_SHEET_URL = os.getenv("FEEDBACK_GS_URL") def save_feedback(comment, rate): payload = { "comment": comment, "rate": rate } try: requests.post(GOOGLE_SHEET_URL, json=payload, timeout=3) except Exception as e: print(f"Ошибка при отправке отзыва: {e}") ### ОСНОВНАЯ ФУНКЦИЯ ГЕНЕРАЦИИ def generate_lesson_plan( image_path: Optional[str], textbook: str, cefr: str, topic: str, goal: str, format_type: str, num_students: int, age: str, adults: bool, level_match: int, duration: int, inventory: str, extra_info: str, methodology: str, target_language: str, hw_required: bool, web_search: bool # repetition: bool, # application: bool, # analysis: bool, # creativity: bool ) -> str: # Валидация API клиента if not client: raise gr.Error("API ключ не настроен") # Проверка наличия изображения if not image_path: return "❗ Загрузите фото страницы учебника для генерации урока" # Загружаем изображение и получаем URL try: with open(image_path, "rb") as f: image_url = upload_to_catbox(f.read()) except Exception as e: logging.error(f"Image upload error: {e}") raise gr.Error("Не удалось загрузить изображение") # Собираем текстовый prompt lesson_params = { 'methodology': methodology, 'target_language': target_language, # для PPP 'textbook': textbook, 'cefr': cefr, 'topic': topic, 'goal': goal, 'num_students': num_students, 'age': 'Взрослые' if adults else age, 'level_match': level_match, 'duration': duration, 'inventory': inventory, 'hw_required': hw_required, 'extra_info': extra_info } # Получаем готовый промпт from app.prompt_builder.prompt import build_prompt full_prompt = build_prompt(lesson_params) # Собираем входные данные согласно API input_content = [ { "type": "input_text", "text": full_prompt }, { "type": "input_image", "image_url": image_url, "detail": "high" } ] # Опции инструментов tools = [] tool_choice = None if web_search: tools.append({ "type": "web_search_preview", "search_context_size": "medium", "user_location": {"type": "approximate", "country": "RU"} }) tool_choice = {"type": "web_search_preview"} # Вызов LLM try: response = client.responses.create( input=[{"role": "user", "content": input_content}], model=os.getenv("OPENAI_MODEL"), # gpt-4o-mini gpt-4.1 tools=tools or None, tool_choice=tool_choice, max_output_tokens=8192, reasoning= {"effort":"medium"}, stream=False ) return response.output_text except Exception as e: logging.error(f"Generation error: {e}") raise gr.Error(f"Ошибка генерации: {e}") ########## ИНТЕРФЕЙС # Случайный рисунок в блокноте drawing = random.choice(drawings) # Текст с подсказкой и рисунком в блокноте hint_text = f"""Здесь появится план занятия — заполните вводные и нажмите кнопку **Создать план**
Создание может занять до 1 минуты
{drawing}
""" #ТЕМА И СТИЛИ ### css привязывать именно так #theme='earneleh/paris' theme = gr.themes.Base( secondary_hue="rose", neutral_hue="stone", ).set( body_background_fill='*primary_50', body_text_color='*primary_900', body_text_size='*text_lg', body_text_color_subdued='*primary_700', body_text_weight='600', ) css_path = os.path.join(os.path.dirname(__file__), "styles.css") # ИНТЕРФЕЙС with gr.Blocks(theme=theme, css_paths=css_path) as app: advanced_settings_visible = gr.State(value=False) # Импортируем gr.State для хранения состояния feedback_visible = gr.State(False) # Хранит, открыт ли блок отзыва gr.Markdown("# План урока английского языка", elem_classes=["main-title"]) quote_box = gr.Markdown(random.choice(quotes), elem_classes=["quote-block"]) with gr.Row(): with gr.Column(elem_classes=["left-col"], scale=1): image = gr.Image( label="Фото страницы учебника*", type="filepath", height=0, # Автоматическая высота container=False # Не растягивать контейнер ) # Блок 1: Учебник with gr.Column(variant="panel"): gr.Markdown("### 📚 Учебник", elem_classes=["block-title"]) textbook = gr.Textbox(label="Название учебника", placeholder="напр. English File Beginner", elem_id="textbook_input") cefr = gr.Dropdown(label="Уровень", choices=["", "A1", "A2", "B1", "B2", "C1", "C2"], value="", info="Выберите уровень") # Блок 2: Класс with gr.Column(variant="panel"): gr.Markdown("### 👥 Класс", elem_classes=["block-title"]) format_type = gr.Radio(label="Формат занятия", choices=["Индивидуальное", "Групповое"], value="Групповое") with gr.Group(visible=True) as group_settings: num_students = gr.Slider(label="Количество учеников", minimum=1, maximum=40, value=10, step=1, info="Для групповых занятий") with gr.Group() as age_group: age = gr.Textbox(label="Возраст", placeholder="напр. 10–11", interactive=True) adults = gr.Checkbox(label="Взрослые") level_match = gr.Dropdown(label = "Уровень учеников относительно учебника", choices = ["ниже", "на уровне", "выше", "mixed"], value = "на уровне") # Блок 3: Занятие with gr.Column(variant="panel"): gr.Markdown("### 📄 Занятие", elem_classes=["block-title"]) topic = gr.Textbox(label="Тема", placeholder="напр. Daily routines") goal = gr.Textbox(label="Цель", placeholder="К концу урока ученики смогут...") duration = gr.Slider(label="Длительность (мин)", minimum=30, maximum=180, value=60, step=5) inventory = gr.Textbox(label="Инвентарь", placeholder="по умолчанию - доска и проектор") extra_info = gr.Textbox(label="Допонительная информация", placeholder="Класс весёлый и взвинченный после физкультуры") # Блок 4: Методика with gr.Column(variant="panel"): gr.Markdown("### 🎓 Методика", elem_classes=["block-title"]) methodology = gr.Dropdown(label="Методика преподавания", choices=["PPP (Presentation-Practice-Production)", "TTT (Test-Teach-Test)"], value="PPP (Presentation-Practice-Production)") target_language = gr.Textbox( label="Target language", placeholder="Например: 'I have been to...' 'How long have you...?' (Present Perfect experience)", visible=True # По умолчанию видно, так как PPP выбран ) # advanced_btn = gr.Button(value="➕ Продвинутые настройки (в разработке", size="sm") # # # Блок продвинутых настроек, изначально скрыт # with gr.Column(visible=False) as advanced_block: # gr.Markdown("**💡 Уровни задач (таксономия):**") # repetition = gr.Checkbox(label="Повторение") # application = gr.Checkbox(label="Применение") # analysis = gr.Checkbox(label="Анализ") # creativity = gr.Checkbox(label="Творчество") hw_required = gr.Checkbox(label="Домашнее задание") web_search = gr.Checkbox(label="Доп. материалы из интернета") btn = gr.Button("Создать план", variant="primary", size="lg") # Правая колонка — результат (output) with gr.Column(elem_classes=["right-col"], scale=2): # Общий блок-панель для правой колонки with gr.Column(variant="panel"): # <<< Главная панель gr.Markdown("### План урока", elem_classes=["block-title"]) # Заголовок ВНУТРИ панели # Блок с выводом плана output = gr.Markdown( hint_text, elem_id="plan-output" ) # Кнопка скачивания (оставляем внутри панели) download_btn = gr.DownloadButton( label="⬇️ Скачать .docx", visible=False ) # Кнопка "Помогите нам стать лучше" feedback_btn = gr.Button("💬 Помогите нам стать лучше", elem_classes=["feedback-button"]) # Скрытый блок с обратной связью with gr.Column(visible=False) as feedback_block: gr.Markdown("_Спасибо, что попробовали! Как вам?_\n_Ваши наблюдения и замечания помогают нам расти._") def toggle_feedback_block(current_visible): return ( gr.update(visible=not current_visible), # показать/скрыть блок not current_visible, # обновить состояние gr.update(visible=False) # скрыть благодарность при открытии/закрытии ) feedback_text = gr.Textbox( label="Ваше наблюдение или комментарий", placeholder="Например: 'Для 3 лет лексика подбирается неправильно — какая там 'машина', максимум - 'би-би'", lines=4 ) rating = gr.Radio( choices=["1", "2", "3", "4", "5"], label="Оценка" ) send_feedback = gr.Button("📩 Отправить отзыв") feedback_confirmation = gr.Markdown( visible=False, elem_classes=["feedback-confirmation"] ) # <-- Блок благодарности — ВНЕ feedback_block, но СРАЗУ ПОСЛЕ with gr.Column(visible=False) as feedback_confirmation: gr.Markdown("✅ Спасибо! Ваш комментарий передан и, возможно, уже сегодня ассистент станет полезнее :)") # Остальное gr.Markdown( """ 🙌 Если вас заинтересовал проект, мы приглашаем 👉 [присоединиться к Telegram-группе](https://t.me/+yN9BSJ1DJxxjZmJi) """ ) ### --- Логика интерфейса --- ## Доп. настройки # def toggle_advanced_settings(visible): # return gr.update(visible=not visible), not visible # advanced_btn.click( # fn=toggle_advanced_settings, # inputs=[advanced_settings_visible], # outputs=[advanced_block, advanced_settings_visible] # ) # Формат занятия - видимость поля если выбрано групповое def toggle_format(selected_format): return gr.update(visible=selected_format == "Групповое") format_type.change(fn=toggle_format, inputs=format_type, outputs=group_settings) # Возраст - деактивация поля если нажато "Взрослые" def toggle_age(adult_checked): return gr.update(interactive=not adult_checked) adults.change(fn=toggle_age, inputs=adults, outputs=age) # Функция для переключения видимости Target language def toggle_target_language(methodology_value): return gr.update(visible=methodology_value == "PPP (Presentation-Practice-Production)") methodology.change(fn=toggle_target_language, inputs=methodology, outputs=target_language) # JS для автодополнения app.load( None, None, js=f""" const textbooks = {TEXTBOOKS}; function setupAutocomplete() {{ const input = document.getElementById('textbook_input'); if (!input) return; const datalist = document.createElement('datalist'); datalist.id = 'textbook_suggestions'; textbooks.forEach(book => {{ const option = document.createElement('option'); option.value = book; datalist.appendChild(option); }}); input.setAttribute('list', datalist.id); document.body.appendChild(datalist); }} // Запускаем после загрузки if (document.readyState === 'complete') {{ setupAutocomplete(); }} else {{ document.addEventListener('DOMContentLoaded', setupAutocomplete); }} """ ) ### СПИСОК ВСЕХ ПАРАМЕТРОВ ИНТЕРФЕЙСА all_inputs = [ image, # Gradio компонент, соответствует image_path в функциях textbook, cefr, topic, goal, format_type, num_students, age, adults, level_match, duration, inventory, extra_info, methodology, target_language, hw_required, web_search # repetition, # application, # analysis, # creativity ] # Коллбек генерации def on_generate( image_path: Optional[str], # Переименовано из image textbook: str, cefr: str, topic: str, goal: str, format_type: str, num_students: int, age: str, adults: bool, level_match: int, duration: int, inventory: str, extra_info: str, methodology: str, target_language: str, hw_required: bool, web_search: bool # repetition: bool, # application: bool, # analysis: bool, # creativity: bool ): # Проверка обязательных полей if not image_path or (not adults and not age): return gr.update(value="❗ Заполните обязательные поля (отмечены *)"), gr.update(visible=False) # Собираем все аргументы в словарь kwargs = locals() # Генерация плана text = generate_lesson_plan(**kwargs) # Создание DOCX docx_path = generate_docx(text) if not text.startswith("❗") else None return gr.update(value=text), gr.update(visible=bool(docx_path), value=docx_path) btn.click( fn=on_generate, inputs=all_inputs, outputs=[output, download_btn] ) # Логика: показать форму по нажатию на кнопку feedback_btn.click( fn=toggle_feedback_block, inputs=[feedback_visible], outputs=[feedback_block, feedback_visible, feedback_confirmation] ) # Логика отправки обратной связи def send_feedback_fn(comment, rate): save_feedback(comment, rate) # Сохраняем отзыв return ( gr.update(visible=False), # свернуть форму False, # сбросить состояние gr.update( value="✅ Спасибо! Ваш комментарий передан, и, возможно, уже сегодня ассистент станет полезнее :)", visible=True ) ) send_feedback.click( fn=send_feedback_fn, inputs=[feedback_text, rating], outputs=[feedback_block, feedback_visible, feedback_confirmation] ) if __name__ == "__main__": app.launch()