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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +59 -0
  3. data/.rubocop.yml +79 -0
  4. data/.ruby-version +1 -0
  5. data/.tool-versions +1 -0
  6. data/CHANGELOG.md +173 -0
  7. data/Gemfile +21 -0
  8. data/Makefile +20 -0
  9. data/README.md +160 -32
  10. data/Rakefile +174 -0
  11. data/anki_generator.gemspec +42 -0
  12. data/bin/anki_generator +3 -2
  13. data/docs/CI_SETUP.md +120 -0
  14. data/docs/architecture/current-v1.3.0.architecture.json +315 -0
  15. data/docs/architecture/current-v1.3.0.html +14990 -0
  16. data/docs/architecture/current-v1.3.0.visual-check.json +548 -0
  17. data/docs/architecture/phase3-proposed.architecture.json +310 -0
  18. data/docs/architecture/phase3-proposed.html +15001 -0
  19. data/docs/architecture/phase3-proposed.visual-check.json +548 -0
  20. data/docs/phase3-draft.md +86 -0
  21. data/examples/example_class.rb +13 -0
  22. data/examples/manual_cards.yaml +5 -0
  23. data/examples/study_prompt.txt +3 -0
  24. data/input/input.yaml.example +3 -0
  25. data/lib/anki_generator/anki_connect_client.rb +85 -0
  26. data/lib/anki_generator/apkg_schema.rb +257 -0
  27. data/lib/anki_generator/apkg_writer.rb +149 -0
  28. data/lib/anki_generator/card.rb +83 -0
  29. data/lib/anki_generator/cli.rb +183 -0
  30. data/lib/anki_generator/client_factory.rb +20 -0
  31. data/lib/anki_generator/commands/create_ai_template.rb +39 -0
  32. data/lib/anki_generator/commands/generate_deck.rb +43 -0
  33. data/lib/anki_generator/commands/generate_yaml.rb +63 -0
  34. data/lib/anki_generator/commands/import.rb +75 -0
  35. data/lib/anki_generator/commands/prompt_based.rb +74 -0
  36. data/lib/anki_generator/commands/prompt_to_deck.rb +84 -0
  37. data/lib/anki_generator/commands/push.rb +32 -0
  38. data/lib/anki_generator/commands/serve.rb +99 -0
  39. data/lib/anki_generator/commands/test_api.rb +33 -0
  40. data/lib/anki_generator/deck_builder.rb +184 -0
  41. data/lib/anki_generator/errors.rb +22 -0
  42. data/lib/anki_generator/file_processor.rb +154 -0
  43. data/lib/anki_generator/importers/csv.rb +52 -0
  44. data/lib/anki_generator/importers/markdown.rb +72 -0
  45. data/lib/anki_generator/prompt_builder.rb +77 -0
  46. data/lib/anki_generator/server.rb +98 -0
  47. data/lib/anki_generator/ui.rb +28 -0
  48. data/lib/anki_generator/version.rb +5 -0
  49. data/lib/anki_generator.rb +18 -114
  50. data/prompt.txt +5 -0
  51. metadata +100 -43
  52. data/lib/anki_cli.rb +0 -259
  53. data/lib/file_processor.rb +0 -156
  54. data/lib/openrouter_client.rb +0 -158
data/lib/anki_cli.rb DELETED
@@ -1,259 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'thor'
4
- require 'dotenv/load'
5
- require_relative 'anki_generator'
6
- require_relative 'file_processor'
7
-
8
- class AnkiCLI < Thor
9
- desc 'generate DECK_NAME YAML_FILE OUTPUT_FILE', 'Generate an Anki .apkg deck from a YAML file'
10
- option :api_key, type: :string, desc: 'OpenRouter API key (or set OPENROUTER_API_KEY env var)'
11
- option :model, type: :string, default: 'openai/gpt-3.5-turbo', desc: 'AI model to use'
12
- option :sync_with, type: :string, desc: 'Existing YAML file to sync with'
13
- def generate(deck_name, yaml_file, output_file)
14
- anki_generator = AnkiGenerator.new(
15
- name: deck_name,
16
- deck_file: yaml_file,
17
- api_key: options[:api_key],
18
- model: options[:model]
19
- )
20
-
21
- anki_generator.sync_with_existing_deck(options[:sync_with]) if options[:sync_with]
22
- anki_generator.generate_apkg(output_path: output_file)
23
-
24
- puts "Anki deck '#{deck_name}' has been successfully created as #{output_file}!"
25
- puts "Total cards: #{anki_generator.cards.length}"
26
- end
27
-
28
- desc 'generate_yaml PROMPT OUTPUT_YAML', 'Generate a YAML file from a prompt using AI'
29
- option :api_key, type: :string, desc: 'OpenRouter API key (or set OPENROUTER_API_KEY env var)'
30
- option :model, type: :string, default: 'openai/gpt-3.5-turbo', desc: 'AI model to use'
31
- option :difficulty, type: :string, default: 'medium', desc: 'Difficulty level (easy, medium, hard)'
32
- option :count, type: :numeric, default: 10, desc: 'Number of flashcards to generate'
33
- option :context, type: :string, desc: 'Additional context for better generation'
34
- option :attach, type: :array, desc: 'Attach files or directories for context'
35
- option :prompt_file, type: :boolean, default: false, desc: 'Treat PROMPT as a file path to read from'
36
- def generate_yaml(prompt, output_yaml)
37
- begin
38
- require_relative 'openrouter_client'
39
- client = OpenRouterClient.new(api_key: options[:api_key], model: options[:model])
40
-
41
- # Handle prompt from file
42
- actual_prompt = if options[:prompt_file]
43
- puts "📄 Reading prompt from file: #{prompt}"
44
- FileProcessor.read_prompt_from_file(prompt)
45
- else
46
- prompt
47
- end
48
-
49
- # Process attachments
50
- attachments = nil
51
- if options[:attach]
52
- puts "📎 Processing attachments..."
53
- attachments = FileProcessor.process_attachments(options[:attach])
54
- end
55
-
56
- puts "Generating flashcards for: #{actual_prompt[0..100]}#{'...' if actual_prompt.length > 100}"
57
- puts "Model: #{client.model}"
58
- puts "Difficulty: #{options[:difficulty]}"
59
- puts "Count: #{options[:count]}"
60
- puts "Attachments: #{attachments&.length || 0} file(s)" if attachments
61
- puts "Generating..."
62
-
63
- # Generate flashcards using the prompt as topics
64
- cards = client.generate_multiple_flashcards(
65
- topics: [actual_prompt],
66
- context: options[:context],
67
- difficulty: options[:difficulty],
68
- count: options[:count],
69
- attachments: attachments
70
- )
71
-
72
- # Create YAML structure
73
- yaml_content = {
74
- 'ai_generation' => {
75
- 'topics' => [actual_prompt],
76
- 'context' => options[:context],
77
- 'difficulty' => options[:difficulty],
78
- 'count' => options[:count],
79
- 'save_generated' => false
80
- },
81
- 'cards' => cards
82
- }
83
-
84
- # Write to file
85
- File.open(output_yaml, 'w') do |file|
86
- file.write(yaml_content.to_yaml)
87
- end
88
-
89
- puts "✅ Generated #{cards.length} flashcards!"
90
- puts "YAML file created: #{output_yaml}"
91
- puts ""
92
- puts "Preview of generated cards:"
93
- cards.first(3).each_with_index do |card, index|
94
- puts "#{index + 1}. #{card['front']}"
95
- puts " → #{card['back'][0..100]}#{'...' if card['back'].length > 100}"
96
- puts ""
97
- end
98
-
99
- puts "To create an Anki deck, run:"
100
- puts " anki_generator generate \"My Deck\" #{output_yaml} my_deck.apkg"
101
-
102
- rescue => e
103
- puts "❌ Error generating flashcards: #{e.message}"
104
- exit 1
105
- end
106
- end
107
-
108
- desc 'prompt_to_deck PROMPT DECK_NAME OUTPUT_FILE', 'Generate flashcards from prompt and create deck in one step'
109
- option :api_key, type: :string, desc: 'OpenRouter API key (or set OPENROUTER_API_KEY env var)'
110
- option :model, type: :string, default: 'openai/gpt-3.5-turbo', desc: 'AI model to use'
111
- option :difficulty, type: :string, default: 'medium', desc: 'Difficulty level (easy, medium, hard)'
112
- option :count, type: :numeric, default: 10, desc: 'Number of flashcards to generate'
113
- option :context, type: :string, desc: 'Additional context for better generation'
114
- option :save_yaml, type: :boolean, default: false, desc: 'Save intermediate YAML file'
115
- option :attach, type: :array, desc: 'Attach files or directories for context'
116
- option :prompt_file, type: :boolean, default: false, desc: 'Treat PROMPT as a file path to read from'
117
- def prompt_to_deck(prompt, deck_name, output_file)
118
- begin
119
- require_relative 'openrouter_client'
120
- client = OpenRouterClient.new(api_key: options[:api_key], model: options[:model])
121
-
122
- # Handle prompt from file
123
- actual_prompt = if options[:prompt_file]
124
- puts "📄 Reading prompt from file: #{prompt}"
125
- FileProcessor.read_prompt_from_file(prompt)
126
- else
127
- prompt
128
- end
129
-
130
- # Process attachments
131
- attachments = nil
132
- if options[:attach]
133
- puts "📎 Processing attachments..."
134
- attachments = FileProcessor.process_attachments(options[:attach])
135
- end
136
-
137
- puts "🚀 Generating Anki deck from prompt: #{actual_prompt[0..100]}#{'...' if actual_prompt.length > 100}"
138
- puts "Model: #{client.model}"
139
- puts "Difficulty: #{options[:difficulty]}"
140
- puts "Count: #{options[:count]}"
141
- puts "Attachments: #{attachments&.length || 0} file(s)" if attachments
142
- puts ""
143
-
144
- # Generate flashcards
145
- puts "Generating flashcards..."
146
- cards = client.generate_multiple_flashcards(
147
- topics: [actual_prompt],
148
- context: options[:context],
149
- difficulty: options[:difficulty],
150
- count: options[:count],
151
- attachments: attachments
152
- )
153
-
154
- puts "✅ Generated #{cards.length} flashcards!"
155
-
156
- # Create temporary YAML file
157
- temp_yaml = "temp_#{Time.now.to_i}.yaml"
158
- yaml_content = {
159
- 'ai_generation' => {
160
- 'topics' => [actual_prompt],
161
- 'context' => options[:context],
162
- 'difficulty' => options[:difficulty],
163
- 'count' => options[:count],
164
- 'save_generated' => false
165
- },
166
- 'cards' => cards
167
- }
168
-
169
- File.open(temp_yaml, 'w') do |file|
170
- file.write(yaml_content.to_yaml)
171
- end
172
-
173
- # Generate Anki deck
174
- puts "Creating Anki deck..."
175
- anki_generator = AnkiGenerator.new(
176
- name: deck_name,
177
- deck_file: temp_yaml,
178
- api_key: options[:api_key],
179
- model: options[:model]
180
- )
181
-
182
- anki_generator.generate_apkg(output_path: output_file)
183
-
184
- # Clean up or save YAML
185
- if options[:save_yaml]
186
- yaml_file = output_file.gsub('.apkg', '.yaml')
187
- File.rename(temp_yaml, yaml_file)
188
- puts "YAML file saved: #{yaml_file}"
189
- else
190
- File.delete(temp_yaml)
191
- end
192
-
193
- puts "🎉 Anki deck '#{deck_name}' created successfully!"
194
- puts "File: #{output_file}"
195
- puts "Total cards: #{cards.length}"
196
- puts ""
197
- puts "Preview of generated cards:"
198
- cards.first(3).each_with_index do |card, index|
199
- puts "#{index + 1}. #{card['front']}"
200
- puts " → #{card['back'][0..100]}#{'...' if card['back'].length > 100}"
201
- puts ""
202
- end
203
-
204
- rescue => e
205
- puts "❌ Error: #{e.message}"
206
- exit 1
207
- end
208
- end
209
-
210
- desc 'create_ai_template TEMPLATE_FILE', 'Create a template YAML file for AI generation'
211
- def create_ai_template(template_file)
212
- template_content = {
213
- 'ai_generation' => {
214
- 'topics' => ['Example Topic 1', 'Example Topic 2'],
215
- 'context' => 'Additional context for generating flashcards',
216
- 'difficulty' => 'medium',
217
- 'count' => 5,
218
- 'save_generated' => true
219
- },
220
- 'cards' => [
221
- {
222
- 'front' => 'Example manual card front',
223
- 'back' => 'Example manual card back'
224
- }
225
- ]
226
- }
227
-
228
- File.open(template_file, 'w') do |file|
229
- file.write(template_content.to_yaml)
230
- end
231
-
232
- puts "AI generation template created: #{template_file}"
233
- puts "Edit the file and run 'anki_generator generate' to create your deck!"
234
- end
235
-
236
- desc 'test_api', 'Test OpenRouter API connection'
237
- option :api_key, type: :string, desc: 'OpenRouter API key (or set OPENROUTER_API_KEY env var)'
238
- option :model, type: :string, default: 'openai/gpt-3.5-turbo', desc: 'AI model to use'
239
- def test_api
240
- begin
241
- require_relative 'openrouter_client'
242
- client = OpenRouterClient.new(api_key: options[:api_key], model: options[:model])
243
-
244
- puts "Testing OpenRouter API connection..."
245
- puts "Model: #{client.model}"
246
-
247
- card = client.generate_flashcard(topic: 'Ruby programming', difficulty: 'easy')
248
-
249
- puts "✅ API connection successful!"
250
- puts "Sample generated card:"
251
- puts "Front: #{card['front']}"
252
- puts "Back: #{card['back']}"
253
-
254
- rescue => e
255
- puts "❌ API test failed: #{e.message}"
256
- exit 1
257
- end
258
- end
259
- end
@@ -1,156 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'pathname'
4
-
5
- # FileProcessor -> Handles file and directory processing for attachments
6
- class FileProcessor
7
- # Supported text file extensions
8
- TEXT_EXTENSIONS = %w[.txt .md .rb .py .js .ts .java .cpp .c .h .hpp .css .html .xml .json .yaml .yml .sql .sh .bat .ps1 .php .go .rs .swift .kt .scala .clj .hs .elm .ex .exs .erl .pl .r .m .tex .org .rst .adoc].freeze
9
-
10
- # Maximum file size in bytes (1MB)
11
- MAX_FILE_SIZE = 1_048_576
12
-
13
- # Maximum total content size (5MB)
14
- MAX_TOTAL_SIZE = 5_242_880
15
-
16
- def self.process_attachments(paths)
17
- return [] if paths.nil? || paths.empty?
18
-
19
- attachments = []
20
- total_size = 0
21
-
22
- paths.each do |path_str|
23
- path = Pathname.new(path_str)
24
-
25
- unless path.exist?
26
- puts "⚠️ Warning: Path does not exist: #{path_str}"
27
- next
28
- end
29
-
30
- if path.directory?
31
- dir_attachments = process_directory(path)
32
- dir_attachments.each do |attachment|
33
- if total_size + attachment[:content].bytesize > MAX_TOTAL_SIZE
34
- puts "⚠️ Warning: Total attachment size limit reached. Skipping remaining files."
35
- break
36
- end
37
- attachments << attachment
38
- total_size += attachment[:content].bytesize
39
- end
40
- elsif path.file?
41
- attachment = process_file(path)
42
- if attachment
43
- if total_size + attachment[:content].bytesize > MAX_TOTAL_SIZE
44
- puts "⚠️ Warning: Total attachment size limit reached. Skipping #{path_str}"
45
- else
46
- attachments << attachment
47
- total_size += attachment[:content].bytesize
48
- end
49
- end
50
- else
51
- puts "⚠️ Warning: Path is neither file nor directory: #{path_str}"
52
- end
53
- end
54
-
55
- puts "📎 Processed #{attachments.length} file(s) (#{format_size(total_size)})" if attachments.any?
56
- attachments
57
- end
58
-
59
- def self.process_directory(dir_path)
60
- attachments = []
61
-
62
- # Find all text files in directory (non-recursive for now)
63
- dir_path.children.each do |child|
64
- next unless child.file?
65
-
66
- attachment = process_file(child)
67
- attachments << attachment if attachment
68
- end
69
-
70
- attachments
71
- end
72
-
73
- def self.process_file(file_path)
74
- # Check file size
75
- if file_path.size > MAX_FILE_SIZE
76
- puts "⚠️ Warning: File too large, skipping: #{file_path} (#{format_size(file_path.size)})"
77
- return nil
78
- end
79
-
80
- # Check if it's a text file
81
- unless text_file?(file_path)
82
- puts "⚠️ Warning: Non-text file, skipping: #{file_path}"
83
- return nil
84
- end
85
-
86
- begin
87
- content = file_path.read(encoding: 'UTF-8')
88
-
89
- # Truncate if too long
90
- if content.bytesize > MAX_FILE_SIZE
91
- content = content.byteslice(0, MAX_FILE_SIZE) + "\n... [truncated]"
92
- end
93
-
94
- {
95
- filename: file_path.basename.to_s,
96
- path: file_path.to_s,
97
- content: content
98
- }
99
- rescue => e
100
- puts "⚠️ Warning: Could not read file #{file_path}: #{e.message}"
101
- nil
102
- end
103
- end
104
-
105
- def self.text_file?(file_path)
106
- # Check extension
107
- ext = file_path.extname.downcase
108
- return true if TEXT_EXTENSIONS.include?(ext)
109
-
110
- # For files without extension, try to detect if it's text
111
- return false if file_path.extname.empty? && file_path.basename.to_s !~ /^[A-Z_]+$/
112
-
113
- # Try to read first few bytes to detect binary files
114
- begin
115
- sample = file_path.read(512, encoding: 'BINARY')
116
- # If it contains null bytes, it's likely binary
117
- !sample.include?("\x00")
118
- rescue
119
- false
120
- end
121
- end
122
-
123
- def self.format_size(bytes)
124
- if bytes < 1024
125
- "#{bytes} B"
126
- elsif bytes < 1024 * 1024
127
- "#{(bytes / 1024.0).round(1)} KB"
128
- else
129
- "#{(bytes / (1024.0 * 1024)).round(1)} MB"
130
- end
131
- end
132
-
133
- def self.read_prompt_from_file(file_path)
134
- path = Pathname.new(file_path)
135
-
136
- unless path.exist?
137
- raise "Prompt file does not exist: #{file_path}"
138
- end
139
-
140
- unless path.file?
141
- raise "Prompt path is not a file: #{file_path}"
142
- end
143
-
144
- begin
145
- content = path.read(encoding: 'UTF-8').strip
146
-
147
- if content.empty?
148
- raise "Prompt file is empty: #{file_path}"
149
- end
150
-
151
- content
152
- rescue => e
153
- raise "Could not read prompt file #{file_path}: #{e.message}"
154
- end
155
- end
156
- end
@@ -1,158 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'faraday'
4
- require 'json'
5
- require 'dotenv/load'
6
-
7
- # OpenRouterClient -> API client for OpenRouter
8
- class OpenRouterClient
9
- BASE_URL = 'https://openrouter.ai/api/v1'
10
-
11
- attr_reader :api_key, :model
12
-
13
- def initialize(api_key: nil, model: 'openai/gpt-3.5-turbo')
14
- @api_key = api_key || ENV['OPENROUTER_API_KEY']
15
- @model = model
16
-
17
- raise ArgumentError, 'API key is required' if @api_key.nil? || @api_key.empty?
18
- end
19
-
20
- def generate_flashcard(topic:, context: nil, difficulty: 'medium', attachments: nil)
21
- prompt = build_flashcard_prompt(topic: topic, context: context, difficulty: difficulty, attachments: attachments)
22
-
23
- response = make_request(prompt)
24
- parse_flashcard_response(response)
25
- end
26
-
27
- def generate_multiple_flashcards(topics:, context: nil, difficulty: 'medium', count: 5, attachments: nil)
28
- prompt = build_multiple_flashcards_prompt(
29
- topics: topics,
30
- context: context,
31
- difficulty: difficulty,
32
- count: count,
33
- attachments: attachments
34
- )
35
-
36
- response = make_request(prompt)
37
- parse_multiple_flashcards_response(response)
38
- end
39
-
40
- private
41
-
42
- def connection
43
- @connection ||= Faraday.new(url: BASE_URL) do |conn|
44
- conn.request :json
45
- conn.response :json
46
- conn.adapter Faraday.default_adapter
47
- conn.headers['Authorization'] = "Bearer #{@api_key}"
48
- conn.headers['Content-Type'] = 'application/json'
49
- end
50
- end
51
-
52
- def make_request(prompt)
53
- response = connection.post('/chat/completions') do |req|
54
- req.body = {
55
- model: @model,
56
- messages: [
57
- {
58
- role: 'user',
59
- content: prompt
60
- }
61
- ],
62
- temperature: 0.7,
63
- max_tokens: 1000
64
- }
65
- end
66
-
67
- handle_response(response)
68
- end
69
-
70
- def handle_response(response)
71
- unless response.success?
72
- raise "OpenRouter API error: #{response.status} - #{response.body}"
73
- end
74
-
75
- response.body.dig('choices', 0, 'message', 'content')
76
- end
77
-
78
- def build_flashcard_prompt(topic:, context:, difficulty:, attachments:)
79
- base_prompt = <<~PROMPT
80
- Create a single flashcard for the topic: "#{topic}"
81
- Difficulty level: #{difficulty}
82
- #{context ? "Additional context: #{context}" : ''}
83
-
84
- #{build_attachments_section(attachments) if attachments}
85
-
86
- Format your response as JSON with exactly this structure:
87
- {
88
- "front": "Question or prompt",
89
- "back": "Answer or explanation"
90
- }
91
-
92
- Make the flashcard educational and appropriate for the #{difficulty} difficulty level.
93
- For the back side, provide a clear, concise explanation.
94
- #{attachments ? "Use the provided file content to create more accurate and detailed flashcards." : ""}
95
- PROMPT
96
-
97
- base_prompt.strip
98
- end
99
-
100
- def build_multiple_flashcards_prompt(topics:, context:, difficulty:, count:, attachments:)
101
- topics_list = topics.is_a?(Array) ? topics.join(', ') : topics.to_s
102
-
103
- base_prompt = <<~PROMPT
104
- Create #{count} flashcards covering these topics: #{topics_list}
105
- Difficulty level: #{difficulty}
106
- #{context ? "Additional context: #{context}" : ''}
107
-
108
- #{build_attachments_section(attachments) if attachments}
109
-
110
- Format your response as JSON with exactly this structure:
111
- [
112
- {
113
- "front": "Question or prompt 1",
114
- "back": "Answer or explanation 1"
115
- },
116
- {
117
- "front": "Question or prompt 2",
118
- "back": "Answer or explanation 2"
119
- }
120
- ]
121
-
122
- Make the flashcards educational, diverse, and appropriate for the #{difficulty} difficulty level.
123
- Ensure each flashcard covers different aspects of the topics.
124
- #{attachments ? "Use the provided file content to create more accurate and detailed flashcards based on the specific information in the files." : ""}
125
- PROMPT
126
-
127
- base_prompt.strip
128
- end
129
-
130
- def parse_flashcard_response(response)
131
- JSON.parse(response)
132
- rescue JSON::ParserError => e
133
- raise "Failed to parse OpenRouter response as JSON: #{e.message}"
134
- end
135
-
136
- def parse_multiple_flashcards_response(response)
137
- parsed = JSON.parse(response)
138
-
139
- # Ensure we return an array
140
- parsed.is_a?(Array) ? parsed : [parsed]
141
- rescue JSON::ParserError => e
142
- raise "Failed to parse OpenRouter response as JSON: #{e.message}"
143
- end
144
-
145
- def build_attachments_section(attachments)
146
- return "" unless attachments && !attachments.empty?
147
-
148
- section = "=== ATTACHED FILE CONTENT ===\n\n"
149
-
150
- attachments.each do |attachment|
151
- section += "--- #{attachment[:filename]} ---\n"
152
- section += "#{attachment[:content]}\n\n"
153
- end
154
-
155
- section += "=== END ATTACHED CONTENT ===\n"
156
- section
157
- end
158
- end