# Standard library imports import asyncio import os import re from datetime import datetime import gradio as gr import pandas as pd from ankigen_core.card_generator import ( AVAILABLE_MODELS, orchestrate_card_generation, ) # GENERATION_MODES is internal to card_generator from ankigen_core.exporters import ( export_dataframe_to_apkg, export_dataframe_to_csv, ) # Anki models (BASIC_MODEL, CLOZE_MODEL) are internal to exporters from ankigen_core.llm_interface import ( OpenAIClientManager, ) # structured_output_completion is internal to core modules from ankigen_core.ui_logic import update_mode_visibility from ankigen_core.utils import ( ResponseCache, get_logger, ) # fetch_webpage_text is used by card_generator from ankigen_core.auto_config import AutoConfigService # --- Initialization --- logger = get_logger() response_cache = ResponseCache() # Initialize cache client_manager = OpenAIClientManager() # Initialize client manager # Agent system is required 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( # type: ignore 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): # Fallback for older gradio versions or when themes are not available custom_theme = None # CSS for the interface (moved to module level for Gradio 6 compatibility) 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 for Initialization --- 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", ], ) # ------------------------------------- # --- Helper function for log viewing (Subtask 15.5) --- 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") # Use the logger_name parameter to construct the log file name 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() # Display last N lines, e.g., 100 return "\n".join(lines[-100:]) # Ensured this is standard newline return f"Log file for today ({log_file}) not found or is empty." except Exception as e: # Use the main app logger to log this error, but don't let it crash the UI # function 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...") # Theme/css/js passed in for Gradio 4.x compatibility (goes in Blocks()) # For Gradio 6.x, these are passed to launch() instead 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, # Hidden since only one mode exists ) 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", ) # Context7 Library Documentation 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="
Total Cards Generated: 0
", visible=False, ) # Token usage display token_usage_html = gr.HTML( value="
Token Usage: No usage data
", visible=True, ) # Export buttons 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 ) # --- Event Handlers --- (Updated to use functions from ankigen_core) 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], ) # Define an async wrapper for the orchestrate_card_generation 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, "", # source_text - deprecated "", # url_input - deprecated 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], ) # Define handler for CSV export (similar to APKG) 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: # export_dataframe_to_csv from exporters.py returns a relative path # or a filename if no path was part of its input. # It already handles None input for filename_suggestion. 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) # This case might happen if export_dataframe_to_csv itself had an internal issue # and returned None, though it typically raises an error or returns path. 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, # Use the new handler inputs=[output], outputs=[download_file_output], api_name="export_main_to_csv", ) # Define handler for APKG export from DataFrame (Item 5) 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" # Default name inside Anki ) 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}" # Fallback with timestamp # Construct the output filename and path # Use the deck_name_inside_anki for the base of the filename for consistency 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" # As defined in export_dataframe_to_apkg os.makedirs(output_dir, exist_ok=True) # Ensure directory exists full_output_path = os.path.join(output_dir, output_filename) try: # Call export_dataframe_to_apkg with correct arguments: # 1. df (DataFrame) # 2. output_path (full path for the .apkg file) # 3. deck_name (name of the deck inside Anki) exported_path_relative = await asyncio.to_thread( export_dataframe_to_apkg, df, full_output_path, # Pass the constructed full output path deck_name_inside_anki, # This is the name for the deck inside the .apkg file ) # export_dataframe_to_apkg returns the actual path it used, which should match full_output_path 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) # Wire button to handler (Item 6) export_apkg_button.click( fn=handle_export_dataframe_to_apkg_click, inputs=[output, subject], # Added subject as input outputs=[download_file_output], api_name="export_main_to_apkg", ) # Auto-fill handler 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 # Return no updates for all outputs if not api_key: gr.Warning("OpenAI API key is required for auto-configuration") return [gr.update()] * 9 try: progress(0, desc="Analyzing subject...") # Initialize OpenAI client await client_manager.initialize_client(api_key) openai_client = client_manager.get_client() # Get auto-configuration 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 updates for all relevant UI components return ( gr.update( value=config.get("library_name", "") ), # library_name_input gr.update( value=config.get("library_topic", "") ), # library_topic_input gr.update(value=topics_value), # topic_number gr.update(value=cards_value), # cards_per_topic gr.update(value=total_cards_text), # total_cards_preview gr.update( value=config.get("preference_prompt", "") ), # preference_prompt gr.update( value=config.get("generate_cloze_checkbox", False) ), # generate_cloze_checkbox gr.update( value=config.get("model_choice", "gpt-5.2-auto") ), # model_choice gr.update( open=True ), # Open the Library Documentation accordion ) 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 # --- Main Execution --- (Runs if script is executed directly) if __name__ == "__main__": import os from packaging import version try: # Detect Gradio version for API compatibility 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: # Gradio 6.x: theme/css/js go in launch() ankigen_interface = create_ankigen_interface() launch_kwargs = { "theme": custom_theme, "css": custom_css, "js": js_storage, } else: # Gradio 4.x: theme/css/js go in Blocks() 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"): # On HuggingFace Spaces ankigen_interface.queue(default_concurrency_limit=2, max_size=10).launch( **launch_kwargs ) else: # Local development 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)