langextract 0.3.0 → 0.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/CHANGELOG.md +14 -0
- data/README.md +40 -1
- data/lib/langextract/core/fuzzy_aligner.rb +123 -0
- data/lib/langextract/core/fuzzy_alignment_index.rb +81 -0
- data/lib/langextract/core/fuzzy_alignment_planner.rb +239 -0
- data/lib/langextract/core/fuzzy_alignment_policy.rb +124 -0
- data/lib/langextract/core/fuzzy_token_stream.rb +113 -0
- data/lib/langextract/core/resolver.rb +72 -132
- data/lib/langextract/core/token_similarity.rb +152 -0
- data/lib/langextract/factory.rb +8 -2
- data/lib/langextract/providers/ruby_llm.rb +32 -2
- data/lib/langextract/version.rb +1 -1
- metadata +22 -2
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "data"
|
|
4
|
+
require_relative "tokenizer"
|
|
5
|
+
|
|
6
|
+
module LangExtract
|
|
7
|
+
module Core
|
|
8
|
+
# Builds the temporary source-token stream used only by fuzzy alignment.
|
|
9
|
+
# Canonical tokenizer output remains unchanged for exact and token offsets.
|
|
10
|
+
class FuzzyTokenStream
|
|
11
|
+
DASH_PUNCTUATION = /\p{Pd}/u
|
|
12
|
+
# Commas are the grouping variant covered by the fuzzy contract. Keep
|
|
13
|
+
# periods intact so sentence-boundary barriers remain visible.
|
|
14
|
+
NUMERIC_SEPARATOR = /,/u
|
|
15
|
+
APOSTROPHE = /[\u0027\u2019]/u
|
|
16
|
+
SEPARATOR_PUNCTUATION = /\A(?:\p{Pd}|,)\z/u
|
|
17
|
+
|
|
18
|
+
def initialize(tokens)
|
|
19
|
+
@tokens = tokens
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def tokens_in(range)
|
|
23
|
+
in_range = tokens.filter do |token|
|
|
24
|
+
token.char_interval.start_pos >= range.begin && token.char_interval.end_pos <= range.end
|
|
25
|
+
end
|
|
26
|
+
pieces = in_range.flat_map { |token| split_boundary_punctuation(token) }
|
|
27
|
+
merge_apostrophe_tokens(pieces).reject { |token| separator_punctuation?(token.text) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
attr_reader :tokens
|
|
33
|
+
|
|
34
|
+
def split_boundary_punctuation(token)
|
|
35
|
+
pieces = []
|
|
36
|
+
cursor = 0
|
|
37
|
+
token.text.to_enum(:scan, /#{DASH_PUNCTUATION}|(?<=\p{N})#{NUMERIC_SEPARATOR}(?=\p{N})/u).each do
|
|
38
|
+
match = Regexp.last_match
|
|
39
|
+
append_token_piece(token, pieces, cursor, match.begin(0))
|
|
40
|
+
append_token_piece(token, pieces, match.begin(0), match.end(0))
|
|
41
|
+
cursor = match.end(0)
|
|
42
|
+
end
|
|
43
|
+
append_token_piece(token, pieces, cursor, token.text.length)
|
|
44
|
+
pieces
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def merge_apostrophe_tokens(pieces)
|
|
48
|
+
merged = []
|
|
49
|
+
index = 0
|
|
50
|
+
while index < pieces.length
|
|
51
|
+
current = pieces[index]
|
|
52
|
+
if apostrophe?(current.text) && contiguous_text_pieces?(merged.last, pieces[index + 1], current)
|
|
53
|
+
merged << merged_apostrophe_token(merged.pop, current, pieces[index + 1], merged.length)
|
|
54
|
+
index += 2
|
|
55
|
+
else
|
|
56
|
+
merged << current
|
|
57
|
+
index += 1
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
reindex(merged)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def merged_apostrophe_token(previous, apostrophe, following, index)
|
|
64
|
+
Token.new(
|
|
65
|
+
text: previous.text + apostrophe.text + following.text,
|
|
66
|
+
char_interval: CharInterval.new(
|
|
67
|
+
start_pos: previous.char_interval.start_pos,
|
|
68
|
+
end_pos: following.char_interval.end_pos
|
|
69
|
+
),
|
|
70
|
+
index: index
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def reindex(tokens)
|
|
75
|
+
tokens.each_with_index.map do |token, index|
|
|
76
|
+
Token.new(text: token.text, char_interval: token.char_interval, index: index)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def contiguous_text_pieces?(previous, following, apostrophe)
|
|
81
|
+
previous && following &&
|
|
82
|
+
previous.char_interval.end_pos == apostrophe.char_interval.start_pos &&
|
|
83
|
+
apostrophe.char_interval.end_pos == following.char_interval.start_pos &&
|
|
84
|
+
previous.text.match?(text_piece_pattern) && following.text.match?(text_piece_pattern)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def text_piece_pattern
|
|
88
|
+
/\A[\p{L}\p{N}_-]+\z/u
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def apostrophe?(text)
|
|
92
|
+
text.match?(APOSTROPHE)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def separator_punctuation?(text)
|
|
96
|
+
text.match?(SEPARATOR_PUNCTUATION)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def append_token_piece(token, pieces, start_pos, end_pos)
|
|
100
|
+
return if start_pos >= end_pos
|
|
101
|
+
|
|
102
|
+
pieces << Token.new(
|
|
103
|
+
text: token.text[start_pos...end_pos],
|
|
104
|
+
char_interval: CharInterval.new(
|
|
105
|
+
start_pos: token.char_interval.start_pos + start_pos,
|
|
106
|
+
end_pos: token.char_interval.start_pos + end_pos
|
|
107
|
+
),
|
|
108
|
+
index: pieces.length
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -1,37 +1,34 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "data"
|
|
4
|
+
require_relative "fuzzy_aligner"
|
|
5
|
+
require_relative "fuzzy_token_stream"
|
|
6
|
+
require_relative "token_similarity"
|
|
4
7
|
require_relative "tokenizer"
|
|
5
8
|
|
|
6
9
|
module LangExtract
|
|
7
10
|
module Core
|
|
8
|
-
|
|
9
|
-
module_function
|
|
10
|
-
|
|
11
|
-
def passes?(target, candidate, target_counts, threshold)
|
|
12
|
-
denominator = target.length + candidate.length
|
|
13
|
-
length_matches = [target.length, candidate.length].min
|
|
14
|
-
return false if (2.0 * length_matches / denominator) < threshold
|
|
15
|
-
|
|
16
|
-
candidate_counts = candidate.each_char.tally
|
|
17
|
-
character_matches = target_counts.sum { |character, count| [count, candidate_counts[character].to_i].min }
|
|
18
|
-
(2.0 * character_matches / denominator) >= threshold
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
|
|
11
|
+
# Resolves model extraction text to exact or fuzzy source intervals.
|
|
22
12
|
class Resolver
|
|
23
13
|
DEFAULT_FUZZY_THRESHOLD = 0.78
|
|
24
|
-
|
|
25
|
-
|
|
14
|
+
DEFAULT_MIN_COVERAGE = 0.70
|
|
15
|
+
DEFAULT_MIN_DENSITY = 0.50
|
|
16
|
+
MAX_FUZZY_RANGE_TOKENS = 20_000
|
|
17
|
+
FuzzySourceContext = Data.define(:tokens, :index)
|
|
26
18
|
|
|
27
19
|
def initialize(text:, tokenizer: UnicodeTokenizer.new, fuzzy_threshold: DEFAULT_FUZZY_THRESHOLD,
|
|
28
|
-
allow_overlaps: false, suppress_alignment_errors: true
|
|
20
|
+
allow_overlaps: false, suppress_alignment_errors: true,
|
|
21
|
+
min_coverage: DEFAULT_MIN_COVERAGE, min_density: DEFAULT_MIN_DENSITY)
|
|
29
22
|
@text = text
|
|
30
23
|
@tokenizer = tokenizer
|
|
31
24
|
@fuzzy_threshold = fuzzy_threshold
|
|
32
25
|
@allow_overlaps = allow_overlaps
|
|
33
26
|
@suppress_alignment_errors = suppress_alignment_errors
|
|
27
|
+
@min_coverage = min_coverage
|
|
28
|
+
@min_density = min_density
|
|
34
29
|
@tokens = tokenizer.tokenize(text)
|
|
30
|
+
@fuzzy_token_stream = FuzzyTokenStream.new(tokens)
|
|
31
|
+
@fuzzy_contexts = {}
|
|
35
32
|
end
|
|
36
33
|
|
|
37
34
|
def resolve(items, document_id: nil, preferred_interval: nil)
|
|
@@ -44,7 +41,8 @@ module LangExtract
|
|
|
44
41
|
|
|
45
42
|
private
|
|
46
43
|
|
|
47
|
-
attr_reader :text, :tokens, :fuzzy_threshold, :allow_overlaps, :suppress_alignment_errors
|
|
44
|
+
attr_reader :text, :tokens, :tokenizer, :fuzzy_threshold, :allow_overlaps, :suppress_alignment_errors,
|
|
45
|
+
:min_coverage, :min_density, :fuzzy_token_stream, :fuzzy_contexts
|
|
48
46
|
|
|
49
47
|
def resolve_one(item, index, document_id, preferred_interval, occupied)
|
|
50
48
|
hash = HashCoercion.stringify_keys(item)
|
|
@@ -88,39 +86,44 @@ module LangExtract
|
|
|
88
86
|
end
|
|
89
87
|
|
|
90
88
|
def exact_intervals_in_range(extraction_text, range)
|
|
89
|
+
intervals = case_sensitive_intervals(extraction_text, range)
|
|
90
|
+
return intervals unless intervals.empty?
|
|
91
|
+
|
|
92
|
+
case_insensitive_intervals(extraction_text, range)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def case_sensitive_intervals(extraction_text, range)
|
|
91
96
|
intervals = []
|
|
92
97
|
cursor = range.begin
|
|
93
|
-
|
|
94
|
-
while cursor < range_end
|
|
98
|
+
while cursor < range.end
|
|
95
99
|
match_pos = text.index(extraction_text, cursor)
|
|
96
|
-
break unless match_pos && match_pos <
|
|
100
|
+
break unless match_pos && match_pos < range.end
|
|
97
101
|
|
|
98
102
|
end_pos = match_pos + extraction_text.length
|
|
99
|
-
intervals << CharInterval.new(start_pos: match_pos, end_pos: end_pos) if end_pos <=
|
|
103
|
+
intervals << CharInterval.new(start_pos: match_pos, end_pos: end_pos) if end_pos <= range.end
|
|
100
104
|
cursor = match_pos + 1
|
|
101
105
|
end
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
cursor = range.begin
|
|
105
|
-
while (match = pattern.match(text, cursor)) && match.begin(0) < range_end
|
|
106
|
-
intervals << CharInterval.new(start_pos: match.begin(0), end_pos: match.end(0)) if match.end(0) <= range_end
|
|
107
|
-
cursor = match.begin(0) + 1
|
|
108
|
-
end
|
|
109
|
-
end
|
|
106
|
+
intervals
|
|
107
|
+
end
|
|
110
108
|
|
|
109
|
+
def case_insensitive_intervals(extraction_text, range)
|
|
110
|
+
intervals = []
|
|
111
|
+
pattern = Regexp.new(Regexp.escape(extraction_text), Regexp::IGNORECASE)
|
|
112
|
+
cursor = range.begin
|
|
113
|
+
while (match = pattern.match(text, cursor)) && match.begin(0) < range.end
|
|
114
|
+
intervals << CharInterval.new(start_pos: match.begin(0), end_pos: match.end(0)) if match.end(0) <= range.end
|
|
115
|
+
cursor = match.begin(0) + 1
|
|
116
|
+
end
|
|
111
117
|
intervals
|
|
112
118
|
end
|
|
113
119
|
|
|
114
120
|
def find_fuzzy(extraction_text, preferred_interval, occupied)
|
|
115
|
-
|
|
116
|
-
return nil if
|
|
121
|
+
target_tokens = tokenize_extraction(extraction_text)
|
|
122
|
+
return nil if target_tokens.empty?
|
|
117
123
|
|
|
118
|
-
target_counts = target.each_char.tally
|
|
119
124
|
overlap_fallback = nil
|
|
120
|
-
|
|
121
125
|
candidate_search_ranges(preferred_interval).each do |range|
|
|
122
|
-
candidates =
|
|
123
|
-
candidates = ranked_unique_candidates(candidates)
|
|
126
|
+
candidates = fuzzy_candidates(target_tokens, range, occupied)
|
|
124
127
|
overlap_fallback ||= candidates.first&.first
|
|
125
128
|
non_overlap = candidates.find { |interval, _score| allow_overlaps || !overlaps_any?(interval, occupied) }
|
|
126
129
|
return [non_overlap.first, AlignmentStatus::FUZZY] if non_overlap
|
|
@@ -129,54 +132,49 @@ module LangExtract
|
|
|
129
132
|
overlap_fallback && [overlap_fallback, AlignmentStatus::OVERLAP]
|
|
130
133
|
end
|
|
131
134
|
|
|
132
|
-
def
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
max_length = [(target_length * 1.35).ceil, min_length].max
|
|
136
|
-
candidates = []
|
|
135
|
+
def fuzzy_candidates(target_tokens, range, occupied)
|
|
136
|
+
context = fuzzy_context(range)
|
|
137
|
+
return [] unless context
|
|
137
138
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
139
|
+
FuzzyAligner.new(
|
|
140
|
+
source_tokens: context.tokens,
|
|
141
|
+
fuzzy_threshold: fuzzy_threshold,
|
|
142
|
+
min_coverage: min_coverage,
|
|
143
|
+
min_density: min_density,
|
|
144
|
+
allow_overlaps: allow_overlaps,
|
|
145
|
+
alignment_index: context.index
|
|
146
|
+
).candidates(target_tokens, range, occupied)
|
|
147
|
+
end
|
|
147
148
|
|
|
148
|
-
|
|
149
|
-
|
|
149
|
+
def fuzzy_context(range)
|
|
150
|
+
key = [range.begin, range.end]
|
|
151
|
+
return fuzzy_contexts[key] if fuzzy_contexts.key?(key)
|
|
150
152
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
153
|
+
# Skip the entire oversized range instead of truncating candidate
|
|
154
|
+
# starts; preferred chunk ranges and exact alignment remain available.
|
|
155
|
+
return fuzzy_contexts[key] = nil if canonical_token_count(range) > MAX_FUZZY_RANGE_TOKENS
|
|
154
156
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
end
|
|
157
|
+
source_tokens = fuzzy_token_stream.tokens_in(range)
|
|
158
|
+
return fuzzy_contexts[key] = nil if source_tokens.empty? || source_tokens.length > MAX_FUZZY_RANGE_TOKENS
|
|
158
159
|
|
|
159
|
-
|
|
160
|
+
fuzzy_contexts[key] = FuzzySourceContext.new(
|
|
161
|
+
tokens: source_tokens,
|
|
162
|
+
index: FuzzyAlignmentIndex.new(source_tokens)
|
|
163
|
+
)
|
|
160
164
|
end
|
|
161
165
|
|
|
162
|
-
def
|
|
163
|
-
|
|
164
|
-
start_pos
|
|
165
|
-
|
|
166
|
-
end.uniq
|
|
167
|
-
|
|
168
|
-
return starts.first(MAX_FUZZY_CANDIDATE_STARTS) unless starts.empty?
|
|
169
|
-
|
|
170
|
-
(range.begin...range.end).reject { |position| text[position].match?(/\s/) }.first(MAX_FUZZY_CANDIDATE_STARTS)
|
|
166
|
+
def canonical_token_count(range)
|
|
167
|
+
tokens.count do |token|
|
|
168
|
+
token.char_interval.start_pos >= range.begin && token.char_interval.end_pos <= range.end
|
|
169
|
+
end
|
|
171
170
|
end
|
|
172
171
|
|
|
173
|
-
def
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
172
|
+
def tokenize_extraction(extraction_text)
|
|
173
|
+
target_tokens = tokenizer.tokenize(extraction_text.unicode_normalize(:nfc))
|
|
174
|
+
FuzzyTokenStream.new(target_tokens).tokens_in(0...extraction_text.length).filter_map do |token|
|
|
175
|
+
normalized = TokenSimilarity.normalize(token.text)
|
|
176
|
+
normalized unless normalized.empty?
|
|
177
177
|
end
|
|
178
|
-
|
|
179
|
-
best_by_interval.values.sort_by { |interval, score| [-score, interval.start_pos, interval.end_pos] }
|
|
180
178
|
end
|
|
181
179
|
|
|
182
180
|
def candidate_search_ranges(preferred_interval)
|
|
@@ -215,64 +213,6 @@ module LangExtract
|
|
|
215
213
|
def overlap_status?(status)
|
|
216
214
|
status == AlignmentStatus::OVERLAP
|
|
217
215
|
end
|
|
218
|
-
|
|
219
|
-
def normalize_for_match(value)
|
|
220
|
-
value.to_s.unicode_normalize(:nfc).downcase.gsub(/\s+/, " ").strip
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
def similarity(left, right)
|
|
224
|
-
return 1.0 if left == right
|
|
225
|
-
return 0.0 if left.empty? || right.empty?
|
|
226
|
-
|
|
227
|
-
matches = sequence_match_count(left.each_char.to_a, right.each_char.to_a)
|
|
228
|
-
(2.0 * matches) / (left.length + right.length)
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
def sequence_match_count(left_chars, right_chars, left_range = 0...left_chars.length,
|
|
232
|
-
right_range = 0...right_chars.length)
|
|
233
|
-
match = longest_common_substring(left_chars, right_chars, left_range, right_range)
|
|
234
|
-
return 0 unless match.span_length.positive?
|
|
235
|
-
|
|
236
|
-
left_count = sequence_match_count(
|
|
237
|
-
left_chars,
|
|
238
|
-
right_chars,
|
|
239
|
-
left_range.begin...match.left_start,
|
|
240
|
-
right_range.begin...match.right_start
|
|
241
|
-
)
|
|
242
|
-
right_count = sequence_match_count(
|
|
243
|
-
left_chars,
|
|
244
|
-
right_chars,
|
|
245
|
-
(match.left_start + match.span_length)...left_range.end,
|
|
246
|
-
(match.right_start + match.span_length)...right_range.end
|
|
247
|
-
)
|
|
248
|
-
|
|
249
|
-
match.span_length + left_count + right_count
|
|
250
|
-
end
|
|
251
|
-
|
|
252
|
-
def longest_common_substring(left_chars, right_chars, left_range, right_range)
|
|
253
|
-
best = Match.new(left_start: left_range.begin, right_start: right_range.begin, span_length: 0)
|
|
254
|
-
previous_lengths = Array.new(right_range.size + 1, 0)
|
|
255
|
-
|
|
256
|
-
left_range.each do |left_index|
|
|
257
|
-
current_lengths = Array.new(right_range.size + 1, 0)
|
|
258
|
-
right_range.each_with_index do |right_index, offset|
|
|
259
|
-
next unless left_chars[left_index] == right_chars[right_index]
|
|
260
|
-
|
|
261
|
-
length = previous_lengths[offset] + 1
|
|
262
|
-
current_lengths[offset + 1] = length
|
|
263
|
-
next unless length > best.span_length
|
|
264
|
-
|
|
265
|
-
best = Match.new(
|
|
266
|
-
left_start: left_index - length + 1,
|
|
267
|
-
right_start: right_index - length + 1,
|
|
268
|
-
span_length: length
|
|
269
|
-
)
|
|
270
|
-
end
|
|
271
|
-
previous_lengths = current_lengths
|
|
272
|
-
end
|
|
273
|
-
|
|
274
|
-
best
|
|
275
|
-
end
|
|
276
216
|
end
|
|
277
217
|
end
|
|
278
218
|
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LangExtract
|
|
4
|
+
module Core
|
|
5
|
+
# Per-token similarity gate used by the token-level fuzzy aligner.
|
|
6
|
+
# Prevents near-word substitutions (humane→human, chart→cart) by
|
|
7
|
+
# requiring both a length-ratio floor and a character-similarity floor.
|
|
8
|
+
module TokenSimilarity
|
|
9
|
+
MIN_LENGTH_RATIO = 0.85
|
|
10
|
+
MIN_CHAR_SIMILARITY = 0.78
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def similar?(target_token, source_token)
|
|
15
|
+
target = normalize(target_token)
|
|
16
|
+
source = normalize(source_token)
|
|
17
|
+
return true if target == source
|
|
18
|
+
return false if target.empty? || source.empty?
|
|
19
|
+
|
|
20
|
+
# Short one-edit substitutions are useful when they are anchored by
|
|
21
|
+
# another exact token in the same extraction. The alignment policy enforces
|
|
22
|
+
# that contextual anchor; keeping the broader lexical gate here lets
|
|
23
|
+
# it consider those candidates without weakening single-token
|
|
24
|
+
# grounding.
|
|
25
|
+
return true if short_typo?(target, source)
|
|
26
|
+
|
|
27
|
+
ratio = [target.length, source.length].min.to_f / [target.length, source.length].max
|
|
28
|
+
return false if ratio < MIN_LENGTH_RATIO
|
|
29
|
+
|
|
30
|
+
char_similarity(target, source) >= MIN_CHAR_SIMILARITY
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def normalize(value)
|
|
34
|
+
normalized = value.to_s.unicode_normalize(:nfc).downcase
|
|
35
|
+
normalized.gsub(/[\u0027\u2019]/u, "").gsub(/\s+/, " ").strip
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# A one-character edit is intentionally limited to short words. Long
|
|
39
|
+
# near-words (for example `human`/`humane`) remain subject to the
|
|
40
|
+
# stricter ratio and character-similarity gates above.
|
|
41
|
+
def short_typo?(target_token, source_token)
|
|
42
|
+
target = normalize(target_token)
|
|
43
|
+
source = normalize(source_token)
|
|
44
|
+
return false if target.empty? || source.empty?
|
|
45
|
+
return false if target == source
|
|
46
|
+
return false if target.length < 3 || source.length < 3
|
|
47
|
+
return false if [target.length, source.length].max > 5
|
|
48
|
+
|
|
49
|
+
edit_distance_at_most_one?(target, source)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def edit_distance_at_most_one?(left, right)
|
|
53
|
+
return true if left == right
|
|
54
|
+
return false if (left.length - right.length).abs > 1
|
|
55
|
+
|
|
56
|
+
return equal_length_one_mismatch?(left, right) if left.length == right.length
|
|
57
|
+
|
|
58
|
+
one_insertion_or_deletion?(left, right)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def equal_length_one_mismatch?(left, right)
|
|
62
|
+
left.each_char.zip(right.each_char).count { |a, b| a != b } <= 1
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def one_insertion_or_deletion?(left, right)
|
|
66
|
+
shorter, longer = left.length < right.length ? [left, right] : [right, left]
|
|
67
|
+
shorter_chars = shorter.each_char.to_a
|
|
68
|
+
longer_chars = longer.each_char.to_a
|
|
69
|
+
short_index = 0
|
|
70
|
+
long_index = 0
|
|
71
|
+
edits = 0
|
|
72
|
+
|
|
73
|
+
# Keep this bounded to one edit. The early exits make the routine
|
|
74
|
+
# linear in the short token length and avoid allocating a matrix.
|
|
75
|
+
while short_index < shorter.length && long_index < longer.length
|
|
76
|
+
if shorter_chars[short_index] == longer_chars[long_index]
|
|
77
|
+
short_index += 1
|
|
78
|
+
else
|
|
79
|
+
edits += 1
|
|
80
|
+
return false if edits > 1
|
|
81
|
+
end
|
|
82
|
+
long_index += 1
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
edits + (longer.length - long_index) <= 1
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def char_similarity(left, right)
|
|
89
|
+
return 1.0 if left == right
|
|
90
|
+
return 0.0 if left.empty? || right.empty?
|
|
91
|
+
|
|
92
|
+
matches = SequenceMatcher.match_count(left.each_char.to_a, right.each_char.to_a)
|
|
93
|
+
(2.0 * matches) / (left.length + right.length)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Longest-common-substring sequence matcher used for per-token character
|
|
98
|
+
# similarity scoring (equivalent to Python's SequenceMatcher.ratio()).
|
|
99
|
+
module SequenceMatcher
|
|
100
|
+
Match = Data.define(:left_start, :right_start, :span_length)
|
|
101
|
+
|
|
102
|
+
module_function
|
|
103
|
+
|
|
104
|
+
def match_count(left_chars, right_chars)
|
|
105
|
+
recursive_match_count(left_chars, right_chars, 0...left_chars.length, 0...right_chars.length)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def recursive_match_count(left_chars, right_chars, left_range, right_range)
|
|
109
|
+
match = longest_common_substring(left_chars, right_chars, left_range, right_range)
|
|
110
|
+
return 0 unless match.span_length.positive?
|
|
111
|
+
|
|
112
|
+
left_count = recursive_match_count(
|
|
113
|
+
left_chars, right_chars,
|
|
114
|
+
left_range.begin...match.left_start,
|
|
115
|
+
right_range.begin...match.right_start
|
|
116
|
+
)
|
|
117
|
+
right_count = recursive_match_count(
|
|
118
|
+
left_chars, right_chars,
|
|
119
|
+
(match.left_start + match.span_length)...left_range.end,
|
|
120
|
+
(match.right_start + match.span_length)...right_range.end
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
match.span_length + left_count + right_count
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def longest_common_substring(left_chars, right_chars, left_range, right_range)
|
|
127
|
+
best = Match.new(left_start: left_range.begin, right_start: right_range.begin, span_length: 0)
|
|
128
|
+
previous_lengths = Array.new(right_range.size + 1, 0)
|
|
129
|
+
|
|
130
|
+
left_range.each do |left_index|
|
|
131
|
+
current_lengths = Array.new(right_range.size + 1, 0)
|
|
132
|
+
right_range.each_with_index do |right_index, offset|
|
|
133
|
+
next unless left_chars[left_index] == right_chars[right_index]
|
|
134
|
+
|
|
135
|
+
length = previous_lengths[offset] + 1
|
|
136
|
+
current_lengths[offset + 1] = length
|
|
137
|
+
next unless length > best.span_length
|
|
138
|
+
|
|
139
|
+
best = Match.new(
|
|
140
|
+
left_start: left_index - length + 1,
|
|
141
|
+
right_start: right_index - length + 1,
|
|
142
|
+
span_length: length
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
previous_lengths = current_lengths
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
best
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
data/lib/langextract/factory.rb
CHANGED
|
@@ -7,12 +7,13 @@ require_relative "providers/ruby_llm"
|
|
|
7
7
|
|
|
8
8
|
module LangExtract
|
|
9
9
|
class ModelConfig
|
|
10
|
-
attr_reader :adapter, :provider, :model, :options
|
|
10
|
+
attr_reader :adapter, :provider, :model, :options, :structured_output
|
|
11
11
|
|
|
12
|
-
def initialize(adapter: "ruby_llm", provider: nil, model: nil, **options)
|
|
12
|
+
def initialize(adapter: "ruby_llm", provider: nil, model: nil, structured_output: false, **options)
|
|
13
13
|
@adapter = adapter.to_s
|
|
14
14
|
@provider = provider&.to_s
|
|
15
15
|
@model = model || LangExtract.config.default_model
|
|
16
|
+
@structured_output = structured_output
|
|
16
17
|
@options = options.freeze
|
|
17
18
|
|
|
18
19
|
validate!
|
|
@@ -24,6 +25,7 @@ module LangExtract
|
|
|
24
25
|
"adapter" => adapter,
|
|
25
26
|
"provider" => provider,
|
|
26
27
|
"model" => model,
|
|
28
|
+
"structured_output" => structured_output,
|
|
27
29
|
"options" => options
|
|
28
30
|
}
|
|
29
31
|
end
|
|
@@ -34,6 +36,10 @@ module LangExtract
|
|
|
34
36
|
raise Core::InvalidModelConfigError, "adapter is required" if adapter.empty?
|
|
35
37
|
raise Core::InvalidModelConfigError, "provider cannot be blank" if provider == ""
|
|
36
38
|
raise Core::InvalidModelConfigError, "model cannot be blank" if model == ""
|
|
39
|
+
return if [true, false].include?(structured_output)
|
|
40
|
+
|
|
41
|
+
raise Core::InvalidModelConfigError,
|
|
42
|
+
"structured_output must be a boolean, got: #{structured_output.inspect}"
|
|
37
43
|
end
|
|
38
44
|
end
|
|
39
45
|
|
|
@@ -2,15 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
4
|
require_relative "../core/types"
|
|
5
|
+
require "json"
|
|
5
6
|
require "timeout"
|
|
6
7
|
|
|
7
8
|
module LangExtract
|
|
8
9
|
module Providers
|
|
9
10
|
class RubyLLMProvider < Base
|
|
11
|
+
INTERNAL_EXTRACTION_SCHEMA = {
|
|
12
|
+
"type" => "object",
|
|
13
|
+
"properties" => {
|
|
14
|
+
"extractions" => {
|
|
15
|
+
"type" => "array",
|
|
16
|
+
"items" => {
|
|
17
|
+
"type" => "object",
|
|
18
|
+
"properties" => {
|
|
19
|
+
"text" => { "type" => "string" },
|
|
20
|
+
"extraction_class" => { "type" => "string" },
|
|
21
|
+
"description" => { "type" => "string" },
|
|
22
|
+
"attributes" => { "type" => "object" },
|
|
23
|
+
"group_id" => { "type" => "string" }
|
|
24
|
+
},
|
|
25
|
+
"required" => ["text"]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"required" => ["extractions"]
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
10
32
|
def infer(prompt:)
|
|
11
33
|
require "ruby_llm"
|
|
12
34
|
|
|
13
|
-
response =
|
|
35
|
+
response = build_chat.ask(prompt)
|
|
14
36
|
InferenceResult.new(text: extract_text(response), raw: response)
|
|
15
37
|
rescue LoadError => e
|
|
16
38
|
raise Core::ProviderConfigError, "ruby_llm is required for live provider inference: #{e.message}"
|
|
@@ -23,6 +45,12 @@ module LangExtract
|
|
|
23
45
|
|
|
24
46
|
private
|
|
25
47
|
|
|
48
|
+
def build_chat
|
|
49
|
+
chat = RubyLLM.chat(**chat_options)
|
|
50
|
+
chat.with_schema(INTERNAL_EXTRACTION_SCHEMA) if config.structured_output
|
|
51
|
+
chat
|
|
52
|
+
end
|
|
53
|
+
|
|
26
54
|
def provider_error_class(error)
|
|
27
55
|
return Core::ProviderTimeoutError if timeout_error?(error)
|
|
28
56
|
return Core::ProviderAuthError if ruby_llm_error?(error, :UnauthorizedError, :ForbiddenError,
|
|
@@ -57,7 +85,9 @@ module LangExtract
|
|
|
57
85
|
end
|
|
58
86
|
|
|
59
87
|
def extract_text(response)
|
|
60
|
-
|
|
88
|
+
content = response.content
|
|
89
|
+
return JSON.generate(content) if content.is_a?(Hash) || content.is_a?(Array)
|
|
90
|
+
return content.to_s if response.respond_to?(:content)
|
|
61
91
|
return response.text.to_s if response.respond_to?(:text)
|
|
62
92
|
|
|
63
93
|
response.to_s
|
data/lib/langextract/version.rb
CHANGED