bilingual-lint 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. bilingual_lint-0.1.0/.gitattributes +3 -0
  2. bilingual_lint-0.1.0/.github/workflows/ci.yml +21 -0
  3. bilingual_lint-0.1.0/.gitignore +8 -0
  4. bilingual_lint-0.1.0/CHANGELOG.md +26 -0
  5. bilingual_lint-0.1.0/CONTRIBUTING.md +34 -0
  6. bilingual_lint-0.1.0/LICENSE +21 -0
  7. bilingual_lint-0.1.0/PKG-INFO +216 -0
  8. bilingual_lint-0.1.0/README.md +182 -0
  9. bilingual_lint-0.1.0/docs/superpowers/plans/2026-11-11-bilingual-lint.md +5447 -0
  10. bilingual_lint-0.1.0/docs/superpowers/specs/2026-11-11-bilingual-lint-design.md +213 -0
  11. bilingual_lint-0.1.0/examples/build_examples.py +92 -0
  12. bilingual_lint-0.1.0/examples/clean_en_ru.docx +0 -0
  13. bilingual_lint-0.1.0/examples/sample_en.docx +0 -0
  14. bilingual_lint-0.1.0/examples/sample_en_ru.docx +0 -0
  15. bilingual_lint-0.1.0/examples/sample_ru.docx +0 -0
  16. bilingual_lint-0.1.0/pyproject.toml +59 -0
  17. bilingual_lint-0.1.0/src/bilingual_lint/__init__.py +1 -0
  18. bilingual_lint-0.1.0/src/bilingual_lint/__main__.py +3 -0
  19. bilingual_lint-0.1.0/src/bilingual_lint/analysis.py +13 -0
  20. bilingual_lint-0.1.0/src/bilingual_lint/cli.py +174 -0
  21. bilingual_lint-0.1.0/src/bilingual_lint/config.py +118 -0
  22. bilingual_lint-0.1.0/src/bilingual_lint/facts/__init__.py +0 -0
  23. bilingual_lint-0.1.0/src/bilingual_lint/facts/dates.py +108 -0
  24. bilingual_lint-0.1.0/src/bilingual_lint/facts/extract.py +146 -0
  25. bilingual_lint-0.1.0/src/bilingual_lint/facts/numbers.py +89 -0
  26. bilingual_lint-0.1.0/src/bilingual_lint/facts/words.py +113 -0
  27. bilingual_lint-0.1.0/src/bilingual_lint/llm/__init__.py +0 -0
  28. bilingual_lint-0.1.0/src/bilingual_lint/llm/reviewer.py +110 -0
  29. bilingual_lint-0.1.0/src/bilingual_lint/loaders/__init__.py +20 -0
  30. bilingual_lint-0.1.0/src/bilingual_lint/loaders/docx_reader.py +112 -0
  31. bilingual_lint-0.1.0/src/bilingual_lint/loaders/two_column.py +96 -0
  32. bilingual_lint-0.1.0/src/bilingual_lint/loaders/two_files.py +60 -0
  33. bilingual_lint-0.1.0/src/bilingual_lint/models.py +74 -0
  34. bilingual_lint-0.1.0/src/bilingual_lint/pairing.py +148 -0
  35. bilingual_lint-0.1.0/src/bilingual_lint/report/__init__.py +0 -0
  36. bilingual_lint-0.1.0/src/bilingual_lint/report/console.py +55 -0
  37. bilingual_lint-0.1.0/src/bilingual_lint/report/json_out.py +56 -0
  38. bilingual_lint-0.1.0/src/bilingual_lint/report/markdown.py +57 -0
  39. bilingual_lint-0.1.0/src/bilingual_lint/rules/__init__.py +73 -0
  40. bilingual_lint-0.1.0/src/bilingual_lint/rules/base.py +95 -0
  41. bilingual_lint-0.1.0/src/bilingual_lint/rules/bi001_numbers.py +15 -0
  42. bilingual_lint-0.1.0/src/bilingual_lint/rules/bi002_money.py +43 -0
  43. bilingual_lint-0.1.0/src/bilingual_lint/rules/bi003_dates.py +42 -0
  44. bilingual_lint-0.1.0/src/bilingual_lint/rules/bi004_words.py +24 -0
  45. bilingual_lint-0.1.0/src/bilingual_lint/rules/bi005_durations.py +36 -0
  46. bilingual_lint-0.1.0/src/bilingual_lint/rules/bi006_refs.py +29 -0
  47. bilingual_lint-0.1.0/src/bilingual_lint/rules/bi007_structure.py +74 -0
  48. bilingual_lint-0.1.0/src/bilingual_lint/rules/bi008_terms.py +86 -0
  49. bilingual_lint-0.1.0/src/bilingual_lint/rules/bi009_identifiers.py +29 -0
  50. bilingual_lint-0.1.0/src/bilingual_lint/rules/bi010_prevailing.py +87 -0
  51. bilingual_lint-0.1.0/src/bilingual_lint/rules/bi011_modality.py +46 -0
  52. bilingual_lint-0.1.0/src/bilingual_lint/segment.py +78 -0
  53. bilingual_lint-0.1.0/src/bilingual_lint/text_utils.py +39 -0
  54. bilingual_lint-0.1.0/src/bilingual_lint/wordlists/__init__.py +135 -0
  55. bilingual_lint-0.1.0/src/bilingual_lint/wordlists/en.yaml +98 -0
  56. bilingual_lint-0.1.0/src/bilingual_lint/wordlists/neutral.yaml +11 -0
  57. bilingual_lint-0.1.0/src/bilingual_lint/wordlists/ru.yaml +152 -0
  58. bilingual_lint-0.1.0/tests/__init__.py +0 -0
  59. bilingual_lint-0.1.0/tests/facts/__init__.py +0 -0
  60. bilingual_lint-0.1.0/tests/facts/test_dates.py +63 -0
  61. bilingual_lint-0.1.0/tests/facts/test_extract.py +89 -0
  62. bilingual_lint-0.1.0/tests/facts/test_numbers.py +70 -0
  63. bilingual_lint-0.1.0/tests/facts/test_words.py +67 -0
  64. bilingual_lint-0.1.0/tests/golden/sample_en_ru.json +269 -0
  65. bilingual_lint-0.1.0/tests/golden/sample_en_ru.md +102 -0
  66. bilingual_lint-0.1.0/tests/helpers.py +48 -0
  67. bilingual_lint-0.1.0/tests/rules/__init__.py +0 -0
  68. bilingual_lint-0.1.0/tests/rules/test_text_rules.py +198 -0
  69. bilingual_lint-0.1.0/tests/rules/test_value_rules.py +141 -0
  70. bilingual_lint-0.1.0/tests/test_cli.py +120 -0
  71. bilingual_lint-0.1.0/tests/test_config.py +46 -0
  72. bilingual_lint-0.1.0/tests/test_examples.py +78 -0
  73. bilingual_lint-0.1.0/tests/test_field_regressions.py +195 -0
  74. bilingual_lint-0.1.0/tests/test_llm.py +137 -0
  75. bilingual_lint-0.1.0/tests/test_loaders.py +184 -0
  76. bilingual_lint-0.1.0/tests/test_no_internal_terms.py +34 -0
  77. bilingual_lint-0.1.0/tests/test_pairing.py +70 -0
  78. bilingual_lint-0.1.0/tests/test_registry.py +54 -0
  79. bilingual_lint-0.1.0/tests/test_segment.py +63 -0
  80. bilingual_lint-0.1.0/tests/test_text_utils.py +24 -0
@@ -0,0 +1,3 @@
1
+ * text=auto eol=lf
2
+ *.docx binary
3
+ *.png binary
@@ -0,0 +1,21 @@
1
+ name: CI
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ pull_request:
6
+ jobs:
7
+ test:
8
+ runs-on: ${{ matrix.os }}
9
+ strategy:
10
+ fail-fast: false
11
+ matrix:
12
+ os: [ubuntu-latest, windows-latest]
13
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+ - run: pip install -e ".[dev]"
20
+ - run: ruff check .
21
+ - run: pytest -q
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .ruff_cache/
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-11-12
4
+
5
+ First version.
6
+
7
+ - Input: two-column bilingual DOCX, or two separate DOCX files paired by
8
+ clause numbers, digit signatures and length.
9
+ - Twelve rules, BI000 to BI011: numbers, amounts and currency, dates,
10
+ digits against words, durations and day types, clause references, missing or
11
+ untranslated text, defined terms and glossary, identifiers, the
12
+ prevailing-language clause, obligation and negation cues.
13
+ - Language-neutral rules for any pair; word-based rules for English and Russian.
14
+ - Console, JSON and Markdown reports; exit codes for use as a pipeline gate.
15
+ - Optional model pass (`--llm`) for pairs the rules cannot settle. Off by default.
16
+ `--llm-scope all` sends every pair instead, to catch wording dropped from one
17
+ side when no number or term gives it away. Pairs with the same text on both
18
+ sides, or with no words, are skipped; headings and labels no longer come back
19
+ as "unclear".
20
+ - After a first run over real two-column documents: times of day are compared
21
+ (`21:00` against `9 p.m.`); a currency named without an amount is compared;
22
+ a clause reference worded differently on one side no longer reads as a
23
+ number mismatch; a defined term matches its singular and plural and keeps
24
+ its capital letter; a Latin name kept on a non-Latin side is not reported as
25
+ untranslated; fewer false negation and obligation cues (`No. 15`, `unless`,
26
+ `shall be entitled to`).
@@ -0,0 +1,34 @@
1
+ # Contributing
2
+
3
+ Thanks for taking a look.
4
+
5
+ ## Setup
6
+
7
+ ```
8
+ pip install -e ".[dev]"
9
+ pytest -q
10
+ ruff check .
11
+ ```
12
+
13
+ ## Ground rules
14
+
15
+ - **Rules first.** A check that can be done deterministically is done
16
+ deterministically. The model pass is for what rules cannot settle.
17
+ - **Every rule has two tests:** a clean case that must stay silent and a defect
18
+ that must be found. A rule that cannot be made to fail is not a rule.
19
+ - **No real documents.** Examples and fixtures are invented. Do not add client,
20
+ firm or personal data to the repository, in any form.
21
+ - **Say what you could not read.** If the tool cannot determine something, it
22
+ reports that or stays silent. It does not guess.
23
+
24
+ ## Adding a language
25
+
26
+ Add `src/bilingual_lint/wordlists/<code>.yaml` with the same keys as `en.yaml`.
27
+ Entries are regular-expression fragments. The word-based rules pick the language
28
+ up automatically once both languages of a pair have a wordlist. Add tests next
29
+ to the existing English and Russian ones.
30
+
31
+ ## Adding a rule
32
+
33
+ One file in `src/bilingual_lint/rules/`, one entry in `ALL_RULES`, one entry in
34
+ `RULE_DEFAULTS`, one row in the README table, and tests.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kirill Sakhin
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,216 @@
1
+ Metadata-Version: 2.5
2
+ Name: bilingual-lint
3
+ Version: 0.1.0
4
+ Summary: A linter for bilingual legal documents — find where the two language versions of a contract disagree on numbers, dates, amounts, clauses and the prevailing language
5
+ Project-URL: Homepage, https://github.com/kirillsakhin/bilingual-lint
6
+ Project-URL: Issues, https://github.com/kirillsakhin/bilingual-lint/issues
7
+ Project-URL: Changelog, https://github.com/kirillsakhin/bilingual-lint/blob/main/CHANGELOG.md
8
+ Author: Kirill Sakhin
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: bilingual,contracts,docx,legal,legal-tech,linter,translation
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Legal Industry
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Office/Business
22
+ Classifier: Topic :: Text Processing :: Linguistic
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: python-docx>=1.1
25
+ Requires-Dist: pyyaml>=6.0
26
+ Requires-Dist: rich>=13.0
27
+ Requires-Dist: typer>=0.12
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=8.0; extra == 'dev'
30
+ Requires-Dist: ruff>=0.6; extra == 'dev'
31
+ Provides-Extra: llm
32
+ Requires-Dist: litellm>=1.40; extra == 'llm'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # bilingual-lint
36
+
37
+ [![CI](https://github.com/kirillsakhin/bilingual-lint/actions/workflows/ci.yml/badge.svg)](https://github.com/kirillsakhin/bilingual-lint/actions/workflows/ci.yml)
38
+
39
+ A linter for bilingual legal documents. Give it a contract in two languages and it
40
+ reports where the two versions disagree: a different amount, a different date, a
41
+ missing paragraph, a clause that makes English prevail on the left and Russian on
42
+ the right. Deterministically, offline, the same way every time.
43
+
44
+ ## Why
45
+
46
+ A bilingual contract is one deal written twice. Every number, date, deadline and
47
+ party name has to be the same in both columns, and nobody enjoys checking that by
48
+ eye on page 40.
49
+
50
+ The obvious 2026 answer is "have an LLM compare the two versions". It helps with
51
+ meaning, but it is the wrong tool for facts: it can pass a changed digit in one
52
+ clause and flag the same thing in another, and two runs disagree with each other.
53
+
54
+ So bilingual-lint does it the other way around. Rules check the facts: they catch
55
+ the same defect every time, need no API key and send nothing anywhere. A model
56
+ comes second, optionally, and by default only for the pairs the rules cannot
57
+ settle.
58
+
59
+ It does not judge translation style or quality. It checks what must be identical.
60
+
61
+ ## Install
62
+
63
+ From a clone of this repository:
64
+
65
+ ```
66
+ pip install -e .
67
+ ```
68
+
69
+ Python 3.10 or newer. The optional model pass needs `pip install -e ".[llm]"`.
70
+
71
+ ## Quickstart
72
+
73
+ A two-column bilingual DOCX:
74
+
75
+ ```
76
+ bilingual-lint check contract.docx
77
+ ```
78
+
79
+ A source file and its translation:
80
+
81
+ ```
82
+ bilingual-lint check contract_en.docx contract_ru.docx
83
+ ```
84
+
85
+ Against the bundled sample ([`examples/sample_en_ru.docx`](examples/sample_en_ru.docx),
86
+ an invented contract with twelve planted defects), `--format md` gives, in part:
87
+
88
+ ```
89
+ # bilingual-lint report
90
+
91
+ - Mode: two-column, en / ru
92
+ - Pairs checked: 13 (0 without a counterpart)
93
+ - Findings: 12, 8 error, 3 warning, 1 info
94
+
95
+ ## BI002 · error · pair 4
96
+
97
+ amounts differ — en only: 150,000 · ru only: 105 000
98
+
99
+ ## BI005 · error · pair 5
100
+
101
+ duration day type differs — business against calendar days (en: 10, ru: 10)
102
+
103
+ ## BI010 · error · pair 9
104
+
105
+ the two sides disagree on which language prevails — en side: en · ru side: ru
106
+
107
+ ## BI004 · error · pair 10
108
+
109
+ digits and words disagree on the ru side — 3 against "двух"
110
+ ```
111
+
112
+ The clean copy of the same contract ([`examples/clean_en_ru.docx`](examples/clean_en_ru.docx))
113
+ produces no findings.
114
+
115
+ Exit codes: `0` nothing at or above the threshold, `1` findings, `2` the input
116
+ could not be read or the command was wrong. That makes it usable as a gate after
117
+ a translation step.
118
+
119
+ ## Rules
120
+
121
+ | Code | Checks | Default | Languages |
122
+ |---|---|---|---|
123
+ | BI000 | Notes about the file: tracked changes, comments, text outside the bilingual table | info | any |
124
+ | BI001 | A number or percentage appears on one side only | error | any |
125
+ | BI002 | A money amount or its currency differs, or the two sides name different currencies | error | any |
126
+ | BI003 | A date or a time of day differs, or a date agrees only under one day/month reading (`02/03/2025`) | error | any |
127
+ | BI004 | Digits and words disagree on the same side: `ten (15)` | error | en, ru |
128
+ | BI005 | A time period differs in length, unit or day type (30 days vs 30 business days) | error | en, ru |
129
+ | BI006 | A clause number or cross-reference differs | warning | any |
130
+ | BI007 | A segment is missing, empty, untranslated or far off in length | error | any |
131
+ | BI008 | A defined term or glossary term is rendered inconsistently | warning | en, ru |
132
+ | BI009 | A registration number, IBAN, e-mail or URL differs | error | any |
133
+ | BI010 | The prevailing-language clause is one-sided or contradicts itself | error | en, ru |
134
+ | BI011 | Obligation, permission or negation wording differs (heuristic) | info | en, ru |
135
+
136
+ "any" rules work for every language pair. The others need a wordlist and currently
137
+ ship for English and Russian; for other pairs they are skipped, and the report
138
+ says which.
139
+
140
+ ### How numbers are compared
141
+
142
+ `1,000` is one thousand in English and exactly one in Russian. bilingual-lint does
143
+ not guess: an ambiguous number keeps both readings and matches if either agrees
144
+ with the other side. Set `strict_separators: true` to read each language by its
145
+ own convention only.
146
+
147
+ A number spelled out on one side (`two copies` / `2 экземпляра`) is not a defect.
148
+
149
+ ## Two separate files
150
+
151
+ Paragraphs are paired deterministically: first at anchors (clause numbers and
152
+ paragraphs with a unique set of digits), then by length between anchors. A
153
+ paragraph with no partner becomes a finding. Each pair has a confidence value; for
154
+ low-confidence pairs the report says "check the pairing first" and errors are
155
+ shown as warnings.
156
+
157
+ ## Options
158
+
159
+ ```
160
+ bilingual-lint check FILE [FILE]
161
+ --langs en,es name the two languages (needed when they share a script)
162
+ --glossary terms.yaml term pairs to enforce: - {en: Buyer, ru: Покупатель}
163
+ --config FILE use this config file instead of ./.bilingual-lint.yaml
164
+ --format console|json|md
165
+ --output report.json
166
+ --fail-on error|warning
167
+ --rules BI001,BI010 run only these rules
168
+ --llm MODEL optional model pass (see below)
169
+ --llm-gate treat a model answer of "differs" as an error
170
+ --llm-scope SCOPE flagged (default) or all
171
+ bilingual-lint rules list the rules
172
+ bilingual-lint init write a starter .bilingual-lint.yaml
173
+ ```
174
+
175
+ ## Optional model pass
176
+
177
+ `--llm` sends only the pairs the rules flagged but could not settle (BI011 and
178
+ low-confidence pairs) to a model through [litellm](https://github.com/BerriAI/litellm)
179
+ and asks one question: do these two texts say the same thing? Answers are marked
180
+ as model suggestions and do not change the exit code unless you pass `--llm-gate`.
181
+
182
+ `--llm-scope all` sends every pair instead, up to `llm.max_pairs`. That is the
183
+ only way to catch wording dropped from one side when no number or defined term
184
+ gives it away, and it means the whole document goes to the provider. Pairs with
185
+ nothing to compare (the same text on both sides, or no words at all) are not
186
+ sent; the report counts them as skipped.
187
+
188
+ **Without `--llm`, no text leaves your machine.** With it, the flagged pairs go to
189
+ the provider of the model you chose. Decide whether that is acceptable for the
190
+ document in front of you.
191
+
192
+ ## Limits
193
+
194
+ - DOCX only. No PDF, no scanned documents.
195
+ - Headers, footers, footnotes and text boxes anchored outside the body are not read.
196
+ - Tracked changes are read as if accepted; the report says when a file has them.
197
+ - Word-based rules exist for English and Russian only.
198
+ - BI011 is a wordlist heuristic and will produce false flags; it is `info` for that reason.
199
+ - In two-file mode a short missing paragraph next to a long one can be absorbed
200
+ into its neighbour instead of being reported as missing.
201
+ - No accuracy figures are published yet. A benchmark on public bilingual texts
202
+ with planted defects is the next milestone; until then, treat the tool as a
203
+ second pair of eyes, not as proof that a document is clean.
204
+
205
+ ## Development
206
+
207
+ ```
208
+ pip install -e ".[dev]"
209
+ pytest -q
210
+ ruff check .
211
+ python examples/build_examples.py # rebuild the example documents
212
+ ```
213
+
214
+ ## Licence
215
+
216
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,182 @@
1
+ # bilingual-lint
2
+
3
+ [![CI](https://github.com/kirillsakhin/bilingual-lint/actions/workflows/ci.yml/badge.svg)](https://github.com/kirillsakhin/bilingual-lint/actions/workflows/ci.yml)
4
+
5
+ A linter for bilingual legal documents. Give it a contract in two languages and it
6
+ reports where the two versions disagree: a different amount, a different date, a
7
+ missing paragraph, a clause that makes English prevail on the left and Russian on
8
+ the right. Deterministically, offline, the same way every time.
9
+
10
+ ## Why
11
+
12
+ A bilingual contract is one deal written twice. Every number, date, deadline and
13
+ party name has to be the same in both columns, and nobody enjoys checking that by
14
+ eye on page 40.
15
+
16
+ The obvious 2026 answer is "have an LLM compare the two versions". It helps with
17
+ meaning, but it is the wrong tool for facts: it can pass a changed digit in one
18
+ clause and flag the same thing in another, and two runs disagree with each other.
19
+
20
+ So bilingual-lint does it the other way around. Rules check the facts: they catch
21
+ the same defect every time, need no API key and send nothing anywhere. A model
22
+ comes second, optionally, and by default only for the pairs the rules cannot
23
+ settle.
24
+
25
+ It does not judge translation style or quality. It checks what must be identical.
26
+
27
+ ## Install
28
+
29
+ From a clone of this repository:
30
+
31
+ ```
32
+ pip install -e .
33
+ ```
34
+
35
+ Python 3.10 or newer. The optional model pass needs `pip install -e ".[llm]"`.
36
+
37
+ ## Quickstart
38
+
39
+ A two-column bilingual DOCX:
40
+
41
+ ```
42
+ bilingual-lint check contract.docx
43
+ ```
44
+
45
+ A source file and its translation:
46
+
47
+ ```
48
+ bilingual-lint check contract_en.docx contract_ru.docx
49
+ ```
50
+
51
+ Against the bundled sample ([`examples/sample_en_ru.docx`](examples/sample_en_ru.docx),
52
+ an invented contract with twelve planted defects), `--format md` gives, in part:
53
+
54
+ ```
55
+ # bilingual-lint report
56
+
57
+ - Mode: two-column, en / ru
58
+ - Pairs checked: 13 (0 without a counterpart)
59
+ - Findings: 12, 8 error, 3 warning, 1 info
60
+
61
+ ## BI002 · error · pair 4
62
+
63
+ amounts differ — en only: 150,000 · ru only: 105 000
64
+
65
+ ## BI005 · error · pair 5
66
+
67
+ duration day type differs — business against calendar days (en: 10, ru: 10)
68
+
69
+ ## BI010 · error · pair 9
70
+
71
+ the two sides disagree on which language prevails — en side: en · ru side: ru
72
+
73
+ ## BI004 · error · pair 10
74
+
75
+ digits and words disagree on the ru side — 3 against "двух"
76
+ ```
77
+
78
+ The clean copy of the same contract ([`examples/clean_en_ru.docx`](examples/clean_en_ru.docx))
79
+ produces no findings.
80
+
81
+ Exit codes: `0` nothing at or above the threshold, `1` findings, `2` the input
82
+ could not be read or the command was wrong. That makes it usable as a gate after
83
+ a translation step.
84
+
85
+ ## Rules
86
+
87
+ | Code | Checks | Default | Languages |
88
+ |---|---|---|---|
89
+ | BI000 | Notes about the file: tracked changes, comments, text outside the bilingual table | info | any |
90
+ | BI001 | A number or percentage appears on one side only | error | any |
91
+ | BI002 | A money amount or its currency differs, or the two sides name different currencies | error | any |
92
+ | BI003 | A date or a time of day differs, or a date agrees only under one day/month reading (`02/03/2025`) | error | any |
93
+ | BI004 | Digits and words disagree on the same side: `ten (15)` | error | en, ru |
94
+ | BI005 | A time period differs in length, unit or day type (30 days vs 30 business days) | error | en, ru |
95
+ | BI006 | A clause number or cross-reference differs | warning | any |
96
+ | BI007 | A segment is missing, empty, untranslated or far off in length | error | any |
97
+ | BI008 | A defined term or glossary term is rendered inconsistently | warning | en, ru |
98
+ | BI009 | A registration number, IBAN, e-mail or URL differs | error | any |
99
+ | BI010 | The prevailing-language clause is one-sided or contradicts itself | error | en, ru |
100
+ | BI011 | Obligation, permission or negation wording differs (heuristic) | info | en, ru |
101
+
102
+ "any" rules work for every language pair. The others need a wordlist and currently
103
+ ship for English and Russian; for other pairs they are skipped, and the report
104
+ says which.
105
+
106
+ ### How numbers are compared
107
+
108
+ `1,000` is one thousand in English and exactly one in Russian. bilingual-lint does
109
+ not guess: an ambiguous number keeps both readings and matches if either agrees
110
+ with the other side. Set `strict_separators: true` to read each language by its
111
+ own convention only.
112
+
113
+ A number spelled out on one side (`two copies` / `2 экземпляра`) is not a defect.
114
+
115
+ ## Two separate files
116
+
117
+ Paragraphs are paired deterministically: first at anchors (clause numbers and
118
+ paragraphs with a unique set of digits), then by length between anchors. A
119
+ paragraph with no partner becomes a finding. Each pair has a confidence value; for
120
+ low-confidence pairs the report says "check the pairing first" and errors are
121
+ shown as warnings.
122
+
123
+ ## Options
124
+
125
+ ```
126
+ bilingual-lint check FILE [FILE]
127
+ --langs en,es name the two languages (needed when they share a script)
128
+ --glossary terms.yaml term pairs to enforce: - {en: Buyer, ru: Покупатель}
129
+ --config FILE use this config file instead of ./.bilingual-lint.yaml
130
+ --format console|json|md
131
+ --output report.json
132
+ --fail-on error|warning
133
+ --rules BI001,BI010 run only these rules
134
+ --llm MODEL optional model pass (see below)
135
+ --llm-gate treat a model answer of "differs" as an error
136
+ --llm-scope SCOPE flagged (default) or all
137
+ bilingual-lint rules list the rules
138
+ bilingual-lint init write a starter .bilingual-lint.yaml
139
+ ```
140
+
141
+ ## Optional model pass
142
+
143
+ `--llm` sends only the pairs the rules flagged but could not settle (BI011 and
144
+ low-confidence pairs) to a model through [litellm](https://github.com/BerriAI/litellm)
145
+ and asks one question: do these two texts say the same thing? Answers are marked
146
+ as model suggestions and do not change the exit code unless you pass `--llm-gate`.
147
+
148
+ `--llm-scope all` sends every pair instead, up to `llm.max_pairs`. That is the
149
+ only way to catch wording dropped from one side when no number or defined term
150
+ gives it away, and it means the whole document goes to the provider. Pairs with
151
+ nothing to compare (the same text on both sides, or no words at all) are not
152
+ sent; the report counts them as skipped.
153
+
154
+ **Without `--llm`, no text leaves your machine.** With it, the flagged pairs go to
155
+ the provider of the model you chose. Decide whether that is acceptable for the
156
+ document in front of you.
157
+
158
+ ## Limits
159
+
160
+ - DOCX only. No PDF, no scanned documents.
161
+ - Headers, footers, footnotes and text boxes anchored outside the body are not read.
162
+ - Tracked changes are read as if accepted; the report says when a file has them.
163
+ - Word-based rules exist for English and Russian only.
164
+ - BI011 is a wordlist heuristic and will produce false flags; it is `info` for that reason.
165
+ - In two-file mode a short missing paragraph next to a long one can be absorbed
166
+ into its neighbour instead of being reported as missing.
167
+ - No accuracy figures are published yet. A benchmark on public bilingual texts
168
+ with planted defects is the next milestone; until then, treat the tool as a
169
+ second pair of eyes, not as proof that a document is clean.
170
+
171
+ ## Development
172
+
173
+ ```
174
+ pip install -e ".[dev]"
175
+ pytest -q
176
+ ruff check .
177
+ python examples/build_examples.py # rebuild the example documents
178
+ ```
179
+
180
+ ## Licence
181
+
182
+ MIT. See [LICENSE](LICENSE).