| | """CLI interface for AnkiGen - Generate Anki flashcards from the command line""" |
| |
|
| | import asyncio |
| | import os |
| | import sys |
| | from pathlib import Path |
| | from typing import Optional |
| |
|
| | import click |
| | import pandas as pd |
| | from rich.console import Console |
| | from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn |
| | from rich.table import Table |
| | from rich.panel import Panel |
| |
|
| | from ankigen_core.agents.token_tracker import get_token_tracker |
| | from ankigen_core.auto_config import AutoConfigService |
| | from ankigen_core.card_generator import orchestrate_card_generation |
| | from ankigen_core.exporters import export_dataframe_to_apkg, export_dataframe_to_csv |
| | from ankigen_core.llm_interface import OpenAIClientManager |
| | from ankigen_core.utils import ResponseCache, get_logger |
| |
|
| | console = Console() |
| | logger = get_logger() |
| |
|
| |
|
| | def get_api_key() -> str: |
| | """Get OpenAI API key from env or prompt user""" |
| | api_key = os.getenv("OPENAI_API_KEY") |
| |
|
| | if not api_key: |
| | console.print("[yellow]OpenAI API key not found in environment[/yellow]") |
| | api_key = click.prompt("Enter your OpenAI API key", hide_input=True) |
| |
|
| | return api_key |
| |
|
| |
|
| | async def auto_configure_from_prompt( |
| | prompt: str, |
| | api_key: str, |
| | override_topics: Optional[int] = None, |
| | override_cards: Optional[int] = None, |
| | override_model: Optional[str] = None, |
| | ) -> dict: |
| | """Auto-configure settings from a prompt using AI analysis""" |
| |
|
| | with Progress( |
| | SpinnerColumn(), |
| | TextColumn("[progress.description]{task.description}"), |
| | console=console, |
| | ) as progress: |
| | progress.add_task("Analyzing subject...", total=None) |
| |
|
| | |
| | client_manager = OpenAIClientManager() |
| | await client_manager.initialize_client(api_key) |
| | openai_client = client_manager.get_client() |
| |
|
| | |
| | auto_config_service = AutoConfigService() |
| | config = await auto_config_service.auto_configure( |
| | prompt, openai_client, target_topic_count=override_topics |
| | ) |
| |
|
| | |
| | if override_cards is not None: |
| | config["cards_per_topic"] = override_cards |
| | if override_model is not None: |
| | config["model_choice"] = override_model |
| |
|
| | |
| | table = Table( |
| | title="Auto-Configuration", show_header=True, header_style="bold cyan" |
| | ) |
| | table.add_column("Setting", style="dim") |
| | table.add_column("Value", style="green") |
| |
|
| | table.add_row("Topics", str(config.get("topic_number", "N/A"))) |
| | table.add_row("Cards per Topic", str(config.get("cards_per_topic", "N/A"))) |
| | table.add_row( |
| | "Total Cards", |
| | str(config.get("topic_number", 0) * config.get("cards_per_topic", 0)), |
| | ) |
| | table.add_row("Model", config.get("model_choice", "N/A")) |
| |
|
| | if config.get("library_name"): |
| | table.add_row("Library", config.get("library_name")) |
| | if config.get("library_topic"): |
| | table.add_row("Library Topic", config.get("library_topic")) |
| |
|
| | |
| | if config.get("topics_list"): |
| | topics = config["topics_list"] |
| | |
| | if len(topics) <= 4: |
| | topics_str = ", ".join(topics) |
| | else: |
| | topics_str = ", ".join(topics[:3]) + f", ... (+{len(topics) - 3} more)" |
| | table.add_row("Subtopics", topics_str) |
| |
|
| | if config.get("preference_prompt"): |
| | table.add_row( |
| | "Learning Focus", config.get("preference_prompt", "")[:50] + "..." |
| | ) |
| |
|
| | console.print(table) |
| |
|
| | return config |
| |
|
| |
|
| | async def generate_cards_from_config( |
| | prompt: str, |
| | config: dict, |
| | api_key: str, |
| | ) -> tuple: |
| | """Generate cards using the configuration""" |
| |
|
| | client_manager = OpenAIClientManager() |
| | response_cache = ResponseCache() |
| |
|
| | with Progress( |
| | SpinnerColumn(), |
| | TextColumn("[progress.description]{task.description}"), |
| | BarColumn(), |
| | TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), |
| | console=console, |
| | ) as progress: |
| | task = progress.add_task( |
| | f"Generating {config['topic_number'] * config['cards_per_topic']} cards...", |
| | total=100, |
| | ) |
| |
|
| | |
| | ( |
| | output_df, |
| | total_cards_html, |
| | token_usage_html, |
| | ) = await orchestrate_card_generation( |
| | client_manager=client_manager, |
| | cache=response_cache, |
| | api_key_input=api_key, |
| | subject=prompt, |
| | generation_mode="subject", |
| | source_text="", |
| | url_input="", |
| | model_name=config.get("model_choice", "gpt-5.2-auto"), |
| | topic_number=config.get("topic_number", 3), |
| | cards_per_topic=config.get("cards_per_topic", 5), |
| | preference_prompt=config.get("preference_prompt", ""), |
| | generate_cloze=config.get("generate_cloze_checkbox", False), |
| | library_name=config.get("library_name") |
| | if config.get("library_name") |
| | else None, |
| | library_topic=config.get("library_topic") |
| | if config.get("library_topic") |
| | else None, |
| | topics_list=config.get("topics_list"), |
| | ) |
| |
|
| | progress.update(task, completed=100) |
| |
|
| | return output_df, total_cards_html, token_usage_html |
| |
|
| |
|
| | def export_cards( |
| | df: pd.DataFrame, |
| | output_path: str, |
| | deck_name: str, |
| | export_format: str = "apkg", |
| | ) -> str: |
| | """Export cards to file""" |
| |
|
| | with Progress( |
| | SpinnerColumn(), |
| | TextColumn("[progress.description]{task.description}"), |
| | console=console, |
| | ) as progress: |
| | progress.add_task(f"Exporting to {export_format.upper()}...", total=None) |
| |
|
| | if export_format == "apkg": |
| | |
| | if not output_path.endswith(".apkg"): |
| | output_path = ( |
| | output_path.replace(".csv", ".apkg") |
| | if ".csv" in output_path |
| | else f"{output_path}.apkg" |
| | ) |
| |
|
| | exported_path = export_dataframe_to_apkg(df, output_path, deck_name) |
| | else: |
| | |
| | if not output_path.endswith(".csv"): |
| | output_path = ( |
| | output_path.replace(".apkg", ".csv") |
| | if ".apkg" in output_path |
| | else f"{output_path}.csv" |
| | ) |
| |
|
| | exported_path = export_dataframe_to_csv(df, output_path) |
| |
|
| | return exported_path |
| |
|
| |
|
| | @click.command() |
| | @click.option( |
| | "-p", |
| | "--prompt", |
| | required=True, |
| | help="Subject or topic for flashcard generation (e.g., 'Basic SQL', 'React Hooks')", |
| | ) |
| | @click.option( |
| | "--topics", |
| | type=int, |
| | help="Number of topics (auto-detected if not specified)", |
| | ) |
| | @click.option( |
| | "--cards-per-topic", |
| | type=int, |
| | help="Number of cards per topic (auto-detected if not specified)", |
| | ) |
| | @click.option( |
| | "--model", |
| | type=click.Choice( |
| | ["gpt-5.2-auto", "gpt-5.2-instant", "gpt-5.2-thinking"], |
| | case_sensitive=False, |
| | ), |
| | help="Model to use for generation (auto-selected if not specified)", |
| | ) |
| | @click.option( |
| | "-o", |
| | "--output", |
| | default="deck.apkg", |
| | help="Output file path (default: deck.apkg)", |
| | ) |
| | @click.option( |
| | "--format", |
| | "export_format", |
| | type=click.Choice(["apkg", "csv"], case_sensitive=False), |
| | default="apkg", |
| | help="Export format (default: apkg)", |
| | ) |
| | @click.option( |
| | "--api-key", |
| | envvar="OPENAI_API_KEY", |
| | help="OpenAI API key (or set OPENAI_API_KEY env var)", |
| | ) |
| | @click.option( |
| | "--no-confirm", |
| | is_flag=True, |
| | help="Skip confirmation prompt", |
| | ) |
| | def main( |
| | prompt: str, |
| | topics: Optional[int], |
| | cards_per_topic: Optional[int], |
| | model: Optional[str], |
| | output: str, |
| | export_format: str, |
| | api_key: Optional[str], |
| | no_confirm: bool, |
| | ): |
| | """ |
| | AnkiGen CLI - Generate Anki flashcards from the command line |
| | |
| | Examples: |
| | |
| | # Quick generation with auto-config |
| | ankigen -p "Basic SQL" |
| | |
| | # With custom settings |
| | ankigen -p "React Hooks" --topics 5 --cards-per-topic 8 --output hooks.apkg |
| | |
| | # Export to CSV |
| | ankigen -p "Docker basics" --format csv -o docker.csv |
| | """ |
| |
|
| | |
| | console.print( |
| | Panel.fit( |
| | "[bold cyan]AnkiGen CLI[/bold cyan]\n[dim]Generate Anki flashcards with AI[/dim]", |
| | border_style="cyan", |
| | ) |
| | ) |
| | console.print() |
| |
|
| | |
| | if not api_key: |
| | api_key = get_api_key() |
| |
|
| | |
| | async def workflow(): |
| | try: |
| | |
| | console.print(f"[bold]Subject:[/bold] {prompt}\n") |
| | config = await auto_configure_from_prompt( |
| | prompt=prompt, |
| | api_key=api_key, |
| | override_topics=topics, |
| | override_cards=cards_per_topic, |
| | override_model=model, |
| | ) |
| |
|
| | |
| | if not no_confirm: |
| | console.print() |
| | if not click.confirm("Proceed with card generation?", default=True): |
| | console.print("[yellow]Cancelled[/yellow]") |
| | return |
| |
|
| | console.print() |
| |
|
| | |
| | df, total_html, token_html = await generate_cards_from_config( |
| | prompt=prompt, |
| | config=config, |
| | api_key=api_key, |
| | ) |
| |
|
| | if df.empty: |
| | console.print("[red]✗[/red] No cards generated") |
| | sys.exit(1) |
| |
|
| | |
| | console.print() |
| | deck_name = f"AnkiGen - {prompt}" |
| | exported_path = export_cards( |
| | df=df, |
| | output_path=output, |
| | deck_name=deck_name, |
| | export_format=export_format, |
| | ) |
| |
|
| | |
| | console.print() |
| | file_size = Path(exported_path).stat().st_size / 1024 |
| |
|
| | summary = Table.grid(padding=(0, 2)) |
| | summary.add_row("[green]✓[/green] Success!", "") |
| | summary.add_row("Cards Generated:", f"[bold]{len(df)}[/bold]") |
| | summary.add_row("Output File:", f"[bold]{exported_path}[/bold]") |
| | summary.add_row("File Size:", f"{file_size:.1f} KB") |
| |
|
| | |
| | tracker = get_token_tracker() |
| | session = tracker.get_session_summary() |
| | if session["total_tokens"] > 0: |
| | |
| | total_input = sum(u.prompt_tokens for u in tracker.usage_history) |
| | total_output = sum(u.completion_tokens for u in tracker.usage_history) |
| | summary.add_row( |
| | "Tokens:", |
| | f"{total_input:,} in / {total_output:,} out ({session['total_tokens']:,} total)", |
| | ) |
| |
|
| | console.print( |
| | Panel(summary, border_style="green", title="Generation Complete") |
| | ) |
| |
|
| | except KeyboardInterrupt: |
| | console.print("\n[yellow]Cancelled by user[/yellow]") |
| | sys.exit(130) |
| | except Exception as e: |
| | logger.error(f"CLI error: {e}", exc_info=True) |
| | console.print(f"[red]✗ Error:[/red] {str(e)}") |
| | sys.exit(1) |
| |
|
| | |
| | asyncio.run(workflow()) |
| |
|
| |
|
| | if __name__ == "__main__": |
| | main() |
| |
|