| | |
| | 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) |
| |
|