pangram 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/.github/workflows/ci.yml +31 -0
- data/.github/workflows/release.yml +80 -0
- data/.gitignore +11 -0
- data/.mise.toml +3 -0
- data/.rspec +2 -0
- data/.rubocop.yml +31 -0
- data/CHANGELOG.md +10 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +110 -0
- data/LICENSE +21 -0
- data/Makefile +66 -0
- data/README.md +228 -0
- data/Rakefile +8 -0
- data/docs/API_REFERENCE.md +254 -0
- data/docs/DEVELOPMENT.md +113 -0
- data/docs/README.md +25 -0
- data/docs/RELEASING.md +72 -0
- data/lib/pangram/client.rb +587 -0
- data/lib/pangram/errors.rb +37 -0
- data/lib/pangram/version.rb +7 -0
- data/lib/pangram.rb +15 -0
- data/pangram.gemspec +51 -0
- metadata +270 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'faraday/multipart'
|
|
5
|
+
require 'faraday/net_http'
|
|
6
|
+
require 'json'
|
|
7
|
+
require 'uri'
|
|
8
|
+
|
|
9
|
+
# Ruby SDK for Pangram AI detection and plagiarism APIs.
|
|
10
|
+
module Pangram
|
|
11
|
+
# Client for Pangram text detection, bulk, file upload, and plagiarism APIs.
|
|
12
|
+
class Client
|
|
13
|
+
# Base URL for model discovery, text prediction, and bulk jobs.
|
|
14
|
+
TEXT_API_ENDPOINT = 'https://text.external-api.pangram.com'
|
|
15
|
+
|
|
16
|
+
# Base URL for multipart document prediction.
|
|
17
|
+
FILE_UPLOAD_API_ENDPOINT = 'https://file-external.api.pangram.com'
|
|
18
|
+
|
|
19
|
+
# Base URL for plagiarism detection.
|
|
20
|
+
PLAGIARISM_API_ENDPOINT = 'https://plagiarism.api.pangram.com'
|
|
21
|
+
|
|
22
|
+
# Terminal success stage for asynchronous text prediction.
|
|
23
|
+
ASYNC_SUCCESS_STAGE = 'STAGE_SUCCESS'
|
|
24
|
+
|
|
25
|
+
# Terminal failure stage for asynchronous text prediction.
|
|
26
|
+
ASYNC_FAILED_STAGE = 'STAGE_FAILED'
|
|
27
|
+
|
|
28
|
+
# Terminal statuses for asynchronous bulk jobs.
|
|
29
|
+
BULK_TERMINAL_STATUSES = %w[succeeded failed partial].freeze
|
|
30
|
+
|
|
31
|
+
# Default total prediction deadline in seconds.
|
|
32
|
+
DEFAULT_PREDICT_TIMEOUT = 300
|
|
33
|
+
|
|
34
|
+
# Default total bulk polling deadline in seconds.
|
|
35
|
+
DEFAULT_BULK_TIMEOUT = 3600
|
|
36
|
+
|
|
37
|
+
# Default delay between asynchronous status requests in seconds.
|
|
38
|
+
DEFAULT_POLL_INTERVAL = 0.5
|
|
39
|
+
|
|
40
|
+
# Smallest allowed polling delay and request timeout in seconds.
|
|
41
|
+
MIN_POLL_INTERVAL = 0.1
|
|
42
|
+
|
|
43
|
+
# Maximum timeout for ordinary API requests in seconds.
|
|
44
|
+
HTTP_REQUEST_TIMEOUT = 10
|
|
45
|
+
|
|
46
|
+
# Timeout for plagiarism requests in seconds.
|
|
47
|
+
PLAGIARISM_TIMEOUT = 90
|
|
48
|
+
|
|
49
|
+
# Largest results page accepted by the Bulk API.
|
|
50
|
+
MAX_BULK_PAGE_LIMIT = 1000
|
|
51
|
+
|
|
52
|
+
# Transient HTTP statuses worth retrying while polling or paginating.
|
|
53
|
+
RETRYABLE_STATUSES = [408, 429, 500, 502, 503, 504].freeze
|
|
54
|
+
|
|
55
|
+
# Warning emitted while omitted model selectors remain backward-compatible.
|
|
56
|
+
MODEL_SELECTION_DEPRECATION_MESSAGE = 'Omitting model is deprecated. Pass model: "default" or another ' \
|
|
57
|
+
'identifier returned by list_models. Model will be required after ' \
|
|
58
|
+
'September 30, 2026.'
|
|
59
|
+
|
|
60
|
+
attr_reader :api_key
|
|
61
|
+
|
|
62
|
+
def initialize(api_key: nil)
|
|
63
|
+
@api_key = (api_key.nil? ? ENV.fetch('PANGRAM_API_KEY', nil) : api_key).to_s.strip
|
|
64
|
+
if @api_key.empty?
|
|
65
|
+
raise AuthenticationError, 'API key is required. Set PANGRAM_API_KEY or pass api_key: to Pangram.new.'
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
@text_connection = build_connection(TEXT_API_ENDPOINT)
|
|
69
|
+
@file_connection = build_connection(FILE_UPLOAD_API_ENDPOINT, multipart: true)
|
|
70
|
+
@plagiarism_connection = build_connection(PLAGIARISM_API_ENDPOINT)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Redacted inspection so the API key never leaks into logs or error reports.
|
|
74
|
+
def inspect
|
|
75
|
+
"#<#{self.class.name} api_key=[FILTERED]>"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Return the ordered model selectors available to this API key.
|
|
79
|
+
def list_models
|
|
80
|
+
response = json_request(
|
|
81
|
+
@text_connection,
|
|
82
|
+
:get,
|
|
83
|
+
'/models',
|
|
84
|
+
headers: auth_headers,
|
|
85
|
+
timeout: HTTP_REQUEST_TIMEOUT,
|
|
86
|
+
operation: 'listing models'
|
|
87
|
+
)
|
|
88
|
+
models = response['models'] if response.is_a?(Hash)
|
|
89
|
+
|
|
90
|
+
invalid_response!('model catalog', response) unless valid_model_catalog?(models)
|
|
91
|
+
|
|
92
|
+
models.dup
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Submit a text task, poll it to completion, and return the successful API payload.
|
|
96
|
+
def predict(text, model: nil, public_dashboard_link: false, timeout: DEFAULT_PREDICT_TIMEOUT,
|
|
97
|
+
poll_interval: DEFAULT_POLL_INTERVAL)
|
|
98
|
+
predict_with_resolved_model(
|
|
99
|
+
text,
|
|
100
|
+
model: resolve_model(model),
|
|
101
|
+
public_dashboard_link: public_dashboard_link,
|
|
102
|
+
timeout: timeout,
|
|
103
|
+
poll_interval: poll_interval
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Predict text and request a public Pangram dashboard link.
|
|
108
|
+
def predict_with_dashboard_link(text, model: nil, timeout: DEFAULT_PREDICT_TIMEOUT,
|
|
109
|
+
poll_interval: DEFAULT_POLL_INTERVAL)
|
|
110
|
+
predict_with_resolved_model(
|
|
111
|
+
text,
|
|
112
|
+
model: resolve_model(model),
|
|
113
|
+
public_dashboard_link: true,
|
|
114
|
+
timeout: timeout,
|
|
115
|
+
poll_interval: poll_interval
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Submit a Bulk API job. Provide exactly one of text or items.
|
|
120
|
+
def submit_bulk(text: nil, items: nil, model: nil)
|
|
121
|
+
payload = bulk_payload(text, items)
|
|
122
|
+
normalized_model = resolve_model(model)
|
|
123
|
+
payload[:model] = normalized_model unless normalized_model.nil?
|
|
124
|
+
|
|
125
|
+
response = json_request(
|
|
126
|
+
@text_connection,
|
|
127
|
+
:post,
|
|
128
|
+
'/bulk',
|
|
129
|
+
body: payload,
|
|
130
|
+
expected_statuses: [202],
|
|
131
|
+
timeout: HTTP_REQUEST_TIMEOUT,
|
|
132
|
+
operation: 'submitting bulk job'
|
|
133
|
+
)
|
|
134
|
+
ensure_hash_response!(response, 'bulk response')
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Fetch the status and counters for a Bulk API job.
|
|
138
|
+
def get_bulk_status(bulk_id)
|
|
139
|
+
fetch_bulk_status(bulk_id, HTTP_REQUEST_TIMEOUT)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Fetch one page of Bulk API item metadata.
|
|
143
|
+
def get_bulk_items(bulk_id, offset: 0, limit: 100)
|
|
144
|
+
response = json_request(
|
|
145
|
+
@text_connection,
|
|
146
|
+
:get,
|
|
147
|
+
"/bulk/#{escape_path_segment(bulk_id, 'bulk_id')}/items",
|
|
148
|
+
params: { offset: offset, limit: limit },
|
|
149
|
+
timeout: HTTP_REQUEST_TIMEOUT,
|
|
150
|
+
operation: 'fetching bulk items'
|
|
151
|
+
)
|
|
152
|
+
ensure_hash_response!(response, 'bulk items response')
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Fetch one page of Bulk API results.
|
|
156
|
+
def get_bulk_results_page(bulk_id, offset: 0, limit: 100, timeout: HTTP_REQUEST_TIMEOUT)
|
|
157
|
+
response = json_request(
|
|
158
|
+
@text_connection,
|
|
159
|
+
:get,
|
|
160
|
+
"/bulk/#{escape_path_segment(bulk_id, 'bulk_id')}/results",
|
|
161
|
+
params: { offset: offset, limit: limit },
|
|
162
|
+
timeout: timeout,
|
|
163
|
+
operation: 'fetching bulk results'
|
|
164
|
+
)
|
|
165
|
+
ensure_hash_response!(response, 'bulk results response')
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Materialize every Bulk API results page in one Hash.
|
|
169
|
+
def get_bulk_results(bulk_id, page_size: MAX_BULK_PAGE_LIMIT, timeout: DEFAULT_BULK_TIMEOUT)
|
|
170
|
+
validate_bulk_results_options!(page_size, timeout)
|
|
171
|
+
deadline = monotonic_time + timeout
|
|
172
|
+
offset = 0
|
|
173
|
+
total_items = nil
|
|
174
|
+
response_bulk_id = bulk_id
|
|
175
|
+
items = []
|
|
176
|
+
failed_items = []
|
|
177
|
+
|
|
178
|
+
while total_items.nil? || offset < total_items
|
|
179
|
+
page = fetch_bulk_results_page(bulk_id, offset, page_size, deadline, timeout)
|
|
180
|
+
validate_bulk_results_page!(page)
|
|
181
|
+
# Lock in the first page's counters; later pages only contribute items.
|
|
182
|
+
if total_items.nil?
|
|
183
|
+
total_items = page['total_items']
|
|
184
|
+
response_bulk_id = page['bulk_id']
|
|
185
|
+
end
|
|
186
|
+
items.concat(page['items'])
|
|
187
|
+
failed_items.concat(page['failed_items'])
|
|
188
|
+
offset += page_size
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
{
|
|
192
|
+
'bulk_id' => response_bulk_id,
|
|
193
|
+
'total_items' => total_items || 0,
|
|
194
|
+
'items' => items,
|
|
195
|
+
'failed_items' => failed_items
|
|
196
|
+
}
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Poll a Bulk API job until its status is succeeded, failed, or partial.
|
|
200
|
+
def wait_for_bulk(bulk_id, timeout: DEFAULT_BULK_TIMEOUT, poll_interval: DEFAULT_POLL_INTERVAL)
|
|
201
|
+
validate_polling_options!(timeout, poll_interval)
|
|
202
|
+
deadline = monotonic_time + timeout
|
|
203
|
+
interval = [MIN_POLL_INTERVAL, poll_interval].max
|
|
204
|
+
last_status = nil
|
|
205
|
+
|
|
206
|
+
loop do
|
|
207
|
+
raise bulk_timeout_error(bulk_id, timeout, last_status) if monotonic_time >= deadline
|
|
208
|
+
|
|
209
|
+
begin
|
|
210
|
+
response = fetch_bulk_status(bulk_id, request_timeout(deadline))
|
|
211
|
+
rescue NetworkError, APIError => e
|
|
212
|
+
raise if e.is_a?(APIError) && !RETRYABLE_STATUSES.include?(e.status)
|
|
213
|
+
raise bulk_timeout_error(bulk_id, timeout, last_status) if monotonic_time >= deadline
|
|
214
|
+
|
|
215
|
+
sleep_before_retry(deadline, interval)
|
|
216
|
+
next
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
last_status = response['status']
|
|
220
|
+
return response if BULK_TERMINAL_STATUSES.include?(last_status)
|
|
221
|
+
|
|
222
|
+
sleep_before_retry(deadline, interval)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Upload one file for AI detection and return its result.
|
|
227
|
+
def predict_file(file_path, public_dashboard_link: false, timeout: DEFAULT_PREDICT_TIMEOUT)
|
|
228
|
+
results = predict_files([file_path], public_dashboard_link: public_dashboard_link, timeout: timeout)
|
|
229
|
+
invalid_response!('file upload response', results) if results.empty?
|
|
230
|
+
|
|
231
|
+
results.first
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Upload one or more files for AI detection.
|
|
235
|
+
def predict_files(file_paths, public_dashboard_link: false, timeout: DEFAULT_PREDICT_TIMEOUT)
|
|
236
|
+
validate_file_options!(file_paths, timeout)
|
|
237
|
+
|
|
238
|
+
opened_files = open_upload_files(file_paths)
|
|
239
|
+
response = upload_files(opened_files, public_dashboard_link, timeout)
|
|
240
|
+
invalid_response!('file upload response', response) unless valid_file_upload_response?(response)
|
|
241
|
+
|
|
242
|
+
response
|
|
243
|
+
ensure
|
|
244
|
+
opened_files&.each(&:close)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Check text for potential plagiarism against online sources.
|
|
248
|
+
def check_plagiarism(text)
|
|
249
|
+
response = json_request(
|
|
250
|
+
@plagiarism_connection,
|
|
251
|
+
:post,
|
|
252
|
+
'/',
|
|
253
|
+
body: { text: text, source: "ruby_sdk_#{VERSION}" },
|
|
254
|
+
timeout: PLAGIARISM_TIMEOUT,
|
|
255
|
+
operation: 'checking plagiarism'
|
|
256
|
+
)
|
|
257
|
+
ensure_hash_response!(response, 'plagiarism response')
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Deprecated compatibility alias for predict.
|
|
261
|
+
def predict_short(text, model: nil)
|
|
262
|
+
deprecate(:predict_short,
|
|
263
|
+
'predict_short is deprecated; use predict instead. This method may be removed after August 1, 2026.')
|
|
264
|
+
predict_with_resolved_model(
|
|
265
|
+
text,
|
|
266
|
+
model: resolve_model(model),
|
|
267
|
+
public_dashboard_link: false,
|
|
268
|
+
timeout: DEFAULT_PREDICT_TIMEOUT,
|
|
269
|
+
poll_interval: DEFAULT_POLL_INTERVAL
|
|
270
|
+
)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Deprecated sequential compatibility helper. Prefer submit_bulk.
|
|
274
|
+
def batch_predict(text_batch, model: nil)
|
|
275
|
+
deprecate(:batch_predict,
|
|
276
|
+
'batch_predict is deprecated; use submit_bulk instead. ' \
|
|
277
|
+
'This method may be removed after August 1, 2026.')
|
|
278
|
+
normalized_model = resolve_model(model)
|
|
279
|
+
text_batch.map do |text|
|
|
280
|
+
predict_with_resolved_model(
|
|
281
|
+
text,
|
|
282
|
+
model: normalized_model,
|
|
283
|
+
public_dashboard_link: false,
|
|
284
|
+
timeout: DEFAULT_PREDICT_TIMEOUT,
|
|
285
|
+
poll_interval: DEFAULT_POLL_INTERVAL
|
|
286
|
+
)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
private
|
|
291
|
+
|
|
292
|
+
def predict_with_resolved_model(text, model:, public_dashboard_link:, timeout:, poll_interval:)
|
|
293
|
+
validate_polling_options!(timeout, poll_interval)
|
|
294
|
+
deadline = monotonic_time + timeout
|
|
295
|
+
task_id = submit_prediction_task(text, model, public_dashboard_link, deadline)
|
|
296
|
+
poll_prediction_task(task_id, deadline, timeout, [MIN_POLL_INTERVAL, poll_interval].max)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def submit_prediction_task(text, model, public_dashboard_link, deadline)
|
|
300
|
+
payload = { text: text, public_dashboard_link: public_dashboard_link }
|
|
301
|
+
payload[:model] = model unless model.nil?
|
|
302
|
+
response = json_request(
|
|
303
|
+
@text_connection,
|
|
304
|
+
:post,
|
|
305
|
+
'/task',
|
|
306
|
+
body: payload,
|
|
307
|
+
timeout: request_timeout(deadline),
|
|
308
|
+
operation: 'submitting prediction task'
|
|
309
|
+
)
|
|
310
|
+
invalid_response!('task response', response) unless response.is_a?(Hash)
|
|
311
|
+
|
|
312
|
+
task_id = response['task_id']
|
|
313
|
+
invalid_response!('task response (missing task_id)', response) unless task_id.is_a?(String) && !task_id.empty?
|
|
314
|
+
|
|
315
|
+
task_id
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def poll_prediction_task(task_id, deadline, timeout, poll_interval)
|
|
319
|
+
loop do
|
|
320
|
+
raise prediction_timeout_error(task_id, timeout) if monotonic_time >= deadline
|
|
321
|
+
|
|
322
|
+
response = fetch_prediction_task_with_retry(task_id, deadline, timeout, poll_interval)
|
|
323
|
+
stage = response['stage']
|
|
324
|
+
return response if stage == ASYNC_SUCCESS_STAGE
|
|
325
|
+
|
|
326
|
+
raise_failed_task!(task_id, response) if stage == ASYNC_FAILED_STAGE
|
|
327
|
+
invalid_response!("task result (missing stage for task #{task_id})", response) if stage.nil?
|
|
328
|
+
|
|
329
|
+
sleep_before_retry(deadline, poll_interval)
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def fetch_prediction_task_with_retry(task_id, deadline, timeout, poll_interval)
|
|
334
|
+
fetch_prediction_task(task_id, deadline)
|
|
335
|
+
rescue NetworkError, APIError => e
|
|
336
|
+
raise if e.is_a?(APIError) && !RETRYABLE_STATUSES.include?(e.status)
|
|
337
|
+
raise prediction_timeout_error(task_id, timeout) if monotonic_time >= deadline
|
|
338
|
+
|
|
339
|
+
sleep_before_retry(deadline, poll_interval)
|
|
340
|
+
retry
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def fetch_prediction_task(task_id, deadline)
|
|
344
|
+
response = json_request(
|
|
345
|
+
@text_connection,
|
|
346
|
+
:get,
|
|
347
|
+
"/task/#{escape_path_segment(task_id, 'task_id')}",
|
|
348
|
+
timeout: request_timeout(deadline),
|
|
349
|
+
operation: 'polling prediction task'
|
|
350
|
+
)
|
|
351
|
+
ensure_hash_response!(response, 'task result')
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def fetch_bulk_status(bulk_id, timeout)
|
|
355
|
+
response = json_request(
|
|
356
|
+
@text_connection,
|
|
357
|
+
:get,
|
|
358
|
+
"/bulk/#{escape_path_segment(bulk_id, 'bulk_id')}",
|
|
359
|
+
timeout: timeout,
|
|
360
|
+
operation: 'fetching bulk status'
|
|
361
|
+
)
|
|
362
|
+
ensure_hash_response!(response, 'bulk status response')
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def bulk_payload(text, items)
|
|
366
|
+
raise ValidationError, 'Provide exactly one of text or items' if text.nil? == items.nil?
|
|
367
|
+
|
|
368
|
+
key, value = text.nil? ? [:items, items] : [:text, text]
|
|
369
|
+
raise ValidationError, "#{key} must be a non-empty Array" unless value.is_a?(Array) && !value.empty?
|
|
370
|
+
|
|
371
|
+
{ key => value }
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def validate_bulk_results_options!(page_size, timeout)
|
|
375
|
+
unless page_size.is_a?(Integer) && page_size.between?(1, MAX_BULK_PAGE_LIMIT)
|
|
376
|
+
raise ValidationError, "page_size must be between 1 and #{MAX_BULK_PAGE_LIMIT}"
|
|
377
|
+
end
|
|
378
|
+
return if timeout.respond_to?(:positive?) && timeout.positive?
|
|
379
|
+
|
|
380
|
+
raise ValidationError, 'timeout must be greater than 0'
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def fetch_bulk_results_page(bulk_id, offset, page_size, deadline, timeout)
|
|
384
|
+
loop do
|
|
385
|
+
raise bulk_results_timeout_error(bulk_id, timeout) if monotonic_time >= deadline
|
|
386
|
+
|
|
387
|
+
begin
|
|
388
|
+
return get_bulk_results_page(bulk_id, offset: offset, limit: page_size, timeout: request_timeout(deadline))
|
|
389
|
+
rescue NetworkError, APIError => e
|
|
390
|
+
raise if e.is_a?(APIError) && !RETRYABLE_STATUSES.include?(e.status)
|
|
391
|
+
raise bulk_results_timeout_error(bulk_id, timeout) if monotonic_time >= deadline
|
|
392
|
+
|
|
393
|
+
sleep_before_retry(deadline, DEFAULT_POLL_INTERVAL)
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def upload_files(opened_files, public_dashboard_link, timeout)
|
|
399
|
+
parts = opened_files.map do |file|
|
|
400
|
+
Faraday::Multipart::FilePart.new(file, 'application/octet-stream', File.basename(file.path))
|
|
401
|
+
end
|
|
402
|
+
response = @file_connection.post('/') do |request|
|
|
403
|
+
request.headers.update(auth_headers)
|
|
404
|
+
request.options.timeout = timeout
|
|
405
|
+
request.options.open_timeout = timeout
|
|
406
|
+
request.body = {
|
|
407
|
+
files: parts,
|
|
408
|
+
public_dashboard_link: public_dashboard_link ? 'true' : 'false'
|
|
409
|
+
}
|
|
410
|
+
end
|
|
411
|
+
parse_response_json(response, [200])
|
|
412
|
+
rescue Faraday::Error => e
|
|
413
|
+
raise NetworkError, "Pangram API request failed while uploading files: #{e.message}"
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def open_upload_file(file_path)
|
|
417
|
+
path = file_path.respond_to?(:to_path) ? file_path.to_path : file_path.to_s
|
|
418
|
+
File.open(path, 'rb')
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def open_upload_files(file_paths)
|
|
422
|
+
opened_files = []
|
|
423
|
+
file_paths.reduce(opened_files) do |files, file_path|
|
|
424
|
+
files << open_upload_file(file_path)
|
|
425
|
+
end
|
|
426
|
+
rescue StandardError
|
|
427
|
+
opened_files.each(&:close)
|
|
428
|
+
raise
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def json_request(connection, method, path, timeout:, operation:, body: nil, params: nil, headers: json_headers,
|
|
432
|
+
expected_statuses: [200])
|
|
433
|
+
response = connection.public_send(method, path) do |request|
|
|
434
|
+
request.headers.update(headers)
|
|
435
|
+
request.params.update(params) unless params.nil?
|
|
436
|
+
request.body = JSON.generate(body) unless body.nil?
|
|
437
|
+
request.options.timeout = timeout
|
|
438
|
+
request.options.open_timeout = timeout
|
|
439
|
+
end
|
|
440
|
+
parse_response_json(response, expected_statuses)
|
|
441
|
+
rescue Faraday::Error => e
|
|
442
|
+
raise NetworkError, "Pangram API request failed while #{operation}: #{e.message}"
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def parse_response_json(response, expected_statuses)
|
|
446
|
+
unless expected_statuses.include?(response.status)
|
|
447
|
+
raise APIError.new("Error returned by API: [#{response.status}] #{response.body}",
|
|
448
|
+
status: response.status, body: response.body)
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
begin
|
|
452
|
+
parsed = JSON.parse(response.body)
|
|
453
|
+
rescue JSON::ParserError
|
|
454
|
+
raise InvalidResponseError, "Error returned by API: non-JSON response: #{response.body}"
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
error = parsed['error'] if parsed.is_a?(Hash)
|
|
458
|
+
raise APIError.new("Error returned by API: #{error}", status: response.status, body: response.body) if error
|
|
459
|
+
|
|
460
|
+
parsed
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def build_connection(endpoint, multipart: false)
|
|
464
|
+
Faraday.new(url: endpoint) do |connection|
|
|
465
|
+
connection.request :multipart, flat_encode: true if multipart
|
|
466
|
+
connection.request :url_encoded if multipart
|
|
467
|
+
connection.adapter :net_http
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def auth_headers
|
|
472
|
+
{ 'x-api-key' => @api_key }
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def json_headers
|
|
476
|
+
auth_headers.merge('Content-Type' => 'application/json')
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def resolve_model(model)
|
|
480
|
+
if model.nil?
|
|
481
|
+
deprecate(:model_selection, MODEL_SELECTION_DEPRECATION_MESSAGE)
|
|
482
|
+
return nil
|
|
483
|
+
end
|
|
484
|
+
raise ValidationError, 'model must be a non-empty string' unless model.is_a?(String) && !model.strip.empty?
|
|
485
|
+
|
|
486
|
+
model.strip
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# Emit each deprecation warning at most once per client instance.
|
|
490
|
+
def deprecate(key, message)
|
|
491
|
+
@deprecation_warnings ||= {}
|
|
492
|
+
return if @deprecation_warnings[key]
|
|
493
|
+
|
|
494
|
+
warn(message)
|
|
495
|
+
@deprecation_warnings[key] = true
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def valid_model_catalog?(models)
|
|
499
|
+
models.is_a?(Array) && models.all? do |model|
|
|
500
|
+
model.is_a?(String) && !model.empty? && model == model.strip
|
|
501
|
+
end && models.include?('default') && models.uniq.length == models.length
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def validate_polling_options!(timeout, poll_interval)
|
|
505
|
+
unless timeout.respond_to?(:positive?) && timeout.positive?
|
|
506
|
+
raise ValidationError, 'timeout must be greater than 0'
|
|
507
|
+
end
|
|
508
|
+
return if poll_interval.respond_to?(:negative?) && !poll_interval.negative?
|
|
509
|
+
|
|
510
|
+
raise ValidationError, 'poll_interval cannot be negative'
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def validate_file_options!(file_paths, timeout)
|
|
514
|
+
unless file_paths.is_a?(Array) && !file_paths.empty?
|
|
515
|
+
raise ValidationError, 'file_paths must contain at least one file'
|
|
516
|
+
end
|
|
517
|
+
return if timeout.respond_to?(:positive?) && timeout.positive?
|
|
518
|
+
|
|
519
|
+
raise ValidationError, 'timeout must be greater than 0'
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def valid_file_upload_response?(response)
|
|
523
|
+
response.is_a?(Array) && response.all?(Hash)
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def validate_bulk_results_page!(page)
|
|
527
|
+
valid = page['bulk_id'].is_a?(String) && page['total_items'].is_a?(Integer) &&
|
|
528
|
+
page['total_items'] >= 0 && page['items'].is_a?(Array) && page['failed_items'].is_a?(Array)
|
|
529
|
+
invalid_response!('bulk results page', page) unless valid
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def ensure_hash_response!(response, name)
|
|
533
|
+
invalid_response!(name, response) unless response.is_a?(Hash)
|
|
534
|
+
|
|
535
|
+
response
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def invalid_response!(name, response)
|
|
539
|
+
raise InvalidResponseError, "Error returned by API: invalid #{name}: #{response.inspect}"
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def raise_failed_task!(task_id, response)
|
|
543
|
+
message = response['headline'] || response['detail'] || 'task failed'
|
|
544
|
+
raise APIError, "Error returned by API: task #{task_id} failed: #{message}"
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def request_timeout(deadline)
|
|
548
|
+
(deadline - monotonic_time).clamp(MIN_POLL_INTERVAL, HTTP_REQUEST_TIMEOUT)
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def sleep_before_retry(deadline, interval)
|
|
552
|
+
duration = (deadline - monotonic_time).clamp(0, interval)
|
|
553
|
+
sleep(duration) if duration.positive?
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def monotonic_time
|
|
557
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def prediction_timeout_error(task_id, timeout)
|
|
561
|
+
TimeoutError.new("Pangram prediction task #{task_id} did not complete within #{format_timeout(timeout)}s")
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def bulk_timeout_error(bulk_id, timeout, last_status)
|
|
565
|
+
TimeoutError.new(
|
|
566
|
+
"Pangram bulk job #{bulk_id} did not complete within #{format_timeout(timeout)}s; " \
|
|
567
|
+
"last status=#{last_status || 'nil'}"
|
|
568
|
+
)
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
def bulk_results_timeout_error(bulk_id, timeout)
|
|
572
|
+
TimeoutError.new(
|
|
573
|
+
"Pangram bulk results for job #{bulk_id} did not finish within #{format_timeout(timeout)}s"
|
|
574
|
+
)
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def format_timeout(timeout)
|
|
578
|
+
format('%.0f', timeout)
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def escape_path_segment(value, name)
|
|
582
|
+
raise ValidationError, "#{name} must be a non-empty string" unless value.is_a?(String) && !value.strip.empty?
|
|
583
|
+
|
|
584
|
+
URI.encode_www_form_component(value).gsub('+', '%20')
|
|
585
|
+
end
|
|
586
|
+
end
|
|
587
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Ruby SDK for Pangram AI detection and plagiarism APIs.
|
|
4
|
+
module Pangram
|
|
5
|
+
# Base class for all errors raised by this SDK.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Raised when no Pangram API key is configured.
|
|
9
|
+
class AuthenticationError < Error; end
|
|
10
|
+
|
|
11
|
+
# Raised when a client method receives an invalid argument.
|
|
12
|
+
class ValidationError < Error; end
|
|
13
|
+
|
|
14
|
+
# Raised when Pangram rejects a request or an asynchronous task fails.
|
|
15
|
+
class APIError < Error
|
|
16
|
+
# HTTP status code of the failed response, when available.
|
|
17
|
+
attr_reader :status
|
|
18
|
+
|
|
19
|
+
# Raw response body of the failed response, when available.
|
|
20
|
+
attr_reader :body
|
|
21
|
+
|
|
22
|
+
def initialize(message, status: nil, body: nil)
|
|
23
|
+
super(message)
|
|
24
|
+
@status = status
|
|
25
|
+
@body = body
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Raised when Pangram returns invalid JSON or an unexpected response shape.
|
|
30
|
+
class InvalidResponseError < APIError; end
|
|
31
|
+
|
|
32
|
+
# Raised when an HTTP request fails at the transport layer.
|
|
33
|
+
class NetworkError < Error; end
|
|
34
|
+
|
|
35
|
+
# Raised when asynchronous polling exceeds its total deadline.
|
|
36
|
+
class TimeoutError < Error; end
|
|
37
|
+
end
|
data/lib/pangram.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'pangram/version'
|
|
4
|
+
require_relative 'pangram/errors'
|
|
5
|
+
require_relative 'pangram/client'
|
|
6
|
+
|
|
7
|
+
# Ruby SDK for Pangram AI detection and plagiarism APIs.
|
|
8
|
+
module Pangram
|
|
9
|
+
class << self
|
|
10
|
+
# Create an isolated Pangram API client.
|
|
11
|
+
def new(**kwargs)
|
|
12
|
+
Client.new(**kwargs)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
data/pangram.gemspec
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/pangram/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'pangram'
|
|
7
|
+
spec.version = Pangram::VERSION
|
|
8
|
+
spec.authors = ['Richard Sun']
|
|
9
|
+
spec.email = ['richard.sun@ai-firstly.com']
|
|
10
|
+
|
|
11
|
+
spec.summary = 'Ruby SDK for the Pangram AI detection API'
|
|
12
|
+
spec.description = 'A Ruby client for Pangram AI detection, bulk jobs, file uploads, and plagiarism detection.'
|
|
13
|
+
spec.homepage = 'https://docs.pangram.com'
|
|
14
|
+
spec.license = 'MIT'
|
|
15
|
+
spec.required_ruby_version = '>= 3.1.0'
|
|
16
|
+
|
|
17
|
+
spec.metadata['allowed_push_host'] = 'https://rubygems.org'
|
|
18
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
|
19
|
+
spec.metadata['source_code_uri'] = 'https://github.com/ai-firstly/pangram'
|
|
20
|
+
spec.metadata['changelog_uri'] = 'https://github.com/ai-firstly/pangram/blob/master/CHANGELOG.md'
|
|
21
|
+
spec.metadata['documentation_uri'] = 'https://rubydoc.info/gems/pangram'
|
|
22
|
+
spec.metadata['bug_tracker_uri'] = 'https://github.com/ai-firstly/pangram/issues'
|
|
23
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
24
|
+
|
|
25
|
+
spec.files = Dir.chdir(__dir__) do
|
|
26
|
+
if File.directory?('.git')
|
|
27
|
+
`git ls-files -z`.split("\x0").reject { |file| file.match?(%r{\A(?:spec|test|features)/}) }
|
|
28
|
+
else
|
|
29
|
+
Dir.glob('**/*', File::FNM_DOTMATCH).reject do |file|
|
|
30
|
+
File.directory?(file) || file == '.rspec_status' ||
|
|
31
|
+
file.match?(%r{\A(?:\.git|spec|test|features|pkg|coverage|tmp|vendor)/}) || file.end_with?('.gem')
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
spec.require_paths = ['lib']
|
|
36
|
+
|
|
37
|
+
spec.add_dependency 'faraday', '>= 1.8', '< 3.0'
|
|
38
|
+
spec.add_dependency 'faraday-multipart', '~> 1.0'
|
|
39
|
+
spec.add_dependency 'faraday-net_http', '>= 1.0', '< 4.0'
|
|
40
|
+
spec.add_dependency 'json', '~> 2.0'
|
|
41
|
+
|
|
42
|
+
spec.add_development_dependency 'bundler', '>= 2.0', '< 3.0'
|
|
43
|
+
spec.add_development_dependency 'parallel', '~> 1.0' # parallel 2 requires Ruby 3.3+
|
|
44
|
+
spec.add_development_dependency 'public_suffix', '< 7.0' # public_suffix 7 requires Ruby 3.2+
|
|
45
|
+
spec.add_development_dependency 'rake', '~> 13.0'
|
|
46
|
+
spec.add_development_dependency 'rspec', '~> 3.0'
|
|
47
|
+
spec.add_development_dependency 'rubocop', '~> 1.0'
|
|
48
|
+
spec.add_development_dependency 'simplecov', '~> 0.22'
|
|
49
|
+
spec.add_development_dependency 'webmock', '~> 3.0'
|
|
50
|
+
spec.add_development_dependency 'yard', '~> 0.9'
|
|
51
|
+
end
|