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
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+ require 'webrick'
5
+ require_relative '../server'
6
+ require_relative '../ui'
7
+
8
+ begin
9
+ require 'rackup'
10
+ require 'rackup/handler/webrick'
11
+ rescue LoadError
12
+ # rackup gem not installed (it is not in the lockfile) — the small adapter
13
+ # below bridges WEBrick to the Rack app instead.
14
+ end
15
+
16
+ module AnkiGenerator
17
+ module Commands
18
+ # `anki_generator serve` — start the local preview/export web UI.
19
+ class Serve
20
+ DEFAULT_PORT = 8787
21
+
22
+ def initialize(port: DEFAULT_PORT, provider: nil, ui: UI.new)
23
+ @port = port
24
+ @provider = provider
25
+ @ui = ui
26
+ end
27
+
28
+ # Blocks until the server is stopped (Ctrl+C).
29
+ def run
30
+ AnkiGenerator::Server.set(:default_provider, @provider)
31
+
32
+ @ui.info("Anki Generator UI available at http://localhost:#{@port}")
33
+ @ui.info('Press Ctrl+C to stop')
34
+
35
+ if defined?(Rackup::Handler::WEBrick)
36
+ Rackup::Handler::WEBrick.run(
37
+ AnkiGenerator::Server,
38
+ Host: '127.0.0.1', Port: @port,
39
+ AccessLog: [], Logger: WEBrick::Log.new(File::NULL)
40
+ )
41
+ else
42
+ run_webrick
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def run_webrick
49
+ server = WEBrick::HTTPServer.new(
50
+ BindAddress: '127.0.0.1',
51
+ Port: @port,
52
+ Logger: WEBrick::Log.new(File::NULL),
53
+ AccessLog: []
54
+ )
55
+ server.mount('/', RackServlet, AnkiGenerator::Server)
56
+ trap('INT') { server.shutdown }
57
+ server.start
58
+ ensure
59
+ server&.shutdown
60
+ end
61
+
62
+ # Minimal WEBrick -> Rack bridge used when the rackup gem is unavailable.
63
+ class RackServlet < WEBrick::HTTPServlet::AbstractServlet
64
+ def initialize(server, app)
65
+ super(server)
66
+ @app = app
67
+ end
68
+
69
+ def service(request, response)
70
+ status, headers, body = @app.call(rack_env(request))
71
+ response.status = status
72
+ headers.each do |key, value|
73
+ next if key.start_with?('rack.')
74
+
75
+ response[key] = value.is_a?(Array) ? value.join(', ') : value.to_s
76
+ end
77
+ response.body = +''
78
+ body.each { |part| response.body << part.to_s }
79
+ body.close if body.respond_to?(:close)
80
+ end
81
+
82
+ private
83
+
84
+ def rack_env(request)
85
+ env = request.meta_vars
86
+ env['CONTENT_TYPE'] ||= env.delete('Content-Type')
87
+ env['CONTENT_LENGTH'] ||= env.delete('Content-Length')
88
+ env.update(
89
+ 'rack.version' => Rack::RELEASE,
90
+ 'rack.input' => StringIO.new(request.body.to_s),
91
+ 'rack.errors' => $stderr,
92
+ 'rack.url_scheme' => request.ssl? ? 'https' : 'http'
93
+ )
94
+ env
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../client_factory'
4
+ require_relative '../ui'
5
+
6
+ module AnkiGenerator
7
+ module Commands
8
+ # `anki_generator test_api` — verify the LLM connection by generating a
9
+ # single sample card.
10
+ class TestApi
11
+ def initialize(model: LlmClient::DEFAULT_MODEL, client: nil, ui: UI.new)
12
+ @client = client || ClientFactory.build(model:)
13
+ @ui = ui
14
+ end
15
+
16
+ def run
17
+ ui.info('Testing LLM connection...')
18
+ ui.info("Model: #{client.model}")
19
+
20
+ card = client.generate_flashcard(topic: 'Ruby programming', difficulty: 'easy')
21
+
22
+ ui.success('API connection successful!')
23
+ ui.info('Sample generated card:')
24
+ ui.info("Front: #{card['front']}")
25
+ ui.info("Back: #{card['back']}")
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :client, :ui
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'yaml'
5
+ require_relative 'apkg_writer'
6
+ require_relative 'card'
7
+ require_relative 'errors'
8
+ require_relative 'client_factory'
9
+ require_relative 'ui'
10
+
11
+ module AnkiGenerator
12
+ # Loads card definitions from YAML, optionally enriches them with AI-generated
13
+ # cards via an LLM client, and exports the result as an .apkg deck.
14
+ class DeckBuilder
15
+ attr_reader :name, :deck_file, :cards
16
+ attr_accessor :client
17
+
18
+ def initialize(name:, deck_file:, client: nil, jobs: 1, ui: UI.new($stderr))
19
+ @name = name
20
+ @deck_file = deck_file
21
+ @client = client
22
+ @jobs = jobs
23
+ @ui = ui
24
+ @cards = []
25
+ load_cards
26
+ end
27
+
28
+ def load_cards
29
+ yaml_content = load_yaml(deck_file)
30
+
31
+ if yaml_content.is_a?(Hash) && yaml_content['ai_generation']
32
+ process_ai_generation(yaml_content)
33
+ else
34
+ @cards = build_cards(extract_card_list(yaml_content))
35
+ end
36
+ end
37
+
38
+ def process_ai_generation(config, attachments: nil)
39
+ ai_config = config['ai_generation']
40
+ existing_cards = build_cards(config['cards'] || [])
41
+
42
+ if ai_config['topics'] && client
43
+ ai_cards = generate_ai_cards(
44
+ topics: ai_config['topics'],
45
+ context: ai_config['context'],
46
+ difficulty: ai_config['difficulty'] || 'medium',
47
+ count: ai_config['count'] || 5,
48
+ attachments:,
49
+ jobs: ai_config['jobs'] || @jobs
50
+ )
51
+ @cards = existing_cards + build_cards(ai_cards)
52
+ else
53
+ @cards = existing_cards
54
+ end
55
+
56
+ return unless ai_config['save_generated'] && client
57
+
58
+ save_generated_cards_to_yaml(config)
59
+ end
60
+
61
+ # Generates AI cards for the given topics. With jobs > 1 and multiple
62
+ # topics, each topic is requested in parallel (one API call per topic,
63
+ # count cards each); with jobs == 1 all topics go in a single batched
64
+ # request, which is cheaper but may produce less focused cards.
65
+ def generate_ai_cards(topics:, context: nil, difficulty: 'medium', count: 5, attachments: nil, jobs: 1)
66
+ topic_list = Array(topics)
67
+
68
+ if topic_list.length > 1 && jobs > 1
69
+ generate_topics_in_parallel(topic_list, context:, difficulty:, count:, attachments:, jobs:)
70
+ elsif topic_list.length > 1
71
+ client.generate_multiple_flashcards(
72
+ topics: topic_list, context:, difficulty:, count:, attachments:
73
+ )
74
+ else
75
+ [client.generate_flashcard(topic: topic_list.first, context:, difficulty:, attachments:)]
76
+ end
77
+ end
78
+
79
+ # Appends a reversed copy of every basic card (back becomes front) — the
80
+ # classic "recognition + recall" pattern. Cloze cards are skipped.
81
+ def add_reverse_cards!
82
+ originals = cards.dup
83
+ originals.reject(&:cloze?).each { |card| cards << card.reversed }
84
+ @ui.info("Added #{cards.length - originals.length} reversed cards")
85
+ cards
86
+ end
87
+
88
+ def save_generated_cards_to_yaml(original_config)
89
+ output_file = deck_file.sub(/\.ya?ml\z/, '_generated.yaml')
90
+
91
+ updated_config = original_config.dup
92
+ updated_config['cards'] = cards.map(&:to_h)
93
+ updated_config['ai_generation']['save_generated'] = false # Prevent recursive generation
94
+
95
+ File.write(output_file, updated_config.to_yaml)
96
+ @ui.info("Generated cards saved to: #{output_file}")
97
+ end
98
+
99
+ def generate_apkg(output_path:)
100
+ writer = ApkgWriter.new(name:, output_path:)
101
+ cards.each do |card|
102
+ if card.cloze?
103
+ writer.add_card(card.front, card.back, tags: card.tags, cloze: card.front)
104
+ else
105
+ writer.add_card(card.front, card.back, tags: card.tags)
106
+ end
107
+ end
108
+ writer.save
109
+ end
110
+
111
+ def add_card(front:, back:, tags: [], cloze: nil)
112
+ cards << Card.new(front:, back:, tags:, cloze:)
113
+ end
114
+
115
+ # Merges cards from an existing deck YAML, deduplicating on the normalized
116
+ # front text so re-running generation does not create duplicate cards.
117
+ def sync_with(existing_yaml_file)
118
+ unless File.exist?(existing_yaml_file)
119
+ @ui.warn("Sync file not found, skipping: #{existing_yaml_file}")
120
+ return
121
+ end
122
+
123
+ existing_cards = build_cards(extract_card_list(load_yaml(existing_yaml_file)))
124
+ existing_fronts = existing_cards.map { |card| normalize(card.front) }
125
+ new_cards = cards.reject { |card| existing_fronts.include?(normalize(card.front)) }
126
+
127
+ @cards = existing_cards + new_cards
128
+ @ui.info("Synced #{new_cards.length} new cards with existing deck")
129
+ end
130
+
131
+ private
132
+
133
+ def generate_topics_in_parallel(topics, context:, difficulty:, count:, attachments:, jobs:)
134
+ work = topics.dup
135
+ lock = Mutex.new
136
+ results = Queue.new
137
+
138
+ threads = [jobs, topics.length].min.times.map do
139
+ Thread.new do
140
+ loop do
141
+ topic = lock.synchronize { work.pop }
142
+ break if topic.nil?
143
+
144
+ client.generate_multiple_flashcards(
145
+ topics: [topic], context:, difficulty:, count:, attachments:
146
+ ).each { |card| results << card }
147
+ end
148
+ end
149
+ end
150
+ threads.each(&:join)
151
+
152
+ collected = []
153
+ collected << results.pop until results.empty?
154
+ collected
155
+ end
156
+
157
+ def load_yaml(path)
158
+ YAML.safe_load_file(path, permitted_classes: [Time, Date], aliases: false)
159
+ rescue Psych::Exception => e
160
+ raise FileProcessingError, "Invalid YAML in #{path}: #{e.message}"
161
+ rescue SystemCallError => e
162
+ raise FileProcessingError, "Cannot read #{path}: #{e.message}"
163
+ end
164
+
165
+ def extract_card_list(content)
166
+ content.is_a?(Array) ? content : content['cards'] || []
167
+ end
168
+
169
+ def build_cards(raw_cards)
170
+ Array(raw_cards).map do |raw|
171
+ next nil unless raw.is_a?(Hash)
172
+
173
+ Card.new(front: raw['front'], back: raw['back'], tags: raw['tags'] || [], cloze: raw['cloze'])
174
+ rescue ValidationError => e
175
+ @ui.warn("Skipping invalid card: #{e.message}")
176
+ nil
177
+ end.compact
178
+ end
179
+
180
+ def normalize(text)
181
+ text.to_s.strip.downcase
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnkiGenerator
4
+ # Base class for all errors raised by this gem. Rescue this to catch anything
5
+ # the library raises intentionally.
6
+ class Error < StandardError; end
7
+
8
+ # Raised when required configuration (e.g. API key) is missing or invalid.
9
+ class ConfigurationError < Error; end
10
+
11
+ # Raised when the LLM provider responds with an error status.
12
+ class ApiError < Error; end
13
+
14
+ # Raised when the API response cannot be parsed into flashcards.
15
+ class ResponseParseError < Error; end
16
+
17
+ # Raised when a flashcard definition is invalid (e.g. empty front/back).
18
+ class ValidationError < Error; end
19
+
20
+ # Raised when an input file (deck YAML, prompt file, attachment) cannot be processed.
21
+ class FileProcessingError < Error; end
22
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require_relative 'errors'
5
+ require_relative 'ui'
6
+
7
+ module AnkiGenerator
8
+ # Handles file and directory processing for attachments and prompt files.
9
+ class FileProcessor
10
+ # Supported text file extensions
11
+ TEXT_EXTENSIONS = %w[
12
+ .txt .md .rb .py .js .ts .java .cpp .c .h .hpp .css .html .xml .json
13
+ .yaml .yml .sql .sh .bat .ps1 .php .go .rs .swift .kt .scala .clj
14
+ .hs .elm .ex .exs .erl .pl .r .m .tex .org .rst .adoc
15
+ ].freeze
16
+
17
+ # Maximum file size in bytes (1MB)
18
+ MAX_FILE_SIZE = 1_048_576
19
+
20
+ # Maximum total content size (5MB)
21
+ MAX_TOTAL_SIZE = 5_242_880
22
+
23
+ class << self
24
+ def process_attachments(paths, ui: UI.new($stderr))
25
+ return [] if paths.nil? || paths.empty?
26
+
27
+ attachments = []
28
+ total_size = 0
29
+
30
+ paths.each do |path_str|
31
+ candidates = candidates_for(path_str, ui:)
32
+ attachments, total_size = take_up_to_budget(candidates, attachments, total_size, ui)
33
+ end
34
+
35
+ ui.info("📎 Processed #{attachments.length} file(s) (#{format_size(total_size)})") if attachments.any?
36
+ attachments
37
+ end
38
+
39
+ # Resolves one CLI path argument to the list of attachments it contributes:
40
+ # a directory contributes all readable text files inside it, a file
41
+ # contributes itself. Unusable paths warn and contribute nothing.
42
+ def candidates_for(path_str, ui:)
43
+ path = Pathname.new(path_str)
44
+
45
+ unless path.exist?
46
+ ui.warn("Path does not exist: #{path_str}")
47
+ return []
48
+ end
49
+
50
+ return process_directory(path, ui:) if path.directory?
51
+
52
+ if path.file?
53
+ attachment = process_file(path, ui:)
54
+ return [attachment].compact
55
+ end
56
+
57
+ ui.warn("Path is neither file nor directory: #{path_str}")
58
+ []
59
+ end
60
+
61
+ def process_directory(dir_path, ui: UI.new($stderr))
62
+ dir_path.children.filter_map do |child|
63
+ process_file(child, ui:) if child.file?
64
+ end
65
+ end
66
+
67
+ def process_file(file_path, ui: UI.new($stderr))
68
+ if file_path.size > MAX_FILE_SIZE
69
+ ui.warn("File too large, skipping: #{file_path} (#{format_size(file_path.size)})")
70
+ return nil
71
+ end
72
+
73
+ unless text_file?(file_path)
74
+ ui.warn("Non-text file, skipping: #{file_path}")
75
+ return nil
76
+ end
77
+
78
+ content = read_content(file_path, ui:)
79
+ return nil unless content
80
+
81
+ {
82
+ filename: file_path.basename.to_s,
83
+ path: file_path.to_s,
84
+ content:
85
+ }
86
+ end
87
+
88
+ def text_file?(file_path)
89
+ ext = file_path.extname.downcase
90
+ return true if TEXT_EXTENSIONS.include?(ext)
91
+
92
+ # Files without an extension are treated as text only if the name looks
93
+ # like a plain-text script (e.g. Gemfile, Rakefile).
94
+ return false if file_path.extname.empty? && file_path.basename.to_s !~ /^[A-Z_]+$/
95
+
96
+ # Fall back to sniffing for null bytes, which indicate binary content.
97
+ !file_path.read(512, encoding: 'BINARY').include?("\x00")
98
+ rescue StandardError
99
+ false
100
+ end
101
+
102
+ def format_size(bytes)
103
+ if bytes < 1024
104
+ "#{bytes} B"
105
+ elsif bytes < 1024 * 1024
106
+ "#{(bytes / 1024.0).round(1)} KB"
107
+ else
108
+ "#{(bytes / (1024.0 * 1024)).round(1)} MB"
109
+ end
110
+ end
111
+
112
+ def read_prompt_from_file(file_path)
113
+ path = Pathname.new(file_path)
114
+ raise FileProcessingError, "Prompt file does not exist: #{file_path}" unless path.exist?
115
+ raise FileProcessingError, "Prompt path is not a file: #{file_path}" unless path.file?
116
+
117
+ content = path.read(encoding: 'UTF-8').strip
118
+ raise FileProcessingError, "Prompt file is empty: #{file_path}" if content.empty?
119
+
120
+ content
121
+ rescue FileProcessingError
122
+ raise
123
+ rescue StandardError => e
124
+ raise FileProcessingError, "Could not read prompt file #{file_path}: #{e.message}"
125
+ end
126
+
127
+ private
128
+
129
+ # Appends candidates to attachments while the total-size budget allows,
130
+ # warning and stopping once the budget is exhausted.
131
+ def take_up_to_budget(candidates, attachments, total_size, ui)
132
+ candidates.each do |attachment|
133
+ if total_size + attachment[:content].bytesize > MAX_TOTAL_SIZE
134
+ ui.warn("Total attachment size limit reached. Skipping #{attachment[:filename]}")
135
+ next
136
+ end
137
+
138
+ attachments << attachment
139
+ total_size += attachment[:content].bytesize
140
+ end
141
+ [attachments, total_size]
142
+ end
143
+
144
+ # NOTE: files larger than MAX_FILE_SIZE never reach this method (they are
145
+ # rejected in process_file), so no post-read truncation is needed.
146
+ def read_content(file_path, ui:)
147
+ file_path.read(encoding: 'UTF-8')
148
+ rescue StandardError => e
149
+ ui.warn("Could not read file #{file_path}: #{e.message}")
150
+ nil
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'csv'
4
+ require_relative '../errors'
5
+
6
+ module AnkiGenerator
7
+ module Importers
8
+ # Parses a CSV file into flashcards.
9
+ #
10
+ # Expected columns: front,back — with an optional third column of tags.
11
+ # A header row (front,back[,tags]) is detected case-insensitively and
12
+ # skipped. Tags are separated by `|` within the third column.
13
+ class Csv
14
+ TAG_SEPARATOR = '|'
15
+
16
+ def self.parse(text)
17
+ new(text).parse
18
+ end
19
+
20
+ def initialize(text)
21
+ @text = text.to_s
22
+ end
23
+
24
+ # Returns an array of card hashes: { 'front', 'back', 'tags' => [...] }.
25
+ def parse
26
+ rows = CSV.parse(@text)
27
+ rows = rows[1..] if header_row?(rows.first)
28
+
29
+ rows.filter_map do |row|
30
+ front, back, tags = row
31
+ next nil if front.to_s.strip.empty?
32
+
33
+ {
34
+ 'front' => front.to_s.strip,
35
+ 'back' => back.to_s.strip,
36
+ 'tags' => tags.to_s.split(TAG_SEPARATOR).map(&:strip).reject(&:empty?)
37
+ }
38
+ end
39
+ rescue CSV::MalformedCSVError => e
40
+ raise FileProcessingError, "Invalid CSV: #{e.message}"
41
+ end
42
+
43
+ private
44
+
45
+ def header_row?(row)
46
+ return false unless row
47
+
48
+ row[0].to_s.strip.casecmp('front').zero?
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../errors'
4
+
5
+ module AnkiGenerator
6
+ module Importers
7
+ # Parses study notes written in Markdown into flashcards.
8
+ #
9
+ # Supported card syntax:
10
+ # Q: What is the capital of France?
11
+ # A: Paris
12
+ #
13
+ # or, as list items:
14
+ # - **What is 2 + 2?** — 4
15
+ #
16
+ # Headings (`#`, `##`, ...) are treated as tags for the cards that follow
17
+ # them, so a file can be organized into sections that become tag groups.
18
+ class Markdown
19
+ BULLET_CARD = /^\s*[-*]\s+\*\*(?<front>.+?)\*\*\s*(?:—|--|-|–|:)\s*(?<back>.+?)\s*$/
20
+ QUESTION_LINE = /^Q:\s*(?<front>.+)$/
21
+ ANSWER_LINE = /^A:\s*(?<back>.+)$/
22
+ HEADING_LINE = /^#+\s+(?<title>.+?)\s*#*\s*$/
23
+
24
+ def self.parse(text)
25
+ new(text).parse
26
+ end
27
+
28
+ def initialize(text)
29
+ @lines = text.to_s.lines.map(&:rstrip)
30
+ end
31
+
32
+ # Returns an array of card hashes: { 'front', 'back', 'tags' => [...] }.
33
+ def parse
34
+ @cards = []
35
+ @tags = []
36
+ @pending_question = nil
37
+
38
+ @lines.each { |line| handle_line(line) }
39
+
40
+ @cards
41
+ end
42
+
43
+ private
44
+
45
+ def handle_line(line)
46
+ if (match = HEADING_LINE.match(line))
47
+ @tags = [match[:title]]
48
+ elsif (match = QUESTION_LINE.match(line))
49
+ @pending_question = match[:front].strip
50
+ elsif (match = ANSWER_LINE.match(line))
51
+ flush_answer(match[:back].strip)
52
+ elsif (match = BULLET_CARD.match(line))
53
+ @cards << card_hash(match[:front].strip, match[:back].strip, @tags)
54
+ elsif !line.strip.empty?
55
+ # Any other non-empty line cancels a dangling Q: without its A:.
56
+ @pending_question = nil
57
+ end
58
+ end
59
+
60
+ def flush_answer(back)
61
+ return unless @pending_question
62
+
63
+ @cards << card_hash(@pending_question, back, @tags)
64
+ @pending_question = nil
65
+ end
66
+
67
+ def card_hash(front, back, tags)
68
+ { 'front' => front, 'back' => back, 'tags' => tags.dup }
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnkiGenerator
4
+ # Builds the prompts sent to the LLM. Kept separate from the client so the
5
+ # prompt contract can be tested in isolation.
6
+ class PromptBuilder
7
+ def single_card(topic:, context: nil, difficulty: 'medium', attachments: nil)
8
+ <<~PROMPT.strip
9
+ Create a single flashcard for the topic: "#{topic}"
10
+ Difficulty level: #{difficulty}
11
+ #{context_line(context)}
12
+ #{attachments_section(attachments)}
13
+ Format your response as JSON with exactly this structure:
14
+ {
15
+ "front": "Question or prompt",
16
+ "back": "Answer or explanation"
17
+ }
18
+
19
+ Make the flashcard educational and appropriate for the #{difficulty} difficulty level.
20
+ For the back side, provide a clear, concise explanation.
21
+ #{attachments_hint(attachments)}
22
+ PROMPT
23
+ end
24
+
25
+ def multiple_cards(topics:, context: nil, difficulty: 'medium', count: 5, attachments: nil)
26
+ topics_list = topics.is_a?(Array) ? topics.join(', ') : topics.to_s
27
+
28
+ <<~PROMPT.strip
29
+ Create #{count} flashcards covering these topics: #{topics_list}
30
+ Difficulty level: #{difficulty}
31
+ #{context_line(context)}
32
+ #{attachments_section(attachments)}
33
+ Format your response as JSON with exactly this structure:
34
+ [
35
+ {
36
+ "front": "Question or prompt 1",
37
+ "back": "Answer or explanation 1"
38
+ },
39
+ {
40
+ "front": "Question or prompt 2",
41
+ "back": "Answer or explanation 2"
42
+ }
43
+ ]
44
+
45
+ Make the flashcards educational, diverse, and appropriate for the #{difficulty} difficulty level.
46
+ Ensure each flashcard covers different aspects of the topics.
47
+ #{attachments_hint(attachments)}
48
+ PROMPT
49
+ end
50
+
51
+ private
52
+
53
+ def context_line(context)
54
+ context ? "Additional context: #{context}" : ''
55
+ end
56
+
57
+ def attachments_hint(attachments)
58
+ if attachments && !attachments.empty?
59
+ 'Use the provided file content to create more accurate and detailed flashcards.'
60
+ else
61
+ ''
62
+ end
63
+ end
64
+
65
+ def attachments_section(attachments)
66
+ return '' unless attachments && !attachments.empty?
67
+
68
+ section = +"=== ATTACHED FILE CONTENT ===\n\n"
69
+ attachments.each do |attachment|
70
+ section << "--- #{attachment[:filename]} ---\n"
71
+ section << "#{attachment[:content]}\n\n"
72
+ end
73
+ section << "=== END ATTACHED CONTENT ===\n"
74
+ section
75
+ end
76
+ end
77
+ end