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
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
|
data/lib/file_processor.rb
DELETED
|
@@ -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
|
data/lib/openrouter_client.rb
DELETED
|
@@ -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
|