sapling-py 4.1.0__tar.gz → 4.2.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.
- {sapling_py-4.1.0/sapling_py.egg-info → sapling_py-4.2.0}/PKG-INFO +4 -2
- {sapling_py-4.1.0 → sapling_py-4.2.0}/README.md +3 -1
- {sapling_py-4.1.0 → sapling_py-4.2.0}/sapling/client.py +353 -4
- sapling_py-4.2.0/sapling/version.py +1 -0
- {sapling_py-4.1.0 → sapling_py-4.2.0/sapling_py.egg-info}/PKG-INFO +4 -2
- {sapling_py-4.1.0 → sapling_py-4.2.0}/sapling_py.egg-info/SOURCES.txt +6 -2
- sapling_py-4.2.0/test/test_classify.py +200 -0
- {sapling_py-4.1.0 → sapling_py-4.2.0}/test/test_client.py +37 -0
- sapling_py-4.2.0/test/test_extract.py +213 -0
- sapling_py-4.2.0/test/test_pii.py +66 -0
- sapling_py-4.2.0/test/test_safety.py +30 -0
- sapling_py-4.2.0/test/test_seo.py +116 -0
- sapling_py-4.1.0/sapling/version.py +0 -1
- sapling_py-4.1.0/test/__init__.py +0 -0
- {sapling_py-4.1.0 → sapling_py-4.2.0}/LICENSE +0 -0
- {sapling_py-4.1.0 → sapling_py-4.2.0}/sapling/__init__.py +0 -0
- {sapling_py-4.1.0 → sapling_py-4.2.0}/sapling_py.egg-info/dependency_links.txt +0 -0
- {sapling_py-4.1.0 → sapling_py-4.2.0}/sapling_py.egg-info/requires.txt +0 -0
- {sapling_py-4.1.0 → sapling_py-4.2.0}/sapling_py.egg-info/top_level.txt +0 -0
- {sapling_py-4.1.0 → sapling_py-4.2.0}/setup.cfg +0 -0
- {sapling_py-4.1.0 → sapling_py-4.2.0}/setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sapling-py
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.2.0
|
|
4
4
|
Summary: Sapling Python Client
|
|
5
5
|
Home-page: https://sapling.ai
|
|
6
6
|
Author: Sapling Intelligence
|
|
@@ -40,7 +40,9 @@ Compare against leading grammar checking tools and APIs like Grammarly (Grammerl
|
|
|
40
40
|
## Overview
|
|
41
41
|
|
|
42
42
|
Sapling is a toolkit for helping developers build language model-powered applications.
|
|
43
|
-
The API provides spelling and grammar checking, autocomplete, tone detection, rephrasing, AI detection,
|
|
43
|
+
The API provides spelling and grammar checking, autocomplete, tone detection, rephrasing, AI detection,
|
|
44
|
+
zero-shot text classification, structured data extraction, PII detection and redaction, content safety
|
|
45
|
+
scoring, SEO analysis, and more.
|
|
44
46
|
|
|
45
47
|
Key Features:
|
|
46
48
|
- **60% More Corrections**: Outperforms [competing solutions](https://sapling.ai/comparison/api#sapling) at similar accuracy levels using state-of-the-art generative AI models.
|
|
@@ -8,7 +8,9 @@ Compare against leading grammar checking tools and APIs like Grammarly (Grammerl
|
|
|
8
8
|
## Overview
|
|
9
9
|
|
|
10
10
|
Sapling is a toolkit for helping developers build language model-powered applications.
|
|
11
|
-
The API provides spelling and grammar checking, autocomplete, tone detection, rephrasing, AI detection,
|
|
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
14
|
|
|
13
15
|
Key Features:
|
|
14
16
|
- **60% More Corrections**: Outperforms [competing solutions](https://sapling.ai/comparison/api#sapling) at similar accuracy levels using state-of-the-art generative AI models.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import requests
|
|
2
2
|
import uuid
|
|
3
|
+
from collections.abc import Iterable
|
|
3
4
|
|
|
4
5
|
|
|
5
6
|
class SaplingError(Exception):
|
|
@@ -631,6 +632,92 @@ class SaplingClient:
|
|
|
631
632
|
}
|
|
632
633
|
return self._request(url, data)
|
|
633
634
|
|
|
635
|
+
def extract(
|
|
636
|
+
self,
|
|
637
|
+
text,
|
|
638
|
+
fields,
|
|
639
|
+
context=None,
|
|
640
|
+
):
|
|
641
|
+
'''
|
|
642
|
+
Extracts structured data from unstructured text: you name the fields you
|
|
643
|
+
want, the API returns their values pulled from the document. Every value is
|
|
644
|
+
grounded in a verbatim span of the input, so this is extraction rather than
|
|
645
|
+
summarization or inference; a field the text does not state is reported
|
|
646
|
+
missing rather than guessed.
|
|
647
|
+
|
|
648
|
+
Example::
|
|
649
|
+
|
|
650
|
+
client.extract(
|
|
651
|
+
'Invoice INV-1042 for Acme Corp, total $1,299.00, due March 5, 2026.',
|
|
652
|
+
fields=[
|
|
653
|
+
'invoice_number',
|
|
654
|
+
{'name': 'total', 'type': 'number', 'description': 'Amount due',
|
|
655
|
+
'required': True},
|
|
656
|
+
{'name': 'due_date', 'type': 'date'},
|
|
657
|
+
'purchase_order',
|
|
658
|
+
],
|
|
659
|
+
context='A vendor invoice',
|
|
660
|
+
)
|
|
661
|
+
# {'data': {'invoice_number': 'INV-1042', 'total': 1299.0,
|
|
662
|
+
# 'due_date': '2026-03-05', 'purchase_order': None},
|
|
663
|
+
# 'fields': [{'name': 'invoice_number', 'type': 'string',
|
|
664
|
+
# 'value': 'INV-1042', 'evidence': 'Invoice INV-1042',
|
|
665
|
+
# 'found': True},
|
|
666
|
+
# {'name': 'total', 'type': 'number', 'value': 1299.0,
|
|
667
|
+
# 'evidence': 'total $1,299.00', 'found': True},
|
|
668
|
+
# {'name': 'due_date', 'type': 'date', 'value': '2026-03-05',
|
|
669
|
+
# 'evidence': 'due March 5, 2026', 'found': True},
|
|
670
|
+
# {'name': 'purchase_order', 'type': 'string', 'value': None,
|
|
671
|
+
# 'evidence': '', 'found': False}],
|
|
672
|
+
# 'missing': ['purchase_order']}
|
|
673
|
+
|
|
674
|
+
:param text: Document to extract from, plain text or HTML (tags are
|
|
675
|
+
stripped), up to 10,000 characters.
|
|
676
|
+
:type text: str
|
|
677
|
+
:param fields: 1-20 fields to extract. Each entry is either a field name
|
|
678
|
+
(str) or a dict ``{'name': str, 'type': str, 'description': str,
|
|
679
|
+
'required': bool}`` where everything but ``name`` is optional. Names are
|
|
680
|
+
up to 50 characters and must be unique (case-insensitive). ``type`` is
|
|
681
|
+
one of ``'string'`` (the default), ``'number'``, ``'integer'``,
|
|
682
|
+
``'boolean'``, ``'date'`` or ``'list'``. ``description`` is up to 200
|
|
683
|
+
characters and sharpens ambiguous fields. ``required`` is a hint to the
|
|
684
|
+
model; a required field the text does not state is still reported
|
|
685
|
+
missing, never guessed.
|
|
686
|
+
:type fields: list[str | dict]
|
|
687
|
+
:raises TypeError: If ``fields`` is None, a single string or a dict rather
|
|
688
|
+
than a list/tuple of fields.
|
|
689
|
+
:param context: Up to 500 characters describing what the document is or how
|
|
690
|
+
to read ambiguous fields (e.g. ``'A vendor invoice'``).
|
|
691
|
+
:type context: str
|
|
692
|
+
:rtype: dict
|
|
693
|
+
:return:
|
|
694
|
+
- data: ``{field name: value}`` map. Every requested field is present,
|
|
695
|
+
with ``None`` where the text did not state it. ``date`` values are
|
|
696
|
+
``YYYY-MM-DD`` strings and ``list`` values are lists of strings.
|
|
697
|
+
- fields: One dict per requested field, in the order requested, with
|
|
698
|
+
``name``, ``type``, ``value``, the verbatim ``evidence`` span the value
|
|
699
|
+
came from (``''`` when not found) and ``found``.
|
|
700
|
+
- missing: Names of the fields the text did not yield, in request order.
|
|
701
|
+
Finding nothing is a normal, successful response.
|
|
702
|
+
'''
|
|
703
|
+
# A bare string would silently be split into one-character field names,
|
|
704
|
+
# a dict would be reduced to its keys, and a non-iterable (None, an int)
|
|
705
|
+
# would raise an opaque "'int' object is not iterable" from list().
|
|
706
|
+
# Fail loudly with a message that names the right shape instead.
|
|
707
|
+
if (not isinstance(fields, Iterable)
|
|
708
|
+
or isinstance(fields, (str, bytes, dict))):
|
|
709
|
+
raise TypeError('fields must be a list of field names or '
|
|
710
|
+
'{name, type, description, required} dicts')
|
|
711
|
+
url = self.url_endpoint + 'extract'
|
|
712
|
+
data = {
|
|
713
|
+
'key': self.api_key,
|
|
714
|
+
'text': text,
|
|
715
|
+
'fields': list(fields),
|
|
716
|
+
}
|
|
717
|
+
if context is not None:
|
|
718
|
+
data['context'] = context
|
|
719
|
+
return self._request(url, data)
|
|
720
|
+
|
|
634
721
|
def tone(
|
|
635
722
|
self,
|
|
636
723
|
text,
|
|
@@ -682,43 +769,79 @@ class SaplingClient:
|
|
|
682
769
|
def quality(
|
|
683
770
|
self,
|
|
684
771
|
text,
|
|
772
|
+
sentence_scores=None,
|
|
773
|
+
rubric=None,
|
|
685
774
|
):
|
|
686
775
|
'''
|
|
687
776
|
Computes a quality score for the provided text based on how "surprising"
|
|
688
777
|
the text is to the model. Lower surprisal corresponds to higher scores.
|
|
778
|
+
Optionally also returns a per-sentence breakdown and an LLM-judged rubric.
|
|
689
779
|
|
|
690
|
-
:param text: Text to compute a quality score for.
|
|
780
|
+
:param text: Text to compute a quality score for. The base score is computed
|
|
781
|
+
on the leading ~4,000 characters; the rubric evaluates the whole text
|
|
782
|
+
(up to 20,000 characters).
|
|
691
783
|
:type text: str
|
|
784
|
+
:param sentence_scores: If true, also return a 1-5 score for each sentence
|
|
785
|
+
under `sentences`.
|
|
786
|
+
:type sentence_scores: bool
|
|
787
|
+
:param rubric: If true, also return an LLM-judged rubric under `rubric`
|
|
788
|
+
(billed at the Tone rate).
|
|
789
|
+
:type rubric: bool
|
|
692
790
|
:rtype: dict
|
|
693
791
|
:return:
|
|
694
792
|
- score: A score from 1 (low quality) to 5 (high quality).
|
|
793
|
+
- sentences: If sentence_scores is set, list of {start, end, text, score} in
|
|
794
|
+
document order; start/end are character offsets into the text.
|
|
795
|
+
- rubric: If rubric is set, {overall, dimensions: {clarity, coherence,
|
|
796
|
+
correctness, concision}, summary, issues: [{quote, dimension, note,
|
|
797
|
+
suggestion, start, end}]}; scores are integers 1-5.
|
|
695
798
|
'''
|
|
696
799
|
url = self.url_endpoint + 'quality'
|
|
697
800
|
data = {
|
|
698
801
|
'key': self.api_key,
|
|
699
802
|
'text': text,
|
|
700
803
|
}
|
|
804
|
+
if sentence_scores is not None:
|
|
805
|
+
data['sentence_scores'] = sentence_scores
|
|
806
|
+
if rubric is not None:
|
|
807
|
+
data['rubric'] = rubric
|
|
701
808
|
return self._request(url, data)
|
|
702
809
|
|
|
703
810
|
def langdetect(
|
|
704
811
|
self,
|
|
705
812
|
text,
|
|
813
|
+
top_k=None,
|
|
814
|
+
segments=None,
|
|
706
815
|
):
|
|
707
816
|
'''
|
|
708
|
-
Identifies the language of the provided text
|
|
817
|
+
Identifies the language of the provided text, with optional runner-up
|
|
818
|
+
candidates and a per-segment breakdown for mixed-language text.
|
|
709
819
|
|
|
710
|
-
:param text: Text to identify the language for.
|
|
820
|
+
:param text: Text to identify the language for (up to 20,000 characters).
|
|
711
821
|
:type text: str
|
|
822
|
+
:param top_k: Number of language candidates to return, 1-10. Defaults to 3 server-side.
|
|
823
|
+
:type top_k: int
|
|
824
|
+
:param segments: If true, also detect the language of each line/sentence
|
|
825
|
+
of the text and return them under `segments`.
|
|
826
|
+
:type segments: bool
|
|
712
827
|
:rtype: dict
|
|
713
828
|
:return:
|
|
714
|
-
- lang: The detected language code.
|
|
829
|
+
- lang: The detected language code (mostly ISO 639-1).
|
|
830
|
+
- name: English name of the detected language.
|
|
715
831
|
- conf: Confidence score for the detection result.
|
|
832
|
+
- candidates: List of {lang, name, conf} for the top languages, most likely first.
|
|
833
|
+
- segments: If segments is set, list of {start, end, text, lang, name, conf, candidates}
|
|
834
|
+
in document order; start/end are character offsets into the text.
|
|
716
835
|
'''
|
|
717
836
|
url = self.url_endpoint + 'langdetect'
|
|
718
837
|
data = {
|
|
719
838
|
'key': self.api_key,
|
|
720
839
|
'text': text,
|
|
721
840
|
}
|
|
841
|
+
if top_k is not None:
|
|
842
|
+
data['top_k'] = top_k
|
|
843
|
+
if segments is not None:
|
|
844
|
+
data['segments'] = segments
|
|
722
845
|
return self._request(url, data)
|
|
723
846
|
|
|
724
847
|
def profanity(
|
|
@@ -741,3 +864,229 @@ class SaplingClient:
|
|
|
741
864
|
'text': text,
|
|
742
865
|
}
|
|
743
866
|
return self._request(url, data)
|
|
867
|
+
|
|
868
|
+
def safety(
|
|
869
|
+
self,
|
|
870
|
+
text,
|
|
871
|
+
threshold=None,
|
|
872
|
+
):
|
|
873
|
+
'''
|
|
874
|
+
Scores the provided text on seven content-safety categories: toxicity,
|
|
875
|
+
profanity, harassment, hate_speech, self_harm, sexual, and violence.
|
|
876
|
+
|
|
877
|
+
:param text: Text to score, up to 20,000 characters.
|
|
878
|
+
:type text: str
|
|
879
|
+
:param threshold: Score at or above which a category is flagged.
|
|
880
|
+
Between 0 and 1 inclusive; the API defaults to 0.5.
|
|
881
|
+
:type threshold: float
|
|
882
|
+
:rtype: dict
|
|
883
|
+
:return:
|
|
884
|
+
- scores: A probability from 0 to 1 for each category.
|
|
885
|
+
- flagged: True if any category scored at or above the threshold.
|
|
886
|
+
- flagged_categories: Categories that scored at or above the threshold.
|
|
887
|
+
- threshold: The threshold that was applied.
|
|
888
|
+
'''
|
|
889
|
+
url = self.url_endpoint + 'safety'
|
|
890
|
+
data = {
|
|
891
|
+
'key': self.api_key,
|
|
892
|
+
'text': text,
|
|
893
|
+
}
|
|
894
|
+
if threshold is not None:
|
|
895
|
+
data['threshold'] = threshold
|
|
896
|
+
return self._request(url, data)
|
|
897
|
+
|
|
898
|
+
def pii(
|
|
899
|
+
self,
|
|
900
|
+
text,
|
|
901
|
+
types=None,
|
|
902
|
+
redact=None,
|
|
903
|
+
):
|
|
904
|
+
'''
|
|
905
|
+
Detects personally identifiable information (PII) in the provided text and
|
|
906
|
+
optionally returns a redacted copy.
|
|
907
|
+
|
|
908
|
+
Detection is deterministic (pattern matching plus checksum validation, no ML)
|
|
909
|
+
and every entity comes with exact character offsets into ``text``, so pass
|
|
910
|
+
raw text (not HTML) and apply the spans to your own copy of the document.
|
|
911
|
+
Person names and street addresses are not detected.
|
|
912
|
+
|
|
913
|
+
:param text: Text to scan for PII.
|
|
914
|
+
:type text: str
|
|
915
|
+
:param types: Optional list of PII types to detect. Defaults to all types:
|
|
916
|
+
``email``, ``phone``, ``ssn``, ``credit_card``, ``ip_address``, ``iban``,
|
|
917
|
+
``us_bank_routing``.
|
|
918
|
+
:type types: list[str]
|
|
919
|
+
:param redact: If True, the response also includes ``redacted_text`` with every
|
|
920
|
+
detected entity replaced by a placeholder such as ``[EMAIL]``.
|
|
921
|
+
:type redact: bool
|
|
922
|
+
:rtype: dict
|
|
923
|
+
:return:
|
|
924
|
+
- entities: List of ``{type, text, start, end, replacement}`` dicts, sorted
|
|
925
|
+
by ``start``; spans never overlap.
|
|
926
|
+
- flagged: True if any PII was detected.
|
|
927
|
+
- types: Sorted list of the PII types found.
|
|
928
|
+
- redacted_text: Only present when ``redact`` is True.
|
|
929
|
+
'''
|
|
930
|
+
url = self.url_endpoint + 'pii'
|
|
931
|
+
data = {
|
|
932
|
+
'key': self.api_key,
|
|
933
|
+
'text': text,
|
|
934
|
+
}
|
|
935
|
+
if types is not None:
|
|
936
|
+
if (not isinstance(types, Iterable)
|
|
937
|
+
or isinstance(types, (str, bytes, dict))):
|
|
938
|
+
raise TypeError('types must be a list of entity type names')
|
|
939
|
+
data['types'] = list(types)
|
|
940
|
+
if redact is not None:
|
|
941
|
+
data['redact'] = redact
|
|
942
|
+
return self._request(url, data)
|
|
943
|
+
|
|
944
|
+
def seo(
|
|
945
|
+
self,
|
|
946
|
+
text,
|
|
947
|
+
keywords=None,
|
|
948
|
+
suggestions=None,
|
|
949
|
+
lang=None,
|
|
950
|
+
):
|
|
951
|
+
'''
|
|
952
|
+
Analyzes the provided page or article text for SEO: deterministic content
|
|
953
|
+
statistics and readability, target-keyword usage, the most frequent terms, and
|
|
954
|
+
(optionally) LLM-generated titles, meta descriptions, a URL slug, and focus
|
|
955
|
+
keywords.
|
|
956
|
+
|
|
957
|
+
:param text: Page or article body, plain text or HTML (block tags become line
|
|
958
|
+
breaks, inline tags are removed), up to 20,000 characters.
|
|
959
|
+
:type text: str
|
|
960
|
+
:param keywords: Optional list of up to 10 target keyword phrases, most
|
|
961
|
+
important first (the first is the primary keyword). Each 1-100 characters.
|
|
962
|
+
Measured in the text and steered into the suggestions.
|
|
963
|
+
:type keywords: list[str]
|
|
964
|
+
:param suggestions: If False, only the deterministic ``stats``, ``keywords`` and
|
|
965
|
+
``top_terms`` are returned (free); no ``suggestions`` are generated. The API
|
|
966
|
+
defaults to True (billed at the Tone rate).
|
|
967
|
+
:type suggestions: bool
|
|
968
|
+
:param lang: ISO 639-1 language code of the text (a region subtag such as
|
|
969
|
+
``pt-BR`` is allowed, or ``auto``). The API defaults to ``en``. Only selects
|
|
970
|
+
the readability formulas: ``flesch_reading_ease`` and
|
|
971
|
+
``flesch_kincaid_grade`` are computed for ``en``, ``de``, ``es``, ``fr``,
|
|
972
|
+
``it``, ``nl``, ``pl`` and ``ru`` and are None for any other language.
|
|
973
|
+
:type lang: str
|
|
974
|
+
:rtype: dict
|
|
975
|
+
:return:
|
|
976
|
+
- stats: ``{chars, words, sentences, paragraphs, reading_time_min,
|
|
977
|
+
flesch_reading_ease, flesch_kincaid_grade}``; the two readability fields
|
|
978
|
+
are None for languages without readability support.
|
|
979
|
+
- keywords: One ``{keyword, count, density, in_first_100_words}`` dict per
|
|
980
|
+
submitted target keyword, in the same order; empty when none were sent.
|
|
981
|
+
- top_terms: Up to 10 most frequent content unigrams/bigrams as
|
|
982
|
+
``{term, count}`` dicts (lower-cased, English stopwords removed).
|
|
983
|
+
- suggestions: Only present when ``suggestions`` is not False:
|
|
984
|
+
``{titles, meta_descriptions, slug, keywords}`` where ``titles`` and
|
|
985
|
+
``meta_descriptions`` are lists of strings, ``slug`` is a kebab-case
|
|
986
|
+
string (or None when no ASCII slug could be derived, e.g. purely CJK
|
|
987
|
+
content) and ``keywords`` is a list of focus-keyword phrases.
|
|
988
|
+
'''
|
|
989
|
+
url = self.url_endpoint + 'seo'
|
|
990
|
+
data = {
|
|
991
|
+
'key': self.api_key,
|
|
992
|
+
'text': text,
|
|
993
|
+
}
|
|
994
|
+
if keywords is not None:
|
|
995
|
+
if (not isinstance(keywords, Iterable)
|
|
996
|
+
or isinstance(keywords, (str, bytes, dict))):
|
|
997
|
+
raise TypeError('keywords must be a list of keyword strings')
|
|
998
|
+
data['keywords'] = list(keywords)
|
|
999
|
+
if suggestions is not None:
|
|
1000
|
+
data['suggestions'] = suggestions
|
|
1001
|
+
if lang is not None:
|
|
1002
|
+
data['lang'] = lang
|
|
1003
|
+
return self._request(url, data)
|
|
1004
|
+
|
|
1005
|
+
def classify(
|
|
1006
|
+
self,
|
|
1007
|
+
text,
|
|
1008
|
+
labels,
|
|
1009
|
+
multi_label=None,
|
|
1010
|
+
threshold=None,
|
|
1011
|
+
context=None,
|
|
1012
|
+
):
|
|
1013
|
+
'''
|
|
1014
|
+
Classifies the provided text into one (or several) of the labels you supply.
|
|
1015
|
+
Zero-shot: no training data or setup is needed, just the label names and,
|
|
1016
|
+
optionally, a short description per label.
|
|
1017
|
+
|
|
1018
|
+
Example::
|
|
1019
|
+
|
|
1020
|
+
client.classify(
|
|
1021
|
+
'I was charged twice for my last invoice.',
|
|
1022
|
+
labels=[
|
|
1023
|
+
'billing',
|
|
1024
|
+
{'name': 'technical issue', 'description': 'Bugs, crashes, errors'},
|
|
1025
|
+
'shipping',
|
|
1026
|
+
'other',
|
|
1027
|
+
],
|
|
1028
|
+
context='Support tickets for a SaaS billing product',
|
|
1029
|
+
)
|
|
1030
|
+
# {'label': 'billing',
|
|
1031
|
+
# 'labels': ['billing'],
|
|
1032
|
+
# 'scores': [{'label': 'billing', 'score': 0.86},
|
|
1033
|
+
# {'label': 'technical issue', 'score': 0.07},
|
|
1034
|
+
# {'label': 'shipping', 'score': 0.04},
|
|
1035
|
+
# {'label': 'other', 'score': 0.03}],
|
|
1036
|
+
# 'rationale': 'The customer reports being charged twice on one invoice.',
|
|
1037
|
+
# 'multi_label': False}
|
|
1038
|
+
|
|
1039
|
+
:param text: Text to classify, plain text or HTML (tags are stripped), up to
|
|
1040
|
+
10,000 characters.
|
|
1041
|
+
:type text: str
|
|
1042
|
+
:param labels: 2-20 candidate labels. Each entry is either a label name (str) or
|
|
1043
|
+
a dict ``{'name': str, 'description': str}`` where ``description`` is
|
|
1044
|
+
optional. Names are up to 50 characters and must be unique
|
|
1045
|
+
(case-insensitive); descriptions are up to 200 characters and help the
|
|
1046
|
+
model with borderline decisions.
|
|
1047
|
+
:type labels: list[str | dict]
|
|
1048
|
+
:raises TypeError: If ``labels`` is None, a single string or a dict rather
|
|
1049
|
+
than a list/tuple of labels.
|
|
1050
|
+
:param multi_label: If False (the API default), exactly one label applies and
|
|
1051
|
+
the scores form a probability distribution over the labels. If True, zero,
|
|
1052
|
+
one or several labels may apply and each score is an independent 0-1
|
|
1053
|
+
probability.
|
|
1054
|
+
:type multi_label: bool
|
|
1055
|
+
:param threshold: Multi-label cut-off, 0-1: ``labels`` in the response contains
|
|
1056
|
+
every label with ``score >= threshold``. The API defaults to 0.5. Ignored in
|
|
1057
|
+
single-label mode.
|
|
1058
|
+
:type threshold: float
|
|
1059
|
+
:param context: Up to 500 characters describing what the texts are or how to
|
|
1060
|
+
decide (e.g. ``'Support tickets for a SaaS billing product'``). Guidance
|
|
1061
|
+
only; the model can still only answer with the labels you provide.
|
|
1062
|
+
:type context: str
|
|
1063
|
+
:rtype: dict
|
|
1064
|
+
:return:
|
|
1065
|
+
- label: Best-matching label name (always one of the input labels).
|
|
1066
|
+
- labels: Single-label mode: ``[label]``. Multi-label mode: every label
|
|
1067
|
+
with ``score >= threshold``, descending by score; may be empty when none
|
|
1068
|
+
of the labels apply.
|
|
1069
|
+
- scores: One ``{label, score}`` dict per input label, sorted descending
|
|
1070
|
+
by score (0-1, rounded to 4 decimals).
|
|
1071
|
+
- rationale: One sentence naming the evidence in the text (may be empty).
|
|
1072
|
+
- multi_label: The mode that was used.
|
|
1073
|
+
'''
|
|
1074
|
+
# A bare string would silently be split into one-character labels and
|
|
1075
|
+
# None would raise an opaque TypeError from list(); fail loudly instead.
|
|
1076
|
+
if (not isinstance(labels, Iterable)
|
|
1077
|
+
or isinstance(labels, (str, bytes, dict))):
|
|
1078
|
+
raise TypeError('labels must be a list of label names or '
|
|
1079
|
+
'{name, description} dicts')
|
|
1080
|
+
url = self.url_endpoint + 'classify'
|
|
1081
|
+
data = {
|
|
1082
|
+
'key': self.api_key,
|
|
1083
|
+
'text': text,
|
|
1084
|
+
'labels': list(labels),
|
|
1085
|
+
}
|
|
1086
|
+
if multi_label is not None:
|
|
1087
|
+
data['multi_label'] = multi_label
|
|
1088
|
+
if threshold is not None:
|
|
1089
|
+
data['threshold'] = threshold
|
|
1090
|
+
if context is not None:
|
|
1091
|
+
data['context'] = context
|
|
1092
|
+
return self._request(url, data)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '4.2.0'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sapling-py
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.2.0
|
|
4
4
|
Summary: Sapling Python Client
|
|
5
5
|
Home-page: https://sapling.ai
|
|
6
6
|
Author: Sapling Intelligence
|
|
@@ -40,7 +40,9 @@ Compare against leading grammar checking tools and APIs like Grammarly (Grammerl
|
|
|
40
40
|
## Overview
|
|
41
41
|
|
|
42
42
|
Sapling is a toolkit for helping developers build language model-powered applications.
|
|
43
|
-
The API provides spelling and grammar checking, autocomplete, tone detection, rephrasing, AI detection,
|
|
43
|
+
The API provides spelling and grammar checking, autocomplete, tone detection, rephrasing, AI detection,
|
|
44
|
+
zero-shot text classification, structured data extraction, PII detection and redaction, content safety
|
|
45
|
+
scoring, SEO analysis, and more.
|
|
44
46
|
|
|
45
47
|
Key Features:
|
|
46
48
|
- **60% More Corrections**: Outperforms [competing solutions](https://sapling.ai/comparison/api#sapling) at similar accuracy levels using state-of-the-art generative AI models.
|
|
@@ -9,5 +9,9 @@ sapling_py.egg-info/SOURCES.txt
|
|
|
9
9
|
sapling_py.egg-info/dependency_links.txt
|
|
10
10
|
sapling_py.egg-info/requires.txt
|
|
11
11
|
sapling_py.egg-info/top_level.txt
|
|
12
|
-
test/
|
|
13
|
-
test/test_client.py
|
|
12
|
+
test/test_classify.py
|
|
13
|
+
test/test_client.py
|
|
14
|
+
test/test_extract.py
|
|
15
|
+
test/test_pii.py
|
|
16
|
+
test/test_safety.py
|
|
17
|
+
test/test_seo.py
|
|
@@ -0,0 +1,200 @@
|
|
|
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
|
+
LABELS = ['billing', 'technical issue', 'shipping', 'other']
|
|
12
|
+
|
|
13
|
+
CLASSIFY_RESPONSE = {
|
|
14
|
+
'label': 'billing',
|
|
15
|
+
'labels': ['billing'],
|
|
16
|
+
'scores': [
|
|
17
|
+
{'label': 'billing', 'score': 0.86},
|
|
18
|
+
{'label': 'technical issue', 'score': 0.07},
|
|
19
|
+
{'label': 'shipping', 'score': 0.04},
|
|
20
|
+
{'label': 'other', 'score': 0.03},
|
|
21
|
+
],
|
|
22
|
+
'rationale': 'The customer reports being charged twice on one invoice.',
|
|
23
|
+
'multi_label': False,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
MULTI_LABEL_RESPONSE = {
|
|
27
|
+
'label': 'billing',
|
|
28
|
+
'labels': ['billing', 'technical issue'],
|
|
29
|
+
'scores': [
|
|
30
|
+
{'label': 'billing', 'score': 0.92},
|
|
31
|
+
{'label': 'technical issue', 'score': 0.81},
|
|
32
|
+
{'label': 'shipping', 'score': 0.03},
|
|
33
|
+
{'label': 'other', 'score': 0.02},
|
|
34
|
+
],
|
|
35
|
+
'rationale': 'Mentions a duplicate charge and an app crash.',
|
|
36
|
+
'multi_label': True,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.fixture
|
|
41
|
+
def client():
|
|
42
|
+
return SaplingClient(api_key=API_KEY)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _last_request_body():
|
|
46
|
+
return json.loads(responses.calls[-1].request.body)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@responses.activate
|
|
50
|
+
def test_classify_returns_json_and_sends_key(client):
|
|
51
|
+
responses.add(responses.POST, BASE + 'classify', json=CLASSIFY_RESPONSE, status=200)
|
|
52
|
+
result = client.classify('I was charged twice for my last invoice.', LABELS)
|
|
53
|
+
assert result == CLASSIFY_RESPONSE
|
|
54
|
+
body = _last_request_body()
|
|
55
|
+
assert body == {'key': API_KEY, 'text': 'I was charged twice for my last invoice.',
|
|
56
|
+
'labels': LABELS}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@responses.activate
|
|
60
|
+
def test_classify_posts_to_classify_endpoint(client):
|
|
61
|
+
responses.add(responses.POST, BASE + 'classify', json=CLASSIFY_RESPONSE, status=200)
|
|
62
|
+
client.classify('some text', LABELS)
|
|
63
|
+
assert len(responses.calls) == 1
|
|
64
|
+
assert responses.calls[0].request.url == BASE + 'classify'
|
|
65
|
+
assert responses.calls[0].request.method == 'POST'
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@responses.activate
|
|
69
|
+
def test_classify_label_dicts_passed_through(client):
|
|
70
|
+
responses.add(responses.POST, BASE + 'classify', json=CLASSIFY_RESPONSE, status=200)
|
|
71
|
+
labels = [
|
|
72
|
+
'billing',
|
|
73
|
+
{'name': 'technical issue', 'description': 'Bugs, crashes, errors'},
|
|
74
|
+
{'name': 'shipping'},
|
|
75
|
+
'other',
|
|
76
|
+
]
|
|
77
|
+
client.classify('some text', labels)
|
|
78
|
+
body = _last_request_body()
|
|
79
|
+
assert body['labels'] == labels
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@responses.activate
|
|
83
|
+
def test_classify_labels_tuple_sent_as_list(client):
|
|
84
|
+
responses.add(responses.POST, BASE + 'classify', json=CLASSIFY_RESPONSE, status=200)
|
|
85
|
+
client.classify('some text', ('billing', 'shipping'))
|
|
86
|
+
body = _last_request_body()
|
|
87
|
+
assert body['labels'] == ['billing', 'shipping']
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@responses.activate
|
|
91
|
+
def test_classify_optional_params_omitted_by_default(client):
|
|
92
|
+
responses.add(responses.POST, BASE + 'classify', json=CLASSIFY_RESPONSE, status=200)
|
|
93
|
+
client.classify('some text', LABELS)
|
|
94
|
+
body = _last_request_body()
|
|
95
|
+
assert 'multi_label' not in body
|
|
96
|
+
assert 'threshold' not in body
|
|
97
|
+
assert 'context' not in body
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@responses.activate
|
|
101
|
+
def test_classify_optional_params_sent_when_provided(client):
|
|
102
|
+
responses.add(responses.POST, BASE + 'classify', json=MULTI_LABEL_RESPONSE, status=200)
|
|
103
|
+
result = client.classify('I was charged twice and the app crashes on launch', LABELS,
|
|
104
|
+
multi_label=True, threshold=0.7,
|
|
105
|
+
context='Support tickets for a SaaS billing product')
|
|
106
|
+
assert result == MULTI_LABEL_RESPONSE
|
|
107
|
+
assert result['labels'] == ['billing', 'technical issue']
|
|
108
|
+
body = _last_request_body()
|
|
109
|
+
assert body == {'key': API_KEY,
|
|
110
|
+
'text': 'I was charged twice and the app crashes on launch',
|
|
111
|
+
'labels': LABELS,
|
|
112
|
+
'multi_label': True, 'threshold': 0.7,
|
|
113
|
+
'context': 'Support tickets for a SaaS billing product'}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@responses.activate
|
|
117
|
+
def test_classify_multi_label_false_sent(client):
|
|
118
|
+
responses.add(responses.POST, BASE + 'classify', json=CLASSIFY_RESPONSE, status=200)
|
|
119
|
+
client.classify('some text', LABELS, multi_label=False)
|
|
120
|
+
body = _last_request_body()
|
|
121
|
+
assert body['multi_label'] is False
|
|
122
|
+
assert 'threshold' not in body
|
|
123
|
+
assert 'context' not in body
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@responses.activate
|
|
127
|
+
def test_classify_threshold_zero_sent(client):
|
|
128
|
+
responses.add(responses.POST, BASE + 'classify', json=MULTI_LABEL_RESPONSE, status=200)
|
|
129
|
+
client.classify('some text', LABELS, multi_label=True, threshold=0)
|
|
130
|
+
body = _last_request_body()
|
|
131
|
+
assert body['threshold'] == 0
|
|
132
|
+
assert body['multi_label'] is True
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@responses.activate
|
|
136
|
+
def test_classify_context_sent_when_provided(client):
|
|
137
|
+
responses.add(responses.POST, BASE + 'classify', json=CLASSIFY_RESPONSE, status=200)
|
|
138
|
+
client.classify('some text', LABELS, context='Product reviews')
|
|
139
|
+
body = _last_request_body()
|
|
140
|
+
assert body['context'] == 'Product reviews'
|
|
141
|
+
assert 'multi_label' not in body
|
|
142
|
+
assert 'threshold' not in body
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@responses.activate
|
|
146
|
+
def test_classify_multi_label_empty_labels_returned_verbatim(client):
|
|
147
|
+
# "None of these apply" is a valid multi-label answer.
|
|
148
|
+
payload = dict(MULTI_LABEL_RESPONSE, labels=[], rationale='')
|
|
149
|
+
responses.add(responses.POST, BASE + 'classify', json=payload, status=200)
|
|
150
|
+
result = client.classify('some text', LABELS, multi_label=True, threshold=0.95)
|
|
151
|
+
assert result['labels'] == []
|
|
152
|
+
assert result['rationale'] == ''
|
|
153
|
+
assert result['scores'] == MULTI_LABEL_RESPONSE['scores']
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@responses.activate
|
|
157
|
+
def test_classify_error_raises_saplingerror_with_body(client):
|
|
158
|
+
responses.add(responses.POST, BASE + 'classify',
|
|
159
|
+
json={'msg': 'Invalid labels: Needs between 2 and 20 labels.'}, status=400)
|
|
160
|
+
with pytest.raises(SaplingError) as exc_info:
|
|
161
|
+
client.classify('some text', ['only one'])
|
|
162
|
+
err = exc_info.value
|
|
163
|
+
assert err.status_code == 400
|
|
164
|
+
assert 'Invalid labels: Needs between 2 and 20 labels.' in str(err)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@responses.activate
|
|
168
|
+
def test_classify_upstream_error_raises_saplingerror(client):
|
|
169
|
+
responses.add(responses.POST, BASE + 'classify',
|
|
170
|
+
json={'msg': 'Unexpected error classifying text.'}, status=502)
|
|
171
|
+
with pytest.raises(SaplingError) as exc_info:
|
|
172
|
+
client.classify('some text', LABELS)
|
|
173
|
+
err = exc_info.value
|
|
174
|
+
assert err.status_code == 502
|
|
175
|
+
assert 'Unexpected error classifying text.' in str(err)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@responses.activate
|
|
179
|
+
def test_classify_unauthorized_raises_saplingerror(client):
|
|
180
|
+
responses.add(responses.POST, BASE + 'classify', json={'msg': 'Invalid API key.'},
|
|
181
|
+
status=401)
|
|
182
|
+
with pytest.raises(SaplingError) as exc_info:
|
|
183
|
+
client.classify('some text', LABELS)
|
|
184
|
+
assert exc_info.value.status_code == 401
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@responses.activate
|
|
188
|
+
def test_classify_custom_hostname_and_pathname():
|
|
189
|
+
client = SaplingClient(api_key=API_KEY, hostname='https://sapling.example.com',
|
|
190
|
+
pathname='/custom/v1/')
|
|
191
|
+
responses.add(responses.POST, 'https://sapling.example.com/custom/v1/classify',
|
|
192
|
+
json=CLASSIFY_RESPONSE, status=200)
|
|
193
|
+
result = client.classify('some text', LABELS)
|
|
194
|
+
assert result == CLASSIFY_RESPONSE
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@pytest.mark.parametrize('bad_labels', [None, 'billing', b'billing', {'name': 'billing'}, 42, True])
|
|
198
|
+
def test_classify_rejects_non_list_labels(client, bad_labels):
|
|
199
|
+
with pytest.raises(TypeError):
|
|
200
|
+
client.classify('I was charged twice.', bad_labels)
|
|
@@ -139,6 +139,10 @@ def test_rephrase_sends_expected_params(client):
|
|
|
139
139
|
('quality', 'quality', {'score': 3.5}),
|
|
140
140
|
('langdetect', 'langdetect', {'lang': 'en', 'conf': 0.99}),
|
|
141
141
|
('profanity', 'profanity', {'toks': ['a'], 'labels': [0]}),
|
|
142
|
+
('safety', 'safety', {
|
|
143
|
+
'scores': {'toxicity': 0.9}, 'flagged': True,
|
|
144
|
+
'flagged_categories': ['toxicity'], 'threshold': 0.5,
|
|
145
|
+
}),
|
|
142
146
|
],
|
|
143
147
|
)
|
|
144
148
|
@responses.activate
|
|
@@ -150,9 +154,42 @@ def test_single_text_endpoints(client, method, path, payload):
|
|
|
150
154
|
assert body == {'key': API_KEY, 'text': 'some text'}
|
|
151
155
|
|
|
152
156
|
|
|
157
|
+
@responses.activate
|
|
158
|
+
def test_langdetect_options(client):
|
|
159
|
+
payload = {
|
|
160
|
+
'lang': 'en', 'name': 'English', 'conf': 0.6,
|
|
161
|
+
'candidates': [{'lang': 'en', 'name': 'English', 'conf': 0.6}],
|
|
162
|
+
'segments': [{'start': 0, 'end': 5, 'text': 'Hello', 'lang': 'en', 'name': 'English',
|
|
163
|
+
'conf': 0.9, 'candidates': [{'lang': 'en', 'name': 'English', 'conf': 0.9}]}],
|
|
164
|
+
}
|
|
165
|
+
responses.add(responses.POST, BASE + 'langdetect', json=payload, status=200)
|
|
166
|
+
result = client.langdetect('Hello', top_k=1, segments=True)
|
|
167
|
+
assert result == payload
|
|
168
|
+
assert _last_request_body() == {'key': API_KEY, 'text': 'Hello', 'top_k': 1, 'segments': True}
|
|
169
|
+
|
|
170
|
+
|
|
153
171
|
@responses.activate
|
|
154
172
|
def test_hostname_and_pathname_override():
|
|
155
173
|
client = SaplingClient(api_key=API_KEY, hostname='http://localhost:5000', pathname='/api/v1/')
|
|
156
174
|
responses.add(responses.POST, 'http://localhost:5000/api/v1/edits', json={'edits': []}, status=200)
|
|
157
175
|
client.edits('hi')
|
|
158
176
|
assert responses.calls[-1].request.url == 'http://localhost:5000/api/v1/edits'
|
|
177
|
+
|
|
178
|
+
@responses.activate
|
|
179
|
+
def test_quality_options(client):
|
|
180
|
+
payload = {
|
|
181
|
+
'score': 3.4,
|
|
182
|
+
'sentences': [{'start': 0, 'end': 5, 'text': 'Hello', 'score': 4.1}],
|
|
183
|
+
'rubric': {
|
|
184
|
+
'overall': 3,
|
|
185
|
+
'dimensions': {'clarity': 4, 'coherence': 3, 'correctness': 3, 'concision': 2},
|
|
186
|
+
'summary': 'Fine.',
|
|
187
|
+
'issues': [{'quote': 'Hello', 'dimension': 'clarity', 'note': 'n',
|
|
188
|
+
'suggestion': 'Hi', 'start': 0, 'end': 5}],
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
responses.add(responses.POST, BASE + 'quality', json=payload, status=200)
|
|
192
|
+
result = client.quality('Hello', sentence_scores=True, rubric=True)
|
|
193
|
+
assert result == payload
|
|
194
|
+
assert _last_request_body() == {'key': API_KEY, 'text': 'Hello',
|
|
195
|
+
'sentence_scores': True, 'rubric': True}
|
|
@@ -0,0 +1,213 @@
|
|
|
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 = 'Invoice INV-1042 for Acme Corp, total $1,299.00, due March 5, 2026.'
|
|
12
|
+
|
|
13
|
+
FIELDS = [
|
|
14
|
+
'invoice_number',
|
|
15
|
+
{'name': 'total', 'type': 'number', 'description': 'Amount due', 'required': True},
|
|
16
|
+
{'name': 'due_date', 'type': 'date'},
|
|
17
|
+
'purchase_order',
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
EXTRACT_RESPONSE = {
|
|
21
|
+
'data': {
|
|
22
|
+
'invoice_number': 'INV-1042',
|
|
23
|
+
'total': 1299.0,
|
|
24
|
+
'due_date': '2026-03-05',
|
|
25
|
+
'purchase_order': None,
|
|
26
|
+
},
|
|
27
|
+
'fields': [
|
|
28
|
+
{'name': 'invoice_number', 'type': 'string', 'value': 'INV-1042',
|
|
29
|
+
'evidence': 'Invoice INV-1042', 'found': True},
|
|
30
|
+
{'name': 'total', 'type': 'number', 'value': 1299.0,
|
|
31
|
+
'evidence': 'total $1,299.00', 'found': True},
|
|
32
|
+
{'name': 'due_date', 'type': 'date', 'value': '2026-03-05',
|
|
33
|
+
'evidence': 'due March 5, 2026', 'found': True},
|
|
34
|
+
{'name': 'purchase_order', 'type': 'string', 'value': None,
|
|
35
|
+
'evidence': '', 'found': False},
|
|
36
|
+
],
|
|
37
|
+
'missing': ['purchase_order'],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.fixture
|
|
42
|
+
def client():
|
|
43
|
+
return SaplingClient(api_key=API_KEY)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _last_request_body():
|
|
47
|
+
return json.loads(responses.calls[-1].request.body)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@responses.activate
|
|
51
|
+
def test_extract_returns_json_and_sends_key(client):
|
|
52
|
+
responses.add(responses.POST, BASE + 'extract', json=EXTRACT_RESPONSE, status=200)
|
|
53
|
+
result = client.extract(TEXT, FIELDS)
|
|
54
|
+
assert result == EXTRACT_RESPONSE
|
|
55
|
+
body = _last_request_body()
|
|
56
|
+
assert body == {'key': API_KEY, 'text': TEXT, 'fields': FIELDS}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@responses.activate
|
|
60
|
+
def test_extract_posts_to_extract_endpoint(client):
|
|
61
|
+
responses.add(responses.POST, BASE + 'extract', json=EXTRACT_RESPONSE, status=200)
|
|
62
|
+
client.extract('some text', ['invoice_number'])
|
|
63
|
+
assert len(responses.calls) == 1
|
|
64
|
+
assert responses.calls[0].request.url == BASE + 'extract'
|
|
65
|
+
assert responses.calls[0].request.method == 'POST'
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@responses.activate
|
|
69
|
+
def test_extract_field_dicts_passed_through(client):
|
|
70
|
+
responses.add(responses.POST, BASE + 'extract', json=EXTRACT_RESPONSE, status=200)
|
|
71
|
+
fields = [
|
|
72
|
+
'invoice_number',
|
|
73
|
+
{'name': 'total', 'type': 'number', 'description': 'Amount due', 'required': True},
|
|
74
|
+
{'name': 'line_items', 'type': 'list'},
|
|
75
|
+
{'name': 'is_urgent', 'type': 'boolean'},
|
|
76
|
+
]
|
|
77
|
+
client.extract('some text', fields)
|
|
78
|
+
body = _last_request_body()
|
|
79
|
+
assert body['fields'] == fields
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@responses.activate
|
|
83
|
+
def test_extract_fields_tuple_sent_as_list(client):
|
|
84
|
+
responses.add(responses.POST, BASE + 'extract', json=EXTRACT_RESPONSE, status=200)
|
|
85
|
+
client.extract('some text', ('invoice_number', 'total'))
|
|
86
|
+
body = _last_request_body()
|
|
87
|
+
assert body['fields'] == ['invoice_number', 'total']
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@responses.activate
|
|
91
|
+
def test_extract_optional_params_omitted_by_default(client):
|
|
92
|
+
responses.add(responses.POST, BASE + 'extract', json=EXTRACT_RESPONSE, status=200)
|
|
93
|
+
client.extract('some text', FIELDS)
|
|
94
|
+
body = _last_request_body()
|
|
95
|
+
assert 'context' not in body
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@responses.activate
|
|
99
|
+
def test_extract_context_sent_when_provided(client):
|
|
100
|
+
responses.add(responses.POST, BASE + 'extract', json=EXTRACT_RESPONSE, status=200)
|
|
101
|
+
result = client.extract(TEXT, FIELDS, context='A vendor invoice')
|
|
102
|
+
assert result == EXTRACT_RESPONSE
|
|
103
|
+
body = _last_request_body()
|
|
104
|
+
assert body == {'key': API_KEY, 'text': TEXT, 'fields': FIELDS,
|
|
105
|
+
'context': 'A vendor invoice'}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@responses.activate
|
|
109
|
+
def test_extract_empty_context_sent(client):
|
|
110
|
+
# '' is not None, so it is forwarded rather than dropped.
|
|
111
|
+
responses.add(responses.POST, BASE + 'extract', json=EXTRACT_RESPONSE, status=200)
|
|
112
|
+
client.extract('some text', FIELDS, context='')
|
|
113
|
+
body = _last_request_body()
|
|
114
|
+
assert body['context'] == ''
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@responses.activate
|
|
118
|
+
def test_extract_nothing_found_returned_verbatim(client):
|
|
119
|
+
# Finding nothing is a normal 200, not an error.
|
|
120
|
+
payload = {
|
|
121
|
+
'data': {'invoice_number': None, 'total': None},
|
|
122
|
+
'fields': [
|
|
123
|
+
{'name': 'invoice_number', 'type': 'string', 'value': None,
|
|
124
|
+
'evidence': '', 'found': False},
|
|
125
|
+
{'name': 'total', 'type': 'number', 'value': None,
|
|
126
|
+
'evidence': '', 'found': False},
|
|
127
|
+
],
|
|
128
|
+
'missing': ['invoice_number', 'total'],
|
|
129
|
+
}
|
|
130
|
+
responses.add(responses.POST, BASE + 'extract', json=payload, status=200)
|
|
131
|
+
result = client.extract('Nothing invoice-like here.', ['invoice_number', 'total'])
|
|
132
|
+
assert result == payload
|
|
133
|
+
assert result['missing'] == ['invoice_number', 'total']
|
|
134
|
+
assert all(value is None for value in result['data'].values())
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@responses.activate
|
|
138
|
+
def test_extract_error_raises_saplingerror_with_body(client):
|
|
139
|
+
responses.add(responses.POST, BASE + 'extract',
|
|
140
|
+
json={'msg': 'Invalid fields: Needs between 1 and 20 fields.'},
|
|
141
|
+
status=400)
|
|
142
|
+
with pytest.raises(SaplingError) as exc_info:
|
|
143
|
+
client.extract('some text', [])
|
|
144
|
+
err = exc_info.value
|
|
145
|
+
assert err.status_code == 400
|
|
146
|
+
assert 'Invalid fields: Needs between 1 and 20 fields.' in str(err)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@responses.activate
|
|
150
|
+
def test_extract_upstream_error_raises_saplingerror(client):
|
|
151
|
+
responses.add(responses.POST, BASE + 'extract',
|
|
152
|
+
json={'msg': 'Unexpected error extracting from text.'}, status=502)
|
|
153
|
+
with pytest.raises(SaplingError) as exc_info:
|
|
154
|
+
client.extract('some text', FIELDS)
|
|
155
|
+
err = exc_info.value
|
|
156
|
+
assert err.status_code == 502
|
|
157
|
+
assert 'Unexpected error extracting from text.' in str(err)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@responses.activate
|
|
161
|
+
def test_extract_unauthorized_raises_saplingerror(client):
|
|
162
|
+
responses.add(responses.POST, BASE + 'extract', json={'msg': 'Invalid API key.'},
|
|
163
|
+
status=401)
|
|
164
|
+
with pytest.raises(SaplingError) as exc_info:
|
|
165
|
+
client.extract('some text', FIELDS)
|
|
166
|
+
assert exc_info.value.status_code == 401
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@responses.activate
|
|
170
|
+
def test_extract_rate_limited_raises_saplingerror(client):
|
|
171
|
+
responses.add(responses.POST, BASE + 'extract',
|
|
172
|
+
json={'msg': 'Too many requests.'}, status=429)
|
|
173
|
+
with pytest.raises(SaplingError) as exc_info:
|
|
174
|
+
client.extract('some text', FIELDS)
|
|
175
|
+
assert exc_info.value.status_code == 429
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@responses.activate
|
|
179
|
+
def test_extract_custom_hostname_and_pathname():
|
|
180
|
+
client = SaplingClient(api_key=API_KEY, hostname='https://sapling.example.com',
|
|
181
|
+
pathname='/custom/v1/')
|
|
182
|
+
responses.add(responses.POST, 'https://sapling.example.com/custom/v1/extract',
|
|
183
|
+
json=EXTRACT_RESPONSE, status=200)
|
|
184
|
+
result = client.extract(TEXT, FIELDS)
|
|
185
|
+
assert result == EXTRACT_RESPONSE
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# None / an int / a bool are not iterable at all; a str, bytes or dict is
|
|
189
|
+
# iterable but into the wrong thing (characters, bytes, keys).
|
|
190
|
+
BAD_FIELDS = [None, 42, 3.5, True, 'invoice_number', b'invoice_number',
|
|
191
|
+
{'name': 'invoice_number'}]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@pytest.mark.parametrize('bad_fields', BAD_FIELDS)
|
|
195
|
+
def test_extract_rejects_non_list_fields(client, bad_fields):
|
|
196
|
+
with pytest.raises(TypeError):
|
|
197
|
+
client.extract(TEXT, bad_fields)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@pytest.mark.parametrize('bad_fields', BAD_FIELDS)
|
|
201
|
+
def test_extract_non_list_fields_message_is_helpful(client, bad_fields):
|
|
202
|
+
# Not the opaque "'int' object is not iterable" from list().
|
|
203
|
+
with pytest.raises(TypeError, match='fields must be a list'):
|
|
204
|
+
client.extract(TEXT, bad_fields)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@responses.activate
|
|
208
|
+
def test_extract_accepts_any_iterable_of_fields(client):
|
|
209
|
+
# list(fields) already handled tuples; a generator is iterable too and must
|
|
210
|
+
# not be rejected by the stricter guard.
|
|
211
|
+
responses.add(responses.POST, BASE + 'extract', json=EXTRACT_RESPONSE, status=200)
|
|
212
|
+
client.extract(TEXT, (name for name in ['invoice_number', 'total']))
|
|
213
|
+
assert _last_request_body()['fields'] == ['invoice_number', 'total']
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
PII_RESPONSE = {
|
|
12
|
+
'entities': [{
|
|
13
|
+
'type': 'email', 'text': 'jane@example.com', 'start': 6, 'end': 22,
|
|
14
|
+
'replacement': '[EMAIL]',
|
|
15
|
+
}],
|
|
16
|
+
'flagged': True,
|
|
17
|
+
'types': ['email'],
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def client():
|
|
23
|
+
return SaplingClient(api_key=API_KEY)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _last_request_body():
|
|
27
|
+
return json.loads(responses.calls[-1].request.body)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@responses.activate
|
|
31
|
+
def test_pii_returns_json_and_sends_key(client):
|
|
32
|
+
responses.add(responses.POST, BASE + 'pii', json=PII_RESPONSE, status=200)
|
|
33
|
+
result = client.pii('Email jane@example.com')
|
|
34
|
+
assert result == PII_RESPONSE
|
|
35
|
+
body = _last_request_body()
|
|
36
|
+
assert body == {'key': API_KEY, 'text': 'Email jane@example.com'}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@responses.activate
|
|
40
|
+
def test_pii_optional_params_sent_when_provided(client):
|
|
41
|
+
payload = dict(PII_RESPONSE, redacted_text='Email [EMAIL]')
|
|
42
|
+
responses.add(responses.POST, BASE + 'pii', json=payload, status=200)
|
|
43
|
+
result = client.pii('Email jane@example.com', types=('email', 'phone'), redact=True)
|
|
44
|
+
assert result['redacted_text'] == 'Email [EMAIL]'
|
|
45
|
+
body = _last_request_body()
|
|
46
|
+
assert body['types'] == ['email', 'phone']
|
|
47
|
+
assert body['redact'] is True
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@responses.activate
|
|
51
|
+
def test_pii_optional_params_omitted_by_default(client):
|
|
52
|
+
responses.add(responses.POST, BASE + 'pii', json=PII_RESPONSE, status=200)
|
|
53
|
+
client.pii('some text')
|
|
54
|
+
body = _last_request_body()
|
|
55
|
+
assert 'types' not in body
|
|
56
|
+
assert 'redact' not in body
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@responses.activate
|
|
60
|
+
@pytest.mark.parametrize('bad_types', ['email', b'email', {'email': True}, 42, True])
|
|
61
|
+
def test_pii_rejects_invalid_types(client, bad_types):
|
|
62
|
+
# A bare string would be split into characters and a dict reduced to its keys;
|
|
63
|
+
# non-iterables would raise an opaque TypeError from list().
|
|
64
|
+
with pytest.raises(TypeError):
|
|
65
|
+
client.pii('Email jane@example.com', types=bad_types)
|
|
66
|
+
assert len(responses.calls) == 0
|
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def client():
|
|
14
|
+
return SaplingClient(api_key=API_KEY)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@responses.activate
|
|
18
|
+
def test_safety_threshold_sent_when_provided(client):
|
|
19
|
+
responses.add(responses.POST, BASE + 'safety', json={'flagged': False}, status=200)
|
|
20
|
+
client.safety('some text', threshold=0.8)
|
|
21
|
+
body = json.loads(responses.calls[-1].request.body)
|
|
22
|
+
assert body == {'key': API_KEY, 'text': 'some text', 'threshold': 0.8}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@responses.activate
|
|
26
|
+
def test_safety_threshold_omitted_by_default(client):
|
|
27
|
+
responses.add(responses.POST, BASE + 'safety', json={'flagged': False}, status=200)
|
|
28
|
+
client.safety('some text')
|
|
29
|
+
body = json.loads(responses.calls[-1].request.body)
|
|
30
|
+
assert 'threshold' not in body
|
|
@@ -0,0 +1,116 @@
|
|
|
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
|
+
SEO_STATS_RESPONSE = {
|
|
12
|
+
'stats': {
|
|
13
|
+
'chars': 372, 'words': 63, 'sentences': 6, 'paragraphs': 3,
|
|
14
|
+
'reading_time_min': 1, 'flesch_reading_ease': 66.4, 'flesch_kincaid_grade': 7.2,
|
|
15
|
+
},
|
|
16
|
+
'keywords': [],
|
|
17
|
+
'top_terms': [{'term': 'grammar', 'count': 6}, {'term': 'grammar checker', 'count': 5}],
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
SEO_RESPONSE = dict(
|
|
21
|
+
SEO_STATS_RESPONSE,
|
|
22
|
+
keywords=[{'keyword': 'grammar checker', 'count': 5, 'density': 7.94,
|
|
23
|
+
'in_first_100_words': True}],
|
|
24
|
+
suggestions={
|
|
25
|
+
'titles': ['Grammar Checker Guide: What Modern Tools Catch'],
|
|
26
|
+
'meta_descriptions': ['Learn what a modern grammar checker catches beyond typos.'],
|
|
27
|
+
'slug': 'grammar-checker-guide',
|
|
28
|
+
'keywords': ['grammar checker', 'grammar checker for teams'],
|
|
29
|
+
},
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture
|
|
34
|
+
def client():
|
|
35
|
+
return SaplingClient(api_key=API_KEY)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _last_request_body():
|
|
39
|
+
return json.loads(responses.calls[-1].request.body)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@responses.activate
|
|
43
|
+
def test_seo_returns_json_and_sends_key(client):
|
|
44
|
+
responses.add(responses.POST, BASE + 'seo', json=SEO_RESPONSE, status=200)
|
|
45
|
+
result = client.seo('Grammar checkers catch more than typos.')
|
|
46
|
+
assert result == SEO_RESPONSE
|
|
47
|
+
body = _last_request_body()
|
|
48
|
+
assert body == {'key': API_KEY, 'text': 'Grammar checkers catch more than typos.'}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@responses.activate
|
|
52
|
+
def test_seo_optional_params_sent_when_provided(client):
|
|
53
|
+
responses.add(responses.POST, BASE + 'seo', json=SEO_RESPONSE, status=200)
|
|
54
|
+
result = client.seo('some text', keywords=('grammar checker', 'passive voice'),
|
|
55
|
+
suggestions=True, lang='en')
|
|
56
|
+
assert result['suggestions']['slug'] == 'grammar-checker-guide'
|
|
57
|
+
body = _last_request_body()
|
|
58
|
+
assert body == {'key': API_KEY, 'text': 'some text',
|
|
59
|
+
'keywords': ['grammar checker', 'passive voice'],
|
|
60
|
+
'suggestions': True, 'lang': 'en'}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@responses.activate
|
|
64
|
+
def test_seo_suggestions_false_sent(client):
|
|
65
|
+
responses.add(responses.POST, BASE + 'seo', json=SEO_STATS_RESPONSE, status=200)
|
|
66
|
+
result = client.seo('some text', suggestions=False)
|
|
67
|
+
assert result == SEO_STATS_RESPONSE
|
|
68
|
+
assert 'suggestions' not in result
|
|
69
|
+
body = _last_request_body()
|
|
70
|
+
assert body['suggestions'] is False
|
|
71
|
+
assert 'keywords' not in body
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@responses.activate
|
|
75
|
+
def test_seo_lang_sent_when_provided(client):
|
|
76
|
+
# Unsupported readability language: readability fields come back as null.
|
|
77
|
+
payload = dict(SEO_STATS_RESPONSE, stats=dict(
|
|
78
|
+
SEO_STATS_RESPONSE['stats'], flesch_reading_ease=None, flesch_kincaid_grade=None))
|
|
79
|
+
responses.add(responses.POST, BASE + 'seo', json=payload, status=200)
|
|
80
|
+
result = client.seo('some text', lang='pt-BR')
|
|
81
|
+
assert result['stats']['flesch_reading_ease'] is None
|
|
82
|
+
body = _last_request_body()
|
|
83
|
+
assert body['lang'] == 'pt-BR'
|
|
84
|
+
assert 'keywords' not in body
|
|
85
|
+
assert 'suggestions' not in body
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@responses.activate
|
|
89
|
+
def test_seo_optional_params_omitted_by_default(client):
|
|
90
|
+
responses.add(responses.POST, BASE + 'seo', json=SEO_STATS_RESPONSE, status=200)
|
|
91
|
+
client.seo('some text')
|
|
92
|
+
body = _last_request_body()
|
|
93
|
+
assert 'keywords' not in body
|
|
94
|
+
assert 'suggestions' not in body
|
|
95
|
+
assert 'lang' not in body
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@responses.activate
|
|
99
|
+
def test_seo_error_raises_saplingerror_with_body(client):
|
|
100
|
+
responses.add(responses.POST, BASE + 'seo',
|
|
101
|
+
json={'msg': 'Unexpected error generating SEO suggestions.'}, status=502)
|
|
102
|
+
with pytest.raises(SaplingError) as exc_info:
|
|
103
|
+
client.seo('some text')
|
|
104
|
+
err = exc_info.value
|
|
105
|
+
assert err.status_code == 502
|
|
106
|
+
assert 'Unexpected error generating SEO suggestions.' in str(err)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@responses.activate
|
|
110
|
+
@pytest.mark.parametrize('bad_keywords', ['grammar', b'grammar', {'grammar': True}, 42, True])
|
|
111
|
+
def test_seo_rejects_invalid_keywords(client, bad_keywords):
|
|
112
|
+
# A bare string would be split into characters and a dict reduced to its keys;
|
|
113
|
+
# non-iterables would raise an opaque TypeError from list().
|
|
114
|
+
with pytest.raises(TypeError):
|
|
115
|
+
client.seo('some text', keywords=bad_keywords)
|
|
116
|
+
assert len(responses.calls) == 0
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = '4.1.0'
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|