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.
- checksums.yaml +7 -0
- data/LICENSE.adoc +134 -0
- data/README.md +534 -0
- data/app/jobs/smart_csv_import/import_job.rb +22 -0
- data/app/models/smart_csv_import/import.rb +36 -0
- data/app/models/smart_csv_import/import_row_error.rb +17 -0
- data/lib/generators/smart_csv_import/import/import_generator.rb +49 -0
- data/lib/generators/smart_csv_import/import/templates/import_form.rb.tt +32 -0
- data/lib/generators/smart_csv_import/import/templates/import_form_spec.rb.tt +38 -0
- data/lib/generators/smart_csv_import/install/install_generator.rb +34 -0
- data/lib/generators/smart_csv_import/install/templates/create_smart_csv_import_import_row_errors.rb.tt +18 -0
- data/lib/generators/smart_csv_import/install/templates/create_smart_csv_import_imports.rb.tt +23 -0
- data/lib/generators/smart_csv_import/install/templates/initializer.rb.tt +51 -0
- data/lib/generators/smart_csv_import/scaffold/scaffold_generator.rb +56 -0
- data/lib/generators/smart_csv_import/scaffold/templates/controller.rb.tt +33 -0
- data/lib/generators/smart_csv_import/scaffold/templates/new.html.erb.tt +12 -0
- data/lib/generators/smart_csv_import/scaffold/templates/show.html.erb.tt +59 -0
- data/lib/smart_csv_import/configuration.rb +77 -0
- data/lib/smart_csv_import/cosine_similarity.rb +15 -0
- data/lib/smart_csv_import/engine.rb +12 -0
- data/lib/smart_csv_import/failed_row_exporter.rb +78 -0
- data/lib/smart_csv_import/file_storage.rb +34 -0
- data/lib/smart_csv_import/header_normalizer.rb +76 -0
- data/lib/smart_csv_import/logging.rb +37 -0
- data/lib/smart_csv_import/match_result.rb +36 -0
- data/lib/smart_csv_import/matchable.rb +76 -0
- data/lib/smart_csv_import/matcher.rb +198 -0
- data/lib/smart_csv_import/normalizers/boolean_converter.rb +26 -0
- data/lib/smart_csv_import/normalizers/date_converter.rb +28 -0
- data/lib/smart_csv_import/notifications.rb +16 -0
- data/lib/smart_csv_import/processor/csv_preflight_analyzer.rb +74 -0
- data/lib/smart_csv_import/processor/import_result_builder.rb +97 -0
- data/lib/smart_csv_import/processor/mapping_review_policy.rb +90 -0
- data/lib/smart_csv_import/processor/nil_cell_counter.rb +19 -0
- data/lib/smart_csv_import/processor/null_progress_callback.rb +11 -0
- data/lib/smart_csv_import/processor/row_processor.rb +70 -0
- data/lib/smart_csv_import/processor.rb +294 -0
- data/lib/smart_csv_import/result.rb +101 -0
- data/lib/smart_csv_import/stability_report.rb +104 -0
- data/lib/smart_csv_import/strategies/llm.rb +106 -0
- data/lib/smart_csv_import/strategies/lookup.rb +41 -0
- data/lib/smart_csv_import/strategies/vector.rb +155 -0
- data/lib/smart_csv_import/strategy.rb +9 -0
- data/lib/smart_csv_import/strategy_failure.rb +13 -0
- data/lib/smart_csv_import/version.rb +5 -0
- data/lib/smart_csv_import.rb +79 -0
- data/smart_csv_import.gemspec +35 -0
- 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,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
|