langextract 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/CHANGELOG.md +9 -0
- data/LICENSE.txt +21 -0
- data/README.md +250 -0
- data/lib/langextract/config.rb +20 -0
- data/lib/langextract/core/annotation.rb +36 -0
- data/lib/langextract/core/chunking.rb +116 -0
- data/lib/langextract/core/data.rb +271 -0
- data/lib/langextract/core/errors.rb +13 -0
- data/lib/langextract/core/format_handler.rb +154 -0
- data/lib/langextract/core/prompt_validation.rb +52 -0
- data/lib/langextract/core/prompting.rb +72 -0
- data/lib/langextract/core/resolver.rb +261 -0
- data/lib/langextract/core/tokenizer.rb +50 -0
- data/lib/langextract/core/types.rb +21 -0
- data/lib/langextract/errors.rb +13 -0
- data/lib/langextract/extractor.rb +158 -0
- data/lib/langextract/factory.rb +61 -0
- data/lib/langextract/io.rb +35 -0
- data/lib/langextract/providers/base.rb +21 -0
- data/lib/langextract/providers/router.rb +35 -0
- data/lib/langextract/providers/ruby_llm.rb +37 -0
- data/lib/langextract/version.rb +5 -0
- data/lib/langextract/visualization.rb +106 -0
- data/lib/langextract.rb +53 -0
- metadata +74 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "data"
|
|
4
|
+
require_relative "tokenizer"
|
|
5
|
+
|
|
6
|
+
module LangExtract
|
|
7
|
+
module Core
|
|
8
|
+
class Resolver
|
|
9
|
+
DEFAULT_FUZZY_THRESHOLD = 0.78
|
|
10
|
+
MAX_FUZZY_CANDIDATE_STARTS = 4_000
|
|
11
|
+
Match = Data.define(:left_start, :right_start, :span_length)
|
|
12
|
+
|
|
13
|
+
def initialize(text:, tokenizer: UnicodeTokenizer.new, fuzzy_threshold: DEFAULT_FUZZY_THRESHOLD,
|
|
14
|
+
allow_overlaps: false, suppress_alignment_errors: true)
|
|
15
|
+
@text = text
|
|
16
|
+
@tokenizer = tokenizer
|
|
17
|
+
@fuzzy_threshold = fuzzy_threshold
|
|
18
|
+
@allow_overlaps = allow_overlaps
|
|
19
|
+
@suppress_alignment_errors = suppress_alignment_errors
|
|
20
|
+
@tokens = tokenizer.tokenize(text)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def resolve(items, document_id: nil, preferred_interval: nil)
|
|
24
|
+
occupied = []
|
|
25
|
+
|
|
26
|
+
items.map.with_index do |item, index|
|
|
27
|
+
resolve_one(item, index, document_id, preferred_interval, occupied)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
attr_reader :text, :tokens, :fuzzy_threshold, :allow_overlaps, :suppress_alignment_errors
|
|
34
|
+
|
|
35
|
+
def resolve_one(item, index, document_id, preferred_interval, occupied)
|
|
36
|
+
hash = HashCoercion.stringify_keys(item)
|
|
37
|
+
extraction_text = hash.fetch("text").to_s
|
|
38
|
+
|
|
39
|
+
interval, status = find_interval(extraction_text, preferred_interval, occupied)
|
|
40
|
+
occupied << interval if interval && !overlap_status?(status)
|
|
41
|
+
|
|
42
|
+
build_extraction(hash, extraction_text, interval, status, index, document_id)
|
|
43
|
+
rescue AlignmentError
|
|
44
|
+
raise unless suppress_alignment_errors
|
|
45
|
+
|
|
46
|
+
build_extraction(hash || {}, extraction_text || "", nil, AlignmentStatus::ERROR, index, document_id)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def find_interval(extraction_text, preferred_interval, occupied)
|
|
50
|
+
return [nil, AlignmentStatus::UNGROUNDED] if extraction_text.strip.empty?
|
|
51
|
+
|
|
52
|
+
exact = find_exact(extraction_text, preferred_interval, occupied)
|
|
53
|
+
return exact if exact
|
|
54
|
+
|
|
55
|
+
fuzzy = find_fuzzy(extraction_text, preferred_interval, occupied)
|
|
56
|
+
return fuzzy if fuzzy
|
|
57
|
+
|
|
58
|
+
raise AlignmentError, "could not align extraction: #{extraction_text}" unless suppress_alignment_errors
|
|
59
|
+
|
|
60
|
+
[nil, AlignmentStatus::UNGROUNDED]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def find_exact(extraction_text, preferred_interval, occupied)
|
|
64
|
+
intervals = candidate_search_ranges(preferred_interval).flat_map do |range|
|
|
65
|
+
exact_intervals_in_range(extraction_text, range)
|
|
66
|
+
end
|
|
67
|
+
intervals = intervals.uniq
|
|
68
|
+
return nil if intervals.empty?
|
|
69
|
+
|
|
70
|
+
first_non_overlap = intervals.find { |interval| allow_overlaps || !overlaps_any?(interval, occupied) }
|
|
71
|
+
return [first_non_overlap, AlignmentStatus::EXACT] if first_non_overlap
|
|
72
|
+
|
|
73
|
+
[intervals.first, AlignmentStatus::OVERLAP]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def exact_intervals_in_range(extraction_text, range)
|
|
77
|
+
intervals = []
|
|
78
|
+
cursor = range.begin
|
|
79
|
+
|
|
80
|
+
while cursor < range.end
|
|
81
|
+
match_pos = text.index(extraction_text, cursor)
|
|
82
|
+
break unless match_pos && match_pos < range.end
|
|
83
|
+
|
|
84
|
+
end_pos = match_pos + extraction_text.length
|
|
85
|
+
intervals << CharInterval.new(start_pos: match_pos, end_pos: end_pos) if end_pos <= range.end
|
|
86
|
+
cursor = match_pos + 1
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
if intervals.empty?
|
|
90
|
+
downcase_text = text[range].downcase
|
|
91
|
+
downcase_target = extraction_text.downcase
|
|
92
|
+
local_pos = downcase_text.index(downcase_target)
|
|
93
|
+
if local_pos
|
|
94
|
+
intervals << CharInterval.new(
|
|
95
|
+
start_pos: range.begin + local_pos,
|
|
96
|
+
end_pos: range.begin + local_pos + extraction_text.length
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
intervals
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def find_fuzzy(extraction_text, preferred_interval, occupied)
|
|
105
|
+
target = normalize_for_match(extraction_text)
|
|
106
|
+
return nil if target.empty?
|
|
107
|
+
|
|
108
|
+
candidates = candidate_search_ranges(preferred_interval).flat_map do |range|
|
|
109
|
+
fuzzy_candidates_in_range(extraction_text, target, range)
|
|
110
|
+
end
|
|
111
|
+
candidates = ranked_unique_candidates(candidates)
|
|
112
|
+
return nil if candidates.empty?
|
|
113
|
+
|
|
114
|
+
non_overlap = candidates.find { |interval, _score| allow_overlaps || !overlaps_any?(interval, occupied) }
|
|
115
|
+
return [non_overlap.first, AlignmentStatus::FUZZY] if non_overlap
|
|
116
|
+
|
|
117
|
+
[candidates.first.first, AlignmentStatus::OVERLAP]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def fuzzy_candidates_in_range(extraction_text, normalized_target, range)
|
|
121
|
+
target_length = extraction_text.length
|
|
122
|
+
min_length = [1, (target_length * 0.65).floor].max
|
|
123
|
+
max_length = [(target_length * 1.35).ceil, min_length].max
|
|
124
|
+
candidates = []
|
|
125
|
+
|
|
126
|
+
candidate_start_positions(range).each do |start_pos|
|
|
127
|
+
(min_length..max_length).each do |length|
|
|
128
|
+
end_pos = start_pos + length
|
|
129
|
+
next if end_pos > range.end
|
|
130
|
+
|
|
131
|
+
candidate = text[start_pos...end_pos]
|
|
132
|
+
score = similarity(normalized_target, normalize_for_match(candidate))
|
|
133
|
+
next if score < fuzzy_threshold
|
|
134
|
+
|
|
135
|
+
candidates << [
|
|
136
|
+
CharInterval.new(start_pos: start_pos, end_pos: end_pos),
|
|
137
|
+
score
|
|
138
|
+
]
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
candidates
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def candidate_start_positions(range)
|
|
146
|
+
starts = tokens.filter_map do |token|
|
|
147
|
+
start_pos = token.char_interval.start_pos
|
|
148
|
+
start_pos if start_pos >= range.begin && start_pos < range.end
|
|
149
|
+
end.uniq
|
|
150
|
+
|
|
151
|
+
return starts.first(MAX_FUZZY_CANDIDATE_STARTS) unless starts.empty?
|
|
152
|
+
|
|
153
|
+
(range.begin...range.end).reject { |position| text[position].match?(/\s/) }.first(MAX_FUZZY_CANDIDATE_STARTS)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def ranked_unique_candidates(candidates)
|
|
157
|
+
best_by_interval = candidates.each_with_object({}) do |(interval, score), result|
|
|
158
|
+
key = [interval.start_pos, interval.end_pos]
|
|
159
|
+
result[key] = [interval, score] if result[key].nil? || score > result[key].last
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
best_by_interval.values.sort_by { |interval, score| [-score, interval.start_pos, interval.end_pos] }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def candidate_search_ranges(preferred_interval)
|
|
166
|
+
ranges = []
|
|
167
|
+
ranges << (preferred_interval.start_pos...preferred_interval.end_pos) if preferred_interval
|
|
168
|
+
ranges << (0...text.length)
|
|
169
|
+
ranges
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def build_extraction(hash, extraction_text, interval, status, index, document_id)
|
|
173
|
+
Extraction.new(
|
|
174
|
+
extraction_class: hash["extraction_class"],
|
|
175
|
+
text: extraction_text,
|
|
176
|
+
description: hash["description"],
|
|
177
|
+
attributes: hash["attributes"] || {},
|
|
178
|
+
char_interval: interval,
|
|
179
|
+
token_interval: interval ? token_interval_for(interval) : nil,
|
|
180
|
+
alignment_status: status,
|
|
181
|
+
extraction_index: index,
|
|
182
|
+
group_id: hash["group_id"],
|
|
183
|
+
document_id: document_id
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def token_interval_for(char_interval)
|
|
188
|
+
matching = tokens.select { |token| token.char_interval.overlaps?(char_interval) }
|
|
189
|
+
return nil if matching.empty?
|
|
190
|
+
|
|
191
|
+
TokenInterval.new(start_pos: matching.first.index, end_pos: matching.last.index + 1)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def overlaps_any?(interval, occupied)
|
|
195
|
+
occupied.any? { |other| interval.overlaps?(other) }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def overlap_status?(status)
|
|
199
|
+
status == AlignmentStatus::OVERLAP
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def normalize_for_match(value)
|
|
203
|
+
value.to_s.unicode_normalize(:nfc).downcase.gsub(/\s+/, " ").strip
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def similarity(left, right)
|
|
207
|
+
return 1.0 if left == right
|
|
208
|
+
return 0.0 if left.empty? || right.empty?
|
|
209
|
+
|
|
210
|
+
matches = sequence_match_count(left.each_char.to_a, right.each_char.to_a)
|
|
211
|
+
(2.0 * matches) / (left.length + right.length)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def sequence_match_count(left_chars, right_chars, left_range = 0...left_chars.length,
|
|
215
|
+
right_range = 0...right_chars.length)
|
|
216
|
+
match = longest_common_substring(left_chars, right_chars, left_range, right_range)
|
|
217
|
+
return 0 unless match.span_length.positive?
|
|
218
|
+
|
|
219
|
+
left_count = sequence_match_count(
|
|
220
|
+
left_chars,
|
|
221
|
+
right_chars,
|
|
222
|
+
left_range.begin...match.left_start,
|
|
223
|
+
right_range.begin...match.right_start
|
|
224
|
+
)
|
|
225
|
+
right_count = sequence_match_count(
|
|
226
|
+
left_chars,
|
|
227
|
+
right_chars,
|
|
228
|
+
(match.left_start + match.span_length)...left_range.end,
|
|
229
|
+
(match.right_start + match.span_length)...right_range.end
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
match.span_length + left_count + right_count
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def longest_common_substring(left_chars, right_chars, left_range, right_range)
|
|
236
|
+
best = Match.new(left_start: left_range.begin, right_start: right_range.begin, span_length: 0)
|
|
237
|
+
previous_lengths = Array.new(right_range.size + 1, 0)
|
|
238
|
+
|
|
239
|
+
left_range.each do |left_index|
|
|
240
|
+
current_lengths = Array.new(right_range.size + 1, 0)
|
|
241
|
+
right_range.each_with_index do |right_index, offset|
|
|
242
|
+
next unless left_chars[left_index] == right_chars[right_index]
|
|
243
|
+
|
|
244
|
+
length = previous_lengths[offset] + 1
|
|
245
|
+
current_lengths[offset + 1] = length
|
|
246
|
+
next unless length > best.span_length
|
|
247
|
+
|
|
248
|
+
best = Match.new(
|
|
249
|
+
left_start: left_index - length + 1,
|
|
250
|
+
right_start: right_index - length + 1,
|
|
251
|
+
span_length: length
|
|
252
|
+
)
|
|
253
|
+
end
|
|
254
|
+
previous_lengths = current_lengths
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
best
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "data"
|
|
4
|
+
|
|
5
|
+
module LangExtract
|
|
6
|
+
module Core
|
|
7
|
+
Token = Data.define(:text, :char_interval, :index) do
|
|
8
|
+
def to_h
|
|
9
|
+
{
|
|
10
|
+
"text" => text,
|
|
11
|
+
"char_interval" => char_interval.to_h,
|
|
12
|
+
"index" => index
|
|
13
|
+
}
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class RegexTokenizer
|
|
18
|
+
DEFAULT_PATTERN = /\S+/
|
|
19
|
+
|
|
20
|
+
def initialize(pattern: DEFAULT_PATTERN)
|
|
21
|
+
@pattern = pattern
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def tokenize(text)
|
|
25
|
+
tokens = []
|
|
26
|
+
text.to_enum(:scan, @pattern).each do
|
|
27
|
+
match = Regexp.last_match
|
|
28
|
+
tokens << Token.new(
|
|
29
|
+
text: match[0],
|
|
30
|
+
char_interval: CharInterval.new(start_pos: match.begin(0), end_pos: match.end(0)),
|
|
31
|
+
index: tokens.length
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
tokens.freeze
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class UnicodeTokenizer
|
|
39
|
+
TOKEN_PATTERN = /
|
|
40
|
+
\p{L}[\p{L}\p{M}\p{N}_'-]* |
|
|
41
|
+
\p{N}+(?:[.,]\p{N}+)* |
|
|
42
|
+
[^\s]
|
|
43
|
+
/ux
|
|
44
|
+
|
|
45
|
+
def tokenize(text)
|
|
46
|
+
RegexTokenizer.new(pattern: TOKEN_PATTERN).tokenize(text)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module LangExtract
|
|
6
|
+
module Core
|
|
7
|
+
module AlignmentStatus
|
|
8
|
+
EXACT = "exact"
|
|
9
|
+
FUZZY = "fuzzy"
|
|
10
|
+
UNGROUNDED = "ungrounded"
|
|
11
|
+
OVERLAP = "overlap"
|
|
12
|
+
ERROR = "error"
|
|
13
|
+
|
|
14
|
+
ALL = [EXACT, FUZZY, UNGROUNDED, OVERLAP, ERROR].freeze
|
|
15
|
+
|
|
16
|
+
def self.valid?(status)
|
|
17
|
+
ALL.include?(status)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "core/errors"
|
|
4
|
+
|
|
5
|
+
module LangExtract
|
|
6
|
+
Error = Core::Error
|
|
7
|
+
InvalidModelConfigError = Core::InvalidModelConfigError
|
|
8
|
+
ProviderConfigError = Core::ProviderConfigError
|
|
9
|
+
FormatParsingError = Core::FormatParsingError
|
|
10
|
+
PromptValidationError = Core::PromptValidationError
|
|
11
|
+
AlignmentError = Core::AlignmentError
|
|
12
|
+
IOFailure = Core::IOFailure
|
|
13
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "core/annotation"
|
|
4
|
+
require_relative "core/chunking"
|
|
5
|
+
require_relative "core/format_handler"
|
|
6
|
+
require_relative "core/prompting"
|
|
7
|
+
|
|
8
|
+
module LangExtract
|
|
9
|
+
class Extractor
|
|
10
|
+
DEFAULT_MAX_CHAR_BUFFER = Core::SentenceAwareChunker::DEFAULT_MAX_CHAR_BUFFER
|
|
11
|
+
|
|
12
|
+
def initialize(model:, prompt_description:, examples: [], additional_context: nil,
|
|
13
|
+
max_char_buffer: DEFAULT_MAX_CHAR_BUFFER, context_window_chars: 0,
|
|
14
|
+
extraction_passes: 1, format: :auto, strict: true,
|
|
15
|
+
prompt_validation: :warning, suppress_parse_errors: false,
|
|
16
|
+
suppress_alignment_errors: true, allow_overlaps: false,
|
|
17
|
+
fuzzy_threshold: Core::Resolver::DEFAULT_FUZZY_THRESHOLD,
|
|
18
|
+
tokenizer: Core::UnicodeTokenizer.new)
|
|
19
|
+
@model = model
|
|
20
|
+
@prompt_description = prompt_description
|
|
21
|
+
@examples = examples
|
|
22
|
+
@additional_context = additional_context
|
|
23
|
+
@max_char_buffer = max_char_buffer
|
|
24
|
+
@context_window_chars = context_window_chars
|
|
25
|
+
@extraction_passes = extraction_passes
|
|
26
|
+
@format = format
|
|
27
|
+
@strict = strict
|
|
28
|
+
@prompt_validation = prompt_validation
|
|
29
|
+
@suppress_parse_errors = suppress_parse_errors
|
|
30
|
+
@suppress_alignment_errors = suppress_alignment_errors
|
|
31
|
+
@allow_overlaps = allow_overlaps
|
|
32
|
+
@fuzzy_threshold = fuzzy_threshold
|
|
33
|
+
@tokenizer = tokenizer
|
|
34
|
+
validate_model!
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def extract(text: nil, documents: nil)
|
|
38
|
+
coerced_documents = coerce_documents(text, documents)
|
|
39
|
+
annotated = coerced_documents.map { |document| extract_document(document) }
|
|
40
|
+
|
|
41
|
+
documents.nil? && !text.nil? ? annotated.first : annotated
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
attr_reader :model, :prompt_description, :examples, :additional_context, :max_char_buffer,
|
|
47
|
+
:context_window_chars, :extraction_passes, :format, :strict, :prompt_validation,
|
|
48
|
+
:suppress_parse_errors, :suppress_alignment_errors, :allow_overlaps, :fuzzy_threshold,
|
|
49
|
+
:tokenizer
|
|
50
|
+
|
|
51
|
+
def extract_document(document)
|
|
52
|
+
chunker = Core::SentenceAwareChunker.new(max_char_buffer: max_char_buffer, tokenizer: tokenizer)
|
|
53
|
+
prompt_builder = Core::PromptBuilder.new(validation_mode: prompt_validation)
|
|
54
|
+
format_handler = Core::FormatHandler.new
|
|
55
|
+
extractions = []
|
|
56
|
+
|
|
57
|
+
chunker.chunks(document).each do |chunk|
|
|
58
|
+
extraction_passes.times do |pass_index|
|
|
59
|
+
raw_output = infer(prompt_builder, document, chunk, pass_index)
|
|
60
|
+
parsed = parse(format_handler, raw_output)
|
|
61
|
+
resolver = Core::Resolver.new(
|
|
62
|
+
text: document.text,
|
|
63
|
+
tokenizer: tokenizer,
|
|
64
|
+
fuzzy_threshold: fuzzy_threshold,
|
|
65
|
+
allow_overlaps: allow_overlaps,
|
|
66
|
+
suppress_alignment_errors: suppress_alignment_errors
|
|
67
|
+
)
|
|
68
|
+
extractions.concat(
|
|
69
|
+
resolver.resolve(parsed, document_id: document.id, preferred_interval: chunk.char_interval)
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
Core::AnnotatedDocument.new(document: document, extractions: merge_extractions(extractions))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def infer(prompt_builder, document, chunk, pass_index)
|
|
78
|
+
prompt = prompt_builder.build(
|
|
79
|
+
document: document,
|
|
80
|
+
chunk: chunk,
|
|
81
|
+
prompt_description: prompt_description,
|
|
82
|
+
examples: examples,
|
|
83
|
+
additional_context: additional_context,
|
|
84
|
+
context_window_chars: context_window_chars,
|
|
85
|
+
pass_index: pass_index,
|
|
86
|
+
total_passes: extraction_passes
|
|
87
|
+
)
|
|
88
|
+
call_model(prompt)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def call_model(prompt)
|
|
92
|
+
result = model.infer(prompt: prompt)
|
|
93
|
+
|
|
94
|
+
result.respond_to?(:text) ? result.text.to_s : result.to_s
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def parse(format_handler, raw_output)
|
|
98
|
+
format_handler.parse(raw_output, format: format, strict: strict)
|
|
99
|
+
rescue Core::FormatParsingError
|
|
100
|
+
raise unless suppress_parse_errors
|
|
101
|
+
|
|
102
|
+
[]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def merge_extractions(extractions)
|
|
106
|
+
occupied = []
|
|
107
|
+
seen = {}
|
|
108
|
+
|
|
109
|
+
extractions.filter_map do |extraction|
|
|
110
|
+
key = merge_key(extraction)
|
|
111
|
+
next if seen[key]
|
|
112
|
+
|
|
113
|
+
seen[key] = true
|
|
114
|
+
if extraction.grounded?
|
|
115
|
+
next if !allow_overlaps && occupied.any? { |interval| interval.overlaps?(extraction.char_interval) }
|
|
116
|
+
|
|
117
|
+
occupied << extraction.char_interval
|
|
118
|
+
end
|
|
119
|
+
extraction
|
|
120
|
+
end.freeze
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def merge_key(extraction)
|
|
124
|
+
interval = extraction.char_interval
|
|
125
|
+
[
|
|
126
|
+
extraction.document_id,
|
|
127
|
+
interval&.start_pos,
|
|
128
|
+
interval&.end_pos,
|
|
129
|
+
extraction.text,
|
|
130
|
+
extraction.description,
|
|
131
|
+
extraction.extraction_class
|
|
132
|
+
]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def coerce_documents(text, documents)
|
|
136
|
+
if documents
|
|
137
|
+
Array(documents).map.with_index { |document, index| coerce_document(document, index) }
|
|
138
|
+
elsif text
|
|
139
|
+
[Core::Document.new(text: text, id: "document_0")]
|
|
140
|
+
else
|
|
141
|
+
raise ArgumentError, "text or documents is required"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def coerce_document(document, index)
|
|
146
|
+
return document if document.is_a?(Core::Document)
|
|
147
|
+
return Core::Document.from_h(document) if document.is_a?(Hash)
|
|
148
|
+
|
|
149
|
+
Core::Document.new(text: document.to_s, id: "document_#{index}")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def validate_model!
|
|
153
|
+
return if model.respond_to?(:infer)
|
|
154
|
+
|
|
155
|
+
raise Core::InvalidModelConfigError, "model must respond to #infer(prompt:)"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
require_relative "core/errors"
|
|
5
|
+
require_relative "providers/router"
|
|
6
|
+
require_relative "providers/ruby_llm"
|
|
7
|
+
|
|
8
|
+
module LangExtract
|
|
9
|
+
class ModelConfig
|
|
10
|
+
attr_reader :adapter, :provider, :model, :options
|
|
11
|
+
|
|
12
|
+
def initialize(adapter: "ruby_llm", provider: nil, model: nil, **options)
|
|
13
|
+
@adapter = adapter.to_s
|
|
14
|
+
@provider = provider&.to_s
|
|
15
|
+
@model = model || LangExtract.config.default_model
|
|
16
|
+
@options = options.freeze
|
|
17
|
+
|
|
18
|
+
validate!
|
|
19
|
+
freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def to_h
|
|
23
|
+
{
|
|
24
|
+
"adapter" => adapter,
|
|
25
|
+
"provider" => provider,
|
|
26
|
+
"model" => model,
|
|
27
|
+
"options" => options
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def validate!
|
|
34
|
+
raise Core::InvalidModelConfigError, "adapter is required" if adapter.empty?
|
|
35
|
+
raise Core::InvalidModelConfigError, "provider cannot be blank" if provider == ""
|
|
36
|
+
raise Core::InvalidModelConfigError, "model cannot be blank" if model == ""
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
module Factory
|
|
41
|
+
module_function
|
|
42
|
+
|
|
43
|
+
def create_model(config = ModelConfig.new)
|
|
44
|
+
router.create(config)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def router
|
|
48
|
+
@router ||= default_router
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def reset_router!
|
|
52
|
+
@router = default_router
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def default_router
|
|
56
|
+
Providers::Router.new.tap do |router|
|
|
57
|
+
router.register("ruby_llm", Providers::RubyLLMProvider)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "core/data"
|
|
6
|
+
require_relative "core/types"
|
|
7
|
+
|
|
8
|
+
module LangExtract
|
|
9
|
+
module IO
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def save_annotated_documents(path, documents)
|
|
13
|
+
normalized = Array(documents)
|
|
14
|
+
::File.open(path, "w") do |file|
|
|
15
|
+
normalized.each do |document|
|
|
16
|
+
annotated = document.is_a?(Core::AnnotatedDocument) ? document : Core::AnnotatedDocument.from_h(document)
|
|
17
|
+
file.puts(JSON.generate(annotated.to_h))
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
path
|
|
21
|
+
rescue SystemCallError, JSON::GeneratorError => e
|
|
22
|
+
raise Core::IOFailure, "failed to save annotated documents: #{e.message}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def load_annotated_documents_jsonl(path)
|
|
26
|
+
::File.readlines(path, chomp: true).filter_map do |line|
|
|
27
|
+
next if line.strip.empty?
|
|
28
|
+
|
|
29
|
+
Core::AnnotatedDocument.from_h(JSON.parse(line))
|
|
30
|
+
end
|
|
31
|
+
rescue SystemCallError, JSON::ParserError => e
|
|
32
|
+
raise Core::IOFailure, "failed to load annotated documents: #{e.message}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LangExtract
|
|
4
|
+
module Providers
|
|
5
|
+
InferenceResult = Data.define(:text, :raw)
|
|
6
|
+
|
|
7
|
+
class Base
|
|
8
|
+
def initialize(config)
|
|
9
|
+
@config = config
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def infer(prompt:)
|
|
13
|
+
raise NotImplementedError, "#{self.class} must implement #infer"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
attr_reader :config
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../core/errors"
|
|
4
|
+
|
|
5
|
+
module LangExtract
|
|
6
|
+
module Providers
|
|
7
|
+
class Router
|
|
8
|
+
def initialize
|
|
9
|
+
@providers = {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def register(name, provider_class)
|
|
13
|
+
providers[name.to_s] = provider_class
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def fetch(name)
|
|
17
|
+
providers.fetch(name.to_s) do
|
|
18
|
+
raise Core::InvalidModelConfigError, "unknown provider adapter: #{name}"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def create(config)
|
|
23
|
+
fetch(config.adapter).new(config)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def names
|
|
27
|
+
providers.keys.sort
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
attr_reader :providers
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "../core/types"
|
|
5
|
+
|
|
6
|
+
module LangExtract
|
|
7
|
+
module Providers
|
|
8
|
+
class RubyLLMProvider < Base
|
|
9
|
+
def infer(prompt:)
|
|
10
|
+
require "ruby_llm"
|
|
11
|
+
|
|
12
|
+
response = RubyLLM.chat(**chat_options).ask(prompt)
|
|
13
|
+
InferenceResult.new(text: extract_text(response), raw: response)
|
|
14
|
+
rescue LoadError => e
|
|
15
|
+
raise Core::ProviderConfigError, "ruby_llm is required for live provider inference: #{e.message}"
|
|
16
|
+
rescue StandardError => e
|
|
17
|
+
raise Core::ProviderConfigError, "provider inference failed: #{e.message}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def chat_options
|
|
23
|
+
options = config.options.dup
|
|
24
|
+
options[:model] = config.model if config.model
|
|
25
|
+
options[:provider] = config.provider if config.provider
|
|
26
|
+
options
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def extract_text(response)
|
|
30
|
+
return response.content.to_s if response.respond_to?(:content)
|
|
31
|
+
return response.text.to_s if response.respond_to?(:text)
|
|
32
|
+
|
|
33
|
+
response.to_s
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|