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.
@@ -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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Ruby SDK for Pangram AI detection and plagiarism APIs.
4
+ module Pangram
5
+ # Current pangram gem version.
6
+ VERSION = '0.1.0'
7
+ 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