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