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.
@@ -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
- module SimilarityUpperBound
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
- MAX_FUZZY_CANDIDATE_STARTS = 4_000
25
- Match = Data.define(:left_start, :right_start, :span_length)
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
- range_end = range.end
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 < range_end
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 <= range_end
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
- if intervals.empty?
103
- pattern = Regexp.new(Regexp.escape(extraction_text), Regexp::IGNORECASE)
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
- target = normalize_for_match(extraction_text)
116
- return nil if target.empty?
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 = fuzzy_candidates_in_range(extraction_text, target, target_counts, range, occupied)
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 fuzzy_candidates_in_range(extraction_text, normalized_target, target_counts, range, occupied)
133
- target_length = extraction_text.length
134
- min_length = [1, (target_length * 0.65).floor].max
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
- candidate_start_positions(range).each do |start_pos|
139
- (min_length..max_length).each do |length|
140
- end_pos = start_pos + length
141
- next if end_pos > range.end
142
-
143
- candidate = text[start_pos...end_pos]
144
- normalized_candidate = normalize_for_match(candidate)
145
- next unless SimilarityUpperBound.passes?(normalized_target, normalized_candidate, target_counts,
146
- fuzzy_threshold)
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
- score = similarity(normalized_target, normalized_candidate)
149
- next if score < fuzzy_threshold
149
+ def fuzzy_context(range)
150
+ key = [range.begin, range.end]
151
+ return fuzzy_contexts[key] if fuzzy_contexts.key?(key)
150
152
 
151
- match = [CharInterval.new(start_pos: start_pos, end_pos: end_pos), score]
152
- interval = match.first
153
- return [match] if score >= 1.0 && (allow_overlaps || !overlaps_any?(interval, occupied))
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
- candidates << match
156
- end
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
- candidates
160
+ fuzzy_contexts[key] = FuzzySourceContext.new(
161
+ tokens: source_tokens,
162
+ index: FuzzyAlignmentIndex.new(source_tokens)
163
+ )
160
164
  end
161
165
 
162
- def candidate_start_positions(range)
163
- starts = tokens.filter_map do |token|
164
- start_pos = token.char_interval.start_pos
165
- start_pos if start_pos >= range.begin && start_pos < range.end
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 ranked_unique_candidates(candidates)
174
- best_by_interval = candidates.each_with_object({}) do |(interval, score), result|
175
- key = [interval.start_pos, interval.end_pos]
176
- result[key] = [interval, score] if result[key].nil? || score > result[key].last
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
@@ -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 = RubyLLM.chat(**chat_options).ask(prompt)
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
- return response.content.to_s if response.respond_to?(:content)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LangExtract
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end