fix: review reported "approve, risk 0" on documents that score "block"#15
Merged
Conversation
Two independent defects each zeroed out the review, and the fail-open
design reported "nothing was evaluated" as "nothing was wrong."
1. Playbook schema mismatch, silently ignored.
review_text iterated playbook["policy"], a list. But default-policy.json,
org-policy.json, and everything quickstart writes key their rules under
clause_rules, a dict. Such a file has no "policy" key, so the rule loop
never executed. No error, no warning.
This hit the documented happy path. `demo` -- the zero-config first-run
command -- and the README's own
`review --playbook config/default-policy.json` both returned
approve/0/0 on the bundled sample NDA, which actually scores block at
risk 17.4 with 6 findings.
2. review read .docx as raw zip bytes.
cmd_review used _read_text_file, a plain Path.read_text, so a .docx
arrived as PK\x03\x04... and no keyword could match. _read_any_text,
which routes .docx/.pdf to real extractors and returns an
extraction_status, already existed and simply wasn't called.
3. negotiate review was vacuous on the same axis: with no built playbook it
hand-rolled a fallback policy list whose entries had no `keywords`, and
clause_hit(text, []) is always False.
Fail-closed contract:
- Every review carries a `coverage` block (rules_evaluated, rules_matched,
extraction_status, text_chars), so risk_score 0 with rules_matched 0 is
self-evidently "nothing was checked".
- New decision `needs_review` when rules ran but matched nothing on a
non-empty document. Any real NDA trips at least one clause keyword.
- New hard failures at exit 2: EMPTY_DOCUMENT and PLAYBOOK_NO_RULES.
Missing input files keep their plain-text `error:` wording and exit 4.
Also adds review_nda() and load_policy(), which AGENTS.md has told
importers to use even though neither function existed.
Why 113 green tests sat on top of this: test_review_golden.py and
test_review_explainability.py converted clause_rules into a policy list
themselves before calling review_text -- performing exactly the step the
CLI was missing. The engine was well tested; the wiring that feeds it never
was. The calibration fixture went further and encoded the bug as expected
behaviour: its `approve` case was {"text": "", "expected_decision":
"approve"}. Tests now drive the real CLI with an unconverted policy file,
and that fixture case is a genuinely clean NDA.
Reported-by: baher@medicus.ai
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Routing cmd_review through _read_any_text (previous commit) sent every input through _normalize_extracted_text, which collapsed all `\s+` runs -- including newlines -- into single spaces. locate_clause splits on lines and extract_clause_snippet splits on blank lines, so the document arrived as one run-on string and `--why` evidence silently degraded: clause_heading became '' and paragraph_index became 1 for every finding. Collapse horizontal whitespace only; keep newlines, capping runs of blank lines at one. This restores `review --why` to the evidence it reported before (heading 'NON-DISCLOSURE AGREEMENT', paragraph_index 3/7/9 on the bundled fixture) and also improves the .pdf path, where pdftotext -layout emits meaningful line structure. Three of the four new TestExtractionPreservesStructure tests fail against the flattening version. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolves two doc conflicts with #14 (signer policy), which landed first: - CHANGELOG.md: both PRs added a `### Fixed` and `### Added` under [Unreleased]. Merged into one of each, signer-policy entries first since they are already on main. - README.md: both added a row to the Core concepts table. Kept both. nda_review_cli.py auto-merged cleanly -- #14 touches the negotiate red-flag call sites and sign-off, #15 touches the review engine. 200 tests pass and `make smoke` is green on the merged tree. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DrBaher
added a commit
that referenced
this pull request
Jul 9, 2026
Picks up #14 (signer policy) and #15 (vacuous-approve fix) after both landed. No conflicts: this branch touches the .docx extractor and the review coverage block; #14 touches negotiation. 224 tests pass and `make smoke` is green on the merged tree. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the defect report for v0.5.4:
review_ndareturneddecision: approve, risk_score: 0, findings: []for a document that scoresblockonce its inputs are actually parsed.All three reported bugs reproduce and are fixed. Investigation turned up three more of the same kind.
The two bugs that zero out the review
1. Playbook schema mismatch, silently ignored.
review_textiteratedplaybook["policy"](a list). Butconfig/default-policy.json,config/org-policy.json, and everythingquickstartwrites key their rules underclause_rules(a dict). Such a file has nopolicykey, so the rule loop never executed. No error, no warning.2.
reviewread.docxas raw zip bytes.cmd_reviewused_read_text_file— a plainPath.read_text— so a.docxarrived asPK\x03\x04...and no keyword could match._read_any_text, which routes.docx/.pdfto real extractors and returns anextraction_status, already existed and simply wasn't called.Same document, same rules, before → after:
.txtclause_rules.docxclause_rules.docxpolicylistThree more the report didn't cover
demowas broken. The flagship zero-config first-run command returnedapprove / 0 / 0on the bundled sample NDA. So did the exactreview --playbook config/default-policy.jsoncommand in the README. A new user's first impression of this tool was a vacuous approve.negotiate reviewwas vacuous on the same axis. With no built playbook it hand-rolled a fallback{"policy": [...]}whose entries carriedclauseandpreferredbut nokeywords— andclause_hit(text, [])is alwaysFalse. It now returns 9 findings on the bundled mutual template.AGENTS.mdtells importers tofrom nda_review_cli import review_nda, load_policy. Neither function was defined. Both are now real.Fail-closed contract (report's Bug 3)
A review can no longer report
approvefor a document it never inspected.coverageblock:rules_evaluated,rules_matched,extraction_status,text_chars.risk_score: 0withrules_matched: 0is now self-evidently nothing was checked, not nothing was wrong.needs_reviewwhen rules ran but matched nothing on a non-empty document. Every real NDA trips at least one clause keyword (confidential information,term, …), so zero matches means the text isn't an NDA or extraction mangled it.2:EMPTY_DOCUMENT(blank text or failed extraction, withextraction_statusandextractors_triedindetails) andPLAYBOOK_NO_RULES(no keyword-bearing rules, so no clause could ever match).error: ...wording and exit4— introducing a JSON envelope for one command would have made the CLI inconsistent with itself.needs_reviewis a fourth decision value.normalize_decision_bucketand the calibration confusion matrix both learned it, and now derive from a singleDECISION_BUCKETSconstant so they can't desync again.Why 113 green tests sat on top of this
tests/test_review_golden.pyandtest_review_explainability.pyconvertedclause_rulesinto apolicylist themselves before callingreview_text— performing exactly the step the CLI was missing. The engine was well tested; the wiring that feeds it never was.The calibration fixture went further and encoded the bug as expected behaviour:
{ "id": "approve-empty", "text": "", "expected_decision": "approve" }An empty document, expected to
approve. That case is now a genuinely clean NDA (approve at risk 2.4, two clauses matched), plus aneeds_reviewcase. The tests now drive the real CLI with an unconverted policy file, andtest_llm_review's keyword-less fixture — which was also reviewing nothing — was fixed.Verification
test_review_coverage.pyfail against the previous source, as do all 3 inClauseRulesPlaybookTests. The rest are negative controls (a genuinely clean NDA must stillapprove;needs_reviewmust not swallow it).1.0across all four buckets.demo,tutorial,--catalog json,policy-validate, stdin (--file -) all verified working.Reported-by: baher@medicus.ai
🤖 Generated with Claude Code