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