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.
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "types"
6
+
7
+ module LangExtract
8
+ module Core
9
+ module HashCoercion
10
+ module_function
11
+
12
+ def stringify_keys(hash)
13
+ (hash || {}).each_with_object({}) do |(key, value), result|
14
+ result[key.to_s] = coerce_value(value)
15
+ end
16
+ end
17
+
18
+ def coerce_value(value)
19
+ case value
20
+ when Hash
21
+ stringify_keys(value)
22
+ when Array
23
+ value.map { |entry| coerce_value(entry) }.freeze
24
+ else
25
+ value
26
+ end
27
+ end
28
+
29
+ def read(hash, *keys)
30
+ keys.each do |key|
31
+ return hash[key] if hash.key?(key)
32
+ return hash[key.to_s] if hash.key?(key.to_s)
33
+ end
34
+ nil
35
+ end
36
+ end
37
+
38
+ class CharInterval
39
+ attr_reader :start_pos, :end_pos
40
+
41
+ def initialize(start_pos:, end_pos:)
42
+ raise ArgumentError, "start_pos must be non-negative" if start_pos.negative?
43
+ raise ArgumentError, "end_pos must be >= start_pos" if end_pos < start_pos
44
+
45
+ @start_pos = start_pos
46
+ @end_pos = end_pos
47
+ freeze
48
+ end
49
+
50
+ def length
51
+ end_pos - start_pos
52
+ end
53
+
54
+ def overlaps?(other)
55
+ start_pos < other.end_pos && other.start_pos < end_pos
56
+ end
57
+
58
+ def contains?(other)
59
+ start_pos <= other.start_pos && end_pos >= other.end_pos
60
+ end
61
+
62
+ def shift(offset)
63
+ self.class.new(start_pos: start_pos + offset, end_pos: end_pos + offset)
64
+ end
65
+
66
+ def to_h
67
+ { "start_pos" => start_pos, "end_pos" => end_pos }
68
+ end
69
+
70
+ def self.from_h(hash)
71
+ start_pos = HashCoercion.read(hash, :start_pos)
72
+ end_pos = HashCoercion.read(hash, :end_pos)
73
+ raise ArgumentError, "start_pos is required" if start_pos.nil?
74
+ raise ArgumentError, "end_pos is required" if end_pos.nil?
75
+
76
+ new(
77
+ start_pos: start_pos.to_i,
78
+ end_pos: end_pos.to_i
79
+ )
80
+ end
81
+
82
+ def ==(other)
83
+ other.is_a?(self.class) && start_pos == other.start_pos && end_pos == other.end_pos
84
+ end
85
+ alias eql? ==
86
+
87
+ def hash
88
+ [start_pos, end_pos].hash
89
+ end
90
+
91
+ def to_s
92
+ "[#{start_pos}, #{end_pos})"
93
+ end
94
+ end
95
+
96
+ class TokenInterval < CharInterval; end
97
+
98
+ class Extraction
99
+ attr_reader :extraction_class, :text, :description, :attributes, :char_interval,
100
+ :token_interval, :alignment_status, :extraction_index, :group_id,
101
+ :document_id
102
+
103
+ def initialize(text:, extraction_class: nil, description: nil, attributes: {},
104
+ char_interval: nil, token_interval: nil,
105
+ alignment_status: AlignmentStatus::UNGROUNDED,
106
+ extraction_index: nil, group_id: nil, document_id: nil)
107
+ raise ArgumentError, "text is required" if text.nil?
108
+ raise ArgumentError, "invalid alignment status" unless AlignmentStatus.valid?(alignment_status)
109
+
110
+ @extraction_class = extraction_class
111
+ @text = text
112
+ @description = description
113
+ @attributes = HashCoercion.stringify_keys(attributes).freeze
114
+ @char_interval = char_interval
115
+ @token_interval = token_interval
116
+ @alignment_status = alignment_status
117
+ @extraction_index = extraction_index
118
+ @group_id = group_id
119
+ @document_id = document_id
120
+ freeze
121
+ end
122
+
123
+ def grounded?
124
+ char_interval && ![AlignmentStatus::UNGROUNDED, AlignmentStatus::ERROR].include?(alignment_status)
125
+ end
126
+
127
+ def to_h
128
+ {
129
+ "extraction_class" => extraction_class,
130
+ "text" => text,
131
+ "description" => description,
132
+ "attributes" => attributes,
133
+ "char_interval" => char_interval&.to_h,
134
+ "token_interval" => token_interval&.to_h,
135
+ "alignment_status" => alignment_status,
136
+ "extraction_index" => extraction_index,
137
+ "group_id" => group_id,
138
+ "document_id" => document_id
139
+ }
140
+ end
141
+
142
+ def self.from_h(hash)
143
+ new(
144
+ extraction_class: HashCoercion.read(hash, :extraction_class, :class, :type),
145
+ text: HashCoercion.read(hash, :text, :extraction_text).to_s,
146
+ description: HashCoercion.read(hash, :description),
147
+ attributes: HashCoercion.read(hash, :attributes) || {},
148
+ char_interval: interval_from_hash(HashCoercion.read(hash, :char_interval), CharInterval),
149
+ token_interval: interval_from_hash(HashCoercion.read(hash, :token_interval), TokenInterval),
150
+ alignment_status: HashCoercion.read(hash, :alignment_status) || AlignmentStatus::UNGROUNDED,
151
+ extraction_index: HashCoercion.read(hash, :extraction_index),
152
+ group_id: HashCoercion.read(hash, :group_id),
153
+ document_id: HashCoercion.read(hash, :document_id)
154
+ )
155
+ end
156
+
157
+ def self.interval_from_hash(value, interval_class)
158
+ return value if value.is_a?(interval_class)
159
+ return nil if value.nil?
160
+
161
+ interval_class.from_h(value)
162
+ end
163
+ end
164
+
165
+ class Document
166
+ attr_reader :text, :id, :metadata
167
+
168
+ def initialize(text:, id: nil, metadata: {})
169
+ raise ArgumentError, "text is required" if text.nil?
170
+
171
+ @text = text
172
+ @id = id
173
+ @metadata = HashCoercion.stringify_keys(metadata).freeze
174
+ freeze
175
+ end
176
+
177
+ def to_h
178
+ { "id" => id, "text" => text, "metadata" => metadata }
179
+ end
180
+
181
+ def self.from_h(hash)
182
+ new(
183
+ id: HashCoercion.read(hash, :id),
184
+ text: HashCoercion.read(hash, :text).to_s,
185
+ metadata: HashCoercion.read(hash, :metadata) || {}
186
+ )
187
+ end
188
+ end
189
+
190
+ class AnnotatedDocument
191
+ attr_reader :document, :extractions
192
+
193
+ def initialize(document:, extractions: [])
194
+ @document = document
195
+ @extractions = extractions.map { |item| item.is_a?(Extraction) ? item : Extraction.from_h(item) }.freeze
196
+ freeze
197
+ end
198
+
199
+ def id
200
+ document.id
201
+ end
202
+
203
+ def text
204
+ document.text
205
+ end
206
+
207
+ def metadata
208
+ document.metadata
209
+ end
210
+
211
+ def to_h
212
+ {
213
+ "document" => document.to_h,
214
+ "extractions" => extractions.map(&:to_h)
215
+ }
216
+ end
217
+
218
+ def self.from_h(hash)
219
+ document_hash = HashCoercion.read(hash, :document)
220
+ document = Document.from_h(document_hash || hash)
221
+ extractions = Array(HashCoercion.read(hash, :extractions)).map { |item| Extraction.from_h(item) }
222
+
223
+ new(document: document, extractions: extractions)
224
+ end
225
+ end
226
+
227
+ class ExampleData
228
+ attr_reader :text, :extractions
229
+
230
+ def initialize(text:, extractions:)
231
+ raise ArgumentError, "text is required" if text.nil?
232
+
233
+ @text = text
234
+ @extractions = extractions.map { |item| normalize_extraction(item) }.freeze
235
+ freeze
236
+ end
237
+
238
+ def to_h
239
+ {
240
+ "text" => text,
241
+ "extractions" => extractions
242
+ }
243
+ end
244
+
245
+ def self.from_h(hash)
246
+ new(
247
+ text: HashCoercion.read(hash, :text).to_s,
248
+ extractions: Array(HashCoercion.read(hash, :extractions))
249
+ )
250
+ end
251
+
252
+ private
253
+
254
+ def normalize_extraction(item)
255
+ return example_hash_from_extraction(item) if item.is_a?(Extraction)
256
+
257
+ HashCoercion.stringify_keys(item)
258
+ end
259
+
260
+ def example_hash_from_extraction(extraction)
261
+ {
262
+ "extraction_class" => extraction.extraction_class,
263
+ "text" => extraction.text,
264
+ "description" => extraction.description,
265
+ "attributes" => extraction.attributes,
266
+ "group_id" => extraction.group_id
267
+ }
268
+ end
269
+ end
270
+ end
271
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LangExtract
4
+ module Core
5
+ class Error < StandardError; end
6
+ class InvalidModelConfigError < Error; end
7
+ class ProviderConfigError < Error; end
8
+ class FormatParsingError < Error; end
9
+ class PromptValidationError < Error; end
10
+ class AlignmentError < Error; end
11
+ class IOFailure < Error; end
12
+ end
13
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+
6
+ require_relative "data"
7
+
8
+ module LangExtract
9
+ module Core
10
+ class FormatHandler
11
+ FENCE_PATTERN = /```(?:json|yaml|yml)?\s*(.*?)```/im
12
+
13
+ def parse(output, format: :auto, strict: true, schema: nil)
14
+ candidates = fenced_payloads(output)
15
+ candidates << output.to_s if candidates.empty?
16
+
17
+ parsed = parse_first(candidates, format)
18
+ normalize_extractions(parsed).tap do |extractions|
19
+ validate_schema!(extractions, schema) if schema
20
+ end
21
+ rescue FormatParsingError
22
+ raise if strict
23
+
24
+ []
25
+ end
26
+
27
+ private
28
+
29
+ def fenced_payloads(output)
30
+ output.to_s.scan(FENCE_PATTERN).flatten.map(&:strip).reject(&:empty?)
31
+ end
32
+
33
+ def parse_first(candidates, format)
34
+ errors = []
35
+
36
+ candidates.each do |candidate|
37
+ parse_formats(format).each do |parser_format|
38
+ parsed = parse_payload(candidate, parser_format)
39
+ unless parsed.nil? || parsed == false || parsed.is_a?(Hash) || parsed.is_a?(Array)
40
+ raise FormatParsingError, "parsed payload must be an object or array"
41
+ end
42
+
43
+ return parsed
44
+ rescue JSON::ParserError, Psych::Exception, TypeError, FormatParsingError => e
45
+ errors << "#{parser_format}: #{e.message}"
46
+ end
47
+ end
48
+
49
+ raise FormatParsingError, "unable to parse provider output (#{errors.join('; ')})"
50
+ end
51
+
52
+ def parse_formats(format)
53
+ case format.to_sym
54
+ when :auto
55
+ %i[json yaml]
56
+ when :json, :yaml
57
+ [format.to_sym]
58
+ else
59
+ raise FormatParsingError, "unsupported format: #{format}"
60
+ end
61
+ end
62
+
63
+ def parse_payload(payload, format)
64
+ case format
65
+ when :json
66
+ JSON.parse(payload)
67
+ when :yaml
68
+ YAML.safe_load(payload, permitted_classes: [Symbol], aliases: false)
69
+ end
70
+ end
71
+
72
+ def normalize_extractions(parsed)
73
+ payload = unwrap(parsed)
74
+ extraction_items = payload.is_a?(Array) ? payload : [payload]
75
+
76
+ extraction_items.compact.map do |item|
77
+ normalize_extraction(item)
78
+ end
79
+ end
80
+
81
+ def unwrap(parsed)
82
+ return [] if parsed.nil? || parsed == false
83
+ return parsed unless parsed.is_a?(Hash)
84
+
85
+ hash = HashCoercion.stringify_keys(parsed)
86
+ hash["extractions"] || hash["data"] || hash["items"] || hash
87
+ end
88
+
89
+ def normalize_extraction(item)
90
+ raise FormatParsingError, "extraction must be an object" unless item.is_a?(Hash)
91
+
92
+ hash = HashCoercion.stringify_keys(item)
93
+ text = hash["text"] || hash["extraction_text"]
94
+ raise FormatParsingError, "extraction text is required" if text.nil?
95
+
96
+ {
97
+ "extraction_class" => hash["extraction_class"] || hash["class"] || hash["type"],
98
+ "text" => text.to_s,
99
+ "description" => hash["description"],
100
+ "attributes" => hash["attributes"] || {},
101
+ "group_id" => hash["group_id"]
102
+ }
103
+ end
104
+
105
+ def validate_schema!(extractions, schema)
106
+ normalized_schema = HashCoercion.stringify_keys(schema)
107
+ required = Array(normalized_schema["required"])
108
+ properties = normalized_schema["properties"] || {}
109
+
110
+ extractions.each_with_index do |extraction, index|
111
+ required.each do |field|
112
+ value = extraction[field.to_s]
113
+ raise FormatParsingError, "schema validation failed: extraction #{index} missing #{field}" if blank?(value)
114
+ end
115
+
116
+ validate_property_types!(extraction, properties, index)
117
+ end
118
+ end
119
+
120
+ def validate_property_types!(extraction, properties, index)
121
+ properties.each do |field, rule|
122
+ value = extraction[field]
123
+ next if value.nil?
124
+
125
+ expected_type = rule["type"]
126
+ next if expected_type.nil? || matches_type?(value, expected_type)
127
+
128
+ raise FormatParsingError, "schema validation failed: extraction #{index} #{field} must be #{expected_type}"
129
+ end
130
+ end
131
+
132
+ def matches_type?(value, expected_type)
133
+ case expected_type
134
+ when "string"
135
+ value.is_a?(String)
136
+ when "object"
137
+ value.is_a?(Hash)
138
+ when "array"
139
+ value.is_a?(Array)
140
+ when "number"
141
+ value.is_a?(Numeric)
142
+ when "boolean"
143
+ [true, false].include?(value)
144
+ else
145
+ true
146
+ end
147
+ end
148
+
149
+ def blank?(value)
150
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "data"
4
+ require_relative "types"
5
+
6
+ module LangExtract
7
+ module Core
8
+ class PromptValidation
9
+ MODES = %i[off warning error].freeze
10
+
11
+ def initialize(mode: :warning, warning_io: $stderr)
12
+ @mode = mode.to_sym
13
+ @warning_io = warning_io
14
+ raise ArgumentError, "invalid prompt validation mode" unless MODES.include?(@mode)
15
+ end
16
+
17
+ def validate!(examples)
18
+ return if mode == :off
19
+
20
+ issues = examples.flat_map.with_index { |example, index| issues_for(example, index) }
21
+ return if issues.empty?
22
+
23
+ message = "Prompt examples failed validation: #{issues.join('; ')}"
24
+ raise PromptValidationError, message if mode == :error
25
+
26
+ warning_io.puts(message)
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :mode, :warning_io
32
+
33
+ def issues_for(example, index)
34
+ normalized = example.is_a?(ExampleData) ? example : ExampleData.from_h(example)
35
+ issues = []
36
+ issues << "example #{index} text is empty" if normalized.text.strip.empty?
37
+ issues << "example #{index} has no extractions" if normalized.extractions.empty?
38
+
39
+ normalized.extractions.each_with_index do |extraction, extraction_index|
40
+ extraction_text = extraction["text"] || extraction["extraction_text"]
41
+ if extraction_text.to_s.strip.empty?
42
+ issues << "example #{index} extraction #{extraction_index} text is empty"
43
+ elsif !normalized.text.include?(extraction_text.to_s)
44
+ issues << "example #{index} extraction #{extraction_index} text is not present in example text"
45
+ end
46
+ end
47
+
48
+ issues
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "chunking"
6
+ require_relative "prompt_validation"
7
+
8
+ module LangExtract
9
+ module Core
10
+ class PromptBuilder
11
+ def initialize(validation_mode: :warning, warning_io: $stderr)
12
+ @validator = PromptValidation.new(mode: validation_mode, warning_io: warning_io)
13
+ end
14
+
15
+ def build(document:, chunk:, prompt_description:, examples: [], additional_context: nil,
16
+ context_window_chars: 0, pass_index: nil, total_passes: 1)
17
+ normalized_examples = examples.map { |example| normalize_example(example) }
18
+ validator.validate!(normalized_examples)
19
+
20
+ sections = []
21
+ sections << "Task: #{prompt_description}"
22
+ sections << "Additional context: #{additional_context}" if additional_context
23
+ sections << pass_section(pass_index, total_passes) if total_passes.to_i > 1
24
+ sections << context_section(document, chunk, context_window_chars) if context_window_chars.positive?
25
+ sections << examples_section(normalized_examples) unless normalized_examples.empty?
26
+ sections << "Text:\n#{chunk.text}"
27
+ sections << output_contract
28
+ sections.join("\n\n")
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :validator
34
+
35
+ def normalize_example(example)
36
+ example.is_a?(ExampleData) ? example : ExampleData.from_h(example)
37
+ end
38
+
39
+ def context_section(document, chunk, context_window_chars)
40
+ before_start = [chunk.char_interval.start_pos - context_window_chars, 0].max
41
+ after_end = [chunk.char_interval.end_pos + context_window_chars, document.text.length].min
42
+ before = document.text[before_start...chunk.char_interval.start_pos]
43
+ after = document.text[chunk.char_interval.end_pos...after_end]
44
+
45
+ "Context before:\n#{before}\n\nContext after:\n#{after}"
46
+ end
47
+
48
+ def examples_section(examples)
49
+ rendered = examples.map.with_index do |example, index|
50
+ [
51
+ "Example #{index + 1} text:",
52
+ example.text,
53
+ "Example #{index + 1} extractions:",
54
+ JSON.pretty_generate(example.extractions)
55
+ ].join("\n")
56
+ end
57
+
58
+ rendered.join("\n\n")
59
+ end
60
+
61
+ def pass_section(pass_index, total_passes)
62
+ "Extraction pass: #{pass_index + 1} of #{total_passes}. Return grounded extractions for this pass."
63
+ end
64
+
65
+ def output_contract
66
+ <<~TEXT.strip
67
+ Return JSON with an "extractions" array. Each extraction must include "text", and may include "extraction_class", "description", "attributes", and "group_id".
68
+ TEXT
69
+ end
70
+ end
71
+ end
72
+ end