quire-grammar 0.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.
- quire_grammar-0.2.0/LICENSE +21 -0
- quire_grammar-0.2.0/PKG-INFO +121 -0
- quire_grammar-0.2.0/README.md +95 -0
- quire_grammar-0.2.0/pyproject.toml +64 -0
- quire_grammar-0.2.0/setup.cfg +4 -0
- quire_grammar-0.2.0/src/quire_grammar/__init__.py +17 -0
- quire_grammar-0.2.0/src/quire_grammar/__main__.py +57 -0
- quire_grammar-0.2.0/src/quire_grammar/check.py +56 -0
- quire_grammar-0.2.0/src/quire_grammar/edits.py +241 -0
- quire_grammar-0.2.0/src/quire_grammar/model.py +245 -0
- quire_grammar-0.2.0/src/quire_grammar/py.typed +0 -0
- quire_grammar-0.2.0/src/quire_grammar/registry.py +45 -0
- quire_grammar-0.2.0/src/quire_grammar/rules/__init__.py +11 -0
- quire_grammar-0.2.0/src/quire_grammar/rules/agreement.py +99 -0
- quire_grammar-0.2.0/src/quire_grammar/rules/confusables.py +200 -0
- quire_grammar-0.2.0/src/quire_grammar/rules/mechanics.py +142 -0
- quire_grammar-0.2.0/src/quire_grammar/rules/punctuation.py +130 -0
- quire_grammar-0.2.0/src/quire_grammar/rules/style.py +144 -0
- quire_grammar-0.2.0/src/quire_grammar/segment.py +35 -0
- quire_grammar-0.2.0/src/quire_grammar/types.py +41 -0
- quire_grammar-0.2.0/src/quire_grammar.egg-info/PKG-INFO +121 -0
- quire_grammar-0.2.0/src/quire_grammar.egg-info/SOURCES.txt +34 -0
- quire_grammar-0.2.0/src/quire_grammar.egg-info/dependency_links.txt +1 -0
- quire_grammar-0.2.0/src/quire_grammar.egg-info/entry_points.txt +2 -0
- quire_grammar-0.2.0/src/quire_grammar.egg-info/requires.txt +18 -0
- quire_grammar-0.2.0/src/quire_grammar.egg-info/top_level.txt +1 -0
- quire_grammar-0.2.0/tests/test_build_dataset.py +89 -0
- quire_grammar-0.2.0/tests/test_check.py +44 -0
- quire_grammar-0.2.0/tests/test_corrupt.py +94 -0
- quire_grammar-0.2.0/tests/test_edits.py +93 -0
- quire_grammar-0.2.0/tests/test_eval.py +65 -0
- quire_grammar-0.2.0/tests/test_labels.py +32 -0
- quire_grammar-0.2.0/tests/test_model.py +113 -0
- quire_grammar-0.2.0/tests/test_model_integration.py +64 -0
- quire_grammar-0.2.0/tests/test_prepare_corpus.py +97 -0
- quire_grammar-0.2.0/tests/test_rules.py +149 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Roger Cooper
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: quire-grammar
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: An offline English grammar and style checker — a writer's aid, not a proofreading service.
|
|
5
|
+
Author: Roger Cooper
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Provides-Extra: model
|
|
11
|
+
Requires-Dist: quire-grammar-model>=0.2; extra == "model"
|
|
12
|
+
Requires-Dist: onnxruntime>=1.17; extra == "model"
|
|
13
|
+
Requires-Dist: tokenizers>=0.15; extra == "model"
|
|
14
|
+
Provides-Extra: ml
|
|
15
|
+
Requires-Dist: quire-grammar-model>=0.2; extra == "ml"
|
|
16
|
+
Requires-Dist: onnxruntime>=1.17; extra == "ml"
|
|
17
|
+
Requires-Dist: tokenizers>=0.15; extra == "ml"
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
20
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
21
|
+
Requires-Dist: mypy>=1.8; extra == "dev"
|
|
22
|
+
Requires-Dist: onnxruntime>=1.17; extra == "dev"
|
|
23
|
+
Requires-Dist: tokenizers>=0.15; extra == "dev"
|
|
24
|
+
Requires-Dist: numpy>=1.26; extra == "dev"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# quire-grammar
|
|
28
|
+
|
|
29
|
+
A **standalone, offline English grammar and style checker** — a writer's aid,
|
|
30
|
+
not a proofreading service, and a library other writing tools can license
|
|
31
|
+
rather than build their own. It runs fully offline and is permissively
|
|
32
|
+
licensed end to end.
|
|
33
|
+
|
|
34
|
+
[Quire Desktop](https://github.com/jutreuter/quire-desktop) is the first
|
|
35
|
+
consumer — the rules layer is a hard dependency (the always-on checker) and
|
|
36
|
+
the model is the opt-in `[model]` extra, *off if missing* the way WordNet and
|
|
37
|
+
SCOWL spell-check are. The dependency runs one way and the `check()` API is
|
|
38
|
+
designed to stand on its own.
|
|
39
|
+
|
|
40
|
+
## Use it
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from quire_grammar import check
|
|
44
|
+
|
|
45
|
+
for c in check("Its been a long day and and the sky is is grey."):
|
|
46
|
+
print(c.rule_id, repr(c.original), "->", c.suggestions, f"({c.confidence})")
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
$ quire-grammar "She had a apple. This went better then that."
|
|
51
|
+
1:9 [mechanics/a-vs-an] “a” before “apple” — “an” matches the sound. → 'an'
|
|
52
|
+
1:35 [confusable/then-vs-than] “then” here reads as “than”. → 'than'
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`check()` returns a sorted `list[Correction]` — a stable dataclass of
|
|
56
|
+
`start, end, original, category, rule_id, message, suggestions, confidence`.
|
|
57
|
+
That signature does not change whether or not the model is installed.
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
pip install quire-grammar # rules layer — pure Python, zero deps
|
|
63
|
+
pip install quire-grammar[model] # + the phase-2 model (onnxruntime, tokenizers, ~40 MB weights)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Without the `[model]` extra, `check()` runs rules-only and the model
|
|
67
|
+
contributes nothing — the same "off if missing" behaviour Quire gives WordNet
|
|
68
|
+
and spell-check.
|
|
69
|
+
|
|
70
|
+
## How it works
|
|
71
|
+
|
|
72
|
+
**Phase 1 — a deterministic rules layer (this repo, now).** Pure-Python,
|
|
73
|
+
**zero runtime dependencies**. Each rule is a narrow, high-precision pattern
|
|
74
|
+
for a mistake a *native writer* actually makes. The guard rail is precision:
|
|
75
|
+
a checker that cries wolf gets switched off, so the test suite weighs "must
|
|
76
|
+
not flag" cases as heavily as "must flag".
|
|
77
|
+
|
|
78
|
+
| category | rules |
|
|
79
|
+
|---|---|
|
|
80
|
+
| **mechanics** | doubled word · `a`/`an` · space before punctuation · missing space after a sentence · capitalisation after `.` |
|
|
81
|
+
| **confusable** | its/it's · there/their/they're · your/you're · then/than · affect/effect · lose/loose · lead/led · breath/breathe · to/too · whose/who's · passed/past · weather/whether · "should of" → "should have" |
|
|
82
|
+
| **agreement** | pronoun subject–verb (`he don't`, `they was`, `I are`) · within-sentence past → present tense drift |
|
|
83
|
+
| **punctuation** | comma splice · broken-off subordinate-clause fragment · straight-vs-curly quotes/apostrophes/dashes *(opt-in)* |
|
|
84
|
+
| **style** | filter verb · `said angrily` · overlong sentence · dangling modifier · weak intensifier *(opt-in)* · passive voice *(opt-in)* |
|
|
85
|
+
|
|
86
|
+
`enable` / `disable` take rule ids or whole categories. Opt-in rules are off
|
|
87
|
+
until you ask for them.
|
|
88
|
+
|
|
89
|
+
**Phase 2 — a small local model behind the same `check()` API.** A
|
|
90
|
+
GECToR-style edit tagger (DistilBERT-base-cased, 66 MB int8 ONNX, ~40 MB in
|
|
91
|
+
the wheel) run on CPU via `onnxruntime` — no GPU is assumed, for training or
|
|
92
|
+
inference. It catches homophone and agreement slips the deliberately-narrow
|
|
93
|
+
rules miss, gated at a 0.9 confidence floor (tuned so it flags ~1% of
|
|
94
|
+
genuinely clean sentences), merged with the rule hits so a rule wins on any
|
|
95
|
+
span overlap. Trained **synthetic-first** — `corrupt.py` errors over
|
|
96
|
+
public-domain prose, because the usual GEC learner corpora are
|
|
97
|
+
research/non-commercial licensed. It ships as the separate
|
|
98
|
+
[`quire-grammar-model`](packages/quire-grammar-model/) distribution
|
|
99
|
+
(`quire-grammar[model]`); training code and data pipeline live in `train/`
|
|
100
|
+
and are **not** shipped.
|
|
101
|
+
|
|
102
|
+
See [CLAUDE.md](CLAUDE.md) for the design contract and the phase-2 plan.
|
|
103
|
+
|
|
104
|
+
## Develop
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
python -m venv .venv
|
|
108
|
+
.venv/bin/pip install -e ".[dev]" -e ./packages/quire-grammar-model
|
|
109
|
+
.venv/bin/python -m pytest -q
|
|
110
|
+
.venv/bin/ruff check src train tests packages
|
|
111
|
+
.venv/bin/mypy
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The phase-2 training scripts in `train/` need the heavier ML stack —
|
|
115
|
+
`.venv/bin/pip install -r train/requirements.txt` (CPU-only, all permissive).
|
|
116
|
+
It is **not** a dependency of the shipped package.
|
|
117
|
+
|
|
118
|
+
`quire-grammar` is MIT; `quire-grammar-model` is Apache-2.0 (it carries a
|
|
119
|
+
`distilbert-base-cased` lineage). Everything either depends on — now and
|
|
120
|
+
planned — is MIT / BSD / Apache. No GPL, no AGPL, so a licensee can
|
|
121
|
+
redistribute the whole thing.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# quire-grammar
|
|
2
|
+
|
|
3
|
+
A **standalone, offline English grammar and style checker** — a writer's aid,
|
|
4
|
+
not a proofreading service, and a library other writing tools can license
|
|
5
|
+
rather than build their own. It runs fully offline and is permissively
|
|
6
|
+
licensed end to end.
|
|
7
|
+
|
|
8
|
+
[Quire Desktop](https://github.com/jutreuter/quire-desktop) is the first
|
|
9
|
+
consumer — the rules layer is a hard dependency (the always-on checker) and
|
|
10
|
+
the model is the opt-in `[model]` extra, *off if missing* the way WordNet and
|
|
11
|
+
SCOWL spell-check are. The dependency runs one way and the `check()` API is
|
|
12
|
+
designed to stand on its own.
|
|
13
|
+
|
|
14
|
+
## Use it
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from quire_grammar import check
|
|
18
|
+
|
|
19
|
+
for c in check("Its been a long day and and the sky is is grey."):
|
|
20
|
+
print(c.rule_id, repr(c.original), "->", c.suggestions, f"({c.confidence})")
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
$ quire-grammar "She had a apple. This went better then that."
|
|
25
|
+
1:9 [mechanics/a-vs-an] “a” before “apple” — “an” matches the sound. → 'an'
|
|
26
|
+
1:35 [confusable/then-vs-than] “then” here reads as “than”. → 'than'
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`check()` returns a sorted `list[Correction]` — a stable dataclass of
|
|
30
|
+
`start, end, original, category, rule_id, message, suggestions, confidence`.
|
|
31
|
+
That signature does not change whether or not the model is installed.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
pip install quire-grammar # rules layer — pure Python, zero deps
|
|
37
|
+
pip install quire-grammar[model] # + the phase-2 model (onnxruntime, tokenizers, ~40 MB weights)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Without the `[model]` extra, `check()` runs rules-only and the model
|
|
41
|
+
contributes nothing — the same "off if missing" behaviour Quire gives WordNet
|
|
42
|
+
and spell-check.
|
|
43
|
+
|
|
44
|
+
## How it works
|
|
45
|
+
|
|
46
|
+
**Phase 1 — a deterministic rules layer (this repo, now).** Pure-Python,
|
|
47
|
+
**zero runtime dependencies**. Each rule is a narrow, high-precision pattern
|
|
48
|
+
for a mistake a *native writer* actually makes. The guard rail is precision:
|
|
49
|
+
a checker that cries wolf gets switched off, so the test suite weighs "must
|
|
50
|
+
not flag" cases as heavily as "must flag".
|
|
51
|
+
|
|
52
|
+
| category | rules |
|
|
53
|
+
|---|---|
|
|
54
|
+
| **mechanics** | doubled word · `a`/`an` · space before punctuation · missing space after a sentence · capitalisation after `.` |
|
|
55
|
+
| **confusable** | its/it's · there/their/they're · your/you're · then/than · affect/effect · lose/loose · lead/led · breath/breathe · to/too · whose/who's · passed/past · weather/whether · "should of" → "should have" |
|
|
56
|
+
| **agreement** | pronoun subject–verb (`he don't`, `they was`, `I are`) · within-sentence past → present tense drift |
|
|
57
|
+
| **punctuation** | comma splice · broken-off subordinate-clause fragment · straight-vs-curly quotes/apostrophes/dashes *(opt-in)* |
|
|
58
|
+
| **style** | filter verb · `said angrily` · overlong sentence · dangling modifier · weak intensifier *(opt-in)* · passive voice *(opt-in)* |
|
|
59
|
+
|
|
60
|
+
`enable` / `disable` take rule ids or whole categories. Opt-in rules are off
|
|
61
|
+
until you ask for them.
|
|
62
|
+
|
|
63
|
+
**Phase 2 — a small local model behind the same `check()` API.** A
|
|
64
|
+
GECToR-style edit tagger (DistilBERT-base-cased, 66 MB int8 ONNX, ~40 MB in
|
|
65
|
+
the wheel) run on CPU via `onnxruntime` — no GPU is assumed, for training or
|
|
66
|
+
inference. It catches homophone and agreement slips the deliberately-narrow
|
|
67
|
+
rules miss, gated at a 0.9 confidence floor (tuned so it flags ~1% of
|
|
68
|
+
genuinely clean sentences), merged with the rule hits so a rule wins on any
|
|
69
|
+
span overlap. Trained **synthetic-first** — `corrupt.py` errors over
|
|
70
|
+
public-domain prose, because the usual GEC learner corpora are
|
|
71
|
+
research/non-commercial licensed. It ships as the separate
|
|
72
|
+
[`quire-grammar-model`](packages/quire-grammar-model/) distribution
|
|
73
|
+
(`quire-grammar[model]`); training code and data pipeline live in `train/`
|
|
74
|
+
and are **not** shipped.
|
|
75
|
+
|
|
76
|
+
See [CLAUDE.md](CLAUDE.md) for the design contract and the phase-2 plan.
|
|
77
|
+
|
|
78
|
+
## Develop
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
python -m venv .venv
|
|
82
|
+
.venv/bin/pip install -e ".[dev]" -e ./packages/quire-grammar-model
|
|
83
|
+
.venv/bin/python -m pytest -q
|
|
84
|
+
.venv/bin/ruff check src train tests packages
|
|
85
|
+
.venv/bin/mypy
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The phase-2 training scripts in `train/` need the heavier ML stack —
|
|
89
|
+
`.venv/bin/pip install -r train/requirements.txt` (CPU-only, all permissive).
|
|
90
|
+
It is **not** a dependency of the shipped package.
|
|
91
|
+
|
|
92
|
+
`quire-grammar` is MIT; `quire-grammar-model` is Apache-2.0 (it carries a
|
|
93
|
+
`distilbert-base-cased` lineage). Everything either depends on — now and
|
|
94
|
+
planned — is MIT / BSD / Apache. No GPL, no AGPL, so a licensee can
|
|
95
|
+
redistribute the whole thing.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "quire-grammar"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "An offline English grammar and style checker — a writer's aid, not a proofreading service."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Roger Cooper" }]
|
|
13
|
+
# The rules layer has ZERO runtime dependencies. Keep it that way.
|
|
14
|
+
dependencies = []
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
# Phase 2 — the small local model behind the same check() API. `model` pulls
|
|
18
|
+
# the ONNX weights (the separate quire-grammar-model dist) plus the runtime.
|
|
19
|
+
model = ["quire-grammar-model>=0.2", "onnxruntime>=1.17", "tokenizers>=0.15"]
|
|
20
|
+
ml = ["quire-grammar-model>=0.2", "onnxruntime>=1.17", "tokenizers>=0.15"] # deprecated alias for `model`
|
|
21
|
+
# dev pulls the model runtime too (onnxruntime/tokenizers/numpy) so the full
|
|
22
|
+
# test suite — including the ONNX integration tests — runs after
|
|
23
|
+
# pip install -e ".[dev]" -e ./packages/quire-grammar-model
|
|
24
|
+
dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.8",
|
|
25
|
+
"onnxruntime>=1.17", "tokenizers>=0.15", "numpy>=1.26"]
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
quire-grammar = "quire_grammar.__main__:main"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools]
|
|
31
|
+
package-dir = { "" = "src" }
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.package-data]
|
|
37
|
+
# py.typed marks the package as typed; models/* is the optional local-build
|
|
38
|
+
# fallback (the real model ships as the separate quire-grammar-model dist)
|
|
39
|
+
quire_grammar = ["py.typed", "models/*"]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
# "src" for the package, "packages/.../src" so the model dist is importable
|
|
43
|
+
# without installing it, "." for the unshipped train/ package
|
|
44
|
+
pythonpath = ["src", "packages/quire-grammar-model/src", "."]
|
|
45
|
+
testpaths = ["tests"]
|
|
46
|
+
|
|
47
|
+
[tool.ruff]
|
|
48
|
+
line-length = 100
|
|
49
|
+
target-version = "py311"
|
|
50
|
+
|
|
51
|
+
[tool.ruff.lint]
|
|
52
|
+
select = ["E", "F", "W", "I", "B", "UP", "RUF"]
|
|
53
|
+
ignore = ["RUF001"] # curly quotes / “ ” are deliberate in message text
|
|
54
|
+
|
|
55
|
+
[tool.mypy]
|
|
56
|
+
files = ["src/quire_grammar", "train", "packages/quire-grammar-model/src"]
|
|
57
|
+
mypy_path = "packages/quire-grammar-model/src"
|
|
58
|
+
|
|
59
|
+
# train/train.py and train/export.py are workbench-only and import the heavy
|
|
60
|
+
# ML stack from train/requirements.txt, which isn't installed for lint/CI.
|
|
61
|
+
[[tool.mypy.overrides]]
|
|
62
|
+
module = ["torch.*", "transformers.*", "datasets.*", "numpy.*", "onnx.*",
|
|
63
|
+
"onnxruntime.*", "tokenizers.*"]
|
|
64
|
+
ignore_missing_imports = true
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""quire-grammar — an offline English grammar and style checker.
|
|
2
|
+
|
|
3
|
+
Public surface (stable):
|
|
4
|
+
|
|
5
|
+
check(text, *, enable=None, disable=None, min_confidence=0.0) -> list[Correction]
|
|
6
|
+
|
|
7
|
+
Everything else is an implementation detail. The phase-2 local model plugs in
|
|
8
|
+
behind ``check`` without changing this signature.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .check import check
|
|
14
|
+
from .types import CATEGORIES, Correction
|
|
15
|
+
|
|
16
|
+
__version__ = "0.0.1"
|
|
17
|
+
__all__ = ["CATEGORIES", "Correction", "__version__", "check"]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""quire-grammar CLI:
|
|
2
|
+
|
|
3
|
+
quire-grammar "Its a nice day and and the the sky is is blue."
|
|
4
|
+
echo "text" | quire-grammar -
|
|
5
|
+
quire-grammar --json chapter-3.txt
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from . import __version__, check
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _line_col(text: str, pos: int) -> tuple[int, int]:
|
|
18
|
+
head = text[:pos]
|
|
19
|
+
return head.count("\n") + 1, pos - (head.rfind("\n") + 1) + 1
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main(argv: list[str] | None = None) -> int:
|
|
23
|
+
ap = argparse.ArgumentParser(prog="quire-grammar", description=__doc__)
|
|
24
|
+
ap.add_argument("source", help='text, a file path, or "-" for stdin')
|
|
25
|
+
ap.add_argument("--json", action="store_true", help="machine-readable output")
|
|
26
|
+
ap.add_argument("--min-confidence", type=float, default=0.0)
|
|
27
|
+
ap.add_argument("--disable", default="", help="comma-separated rule ids / categories")
|
|
28
|
+
ap.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
29
|
+
args = ap.parse_args(argv)
|
|
30
|
+
|
|
31
|
+
if args.source == "-":
|
|
32
|
+
text = sys.stdin.read()
|
|
33
|
+
else:
|
|
34
|
+
try:
|
|
35
|
+
with open(args.source, encoding="utf-8") as f:
|
|
36
|
+
text = f.read()
|
|
37
|
+
except OSError:
|
|
38
|
+
text = args.source
|
|
39
|
+
|
|
40
|
+
hits = check(text, disable=[s for s in args.disable.split(",") if s],
|
|
41
|
+
min_confidence=args.min_confidence)
|
|
42
|
+
|
|
43
|
+
if args.json:
|
|
44
|
+
print(json.dumps([vars(h) | {"meta": h.meta} for h in hits], indent=2))
|
|
45
|
+
return 1 if hits else 0
|
|
46
|
+
|
|
47
|
+
for h in hits:
|
|
48
|
+
line, col = _line_col(text, h.start)
|
|
49
|
+
fix = f" → {h.suggestions[0]!r}" if h.suggestions else ""
|
|
50
|
+
print(f"{line}:{col} [{h.category}/{h.rule_id}] {h.message}{fix}")
|
|
51
|
+
if not hits:
|
|
52
|
+
print("no issues found")
|
|
53
|
+
return 1 if hits else 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""The one call the rest of the world makes.
|
|
2
|
+
|
|
3
|
+
from quire_grammar import check
|
|
4
|
+
for c in check("Its a nice day and and the the sky is is blue."):
|
|
5
|
+
print(c.rule_id, c.original, "->", c.suggestions)
|
|
6
|
+
|
|
7
|
+
``enable`` / ``disable`` accept rule ids *or* category names, so a UI can
|
|
8
|
+
switch off "style" wholesale or turn on a single opt-in rule.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable, Iterable
|
|
14
|
+
|
|
15
|
+
from . import rules as _rules # noqa: F401 — importing registers every rule
|
|
16
|
+
from .registry import all_rules
|
|
17
|
+
from .types import Correction
|
|
18
|
+
|
|
19
|
+
# Phase 2: when the optional model is installed, its predictions are merged
|
|
20
|
+
# in here (same Correction shape, category "agreement" etc.), de-duplicated
|
|
21
|
+
# against the rule hits, and gated by a confidence threshold.
|
|
22
|
+
_model_predict: Callable[[str], list[Correction]] | None
|
|
23
|
+
try: # pragma: no cover
|
|
24
|
+
from .model import predict as _model_predict
|
|
25
|
+
except Exception: # pragma: no cover
|
|
26
|
+
_model_predict = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def check(
|
|
30
|
+
text: str,
|
|
31
|
+
*,
|
|
32
|
+
enable: Iterable[str] | None = None,
|
|
33
|
+
disable: Iterable[str] | None = None,
|
|
34
|
+
min_confidence: float = 0.0,
|
|
35
|
+
) -> list[Correction]:
|
|
36
|
+
on = set(enable or ())
|
|
37
|
+
off = set(disable or ())
|
|
38
|
+
|
|
39
|
+
out: list[Correction] = []
|
|
40
|
+
for r in all_rules():
|
|
41
|
+
keys = {r.id, r.category}
|
|
42
|
+
active = bool(keys & on) or (r.enabled_by_default and not (keys & off))
|
|
43
|
+
if active:
|
|
44
|
+
out.extend(r.fn(text))
|
|
45
|
+
|
|
46
|
+
if _model_predict is not None and "model" not in off: # pragma: no cover
|
|
47
|
+
rule_spans = [(c.start, c.end) for c in out]
|
|
48
|
+
for c in _model_predict(text):
|
|
49
|
+
# a rule hit wins on any span overlap — it's the higher-precision
|
|
50
|
+
# signal; the model only adds what the rules missed
|
|
51
|
+
if not any(c.start < re_ and rs < c.end for rs, re_ in rule_spans):
|
|
52
|
+
out.append(c)
|
|
53
|
+
|
|
54
|
+
out = [c for c in out if c.confidence >= min_confidence]
|
|
55
|
+
out.sort(key=lambda c: (c.start, c.end, c.rule_id))
|
|
56
|
+
return out
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""The GECToR-style edit-tag scheme — shared by the phase-2 model's training
|
|
2
|
+
pipeline and its runtime decoder, so it lives in the shipped package.
|
|
3
|
+
|
|
4
|
+
The model reads a sentence, splits it into word tokens, and predicts one
|
|
5
|
+
**edit tag** per token. Applying the tags and re-joining rebuilds the
|
|
6
|
+
corrected sentence.
|
|
7
|
+
|
|
8
|
+
$KEEP leave the token alone
|
|
9
|
+
$DELETE drop the token
|
|
10
|
+
$REPLACE_{t} swap the token for t
|
|
11
|
+
$APPEND_{t} insert t (one or more words) after the token
|
|
12
|
+
$CASE_CAPITAL Titlecase the token
|
|
13
|
+
$CASE_LOWER lowercase the token
|
|
14
|
+
$CASE_UPPER UPPERCASE the token
|
|
15
|
+
|
|
16
|
+
Kept small on purpose — precision over coverage. Multi-edit tokens (GECToR
|
|
17
|
+
uses iterative rounds) are rare in native-writer text; ``$APPEND_{t}`` carries
|
|
18
|
+
the whole inserted run so one pass covers them. Anything the scheme cannot
|
|
19
|
+
express round-trips wrong, and :func:`encode`'s caller drops the pair.
|
|
20
|
+
|
|
21
|
+
Zero dependencies — part of the rules-layer contract.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
|
|
29
|
+
KEEP = "$KEEP"
|
|
30
|
+
DELETE = "$DELETE"
|
|
31
|
+
_REPLACE = "$REPLACE_"
|
|
32
|
+
_APPEND = "$APPEND_"
|
|
33
|
+
CASE_CAPITAL = "$CASE_CAPITAL"
|
|
34
|
+
CASE_LOWER = "$CASE_LOWER"
|
|
35
|
+
CASE_UPPER = "$CASE_UPPER"
|
|
36
|
+
|
|
37
|
+
_CASE_TAGS = (CASE_CAPITAL, CASE_LOWER, CASE_UPPER)
|
|
38
|
+
|
|
39
|
+
#: the tags that are always in the vocabulary, at fixed indices
|
|
40
|
+
BASE_TAGS: tuple[str, ...] = (KEEP, DELETE, *_CASE_TAGS)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def replace_tag(token: str) -> str:
|
|
44
|
+
return _REPLACE + token
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def append_tag(run: str) -> str:
|
|
48
|
+
return _APPEND + run
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_replace(tag: str) -> bool:
|
|
52
|
+
return tag.startswith(_REPLACE)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_append(tag: str) -> bool:
|
|
56
|
+
return tag.startswith(_APPEND)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def tag_value(tag: str) -> str:
|
|
60
|
+
"""The payload of a ``$REPLACE_`` / ``$APPEND_`` tag."""
|
|
61
|
+
return tag.split("_", 1)[1]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# --------------------------------------------------------------------------
|
|
65
|
+
# tokenisation — word tokens with punctuation split off, plus a deterministic
|
|
66
|
+
# detokeniser. Not exact for pathological spacing; callers verify and drop.
|
|
67
|
+
# --------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
_TOKEN_RE = re.compile(
|
|
70
|
+
r"""
|
|
71
|
+
\d[\d,]*(?:\.\d+)? # 12 1,000 3.5
|
|
72
|
+
| [A-Za-z]+(?:['’][A-Za-z]+)* # word, with internal apostrophes
|
|
73
|
+
| \.\.\.|[.!?]+ # sentence punctuation, runs kept together
|
|
74
|
+
| --+|[-–—] # dashes / hyphen
|
|
75
|
+
| [^\s\w] # any other single punctuation mark
|
|
76
|
+
""",
|
|
77
|
+
re.VERBOSE,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# marks that hug the previous token (no space before)
|
|
81
|
+
_CLOSE = set(".,;:!?)]}%…") | {"...", "n't", "'s", "'re", "'ve", "'ll", "'m", "'d"}
|
|
82
|
+
# marks that hug the next token (no space after)
|
|
83
|
+
_OPEN = set("([{$")
|
|
84
|
+
_CONTRACTION = re.compile(r"^['’](s|re|ve|ll|m|d)$|^n['’]t$", re.I)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def tokenize(text: str) -> list[str]:
|
|
88
|
+
return _TOKEN_RE.findall(text)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def tokenize_spans(text: str) -> list[tuple[str, int, int]]:
|
|
92
|
+
"""``(token, start, end)`` — same tokens as :func:`tokenize`, with their
|
|
93
|
+
character offsets in ``text`` (the runtime decoder needs them to place
|
|
94
|
+
``Correction`` spans)."""
|
|
95
|
+
return [(m.group(0), m.start(), m.end()) for m in _TOKEN_RE.finditer(text)]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def detokenize(tokens: list[str]) -> str:
|
|
99
|
+
"""Join word tokens back into a sentence. Deterministic; assumes the
|
|
100
|
+
tokens describe ordinary prose."""
|
|
101
|
+
out: list[str] = []
|
|
102
|
+
quote_open = False
|
|
103
|
+
for tok in tokens:
|
|
104
|
+
if not out:
|
|
105
|
+
out.append(tok)
|
|
106
|
+
continue
|
|
107
|
+
prev = out[-1]
|
|
108
|
+
glue = (
|
|
109
|
+
tok in _CLOSE
|
|
110
|
+
or _CONTRACTION.match(tok)
|
|
111
|
+
or prev in _OPEN
|
|
112
|
+
or prev[-1:] in _OPEN
|
|
113
|
+
or (tok == '"' and quote_open)
|
|
114
|
+
or (prev == '"' and not quote_open)
|
|
115
|
+
or (tok in "-–—")
|
|
116
|
+
or (prev in "-–—")
|
|
117
|
+
or (tok in "'’" and quote_open)
|
|
118
|
+
)
|
|
119
|
+
out.append(tok if glue else " " + tok)
|
|
120
|
+
if tok == '"':
|
|
121
|
+
quote_open = not quote_open
|
|
122
|
+
return "".join(out)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# --------------------------------------------------------------------------
|
|
126
|
+
# alignment — token-level Levenshtein, then read off the edit tags
|
|
127
|
+
# --------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _case_transform(src: str, dst: str) -> str | None:
|
|
131
|
+
if src == dst or src.lower() != dst.lower():
|
|
132
|
+
return None
|
|
133
|
+
if dst == src.capitalize():
|
|
134
|
+
return CASE_CAPITAL
|
|
135
|
+
if dst == src.lower():
|
|
136
|
+
return CASE_LOWER
|
|
137
|
+
if dst == src.upper():
|
|
138
|
+
return CASE_UPPER
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _flush_append(tags: list[str], src_pos: int, pending: list[str]) -> None:
|
|
143
|
+
if pending:
|
|
144
|
+
tags[src_pos - 1] = append_tag(" ".join(pending))
|
|
145
|
+
pending.clear()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _tags_from_alignment(src: list[str], tgt: list[str]) -> list[str] | None:
|
|
149
|
+
n, m = len(src), len(tgt)
|
|
150
|
+
d = [[0] * (m + 1) for _ in range(n + 1)]
|
|
151
|
+
for i in range(1, n + 1):
|
|
152
|
+
d[i][0] = i
|
|
153
|
+
for j in range(1, m + 1):
|
|
154
|
+
d[0][j] = j
|
|
155
|
+
for i in range(1, n + 1):
|
|
156
|
+
for j in range(1, m + 1):
|
|
157
|
+
cost = 0 if src[i - 1] == tgt[j - 1] else 1
|
|
158
|
+
d[i][j] = min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost)
|
|
159
|
+
|
|
160
|
+
tags: list[str] = [KEEP] * n
|
|
161
|
+
pending_append: list[str] = []
|
|
162
|
+
i, j = n, m
|
|
163
|
+
lead_insert: list[str] = []
|
|
164
|
+
while i > 0 or j > 0:
|
|
165
|
+
if i > 0 and j > 0 and src[i - 1] == tgt[j - 1] and d[i][j] == d[i - 1][j - 1]:
|
|
166
|
+
_flush_append(tags, i, pending_append)
|
|
167
|
+
i, j = i - 1, j - 1
|
|
168
|
+
elif i > 0 and j > 0 and d[i][j] == d[i - 1][j - 1] + 1:
|
|
169
|
+
ct = _case_transform(src[i - 1], tgt[j - 1])
|
|
170
|
+
if pending_append:
|
|
171
|
+
tags[i - 1] = replace_tag(" ".join([tgt[j - 1], *pending_append]))
|
|
172
|
+
pending_append.clear()
|
|
173
|
+
else:
|
|
174
|
+
tags[i - 1] = ct or replace_tag(tgt[j - 1])
|
|
175
|
+
i, j = i - 1, j - 1
|
|
176
|
+
elif j > 0 and d[i][j] == d[i][j - 1] + 1:
|
|
177
|
+
pending_append.insert(0, tgt[j - 1])
|
|
178
|
+
j -= 1
|
|
179
|
+
if i == 0:
|
|
180
|
+
lead_insert = list(pending_append)
|
|
181
|
+
else:
|
|
182
|
+
tags[i - 1] = DELETE
|
|
183
|
+
_flush_append(tags, i, pending_append)
|
|
184
|
+
i -= 1
|
|
185
|
+
if lead_insert:
|
|
186
|
+
return None
|
|
187
|
+
return tags
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# --------------------------------------------------------------------------
|
|
191
|
+
# public: encode / apply / verify
|
|
192
|
+
# --------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass(frozen=True)
|
|
196
|
+
class Tagged:
|
|
197
|
+
tokens: tuple[str, ...]
|
|
198
|
+
tags: tuple[str, ...]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def encode(corrupt: str, clean: str) -> Tagged | None:
|
|
202
|
+
"""``(corrupt, clean)`` → token tags, or ``None`` if the scheme can't
|
|
203
|
+
represent the edit or the result won't round-trip."""
|
|
204
|
+
src, tgt = tokenize(corrupt), tokenize(clean)
|
|
205
|
+
if not src:
|
|
206
|
+
return None
|
|
207
|
+
tags = _tags_from_alignment(src, tgt)
|
|
208
|
+
if tags is None:
|
|
209
|
+
return None
|
|
210
|
+
tagged = Tagged(tuple(src), tuple(tags))
|
|
211
|
+
if apply(tagged.tokens, tagged.tags) != detokenize(tgt):
|
|
212
|
+
return None
|
|
213
|
+
return tagged
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def apply(tokens: tuple[str, ...] | list[str],
|
|
217
|
+
tags: tuple[str, ...] | list[str]) -> str:
|
|
218
|
+
out: list[str] = []
|
|
219
|
+
for tok, tag in zip(tokens, tags, strict=True):
|
|
220
|
+
if tag == KEEP:
|
|
221
|
+
out.append(tok)
|
|
222
|
+
elif tag == DELETE:
|
|
223
|
+
continue
|
|
224
|
+
elif tag == CASE_CAPITAL:
|
|
225
|
+
out.append(tok.capitalize())
|
|
226
|
+
elif tag == CASE_LOWER:
|
|
227
|
+
out.append(tok.lower())
|
|
228
|
+
elif tag == CASE_UPPER:
|
|
229
|
+
out.append(tok.upper())
|
|
230
|
+
elif is_replace(tag):
|
|
231
|
+
out.extend(tokenize(tag_value(tag)))
|
|
232
|
+
elif is_append(tag):
|
|
233
|
+
out.append(tok)
|
|
234
|
+
out.extend(tokenize(tag_value(tag)))
|
|
235
|
+
else: # unknown tag → treat as KEEP (a miss, never a crash)
|
|
236
|
+
out.append(tok)
|
|
237
|
+
return detokenize(out)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def verify(corrupt: str, clean: str, tagged: Tagged) -> bool:
|
|
241
|
+
return apply(tagged.tokens, tagged.tags) == detokenize(tokenize(clean))
|