anki_generator 1.1.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.gitignore +59 -0
- data/.rubocop.yml +79 -0
- data/.ruby-version +1 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +173 -0
- data/Gemfile +21 -0
- data/Makefile +20 -0
- data/README.md +160 -32
- data/Rakefile +174 -0
- data/anki_generator.gemspec +42 -0
- data/bin/anki_generator +3 -2
- data/docs/CI_SETUP.md +120 -0
- data/docs/architecture/current-v1.3.0.architecture.json +315 -0
- data/docs/architecture/current-v1.3.0.html +14990 -0
- data/docs/architecture/current-v1.3.0.visual-check.json +548 -0
- data/docs/architecture/phase3-proposed.architecture.json +310 -0
- data/docs/architecture/phase3-proposed.html +15001 -0
- data/docs/architecture/phase3-proposed.visual-check.json +548 -0
- data/docs/phase3-draft.md +86 -0
- data/examples/example_class.rb +13 -0
- data/examples/manual_cards.yaml +5 -0
- data/examples/study_prompt.txt +3 -0
- data/input/input.yaml.example +3 -0
- data/lib/anki_generator/anki_connect_client.rb +85 -0
- data/lib/anki_generator/apkg_schema.rb +257 -0
- data/lib/anki_generator/apkg_writer.rb +149 -0
- data/lib/anki_generator/card.rb +83 -0
- data/lib/anki_generator/cli.rb +183 -0
- data/lib/anki_generator/client_factory.rb +20 -0
- data/lib/anki_generator/commands/create_ai_template.rb +39 -0
- data/lib/anki_generator/commands/generate_deck.rb +43 -0
- data/lib/anki_generator/commands/generate_yaml.rb +63 -0
- data/lib/anki_generator/commands/import.rb +75 -0
- data/lib/anki_generator/commands/prompt_based.rb +74 -0
- data/lib/anki_generator/commands/prompt_to_deck.rb +84 -0
- data/lib/anki_generator/commands/push.rb +32 -0
- data/lib/anki_generator/commands/serve.rb +99 -0
- data/lib/anki_generator/commands/test_api.rb +33 -0
- data/lib/anki_generator/deck_builder.rb +184 -0
- data/lib/anki_generator/errors.rb +22 -0
- data/lib/anki_generator/file_processor.rb +154 -0
- data/lib/anki_generator/importers/csv.rb +52 -0
- data/lib/anki_generator/importers/markdown.rb +72 -0
- data/lib/anki_generator/prompt_builder.rb +77 -0
- data/lib/anki_generator/server.rb +98 -0
- data/lib/anki_generator/ui.rb +28 -0
- data/lib/anki_generator/version.rb +5 -0
- data/lib/anki_generator.rb +18 -114
- data/prompt.txt +5 -0
- metadata +100 -43
- data/lib/anki_cli.rb +0 -259
- data/lib/file_processor.rb +0 -156
- data/lib/openrouter_client.rb +0 -158
|
@@ -0,0 +1,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
|