smart_csv_import 0.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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.adoc +134 -0
  3. data/README.md +534 -0
  4. data/app/jobs/smart_csv_import/import_job.rb +22 -0
  5. data/app/models/smart_csv_import/import.rb +36 -0
  6. data/app/models/smart_csv_import/import_row_error.rb +17 -0
  7. data/lib/generators/smart_csv_import/import/import_generator.rb +49 -0
  8. data/lib/generators/smart_csv_import/import/templates/import_form.rb.tt +32 -0
  9. data/lib/generators/smart_csv_import/import/templates/import_form_spec.rb.tt +38 -0
  10. data/lib/generators/smart_csv_import/install/install_generator.rb +34 -0
  11. data/lib/generators/smart_csv_import/install/templates/create_smart_csv_import_import_row_errors.rb.tt +18 -0
  12. data/lib/generators/smart_csv_import/install/templates/create_smart_csv_import_imports.rb.tt +23 -0
  13. data/lib/generators/smart_csv_import/install/templates/initializer.rb.tt +51 -0
  14. data/lib/generators/smart_csv_import/scaffold/scaffold_generator.rb +56 -0
  15. data/lib/generators/smart_csv_import/scaffold/templates/controller.rb.tt +33 -0
  16. data/lib/generators/smart_csv_import/scaffold/templates/new.html.erb.tt +12 -0
  17. data/lib/generators/smart_csv_import/scaffold/templates/show.html.erb.tt +59 -0
  18. data/lib/smart_csv_import/configuration.rb +77 -0
  19. data/lib/smart_csv_import/cosine_similarity.rb +15 -0
  20. data/lib/smart_csv_import/engine.rb +12 -0
  21. data/lib/smart_csv_import/failed_row_exporter.rb +78 -0
  22. data/lib/smart_csv_import/file_storage.rb +34 -0
  23. data/lib/smart_csv_import/header_normalizer.rb +76 -0
  24. data/lib/smart_csv_import/logging.rb +37 -0
  25. data/lib/smart_csv_import/match_result.rb +36 -0
  26. data/lib/smart_csv_import/matchable.rb +76 -0
  27. data/lib/smart_csv_import/matcher.rb +198 -0
  28. data/lib/smart_csv_import/normalizers/boolean_converter.rb +26 -0
  29. data/lib/smart_csv_import/normalizers/date_converter.rb +28 -0
  30. data/lib/smart_csv_import/notifications.rb +16 -0
  31. data/lib/smart_csv_import/processor/csv_preflight_analyzer.rb +74 -0
  32. data/lib/smart_csv_import/processor/import_result_builder.rb +97 -0
  33. data/lib/smart_csv_import/processor/mapping_review_policy.rb +90 -0
  34. data/lib/smart_csv_import/processor/nil_cell_counter.rb +19 -0
  35. data/lib/smart_csv_import/processor/null_progress_callback.rb +11 -0
  36. data/lib/smart_csv_import/processor/row_processor.rb +70 -0
  37. data/lib/smart_csv_import/processor.rb +294 -0
  38. data/lib/smart_csv_import/result.rb +101 -0
  39. data/lib/smart_csv_import/stability_report.rb +104 -0
  40. data/lib/smart_csv_import/strategies/llm.rb +106 -0
  41. data/lib/smart_csv_import/strategies/lookup.rb +41 -0
  42. data/lib/smart_csv_import/strategies/vector.rb +155 -0
  43. data/lib/smart_csv_import/strategy.rb +9 -0
  44. data/lib/smart_csv_import/strategy_failure.rb +13 -0
  45. data/lib/smart_csv_import/version.rb +5 -0
  46. data/lib/smart_csv_import.rb +79 -0
  47. data/smart_csv_import.gemspec +35 -0
  48. metadata +216 -0
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartCsvImport
4
+ StableField = Struct.new(:csv_header, :target_field, :strategy, :consistency_rate, keyword_init: true)
5
+ UnstableField = Struct.new(:csv_header, :resolutions, keyword_init: true)
6
+ StabilityAnalysis = Struct.new(:import_type, :imports_analyzed, :stable_fields, :unstable_fields, keyword_init: true)
7
+
8
+ class StabilityReport
9
+ STABILITY_THRESHOLD = 0.9
10
+ ANALYZABLE_STATUSES = %w[completed partial_failure].freeze
11
+
12
+ def initialize(import_type:, lookback: 20)
13
+ @import_type = import_type
14
+ @lookback = lookback
15
+ end
16
+
17
+ def analyze
18
+ imports = fetch_imports
19
+ header_tallies = tally_header_mappings(imports)
20
+ total = imports.length
21
+
22
+ stable_fields = []
23
+ unstable_fields = []
24
+
25
+ header_tallies.each do |csv_header, target_counts|
26
+ top_target, top_count = target_counts.max_by { |_, count| count }
27
+ consistency_rate = (top_count.to_f / total).round(4)
28
+
29
+ if consistency_rate >= STABILITY_THRESHOLD
30
+ stable_fields << StableField.new(
31
+ csv_header: csv_header,
32
+ target_field: top_target.to_sym,
33
+ strategy: nil,
34
+ consistency_rate: consistency_rate
35
+ )
36
+ else
37
+ resolutions = target_counts.map { |target, count| { target: target, count: count } }
38
+ unstable_fields << UnstableField.new(
39
+ csv_header: csv_header,
40
+ resolutions: resolutions
41
+ )
42
+ end
43
+ end
44
+
45
+ StabilityAnalysis.new(
46
+ import_type: @import_type,
47
+ imports_analyzed: total,
48
+ stable_fields: stable_fields,
49
+ unstable_fields: unstable_fields
50
+ )
51
+ end
52
+
53
+ def summary
54
+ analysis = analyze
55
+
56
+ if analysis.imports_analyzed.zero?
57
+ return "No completed imports found for #{@import_type}."
58
+ end
59
+
60
+ lines = ["Stability report for #{@import_type} (#{analysis.imports_analyzed} imports analyzed):"]
61
+
62
+ if analysis.stable_fields.any?
63
+ lines << " Stable fields (#{analysis.stable_fields.length}):"
64
+ analysis.stable_fields.each do |field|
65
+ lines << " - #{field.csv_header} → #{field.target_field} (#{(field.consistency_rate * 100).round(1)}% consistent)"
66
+ end
67
+ end
68
+
69
+ if analysis.unstable_fields.any?
70
+ lines << " Unstable fields (#{analysis.unstable_fields.length}):"
71
+ analysis.unstable_fields.each do |field|
72
+ resolutions_desc = field.resolutions.map { |r| "#{r[:target]}(#{r[:count]})" }.join(", ")
73
+ lines << " - #{field.csv_header}: #{resolutions_desc}"
74
+ end
75
+ end
76
+
77
+ lines.join("\n")
78
+ end
79
+
80
+ private
81
+
82
+ def fetch_imports
83
+ Import
84
+ .where(import_type: @import_type, status: ANALYZABLE_STATUSES)
85
+ .where.not(header_mappings: [nil, {}])
86
+ .order(created_at: :desc)
87
+ .limit(@lookback)
88
+ .to_a
89
+ end
90
+
91
+ def tally_header_mappings(imports)
92
+ imports.each_with_object(Hash.new { |h, k| h[k] = Hash.new(0) }) do |import, tallies|
93
+ mappings = import.header_mappings
94
+ next unless mappings.is_a?(Hash)
95
+
96
+ mappings.each do |csv_header, target_field|
97
+ next if target_field.nil?
98
+
99
+ tallies[csv_header][target_field] += 1
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ruby_llm"
5
+
6
+ module SmartCsvImport
7
+ module Strategies
8
+ class Llm < Strategy
9
+ include Logging
10
+
11
+ # Why we do NOT use HyDE (Hypothetical Document Embeddings) here:
12
+ #
13
+ # HyDE would ask the LLM to generate a description of each header in
14
+ # isolation, then compare those descriptions to field descriptions via
15
+ # embeddings. It was trialled and rejected for two reasons:
16
+ #
17
+ # 1. It throws away the best signal we have. The LLM here already sees
18
+ # both sides — all headers AND all field definitions — in one prompt.
19
+ # That cross-field context is what disambiguates genuinely ambiguous
20
+ # headers. "Cell" next to first_name/last_name/email is clearly a
21
+ # phone number. "Cell" described in isolation could be a phone, a
22
+ # prison cell, or a biological cell — the LLM can't know which.
23
+ #
24
+ # 2. It adds indirection without benefit. Direct matching lets the LLM
25
+ # reason holistically. HyDE turns that into a blind embedding lookup
26
+ # that loses the reasoning context.
27
+ #
28
+ # The right path for genuinely ambiguous headers is: enrich this prompt
29
+ # with business context (csv_source, csv_context on the form class) so
30
+ # the LLM has more signal — not strip signal away via HyDE. If even that
31
+ # isn't enough, surface the header as UnmatchedResult for human review.
32
+ def match(csv_headers:, form_class:, sample_rows: [])
33
+ field_definitions = form_class.csv_fields
34
+ return {} if field_definitions.empty?
35
+
36
+ prompt = build_prompt(csv_headers, field_definitions, form_class)
37
+ response = fetch_llm_response(prompt)
38
+ parse_response(response, csv_headers)
39
+ rescue StandardError => e
40
+ log_error("LLM strategy failed: #{e.message}")
41
+ {}
42
+ end
43
+
44
+ private
45
+
46
+ def build_prompt(csv_headers, field_definitions, form_class)
47
+ fields_desc = field_definitions.map { |name, defn| "- #{name}: #{defn.description}" }.join("\n")
48
+ context_line = form_class.csv_context ? "\nBusiness context: #{form_class.csv_context}" : ""
49
+ source_line = form_class.csv_source ? "\nCSV source: #{form_class.csv_source}" : ""
50
+
51
+ <<~PROMPT
52
+ You are a CSV column matching assistant. Match the following CSV headers to the target fields.#{context_line}#{source_line}
53
+
54
+ CSV Headers:
55
+ #{csv_headers.map { |h| "- #{h}" }.join("\n")}
56
+
57
+ Target Fields:
58
+ #{fields_desc}
59
+
60
+ Return a JSON object with this exact format:
61
+ {"mappings": {"<csv_header>": {"field": "<field_name>", "confidence": <0.0-1.0>}}}
62
+
63
+ Only include headers you can confidently match. Return valid JSON only, no other text.
64
+ PROMPT
65
+ end
66
+
67
+ def fetch_llm_response(prompt)
68
+ model = SmartCsvImport.configuration.llm_model
69
+ log_info("Sending prompt to #{model}")
70
+ chat = RubyLLM.chat(model: model)
71
+ response = chat.ask(prompt)
72
+ log_info("Received response (#{response.content.length} chars)")
73
+ response
74
+ end
75
+
76
+ def parse_response(response, csv_headers)
77
+ content = strip_code_fences(response.content)
78
+ data = JSON.parse(content)
79
+ mappings = data["mappings"] || {}
80
+
81
+ csv_headers.each_with_object({}) do |header, results|
82
+ mapping = mappings[header]
83
+ next unless mapping
84
+
85
+ confidence = mapping["confidence"].to_f.clamp(0.0, 1.0)
86
+ field = mapping["field"]&.to_sym
87
+
88
+ next unless field && confidence > 0
89
+
90
+ results[header] = MatchResult.matched(
91
+ target_field: field,
92
+ confidence: confidence,
93
+ strategy_name: "llm"
94
+ )
95
+ end
96
+ rescue JSON::ParserError => e
97
+ log_error("Failed to parse LLM response: #{e.message}")
98
+ {}
99
+ end
100
+
101
+ def strip_code_fences(text)
102
+ text.gsub(/\A\s*```\w*\n/, "").gsub(/\n```\s*\z/, "")
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartCsvImport
4
+ module Strategies
5
+ class Lookup < Strategy
6
+ class << self
7
+ def mappings(hash = nil)
8
+ return @defined_mappings ||= {} unless hash
9
+
10
+ @defined_mappings = hash.each_with_object({}) do |(header, field), acc|
11
+ acc[header.downcase] = field
12
+ end
13
+ end
14
+
15
+ def defined_mappings
16
+ @defined_mappings ||= {}
17
+ end
18
+
19
+ def inherited(subclass)
20
+ super
21
+ subclass.instance_variable_set(:@defined_mappings, {})
22
+ end
23
+ end
24
+
25
+ def match(csv_headers:, form_class:, sample_rows: [])
26
+ mappings = self.class.defined_mappings
27
+
28
+ csv_headers.each_with_object({}) do |header, results|
29
+ field = mappings[header.downcase]
30
+ next unless field
31
+
32
+ results[header] = MatchResult.matched(
33
+ target_field: field,
34
+ confidence: 1.0,
35
+ strategy_name: "lookup"
36
+ )
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require "fileutils"
6
+ require "ruby_llm"
7
+ require "faraday"
8
+ require_relative "../header_normalizer"
9
+
10
+ module SmartCsvImport
11
+ module Strategies
12
+ class Vector < Strategy
13
+ include Logging
14
+ def match(csv_headers:, form_class:, sample_rows: [])
15
+ field_definitions = form_class.csv_fields
16
+ return {} if field_definitions.empty?
17
+
18
+ field_names = field_definitions.keys
19
+ humanized_names = field_names.map { |name| name.to_s.tr("_", " ") }
20
+
21
+ # Index humanized names for O(1) exact-match lookup
22
+ humanized_index = humanized_names.each_with_index.to_h { |name, i| [name.downcase, field_names[i]] }
23
+
24
+ results = {}
25
+ needs_embedding = []
26
+
27
+ csv_headers.each do |header|
28
+ normalized = HeaderNormalizer.normalize(header)
29
+ if (field = humanized_index[normalized.downcase])
30
+ log_info("Exact match: '#{header}' → :#{field} (normalized: '#{normalized}')")
31
+ results[header] = MatchResult.matched(
32
+ target_field: field,
33
+ confidence: 1.0,
34
+ strategy_name: "vector"
35
+ )
36
+ else
37
+ needs_embedding << header
38
+ end
39
+ end
40
+
41
+ return results if needs_embedding.empty?
42
+
43
+ field_embeddings = fetch_field_embeddings(humanized_names, field_names)
44
+
45
+ normalized_remaining = needs_embedding.map { |h| HeaderNormalizer.normalize(h) }
46
+ raw_header_embeddings = compute_embeddings(normalized_remaining.uniq)
47
+ header_embeddings = needs_embedding.zip(normalized_remaining).to_h do |orig, norm|
48
+ [orig, raw_header_embeddings[norm]]
49
+ end
50
+
51
+ # Build full score matrix so we can check both directions
52
+ score_matrix = needs_embedding.each_with_object({}) do |header, matrix|
53
+ header_vec = header_embeddings[header]
54
+ next unless header_vec
55
+
56
+ matrix[header] = field_names.each_with_object({}) do |field_name, scores|
57
+ field_vec = field_embeddings[field_name]
58
+ scores[field_name] = CosineSimilarity.call(header_vec, field_vec) if field_vec
59
+ end
60
+ end
61
+
62
+ # Best field for each header
63
+ best_field_for = score_matrix.transform_values { |scores| scores.max_by { |_, s| s }&.first }
64
+
65
+ # Best header for each field (among headers needing embedding)
66
+ best_header_for = field_names.each_with_object({}) do |field_name, bh|
67
+ bh[field_name] = score_matrix.max_by { |_, scores| scores[field_name] || -1 }&.first
68
+ end
69
+
70
+ needs_embedding.each do |header|
71
+ best_field = best_field_for[header]
72
+ next unless best_field
73
+
74
+ score = score_matrix[header][best_field]
75
+
76
+ unless best_header_for[best_field] == header
77
+ log_info("Non-mutual: '#{header}' → :#{best_field} (#{score.round(4)}) — field's best header is '#{best_header_for[best_field]}'")
78
+ next
79
+ end
80
+
81
+ results[header] = MatchResult.matched(
82
+ target_field: best_field,
83
+ confidence: score.round(4),
84
+ strategy_name: "vector"
85
+ )
86
+ end
87
+
88
+ results
89
+ rescue RubyLLM::Error, Faraday::Error => e
90
+ log_error("Vector strategy errored (#{e.class}): #{e.message}")
91
+ StrategyFailure.new(strategy_name: "vector", error: e)
92
+ end
93
+
94
+ private
95
+
96
+ def fetch_field_embeddings(embed_texts, field_names)
97
+ cache = load_cache(embed_texts)
98
+ if cache
99
+ log_info("Using cached field embeddings (#{cache.size} fields)")
100
+ return cache
101
+ end
102
+ log_info("No embedding cache found, computing fresh")
103
+
104
+ embeddings = compute_embeddings(embed_texts)
105
+ result = field_names.zip(embed_texts).each_with_object({}) do |(name, text), acc|
106
+ acc[name] = embeddings[text]
107
+ end
108
+
109
+ save_cache(embed_texts, result)
110
+ result
111
+ end
112
+
113
+ def compute_embeddings(texts)
114
+ model = SmartCsvImport.configuration.embedding_model
115
+ log_info("Computing embeddings for #{texts.length} texts via #{model}")
116
+ response = RubyLLM.embed(texts, model: model)
117
+ vectors = response.vectors
118
+ log_info("Received #{vectors.length} embedding vectors")
119
+
120
+ texts.zip(vectors).to_h
121
+ end
122
+
123
+ def cache_dir
124
+ File.join(SmartCsvImport.configuration.storage_path, "embeddings_cache")
125
+ end
126
+
127
+ def cache_key(texts)
128
+ Digest::SHA256.hexdigest(texts.sort.join("|"))
129
+ end
130
+
131
+ def load_cache(texts)
132
+ key = cache_key(texts)
133
+ path = File.join(cache_dir, "#{key}.json")
134
+ return nil unless File.exist?(path)
135
+
136
+ data = JSON.parse(File.read(path))
137
+ data.transform_keys(&:to_sym)
138
+ rescue StandardError
139
+ nil
140
+ end
141
+
142
+ def save_cache(texts, embeddings)
143
+ FileUtils.mkdir_p(cache_dir)
144
+ key = cache_key(texts)
145
+ path = File.join(cache_dir, "#{key}.json")
146
+
147
+ serializable = embeddings.transform_keys(&:to_s)
148
+ File.write(path, JSON.generate(serializable))
149
+ rescue StandardError => e
150
+ log_error("Failed to save embedding cache: #{e.message}")
151
+ end
152
+
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartCsvImport
4
+ class Strategy
5
+ def match(csv_headers:, form_class:, sample_rows: [])
6
+ raise NotImplementedError, "#{self.class}#match must be implemented"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartCsvImport
4
+ StrategyFailure = Struct.new(:strategy_name, :error, keyword_init: true) do
5
+ def reason
6
+ "#{error.class}: #{error.message}"
7
+ end
8
+
9
+ def failure?
10
+ true
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartCsvImport
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "active_support"
5
+ require "active_support/core_ext/module/attribute_accessors"
6
+ require "active_support/core_ext/time/calculations"
7
+
8
+ require_relative "smart_csv_import/version"
9
+ require_relative "smart_csv_import/engine" if defined?(Rails)
10
+
11
+ module SmartCsvImport
12
+ class Error < StandardError; end
13
+ class ConfigurationError < Error; end
14
+
15
+ class << self
16
+ attr_writer :configuration, :base_model_class
17
+
18
+ def configuration
19
+ @configuration ||= Configuration.new
20
+ end
21
+
22
+ def configure
23
+ yield(configuration)
24
+ end
25
+
26
+ def reset_configuration!
27
+ @configuration = Configuration.new
28
+ @base_model_class = nil
29
+ end
30
+
31
+ def base_model_class
32
+ klass = @base_model_class || ActiveRecord::Base
33
+ klass.is_a?(String) ? klass.constantize : klass
34
+ end
35
+
36
+ def process(file_path, form_class:, mode: :sync, batch_size: configuration.batch_size, dry_run: false, confirmed_mappings: nil)
37
+ Processor.new(
38
+ file_path: file_path,
39
+ form_class: form_class,
40
+ mode: mode,
41
+ batch_size: batch_size,
42
+ dry_run: dry_run,
43
+ confirmed_mappings: confirmed_mappings
44
+ ).call
45
+ end
46
+
47
+ def match_headers(file_path, form_class:)
48
+ Matcher.new(file_path: file_path, form_class: form_class).call
49
+ end
50
+ end
51
+ end
52
+
53
+ require_relative "smart_csv_import/configuration"
54
+ require_relative "smart_csv_import/logging"
55
+ require_relative "smart_csv_import/result"
56
+ require_relative "smart_csv_import/match_result"
57
+ require_relative "smart_csv_import/strategy"
58
+ require_relative "smart_csv_import/strategy_failure"
59
+ require_relative "smart_csv_import/matchable"
60
+ # User-space normalizer utilities.
61
+ # Not called by the processing pipeline — available for use in form objects
62
+ # or ETL scripts: SmartCsvImport::Normalizers::BooleanConverter.new.call(value)
63
+ require_relative "smart_csv_import/normalizers/date_converter"
64
+ require_relative "smart_csv_import/normalizers/boolean_converter"
65
+ require_relative "smart_csv_import/file_storage"
66
+ require_relative "smart_csv_import/cosine_similarity"
67
+ require_relative "smart_csv_import/strategies/lookup"
68
+ require_relative "smart_csv_import/strategies/vector"
69
+ require_relative "smart_csv_import/strategies/llm"
70
+ require_relative "smart_csv_import/matcher"
71
+ require_relative "smart_csv_import/processor"
72
+ require_relative "smart_csv_import/processor/nil_cell_counter"
73
+ require_relative "smart_csv_import/processor/null_progress_callback"
74
+ require_relative "smart_csv_import/processor/row_processor"
75
+ require_relative "smart_csv_import/processor/mapping_review_policy"
76
+ require_relative "smart_csv_import/processor/csv_preflight_analyzer"
77
+ require_relative "smart_csv_import/processor/import_result_builder"
78
+ require_relative "smart_csv_import/failed_row_exporter"
79
+ require_relative "smart_csv_import/stability_report"
@@ -0,0 +1,35 @@
1
+ require_relative "lib/smart_csv_import/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "smart_csv_import"
5
+ spec.version = SmartCsvImport::VERSION
6
+ spec.authors = ["Nico Roulston"]
7
+ spec.email = ["nicolas.roulston@gmail.com"]
8
+ spec.homepage = "https://github.com/Nroulston/smart_csv_import"
9
+ spec.summary = "AI-powered CSV import with automatic header matching for Rails"
10
+ spec.description = "A Rails Engine wrapping SmarterCSV for CSV importing with " \
11
+ "three-tier AI header matching (lookup + vector similarity + LLM fallback), " \
12
+ "batch processing, and import tracking. Only headers are sent to AI — " \
13
+ "row data never leaves your application."
14
+ spec.license = "MIT"
15
+
16
+ spec.metadata = {
17
+ "bug_tracker_uri" => "https://github.com/Nroulston/smart_csv_import/issues",
18
+ "changelog_uri" => "https://github.com/Nroulston/smart_csv_import/blob/main/CHANGELOG.md",
19
+ "source_code_uri" => "https://github.com/Nroulston/smart_csv_import",
20
+ "rubygems_mfa_required" => "true"
21
+ }
22
+
23
+ spec.required_ruby_version = ">= 3.1"
24
+
25
+ spec.add_dependency "activemodel", ">= 7.0", "< 9"
26
+ spec.add_dependency "activerecord", ">= 7.0", "< 9"
27
+ spec.add_dependency "activejob", ">= 7.0", "< 9"
28
+ spec.add_dependency "activesupport", ">= 7.0", "< 9"
29
+ spec.add_dependency "csv", "~> 3.0"
30
+ spec.add_dependency "smarter_csv", "~> 1.10"
31
+ spec.add_dependency "ruby_llm", "~> 1.0"
32
+
33
+ spec.extra_rdoc_files = Dir["README*", "LICENSE*"]
34
+ spec.files = Dir["*.gemspec", "lib/**/*", "app/**/*", "config/**/*", "db/**/*"]
35
+ end