| |
| import asyncio |
| import os |
| import re |
| from datetime import datetime |
|
|
| import gradio as gr |
| import pandas as pd |
|
|
| from ankigen.card_generator import ( |
| AVAILABLE_MODELS, |
| orchestrate_card_generation, |
| ) |
| from ankigen.exporters import ( |
| export_dataframe_to_apkg, |
| export_dataframe_to_csv, |
| ) |
| from ankigen.llm_interface import ( |
| OpenAIClientManager, |
| ) |
| from ankigen.ui_logic import update_mode_visibility |
| from ankigen.utils import ( |
| ResponseCache, |
| get_logger, |
| ) |
| from ankigen.auto_config import AutoConfigService |
|
|
| |
| logger = get_logger() |
| response_cache = ResponseCache() |
| client_manager = OpenAIClientManager() |
|
|
| |
|
|
| AGENTS_AVAILABLE_APP = True |
| logger.info("Agent system is available") |
|
|
| js_storage = """ |
| async () => { |
| const loadDecks = () => { |
| const decks = localStorage.getItem('ankigen_decks'); |
| return decks ? JSON.parse(decks) : []; |
| }; |
| const saveDecks = (decks) => { |
| localStorage.setItem('ankigen_decks', JSON.stringify(decks)); |
| }; |
| window.loadStoredDecks = loadDecks; |
| window.saveStoredDecks = saveDecks; |
| return loadDecks(); |
| } |
| """ |
|
|
| try: |
| custom_theme = gr.themes.Soft().set( |
| body_background_fill="*background_fill_secondary", |
| block_background_fill="*background_fill_primary", |
| block_border_width="0", |
| button_primary_background_fill="*primary_500", |
| button_primary_text_color="white", |
| ) |
| except (AttributeError, ImportError): |
| |
| custom_theme = None |
|
|
| |
| custom_css = """ |
| #footer {display:none !important} |
| .gradio-container {max-width: 100% !important; padding: 0 24px;} |
| .tall-dataframe {min-height: 500px !important} |
| .contain {width: 100% !important; max-width: 100% !important; margin: 0 auto; box-sizing: border-box;} |
| .output-cards {border-radius: 8px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);} |
| .hint-text {font-size: 0.9em; color: #666; margin-top: 4px;} |
| .export-group > .gradio-group { margin-bottom: 0 !important; padding-bottom: 5px !important; } |
| """ |
|
|
| |
| example_data = pd.DataFrame( |
| [ |
| [ |
| "1.1", |
| "SQL Basics", |
| "basic", |
| "What is a SELECT statement used for?", |
| "Retrieving data from one or more database tables.", |
| "The SELECT statement is the most common command in SQL...", |
| "```sql\nSELECT column1, column2 FROM my_table WHERE condition;\n```", |
| ["Understanding of database tables"], |
| ["Retrieve specific data"], |
| "beginner", |
| ], |
| [ |
| "2.1", |
| "Python Fundamentals", |
| "cloze", |
| "The primary keyword to define a function in Python is {{c1::def}}.", |
| "def", |
| "Functions are defined using the `def` keyword...", |
| """```python |
| def greet(name): |
| print(f"Hello, {name}!") |
| ```""", |
| ["Basic programming concepts"], |
| ["Define reusable blocks of code"], |
| "beginner", |
| ], |
| ], |
| columns=[ |
| "Index", |
| "Topic", |
| "Card_Type", |
| "Question", |
| "Answer", |
| "Explanation", |
| "Example", |
| "Prerequisites", |
| "Learning_Outcomes", |
| "Difficulty", |
| ], |
| ) |
| |
|
|
|
|
| |
| def get_recent_logs(logger_name="ankigen") -> str: |
| """Fetches the most recent log entries from the current day's log file.""" |
| try: |
| log_dir = os.path.join(os.path.expanduser("~"), ".ankigen", "logs") |
| timestamp = datetime.now().strftime("%Y%m%d") |
| |
| log_file = os.path.join(log_dir, f"{logger_name}_{timestamp}.log") |
|
|
| if os.path.exists(log_file): |
| with open(log_file) as f: |
| lines = f.readlines() |
| |
| return "\n".join(lines[-100:]) |
| return f"Log file for today ({log_file}) not found or is empty." |
| except Exception as e: |
| |
| |
| logger.error(f"Error reading logs: {e}", exc_info=True) |
| return f"Error reading logs: {e!s}" |
|
|
|
|
| def create_ankigen_interface(theme=None, css=None, js=None): |
| logger.info("Creating AnkiGen Gradio interface...") |
| |
| |
| blocks_kwargs = {"title": "AnkiGen"} |
| if theme is not None: |
| blocks_kwargs["theme"] = theme |
| if css is not None: |
| blocks_kwargs["css"] = css |
| if js is not None: |
| blocks_kwargs["js"] = js |
|
|
| with gr.Blocks(**blocks_kwargs) as ankigen: |
| with gr.Column(elem_classes="contain"): |
| gr.Markdown("# 📚 AnkiGen - Anki Card Generator") |
| gr.Markdown("#### Generate Anki flashcards using AI.") |
|
|
| with gr.Tabs(selected="setup") as main_tabs: |
| with gr.Tab("Setup", id="setup"): |
| with gr.Accordion("Configuration Settings", open=True): |
| with gr.Row(): |
| with gr.Column(scale=1): |
| generation_mode = gr.Radio( |
| choices=[ |
| ("Single Subject", "subject"), |
| ], |
| value="subject", |
| label="Generation Mode", |
| info="Choose how you want to generate content", |
| visible=False, |
| ) |
| with gr.Group() as subject_mode: |
| subject = gr.Textbox( |
| label="Subject", |
| placeholder="e.g., 'Basic SQL Concepts'", |
| ) |
| api_key_input = gr.Textbox( |
| label="OpenAI API Key", |
| type="password", |
| placeholder="Enter your OpenAI API key (sk-...)", |
| value=os.getenv("OPENAI_API_KEY", ""), |
| info="Your key is used solely for processing your requests.", |
| elem_id="api-key-textbox", |
| ) |
|
|
| |
| library_accordion = gr.Accordion( |
| "Library Documentation (optional)", open=True |
| ) |
| with library_accordion: |
| library_name_input = gr.Textbox( |
| label="Library Name", |
| placeholder="e.g., 'react', 'tensorflow', 'pandas'", |
| info="Fetch up-to-date documentation for this library", |
| ) |
| library_topic_input = gr.Textbox( |
| label="Documentation Focus (optional)", |
| placeholder="e.g., 'hooks', 'data loading', 'transforms'", |
| info="Specific topic within the library to focus on", |
| ) |
| with gr.Column(scale=1): |
| with gr.Accordion("Advanced Settings", open=True): |
| model_choices_ui = [ |
| (m["label"], m["value"]) |
| for m in AVAILABLE_MODELS |
| ] |
| default_model_value = next( |
| ( |
| m["value"] |
| for m in AVAILABLE_MODELS |
| if m["value"] == "gpt-5.2-auto" |
| ), |
| AVAILABLE_MODELS[0]["value"], |
| ) |
| model_choice = gr.Dropdown( |
| choices=model_choices_ui, |
| value=default_model_value, |
| label="Model Selection", |
| info="Select AI model for generation", |
| allow_custom_value=True, |
| ) |
| topic_number = gr.Slider( |
| label="Number of Topics", |
| minimum=2, |
| maximum=20, |
| step=1, |
| value=2, |
| ) |
| cards_per_topic = gr.Slider( |
| label="Cards per Topic", |
| minimum=2, |
| maximum=30, |
| step=1, |
| value=3, |
| ) |
| total_cards_preview = gr.Markdown( |
| f"**Total cards:** {2 * 3}" |
| ) |
| preference_prompt = gr.Textbox( |
| label="Learning Preferences", |
| placeholder="e.g., 'Beginner focus'", |
| lines=3, |
| ) |
| generate_cloze_checkbox = gr.Checkbox( |
| label="Generate Cloze Cards", |
| value=True, |
| ) |
|
|
| with gr.Row(): |
| auto_fill_btn = gr.Button( |
| "Auto-fill", |
| variant="secondary", |
| ) |
| generate_button = gr.Button( |
| "Generate Cards", variant="primary" |
| ) |
| status_markdown = gr.Markdown("") |
| log_output = gr.Textbox( |
| label="Live Logs", |
| lines=8, |
| interactive=False, |
| ) |
| generation_active = gr.State(False) |
| log_timer = gr.Timer(2) |
|
|
| with gr.Tab("Results", id="results"): |
| with gr.Group() as cards_output: |
| gr.Markdown("### Generated Cards") |
| with gr.Accordion("Output Format", open=False): |
| gr.Markdown( |
| "Cards: Index, Topic, Type, Q, A, Explanation, Example, Prerequisites, Outcomes, Difficulty. Export: CSV, .apkg", |
| ) |
| with gr.Accordion("Example Card Format", open=False): |
| gr.Code( |
| label="Example Card", |
| value='{"front": ..., "back": ..., "metadata": ...}', |
| language="json", |
| ) |
| output = gr.DataFrame( |
| value=example_data, |
| headers=[ |
| "Index", |
| "Topic", |
| "Card_Type", |
| "Question", |
| "Answer", |
| "Explanation", |
| "Example", |
| "Prerequisites", |
| "Learning_Outcomes", |
| "Difficulty", |
| ], |
| datatype=[ |
| "number", |
| "str", |
| "str", |
| "str", |
| "str", |
| "str", |
| "str", |
| "str", |
| "str", |
| "str", |
| ], |
| interactive=True, |
| elem_classes="tall-dataframe", |
| wrap=True, |
| column_widths=[ |
| 50, |
| 100, |
| 80, |
| 200, |
| 200, |
| 250, |
| 200, |
| 150, |
| 150, |
| 100, |
| ], |
| ) |
| total_cards_html = gr.HTML( |
| value="<div><b>Total Cards Generated:</b> <span id='total-cards-count'>0</span></div>", |
| visible=False, |
| ) |
|
|
| |
| token_usage_html = gr.HTML( |
| value="<div style='margin-top: 8px;'><b>Token Usage:</b> <span id='token-usage-display'>No usage data</span></div>", |
| visible=True, |
| ) |
|
|
| |
| with gr.Row(elem_classes="export-group"): |
| export_csv_button = gr.Button("Export to CSV") |
| export_apkg_button = gr.Button("Export to .apkg") |
| download_file_output = gr.File( |
| label="Download Deck", visible=False |
| ) |
|
|
| |
| generation_mode.change( |
| fn=update_mode_visibility, |
| inputs=[ |
| generation_mode, |
| subject, |
| ], |
| outputs=[ |
| subject_mode, |
| cards_output, |
| subject, |
| output, |
| total_cards_html, |
| ], |
| ) |
|
|
| def update_total_cards_preview(topics_value: int, cards_value: int) -> str: |
| """Update the total cards preview based on current sliders.""" |
| try: |
| topics = int(topics_value) |
| cards = int(cards_value) |
| except (TypeError, ValueError): |
| return "**Total cards:** —" |
| return f"**Total cards:** {topics * cards}" |
|
|
| topic_number.change( |
| fn=update_total_cards_preview, |
| inputs=[topic_number, cards_per_topic], |
| outputs=[total_cards_preview], |
| ) |
| cards_per_topic.change( |
| fn=update_total_cards_preview, |
| inputs=[topic_number, cards_per_topic], |
| outputs=[total_cards_preview], |
| ) |
|
|
| |
| async def handle_generate_click( |
| api_key_input_val, |
| subject_val, |
| generation_mode_val, |
| model_choice_val, |
| topic_number_val, |
| cards_per_topic_val, |
| preference_prompt_val, |
| generate_cloze_checkbox_val, |
| library_name_val, |
| library_topic_val, |
| progress=gr.Progress(track_tqdm=True), |
| ): |
| output_df, total_html, token_html = await orchestrate_card_generation( |
| client_manager, |
| response_cache, |
| api_key_input_val, |
| subject_val, |
| generation_mode_val, |
| "", |
| "", |
| model_choice_val, |
| topic_number_val, |
| cards_per_topic_val, |
| preference_prompt_val, |
| generate_cloze_checkbox_val, |
| library_name=library_name_val if library_name_val else None, |
| library_topic=library_topic_val if library_topic_val else None, |
| ) |
| return output_df, total_html, token_html, gr.Tabs(selected="results") |
|
|
| def refresh_logs(active: bool): |
| if not active: |
| return gr.update() |
| return get_recent_logs() |
|
|
| log_timer.tick( |
| fn=refresh_logs, |
| inputs=[generation_active], |
| outputs=[log_output], |
| ) |
|
|
| def start_generation_ui(): |
| return ( |
| gr.update( |
| value="**Generating cards...** This can take a bit.", |
| visible=True, |
| ), |
| gr.update(interactive=False), |
| True, |
| get_recent_logs(), |
| ) |
|
|
| def finish_generation_ui(): |
| return ( |
| gr.update(value="**Ready.**", visible=True), |
| gr.update(interactive=True), |
| False, |
| ) |
|
|
| generate_button.click( |
| fn=start_generation_ui, |
| inputs=[], |
| outputs=[ |
| status_markdown, |
| generate_button, |
| generation_active, |
| log_output, |
| ], |
| ).then( |
| fn=handle_generate_click, |
| inputs=[ |
| api_key_input, |
| subject, |
| generation_mode, |
| model_choice, |
| topic_number, |
| cards_per_topic, |
| preference_prompt, |
| generate_cloze_checkbox, |
| library_name_input, |
| library_topic_input, |
| ], |
| outputs=[output, total_cards_html, token_usage_html, main_tabs], |
| show_progress="full", |
| ).then( |
| fn=finish_generation_ui, |
| inputs=[], |
| outputs=[status_markdown, generate_button, generation_active], |
| ) |
|
|
| |
| async def handle_export_dataframe_to_csv_click(df: pd.DataFrame): |
| if df is None or df.empty: |
| gr.Warning("No cards generated to export to CSV.") |
| return gr.update(value=None, visible=False) |
|
|
| try: |
| |
| |
| |
| exported_path_relative = await asyncio.to_thread( |
| export_dataframe_to_csv, |
| df, |
| filename_suggestion="ankigen_cards.csv", |
| ) |
|
|
| if exported_path_relative: |
| exported_path_absolute = os.path.abspath(exported_path_relative) |
| gr.Info( |
| f"CSV ready for download: {os.path.basename(exported_path_absolute)}", |
| ) |
| return gr.update(value=exported_path_absolute, visible=True) |
| |
| |
| gr.Warning("CSV export failed or returned no path.") |
| return gr.update(value=None, visible=False) |
| except Exception as e: |
| logger.error( |
| f"Error exporting DataFrame to CSV: {e}", |
| exc_info=True, |
| ) |
| gr.Error(f"Failed to export to CSV: {e!s}") |
| return gr.update(value=None, visible=False) |
|
|
| export_csv_button.click( |
| fn=handle_export_dataframe_to_csv_click, |
| inputs=[output], |
| outputs=[download_file_output], |
| api_name="export_main_to_csv", |
| ) |
|
|
| |
| async def handle_export_dataframe_to_apkg_click( |
| df: pd.DataFrame, |
| subject_for_deck_name: str, |
| ): |
| if df is None or df.empty: |
| gr.Warning("No cards generated to export.") |
| return gr.update(value=None, visible=False) |
|
|
| timestamp_for_name = datetime.now().strftime("%Y%m%d_%H%M%S") |
|
|
| deck_name_inside_anki = ( |
| "AnkiGen Exported Deck" |
| ) |
| if subject_for_deck_name and subject_for_deck_name.strip(): |
| clean_subject = re.sub( |
| r"[^a-zA-Z0-9\s_.-]", |
| "", |
| subject_for_deck_name.strip(), |
| ) |
| deck_name_inside_anki = f"AnkiGen - {clean_subject}" |
| elif not df.empty and "Topic" in df.columns and df["Topic"].iloc[0]: |
| first_topic = df["Topic"].iloc[0] |
| clean_first_topic = re.sub( |
| r"[^a-zA-Z0-9\s_.-]", |
| "", |
| str(first_topic).strip(), |
| ) |
| deck_name_inside_anki = f"AnkiGen - {clean_first_topic}" |
| else: |
| deck_name_inside_anki = f"AnkiGen Deck - {timestamp_for_name}" |
|
|
| |
| |
| base_filename = re.sub(r"[^a-zA-Z0-9_.-]", "_", deck_name_inside_anki) |
| output_filename = f"{base_filename}_{timestamp_for_name}.apkg" |
|
|
| output_dir = "output_decks" |
| os.makedirs(output_dir, exist_ok=True) |
| full_output_path = os.path.join(output_dir, output_filename) |
|
|
| try: |
| |
| |
| |
| |
| exported_path_relative = await asyncio.to_thread( |
| export_dataframe_to_apkg, |
| df, |
| full_output_path, |
| deck_name_inside_anki, |
| ) |
|
|
| |
| exported_path_absolute = os.path.abspath(exported_path_relative) |
|
|
| gr.Info( |
| f"Successfully exported deck '{deck_name_inside_anki}' to {exported_path_absolute}", |
| ) |
| return gr.update(value=exported_path_absolute, visible=True) |
| except Exception as e: |
| logger.error( |
| f"Error exporting DataFrame to APKG: {e}", |
| exc_info=True, |
| ) |
| gr.Error(f"Failed to export to APKG: {e!s}") |
| return gr.update(value=None, visible=False) |
|
|
| |
| export_apkg_button.click( |
| fn=handle_export_dataframe_to_apkg_click, |
| inputs=[output, subject], |
| outputs=[download_file_output], |
| api_name="export_main_to_apkg", |
| ) |
|
|
| |
| async def handle_auto_fill_click( |
| subject_text: str, |
| api_key: str, |
| progress=gr.Progress(track_tqdm=True), |
| ): |
| """Handle auto-fill button click to populate all settings""" |
| if not subject_text or not subject_text.strip(): |
| gr.Warning("Please enter a subject first") |
| return [gr.update()] * 9 |
|
|
| if not api_key: |
| gr.Warning("OpenAI API key is required for auto-configuration") |
| return [gr.update()] * 9 |
|
|
| try: |
| progress(0, desc="Analyzing subject...") |
|
|
| |
| await client_manager.initialize_client(api_key) |
| openai_client = client_manager.get_client() |
|
|
| |
| auto_config_service = AutoConfigService() |
| config = await auto_config_service.auto_configure( |
| subject_text, openai_client |
| ) |
|
|
| if not config: |
| gr.Warning("Could not generate configuration") |
| return [gr.update()] * 9 |
|
|
| topics_value = config.get("topic_number", 3) |
| cards_value = config.get("cards_per_topic", 5) |
| total_cards_text = ( |
| f"**Total cards:** {int(topics_value) * int(cards_value)}" |
| ) |
|
|
| |
| return ( |
| gr.update( |
| value=config.get("library_name", "") |
| ), |
| gr.update( |
| value=config.get("library_topic", "") |
| ), |
| gr.update(value=topics_value), |
| gr.update(value=cards_value), |
| gr.update(value=total_cards_text), |
| gr.update( |
| value=config.get("preference_prompt", "") |
| ), |
| gr.update( |
| value=config.get("generate_cloze_checkbox", False) |
| ), |
| gr.update( |
| value=config.get("model_choice", "gpt-5.2-auto") |
| ), |
| gr.update( |
| open=True |
| ), |
| ) |
|
|
| except Exception as e: |
| logger.error(f"Auto-configuration failed: {e}", exc_info=True) |
| gr.Error(f"Auto-configuration failed: {str(e)}") |
| return [gr.update()] * 9 |
|
|
| auto_fill_btn.click( |
| fn=handle_auto_fill_click, |
| inputs=[subject, api_key_input], |
| outputs=[ |
| library_name_input, |
| library_topic_input, |
| topic_number, |
| cards_per_topic, |
| total_cards_preview, |
| preference_prompt, |
| generate_cloze_checkbox, |
| model_choice, |
| library_accordion, |
| ], |
| ) |
|
|
| logger.info("AnkiGen Gradio interface creation complete.") |
| return ankigen |
|
|
|
|
| |
| if __name__ == "__main__": |
| import os |
| from packaging import version |
|
|
| try: |
| |
| gradio_version = version.parse(gr.__version__) |
| is_gradio_6 = gradio_version >= version.parse("5.0.0") |
|
|
| logger.info( |
| f"Detected Gradio version: {gr.__version__} (v6 API: {is_gradio_6})" |
| ) |
|
|
| if is_gradio_6: |
| |
| ankigen_interface = create_ankigen_interface() |
| launch_kwargs = { |
| "theme": custom_theme, |
| "css": custom_css, |
| "js": js_storage, |
| } |
| else: |
| |
| ankigen_interface = create_ankigen_interface( |
| theme=custom_theme, |
| css=custom_css, |
| js=js_storage, |
| ) |
| launch_kwargs = {} |
|
|
| logger.info("Launching AnkiGen Gradio interface...") |
|
|
| if os.environ.get("SPACE_ID"): |
| ankigen_interface.queue(default_concurrency_limit=2, max_size=10).launch( |
| **launch_kwargs |
| ) |
| else: |
| ankigen_interface.queue(default_concurrency_limit=2, max_size=10).launch( |
| server_name="127.0.0.1", share=False, **launch_kwargs |
| ) |
| except Exception as e: |
| logger.critical(f"Failed to launch Gradio interface: {e}", exc_info=True) |
|
|