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.
- bilingual_lint-0.1.0/.gitattributes +3 -0
- bilingual_lint-0.1.0/.github/workflows/ci.yml +21 -0
- bilingual_lint-0.1.0/.gitignore +8 -0
- bilingual_lint-0.1.0/CHANGELOG.md +26 -0
- bilingual_lint-0.1.0/CONTRIBUTING.md +34 -0
- bilingual_lint-0.1.0/LICENSE +21 -0
- bilingual_lint-0.1.0/PKG-INFO +216 -0
- bilingual_lint-0.1.0/README.md +182 -0
- bilingual_lint-0.1.0/docs/superpowers/plans/2026-11-11-bilingual-lint.md +5447 -0
- bilingual_lint-0.1.0/docs/superpowers/specs/2026-11-11-bilingual-lint-design.md +213 -0
- bilingual_lint-0.1.0/examples/build_examples.py +92 -0
- bilingual_lint-0.1.0/examples/clean_en_ru.docx +0 -0
- bilingual_lint-0.1.0/examples/sample_en.docx +0 -0
- bilingual_lint-0.1.0/examples/sample_en_ru.docx +0 -0
- bilingual_lint-0.1.0/examples/sample_ru.docx +0 -0
- bilingual_lint-0.1.0/pyproject.toml +59 -0
- bilingual_lint-0.1.0/src/bilingual_lint/__init__.py +1 -0
- bilingual_lint-0.1.0/src/bilingual_lint/__main__.py +3 -0
- bilingual_lint-0.1.0/src/bilingual_lint/analysis.py +13 -0
- bilingual_lint-0.1.0/src/bilingual_lint/cli.py +174 -0
- bilingual_lint-0.1.0/src/bilingual_lint/config.py +118 -0
- bilingual_lint-0.1.0/src/bilingual_lint/facts/__init__.py +0 -0
- bilingual_lint-0.1.0/src/bilingual_lint/facts/dates.py +108 -0
- bilingual_lint-0.1.0/src/bilingual_lint/facts/extract.py +146 -0
- bilingual_lint-0.1.0/src/bilingual_lint/facts/numbers.py +89 -0
- bilingual_lint-0.1.0/src/bilingual_lint/facts/words.py +113 -0
- bilingual_lint-0.1.0/src/bilingual_lint/llm/__init__.py +0 -0
- bilingual_lint-0.1.0/src/bilingual_lint/llm/reviewer.py +110 -0
- bilingual_lint-0.1.0/src/bilingual_lint/loaders/__init__.py +20 -0
- bilingual_lint-0.1.0/src/bilingual_lint/loaders/docx_reader.py +112 -0
- bilingual_lint-0.1.0/src/bilingual_lint/loaders/two_column.py +96 -0
- bilingual_lint-0.1.0/src/bilingual_lint/loaders/two_files.py +60 -0
- bilingual_lint-0.1.0/src/bilingual_lint/models.py +74 -0
- bilingual_lint-0.1.0/src/bilingual_lint/pairing.py +148 -0
- bilingual_lint-0.1.0/src/bilingual_lint/report/__init__.py +0 -0
- bilingual_lint-0.1.0/src/bilingual_lint/report/console.py +55 -0
- bilingual_lint-0.1.0/src/bilingual_lint/report/json_out.py +56 -0
- bilingual_lint-0.1.0/src/bilingual_lint/report/markdown.py +57 -0
- bilingual_lint-0.1.0/src/bilingual_lint/rules/__init__.py +73 -0
- bilingual_lint-0.1.0/src/bilingual_lint/rules/base.py +95 -0
- bilingual_lint-0.1.0/src/bilingual_lint/rules/bi001_numbers.py +15 -0
- bilingual_lint-0.1.0/src/bilingual_lint/rules/bi002_money.py +43 -0
- bilingual_lint-0.1.0/src/bilingual_lint/rules/bi003_dates.py +42 -0
- bilingual_lint-0.1.0/src/bilingual_lint/rules/bi004_words.py +24 -0
- bilingual_lint-0.1.0/src/bilingual_lint/rules/bi005_durations.py +36 -0
- bilingual_lint-0.1.0/src/bilingual_lint/rules/bi006_refs.py +29 -0
- bilingual_lint-0.1.0/src/bilingual_lint/rules/bi007_structure.py +74 -0
- bilingual_lint-0.1.0/src/bilingual_lint/rules/bi008_terms.py +86 -0
- bilingual_lint-0.1.0/src/bilingual_lint/rules/bi009_identifiers.py +29 -0
- bilingual_lint-0.1.0/src/bilingual_lint/rules/bi010_prevailing.py +87 -0
- bilingual_lint-0.1.0/src/bilingual_lint/rules/bi011_modality.py +46 -0
- bilingual_lint-0.1.0/src/bilingual_lint/segment.py +78 -0
- bilingual_lint-0.1.0/src/bilingual_lint/text_utils.py +39 -0
- bilingual_lint-0.1.0/src/bilingual_lint/wordlists/__init__.py +135 -0
- bilingual_lint-0.1.0/src/bilingual_lint/wordlists/en.yaml +98 -0
- bilingual_lint-0.1.0/src/bilingual_lint/wordlists/neutral.yaml +11 -0
- bilingual_lint-0.1.0/src/bilingual_lint/wordlists/ru.yaml +152 -0
- bilingual_lint-0.1.0/tests/__init__.py +0 -0
- bilingual_lint-0.1.0/tests/facts/__init__.py +0 -0
- bilingual_lint-0.1.0/tests/facts/test_dates.py +63 -0
- bilingual_lint-0.1.0/tests/facts/test_extract.py +89 -0
- bilingual_lint-0.1.0/tests/facts/test_numbers.py +70 -0
- bilingual_lint-0.1.0/tests/facts/test_words.py +67 -0
- bilingual_lint-0.1.0/tests/golden/sample_en_ru.json +269 -0
- bilingual_lint-0.1.0/tests/golden/sample_en_ru.md +102 -0
- bilingual_lint-0.1.0/tests/helpers.py +48 -0
- bilingual_lint-0.1.0/tests/rules/__init__.py +0 -0
- bilingual_lint-0.1.0/tests/rules/test_text_rules.py +198 -0
- bilingual_lint-0.1.0/tests/rules/test_value_rules.py +141 -0
- bilingual_lint-0.1.0/tests/test_cli.py +120 -0
- bilingual_lint-0.1.0/tests/test_config.py +46 -0
- bilingual_lint-0.1.0/tests/test_examples.py +78 -0
- bilingual_lint-0.1.0/tests/test_field_regressions.py +195 -0
- bilingual_lint-0.1.0/tests/test_llm.py +137 -0
- bilingual_lint-0.1.0/tests/test_loaders.py +184 -0
- bilingual_lint-0.1.0/tests/test_no_internal_terms.py +34 -0
- bilingual_lint-0.1.0/tests/test_pairing.py +70 -0
- bilingual_lint-0.1.0/tests/test_registry.py +54 -0
- bilingual_lint-0.1.0/tests/test_segment.py +63 -0
- bilingual_lint-0.1.0/tests/test_text_utils.py +24 -0
|
@@ -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,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
|
+
[](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
|
+
[](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).
|