anki_generator 0.1.0 → 1.1.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/README.md +428 -0
- data/bin/anki_generator +1 -11
- data/lib/anki_cli.rb +259 -0
- data/lib/anki_generator.rb +88 -3
- data/lib/file_processor.rb +156 -0
- data/lib/openrouter_client.rb +158 -0
- metadata +96 -14
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c8124d9ca11c2259790dfabfb10f1fab725cfa4889300d6bf91353cce45b9604
|
|
4
|
+
data.tar.gz: 6be84b8c3d8d430c91ad7c02f0fb063cc3e5ea27c35ee946d2c3ea091aedb994
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1bae0bd4aad23ab158031ab876385a10660559b5a9acca2be6a4ba932a2851e7c468e5b18d5db33f57724198fe8fda3e6478dfec05718f1a15703fd3a188bf1b
|
|
7
|
+
data.tar.gz: 25c74048b6dcd2428aab8f879cfebd41b81d7264bd3a5f8e8805530563b7099b9ee72be220e97fe1af070aa44460a084d20fa8010ea658e686a031415c1c2f90
|
data/README.md
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
# Anki Generator
|
|
2
|
+
|
|
3
|
+
A Ruby tool to generate Anki .apkg files from YAML definitions with AI-powered content generation using OpenRouter and file attachment support.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **AI-Powered Generation**: Create flashcards using OpenRouter API with multiple AI models (GPT, Claude, Llama, etc.)
|
|
8
|
+
- **File Attachment Support**: Attach code files, documentation, or entire directories for context-aware generation
|
|
9
|
+
- **Prompt File Support**: Use text files as prompts for better organization and reusability
|
|
10
|
+
- **Multiple Input Methods**: Generate from YAML files, direct prompts, or file attachments
|
|
11
|
+
- **Intelligent Content Processing**: Automatic text file detection, size limits, and binary file filtering
|
|
12
|
+
- **Direct Prompt-to-Deck Generation**: Create decks in one command without intermediate files
|
|
13
|
+
- **Sync Functionality**: Merge new AI-generated cards with existing decks
|
|
14
|
+
- **Flexible Configuration**: Multiple difficulty levels, context settings, and model selection
|
|
15
|
+
- **Comprehensive CLI**: Full command-line interface with extensive options
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
1. Clone the repository
|
|
20
|
+
2. Install dependencies:
|
|
21
|
+
```bash
|
|
22
|
+
bundle install
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
3. Set up your OpenRouter API key:
|
|
26
|
+
```bash
|
|
27
|
+
cp .env.example .env
|
|
28
|
+
# Edit .env and add your OpenRouter API key
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
### Quick Start - Generate from Prompt
|
|
34
|
+
|
|
35
|
+
The fastest way to create flashcards is directly from a prompt:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# Generate flashcards and create deck in one step
|
|
39
|
+
./bin/anki_generator prompt_to_deck "Ruby programming basics" "Ruby Deck" ruby_deck.apkg --api_key YOUR_API_KEY
|
|
40
|
+
|
|
41
|
+
# Generate from a prompt file with code attachments
|
|
42
|
+
./bin/anki_generator prompt_to_deck study_prompt.txt "Code Study" code_deck.apkg --prompt-file --attach ./src --api_key YOUR_API_KEY
|
|
43
|
+
|
|
44
|
+
# Generate just the YAML file first
|
|
45
|
+
./bin/anki_generator generate_yaml "JavaScript fundamentals" js_cards.yaml --api_key YOUR_API_KEY --count 15
|
|
46
|
+
|
|
47
|
+
# Then create the deck
|
|
48
|
+
./bin/anki_generator generate "JS Deck" js_cards.yaml js_deck.apkg
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Traditional Usage
|
|
52
|
+
|
|
53
|
+
Generate a deck from a YAML file:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
./bin/anki_generator generate "My Deck" input/input.yaml my_deck.apkg
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### AI-Powered Generation
|
|
60
|
+
|
|
61
|
+
Create an AI generation template:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
./bin/anki_generator create_ai_template my_ai_deck.yaml
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Generate a deck with AI content:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
./bin/anki_generator generate "AI Deck" my_ai_deck.yaml ai_deck.apkg --api_key YOUR_API_KEY
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Advanced Options
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
# Use a specific AI model
|
|
77
|
+
./bin/anki_generator prompt_to_deck "Python basics" "Python Deck" python.apkg --model anthropic/claude-3-sonnet
|
|
78
|
+
|
|
79
|
+
# Set difficulty and count
|
|
80
|
+
./bin/anki_generator generate_yaml "Advanced algorithms" algo.yaml --difficulty hard --count 20
|
|
81
|
+
|
|
82
|
+
# Add context for better generation
|
|
83
|
+
./bin/anki_generator prompt_to_deck "Machine Learning" "ML Deck" ml.apkg --context "For computer science students" --difficulty medium
|
|
84
|
+
|
|
85
|
+
# Use prompt from file
|
|
86
|
+
./bin/anki_generator generate_yaml prompt.txt output.yaml --prompt-file
|
|
87
|
+
|
|
88
|
+
# Attach files for context
|
|
89
|
+
./bin/anki_generator prompt_to_deck "Explain this code" "Code Deck" code.apkg --attach src/main.rb --attach config.yml
|
|
90
|
+
|
|
91
|
+
# Attach entire directory
|
|
92
|
+
./bin/anki_generator generate_yaml "Create cards about this project" project.yaml --attach ./src --attach ./docs
|
|
93
|
+
|
|
94
|
+
# Combine file prompt with attachments
|
|
95
|
+
./bin/anki_generator prompt_to_deck prompt.txt "My Deck" deck.apkg --prompt-file --attach ./examples
|
|
96
|
+
|
|
97
|
+
# Save intermediate YAML file
|
|
98
|
+
./bin/anki_generator prompt_to_deck "React hooks" "React Deck" react.apkg --save-yaml
|
|
99
|
+
|
|
100
|
+
# Sync with existing deck
|
|
101
|
+
./bin/anki_generator generate "My Deck" input.yaml output.apkg --sync_with existing_deck.yaml
|
|
102
|
+
|
|
103
|
+
# Test API connection
|
|
104
|
+
./bin/anki_generator test_api --api_key YOUR_API_KEY
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## CLI Commands
|
|
108
|
+
|
|
109
|
+
### `prompt_to_deck` - One-Step Generation
|
|
110
|
+
Generate flashcards from a prompt and create the deck immediately:
|
|
111
|
+
```bash
|
|
112
|
+
anki_generator prompt_to_deck PROMPT DECK_NAME OUTPUT_FILE [options]
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**Options:**
|
|
116
|
+
- `--attach FILE_OR_DIR [FILE_OR_DIR...]` - Attach files or directories for context
|
|
117
|
+
- `--prompt-file` - Treat PROMPT as a file path to read from
|
|
118
|
+
- `--save-yaml` - Save intermediate YAML file
|
|
119
|
+
- `--api-key API_KEY` - OpenRouter API key
|
|
120
|
+
- `--model MODEL` - AI model to use
|
|
121
|
+
- `--difficulty LEVEL` - Difficulty level (easy, medium, hard)
|
|
122
|
+
- `--count N` - Number of flashcards to generate
|
|
123
|
+
- `--context TEXT` - Additional context for better generation
|
|
124
|
+
|
|
125
|
+
### `generate_yaml` - Generate YAML from Prompt
|
|
126
|
+
Create a YAML file from a prompt for later use:
|
|
127
|
+
```bash
|
|
128
|
+
anki_generator generate_yaml PROMPT OUTPUT_YAML [options]
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**Options:**
|
|
132
|
+
- `--attach FILE_OR_DIR [FILE_OR_DIR...]` - Attach files or directories for context
|
|
133
|
+
- `--prompt-file` - Treat PROMPT as a file path to read from
|
|
134
|
+
- `--api-key API_KEY` - OpenRouter API key
|
|
135
|
+
- `--model MODEL` - AI model to use
|
|
136
|
+
- `--difficulty LEVEL` - Difficulty level (easy, medium, hard)
|
|
137
|
+
- `--count N` - Number of flashcards to generate
|
|
138
|
+
- `--context TEXT` - Additional context for better generation
|
|
139
|
+
|
|
140
|
+
### `generate` - Create Deck from YAML
|
|
141
|
+
Generate an Anki deck from an existing YAML file:
|
|
142
|
+
```bash
|
|
143
|
+
anki_generator generate DECK_NAME YAML_FILE OUTPUT_FILE [options]
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### `create_ai_template` - Create Template
|
|
147
|
+
Create a template YAML file for AI generation:
|
|
148
|
+
```bash
|
|
149
|
+
anki_generator create_ai_template TEMPLATE_FILE
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### `test_api` - Test Connection
|
|
153
|
+
Test your OpenRouter API connection:
|
|
154
|
+
```bash
|
|
155
|
+
anki_generator test_api [options]
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## File Attachments
|
|
159
|
+
|
|
160
|
+
The Anki Generator supports attaching files and directories to provide context for AI generation. This is particularly useful for:
|
|
161
|
+
|
|
162
|
+
- Creating flashcards about specific code files
|
|
163
|
+
- Generating questions based on documentation
|
|
164
|
+
- Learning from configuration files or data structures
|
|
165
|
+
- Creating study materials from existing project files
|
|
166
|
+
|
|
167
|
+
### Supported File Types
|
|
168
|
+
|
|
169
|
+
The tool automatically processes text-based files including:
|
|
170
|
+
- Code files: `.rb`, `.py`, `.js`, `.ts`, `.java`, `.cpp`, `.c`, `.go`, `.rs`, etc.
|
|
171
|
+
- Documentation: `.md`, `.txt`, `.rst`, `.adoc`, `.org`
|
|
172
|
+
- Configuration: `.yaml`, `.yml`, `.json`, `.xml`, `.toml`
|
|
173
|
+
- Web files: `.html`, `.css`, `.scss`
|
|
174
|
+
- Scripts: `.sh`, `.bat`, `.ps1`
|
|
175
|
+
|
|
176
|
+
### File Size Limits
|
|
177
|
+
|
|
178
|
+
- Maximum individual file size: 1MB
|
|
179
|
+
- Maximum total attachment size: 5MB
|
|
180
|
+
- Files exceeding limits are automatically skipped with warnings
|
|
181
|
+
|
|
182
|
+
### Usage Examples
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
# Attach a single file
|
|
186
|
+
./bin/anki_generator prompt_to_deck "Explain this Ruby class" "Ruby Deck" ruby.apkg --attach person.rb
|
|
187
|
+
|
|
188
|
+
# Attach multiple files
|
|
189
|
+
./bin/anki_generator generate_yaml "Create cards about these configs" config.yaml --attach database.yml --attach routes.rb --attach Gemfile
|
|
190
|
+
|
|
191
|
+
# Attach entire directories (processes all text files)
|
|
192
|
+
./bin/anki_generator prompt_to_deck "Study this codebase" "Project Deck" project.apkg --attach ./src --attach ./lib
|
|
193
|
+
|
|
194
|
+
# Combine with prompt files
|
|
195
|
+
./bin/anki_generator generate_yaml study_prompt.txt output.yaml --prompt-file --attach ./examples --attach README.md
|
|
196
|
+
|
|
197
|
+
# Use context and attachments together
|
|
198
|
+
./bin/anki_generator prompt_to_deck "Advanced Ruby patterns" "Advanced Ruby" advanced.apkg \
|
|
199
|
+
--attach ./lib --context "Focus on metaprogramming and DSL patterns" --difficulty hard
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### How Attachments Work
|
|
203
|
+
|
|
204
|
+
1. **File Processing**: The tool scans attached files and directories for text-based content
|
|
205
|
+
2. **Content Inclusion**: File contents are included in the AI prompt with clear file boundaries
|
|
206
|
+
3. **Smart Generation**: The AI uses the file content to create more accurate and specific flashcards
|
|
207
|
+
4. **Automatic Filtering**: Binary files and oversized files are automatically skipped
|
|
208
|
+
|
|
209
|
+
### Best Practices
|
|
210
|
+
|
|
211
|
+
- **Be Specific**: Attach only relevant files to avoid overwhelming the AI with too much context
|
|
212
|
+
- **Use Descriptive Prompts**: Combine attachments with clear prompts about what you want to learn
|
|
213
|
+
- **Organize Files**: Group related files in directories for easier attachment
|
|
214
|
+
- **Check File Sizes**: Large files will be skipped, so break them down if needed
|
|
215
|
+
|
|
216
|
+
## YAML Formats
|
|
217
|
+
|
|
218
|
+
### Traditional Format
|
|
219
|
+
|
|
220
|
+
```yaml
|
|
221
|
+
- front: "What is Big O notation?"
|
|
222
|
+
back: "Big O notation describes the limiting behavior of a function..."
|
|
223
|
+
|
|
224
|
+
- front: "Define a graph"
|
|
225
|
+
back: "A graph is a collection of vertices connected by edges"
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### AI Generation Format
|
|
229
|
+
|
|
230
|
+
```yaml
|
|
231
|
+
ai_generation:
|
|
232
|
+
topics:
|
|
233
|
+
- "Ruby programming fundamentals"
|
|
234
|
+
- "Object-oriented programming"
|
|
235
|
+
context: "Beginner-friendly programming concepts"
|
|
236
|
+
difficulty: "medium" # easy, medium, hard
|
|
237
|
+
count: 10
|
|
238
|
+
save_generated: true
|
|
239
|
+
|
|
240
|
+
# Optional manual cards
|
|
241
|
+
cards:
|
|
242
|
+
- front: "Manual question"
|
|
243
|
+
back: "Manual answer"
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Configuration
|
|
247
|
+
|
|
248
|
+
### Environment Variables
|
|
249
|
+
|
|
250
|
+
- `OPENROUTER_API_KEY`: Your OpenRouter API key
|
|
251
|
+
- `OPENROUTER_DEFAULT_MODEL`: Default model to use (optional)
|
|
252
|
+
|
|
253
|
+
### Supported Models
|
|
254
|
+
|
|
255
|
+
The tool supports all models available through OpenRouter:
|
|
256
|
+
|
|
257
|
+
- OpenAI: `openai/gpt-4`, `openai/gpt-3.5-turbo`
|
|
258
|
+
- Anthropic: `anthropic/claude-3-sonnet`, `anthropic/claude-3-haiku`
|
|
259
|
+
- Meta: `meta-llama/llama-3.1-8b-instruct`
|
|
260
|
+
- And many more...
|
|
261
|
+
|
|
262
|
+
## Examples
|
|
263
|
+
|
|
264
|
+
### Quick Examples
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
# Generate 10 Python flashcards
|
|
268
|
+
./bin/anki_generator prompt_to_deck "Python data structures" "Python Deck" python.apkg
|
|
269
|
+
|
|
270
|
+
# Generate 20 hard-level algorithm cards
|
|
271
|
+
./bin/anki_generator generate_yaml "Sorting algorithms" algo.yaml --difficulty hard --count 20
|
|
272
|
+
|
|
273
|
+
# Create deck with context
|
|
274
|
+
./bin/anki_generator prompt_to_deck "React components" "React Deck" react.apkg --context "For beginners learning React"
|
|
275
|
+
|
|
276
|
+
# Study a specific code file
|
|
277
|
+
./bin/anki_generator prompt_to_deck "Create flashcards about this Ruby class" "Ruby Class Deck" ruby_class.apkg --attach person.rb
|
|
278
|
+
|
|
279
|
+
# Learn from project documentation
|
|
280
|
+
./bin/anki_generator generate_yaml "Study this API documentation" api_study.yaml --attach README.md --attach docs/api.md
|
|
281
|
+
|
|
282
|
+
# Comprehensive project study
|
|
283
|
+
./bin/anki_generator prompt_to_deck "Help me understand this codebase structure and patterns" "Codebase Study" study.apkg \
|
|
284
|
+
--attach ./src --attach ./lib --attach README.md --context "Focus on architecture and design patterns"
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### File Attachment Examples
|
|
288
|
+
|
|
289
|
+
```bash
|
|
290
|
+
# Study a single Ruby file
|
|
291
|
+
./bin/anki_generator prompt_to_deck "What does this code do?" "Code Analysis" analysis.apkg --attach calculator.rb
|
|
292
|
+
|
|
293
|
+
# Learn from configuration files
|
|
294
|
+
./bin/anki_generator generate_yaml "Explain these configurations" config_study.yaml --attach config/database.yml --attach config/routes.rb
|
|
295
|
+
|
|
296
|
+
# Study an entire project
|
|
297
|
+
./bin/anki_generator prompt_to_deck "Create comprehensive study cards for this project" "Project Study" project.apkg \
|
|
298
|
+
--attach ./app --attach ./lib --attach Gemfile --attach README.md
|
|
299
|
+
|
|
300
|
+
# Use prompt file with attachments
|
|
301
|
+
echo "Create flashcards focusing on the class structure and methods in the attached files" > study_prompt.txt
|
|
302
|
+
./bin/anki_generator generate_yaml study_prompt.txt class_study.yaml --prompt-file --attach ./models
|
|
303
|
+
|
|
304
|
+
# Advanced usage with multiple options
|
|
305
|
+
./bin/anki_generator prompt_to_deck prompt_file.txt "Advanced Study" advanced.apkg \
|
|
306
|
+
--prompt-file --attach ./src --attach ./docs --difficulty hard --count 25 \
|
|
307
|
+
--context "Focus on advanced patterns and best practices" --save-yaml
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### Example YAML Files
|
|
311
|
+
|
|
312
|
+
See the `input/` directory for example YAML files, or create examples with `rake examples`:
|
|
313
|
+
|
|
314
|
+
- `examples/study_prompt.txt` - Example prompt file
|
|
315
|
+
- `examples/example_class.rb` - Example Ruby code for attachments
|
|
316
|
+
- `examples/manual_cards.yaml` - Traditional format example
|
|
317
|
+
- `input/ai_generation_example.yaml` - AI generation example
|
|
318
|
+
- `input/algorithms_ai.yaml` - Computer science topics
|
|
319
|
+
- `input/input.yaml.example` - Traditional format
|
|
320
|
+
|
|
321
|
+
## Development
|
|
322
|
+
|
|
323
|
+
## Development
|
|
324
|
+
|
|
325
|
+
### Quick Start for Developers
|
|
326
|
+
|
|
327
|
+
```bash
|
|
328
|
+
# Clone and setup
|
|
329
|
+
git clone <repository-url>
|
|
330
|
+
cd anki_generator
|
|
331
|
+
rake setup # Install dependencies and create examples
|
|
332
|
+
|
|
333
|
+
# Run tests
|
|
334
|
+
rake test # Run all tests
|
|
335
|
+
rake test_file[cli] # Run specific test
|
|
336
|
+
|
|
337
|
+
# Try the examples
|
|
338
|
+
rake demo_api # Test API connection
|
|
339
|
+
rake examples # Create example files
|
|
340
|
+
rake demo_attachments # Demo with file attachments
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
### Running Tests
|
|
344
|
+
|
|
345
|
+
```bash
|
|
346
|
+
# Run all tests
|
|
347
|
+
rake test
|
|
348
|
+
|
|
349
|
+
# Run specific test file
|
|
350
|
+
rake test_file[cli] # runs tests/test_cli.rb
|
|
351
|
+
rake test_file[file_processor] # runs tests/test_file_processor.rb
|
|
352
|
+
|
|
353
|
+
# Run tests with coverage
|
|
354
|
+
rake test_coverage
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
### Development Commands
|
|
358
|
+
|
|
359
|
+
```bash
|
|
360
|
+
# Setup development environment
|
|
361
|
+
rake setup # Install deps and create examples
|
|
362
|
+
|
|
363
|
+
# Build and install
|
|
364
|
+
rake build # Build gem
|
|
365
|
+
rake install_local # Build and install gem locally
|
|
366
|
+
rake clean # Clean build artifacts
|
|
367
|
+
|
|
368
|
+
# Code quality
|
|
369
|
+
rake lint # Run RuboCop linter
|
|
370
|
+
rake lint_fix # Auto-fix linting issues
|
|
371
|
+
|
|
372
|
+
# Demos and examples
|
|
373
|
+
rake examples # Create example files
|
|
374
|
+
rake demo_prompt # Demo prompt generation
|
|
375
|
+
rake demo_attachments # Demo file attachment features
|
|
376
|
+
rake demo_api # Test API connection
|
|
377
|
+
|
|
378
|
+
# Utilities
|
|
379
|
+
rake help # Show CLI help
|
|
380
|
+
rake version # Show version info
|
|
381
|
+
rake release_prep # Prepare for release
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
### Project Structure
|
|
385
|
+
|
|
386
|
+
```
|
|
387
|
+
├── lib/
|
|
388
|
+
│ ├── anki_generator.rb # Main generator class
|
|
389
|
+
│ ├── openrouter_client.rb # OpenRouter API client
|
|
390
|
+
│ ├── anki_cli.rb # CLI interface
|
|
391
|
+
│ └── file_processor.rb # File attachment processing
|
|
392
|
+
├── bin/
|
|
393
|
+
│ └── anki_generator # CLI executable
|
|
394
|
+
├── tests/ # Test files
|
|
395
|
+
├── input/ # Example YAML files
|
|
396
|
+
└── README.md
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
## API Integration
|
|
400
|
+
|
|
401
|
+
The tool integrates with OpenRouter to provide access to multiple AI models. You can:
|
|
402
|
+
|
|
403
|
+
1. Generate flashcards on any topic
|
|
404
|
+
2. Specify difficulty levels
|
|
405
|
+
3. Provide context for better generation
|
|
406
|
+
4. Choose from various AI models
|
|
407
|
+
5. Control the number of cards generated
|
|
408
|
+
|
|
409
|
+
## Sync Functionality
|
|
410
|
+
|
|
411
|
+
The sync feature allows you to:
|
|
412
|
+
|
|
413
|
+
- Merge new AI-generated cards with existing manual cards
|
|
414
|
+
- Prevent duplicate cards (based on front text)
|
|
415
|
+
- Maintain version control of your flashcard sets
|
|
416
|
+
- Incrementally build large decks
|
|
417
|
+
|
|
418
|
+
## Contributing
|
|
419
|
+
|
|
420
|
+
1. Fork the repository
|
|
421
|
+
2. Create a feature branch
|
|
422
|
+
3. Add tests for new functionality
|
|
423
|
+
4. Ensure all tests pass
|
|
424
|
+
5. Submit a pull request
|
|
425
|
+
|
|
426
|
+
## License
|
|
427
|
+
|
|
428
|
+
MIT License - see LICENSE file for details.
|
data/bin/anki_generator
CHANGED
|
@@ -1,15 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require_relative '../lib/anki_generator'
|
|
5
|
-
|
|
6
|
-
class AnkiCLI < Thor
|
|
7
|
-
desc 'generate DECK_NAME YAML_FILE OUTPUT_FILE', 'Generate an Anki .apkg deck from a YAML file'
|
|
8
|
-
def generate(deck_name, yaml_file, output_file)
|
|
9
|
-
anki_generator = AnkiGenerator.new(name: deck_name, deck_file: yaml_file)
|
|
10
|
-
anki_generator.generate_apkg(output_path: output_file)
|
|
11
|
-
puts "Anki deck '#{deck_name}' has been successfully created as #{output_file}!"
|
|
12
|
-
end
|
|
13
|
-
end
|
|
3
|
+
require_relative '../lib/anki_cli'
|
|
14
4
|
|
|
15
5
|
AnkiCLI.start(ARGV)
|
data/lib/anki_cli.rb
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
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/anki_generator.rb
CHANGED
|
@@ -2,21 +2,87 @@
|
|
|
2
2
|
|
|
3
3
|
require 'yaml'
|
|
4
4
|
require 'anki2'
|
|
5
|
+
require_relative 'openrouter_client'
|
|
5
6
|
|
|
6
7
|
# AnkiGenerator -> Class
|
|
7
8
|
class AnkiGenerator
|
|
8
|
-
attr_accessor :deck_file, :cards, :name
|
|
9
|
+
attr_accessor :deck_file, :cards, :name, :openrouter_client
|
|
9
10
|
|
|
10
|
-
def initialize(deck_file:, name:)
|
|
11
|
+
def initialize(deck_file:, name:, api_key: nil, model: 'openai/gpt-3.5-turbo')
|
|
11
12
|
self.deck_file = deck_file
|
|
12
13
|
self.name = name
|
|
13
14
|
self.cards = []
|
|
15
|
+
self.openrouter_client = OpenRouterClient.new(api_key: api_key, model: model) if api_key || ENV['OPENROUTER_API_KEY']
|
|
14
16
|
|
|
15
17
|
fill_cards
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
def fill_cards
|
|
19
|
-
|
|
21
|
+
yaml_content = YAML.load_file(deck_file)
|
|
22
|
+
|
|
23
|
+
# Handle both old format (array of cards) and new format (with ai_generation config)
|
|
24
|
+
if yaml_content.is_a?(Hash) && yaml_content['ai_generation']
|
|
25
|
+
process_ai_generation(yaml_content)
|
|
26
|
+
else
|
|
27
|
+
self.cards = yaml_content.is_a?(Array) ? yaml_content : []
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def process_ai_generation(config, attachments: nil)
|
|
32
|
+
ai_config = config['ai_generation']
|
|
33
|
+
existing_cards = config['cards'] || []
|
|
34
|
+
|
|
35
|
+
# Generate AI cards if specified and client is available
|
|
36
|
+
if ai_config['topics'] && openrouter_client
|
|
37
|
+
ai_cards = generate_ai_cards(
|
|
38
|
+
topics: ai_config['topics'],
|
|
39
|
+
context: ai_config['context'],
|
|
40
|
+
difficulty: ai_config['difficulty'] || 'medium',
|
|
41
|
+
count: ai_config['count'] || 5,
|
|
42
|
+
attachments: attachments
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
self.cards = existing_cards + ai_cards
|
|
46
|
+
else
|
|
47
|
+
self.cards = existing_cards
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Save the generated cards back to YAML for future reference
|
|
51
|
+
save_generated_cards_to_yaml(config) if ai_config['save_generated'] && openrouter_client
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def generate_ai_cards(topics:, context: nil, difficulty: 'medium', count: 5, attachments: nil)
|
|
55
|
+
if topics.is_a?(Array) && topics.length > 1
|
|
56
|
+
openrouter_client.generate_multiple_flashcards(
|
|
57
|
+
topics: topics,
|
|
58
|
+
context: context,
|
|
59
|
+
difficulty: difficulty,
|
|
60
|
+
count: count,
|
|
61
|
+
attachments: attachments
|
|
62
|
+
)
|
|
63
|
+
else
|
|
64
|
+
topic = topics.is_a?(Array) ? topics.first : topics
|
|
65
|
+
[openrouter_client.generate_flashcard(
|
|
66
|
+
topic: topic,
|
|
67
|
+
context: context,
|
|
68
|
+
difficulty: difficulty,
|
|
69
|
+
attachments: attachments
|
|
70
|
+
)]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def save_generated_cards_to_yaml(original_config)
|
|
75
|
+
output_file = deck_file.gsub('.yaml', '_generated.yaml')
|
|
76
|
+
|
|
77
|
+
updated_config = original_config.dup
|
|
78
|
+
updated_config['cards'] = cards
|
|
79
|
+
updated_config['ai_generation']['save_generated'] = false # Prevent recursive generation
|
|
80
|
+
|
|
81
|
+
File.open(output_file, 'w') do |file|
|
|
82
|
+
file.write(updated_config.to_yaml)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
puts "Generated cards saved to: #{output_file}"
|
|
20
86
|
end
|
|
21
87
|
|
|
22
88
|
def generate_apkg(output_path:)
|
|
@@ -28,4 +94,23 @@ class AnkiGenerator
|
|
|
28
94
|
|
|
29
95
|
deck.save
|
|
30
96
|
end
|
|
97
|
+
|
|
98
|
+
def add_card(front:, back:)
|
|
99
|
+
cards << { 'front' => front, 'back' => back }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def sync_with_existing_deck(existing_yaml_file)
|
|
103
|
+
return unless File.exist?(existing_yaml_file)
|
|
104
|
+
|
|
105
|
+
existing_cards = YAML.load_file(existing_yaml_file)
|
|
106
|
+
existing_cards = existing_cards.is_a?(Array) ? existing_cards : existing_cards['cards'] || []
|
|
107
|
+
|
|
108
|
+
# Simple deduplication based on front text
|
|
109
|
+
existing_fronts = existing_cards.map { |card| card['front'] }
|
|
110
|
+
new_cards = cards.reject { |card| existing_fronts.include?(card['front']) }
|
|
111
|
+
|
|
112
|
+
self.cards = existing_cards + new_cards
|
|
113
|
+
|
|
114
|
+
puts "Synced #{new_cards.length} new cards with existing deck"
|
|
115
|
+
end
|
|
31
116
|
end
|
|
@@ -0,0 +1,156 @@
|
|
|
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
|
|
@@ -0,0 +1,158 @@
|
|
|
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
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: anki_generator
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ceb
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-01-01 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: anki2
|
|
@@ -25,35 +25,108 @@ dependencies:
|
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
26
|
version: 0.1.2
|
|
27
27
|
- !ruby/object:Gem::Dependency
|
|
28
|
-
name:
|
|
28
|
+
name: thor
|
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
|
30
30
|
requirements:
|
|
31
31
|
- - "~>"
|
|
32
32
|
- !ruby/object:Gem::Version
|
|
33
|
-
version:
|
|
33
|
+
version: '1.2'
|
|
34
34
|
type: :runtime
|
|
35
35
|
prerelease: false
|
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
37
|
requirements:
|
|
38
38
|
- - "~>"
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
|
-
version:
|
|
40
|
+
version: '1.2'
|
|
41
41
|
- !ruby/object:Gem::Dependency
|
|
42
|
-
name:
|
|
42
|
+
name: faraday
|
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
|
44
44
|
requirements:
|
|
45
45
|
- - "~>"
|
|
46
46
|
- !ruby/object:Gem::Version
|
|
47
|
-
version: '
|
|
47
|
+
version: '2.0'
|
|
48
48
|
type: :runtime
|
|
49
49
|
prerelease: false
|
|
50
50
|
version_requirements: !ruby/object:Gem::Requirement
|
|
51
51
|
requirements:
|
|
52
52
|
- - "~>"
|
|
53
53
|
- !ruby/object:Gem::Version
|
|
54
|
-
version: '
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
version: '2.0'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: json
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '2.0'
|
|
62
|
+
type: :runtime
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '2.0'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: dotenv
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '2.8'
|
|
76
|
+
type: :runtime
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '2.8'
|
|
83
|
+
- !ruby/object:Gem::Dependency
|
|
84
|
+
name: minitest
|
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - "~>"
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '5.25'
|
|
90
|
+
type: :development
|
|
91
|
+
prerelease: false
|
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - "~>"
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '5.25'
|
|
97
|
+
- !ruby/object:Gem::Dependency
|
|
98
|
+
name: rake
|
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - "~>"
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '13.0'
|
|
104
|
+
type: :development
|
|
105
|
+
prerelease: false
|
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
107
|
+
requirements:
|
|
108
|
+
- - "~>"
|
|
109
|
+
- !ruby/object:Gem::Version
|
|
110
|
+
version: '13.0'
|
|
111
|
+
- !ruby/object:Gem::Dependency
|
|
112
|
+
name: rubocop
|
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
|
114
|
+
requirements:
|
|
115
|
+
- - "~>"
|
|
116
|
+
- !ruby/object:Gem::Version
|
|
117
|
+
version: '1.0'
|
|
118
|
+
type: :development
|
|
119
|
+
prerelease: false
|
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
121
|
+
requirements:
|
|
122
|
+
- - "~>"
|
|
123
|
+
- !ruby/object:Gem::Version
|
|
124
|
+
version: '1.0'
|
|
125
|
+
description: A powerful command-line tool that generates Anki flashcard decks (.apkg)
|
|
126
|
+
from YAML files, direct prompts, or file attachments. Features AI-powered content
|
|
127
|
+
generation using OpenRouter API with support for multiple models (GPT, Claude, Llama),
|
|
128
|
+
file and directory attachment processing for context-aware generation, prompt file
|
|
129
|
+
support, intelligent content filtering, and flexible deck management with sync capabilities.
|
|
57
130
|
email:
|
|
58
131
|
- ceeb.developer@gmail.com
|
|
59
132
|
executables:
|
|
@@ -61,12 +134,21 @@ executables:
|
|
|
61
134
|
extensions: []
|
|
62
135
|
extra_rdoc_files: []
|
|
63
136
|
files:
|
|
137
|
+
- README.md
|
|
64
138
|
- bin/anki_generator
|
|
139
|
+
- lib/anki_cli.rb
|
|
65
140
|
- lib/anki_generator.rb
|
|
66
|
-
|
|
141
|
+
- lib/file_processor.rb
|
|
142
|
+
- lib/openrouter_client.rb
|
|
143
|
+
homepage: https://github.com/pinkfloydsito/anki_generator
|
|
67
144
|
licenses:
|
|
68
145
|
- MIT
|
|
69
|
-
metadata:
|
|
146
|
+
metadata:
|
|
147
|
+
homepage_uri: https://github.com/pinkfloydsito/anki_generator
|
|
148
|
+
source_code_uri: https://github.com/pinkfloydsito/anki_generator
|
|
149
|
+
changelog_uri: https://github.com/pinkfloydsito/anki_generator/blob/main/CHANGELOG.md
|
|
150
|
+
bug_tracker_uri: https://github.com/pinkfloydsito/anki_generator/issues
|
|
151
|
+
documentation_uri: https://github.com/pinkfloydsito/anki_generator/blob/main/README.md
|
|
70
152
|
post_install_message:
|
|
71
153
|
rdoc_options: []
|
|
72
154
|
require_paths:
|
|
@@ -75,7 +157,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
75
157
|
requirements:
|
|
76
158
|
- - ">="
|
|
77
159
|
- !ruby/object:Gem::Version
|
|
78
|
-
version:
|
|
160
|
+
version: 2.7.0
|
|
79
161
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
80
162
|
requirements:
|
|
81
163
|
- - ">="
|
|
@@ -85,5 +167,5 @@ requirements: []
|
|
|
85
167
|
rubygems_version: 3.3.7
|
|
86
168
|
signing_key:
|
|
87
169
|
specification_version: 4
|
|
88
|
-
summary:
|
|
170
|
+
summary: AI-powered Anki flashcard generator with file attachment support
|
|
89
171
|
test_files: []
|