slopometer 0.0.2__tar.gz → 0.0.4__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.
- slopometer-0.0.4/PKG-INFO +84 -0
- slopometer-0.0.4/README.md +62 -0
- {slopometer-0.0.2 → slopometer-0.0.4}/pyproject.toml +3 -3
- {slopometer-0.0.2 → slopometer-0.0.4}/slopometer/__init__.py +3 -2
- {slopometer-0.0.2 → slopometer-0.0.4}/slopometer/_modidx.py +39 -2
- slopometer-0.0.4/slopometer/cli.py +41 -0
- {slopometer-0.0.2 → slopometer-0.0.4}/slopometer/core.py +4 -5
- {slopometer-0.0.2 → slopometer-0.0.4}/slopometer/corpus.py +7 -7
- {slopometer-0.0.2 → slopometer-0.0.4}/slopometer/features.py +3 -1
- {slopometer-0.0.2 → slopometer-0.0.4}/slopometer/lexicon.py +9 -11
- slopometer-0.0.4/slopometer/pangram.py +117 -0
- {slopometer-0.0.2 → slopometer-0.0.4}/slopometer/para.py +9 -19
- slopometer-0.0.4/slopometer/score.py +157 -0
- {slopometer-0.0.2 → slopometer-0.0.4}/slopometer/segment.py +22 -17
- {slopometer-0.0.2 → slopometer-0.0.4}/slopometer/syntax.py +150 -10
- slopometer-0.0.4/slopometer.egg-info/PKG-INFO +84 -0
- {slopometer-0.0.2 → slopometer-0.0.4}/slopometer.egg-info/SOURCES.txt +1 -1
- slopometer-0.0.4/slopometer.egg-info/requires.txt +6 -0
- slopometer-0.0.2/MANIFEST.in +0 -5
- slopometer-0.0.2/PKG-INFO +0 -65
- slopometer-0.0.2/README.md +0 -45
- slopometer-0.0.2/slopometer/cli.py +0 -31
- slopometer-0.0.2/slopometer/score.py +0 -95
- slopometer-0.0.2/slopometer.egg-info/PKG-INFO +0 -65
- slopometer-0.0.2/slopometer.egg-info/requires.txt +0 -4
- {slopometer-0.0.2 → slopometer-0.0.4}/LICENSE +0 -0
- {slopometer-0.0.2 → slopometer-0.0.4}/setup.cfg +0 -0
- {slopometer-0.0.2 → slopometer-0.0.4}/slopometer.egg-info/dependency_links.txt +0 -0
- {slopometer-0.0.2 → slopometer-0.0.4}/slopometer.egg-info/entry_points.txt +0 -0
- {slopometer-0.0.2 → slopometer-0.0.4}/slopometer.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: slopometer
|
|
3
|
+
Version: 0.0.4
|
|
4
|
+
Summary: Score prose for simplicity and precision against plain-English reference rules
|
|
5
|
+
Author-email: Jeremy Howard <github@jhoward.fastmail.fm>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Repository, https://github.com/AnswerDotAI/slopometer
|
|
8
|
+
Project-URL: Documentation, https://AnswerDotAI.github.io/slopometer/
|
|
9
|
+
Keywords: nbdev
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: fastcore
|
|
16
|
+
Requires-Dist: spacy
|
|
17
|
+
Requires-Dist: mdhtml>=0.1.40
|
|
18
|
+
Requires-Dist: httpx2
|
|
19
|
+
Requires-Dist: warmpy>=0.1.1
|
|
20
|
+
Requires-Dist: rgapi>=0.1.27
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# slopometer
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
<!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->
|
|
27
|
+
|
|
28
|
+
`slopometer` measures prose against a range of reference-prose rules. The rules merge GOV.UK/GDS house style with the discipline of ASD-STE100, including 26 numbered tells, banned-word lists, and a reference register. It also has rules covering a range of best practices for clear technical communication, including paragraph length, referant tracking, and restatement.
|
|
29
|
+
|
|
30
|
+
`slopometer` is for anyone shipping READMEs, docstrings, PR text, or agent responses. It catches slop mechanically, in milliseconds, with no LLM in the loop. Every finding cites a rule, quotes a span, and carries a weight.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
pip install slopometer
|
|
35
|
+
|
|
36
|
+
The first score on a machine downloads spaCy’s `en_core_web_md` model (about 40MB, once) into `~/.cache/slopometer` and loads it by path. The model never enters a virtual environment. One copy serves every project. Environment syncs cannot remove it.
|
|
37
|
+
|
|
38
|
+
## Use
|
|
39
|
+
|
|
40
|
+
``` python
|
|
41
|
+
from slopometer.score import score_text, score_path
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The default minimum is 150 scored words. Shorter inputs return `too short to meter` with their word count and no numeric score. Set `min_words=0` to meter short examples:
|
|
45
|
+
|
|
46
|
+
``` python
|
|
47
|
+
score_text("This section describes our approach. It isn't just a linter - it's a comprehensive paradigm for quality.", min_words=0)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
density 194.1 (weight 33 on 17 prose words), worst 10
|
|
51
|
+
1: [10] notxbuty (tell 16, not-X-but-Y): "isn't just a"
|
|
52
|
+
1: [10] banned: 'comprehensive' -> 'complete'
|
|
53
|
+
1: [10] banned: 'paradigm'
|
|
54
|
+
1: [3] throat_clearing (tell 13, throat-clearing): 'This section describes'
|
|
55
|
+
|
|
56
|
+
Density is weighted findings per 100 words across paragraphs, headings, and list items. Heading and list markers do not count as words. Code blocks and other content excluded from scoring do not enter the denominator or count toward the minimum.
|
|
57
|
+
|
|
58
|
+
Each em dash adds 4 to the total weight. Semicolons joining independent clauses also add 4. Single and double hyphens add no weight on their own. Semicolons in lists of noun phrases or inside parentheses do not score. A comma and “and” before a clause with its own subject add 3. A sentence with three or more sections adds 1 for each section beyond two. A section ends at a comma, semicolon, colon, dash, spaced hyphen, or opening parenthesis. List items stay in one section.
|
|
59
|
+
|
|
60
|
+
[`score_text`](https://AnswerDotAI.github.io/slopometer/score.html#score_text), [`score_path`](https://AnswerDotAI.github.io/slopometer/score.html#score_path), [`score_paths`](https://AnswerDotAI.github.io/slopometer/score.html#score_paths), and [`score_many`](https://AnswerDotAI.github.io/slopometer/score.html#score_many) accept `min_words`, defaulting to 150. A short result has `too_short=True`, an empty findings list, and `None` for `density`, `total`, and `worst`.
|
|
61
|
+
|
|
62
|
+
[`score_path`](https://AnswerDotAI.github.io/slopometer/score.html#score_path) scores Markdown files, the Markdown cells of an `.ipynb` notebook, or the module docstring of a `.py` file. Notebook cells are joined with blank lines and scored as one document; code cells, outputs, and raw cells are excluded. File reports carry `lineno|hash|` addresses, prefixed with the cell ID for notebooks. For a `.py` file, addresses name lines of that file. `Result.location(finding)` returns the source location. [`score_paths`](https://AnswerDotAI.github.io/slopometer/score.html#score_paths) scores several files and directories in parallel, one result per file. A directory contributes its `.md`, `.qmd`, `.ipynb` and `.py` files. The command line accepts the same paths, or Markdown on stdin:
|
|
63
|
+
|
|
64
|
+
slopometer README.md
|
|
65
|
+
slopometer nbs/00_core.ipynb
|
|
66
|
+
slopometer pkg/module.py
|
|
67
|
+
slopometer nbs/ README.md
|
|
68
|
+
git log -1 --format=%B | slopometer --min-words 0
|
|
69
|
+
slopometer draft.md --threshold 10
|
|
70
|
+
slopometer draft.md --pangram
|
|
71
|
+
|
|
72
|
+
`--threshold` returns exit code 1 when any file’s score exceeds the limit. Several paths or a directory give one report per file, highest density first. JSON output is a list with one entry per file. Each entry includes its `path`, which is null for stdin. Inputs below `--min-words` print the short-input message and exit successfully without scoring. JSON output marks them with `too_short: true`, includes `min_words`, and uses null scores. Every JSON finding includes a `location` object with one-based `line`, `address`, `end_line` and `end_address`. The end fields locate the finding’s last character. Notebook locations add `cell_id` and zero-based `cell_index`. Finding `start` and `end` offsets refer to the combined Markdown, not notebook JSON.
|
|
73
|
+
|
|
74
|
+
`--pangram`, [`pangram_text`](https://AnswerDotAI.github.io/slopometer/pangram.html#pangram_text), [`pangram_path`](https://AnswerDotAI.github.io/slopometer/pangram.html#pangram_path) and [`pangram_paths`](https://AnswerDotAI.github.io/slopometer/pangram.html#pangram_paths) score with [Pangram](https://www.pangram.com)’s AI detector instead of the rules. Each finding is a window of text that Pangram scored. Density runs from 0 (human) to 100 (AI). `--threshold` compares against the same scale. Each request sends the text to Pangram’s servers and costs about \$0.05 per 100 words. The API key comes from `PANGRAM_API_KEY`.
|
|
75
|
+
|
|
76
|
+
The command runs warm through `warmpy`. The first input long enough to score loads the model in a background process. Later calls answer in milliseconds. After thirty idle minutes the process exits.
|
|
77
|
+
|
|
78
|
+
## What it checks, and what it cannot
|
|
79
|
+
|
|
80
|
+
The rules live in notebooks that teach each family beside its code. Each rule states its tell, shows a violating example, and shows the plain rewrite. The [lexicon notebook](https://AnswerDotAI.github.io/slopometer/lexicon.html) holds the word and phrase rules. The [syntax notebook](https://AnswerDotAI.github.io/slopometer/syntax.html) builds sentence rules on spaCy’s parse. The [para notebook](https://AnswerDotAI.github.io/slopometer/para.html) covers paragraph and document rules and the limits of word-vector heuristics. The [score notebook](https://AnswerDotAI.github.io/slopometer/score.html) assembles the pipeline. A drift test asserts that every `write_docs` tell maps to a rule or to an explicit unscoreable registry. The meter and the style guide cannot drift apart silently.
|
|
81
|
+
|
|
82
|
+
Vocabulary novelty is not scored. Identifying unexplained terminology requires audience and document context.
|
|
83
|
+
|
|
84
|
+
A rule ships only when its false-positive rate on clean reference prose is near zero. The meter scores the style guide’s own clean passage at exactly 0.0. The score notebook measures the blind spot instead of hiding it: mechanically chopped prose passes every surface rule while staying opaque. Agent review (`check_docs`) and Pangram scoring (`--pangram`) cover that residue. Vale, write-good, and proselint solve neighboring problems. The [lexicon notebook](https://AnswerDotAI.github.io/slopometer/lexicon.html) records what came from them. It also credits the [GOV.UK words-to-avoid list](https://www.gov.uk/guidance/style-guide/a-to-z-of-gov-uk-style#words-to-avoid) (OGL v3).
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# slopometer
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
<!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->
|
|
5
|
+
|
|
6
|
+
`slopometer` measures prose against a range of reference-prose rules. The rules merge GOV.UK/GDS house style with the discipline of ASD-STE100, including 26 numbered tells, banned-word lists, and a reference register. It also has rules covering a range of best practices for clear technical communication, including paragraph length, referant tracking, and restatement.
|
|
7
|
+
|
|
8
|
+
`slopometer` is for anyone shipping READMEs, docstrings, PR text, or agent responses. It catches slop mechanically, in milliseconds, with no LLM in the loop. Every finding cites a rule, quotes a span, and carries a weight.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
pip install slopometer
|
|
13
|
+
|
|
14
|
+
The first score on a machine downloads spaCy’s `en_core_web_md` model (about 40MB, once) into `~/.cache/slopometer` and loads it by path. The model never enters a virtual environment. One copy serves every project. Environment syncs cannot remove it.
|
|
15
|
+
|
|
16
|
+
## Use
|
|
17
|
+
|
|
18
|
+
``` python
|
|
19
|
+
from slopometer.score import score_text, score_path
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The default minimum is 150 scored words. Shorter inputs return `too short to meter` with their word count and no numeric score. Set `min_words=0` to meter short examples:
|
|
23
|
+
|
|
24
|
+
``` python
|
|
25
|
+
score_text("This section describes our approach. It isn't just a linter - it's a comprehensive paradigm for quality.", min_words=0)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
density 194.1 (weight 33 on 17 prose words), worst 10
|
|
29
|
+
1: [10] notxbuty (tell 16, not-X-but-Y): "isn't just a"
|
|
30
|
+
1: [10] banned: 'comprehensive' -> 'complete'
|
|
31
|
+
1: [10] banned: 'paradigm'
|
|
32
|
+
1: [3] throat_clearing (tell 13, throat-clearing): 'This section describes'
|
|
33
|
+
|
|
34
|
+
Density is weighted findings per 100 words across paragraphs, headings, and list items. Heading and list markers do not count as words. Code blocks and other content excluded from scoring do not enter the denominator or count toward the minimum.
|
|
35
|
+
|
|
36
|
+
Each em dash adds 4 to the total weight. Semicolons joining independent clauses also add 4. Single and double hyphens add no weight on their own. Semicolons in lists of noun phrases or inside parentheses do not score. A comma and “and” before a clause with its own subject add 3. A sentence with three or more sections adds 1 for each section beyond two. A section ends at a comma, semicolon, colon, dash, spaced hyphen, or opening parenthesis. List items stay in one section.
|
|
37
|
+
|
|
38
|
+
[`score_text`](https://AnswerDotAI.github.io/slopometer/score.html#score_text), [`score_path`](https://AnswerDotAI.github.io/slopometer/score.html#score_path), [`score_paths`](https://AnswerDotAI.github.io/slopometer/score.html#score_paths), and [`score_many`](https://AnswerDotAI.github.io/slopometer/score.html#score_many) accept `min_words`, defaulting to 150. A short result has `too_short=True`, an empty findings list, and `None` for `density`, `total`, and `worst`.
|
|
39
|
+
|
|
40
|
+
[`score_path`](https://AnswerDotAI.github.io/slopometer/score.html#score_path) scores Markdown files, the Markdown cells of an `.ipynb` notebook, or the module docstring of a `.py` file. Notebook cells are joined with blank lines and scored as one document; code cells, outputs, and raw cells are excluded. File reports carry `lineno|hash|` addresses, prefixed with the cell ID for notebooks. For a `.py` file, addresses name lines of that file. `Result.location(finding)` returns the source location. [`score_paths`](https://AnswerDotAI.github.io/slopometer/score.html#score_paths) scores several files and directories in parallel, one result per file. A directory contributes its `.md`, `.qmd`, `.ipynb` and `.py` files. The command line accepts the same paths, or Markdown on stdin:
|
|
41
|
+
|
|
42
|
+
slopometer README.md
|
|
43
|
+
slopometer nbs/00_core.ipynb
|
|
44
|
+
slopometer pkg/module.py
|
|
45
|
+
slopometer nbs/ README.md
|
|
46
|
+
git log -1 --format=%B | slopometer --min-words 0
|
|
47
|
+
slopometer draft.md --threshold 10
|
|
48
|
+
slopometer draft.md --pangram
|
|
49
|
+
|
|
50
|
+
`--threshold` returns exit code 1 when any file’s score exceeds the limit. Several paths or a directory give one report per file, highest density first. JSON output is a list with one entry per file. Each entry includes its `path`, which is null for stdin. Inputs below `--min-words` print the short-input message and exit successfully without scoring. JSON output marks them with `too_short: true`, includes `min_words`, and uses null scores. Every JSON finding includes a `location` object with one-based `line`, `address`, `end_line` and `end_address`. The end fields locate the finding’s last character. Notebook locations add `cell_id` and zero-based `cell_index`. Finding `start` and `end` offsets refer to the combined Markdown, not notebook JSON.
|
|
51
|
+
|
|
52
|
+
`--pangram`, [`pangram_text`](https://AnswerDotAI.github.io/slopometer/pangram.html#pangram_text), [`pangram_path`](https://AnswerDotAI.github.io/slopometer/pangram.html#pangram_path) and [`pangram_paths`](https://AnswerDotAI.github.io/slopometer/pangram.html#pangram_paths) score with [Pangram](https://www.pangram.com)’s AI detector instead of the rules. Each finding is a window of text that Pangram scored. Density runs from 0 (human) to 100 (AI). `--threshold` compares against the same scale. Each request sends the text to Pangram’s servers and costs about \$0.05 per 100 words. The API key comes from `PANGRAM_API_KEY`.
|
|
53
|
+
|
|
54
|
+
The command runs warm through `warmpy`. The first input long enough to score loads the model in a background process. Later calls answer in milliseconds. After thirty idle minutes the process exits.
|
|
55
|
+
|
|
56
|
+
## What it checks, and what it cannot
|
|
57
|
+
|
|
58
|
+
The rules live in notebooks that teach each family beside its code. Each rule states its tell, shows a violating example, and shows the plain rewrite. The [lexicon notebook](https://AnswerDotAI.github.io/slopometer/lexicon.html) holds the word and phrase rules. The [syntax notebook](https://AnswerDotAI.github.io/slopometer/syntax.html) builds sentence rules on spaCy’s parse. The [para notebook](https://AnswerDotAI.github.io/slopometer/para.html) covers paragraph and document rules and the limits of word-vector heuristics. The [score notebook](https://AnswerDotAI.github.io/slopometer/score.html) assembles the pipeline. A drift test asserts that every `write_docs` tell maps to a rule or to an explicit unscoreable registry. The meter and the style guide cannot drift apart silently.
|
|
59
|
+
|
|
60
|
+
Vocabulary novelty is not scored. Identifying unexplained terminology requires audience and document context.
|
|
61
|
+
|
|
62
|
+
A rule ships only when its false-positive rate on clean reference prose is near zero. The meter scores the style guide’s own clean passage at exactly 0.0. The score notebook measures the blind spot instead of hiding it: mechanically chopped prose passes every surface rule while staying opaque. Agent review (`check_docs`) and Pangram scoring (`--pangram`) cover that residue. Vale, write-good, and proselint solve neighboring problems. The [lexicon notebook](https://AnswerDotAI.github.io/slopometer/lexicon.html) records what came from them. It also credits the [GOV.UK words-to-avoid list](https://www.gov.uk/guidance/style-guide/a-to-z-of-gov-uk-style#words-to-avoid) (OGL v3).
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["setuptools>=
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
3
|
build-backend = "setuptools.build_meta"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
@@ -8,14 +8,14 @@ dynamic = ["version"]
|
|
|
8
8
|
description = "Score prose for simplicity and precision against plain-English reference rules"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
|
-
license =
|
|
11
|
+
license = "Apache-2.0"
|
|
12
12
|
authors = [{name = "Jeremy Howard", email = "github@jhoward.fastmail.fm"}]
|
|
13
13
|
keywords = ['nbdev']
|
|
14
14
|
classifiers = [
|
|
15
15
|
"Programming Language :: Python :: 3",
|
|
16
16
|
"Programming Language :: Python :: 3 :: Only",
|
|
17
17
|
]
|
|
18
|
-
dependencies = ["fastcore", "spacy", "mdhtml", "warmpy>=0.1.0"]
|
|
18
|
+
dependencies = ["fastcore", "spacy", "mdhtml>=0.1.40", "httpx2", "warmpy>=0.1.1", "rgapi>=0.1.27"]
|
|
19
19
|
|
|
20
20
|
[project.urls]
|
|
21
21
|
Repository = "https://github.com/AnswerDotAI/slopometer"
|
|
@@ -7,9 +7,10 @@ Modules:
|
|
|
7
7
|
- `slopometer.corpus`: Build the labeled corpus: weak positives from scored replies, mixed human negatives
|
|
8
8
|
- `slopometer.features`: Continuous measurements for learned scoring
|
|
9
9
|
- `slopometer.lexicon`: Word and phrase rules: banned vocabulary, hedges, fillers, and splices
|
|
10
|
-
- `slopometer.
|
|
10
|
+
- `slopometer.pangram`: Score prose with Pangram's AI detector, as an alternative to the rules
|
|
11
|
+
- `slopometer.para`: Paragraph rules: restatement, elegant variation, forced symmetry, and length
|
|
11
12
|
- `slopometer.score`: Run every rule, weigh the findings, and report worst first
|
|
12
13
|
- `slopometer.segment`: Markdown becomes typed blocks that keep their file positions
|
|
13
14
|
- `slopometer.syntax`: The spaCy rules: sentences, clauses, passives, and the patterns anchored to them"""
|
|
14
15
|
|
|
15
|
-
__version__ = "0.0.
|
|
16
|
+
__version__ = "0.0.4"
|
|
@@ -5,7 +5,8 @@ d = { 'settings': { 'branch': 'main',
|
|
|
5
5
|
'doc_host': 'https://AnswerDotAI.github.io',
|
|
6
6
|
'git_url': 'https://github.com/AnswerDotAI/slopometer',
|
|
7
7
|
'lib_path': 'slopometer'},
|
|
8
|
-
'syms': { 'slopometer.cli': {'slopometer.cli.
|
|
8
|
+
'syms': { 'slopometer.cli': { 'slopometer.cli._as_json': ('cli.html#_as_json', 'slopometer/cli.py'),
|
|
9
|
+
'slopometer.cli.main': ('cli.html#main', 'slopometer/cli.py')},
|
|
9
10
|
'slopometer.core': { 'slopometer.core.Finding': ('core.html#finding', 'slopometer/core.py'),
|
|
10
11
|
'slopometer.core.Finding.__init__': ('core.html#finding.__init__', 'slopometer/core.py'),
|
|
11
12
|
'slopometer.core.Finding.__repr__': ('core.html#finding.__repr__', 'slopometer/core.py'),
|
|
@@ -34,9 +35,27 @@ d = { 'settings': { 'branch': 'main',
|
|
|
34
35
|
'slopometer.lexicon.find_emphasis': ('lexicon.html#find_emphasis', 'slopometer/lexicon.py'),
|
|
35
36
|
'slopometer.lexicon.find_splice': ('lexicon.html#find_splice', 'slopometer/lexicon.py'),
|
|
36
37
|
'slopometer.lexicon.lex_rule': ('lexicon.html#lex_rule', 'slopometer/lexicon.py')},
|
|
38
|
+
'slopometer.pangram': { 'slopometer.pangram.PangramFinding': ('pangram.html#pangramfinding', 'slopometer/pangram.py'),
|
|
39
|
+
'slopometer.pangram.PangramFinding.__init__': ( 'pangram.html#pangramfinding.__init__',
|
|
40
|
+
'slopometer/pangram.py'),
|
|
41
|
+
'slopometer.pangram.PangramFinding.__repr__': ( 'pangram.html#pangramfinding.__repr__',
|
|
42
|
+
'slopometer/pangram.py'),
|
|
43
|
+
'slopometer.pangram.PangramResult': ('pangram.html#pangramresult', 'slopometer/pangram.py'),
|
|
44
|
+
'slopometer.pangram.PangramResult.__init__': ( 'pangram.html#pangramresult.__init__',
|
|
45
|
+
'slopometer/pangram.py'),
|
|
46
|
+
'slopometer.pangram.PangramResult._header': ( 'pangram.html#pangramresult._header',
|
|
47
|
+
'slopometer/pangram.py'),
|
|
48
|
+
'slopometer.pangram.PangramResult.density': ( 'pangram.html#pangramresult.density',
|
|
49
|
+
'slopometer/pangram.py'),
|
|
50
|
+
'slopometer.pangram._pangram_input': ('pangram.html#_pangram_input', 'slopometer/pangram.py'),
|
|
51
|
+
'slopometer.pangram._pangram_result': ('pangram.html#_pangram_result', 'slopometer/pangram.py'),
|
|
52
|
+
'slopometer.pangram._predict': ('pangram.html#_predict', 'slopometer/pangram.py'),
|
|
53
|
+
'slopometer.pangram.pangram_path': ('pangram.html#pangram_path', 'slopometer/pangram.py'),
|
|
54
|
+
'slopometer.pangram.pangram_paths': ('pangram.html#pangram_paths', 'slopometer/pangram.py'),
|
|
55
|
+
'slopometer.pangram.pangram_text': ('pangram.html#pangram_text', 'slopometer/pangram.py')},
|
|
37
56
|
'slopometer.para': { 'slopometer.para._content': ('para.html#_content', 'slopometer/para.py'),
|
|
57
|
+
'slopometer.para._same_word': ('para.html#_same_word', 'slopometer/para.py'),
|
|
38
58
|
'slopometer.para.find_bullet_mold': ('para.html#find_bullet_mold', 'slopometer/para.py'),
|
|
39
|
-
'slopometer.para.find_coinage': ('para.html#find_coinage', 'slopometer/para.py'),
|
|
40
59
|
'slopometer.para.find_heading_echo': ('para.html#find_heading_echo', 'slopometer/para.py'),
|
|
41
60
|
'slopometer.para.find_para_cap': ('para.html#find_para_cap', 'slopometer/para.py'),
|
|
42
61
|
'slopometer.para.find_triad': ('para.html#find_triad', 'slopometer/para.py'),
|
|
@@ -46,28 +65,45 @@ d = { 'settings': { 'branch': 'main',
|
|
|
46
65
|
'slopometer.score.Result.__init__': ('score.html#result.__init__', 'slopometer/score.py'),
|
|
47
66
|
'slopometer.score.Result.__repr__': ('score.html#result.__repr__', 'slopometer/score.py'),
|
|
48
67
|
'slopometer.score.Result._addr': ('score.html#result._addr', 'slopometer/score.py'),
|
|
68
|
+
'slopometer.score.Result._header': ('score.html#result._header', 'slopometer/score.py'),
|
|
69
|
+
'slopometer.score.Result._pos': ('score.html#result._pos', 'slopometer/score.py'),
|
|
49
70
|
'slopometer.score.Result.density': ('score.html#result.density', 'slopometer/score.py'),
|
|
71
|
+
'slopometer.score.Result.location': ('score.html#result.location', 'slopometer/score.py'),
|
|
72
|
+
'slopometer.score.Result.too_short': ('score.html#result.too_short', 'slopometer/score.py'),
|
|
50
73
|
'slopometer.score.Result.total': ('score.html#result.total', 'slopometer/score.py'),
|
|
51
74
|
'slopometer.score.Result.worst': ('score.html#result.worst', 'slopometer/score.py'),
|
|
75
|
+
'slopometer.score._doc_paths': ('score.html#_doc_paths', 'slopometer/score.py'),
|
|
52
76
|
'slopometer.score._rebase': ('score.html#_rebase', 'slopometer/score.py'),
|
|
77
|
+
'slopometer.score._score_file': ('score.html#_score_file', 'slopometer/score.py'),
|
|
53
78
|
'slopometer.score.find_overstructure': ('score.html#find_overstructure', 'slopometer/score.py'),
|
|
54
79
|
'slopometer.score.run_rules': ('score.html#run_rules', 'slopometer/score.py'),
|
|
55
80
|
'slopometer.score.score_many': ('score.html#score_many', 'slopometer/score.py'),
|
|
56
81
|
'slopometer.score.score_path': ('score.html#score_path', 'slopometer/score.py'),
|
|
82
|
+
'slopometer.score.score_paths': ('score.html#score_paths', 'slopometer/score.py'),
|
|
57
83
|
'slopometer.score.score_text': ('score.html#score_text', 'slopometer/score.py')},
|
|
58
84
|
'slopometer.segment': { 'slopometer.segment.Block': ('segment.html#block', 'slopometer/segment.py'),
|
|
59
85
|
'slopometer.segment.Block.__init__': ('segment.html#block.__init__', 'slopometer/segment.py'),
|
|
60
86
|
'slopometer.segment.Block.__repr__': ('segment.html#block.__repr__', 'slopometer/segment.py'),
|
|
61
87
|
'slopometer.segment.n_headings': ('segment.html#n_headings', 'slopometer/segment.py'),
|
|
62
88
|
'slopometer.segment.prose_words': ('segment.html#prose_words', 'slopometer/segment.py'),
|
|
89
|
+
'slopometer.segment.scored_words': ('segment.html#scored_words', 'slopometer/segment.py'),
|
|
63
90
|
'slopometer.segment.scrub': ('segment.html#scrub', 'slopometer/segment.py'),
|
|
64
91
|
'slopometer.segment.segment': ('segment.html#segment', 'slopometer/segment.py')},
|
|
65
92
|
'slopometer.syntax': { 'slopometer.syntax._clausal': ('syntax.html#_clausal', 'slopometer/syntax.py'),
|
|
93
|
+
'slopometer.syntax._inert_subj': ('syntax.html#_inert_subj', 'slopometer/syntax.py'),
|
|
94
|
+
'slopometer.syntax._is_break': ('syntax.html#_is_break', 'slopometer/syntax.py'),
|
|
95
|
+
'slopometer.syntax._list_commas': ('syntax.html#_list_commas', 'slopometer/syntax.py'),
|
|
66
96
|
'slopometer.syntax._n_clauses': ('syntax.html#_n_clauses', 'slopometer/syntax.py'),
|
|
97
|
+
'slopometer.syntax._n_sections': ('syntax.html#_n_sections', 'slopometer/syntax.py'),
|
|
67
98
|
'slopometer.syntax._own_subj': ('syntax.html#_own_subj', 'slopometer/syntax.py'),
|
|
99
|
+
'slopometer.syntax._subject_last': ('syntax.html#_subject_last', 'slopometer/syntax.py'),
|
|
68
100
|
'slopometer.syntax._tok_find': ('syntax.html#_tok_find', 'slopometer/syntax.py'),
|
|
101
|
+
'slopometer.syntax._verbless': ('syntax.html#_verbless', 'slopometer/syntax.py'),
|
|
102
|
+
'slopometer.syntax.find_and_splice': ('syntax.html#find_and_splice', 'slopometer/syntax.py'),
|
|
69
103
|
'slopometer.syntax.find_artifact_agent': ('syntax.html#find_artifact_agent', 'slopometer/syntax.py'),
|
|
70
104
|
'slopometer.syntax.find_clauses': ('syntax.html#find_clauses', 'slopometer/syntax.py'),
|
|
105
|
+
'slopometer.syntax.find_inert_act': ('syntax.html#find_inert_act', 'slopometer/syntax.py'),
|
|
106
|
+
'slopometer.syntax.find_inert_mind': ('syntax.html#find_inert_mind', 'slopometer/syntax.py'),
|
|
71
107
|
'slopometer.syntax.find_intensifiers': ('syntax.html#find_intensifiers', 'slopometer/syntax.py'),
|
|
72
108
|
'slopometer.syntax.find_nominalization': ('syntax.html#find_nominalization', 'slopometer/syntax.py'),
|
|
73
109
|
'slopometer.syntax.find_noun_cluster': ('syntax.html#find_noun_cluster', 'slopometer/syntax.py'),
|
|
@@ -75,6 +111,7 @@ d = { 'settings': { 'branch': 'main',
|
|
|
75
111
|
'slopometer.syntax.find_recipient_subject': ( 'syntax.html#find_recipient_subject',
|
|
76
112
|
'slopometer/syntax.py'),
|
|
77
113
|
'slopometer.syntax.find_rhetorical': ('syntax.html#find_rhetorical', 'slopometer/syntax.py'),
|
|
114
|
+
'slopometer.syntax.find_sections': ('syntax.html#find_sections', 'slopometer/syntax.py'),
|
|
78
115
|
'slopometer.syntax.find_semi_splice': ('syntax.html#find_semi_splice', 'slopometer/syntax.py'),
|
|
79
116
|
'slopometer.syntax.find_sent_len': ('syntax.html#find_sent_len', 'slopometer/syntax.py'),
|
|
80
117
|
'slopometer.syntax.find_verb_banned': ('syntax.html#find_verb_banned', 'slopometer/syntax.py'),
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""The command line: score a file or stdin, warm
|
|
2
|
+
|
|
3
|
+
`slopometer <file>` prints the worst-first report, `slopometer` alone reads stdin, and `--threshold` turns the density into an exit code for CI and hooks. Several files or directories give one report per file, highest density first. `--pangram` scores with Pangram's AI detector instead of the rules. Loading the model takes about a second and scoring takes milliseconds, so the command runs through [warmpy](https://github.com/AnswerDotAI/warmpy): the first call starts a background process that loads the model, later calls reuse it, and it exits after thirty idle minutes. The Claude Code stop hook runs this same command with the response text on stdin.
|
|
4
|
+
|
|
5
|
+
Docs: https://AnswerDotAI.github.io/slopometer/cli.html.md"""
|
|
6
|
+
|
|
7
|
+
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/06_cli.ipynb.
|
|
8
|
+
|
|
9
|
+
# %% auto #0
|
|
10
|
+
__all__ = ['main']
|
|
11
|
+
|
|
12
|
+
# %% ../nbs/06_cli.ipynb #01e3be12
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from warmpy import warm_parse
|
|
16
|
+
|
|
17
|
+
# %% ../nbs/06_cli.ipynb #d21704e3
|
|
18
|
+
def _as_json(res):
|
|
19
|
+
"A `Result` as a JSON-ready dict, with each finding's source location"
|
|
20
|
+
findings = [dict(vars(f), location=res.location(f)) for f in res.findings]
|
|
21
|
+
return dict(path=res.path and str(res.path), density=res.density, worst=res.worst, words=res.words,
|
|
22
|
+
too_short=res.too_short, min_words=res.min_words, findings=findings)
|
|
23
|
+
|
|
24
|
+
@warm_parse
|
|
25
|
+
def main(
|
|
26
|
+
*paths:str, # Markdown, notebook, or `.py` files, or directories of them; stdin when omitted
|
|
27
|
+
threshold:float=None, # Exit code 1 when any density exceeds this, which runs from 0 to 100 with `pangram`
|
|
28
|
+
json:bool=False, # Emit JSON instead of reports: a list with one entry per file
|
|
29
|
+
min_words:int=150, # Minimum scored words; 0 disables the cutoff
|
|
30
|
+
pangram:bool=False, # Score with Pangram's AI detector instead of the rules; needs `PANGRAM_API_KEY`
|
|
31
|
+
):
|
|
32
|
+
"Score Markdown, notebook, or module docstring prose against the aai reference-prose rules, or with Pangram's AI detector"
|
|
33
|
+
from json import dumps
|
|
34
|
+
from slopometer.score import score_paths, score_text
|
|
35
|
+
from slopometer.pangram import pangram_paths, pangram_text
|
|
36
|
+
sp,st = (pangram_paths,pangram_text) if pangram else (score_paths,score_text)
|
|
37
|
+
rs = sp(paths, min_words=min_words) if paths else [st(sys.stdin.read(), min_words=min_words)]
|
|
38
|
+
rs = sorted(rs, key=lambda r: (r.density is not None, r.density or 0), reverse=True)
|
|
39
|
+
if json: print(dumps([_as_json(r) for r in rs]))
|
|
40
|
+
else: print('\n\n'.join(map(str, rs)))
|
|
41
|
+
if threshold is not None and any(r.density is not None and r.density > threshold for r in rs): return 1
|
|
@@ -14,7 +14,7 @@ from fastcore.utils import *
|
|
|
14
14
|
|
|
15
15
|
# %% ../nbs/00_core.ipynb #5fecc627
|
|
16
16
|
tells = {
|
|
17
|
-
1: 'splices', 2: '
|
|
17
|
+
1: 'splices', 2: 'key rule in an aside', 3: 'emphasis devices',
|
|
18
18
|
4: 'elegant variation', 5: 'flourish over identifier', 6: 'consequence glue',
|
|
19
19
|
7: 'hedging', 8: 'noting fillers', 9: 'restatement',
|
|
20
20
|
10: 'justification rider', 11: 'decorative verbs', 12: 'audience misjudged',
|
|
@@ -50,7 +50,7 @@ def rule(
|
|
|
50
50
|
name, # Rule name, unique within slopometer
|
|
51
51
|
tell, # `write_docs` tell number the rule detects
|
|
52
52
|
weight, # Tier the rule's findings carry
|
|
53
|
-
level, # The unit the detector reads: 'phrase', 'sentence', 'para', or 'doc'
|
|
53
|
+
level, # The unit the detector reads: 'phrase', 'markdown', 'sentence', 'para', or 'doc'
|
|
54
54
|
):
|
|
55
55
|
"Register a detector, a function from its unit's text to the list of `Finding`s in it"
|
|
56
56
|
def _f(f):
|
|
@@ -71,10 +71,9 @@ def find_decoration(txt):
|
|
|
71
71
|
|
|
72
72
|
# %% ../nbs/00_core.ipynb #1af3670a
|
|
73
73
|
unscoreable = {
|
|
74
|
-
2: 'knowing which fact is the
|
|
74
|
+
2: 'knowing which fact is the key rule takes judgment',
|
|
75
75
|
5: "telling a flourish from a real identifier takes the project's vocabulary",
|
|
76
76
|
9: 'lead-sentence restatement defeats averaged sentence vectors, and only the heading echo is detectable (measured in the para notebook)',
|
|
77
77
|
10: 'a justification rider reads like a stated consequence, and only the domain says which it is',
|
|
78
|
-
|
|
79
|
-
12: 'matching prose to its audience takes the audience, and the coinage rule covers only the definable half',
|
|
78
|
+
12: 'matching prose and terminology to the reader requires audience and document context',
|
|
80
79
|
26: 'depth is judged against what the reader already knows',}
|
|
@@ -12,7 +12,7 @@ __all__ = ['GH_REPOS', 'CL_SKIP', 'label_overlap', 'label_density', 'reply_final
|
|
|
12
12
|
'build_corpus']
|
|
13
13
|
|
|
14
14
|
# %% ../nbs/08_corpus.ipynb #a2a2e052
|
|
15
|
-
import base64, json,
|
|
15
|
+
import base64, json, httpx2
|
|
16
16
|
from collections import Counter
|
|
17
17
|
from datetime import datetime, timedelta
|
|
18
18
|
from fastcore.utils import *
|
|
@@ -52,10 +52,10 @@ def _mirror_prompts(days, path_re):
|
|
|
52
52
|
from aidialog.dlgskill import find_msgs
|
|
53
53
|
from rgapi.skill import fd
|
|
54
54
|
root = index().root
|
|
55
|
-
cutoff = datetime.now() - timedelta(days=days)
|
|
55
|
+
cutoff = (datetime.now() - timedelta(days=days)).timestamp()
|
|
56
56
|
for p in fd(root, ext='ipynb', path_re=path_re):
|
|
57
|
-
if p.
|
|
58
|
-
yield from ((p, m) for m in find_msgs(msg_type='prompt', context=0, dlg=
|
|
57
|
+
if p.stat().st_mtime <= cutoff: continue
|
|
58
|
+
yield from ((p.relative_to(root), m) for m in find_msgs(msg_type='prompt', context=0, dlg=p))
|
|
59
59
|
|
|
60
60
|
def mirror_replies(
|
|
61
61
|
days=21, # How far back to walk the mirror
|
|
@@ -89,7 +89,7 @@ async def readme_paras(
|
|
|
89
89
|
gql = GhGql()
|
|
90
90
|
names = [o['name'] async for o in gql.paged(gql.organization(login=org).repositories, 'name createdAt isFork')
|
|
91
91
|
if o['createdAt'][:10] < before and not o['isFork']]
|
|
92
|
-
names = sorted(set(names) & {p.
|
|
92
|
+
names = sorted(set(names) & {p.name for p in ls(root, dirs=True, files=False)} - set(exclude))
|
|
93
93
|
txts = await gql.batch(gql.repo(f'{org}/{n}').object(expression='HEAD:README.md')('... on Blob { text }') for n in names)
|
|
94
94
|
return [r for n, t in zip(names, txts) if t for r in doc_paras(t['text'], 'readme', f'{org}/{n}', min_words)]
|
|
95
95
|
|
|
@@ -159,11 +159,11 @@ async def gh_readme_paras(repos=GH_REPOS, date='2022-06-01', min_words=50):
|
|
|
159
159
|
def _wiki_get(lang, tries=2, **params):
|
|
160
160
|
for i in range(tries):
|
|
161
161
|
try:
|
|
162
|
-
r =
|
|
162
|
+
r = httpx2.get(f'https://{lang}.wikipedia.org/w/api.php', follow_redirects=True, timeout=30,
|
|
163
163
|
headers={'User-Agent': 'slopometer/0.1 (https://github.com/AnswerDotAI/slopometer)'},
|
|
164
164
|
params=dict(action='query', prop='extracts', explaintext=1, exintro=1, format='json', **params))
|
|
165
165
|
return r.json()['query']['pages'].values()
|
|
166
|
-
except
|
|
166
|
+
except httpx2.TimeoutException:
|
|
167
167
|
if i == tries-1: raise
|
|
168
168
|
|
|
169
169
|
def wiki_paras(n=40, lang='en', min_words=50):
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""Continuous measurements for learned scoring
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`features` measures prose for experiments with learned scoring. It returns rule-finding rates, sentence and paragraph statistics, grammatical counts, and measures of concrete detail and references.
|
|
4
|
+
|
|
5
|
+
Some measurements failed as standalone rules in the `para` and `score` notebooks. A model trained on labelled examples could still use them in combination. These experiments do not change slopometer's rule-based score.
|
|
4
6
|
|
|
5
7
|
Docs: https://AnswerDotAI.github.io/slopometer/features.html.md"""
|
|
6
8
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Word and phrase rules: banned vocabulary, hedges, fillers, and splices
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
These rules match words, phrases, punctuation, and Markdown emphasis in scrubbed text. They cover banned vocabulary, hedges, fillers, splices, and consequence joins. Findings include plain replacements where available. The rules use regexes without linguistic analysis.
|
|
4
4
|
|
|
5
5
|
Docs: https://AnswerDotAI.github.io/slopometer/lexicon.html.md"""
|
|
6
6
|
|
|
@@ -12,10 +12,10 @@ __all__ = ['banned', 'find_banned', 'hedges', 'noting', 'transitions', 'find_hed
|
|
|
12
12
|
|
|
13
13
|
# %% ../nbs/02_lexicon.ipynb #23bc602c
|
|
14
14
|
from fastcore.utils import *
|
|
15
|
+
from mdhtml import inlines
|
|
15
16
|
from .core import *
|
|
16
17
|
from .segment import *
|
|
17
18
|
|
|
18
|
-
|
|
19
19
|
# %% ../nbs/02_lexicon.ipynb #449d550b
|
|
20
20
|
def lex_rule(
|
|
21
21
|
name, # Rule name, as in `core.rule`
|
|
@@ -64,13 +64,13 @@ find_noting = lex_rule('noting', tell=8, weight=SMELL, lex=noting)
|
|
|
64
64
|
find_transitions = lex_rule('transitions', tell=19, weight=SMELL, lex=transitions)
|
|
65
65
|
|
|
66
66
|
# %% ../nbs/02_lexicon.ipynb #79549961
|
|
67
|
-
_splice = re.compile(r'
|
|
67
|
+
_splice = re.compile(r'—')
|
|
68
68
|
_conseq = re.compile(r', so\b')
|
|
69
69
|
|
|
70
|
-
@rule('splice', tell=1, weight=
|
|
70
|
+
@rule('splice', tell=1, weight=4, level='phrase')
|
|
71
71
|
def find_splice(txt):
|
|
72
|
-
"Em dashes
|
|
73
|
-
return [Finding('splice', 1, m.start(), m.end(), m.group(),
|
|
72
|
+
"Em dashes"
|
|
73
|
+
return [Finding('splice', 1, m.start(), m.end(), m.group(), 4) for m in _splice.finditer(txt)]
|
|
74
74
|
|
|
75
75
|
@rule('conseq', tell=6, weight=SMELL, level='phrase')
|
|
76
76
|
def find_conseq(txt):
|
|
@@ -78,12 +78,10 @@ def find_conseq(txt):
|
|
|
78
78
|
return [Finding('conseq', 6, m.start(), m.end(), m.group(), SMELL) for m in _conseq.finditer(txt)]
|
|
79
79
|
|
|
80
80
|
# %% ../nbs/02_lexicon.ipynb #8daaf936
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
@rule('emphasis', tell=3, weight=SMELL, level='phrase')
|
|
81
|
+
@rule('emphasis', tell=3, weight=SMELL, level='markdown')
|
|
84
82
|
def find_emphasis(txt):
|
|
85
|
-
"Bold and italic
|
|
86
|
-
return [Finding('emphasis', 3,
|
|
83
|
+
"Bold and italic text in Markdown `txt`"
|
|
84
|
+
return [Finding('emphasis', 3, n['start'], n['end'], txt[n['start']:n['end']], SMELL) for n in inlines(txt) if n['type'] in ('emph', 'strong')]
|
|
87
85
|
|
|
88
86
|
# %% ../nbs/02_lexicon.ipynb #cdb0967f
|
|
89
87
|
wordy = {'all of': 'all', 'additional': 'more', 'a number of': 'some', 'along the lines of': 'like',
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Score prose with Pangram's AI detector, as an alternative to the rules
|
|
2
|
+
|
|
3
|
+
`pangram_text` and `pangram_path` send prose to [Pangram](https://www.pangram.com)'s AI-detection API instead of running the rules. They read the same Markdown as `score_text` and `score_path`. They return a `Result` with the same addresses, JSON fields and `min_words` cutoff. `slopometer --pangram` runs `pangram_path` from the command line.
|
|
4
|
+
|
|
5
|
+
Pangram scores text in windows, which can run to hundreds of words. Each finding is one window. Its weight is the window's score multiplied by 10. A window that Pangram rates fully AI-written weighs 10, the same as a kill-tier rule finding. The density is the mean window score, weighted by word count, on a scale from 0 (human) to 100 (AI). Under `--pangram`, `--threshold` compares against this scale.
|
|
6
|
+
|
|
7
|
+
Each request sends the text to Pangram's servers and costs about $0.05 per 100 words. The functions read the API key from `PANGRAM_API_KEY`.
|
|
8
|
+
|
|
9
|
+
Docs: https://AnswerDotAI.github.io/slopometer/pangram.html.md"""
|
|
10
|
+
|
|
11
|
+
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/05a_pangram.ipynb.
|
|
12
|
+
|
|
13
|
+
# %% auto #0
|
|
14
|
+
__all__ = ['PANGRAM_URL', 'PangramFinding', 'PangramResult', 'pangram_text', 'pangram_path', 'pangram_paths']
|
|
15
|
+
|
|
16
|
+
# %% ../nbs/05a_pangram.ipynb #50ef2286
|
|
17
|
+
import time, httpx2
|
|
18
|
+
from bisect import bisect_right
|
|
19
|
+
from fastcore.utils import *
|
|
20
|
+
from .core import *
|
|
21
|
+
from .segment import *
|
|
22
|
+
from .score import Result, _score_file, _doc_paths
|
|
23
|
+
|
|
24
|
+
# %% ../nbs/05a_pangram.ipynb #a265951c
|
|
25
|
+
def _pangram_input(blocks):
|
|
26
|
+
"Text to send to Pangram for `blocks`, and each block's offset in it"
|
|
27
|
+
offs, n = [], 0
|
|
28
|
+
for b in blocks:
|
|
29
|
+
offs.append(n)
|
|
30
|
+
n += len(b.txt) + 1
|
|
31
|
+
return '\n'.join(b.txt for b in blocks), offs
|
|
32
|
+
|
|
33
|
+
# %% ../nbs/05a_pangram.ipynb #a1383847
|
|
34
|
+
PANGRAM_URL = 'https://text.external-api.pangram.com'
|
|
35
|
+
|
|
36
|
+
def _predict(
|
|
37
|
+
text, # Text to classify
|
|
38
|
+
timeout=120, # Seconds to wait for the task to finish
|
|
39
|
+
):
|
|
40
|
+
"Pangram's response for `text`, from a task posted to its API and polled until done"
|
|
41
|
+
with httpx2.Client(base_url=PANGRAM_URL, headers={'x-api-key': os.environ['PANGRAM_API_KEY']}) as cli:
|
|
42
|
+
tid = cli.post('/task', json=dict(text=text, model='default')).raise_for_status().json()['task_id']
|
|
43
|
+
for _ in range(timeout*2):
|
|
44
|
+
res = cli.get(f'/task/{tid}').raise_for_status().json()
|
|
45
|
+
if res['stage'] == 'STAGE_SUCCESS': return res
|
|
46
|
+
if res['stage'] == 'STAGE_FAILED': raise RuntimeError(f'Pangram task {tid} failed')
|
|
47
|
+
time.sleep(0.5)
|
|
48
|
+
raise TimeoutError(f'Pangram task {tid} did not finish in {timeout} seconds')
|
|
49
|
+
|
|
50
|
+
# %% ../nbs/05a_pangram.ipynb #ddbc675b
|
|
51
|
+
class PangramFinding(Finding):
|
|
52
|
+
def __init__(self,
|
|
53
|
+
start, # Character offset where the window starts
|
|
54
|
+
end, # Character offset one past the window's end
|
|
55
|
+
text, # The window's text
|
|
56
|
+
score, # Pangram's `ai_assistance_score`, from 0 (human) to 1 (AI)
|
|
57
|
+
label, # Pangram's label, such as 'AI-Generated' or 'Human Written'
|
|
58
|
+
confidence, # 'High', 'Medium', or 'Low'
|
|
59
|
+
words, # Words in the window
|
|
60
|
+
):
|
|
61
|
+
super().__init__('pangram', None, start, end, text, round(10*score))
|
|
62
|
+
store_attr('score,label,confidence,words')
|
|
63
|
+
def __repr__(self):
|
|
64
|
+
ex = self.text if len(self.text) <= 80 else self.text[:77]+'...'
|
|
65
|
+
return f'[{self.weight}] pangram {self.score:.2f} {self.label}, {self.confidence} confidence, {self.words} words: {ex!r}'
|
|
66
|
+
|
|
67
|
+
# %% ../nbs/05a_pangram.ipynb #392e91e1
|
|
68
|
+
class PangramResult(Result):
|
|
69
|
+
def __init__(self,
|
|
70
|
+
txt, # The scored document
|
|
71
|
+
findings, # `PangramFinding`s, worst first
|
|
72
|
+
words, # Word count across all scored blocks
|
|
73
|
+
payload=None, # Pangram's response; None when no request was sent
|
|
74
|
+
min_words=150, # Minimum scored words; 0 disables the cutoff
|
|
75
|
+
):
|
|
76
|
+
super().__init__(txt, findings, words, min_words=min_words)
|
|
77
|
+
self.payload = payload
|
|
78
|
+
def _header(self): return f'pangram score {self.density} on {self.words} prose words, worst {self.worst}'
|
|
79
|
+
|
|
80
|
+
# %% ../nbs/05a_pangram.ipynb #acd5edf6
|
|
81
|
+
@patch(as_prop=True)
|
|
82
|
+
def density(self:PangramResult):
|
|
83
|
+
"Mean window score, weighted by word count and scaled to 0-100"
|
|
84
|
+
if self.too_short: return None
|
|
85
|
+
n = sum(f.words for f in self.findings)
|
|
86
|
+
return round(100*sum(f.score*f.words for f in self.findings)/max(n, 1), 1)
|
|
87
|
+
|
|
88
|
+
# %% ../nbs/05a_pangram.ipynb #098e727a
|
|
89
|
+
def _pangram_result(txt, payload, min_words=150):
|
|
90
|
+
"`PangramResult` for markdown `txt`, with the windows in Pangram's `payload` mapped back to document offsets"
|
|
91
|
+
blocks = segment(txt)
|
|
92
|
+
sent, offs = _pangram_input(blocks)
|
|
93
|
+
if payload['text'] != sent: raise ValueError('Pangram scored different text from the text sent, and its window offsets cannot be mapped')
|
|
94
|
+
def doc_off(i):
|
|
95
|
+
k = bisect_right(offs, i) - 1
|
|
96
|
+
return blocks[k].start + min(i-offs[k], len(blocks[k].txt))
|
|
97
|
+
fs = [PangramFinding(doc_off(w['start_index']), doc_off(w['end_index']-1)+1, w['text'], w['ai_assistance_score'],
|
|
98
|
+
w['label'], w['confidence'], w['word_count']) for w in payload['windows']]
|
|
99
|
+
return PangramResult(txt, sorted(fs, key=lambda f: (-f.score, f.start)), scored_words(blocks), payload, min_words)
|
|
100
|
+
|
|
101
|
+
# %% ../nbs/05a_pangram.ipynb #24c81a92
|
|
102
|
+
def pangram_text(txt, min_words=150):
|
|
103
|
+
"Score markdown `txt` with Pangram, sending no request below `min_words` scored words"
|
|
104
|
+
if min_words < 0: raise ValueError('min_words must be non-negative')
|
|
105
|
+
blocks = segment(txt)
|
|
106
|
+
words = scored_words(blocks)
|
|
107
|
+
if not words or words < min_words: return PangramResult(txt, [], words, min_words=min_words)
|
|
108
|
+
return _pangram_result(txt, _predict(_pangram_input(blocks)[0]), min_words)
|
|
109
|
+
|
|
110
|
+
# %% ../nbs/05a_pangram.ipynb #db5d53f7
|
|
111
|
+
def pangram_path(p, min_words=150):
|
|
112
|
+
"Score a Markdown file, an `.ipynb` file's Markdown cells, or a `.py` file's module docstring with Pangram, with exhash addresses"
|
|
113
|
+
return _score_file(p, pangram_text, min_words)
|
|
114
|
+
|
|
115
|
+
def pangram_paths(paths, n_workers=8, min_words=150):
|
|
116
|
+
"Score files and directories with Pangram in parallel threads, one `PangramResult` per file"
|
|
117
|
+
return parallel(pangram_path, _doc_paths(paths), n_workers=n_workers, threadpool=True, min_words=min_words)
|