anki_generator 1.1.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.gitignore +59 -0
- data/.rubocop.yml +79 -0
- data/.ruby-version +1 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +173 -0
- data/Gemfile +21 -0
- data/Makefile +20 -0
- data/README.md +160 -32
- data/Rakefile +174 -0
- data/anki_generator.gemspec +42 -0
- data/bin/anki_generator +3 -2
- data/docs/CI_SETUP.md +120 -0
- data/docs/architecture/current-v1.3.0.architecture.json +315 -0
- data/docs/architecture/current-v1.3.0.html +14990 -0
- data/docs/architecture/current-v1.3.0.visual-check.json +548 -0
- data/docs/architecture/phase3-proposed.architecture.json +310 -0
- data/docs/architecture/phase3-proposed.html +15001 -0
- data/docs/architecture/phase3-proposed.visual-check.json +548 -0
- data/docs/phase3-draft.md +86 -0
- data/examples/example_class.rb +13 -0
- data/examples/manual_cards.yaml +5 -0
- data/examples/study_prompt.txt +3 -0
- data/input/input.yaml.example +3 -0
- data/lib/anki_generator/anki_connect_client.rb +85 -0
- data/lib/anki_generator/apkg_schema.rb +257 -0
- data/lib/anki_generator/apkg_writer.rb +149 -0
- data/lib/anki_generator/card.rb +83 -0
- data/lib/anki_generator/cli.rb +183 -0
- data/lib/anki_generator/client_factory.rb +20 -0
- data/lib/anki_generator/commands/create_ai_template.rb +39 -0
- data/lib/anki_generator/commands/generate_deck.rb +43 -0
- data/lib/anki_generator/commands/generate_yaml.rb +63 -0
- data/lib/anki_generator/commands/import.rb +75 -0
- data/lib/anki_generator/commands/prompt_based.rb +74 -0
- data/lib/anki_generator/commands/prompt_to_deck.rb +84 -0
- data/lib/anki_generator/commands/push.rb +32 -0
- data/lib/anki_generator/commands/serve.rb +99 -0
- data/lib/anki_generator/commands/test_api.rb +33 -0
- data/lib/anki_generator/deck_builder.rb +184 -0
- data/lib/anki_generator/errors.rb +22 -0
- data/lib/anki_generator/file_processor.rb +154 -0
- data/lib/anki_generator/importers/csv.rb +52 -0
- data/lib/anki_generator/importers/markdown.rb +72 -0
- data/lib/anki_generator/prompt_builder.rb +77 -0
- data/lib/anki_generator/server.rb +98 -0
- data/lib/anki_generator/ui.rb +28 -0
- data/lib/anki_generator/version.rb +5 -0
- data/lib/anki_generator.rb +18 -114
- data/prompt.txt +5 -0
- metadata +100 -43
- data/lib/anki_cli.rb +0 -259
- data/lib/file_processor.rb +0 -156
- data/lib/openrouter_client.rb +0 -158
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'thor'
|
|
4
|
+
require 'dotenv/load'
|
|
5
|
+
require_relative 'version'
|
|
6
|
+
require_relative 'errors'
|
|
7
|
+
require_relative 'ui'
|
|
8
|
+
require_relative 'client_factory'
|
|
9
|
+
require_relative 'commands/create_ai_template'
|
|
10
|
+
require_relative 'commands/generate_deck'
|
|
11
|
+
require_relative 'commands/generate_yaml'
|
|
12
|
+
require_relative 'commands/import'
|
|
13
|
+
require_relative 'commands/prompt_to_deck'
|
|
14
|
+
require_relative 'commands/push'
|
|
15
|
+
require_relative 'commands/serve'
|
|
16
|
+
require_relative 'commands/test_api'
|
|
17
|
+
|
|
18
|
+
module AnkiGenerator
|
|
19
|
+
# Command-line interface. This class is intentionally thin: it parses
|
|
20
|
+
# arguments with Thor and delegates to command objects in
|
|
21
|
+
# AnkiGenerator::Commands, which hold the actual behaviour.
|
|
22
|
+
class CLI < Thor
|
|
23
|
+
DEFAULT_MODEL = LlmClient::DEFAULT_MODEL
|
|
24
|
+
|
|
25
|
+
def self.exit_on_failure?
|
|
26
|
+
true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class_option :provider,
|
|
30
|
+
type: :string, default: nil,
|
|
31
|
+
desc: 'LLM provider (e.g. gemini, openai, anthropic, openrouter, ollama); ' \
|
|
32
|
+
'auto-resolved from the model when omitted'
|
|
33
|
+
|
|
34
|
+
desc 'generate DECK_NAME YAML_FILE OUTPUT_FILE', 'Generate an Anki .apkg deck from a YAML file'
|
|
35
|
+
option :api_key, type: :string, desc: 'Provider API key (defaults to the provider env var, e.g. GEMINI_API_KEY)'
|
|
36
|
+
option :model, type: :string, desc: 'AI model to use (provider default if omitted)'
|
|
37
|
+
option :sync_with, type: :string, desc: 'Existing YAML file to sync with'
|
|
38
|
+
option :reverse, type: :boolean, default: false, desc: 'Add reversed copy of each basic card'
|
|
39
|
+
option :jobs, type: :numeric, default: 1, desc: 'Parallel API calls for multi-topic generation'
|
|
40
|
+
def generate(deck_name, yaml_file, output_file)
|
|
41
|
+
run_command Commands::GenerateDeck,
|
|
42
|
+
deck_name:,
|
|
43
|
+
yaml_file:,
|
|
44
|
+
output_file:,
|
|
45
|
+
model: option_model,
|
|
46
|
+
sync_with: options[:sync_with],
|
|
47
|
+
reverse: options[:reverse],
|
|
48
|
+
jobs: options[:jobs],
|
|
49
|
+
client: build_client(required: false)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
desc 'generate_yaml PROMPT OUTPUT_YAML', 'Generate a YAML file from a prompt using AI'
|
|
53
|
+
option :api_key, type: :string, desc: 'Provider API key (defaults to the provider env var, e.g. GEMINI_API_KEY)'
|
|
54
|
+
option :model, type: :string, desc: 'AI model to use (provider default if omitted)'
|
|
55
|
+
option :difficulty, type: :string, default: 'medium', desc: 'Difficulty level (easy, medium, hard)'
|
|
56
|
+
option :count, type: :numeric, default: 10, desc: 'Number of flashcards to generate'
|
|
57
|
+
option :context, type: :string, desc: 'Additional context for better generation'
|
|
58
|
+
option :attach, type: :array, desc: 'Attach files or directories for context'
|
|
59
|
+
option :prompt_file, type: :boolean, default: false, desc: 'Treat PROMPT as a file path to read from'
|
|
60
|
+
def generate_yaml(prompt, output_yaml)
|
|
61
|
+
run_command Commands::GenerateYaml,
|
|
62
|
+
prompt:,
|
|
63
|
+
output_yaml:,
|
|
64
|
+
model: option_model,
|
|
65
|
+
difficulty: options[:difficulty],
|
|
66
|
+
count: options[:count],
|
|
67
|
+
context: options[:context],
|
|
68
|
+
attach: options[:attach],
|
|
69
|
+
prompt_file: options[:prompt_file],
|
|
70
|
+
client: build_client
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
desc 'prompt_to_deck PROMPT DECK_NAME OUTPUT_FILE', 'Generate flashcards from prompt and create deck in one step'
|
|
74
|
+
option :api_key, type: :string, desc: 'Provider API key (defaults to the provider env var, e.g. GEMINI_API_KEY)'
|
|
75
|
+
option :model, type: :string, desc: 'AI model to use (provider default if omitted)'
|
|
76
|
+
option :difficulty, type: :string, default: 'medium', desc: 'Difficulty level (easy, medium, hard)'
|
|
77
|
+
option :count, type: :numeric, default: 10, desc: 'Number of flashcards to generate'
|
|
78
|
+
option :context, type: :string, desc: 'Additional context for better generation'
|
|
79
|
+
option :save_yaml, type: :boolean, default: false, desc: 'Save intermediate YAML file'
|
|
80
|
+
option :attach, type: :array, desc: 'Attach files or directories for context'
|
|
81
|
+
option :prompt_file, type: :boolean, default: false, desc: 'Treat PROMPT as a file path to read from'
|
|
82
|
+
def prompt_to_deck(prompt, deck_name, output_file)
|
|
83
|
+
run_command Commands::PromptToDeck,
|
|
84
|
+
prompt:,
|
|
85
|
+
deck_name:,
|
|
86
|
+
output_file:,
|
|
87
|
+
model: option_model,
|
|
88
|
+
difficulty: options[:difficulty],
|
|
89
|
+
count: options[:count],
|
|
90
|
+
context: options[:context],
|
|
91
|
+
save_yaml: options[:save_yaml],
|
|
92
|
+
attach: options[:attach],
|
|
93
|
+
prompt_file: options[:prompt_file],
|
|
94
|
+
client: build_client
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
desc 'import DECK_NAME INPUT_FILE OUTPUT_FILE', 'Build a deck from Markdown or CSV study notes'
|
|
98
|
+
option :reverse, type: :boolean, default: false, desc: 'Add reversed copy of each basic card'
|
|
99
|
+
long_desc 'Supported inputs: Markdown (.md, Q:/A: pairs or "- **Q** — A" bullets, headings become tags) ' \
|
|
100
|
+
'and CSV (front,back[,tags]).'
|
|
101
|
+
def import(deck_name, input_file, output_file)
|
|
102
|
+
run_command Commands::Import,
|
|
103
|
+
deck_name:,
|
|
104
|
+
input_file:,
|
|
105
|
+
output_file:,
|
|
106
|
+
reverse: options[:reverse]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
desc 'push DECK_NAME YAML_FILE', 'Push a YAML card file into a running Anki via AnkiConnect'
|
|
110
|
+
option :url, type: :string, default: AnkiConnectClient::DEFAULT_URL, desc: 'AnkiConnect URL'
|
|
111
|
+
long_desc 'Requires Anki running with the AnkiConnect add-on. Cards are added to the Basic note type; ' \
|
|
112
|
+
'duplicates are skipped.'
|
|
113
|
+
def push(deck_name, yaml_file)
|
|
114
|
+
run_command Commands::Push,
|
|
115
|
+
deck_name:,
|
|
116
|
+
yaml_file:,
|
|
117
|
+
url: options[:url]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
desc 'serve', 'Start a local web UI to preview, edit, and export decks'
|
|
121
|
+
option :port, type: :numeric, default: Commands::Serve::DEFAULT_PORT, desc: 'Port to listen on'
|
|
122
|
+
long_desc 'Opens a browser UI at http://localhost:<port>: paste Markdown notes or generate cards with AI, ' \
|
|
123
|
+
'edit the result, and download an .apkg.'
|
|
124
|
+
def serve
|
|
125
|
+
run_command Commands::Serve,
|
|
126
|
+
port: options[:port],
|
|
127
|
+
provider: options[:provider]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
desc 'create_ai_template TEMPLATE_FILE', 'Create a template YAML file for AI generation'
|
|
131
|
+
def create_ai_template(template_file)
|
|
132
|
+
run_command Commands::CreateAiTemplate, template_file:
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
desc 'test_api', 'Test the LLM API connection'
|
|
136
|
+
option :api_key, type: :string, desc: 'Provider API key (defaults to the provider env var, e.g. GEMINI_API_KEY)'
|
|
137
|
+
option :model, type: :string, desc: 'AI model to use (provider default if omitted)'
|
|
138
|
+
def test_api
|
|
139
|
+
run_command Commands::TestApi, model: option_model, client: build_client
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
desc 'version', 'Show the gem version'
|
|
143
|
+
def version
|
|
144
|
+
say "anki_generator #{AnkiGenerator::VERSION}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
# All AnkiGenerator errors are reported as a clean message + exit 1;
|
|
150
|
+
# unexpected errors still raise with a full backtrace.
|
|
151
|
+
def run_command(command_class, **args)
|
|
152
|
+
command_class.new(**args, ui: UI.new).run
|
|
153
|
+
rescue AnkiGenerator::Error => e
|
|
154
|
+
UI.new.error("Error: #{e.message}")
|
|
155
|
+
exit 1
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def option_model
|
|
159
|
+
options[:model] || DEFAULT_MODEL
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The API key comes from --api_key; otherwise the standard provider env
|
|
163
|
+
# vars (GEMINI_API_KEY, OPENAI_API_KEY, ...) are picked up by LlmClient
|
|
164
|
+
# itself. With required: false and no key anywhere, yields nil (commands
|
|
165
|
+
# then skip AI generation instead of failing).
|
|
166
|
+
def build_client(required: true)
|
|
167
|
+
api_key = options[:api_key]
|
|
168
|
+
if api_key && !api_key.empty?
|
|
169
|
+
return ClientFactory.build(provider: options[:provider], model: option_model, api_key:)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
return ClientFactory.build(provider: options[:provider], model: option_model) if !required || llm_configured?
|
|
173
|
+
|
|
174
|
+
raise ConfigurationError,
|
|
175
|
+
'An LLM API key is required (use --api_key or set an env var such as GOOGLE_API_KEY)'
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def llm_configured?
|
|
179
|
+
(LlmClient::ENV_KEY_VARS + ['GOOGLE_API_KEY']).any? { |name| !(ENV.fetch(name, nil) || '').empty? } ||
|
|
180
|
+
!(ENV.fetch('OLLAMA_URL', nil) || '').empty?
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'llm_client'
|
|
4
|
+
|
|
5
|
+
module AnkiGenerator
|
|
6
|
+
# Builds the unified ruby_llm-backed client. Any provider ruby_llm
|
|
7
|
+
# supports (gemini, openai, anthropic, openrouter, ollama, ...) is valid;
|
|
8
|
+
# pass provider: nil to auto-resolve from the model name.
|
|
9
|
+
module ClientFactory
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def build(model: nil, provider: nil, api_key: nil)
|
|
13
|
+
LlmClient.new(
|
|
14
|
+
model: model || LlmClient::DEFAULT_MODEL,
|
|
15
|
+
provider:,
|
|
16
|
+
api_key:
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../ui'
|
|
4
|
+
|
|
5
|
+
module AnkiGenerator
|
|
6
|
+
module Commands
|
|
7
|
+
# `anki_generator create_ai_template` — write a starter YAML file users can
|
|
8
|
+
# edit and feed back into `generate`.
|
|
9
|
+
class CreateAiTemplate
|
|
10
|
+
TEMPLATE = {
|
|
11
|
+
'ai_generation' => {
|
|
12
|
+
'topics' => ['Example Topic 1', 'Example Topic 2'],
|
|
13
|
+
'context' => 'Additional context for generating flashcards',
|
|
14
|
+
'difficulty' => 'medium',
|
|
15
|
+
'count' => 5,
|
|
16
|
+
'save_generated' => true
|
|
17
|
+
},
|
|
18
|
+
'cards' => [
|
|
19
|
+
{
|
|
20
|
+
'front' => 'Example manual card front',
|
|
21
|
+
'back' => 'Example manual card back'
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
def initialize(template_file:, ui: UI.new)
|
|
27
|
+
@template_file = template_file
|
|
28
|
+
@ui = ui
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def run
|
|
32
|
+
File.write(@template_file, TEMPLATE.to_yaml)
|
|
33
|
+
|
|
34
|
+
@ui.info("AI generation template created: #{@template_file}")
|
|
35
|
+
@ui.info("Edit the file and run 'anki_generator generate' to create your deck!")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../deck_builder'
|
|
4
|
+
require_relative '../client_factory'
|
|
5
|
+
require_relative '../ui'
|
|
6
|
+
|
|
7
|
+
module AnkiGenerator
|
|
8
|
+
module Commands
|
|
9
|
+
# `anki_generator generate` — build an .apkg deck from a YAML card file.
|
|
10
|
+
class GenerateDeck
|
|
11
|
+
def initialize(deck_name:, yaml_file:, output_file:, model: LlmClient::DEFAULT_MODEL,
|
|
12
|
+
sync_with: nil, reverse: false, jobs: 1, client: nil, ui: UI.new)
|
|
13
|
+
@deck_name = deck_name
|
|
14
|
+
@yaml_file = yaml_file
|
|
15
|
+
@output_file = output_file
|
|
16
|
+
@sync_with = sync_with
|
|
17
|
+
@reverse = reverse
|
|
18
|
+
@jobs = jobs
|
|
19
|
+
@client = client || build_client(model)
|
|
20
|
+
@ui = ui
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run
|
|
24
|
+
builder = DeckBuilder.new(name: @deck_name, deck_file: @yaml_file, client: @client, jobs: @jobs, ui: @ui)
|
|
25
|
+
builder.sync_with(@sync_with) if @sync_with
|
|
26
|
+
builder.add_reverse_cards! if @reverse
|
|
27
|
+
builder.generate_apkg(output_path: @output_file)
|
|
28
|
+
|
|
29
|
+
@ui.success("Anki deck '#{@deck_name}' has been successfully created as #{@output_file}!")
|
|
30
|
+
@ui.info("Total cards: #{builder.cards.length}")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def build_client(model)
|
|
36
|
+
# DeckBuilder only calls the client when the YAML has an
|
|
37
|
+
# ai_generation section, so building unconditionally is safe — plain
|
|
38
|
+
# card files never touch the network.
|
|
39
|
+
ClientFactory.build(model:)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'prompt_based'
|
|
4
|
+
require_relative '../client_factory'
|
|
5
|
+
require_relative '../ui'
|
|
6
|
+
|
|
7
|
+
module AnkiGenerator
|
|
8
|
+
module Commands
|
|
9
|
+
# `anki_generator generate_yaml` — generate a YAML card file from a prompt.
|
|
10
|
+
class GenerateYaml
|
|
11
|
+
include PromptBased
|
|
12
|
+
|
|
13
|
+
def initialize(prompt:, output_yaml:, model: LlmClient::DEFAULT_MODEL, difficulty: 'medium',
|
|
14
|
+
count: 10, context: nil, attach: nil, prompt_file: false,
|
|
15
|
+
client: nil, ui: UI.new)
|
|
16
|
+
@prompt = prompt
|
|
17
|
+
@output_yaml = output_yaml
|
|
18
|
+
@difficulty = difficulty
|
|
19
|
+
@count = count
|
|
20
|
+
@context = context
|
|
21
|
+
@attach = attach
|
|
22
|
+
@prompt_file = prompt_file
|
|
23
|
+
@client = client || ClientFactory.build(model:)
|
|
24
|
+
@ui = ui
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run
|
|
28
|
+
actual_prompt = resolve_prompt(@prompt, @prompt_file)
|
|
29
|
+
attachments = resolve_attachments(@attach)
|
|
30
|
+
|
|
31
|
+
announce(prompt: actual_prompt, difficulty: @difficulty, count: @count, attachments:)
|
|
32
|
+
cards = generate_cards(
|
|
33
|
+
prompt: actual_prompt, difficulty: @difficulty, count: @count,
|
|
34
|
+
context: @context, attachments:
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
File.write(@output_yaml, yaml_document(actual_prompt, cards).to_yaml)
|
|
38
|
+
|
|
39
|
+
@ui.success("Generated #{cards.length} flashcards!")
|
|
40
|
+
@ui.info("YAML file created: #{@output_yaml}")
|
|
41
|
+
preview_cards(cards)
|
|
42
|
+
|
|
43
|
+
@ui.info('To create an Anki deck, run:')
|
|
44
|
+
@ui.info(" anki_generator generate \"My Deck\" #{@output_yaml} my_deck.apkg")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def yaml_document(prompt, cards)
|
|
50
|
+
{
|
|
51
|
+
'ai_generation' => {
|
|
52
|
+
'topics' => [prompt],
|
|
53
|
+
'context' => @context,
|
|
54
|
+
'difficulty' => @difficulty,
|
|
55
|
+
'count' => @count,
|
|
56
|
+
'save_generated' => false
|
|
57
|
+
},
|
|
58
|
+
'cards' => cards
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tempfile'
|
|
4
|
+
require_relative '../deck_builder'
|
|
5
|
+
require_relative '../errors'
|
|
6
|
+
require_relative '../importers/csv'
|
|
7
|
+
require_relative '../importers/markdown'
|
|
8
|
+
require_relative '../ui'
|
|
9
|
+
|
|
10
|
+
module AnkiGenerator
|
|
11
|
+
module Commands
|
|
12
|
+
# `anki_generator import` — build a deck straight from study notes
|
|
13
|
+
# (Markdown or CSV) without a YAML intermediate.
|
|
14
|
+
class Import
|
|
15
|
+
EXTENSION_IMPORTERS = {
|
|
16
|
+
'.md' => Importers::Markdown,
|
|
17
|
+
'.markdown' => Importers::Markdown,
|
|
18
|
+
'.csv' => Importers::Csv
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
def self.importer_for(path)
|
|
22
|
+
EXTENSION_IMPORTERS.fetch(File.extname(path).downcase, nil)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(deck_name:, input_file:, output_file:, reverse: false, ui: UI.new)
|
|
26
|
+
@deck_name = deck_name
|
|
27
|
+
@input_file = input_file
|
|
28
|
+
@output_file = output_file
|
|
29
|
+
@reverse = reverse
|
|
30
|
+
@ui = ui
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def run
|
|
34
|
+
cards = import_cards
|
|
35
|
+
|
|
36
|
+
with_temp_deck(cards) do |deck_file|
|
|
37
|
+
builder = DeckBuilder.new(name: @deck_name, deck_file:, ui: @ui)
|
|
38
|
+
builder.add_reverse_cards! if @reverse
|
|
39
|
+
builder.generate_apkg(output_path: @output_file)
|
|
40
|
+
@ui.success("Anki deck '#{@deck_name}' has been successfully created as #{@output_file}!")
|
|
41
|
+
@ui.info("Total cards: #{builder.cards.length}")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def import_cards
|
|
48
|
+
importer = self.class.importer_for(@input_file)
|
|
49
|
+
unless importer
|
|
50
|
+
supported = EXTENSION_IMPORTERS.keys.join(', ')
|
|
51
|
+
raise FileProcessingError, "Unsupported input file #{@input_file} (supported: #{supported})"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
@ui.info("Importing #{@input_file}...")
|
|
55
|
+
cards = importer.parse(File.read(@input_file, encoding: 'UTF-8'))
|
|
56
|
+
raise FileProcessingError, "No cards found in #{@input_file}" if cards.empty?
|
|
57
|
+
|
|
58
|
+
@ui.info("Parsed #{cards.length} card(s)")
|
|
59
|
+
cards
|
|
60
|
+
rescue SystemCallError => e
|
|
61
|
+
raise FileProcessingError, "Cannot read #{@input_file}: #{e.message}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# DeckBuilder loads from a YAML path, so stage the imported cards in a
|
|
65
|
+
# temp file rather than duplicating export logic.
|
|
66
|
+
def with_temp_deck(cards)
|
|
67
|
+
Tempfile.create(['anki_generator_import', '.yaml']) do |file|
|
|
68
|
+
file.write({ 'cards' => cards }.to_yaml)
|
|
69
|
+
file.flush
|
|
70
|
+
yield file.path
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../file_processor'
|
|
4
|
+
require_relative '../ui'
|
|
5
|
+
|
|
6
|
+
module AnkiGenerator
|
|
7
|
+
module Commands
|
|
8
|
+
# Shared behaviour for commands that turn a prompt (+ optional attachments)
|
|
9
|
+
# into flashcards via the configured LLM provider.
|
|
10
|
+
module PromptBased
|
|
11
|
+
attr_reader :client, :ui
|
|
12
|
+
|
|
13
|
+
def resolve_prompt(prompt, prompt_file)
|
|
14
|
+
return prompt unless prompt_file
|
|
15
|
+
|
|
16
|
+
ui.info("📄 Reading prompt from file: #{prompt}")
|
|
17
|
+
FileProcessor.read_prompt_from_file(prompt)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def resolve_attachments(attach)
|
|
21
|
+
return nil unless attach
|
|
22
|
+
|
|
23
|
+
ui.info('📎 Processing attachments...')
|
|
24
|
+
FileProcessor.process_attachments(attach, ui:)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def announce(prompt:, difficulty:, count:, attachments:)
|
|
28
|
+
preview = truncate(prompt)
|
|
29
|
+
ui.info("Generating flashcards for: #{preview}")
|
|
30
|
+
announce_parameters(difficulty:, count:, attachments:)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Deck-flavored announcement used by prompt_to_deck.
|
|
34
|
+
def announce_deck(prompt:, difficulty:, count:, attachments:)
|
|
35
|
+
ui.info("🚀 Generating Anki deck from prompt: #{truncate(prompt)}")
|
|
36
|
+
announce_parameters(difficulty:, count:, attachments:)
|
|
37
|
+
ui.info('')
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def generate_cards(prompt:, difficulty:, count:, context:, attachments:)
|
|
41
|
+
ui.info('Generating...')
|
|
42
|
+
client.generate_multiple_flashcards(
|
|
43
|
+
topics: [prompt],
|
|
44
|
+
context:,
|
|
45
|
+
difficulty:,
|
|
46
|
+
count:,
|
|
47
|
+
attachments:
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def truncate(text)
|
|
52
|
+
text.length > 100 ? "#{text[0..100]}..." : text
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def announce_parameters(difficulty:, count:, attachments:)
|
|
56
|
+
ui.info("Model: #{client.model}")
|
|
57
|
+
ui.info("Difficulty: #{difficulty}")
|
|
58
|
+
ui.info("Count: #{count}")
|
|
59
|
+
ui.info("Attachments: #{attachments&.length || 0} file(s)") if attachments
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def preview_cards(cards, limit: 3)
|
|
63
|
+
ui.info('')
|
|
64
|
+
ui.info('Preview of generated cards:')
|
|
65
|
+
cards.first(limit).each_with_index do |card, index|
|
|
66
|
+
back = card['back'].length > 100 ? "#{card['back'][0..100]}..." : card['back']
|
|
67
|
+
ui.info("#{index + 1}. #{card['front']}")
|
|
68
|
+
ui.info(" → #{back}")
|
|
69
|
+
ui.info('')
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tempfile'
|
|
4
|
+
require_relative 'prompt_based'
|
|
5
|
+
require_relative '../deck_builder'
|
|
6
|
+
require_relative '../client_factory'
|
|
7
|
+
require_relative '../ui'
|
|
8
|
+
|
|
9
|
+
module AnkiGenerator
|
|
10
|
+
module Commands
|
|
11
|
+
# `anki_generator prompt_to_deck` — generate cards from a prompt and build
|
|
12
|
+
# the .apkg deck in one step, via a temporary YAML file that is always
|
|
13
|
+
# cleaned up (or persisted when --save-yaml is passed).
|
|
14
|
+
class PromptToDeck
|
|
15
|
+
include PromptBased
|
|
16
|
+
|
|
17
|
+
def initialize(prompt:, deck_name:, output_file:, model: LlmClient::DEFAULT_MODEL,
|
|
18
|
+
difficulty: 'medium', count: 10, context: nil, save_yaml: false,
|
|
19
|
+
attach: nil, prompt_file: false, client: nil, ui: UI.new)
|
|
20
|
+
@prompt = prompt
|
|
21
|
+
@deck_name = deck_name
|
|
22
|
+
@output_file = output_file
|
|
23
|
+
@difficulty = difficulty
|
|
24
|
+
@count = count
|
|
25
|
+
@context = context
|
|
26
|
+
@save_yaml = save_yaml
|
|
27
|
+
@attach = attach
|
|
28
|
+
@prompt_file = prompt_file
|
|
29
|
+
@client = client || ClientFactory.build(model:)
|
|
30
|
+
@ui = ui
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def run
|
|
34
|
+
actual_prompt = resolve_prompt(@prompt, @prompt_file)
|
|
35
|
+
attachments = resolve_attachments(@attach)
|
|
36
|
+
|
|
37
|
+
announce_deck(prompt: actual_prompt, difficulty: @difficulty, count: @count, attachments:)
|
|
38
|
+
|
|
39
|
+
cards = generate_cards(
|
|
40
|
+
prompt: actual_prompt, difficulty: @difficulty, count: @count,
|
|
41
|
+
context: @context, attachments:
|
|
42
|
+
)
|
|
43
|
+
ui.success("Generated #{cards.length} flashcards!")
|
|
44
|
+
|
|
45
|
+
export_deck(cards)
|
|
46
|
+
|
|
47
|
+
ui.success("Anki deck '#{@deck_name}' created successfully!")
|
|
48
|
+
ui.info("File: #{@output_file}")
|
|
49
|
+
ui.info("Total cards: #{cards.length}")
|
|
50
|
+
preview_cards(cards)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def export_deck(cards)
|
|
56
|
+
deck_document = build_deck_document(cards)
|
|
57
|
+
|
|
58
|
+
Tempfile.create(['anki_generator', '.yaml']) do |file|
|
|
59
|
+
file.write(deck_document.to_yaml)
|
|
60
|
+
file.flush
|
|
61
|
+
ui.info('Creating Anki deck...')
|
|
62
|
+
DeckBuilder.new(name: @deck_name, deck_file: file.path, client:, ui:)
|
|
63
|
+
.generate_apkg(output_path: @output_file)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
persist_yaml(deck_document) if @save_yaml
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def persist_yaml(deck_document)
|
|
70
|
+
yaml_path = @output_file.sub(/\.apkg\z/, '.yaml')
|
|
71
|
+
File.write(yaml_path, deck_document.to_yaml)
|
|
72
|
+
ui.info("YAML file saved: #{yaml_path}")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def build_deck_document(cards)
|
|
76
|
+
# The cards are already generated at this point, so the intermediate
|
|
77
|
+
# document is plain card data. Including the ai_generation section here
|
|
78
|
+
# would make DeckBuilder send the prompt back through the API a second
|
|
79
|
+
# time instead of using the cards we already have.
|
|
80
|
+
{ 'cards' => cards }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../anki_connect_client'
|
|
4
|
+
require_relative '../deck_builder'
|
|
5
|
+
require_relative '../ui'
|
|
6
|
+
|
|
7
|
+
module AnkiGenerator
|
|
8
|
+
module Commands
|
|
9
|
+
# `anki_generator push` — send a YAML card file straight into a running
|
|
10
|
+
# Anki via the AnkiConnect add-on, skipping the .apkg import step.
|
|
11
|
+
class Push
|
|
12
|
+
def initialize(deck_name:, yaml_file:, url: AnkiConnectClient::DEFAULT_URL,
|
|
13
|
+
anki_connect: nil, ui: UI.new)
|
|
14
|
+
@deck_name = deck_name
|
|
15
|
+
@yaml_file = yaml_file
|
|
16
|
+
@anki_connect = anki_connect || AnkiConnectClient.new(base_url: url)
|
|
17
|
+
@ui = ui
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run
|
|
21
|
+
builder = DeckBuilder.new(name: @deck_name, deck_file: @yaml_file, ui: @ui)
|
|
22
|
+
raise FileProcessingError, "No cards found in #{@yaml_file}" if builder.cards.empty?
|
|
23
|
+
|
|
24
|
+
@ui.info("Pushing #{builder.cards.length} card(s) to deck '#{@deck_name}' via AnkiConnect...")
|
|
25
|
+
result = @anki_connect.push_deck(deck_name: @deck_name, cards: builder.cards)
|
|
26
|
+
|
|
27
|
+
@ui.success("Pushed #{result[:added]} card(s) to Anki!")
|
|
28
|
+
@ui.info("Skipped #{result[:duplicate]} duplicate(s)") if result[:duplicate].positive?
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|