Skip to content

Commit e992672

Browse files
authored
Merge pull request #16 from DrBaher/feat/docx-numbering
feat: resolve .docx automatic list numbering during extraction
2 parents 4977662 + 0c5d5b2 commit e992672

9 files changed

Lines changed: 684 additions & 30 deletions

File tree

AGENTS.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@ Every review carries a `coverage` block. `decision: "approve"` with `coverage.ru
1313

1414
```json
1515
"decision": "approve",
16-
"coverage": {"rules_evaluated": 11, "rules_matched": 6, "extraction_status": "ok:docx-xml", "text_chars": 4213}
16+
"coverage": {"rules_evaluated": 11, "rules_matched": 6, "extraction_status": "ok:docx-xml",
17+
"numbering_resolved": true, "text_chars": 4213}
1718
```
1819

20+
`numbering_resolved` is `true` when a `.docx`'s automatic list numbering was resolved to the numbers Word displays, `false` when it could not be (missing `numbering.xml`, an unsupported `numFmt`, or the `textutil` fallback), and `null` where numbering isn't a concept (`.txt`, `.pdf`, stdin). **When it is `false`, don't reason about clause numbers** — "Section 7" may exist in the document even though no `7.` appears in the text you were given.
21+
1922
| `decision` | Means |
2023
|---|---|
2124
| `approve` / `escalate` / `block` | Rules ran, matched, and scored. |
@@ -138,7 +141,7 @@ result = review_nda(text=nda_text, policy=policy)
138141

139142
`load_policy` accepts either playbook shape (a `clause_rules` object or a built `policy` list). `review_nda` raises `EmptyDocumentError` on blank text and `PlaybookError` when the policy yields no keyword-bearing rules — both are cases where a silent `approve` would be a lie. Check `result["coverage"]` before trusting `result["decision"]`.
140143

141-
To read a `.docx` / `.pdf`, use `read_nda_input(path)``(text, extraction_status)`; reading those with `Path.read_text` yields zip/binary noise that no keyword can match.
144+
To read a `.docx` / `.pdf`, use `read_nda_input(path)``(text, extraction)`, where `extraction` carries `extraction_status` and `numbering_resolved`. Reading those files with `Path.read_text` yields zip/binary noise that no keyword can match, and a `.docx` read without resolving `w:numPr` loses every list number Word displays. See [docs/reference/docx-extraction.md](docs/reference/docx-extraction.md).
142145

143146
Stdlib-only at runtime. LLM augmentation requires only `urllib`; no `anthropic` / `openai` SDK dependency.
144147

CHANGELOG.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,35 @@ and the fixture's `approve` case is a genuinely clean NDA.
103103

104104
- **The documented library API now exists.** `AGENTS.md` told importers to
105105
`from nda_review_cli import review_nda, load_policy` — neither function was defined. Both are
106-
now real, alongside `read_nda_input(path) -> (text, extraction_status)` for `.docx`/`.pdf`.
106+
now real, alongside `read_nda_input(path) -> (text, extraction)` for `.docx`/`.pdf`.
107+
108+
- **`.docx` automatic list numbering is resolved.** Word stores a numbered paragraph's visible
109+
number nowhere in its text — the paragraph carries only a `w:pPr/w:numPr` pointer, and the
110+
number is computed at render time from `word/numbering.xml` (`numId``abstractNumId`
111+
per-level `numFmt`/`lvlText`/`start`). Reading only `w:t` nodes therefore produced a silently
112+
*unnumbered* document: a contract whose clauses are numbered 1–20 in Word extracted with no
113+
numbers at all, and an in-body cross-reference to "Section 7" had no target.
114+
115+
Extraction now walks `w:body` in document order and prepends each paragraph's computed number.
116+
Supports `decimal`, `decimalZero`, `lowerLetter`, `upperLetter`, `lowerRoman`, `upperRoman`,
117+
and `none`; `%1``%9` substitution into `lvlText` (so multi-level `"%1.%2"` renders as `2.1`);
118+
`start`, `w:startOverride`, and per-level `w:lvlOverride`; deeper-level resets; and numbering
119+
inherited through a `w:pStyle``w:basedOn` chain in `styles.xml`. Bullets are skipped.
120+
Counters are keyed by `(numId, ilvl)`, so a nested `(a) (b)` list under its own `numId` does
121+
not disturb the outer clause numbering. Stdlib-only.
122+
123+
- **`.docx` paragraphs are newline-joined, not space-flattened.** `" ".join(root.itertext())`
124+
collapsed the whole document into one run-on string, so `locate_clause` (which splits on
125+
lines), `extract_clause_snippet` (which splits on blank lines), and `paragraph_index` all
126+
operated on a single line.
127+
128+
- **Fail visible, not silent: `coverage.numbering_resolved`.** `true` when every numbered
129+
paragraph resolved (and when there were none — nothing to resolve is not a failure), `false`
130+
when `numbering.xml` is missing or unparseable, a `numFmt` is unsupported, or the `textutil`
131+
fallback was used, and `null` where numbering is not a concept (`.txt`, `.pdf`, stdin).
132+
`_read_any_text()` also returns a `numbering` record carrying `numbered_paragraphs` and a
133+
human-readable `reason`. Consumers that reason about clause numbers must degrade rather than
134+
assert when it is `false`. New: [docs/reference/docx-extraction.md](docs/reference/docx-extraction.md).
107135

108136
- `tests/test_review_coverage.py` (24 tests) and `tests/test_review_golden.py::ClauseRulesPlaybookTests`
109137
(3 tests). 22 of the 24 fail against the previous source.

docs/reference/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@ Concept-level reference for `nda-review-cli`. Each file is the canonical home fo
1010
| [scoring.md](scoring.md) | Scoring profiles (`balanced` / `strict` / `commercial`), decision thresholds, calibration. |
1111
| [signer-policy.md](signer-policy.md) | The declarative gate on unattended sign-off. Which clauses an agent may sign, and which escalate to a human. |
1212
| [state-file.md](state-file.md) | The hash-chained negotiation state file. What it stores, how tamper-detection works. |
13+
| [docx-extraction.md](docx-extraction.md) | How `.docx` text is extracted, why Word's automatic list numbering must be reconstructed, and what `numbering_resolved` means. |
1314
| [exit-codes.md](exit-codes.md) | The exit-code map every command honors, plus stable error codes. |
1415
| [llm-data-flow.md](llm-data-flow.md) | What leaves your machine when you pass `--llm`, and what doesn't. |

docs/reference/docx-extraction.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# `.docx` extraction
2+
3+
What `review --file contract.docx` actually reads, and the two things a naive extractor silently throws away.
4+
5+
## Word does not store list numbers
6+
7+
A paragraph numbered `7.` in Word contains no `7` anywhere in its text. It carries only a pointer:
8+
9+
```xml
10+
<w:p>
11+
<w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="3"/></w:numPr></w:pPr>
12+
<w:r><w:t>Each party acknowledges …</w:t></w:r>
13+
</w:p>
14+
```
15+
16+
The visible number is computed at render time by walking `word/numbering.xml`:
17+
18+
```
19+
numId → abstractNumId → per-level { numFmt, lvlText, start }
20+
```
21+
22+
An extractor that reads only `w:t` nodes therefore produces a **silently unnumbered document**. A contract whose operative clauses are numbered 1–20 in Word arrives as prose with no numbers, and an in-body cross-reference to "Section 7" has nothing to point at. Nothing errors; the numbers simply never existed in the text being analyzed.
23+
24+
The `textutil` fallback has the same loss — it renders numbered paragraphs as tab-indented bullets.
25+
26+
## What we resolve
27+
28+
`_extract_docx_text` walks `w:body` paragraphs in document order and prepends each one's computed number.
29+
30+
| Supported | Notes |
31+
|---|---|
32+
| `numFmt` | `decimal`, `decimalZero`, `lowerLetter`, `upperLetter`, `lowerRoman`, `upperRoman`, `none` |
33+
| `lvlText` | `%1``%9` substitution, so multi-level `"%1.%2"` patterns render as `2.1` |
34+
| `start` / `w:startOverride` | Honoured per level |
35+
| `w:lvlOverride` | Per-level `numFmt` / `lvlText` overrides |
36+
| Level resets | A new item at level *N* restarts every deeper level |
37+
| Style-inherited numbering | `w:pStyle``w:basedOn` chain in `styles.xml`; contract templates frequently number through a style rather than on the paragraph |
38+
| `bullet` | Skipped — a bullet has no number, and that is not a failure |
39+
40+
Counters are keyed by `(numId, ilvl)`, so two lists in the same document count independently. A nested `(a) (b)` exceptions list under its own `numId` does not disturb the outer `1. 2. 3.` clause numbering.
41+
42+
## Paragraph structure
43+
44+
The extractor joins paragraphs with newlines rather than flattening the document with `" ".join(...)`.
45+
46+
This is not cosmetic. `locate_clause()` splits on lines to find the nearest heading above a match, `extract_clause_snippet()` splits on blank lines to anchor snippets to legal blocks, and `paragraph_index` counts lines. A flattened document reports `clause_heading: ""` and `paragraph_index: 1` for every finding — the `--why` evidence degrades to nothing while still looking well-formed.
47+
48+
## Fail visible, not silent
49+
50+
Every extraction reports whether numbering was resolved. `review` surfaces it as `coverage.numbering_resolved`:
51+
52+
| Value | Meaning |
53+
|---|---|
54+
| `true` | Every numbered paragraph was resolved to its visible number. A document with no numbered paragraphs is also `true` — nothing to resolve is not a failure to resolve. |
55+
| `false` | `numbering.xml` is absent or unparseable, a `numFmt` isn't supported, or the `textutil` fallback was used. **The numbers you see in Word are not in this text.** |
56+
| `null` | Numbering isn't a concept for this input (`.txt`, `.pdf`, stdin, in-memory text). |
57+
58+
`_read_any_text()` additionally returns a `numbering` record with `resolved`, `numbered_paragraphs`, and a human-readable `reason` when resolution failed.
59+
60+
**Downstream consumers should degrade, not assert.** A tool that checks cross-references or numbering gaps must not report "broken cross-reference to Section 7" as an error when `numbering_resolved` is `false` — it never saw the numbers. Downgrade such findings to warnings and say why.
61+
62+
## See also
63+
64+
- [scoring.md](scoring.md) — the `coverage` block and the `needs_review` decision.
65+
- [policy.md](policy.md) — the two playbook shapes.
66+
- [../../SECURITY.md](../../SECURITY.md) — why `.docx` XML is parsed through `_safe_xml_fromstring` (entity-expansion defence).

docs/reference/scoring.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,13 @@ Scores only mean something if rules actually ran. Every review therefore carries
5959
"rules_evaluated": 11,
6060
"rules_matched": 6,
6161
"extraction_status": "ok:docx-xml",
62+
"numbering_resolved": true,
6263
"text_chars": 4213
6364
}
6465
```
6566

67+
`numbering_resolved` reports whether a `.docx`'s automatic list numbering was reconstructed (`null` where numbering isn't a concept). When it is `false`, the clause numbers Word displays are absent from the text that was scored — see [docx-extraction.md](docx-extraction.md).
68+
6669
`rules_evaluated` is how many keyword-bearing rules the playbook contributed; `rules_matched` is how many found their clause in the document. A score of `0` with `rules_matched: 0` is not a clean NDA — it is a review that checked nothing.
6770

6871
When `rules_matched == 0` on a non-empty document, the decision is **`needs_review`**, never `approve`. Every real NDA trips at least one clause keyword (`confidential information`, `term`, …), so zero matches means the text is not an NDA or extraction mangled it.

0 commit comments

Comments
 (0)