Skip to content

Commit 21289d2

Browse files
jet52claude
andcommitted
Release v1.6.0: fix spacing detection, add page refs and engine footer
- Fix FMT-009 false positive: measure inter-block baseline gaps for PDFs that encode each visual line as a separate text block (was only measuring intra-block gaps, missing double spacing entirely on most briefs) - Add per-check page numbers to HTML report (badges on failed checks, column on passed checks table) - Add PyMuPDF/fallback status line to report footer - Add pages field to CheckResult, pymupdf_used field to ComplianceReport - Add --pymupdf/--no-pymupdf CLI flags to build_report.py - Update known issues: FMT-009 removed from active false-positive list Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8ed9a9d commit 21289d2

6 files changed

Lines changed: 77 additions & 12 deletions

File tree

SKILL.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: brief-compliance
3-
version: 1.5.0
3+
version: 1.6.0
44
description: >-
55
Triggers when a user uploads a legal brief PDF for compliance review against the
66
North Dakota Rules of Appellate Procedure. Analyzes the brief and produces a
@@ -175,12 +175,13 @@ Based on testing across 13 briefs (Feb 2026):
175175

176176
### Mechanical Check False Positives
177177

178-
Three mechanical checks have high false-positive rates. When reporting results, note these caveats to the user:
178+
Two mechanical checks have high false-positive rates. When reporting results, note these caveats to the user:
179179

180180
- **FMT-006 (Font Size)**: Measures the minimum font found anywhere in the PDF. Small fonts in page numbers, headers, footers, superscripts, or PDF artifacts trigger REJECT even when the body text is properly 12pt. If this is the sole REJECT trigger and the reported minimum is 8-11pt, flag it as a likely false positive.
181-
- **FMT-009 (Spacing)**: Nearly all briefs are flagged as single-spaced. The detector is miscalibrated for many PDF encodings. If the brief appears to be a standard attorney-prepared document, note this is likely a false positive.
182181
- **FMT-005 (Bottom Margin)**: Page numbers at the bottom are measured as content in the margin zone. Nearly always triggers.
183182

183+
**Fixed (v1.6.0):** FMT-009 (Spacing) previously only measured intra-block line gaps, missing PDFs that encode each line as a separate text block. Now also measures inter-block baseline distances, which correctly detects double spacing in these PDFs.
184+
184185
### Brief Type Auto-Detection
185186

186187
The `--brief-type auto` flag frequently returns "unknown", especially for appellee briefs. If auto-detection fails, re-run Phase 1 with an explicit `--brief-type` flag based on the cover page text.
@@ -224,7 +225,7 @@ These are run by `check_brief.py` — no changes needed here.
224225
| FMT-006 | Font size >= 12pt | 32(a)(5) | REJECT | ⚠ High false-positive rate — flags small fonts in page numbers, headers, superscripts |
225226
| FMT-007 | Max 16 chars/inch | 32(a)(5) | CORRECTION |
226227
| FMT-008 | Plain roman style | 32(a)(6) | NOTE |
227-
| FMT-009 | Double-spaced body text | 32(a)(5) | CORRECTION | ⚠ High false-positive rate — spacing detection miscalibrated for many PDF encodings |
228+
| FMT-009 | Double-spaced body text | 32(a)(5) | CORRECTION |
228229
| FMT-010 | Footnotes double-spaced, same typeface | 32(a)(5) | NOTE |
229230
| FMT-011 | Pages numbered at bottom | 32(a)(4) | CORRECTION |
230231
| FMT-012 | Numbering starts with "1" on cover | 32(a)(4) | NOTE |

core/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ class CheckResult:
3939
message: str
4040
details: Optional[str] = None
4141
applicable: bool = True # False if this check doesn't apply to the brief type
42+
pages: Optional[list[int]] = None # Page numbers with issues (if applicable)
4243

4344
@property
4445
def failed(self) -> bool:
@@ -91,6 +92,7 @@ class ComplianceReport:
9192
case_title: str = "" # e.g. "Rath v. Rath et al."
9293
brief_label: str = "" # e.g. "Amended Brief of Defendant-Appellant"
9394
pdf_filename: str = "" # original PDF filename
95+
pymupdf_used: bool = True # Whether PyMuPDF was used for mechanical checks
9496

9597
@property
9698
def failed_checks(self) -> list[CheckResult]:

core/pdf_extract.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -200,23 +200,44 @@ def _compute_margins(blocks: list[dict], rect: fitz.Rect) -> tuple[float, float,
200200

201201

202202
def _estimate_line_spacing(blocks: list[dict]) -> Optional[float]:
203-
"""Estimate typical line spacing in points from text block baselines."""
203+
"""Estimate typical line spacing in points from text block baselines.
204+
205+
Measures both intra-block line gaps and inter-block gaps (for PDFs that
206+
encode each visual line as a separate block).
207+
"""
204208
spacings = []
209+
210+
# 1. Intra-block: gaps between lines within the same block
205211
for block in blocks:
206212
if block["type"] != 0:
207213
continue
208214
lines = block.get("lines", [])
209215
for i in range(1, len(lines)):
210-
prev_baseline = lines[i - 1]["bbox"][3] # bottom of previous line
211-
curr_baseline = lines[i]["bbox"][1] # top of current line
212-
# More accurate: use the origin y of spans
213216
prev_origins = [s["origin"][1] for s in lines[i - 1].get("spans", []) if s["text"].strip()]
214217
curr_origins = [s["origin"][1] for s in lines[i].get("spans", []) if s["text"].strip()]
215218
if prev_origins and curr_origins:
216219
spacing = min(curr_origins) - min(prev_origins)
217-
if 8 < spacing < 60: # reasonable range
220+
if 8 < spacing < 60:
218221
spacings.append(spacing)
219222

223+
# 2. Inter-block: gaps between consecutive single-line text blocks.
224+
# Many PDF generators emit each line as its own block, so intra-block
225+
# measurement finds nothing. Walk consecutive text blocks and measure
226+
# baseline-to-baseline distance.
227+
text_blocks = [b for b in blocks if b.get("type") == 0]
228+
for i in range(1, len(text_blocks)):
229+
prev_lines = text_blocks[i - 1].get("lines", [])
230+
curr_lines = text_blocks[i].get("lines", [])
231+
if not prev_lines or not curr_lines:
232+
continue
233+
# Use the last line of prev block and first line of curr block
234+
prev_origins = [s["origin"][1] for s in prev_lines[-1].get("spans", []) if s["text"].strip()]
235+
curr_origins = [s["origin"][1] for s in curr_lines[0].get("spans", []) if s["text"].strip()]
236+
if prev_origins and curr_origins:
237+
spacing = min(curr_origins) - min(prev_origins)
238+
if 8 < spacing < 60:
239+
spacings.append(spacing)
240+
220241
return statistics.median(spacings) if spacings else None
221242

222243

core/report_builder.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ def build_html_report(report: ComplianceReport, version_stamp: str = "") -> str:
109109
110110
<footer>
111111
<p>Generated by the ND Supreme Court Brief Compliance Checker{f' &middot; {_esc(version_stamp)}' if version_stamp else ''}</p>
112+
<p class="engine-note">Mechanical checks: {'PyMuPDF' if report.pymupdf_used else 'Fallback (PyMuPDF unavailable — mechanical checks skipped)'}</p>
112113
</footer>
113114
</div>
114115
</body>
@@ -121,6 +122,10 @@ def _render_check_group(checks: list[CheckResult], title: str, color: str) -> st
121122
rows = ""
122123
for c in checks:
123124
detail = f'<div class="detail">{_esc(c.details)}</div>' if c.details else ""
125+
pages_html = ""
126+
if c.pages:
127+
page_tags = " ".join(f'<span class="page-badge">p.{p}</span>' for p in c.pages)
128+
pages_html = f'<div class="check-pages">Pages: {page_tags}</div>'
124129
rows += f"""<div class="check-card failed" style="border-left-color:{color};">
125130
<div class="check-header">
126131
<span class="check-id">{c.check_id}</span>
@@ -129,6 +134,7 @@ def _render_check_group(checks: list[CheckResult], title: str, color: str) -> st
129134
<span class="severity severity-{c.severity.value}">{c.severity.value.upper()}</span>
130135
</div>
131136
<div class="check-msg">{_esc(c.message)}</div>
137+
{pages_html}
132138
{detail}
133139
</div>
134140
"""
@@ -140,15 +146,19 @@ def _render_checks_table(checks: list[CheckResult], passed: bool = True) -> str:
140146
return ""
141147
rows = ""
142148
for c in checks:
149+
pages_cell = ""
150+
if c.pages:
151+
pages_cell = ", ".join(str(p) for p in c.pages)
143152
rows += f"""<tr>
144153
<td class="check-id-cell">{c.check_id}</td>
145154
<td>{_esc(c.name)}</td>
146155
<td>{_rule_link(c.rule)}</td>
147156
<td>{_esc(c.message)}</td>
157+
<td class="pages-cell">{pages_cell}</td>
148158
</tr>
149159
"""
150160
return f"""<table class="checks-table">
151-
<thead><tr><th>ID</th><th>Check</th><th>Rule</th><th>Result</th></tr></thead>
161+
<thead><tr><th>ID</th><th>Check</th><th>Rule</th><th>Result</th><th>Pages</th></tr></thead>
152162
<tbody>{rows}</tbody>
153163
</table>"""
154164

@@ -228,7 +238,11 @@ def _css() -> str:
228238
details summary::-webkit-details-marker { display: none; }
229239
details summary::before { content: '▶ '; font-size: 0.8rem; }
230240
details[open] summary::before { content: '▼ '; }
241+
.check-pages { margin-top: 0.3rem; font-size: 0.85rem; color: #586069; }
242+
.page-badge { display: inline-block; background: #e1e4e8; color: #24292e; font-size: 0.78rem; font-family: monospace; padding: 0.1rem 0.35rem; border-radius: 3px; margin: 0 0.15rem; }
243+
.pages-cell { font-family: monospace; font-size: 0.85rem; color: #586069; white-space: nowrap; }
231244
footer { text-align: center; color: #586069; font-size: 0.85rem; padding: 1.5rem 0; border-top: 1px solid #e1e4e8; margin-top: 2rem; }
245+
.engine-note { font-size: 0.8rem; margin-top: 0.25rem; }
232246
@media print { .container { max-width: 100%; } .banner { border-width: 3px; } details { open: true; } details[open] summary { display: none; } }
233247
@media (max-width: 600px) { .check-header { flex-direction: column; align-items: flex-start; } .severity { margin-left: 0; } }
234248
"""

scripts/build_report.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,33 @@
2727
from core.version_check import get_version_stamp
2828

2929

30+
def _extract_pages_from_message(message: str) -> list[int] | None:
31+
"""Try to extract page numbers from a check message like 'Top Margin < 1" on 2, 3, 4'."""
32+
# Match patterns like "on 2, 3, 4, 5" or "on pages 2, 3, 4"
33+
m = re.search(r'\bon(?:\s+pages?)?\s+([\d,\s]+)', message)
34+
if m:
35+
nums = re.findall(r'\d+', m.group(1))
36+
if nums:
37+
return [int(n) for n in nums]
38+
# Match "pages: 2, 3" in details
39+
m = re.search(r'pages?[:\s]+([\d,\s]+)', message, re.IGNORECASE)
40+
if m:
41+
nums = re.findall(r'\d+', m.group(1))
42+
if nums:
43+
return [int(n) for n in nums]
44+
return None
45+
46+
3047
def _parse_results(items: list[dict]) -> list[CheckResult]:
3148
"""Convert a list of JSON dicts into CheckResult objects."""
3249
results = []
3350
for item in items:
51+
pages = item.get("pages")
52+
if pages is None and not item["passed"]:
53+
# Try to extract page numbers from message or details
54+
pages = _extract_pages_from_message(item["message"])
55+
if pages is None and item.get("details"):
56+
pages = _extract_pages_from_message(item["details"])
3457
results.append(CheckResult(
3558
check_id=item["check_id"],
3659
name=item["name"],
@@ -40,6 +63,7 @@ def _parse_results(items: list[dict]) -> list[CheckResult]:
4063
message=item["message"],
4164
details=item.get("details"),
4265
applicable=item.get("applicable", True),
66+
pages=pages,
4367
))
4468
return results
4569

@@ -130,6 +154,8 @@ def main():
130154
parser.add_argument("--semantic", required=True, help="Path to semantic results JSON from Claude Code")
131155
parser.add_argument("--output-dir", default=None, help="Directory for HTML report (default: same as intermediate)")
132156
parser.add_argument("--reasoning", default=None, help="Optional reasoning text for the report summary")
157+
parser.add_argument("--pymupdf", action="store_true", default=True, help="PyMuPDF was used for mechanical checks (default)")
158+
parser.add_argument("--no-pymupdf", action="store_false", dest="pymupdf", help="PyMuPDF was NOT used (fallback mode)")
133159
args = parser.parse_args()
134160

135161
intermediate_path = Path(args.intermediate)
@@ -186,6 +212,7 @@ def main():
186212
case_title=case_title,
187213
brief_label=brief_label,
188214
pdf_filename=Path(pdf_path_str).name,
215+
pymupdf_used=args.pymupdf,
189216
)
190217

191218
html = build_html_report(report, version_stamp=get_version_stamp())

version.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
2-
"version": "1.5.0",
3-
"build_date": "2026-02-20",
2+
"version": "1.6.0",
3+
"build_date": "2026-02-25",
44
"rules_verified": "2026-02-17",
55
"rules_freshness_days": 90,
66
"check_url": "https://raw.githubusercontent.com/jet52/ndsc-brief-compliance/main/version.json",

0 commit comments

Comments
 (0)