sapling-py 4.3.0__tar.gz → 4.11.0__tar.gz

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.
Files changed (23) hide show
  1. {sapling_py-4.3.0/sapling_py.egg-info → sapling_py-4.11.0}/PKG-INFO +18 -3
  2. {sapling_py-4.3.0 → sapling_py-4.11.0}/README.md +17 -2
  3. {sapling_py-4.3.0 → sapling_py-4.11.0}/sapling/client.py +256 -22
  4. sapling_py-4.11.0/sapling/version.py +1 -0
  5. {sapling_py-4.3.0 → sapling_py-4.11.0/sapling_py.egg-info}/PKG-INFO +18 -3
  6. {sapling_py-4.3.0 → sapling_py-4.11.0}/sapling_py.egg-info/SOURCES.txt +3 -1
  7. {sapling_py-4.3.0 → sapling_py-4.11.0}/test/test_classify.py +23 -0
  8. {sapling_py-4.3.0 → sapling_py-4.11.0}/test/test_client.py +122 -0
  9. {sapling_py-4.3.0 → sapling_py-4.11.0}/test/test_extract.py +24 -0
  10. {sapling_py-4.3.0 → sapling_py-4.11.0}/test/test_safety.py +23 -0
  11. sapling_py-4.11.0/test/test_simplify.py +95 -0
  12. sapling_py-4.11.0/test/test_translate.py +109 -0
  13. sapling_py-4.3.0/sapling/version.py +0 -1
  14. {sapling_py-4.3.0 → sapling_py-4.11.0}/LICENSE +0 -0
  15. {sapling_py-4.3.0 → sapling_py-4.11.0}/pyproject.toml +0 -0
  16. {sapling_py-4.3.0 → sapling_py-4.11.0}/sapling/__init__.py +0 -0
  17. {sapling_py-4.3.0 → sapling_py-4.11.0}/sapling_py.egg-info/dependency_links.txt +0 -0
  18. {sapling_py-4.3.0 → sapling_py-4.11.0}/sapling_py.egg-info/requires.txt +0 -0
  19. {sapling_py-4.3.0 → sapling_py-4.11.0}/sapling_py.egg-info/top_level.txt +0 -0
  20. {sapling_py-4.3.0 → sapling_py-4.11.0}/setup.cfg +0 -0
  21. {sapling_py-4.3.0 → sapling_py-4.11.0}/setup.py +0 -0
  22. {sapling_py-4.3.0 → sapling_py-4.11.0}/test/test_pii.py +0 -0
  23. {sapling_py-4.3.0 → sapling_py-4.11.0}/test/test_seo.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sapling-py
3
- Version: 4.3.0
3
+ Version: 4.11.0
4
4
  Summary: Sapling Python Client
5
5
  Author-email: Sapling Intelligence <info@sapling.ai>
6
6
  License: Apache License 2.0
@@ -31,8 +31,9 @@ Compare against leading grammar checking tools and APIs like Grammarly (Grammerl
31
31
 
32
32
  Sapling is a toolkit for helping developers build language model-powered applications.
33
33
  The API provides spelling and grammar checking, autocomplete, tone detection, rephrasing, AI detection,
34
- zero-shot text classification, structured data extraction, PII detection and redaction, content safety
35
- scoring, SEO analysis, and more.
34
+ zero-shot text classification, structured data extraction, translation, PII detection
35
+ and redaction, content safety scoring, plain-language
36
+ simplification, SEO analysis, and more.
36
37
 
37
38
  Key Features:
38
39
  - **60% More Corrections**: Outperforms [competing solutions](https://sapling.ai/comparison/api#sapling) at similar accuracy levels using state-of-the-art generative AI models.
@@ -94,6 +95,20 @@ Get a production key by following [this documentation](https://sapling.ai/docs/a
94
95
 
95
96
  Here's some more information on [request options and response structure](https://sapling.ai/docs/api/edits-overview).
96
97
 
98
+ ## Batch requests
99
+
100
+ The analysis endpoints — `classify`, `extract`, `safety`, `langdetect`,
101
+ `translate`, `summarize`, `quality` and `aidetect` — also accept a list of
102
+ 1-10 texts in
103
+ place of a single string. The batch is sent as one request (the same options
104
+ apply to every item) and returns `{'results': [...]}` with one
105
+ single-response-shaped dict per input, in order:
106
+
107
+ ```python
108
+ scores = client.quality(['First draft.', 'Second draft.'], sentence_scores=True)
109
+ langs = client.langdetect(['Bonjour le monde', 'Hello world'])
110
+ ```
111
+
97
112
  ## License
98
113
 
99
114
  Copyright 2024--present Sapling Intelligence, Inc.
@@ -9,8 +9,9 @@ Compare against leading grammar checking tools and APIs like Grammarly (Grammerl
9
9
 
10
10
  Sapling is a toolkit for helping developers build language model-powered applications.
11
11
  The API provides spelling and grammar checking, autocomplete, tone detection, rephrasing, AI detection,
12
- zero-shot text classification, structured data extraction, PII detection and redaction, content safety
13
- scoring, SEO analysis, and more.
12
+ zero-shot text classification, structured data extraction, translation, PII detection
13
+ and redaction, content safety scoring, plain-language
14
+ simplification, SEO analysis, and more.
14
15
 
15
16
  Key Features:
16
17
  - **60% More Corrections**: Outperforms [competing solutions](https://sapling.ai/comparison/api#sapling) at similar accuracy levels using state-of-the-art generative AI models.
@@ -72,6 +73,20 @@ Get a production key by following [this documentation](https://sapling.ai/docs/a
72
73
 
73
74
  Here's some more information on [request options and response structure](https://sapling.ai/docs/api/edits-overview).
74
75
 
76
+ ## Batch requests
77
+
78
+ The analysis endpoints — `classify`, `extract`, `safety`, `langdetect`,
79
+ `translate`, `summarize`, `quality` and `aidetect` — also accept a list of
80
+ 1-10 texts in
81
+ place of a single string. The batch is sent as one request (the same options
82
+ apply to every item) and returns `{'results': [...]}` with one
83
+ single-response-shaped dict per input, in order:
84
+
85
+ ```python
86
+ scores = client.quality(['First draft.', 'Second draft.'], sentence_scores=True)
87
+ langs = client.langdetect(['Bonjour le monde', 'Hello world'])
88
+ ```
89
+
75
90
  ## License
76
91
 
77
92
  Copyright 2024--present Sapling Intelligence, Inc.
@@ -430,8 +430,13 @@ class SaplingClient:
430
430
  '''
431
431
  Score a piece of text on how likely it was generated by AI.
432
432
 
433
- :param text: Text to score for AI-generated content.
434
- :type text: str
433
+ :param text: Text to score for AI-generated content (up to 200,000
434
+ characters). Pass a list or tuple of 1-10 strings to score a batch
435
+ in one request (sent as the API's ``texts`` parameter; the same
436
+ options apply to every item, and the combined length is capped at
437
+ 200,000 characters). A batch response is ``{'results': [...]}``
438
+ with one single-response-shaped dict per input, in order.
439
+ :type text: str | list[str]
435
440
  :param sent_scores: If true, each sentence will also be scored individually.
436
441
  :type sent_scores: bool
437
442
 
@@ -445,12 +450,94 @@ class SaplingClient:
445
450
  url = f'{self.url_endpoint}aidetect'
446
451
  data = {
447
452
  'key': self.api_key,
448
- 'text': text,
449
453
  }
454
+ # A list/tuple of texts is the batch form: one request, one result per
455
+ # item ({'results': [...]}), same options applied to every item.
456
+ if isinstance(text, (list, tuple)):
457
+ data['texts'] = list(text)
458
+ else:
459
+ data['text'] = text
450
460
  if sent_scores is not None:
451
461
  data['sent_scores'] = sent_scores
452
462
  return self._request(url, data)
453
463
 
464
+ def simplify(
465
+ self,
466
+ text,
467
+ reading_level=None,
468
+ preserve_terms=None,
469
+ lang=None,
470
+ ):
471
+ '''
472
+ Rewrites a text in plain language at a target reading level, keeping
473
+ all of its content, structure and language — nothing is summarized or
474
+ dropped. The response includes a deterministic before/after
475
+ readability block (Flesch-Kincaid grade and reading ease) when the
476
+ text's language supports it, so the improvement is verifiable.
477
+
478
+ Example::
479
+
480
+ client.simplify(
481
+ 'The party of the first part shall remit payment within '
482
+ 'thirty (30) days of receipt of the invoice.',
483
+ reading_level='plain',
484
+ preserve_terms=['invoice'],
485
+ )
486
+ # {'simplified': 'You must pay within 30 days of getting '
487
+ # 'the invoice.',
488
+ # 'reading_level': 'plain', 'lang': 'en',
489
+ # 'readability': {'before': {'grade': 12.3, 'ease': 42.1},
490
+ # 'after': {'grade': 5.8, 'ease': 78.4}}}
491
+
492
+ :param text: Text to simplify, up to 5,000 characters. Sent to the
493
+ model as submitted — markup and line structure are preserved, not
494
+ stripped.
495
+ :type text: str
496
+ :param reading_level: Target audience: ``'plain'`` (plain-language
497
+ style for a general audience, the API default), ``'elementary'``
498
+ (~US grade 3-5), ``'middle_school'`` (~grade 6-8) or
499
+ ``'high_school'`` (~grade 9-10).
500
+ :type reading_level: str
501
+ :param preserve_terms: Up to 20 terms (each up to 100 characters) the
502
+ rewrite must keep exactly as written — product names, defined
503
+ legal terms. A rewrite that drops one is refused server-side
504
+ rather than returned.
505
+ :type preserve_terms: list[str]
506
+ :param lang: ISO 639-1 code used to pick the readability formula for
507
+ the before/after scores. Omit for auto-detection (falls back to
508
+ ``'en'``). The rewrite itself always stays in the text's own
509
+ language.
510
+ :type lang: str
511
+ :raises TypeError: If ``preserve_terms`` is a single string rather
512
+ than a list/tuple of terms.
513
+ :rtype: dict
514
+ :return:
515
+ - simplified: The rewritten text, structure preserved.
516
+ - reading_level: The reading level that was applied.
517
+ - lang: The language the readability scores were computed for
518
+ (detected when not supplied).
519
+ - readability: ``{'before': {'grade', 'ease'}, 'after': {'grade',
520
+ 'ease'}}`` Flesch-Kincaid scores, present when the language
521
+ supports readability scoring.
522
+ '''
523
+ # A bare string would silently be split into one-character terms;
524
+ # fail loudly instead (same guard as extract/classify).
525
+ if preserve_terms is not None and (
526
+ not isinstance(preserve_terms, Iterable)
527
+ or isinstance(preserve_terms, (str, bytes, dict))):
528
+ raise TypeError('preserve_terms must be a list of strings')
529
+ url = f'{self.url_endpoint}simplify'
530
+ data = {
531
+ 'key': self.api_key,
532
+ 'text': text,
533
+ }
534
+ if reading_level is not None:
535
+ data['reading_level'] = reading_level
536
+ if preserve_terms is not None:
537
+ data['preserve_terms'] = list(preserve_terms)
538
+ if lang is not None:
539
+ data['lang'] = lang
540
+ return self._request(url, data)
454
541
  def chunk_text(
455
542
  self,
456
543
  text,
@@ -615,21 +702,55 @@ class SaplingClient:
615
702
  def summarize(
616
703
  self,
617
704
  text,
705
+ length=None,
618
706
  ):
619
707
  '''
620
- Summarizes a longer document into a shorter, more digestible one.
708
+ Summarizes a longer document, webpage or email thread into a prose
709
+ summary plus key points.
621
710
 
622
- :param text: Input document to summarize.
623
- :type text: str
711
+ Example::
712
+
713
+ client.summarize(
714
+ 'Hi, I was charged twice for my order this month. ...',
715
+ length='short',
716
+ )
717
+ # {'result': 'The customer was double-charged and wants a refund.',
718
+ # 'summary': 'The customer was double-charged and wants a refund.',
719
+ # 'key_points': ['Charged twice for one order.',
720
+ # 'Requests a refund of the duplicate.'],
721
+ # 'length': 'short'}
722
+
723
+ :param text: Input document to summarize, plain text or HTML (tags
724
+ are stripped), up to 20,000 characters. Pass a list or tuple of
725
+ 1-10 strings to summarize a batch in one request (sent as the
726
+ API's ``texts`` parameter; combined length up to 20,000
727
+ characters, same ``length`` option applied to every item). A
728
+ batch response is ``{'results': [...]}`` with one
729
+ single-response-shaped dict per input, in order.
730
+ :type text: str | list[str]
731
+ :param length: Optional summary length: ``'short'``, ``'medium'``
732
+ (the default) or ``'long'``.
733
+ :type length: str
624
734
  :rtype: dict
625
735
  :return:
626
- - result: The summarized text.
736
+ - summary: The prose summary.
737
+ - result: Mirrors ``summary`` (the endpoint's original response
738
+ key, kept for backward compatibility).
739
+ - key_points: List of key-point strings.
740
+ - length: The length option the summary was generated with.
627
741
  '''
628
742
  url = self.url_endpoint + 'summarize'
629
743
  data = {
630
744
  'key': self.api_key,
631
- 'text': text,
632
745
  }
746
+ # A list/tuple of texts is the batch form: one request, one result per
747
+ # item ({'results': [...]}), same options applied to every item.
748
+ if isinstance(text, (list, tuple)):
749
+ data['texts'] = list(text)
750
+ else:
751
+ data['text'] = text
752
+ if length is not None:
753
+ data['length'] = length
633
754
  return self._request(url, data)
634
755
 
635
756
  def extract(
@@ -672,8 +793,12 @@ class SaplingClient:
672
793
  # 'missing': ['purchase_order']}
673
794
 
674
795
  :param text: Document to extract from, plain text or HTML (tags are
675
- stripped), up to 10,000 characters.
676
- :type text: str
796
+ stripped), up to 10,000 characters. Pass a list or tuple of 1-10
797
+ strings to extract from a batch in one request (sent as the API's
798
+ ``texts`` parameter; combined length up to 10,000 characters). A batch
799
+ response is ``{'results': [...]}`` with one single-response-shaped
800
+ dict per input, in order.
801
+ :type text: str | list[str]
677
802
  :param fields: 1-20 fields to extract. Each entry is either a field name
678
803
  (str) or a dict ``{'name': str, 'type': str, 'description': str,
679
804
  'required': bool}`` where everything but ``name`` is optional. Names are
@@ -711,13 +836,85 @@ class SaplingClient:
711
836
  url = self.url_endpoint + 'extract'
712
837
  data = {
713
838
  'key': self.api_key,
714
- 'text': text,
715
839
  'fields': list(fields),
716
840
  }
841
+ # A list/tuple of texts is the batch form: one request, one result per
842
+ # item ({'results': [...]}), same fields applied to every item.
843
+ if isinstance(text, (list, tuple)):
844
+ data['texts'] = list(text)
845
+ else:
846
+ data['text'] = text
717
847
  if context is not None:
718
848
  data['context'] = context
719
849
  return self._request(url, data)
720
850
 
851
+ def translate(
852
+ self,
853
+ text,
854
+ target_lang,
855
+ source_lang=None,
856
+ formality=None,
857
+ ):
858
+ '''
859
+ Translates text into a target language with an LLM, preserving line
860
+ breaks, markup and placeholders in place. The response reports the
861
+ language the text was actually written in (detected, not assumed from
862
+ the hint).
863
+
864
+ Example::
865
+
866
+ client.translate(
867
+ 'Hello world.\\nSee you at 5pm.',
868
+ target_lang='fr',
869
+ )
870
+ # {'translation': 'Bonjour le monde.\\nÀ 17h.',
871
+ # 'source_lang': 'en', 'source_lang_name': 'English',
872
+ # 'target_lang': 'fr', 'target_lang_name': 'French'}
873
+
874
+ :param text: Text to translate, up to 5,000 characters. Markup and
875
+ whitespace are preserved, not stripped. Pass a list or tuple of
876
+ 1-10 strings to translate a batch in one request (sent as the
877
+ API's ``texts`` parameter; combined length up to 5,000
878
+ characters, same target/source/formality applied to every item).
879
+ A batch response is ``{'results': [...]}`` with one
880
+ single-response-shaped dict per input, in order.
881
+ :type text: str | list[str]
882
+ :param target_lang: Language to translate into: an ISO 639 code
883
+ (``'fr'``, ``'zh-TW'``) or an English language name
884
+ (``'French'``). Region variants ``zh-CN``/``zh-TW``/``zh-HK``,
885
+ ``pt-BR``/``pt-PT``, ``en-GB``/``en-US``, ``fr-CA`` and ``es-419``
886
+ are supported as targets.
887
+ :type target_lang: str
888
+ :param source_lang: Optional hint for the source language, same forms
889
+ as ``target_lang``. The text itself wins if they disagree.
890
+ :type source_lang: str
891
+ :param formality: Optional register: ``'formal'`` (vous/Sie/usted) or
892
+ ``'informal'`` (tu/du/tú). Omit for the model default.
893
+ :type formality: str
894
+ :rtype: dict
895
+ :return:
896
+ - translation: The translated text, structure preserved.
897
+ - source_lang / source_lang_name: The detected source language
898
+ (``'und'`` and ``None`` when undetermined).
899
+ - target_lang / target_lang_name: The normalized target.
900
+ '''
901
+ url = self.url_endpoint + 'translate'
902
+ data = {
903
+ 'key': self.api_key,
904
+ 'target_lang': target_lang,
905
+ }
906
+ # A list/tuple of texts is the batch form: one request, one result per
907
+ # item ({'results': [...]}), same options applied to every item.
908
+ if isinstance(text, (list, tuple)):
909
+ data['texts'] = list(text)
910
+ else:
911
+ data['text'] = text
912
+ if source_lang is not None:
913
+ data['source_lang'] = source_lang
914
+ if formality is not None:
915
+ data['formality'] = formality
916
+ return self._request(url, data)
917
+
721
918
  def tone(
722
919
  self,
723
920
  text,
@@ -779,8 +976,13 @@ class SaplingClient:
779
976
 
780
977
  :param text: Text to compute a quality score for. The base score is computed
781
978
  on the leading ~4,000 characters; the rubric evaluates the whole text
782
- (up to 20,000 characters).
783
- :type text: str
979
+ (up to 20,000 characters). Pass a list or tuple of 1-10 strings to
980
+ score a batch in one request (sent as the API's ``texts`` parameter;
981
+ the same options apply to every item, and with ``rubric`` the
982
+ combined length is capped at 20,000 characters). A batch response is
983
+ ``{'results': [...]}`` with one single-response-shaped dict per
984
+ input, in order.
985
+ :type text: str | list[str]
784
986
  :param sentence_scores: If true, also return a 1-5 score for each sentence
785
987
  under `sentences`.
786
988
  :type sentence_scores: bool
@@ -799,8 +1001,13 @@ class SaplingClient:
799
1001
  url = self.url_endpoint + 'quality'
800
1002
  data = {
801
1003
  'key': self.api_key,
802
- 'text': text,
803
1004
  }
1005
+ # A list/tuple of texts is the batch form: one request, one result per
1006
+ # item ({'results': [...]}), same options applied to every item.
1007
+ if isinstance(text, (list, tuple)):
1008
+ data['texts'] = list(text)
1009
+ else:
1010
+ data['text'] = text
804
1011
  if sentence_scores is not None:
805
1012
  data['sentence_scores'] = sentence_scores
806
1013
  if rubric is not None:
@@ -818,7 +1025,11 @@ class SaplingClient:
818
1025
  candidates and a per-segment breakdown for mixed-language text.
819
1026
 
820
1027
  :param text: Text to identify the language for (up to 20,000 characters).
821
- :type text: str
1028
+ Pass a list or tuple of 1-10 strings to detect a batch in one
1029
+ request (sent as the API's ``texts`` parameter; combined length up
1030
+ to 20,000 characters). A batch response is ``{'results': [...]}``
1031
+ with one single-response-shaped dict per input, in order.
1032
+ :type text: str | list[str]
822
1033
  :param top_k: Number of language candidates to return, 1-10. Defaults to 3 server-side.
823
1034
  :type top_k: int
824
1035
  :param segments: If true, also detect the language of each line/sentence
@@ -836,8 +1047,13 @@ class SaplingClient:
836
1047
  url = self.url_endpoint + 'langdetect'
837
1048
  data = {
838
1049
  'key': self.api_key,
839
- 'text': text,
840
1050
  }
1051
+ # A list/tuple of texts is the batch form: one request, one result per
1052
+ # item ({'results': [...]}), same options applied to every item.
1053
+ if isinstance(text, (list, tuple)):
1054
+ data['texts'] = list(text)
1055
+ else:
1056
+ data['text'] = text
841
1057
  if top_k is not None:
842
1058
  data['top_k'] = top_k
843
1059
  if segments is not None:
@@ -875,8 +1091,12 @@ class SaplingClient:
875
1091
  Scores the provided text on seven content-safety categories: toxicity,
876
1092
  profanity, harassment, hate_speech, self_harm, sexual, and violence.
877
1093
 
878
- :param text: Text to score, up to 20,000 characters.
879
- :type text: str
1094
+ :param text: Text to score, up to 20,000 characters. Pass a list or
1095
+ tuple of 1-10 strings to score a batch in one request (sent as the
1096
+ API's ``texts`` parameter; combined length up to 20,000
1097
+ characters). A batch response is ``{'results': [...]}`` with one
1098
+ single-response-shaped dict per input, in order.
1099
+ :type text: str | list[str]
880
1100
  :param threshold: Score at or above which a category is flagged.
881
1101
  Between 0 and 1 inclusive; the API defaults to 0.5.
882
1102
  :type threshold: float
@@ -897,8 +1117,13 @@ class SaplingClient:
897
1117
  url = self.url_endpoint + 'safety'
898
1118
  data = {
899
1119
  'key': self.api_key,
900
- 'text': text,
901
1120
  }
1121
+ # A list/tuple of texts is the batch form: one request, one result per
1122
+ # item ({'results': [...]}), same options applied to every item.
1123
+ if isinstance(text, (list, tuple)):
1124
+ data['texts'] = list(text)
1125
+ else:
1126
+ data['text'] = text
902
1127
  if threshold is not None:
903
1128
  data['threshold'] = threshold
904
1129
  if spans is not None:
@@ -1047,8 +1272,12 @@ class SaplingClient:
1047
1272
  # 'multi_label': False}
1048
1273
 
1049
1274
  :param text: Text to classify, plain text or HTML (tags are stripped), up to
1050
- 10,000 characters.
1051
- :type text: str
1275
+ 10,000 characters. Pass a list or tuple of 1-10 strings to classify a
1276
+ batch in one request (sent as the API's ``texts`` parameter; combined
1277
+ length up to 10,000 characters). A batch response is
1278
+ ``{'results': [...]}`` with one single-response-shaped dict per input,
1279
+ in order.
1280
+ :type text: str | list[str]
1052
1281
  :param labels: 2-20 candidate labels. Each entry is either a label name (str) or
1053
1282
  a dict ``{'name': str, 'description': str}`` where ``description`` is
1054
1283
  optional. Names are up to 50 characters and must be unique
@@ -1090,9 +1319,14 @@ class SaplingClient:
1090
1319
  url = self.url_endpoint + 'classify'
1091
1320
  data = {
1092
1321
  'key': self.api_key,
1093
- 'text': text,
1094
1322
  'labels': list(labels),
1095
1323
  }
1324
+ # A list/tuple of texts is the batch form: one request, one result per
1325
+ # item ({'results': [...]}), same labels applied to every item.
1326
+ if isinstance(text, (list, tuple)):
1327
+ data['texts'] = list(text)
1328
+ else:
1329
+ data['text'] = text
1096
1330
  if multi_label is not None:
1097
1331
  data['multi_label'] = multi_label
1098
1332
  if threshold is not None:
@@ -0,0 +1 @@
1
+ __version__ = '4.11.0'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sapling-py
3
- Version: 4.3.0
3
+ Version: 4.11.0
4
4
  Summary: Sapling Python Client
5
5
  Author-email: Sapling Intelligence <info@sapling.ai>
6
6
  License: Apache License 2.0
@@ -31,8 +31,9 @@ Compare against leading grammar checking tools and APIs like Grammarly (Grammerl
31
31
 
32
32
  Sapling is a toolkit for helping developers build language model-powered applications.
33
33
  The API provides spelling and grammar checking, autocomplete, tone detection, rephrasing, AI detection,
34
- zero-shot text classification, structured data extraction, PII detection and redaction, content safety
35
- scoring, SEO analysis, and more.
34
+ zero-shot text classification, structured data extraction, translation, PII detection
35
+ and redaction, content safety scoring, plain-language
36
+ simplification, SEO analysis, and more.
36
37
 
37
38
  Key Features:
38
39
  - **60% More Corrections**: Outperforms [competing solutions](https://sapling.ai/comparison/api#sapling) at similar accuracy levels using state-of-the-art generative AI models.
@@ -94,6 +95,20 @@ Get a production key by following [this documentation](https://sapling.ai/docs/a
94
95
 
95
96
  Here's some more information on [request options and response structure](https://sapling.ai/docs/api/edits-overview).
96
97
 
98
+ ## Batch requests
99
+
100
+ The analysis endpoints — `classify`, `extract`, `safety`, `langdetect`,
101
+ `translate`, `summarize`, `quality` and `aidetect` — also accept a list of
102
+ 1-10 texts in
103
+ place of a single string. The batch is sent as one request (the same options
104
+ apply to every item) and returns `{'results': [...]}` with one
105
+ single-response-shaped dict per input, in order:
106
+
107
+ ```python
108
+ scores = client.quality(['First draft.', 'Second draft.'], sentence_scores=True)
109
+ langs = client.langdetect(['Bonjour le monde', 'Hello world'])
110
+ ```
111
+
97
112
  ## License
98
113
 
99
114
  Copyright 2024--present Sapling Intelligence, Inc.
@@ -15,4 +15,6 @@ test/test_client.py
15
15
  test/test_extract.py
16
16
  test/test_pii.py
17
17
  test/test_safety.py
18
- test/test_seo.py
18
+ test/test_seo.py
19
+ test/test_simplify.py
20
+ test/test_translate.py
@@ -198,3 +198,26 @@ def test_classify_custom_hostname_and_pathname():
198
198
  def test_classify_rejects_non_list_labels(client, bad_labels):
199
199
  with pytest.raises(TypeError):
200
200
  client.classify('I was charged twice.', bad_labels)
201
+
202
+
203
+ @responses.activate
204
+ def test_classify_batch_list_sent_as_texts(client):
205
+ # A list of texts is the batch form (SAP-395): sent as `texts`, no `text`
206
+ # key, and the {'results': [...]} body comes back untouched.
207
+ batch_response = {'results': [CLASSIFY_RESPONSE, MULTI_LABEL_RESPONSE]}
208
+ responses.add(responses.POST, BASE + 'classify', json=batch_response, status=200)
209
+ result = client.classify(['first ticket', 'second ticket'], LABELS)
210
+ assert result == batch_response
211
+ body = _last_request_body()
212
+ assert body == {'key': API_KEY, 'texts': ['first ticket', 'second ticket'],
213
+ 'labels': LABELS}
214
+
215
+
216
+ @responses.activate
217
+ def test_classify_batch_tuple_sent_as_list(client):
218
+ responses.add(responses.POST, BASE + 'classify',
219
+ json={'results': [CLASSIFY_RESPONSE]}, status=200)
220
+ client.classify(('only ticket',), LABELS)
221
+ body = _last_request_body()
222
+ assert body['texts'] == ['only ticket']
223
+ assert 'text' not in body
@@ -168,6 +168,31 @@ def test_langdetect_options(client):
168
168
  assert _last_request_body() == {'key': API_KEY, 'text': 'Hello', 'top_k': 1, 'segments': True}
169
169
 
170
170
 
171
+ @responses.activate
172
+ def test_langdetect_batch_list_sent_as_texts(client):
173
+ # A list of texts is the batch form (SAP-397): sent as `texts`, no `text`
174
+ # key, and the {'results': [...]} body comes back untouched.
175
+ batch_response = {'results': [
176
+ {'lang': 'en', 'name': 'English', 'conf': 0.99, 'candidates': []},
177
+ {'lang': 'es', 'name': 'Spanish', 'conf': 0.97, 'candidates': []},
178
+ ]}
179
+ responses.add(responses.POST, BASE + 'langdetect', json=batch_response, status=200)
180
+ result = client.langdetect(['Hello there.', 'Hola.'], top_k=1)
181
+ assert result == batch_response
182
+ assert _last_request_body() == {'key': API_KEY, 'texts': ['Hello there.', 'Hola.'],
183
+ 'top_k': 1}
184
+
185
+
186
+ @responses.activate
187
+ def test_langdetect_batch_tuple_sent_as_list(client):
188
+ responses.add(responses.POST, BASE + 'langdetect',
189
+ json={'results': [{'lang': 'en'}]}, status=200)
190
+ client.langdetect(('Hello there.',))
191
+ body = _last_request_body()
192
+ assert body['texts'] == ['Hello there.']
193
+ assert 'text' not in body
194
+
195
+
171
196
  @responses.activate
172
197
  def test_hostname_and_pathname_override():
173
198
  client = SaplingClient(api_key=API_KEY, hostname='http://localhost:5000', pathname='/api/v1/')
@@ -193,3 +218,100 @@ def test_quality_options(client):
193
218
  assert result == payload
194
219
  assert _last_request_body() == {'key': API_KEY, 'text': 'Hello',
195
220
  'sentence_scores': True, 'rubric': True}
221
+
222
+
223
+ @responses.activate
224
+ def test_quality_batch_list_sent_as_texts(client):
225
+ # A list of texts is the batch form (SAP-400): sent as `texts`, no `text`
226
+ # key, the same options applied to every item, and the {'results': [...]}
227
+ # body comes back untouched.
228
+ batch_response = {'results': [
229
+ {'score': 4.1},
230
+ {'score': 2.3},
231
+ ]}
232
+ responses.add(responses.POST, BASE + 'quality', json=batch_response, status=200)
233
+ result = client.quality(['First document.', 'Second document.'],
234
+ sentence_scores=True)
235
+ assert result == batch_response
236
+ assert _last_request_body() == {'key': API_KEY,
237
+ 'texts': ['First document.', 'Second document.'],
238
+ 'sentence_scores': True}
239
+
240
+
241
+ @responses.activate
242
+ def test_quality_batch_tuple_sent_as_list(client):
243
+ responses.add(responses.POST, BASE + 'quality',
244
+ json={'results': [{'score': 3.0}]}, status=200)
245
+ client.quality(('First document.',))
246
+ body = _last_request_body()
247
+ assert body['texts'] == ['First document.']
248
+ assert 'text' not in body
249
+
250
+
251
+ @responses.activate
252
+ def test_summarize_length_sent_only_when_given(client):
253
+ payload = {'result': 'Short.', 'summary': 'Short.', 'key_points': ['One.'],
254
+ 'length': 'short'}
255
+ responses.add(responses.POST, BASE + 'summarize', json=payload, status=200)
256
+ result = client.summarize('A long document.', length='short')
257
+ assert result == payload
258
+ assert _last_request_body() == {'key': API_KEY, 'text': 'A long document.',
259
+ 'length': 'short'}
260
+
261
+ responses.add(responses.POST, BASE + 'summarize', json=payload, status=200)
262
+ client.summarize('A long document.')
263
+ assert 'length' not in _last_request_body()
264
+
265
+
266
+ @responses.activate
267
+ def test_summarize_batch_list_sent_as_texts(client):
268
+ # A list of texts is the batch form (SAP-399): sent as `texts`, no `text`
269
+ # key, and the {'results': [...]} body comes back untouched.
270
+ batch_response = {'results': [
271
+ {'result': 'One.', 'summary': 'One.', 'key_points': ['A.'], 'length': 'medium'},
272
+ {'result': 'Two.', 'summary': 'Two.', 'key_points': ['B.'], 'length': 'medium'},
273
+ ]}
274
+ responses.add(responses.POST, BASE + 'summarize', json=batch_response, status=200)
275
+ result = client.summarize(['First document.', 'Second document.'])
276
+ assert result == batch_response
277
+ assert _last_request_body() == {'key': API_KEY,
278
+ 'texts': ['First document.', 'Second document.']}
279
+
280
+
281
+ @responses.activate
282
+ def test_summarize_batch_tuple_sent_as_list(client):
283
+ responses.add(responses.POST, BASE + 'summarize',
284
+ json={'results': [{'result': 'One.'}]}, status=200)
285
+ client.summarize(('First document.',), length='long')
286
+ body = _last_request_body()
287
+ assert body['texts'] == ['First document.']
288
+ assert body['length'] == 'long'
289
+ assert 'text' not in body
290
+
291
+
292
+ @responses.activate
293
+ def test_aidetect_batch_list_sent_as_texts(client):
294
+ # A list of texts is the batch form (SAP-403): sent as `texts`, no `text`
295
+ # key, and the {'results': [...]} body comes back untouched.
296
+ batch_response = {'results': [
297
+ {'score': 0.91, 'text': 'Almost certainly generated.'},
298
+ {'score': 0.08, 'text': 'Plainly human prose.'},
299
+ ]}
300
+ responses.add(responses.POST, BASE + 'aidetect', json=batch_response, status=200)
301
+ result = client.aidetect(['Almost certainly generated.', 'Plainly human prose.'],
302
+ sent_scores=False)
303
+ assert result == batch_response
304
+ assert _last_request_body() == {'key': API_KEY,
305
+ 'texts': ['Almost certainly generated.',
306
+ 'Plainly human prose.'],
307
+ 'sent_scores': False}
308
+
309
+
310
+ @responses.activate
311
+ def test_aidetect_batch_tuple_sent_as_list(client):
312
+ responses.add(responses.POST, BASE + 'aidetect',
313
+ json={'results': [{'score': 0.5}]}, status=200)
314
+ client.aidetect(('One draft.',))
315
+ body = _last_request_body()
316
+ assert body['texts'] == ['One draft.']
317
+ assert 'text' not in body
@@ -211,3 +211,27 @@ def test_extract_accepts_any_iterable_of_fields(client):
211
211
  responses.add(responses.POST, BASE + 'extract', json=EXTRACT_RESPONSE, status=200)
212
212
  client.extract(TEXT, (name for name in ['invoice_number', 'total']))
213
213
  assert _last_request_body()['fields'] == ['invoice_number', 'total']
214
+
215
+
216
+ @responses.activate
217
+ def test_extract_batch_list_sent_as_texts(client):
218
+ # A list of texts is the batch form (SAP-395): sent as `texts`, no `text`
219
+ # key, and the {'results': [...]} body comes back untouched.
220
+ batch_response = {'results': [EXTRACT_RESPONSE, EXTRACT_RESPONSE]}
221
+ responses.add(responses.POST, BASE + 'extract', json=batch_response, status=200)
222
+ result = client.extract([TEXT, 'Invoice INV-2077 for Globex.'], FIELDS)
223
+ assert result == batch_response
224
+ body = _last_request_body()
225
+ assert body['texts'] == [TEXT, 'Invoice INV-2077 for Globex.']
226
+ assert 'text' not in body
227
+ assert body['fields'] == FIELDS
228
+
229
+
230
+ @responses.activate
231
+ def test_extract_batch_tuple_sent_as_list(client):
232
+ responses.add(responses.POST, BASE + 'extract',
233
+ json={'results': [EXTRACT_RESPONSE]}, status=200)
234
+ client.extract((TEXT,), FIELDS)
235
+ body = _last_request_body()
236
+ assert body['texts'] == [TEXT]
237
+ assert 'text' not in body
@@ -54,3 +54,26 @@ def test_safety_spans_omitted_by_default(client):
54
54
  client.safety('some text')
55
55
  body = json.loads(responses.calls[-1].request.body)
56
56
  assert 'spans' not in body
57
+
58
+
59
+ @responses.activate
60
+ def test_safety_batch_list_sent_as_texts(client):
61
+ # A list of texts is the batch form (SAP-397): sent as `texts`, no `text`
62
+ # key, and the {'results': [...]} body comes back untouched.
63
+ batch_response = {'results': [{'flagged': True}, {'flagged': False}]}
64
+ responses.add(responses.POST, BASE + 'safety', json=batch_response, status=200)
65
+ result = client.safety(['first message', 'second message'], threshold=0.7)
66
+ assert result == batch_response
67
+ body = json.loads(responses.calls[-1].request.body)
68
+ assert body == {'key': API_KEY, 'texts': ['first message', 'second message'],
69
+ 'threshold': 0.7}
70
+
71
+
72
+ @responses.activate
73
+ def test_safety_batch_tuple_sent_as_list(client):
74
+ responses.add(responses.POST, BASE + 'safety',
75
+ json={'results': [{'flagged': False}]}, status=200)
76
+ client.safety(('only message',))
77
+ body = json.loads(responses.calls[-1].request.body)
78
+ assert body['texts'] == ['only message']
79
+ assert 'text' not in body
@@ -0,0 +1,95 @@
1
+ import json
2
+
3
+ import pytest
4
+ import responses
5
+
6
+ from sapling import SaplingClient
7
+
8
+ API_KEY = 'a' * 32
9
+ BASE = 'https://api.sapling.ai/api/v1/'
10
+
11
+ TEXT = ('The party of the first part shall remit payment within thirty (30) '
12
+ 'days of receipt of the invoice.')
13
+
14
+ SIMPLIFY_RESPONSE = {
15
+ 'simplified': 'You must pay within 30 days of getting the invoice.',
16
+ 'reading_level': 'plain',
17
+ 'lang': 'en',
18
+ 'readability': {
19
+ 'before': {'grade': 12.3, 'ease': 42.1},
20
+ 'after': {'grade': 5.8, 'ease': 78.4},
21
+ },
22
+ }
23
+
24
+
25
+ @pytest.fixture
26
+ def client():
27
+ return SaplingClient(api_key=API_KEY)
28
+
29
+
30
+ def _last_request_body():
31
+ return json.loads(responses.calls[-1].request.body)
32
+
33
+
34
+ @responses.activate
35
+ def test_simplify_returns_json_and_sends_key_and_text(client):
36
+ responses.add(responses.POST, BASE + 'simplify',
37
+ json=SIMPLIFY_RESPONSE, status=200)
38
+ result = client.simplify(TEXT)
39
+ assert result == SIMPLIFY_RESPONSE
40
+ body = _last_request_body()
41
+ assert body == {'key': API_KEY, 'text': TEXT}
42
+
43
+
44
+ @responses.activate
45
+ def test_simplify_posts_to_simplify_endpoint(client):
46
+ responses.add(responses.POST, BASE + 'simplify',
47
+ json=SIMPLIFY_RESPONSE, status=200)
48
+ client.simplify(TEXT)
49
+ assert responses.calls[-1].request.url == BASE + 'simplify'
50
+
51
+
52
+ @responses.activate
53
+ def test_simplify_omits_optional_arguments_when_not_given(client):
54
+ responses.add(responses.POST, BASE + 'simplify',
55
+ json=SIMPLIFY_RESPONSE, status=200)
56
+ client.simplify(TEXT)
57
+ body = _last_request_body()
58
+ assert 'reading_level' not in body
59
+ assert 'preserve_terms' not in body
60
+ assert 'lang' not in body
61
+
62
+
63
+ @responses.activate
64
+ def test_simplify_sends_optional_arguments(client):
65
+ responses.add(responses.POST, BASE + 'simplify',
66
+ json=SIMPLIFY_RESPONSE, status=200)
67
+ client.simplify(TEXT, reading_level='middle_school',
68
+ preserve_terms=['invoice', 'Sapling'], lang='en')
69
+ body = _last_request_body()
70
+ assert body['reading_level'] == 'middle_school'
71
+ assert body['preserve_terms'] == ['invoice', 'Sapling']
72
+ assert body['lang'] == 'en'
73
+
74
+
75
+ @responses.activate
76
+ def test_simplify_accepts_a_tuple_of_preserve_terms(client):
77
+ responses.add(responses.POST, BASE + 'simplify',
78
+ json=SIMPLIFY_RESPONSE, status=200)
79
+ client.simplify(TEXT, preserve_terms=('invoice',))
80
+ assert _last_request_body()['preserve_terms'] == ['invoice']
81
+
82
+
83
+ def test_simplify_rejects_non_list_preserve_terms(client):
84
+ for bad in ('invoice', b'invoice', {'term': 'invoice'}, 5):
85
+ with pytest.raises(TypeError):
86
+ client.simplify(TEXT, preserve_terms=bad)
87
+
88
+
89
+ @responses.activate
90
+ def test_simplify_http_error_raises(client):
91
+ responses.add(responses.POST, BASE + 'simplify',
92
+ json={'msg': 'Unexpected error simplifying text.'},
93
+ status=502)
94
+ with pytest.raises(Exception):
95
+ client.simplify(TEXT)
@@ -0,0 +1,109 @@
1
+ import json
2
+
3
+ import pytest
4
+ import responses
5
+
6
+ from sapling import SaplingClient, SaplingError
7
+
8
+ API_KEY = 'a' * 32
9
+ BASE = 'https://api.sapling.ai/api/v1/'
10
+
11
+ TEXT = 'Hello world.\nSee you at 5pm.'
12
+
13
+ TRANSLATE_RESPONSE = {
14
+ 'translation': 'Bonjour le monde.\nÀ 17h.',
15
+ 'source_lang': 'en',
16
+ 'source_lang_name': 'English',
17
+ 'target_lang': 'fr',
18
+ 'target_lang_name': 'French',
19
+ }
20
+
21
+
22
+ @pytest.fixture
23
+ def client():
24
+ return SaplingClient(api_key=API_KEY)
25
+
26
+
27
+ def _last_request_body():
28
+ return json.loads(responses.calls[-1].request.body)
29
+
30
+
31
+ @responses.activate
32
+ def test_translate_returns_json_and_sends_key(client):
33
+ responses.add(responses.POST, BASE + 'translate',
34
+ json=TRANSLATE_RESPONSE, status=200)
35
+ result = client.translate(TEXT, target_lang='fr')
36
+ assert result == TRANSLATE_RESPONSE
37
+ body = _last_request_body()
38
+ assert body == {'key': API_KEY, 'text': TEXT, 'target_lang': 'fr'}
39
+
40
+
41
+ @responses.activate
42
+ def test_translate_posts_to_translate_endpoint(client):
43
+ responses.add(responses.POST, BASE + 'translate',
44
+ json=TRANSLATE_RESPONSE, status=200)
45
+ client.translate(TEXT, target_lang='fr')
46
+ assert responses.calls[-1].request.url == BASE + 'translate'
47
+
48
+
49
+ @responses.activate
50
+ def test_translate_optional_params_sent_only_when_given(client):
51
+ responses.add(responses.POST, BASE + 'translate',
52
+ json=TRANSLATE_RESPONSE, status=200)
53
+ client.translate(TEXT, target_lang='fr')
54
+ body = _last_request_body()
55
+ assert 'source_lang' not in body
56
+ assert 'formality' not in body
57
+
58
+ client.translate(TEXT, target_lang='fr',
59
+ source_lang='en', formality='formal')
60
+ body = _last_request_body()
61
+ assert body['source_lang'] == 'en'
62
+ assert body['formality'] == 'formal'
63
+
64
+
65
+ @responses.activate
66
+ def test_translate_accepts_names_and_region_variants(client):
67
+ responses.add(responses.POST, BASE + 'translate',
68
+ json=dict(TRANSLATE_RESPONSE, target_lang='zh-TW',
69
+ target_lang_name='Traditional Chinese'),
70
+ status=200)
71
+ result = client.translate(TEXT, target_lang='Traditional Chinese')
72
+ assert result['target_lang'] == 'zh-TW'
73
+ assert _last_request_body()['target_lang'] == 'Traditional Chinese'
74
+
75
+
76
+ @responses.activate
77
+ def test_translate_http_error_raises_sapling_error(client):
78
+ responses.add(responses.POST, BASE + 'translate',
79
+ json={'msg': 'Unknown language: "Klingon".'}, status=400)
80
+ with pytest.raises(SaplingError):
81
+ client.translate(TEXT, target_lang='Klingon')
82
+
83
+
84
+ @responses.activate
85
+ def test_translate_batch_list_sent_as_texts(client):
86
+ # A list of texts is the batch form (SAP-398): sent as `texts`, no `text`
87
+ # key, and the {'results': [...]} body comes back untouched.
88
+ batch_response = {'results': [TRANSLATE_RESPONSE,
89
+ dict(TRANSLATE_RESPONSE,
90
+ translation='À demain.')]}
91
+ responses.add(responses.POST, BASE + 'translate',
92
+ json=batch_response, status=200)
93
+ result = client.translate(['Hello world.', 'See you tomorrow.'],
94
+ target_lang='fr', formality='formal')
95
+ assert result == batch_response
96
+ body = _last_request_body()
97
+ assert body == {'key': API_KEY,
98
+ 'texts': ['Hello world.', 'See you tomorrow.'],
99
+ 'target_lang': 'fr', 'formality': 'formal'}
100
+
101
+
102
+ @responses.activate
103
+ def test_translate_batch_tuple_sent_as_list(client):
104
+ responses.add(responses.POST, BASE + 'translate',
105
+ json={'results': [TRANSLATE_RESPONSE]}, status=200)
106
+ client.translate(('Hello world.',), target_lang='fr')
107
+ body = _last_request_body()
108
+ assert body['texts'] == ['Hello world.']
109
+ assert 'text' not in body
@@ -1 +0,0 @@
1
- __version__ = '4.3.0'
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes