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,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
|