centralia 0.0.1__py3-none-any.whl
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.
- centralia/__init__.py +395 -0
- centralia/audit.py +191 -0
- centralia/classify.py +317 -0
- centralia/courts/__init__.py +1013 -0
- centralia/courts/acca.py +537 -0
- centralia/courts/afcca.py +962 -0
- centralia/courts/akd.py +40 -0
- centralia/courts/ala.py +805 -0
- centralia/courts/alacivapp.py +482 -0
- centralia/courts/alacrimapp.py +632 -0
- centralia/courts/alaska.py +944 -0
- centralia/courts/alaskactapp.py +801 -0
- centralia/courts/almd.py +37 -0
- centralia/courts/alnd.py +44 -0
- centralia/courts/alsd.py +44 -0
- centralia/courts/ared.py +44 -0
- centralia/courts/ariz.py +582 -0
- centralia/courts/arizctapp.py +788 -0
- centralia/courts/ark.py +572 -0
- centralia/courts/arkctapp.py +636 -0
- centralia/courts/armfor.py +671 -0
- centralia/courts/arwd.py +44 -0
- centralia/courts/asbca.py +695 -0
- centralia/courts/azd.py +44 -0
- centralia/courts/bap1.py +641 -0
- centralia/courts/bap10.py +973 -0
- centralia/courts/bap6.py +1119 -0
- centralia/courts/bap8.py +652 -0
- centralia/courts/bap9.py +768 -0
- centralia/courts/bia.py +495 -0
- centralia/courts/ca1.py +481 -0
- centralia/courts/ca10.py +818 -0
- centralia/courts/ca11.py +554 -0
- centralia/courts/ca2.py +1462 -0
- centralia/courts/ca3.py +675 -0
- centralia/courts/ca4.py +678 -0
- centralia/courts/ca5.py +664 -0
- centralia/courts/ca6.py +989 -0
- centralia/courts/ca7.py +1008 -0
- centralia/courts/ca8.py +560 -0
- centralia/courts/ca9.py +1101 -0
- centralia/courts/cacd.py +44 -0
- centralia/courts/cadc.py +833 -0
- centralia/courts/caed.py +44 -0
- centralia/courts/cafc.py +1017 -0
- centralia/courts/cal.py +822 -0
- centralia/courts/calag.py +543 -0
- centralia/courts/calctapp.py +1091 -0
- centralia/courts/cand.py +44 -0
- centralia/courts/casd.py +44 -0
- centralia/courts/cavc.py +635 -0
- centralia/courts/cit.py +968 -0
- centralia/courts/cod.py +59 -0
- centralia/courts/coloctapp.py +603 -0
- centralia/courts/conn.py +531 -0
- centralia/courts/connappct.py +566 -0
- centralia/courts/ctd.py +44 -0
- centralia/courts/dc.py +730 -0
- centralia/courts/dcd.py +46 -0
- centralia/courts/ded.py +44 -0
- centralia/courts/del.py +529 -0
- centralia/courts/delch.py +1022 -0
- centralia/courts/delctcompl.py +546 -0
- centralia/courts/delsuperct.py +1184 -0
- centralia/courts/fla.py +606 -0
- centralia/courts/fladistctapp.py +755 -0
- centralia/courts/flmd.py +44 -0
- centralia/courts/flnd.py +44 -0
- centralia/courts/flsd.py +44 -0
- centralia/courts/ga.py +663 -0
- centralia/courts/gactapp.py +647 -0
- centralia/courts/gamd.py +44 -0
- centralia/courts/gand.py +44 -0
- centralia/courts/gasd.py +44 -0
- centralia/courts/guam.py +576 -0
- centralia/courts/haw.py +1281 -0
- centralia/courts/hawapp.py +1237 -0
- centralia/courts/hid.py +44 -0
- centralia/courts/iand.py +44 -0
- centralia/courts/iasd.py +44 -0
- centralia/courts/idaho.py +949 -0
- centralia/courts/idahoctapp.py +860 -0
- centralia/courts/idd.py +44 -0
- centralia/courts/ilcd.py +44 -0
- centralia/courts/ill.py +604 -0
- centralia/courts/illappct.py +1190 -0
- centralia/courts/ilnd.py +44 -0
- centralia/courts/ilsd.py +44 -0
- centralia/courts/ind.py +724 -0
- centralia/courts/indctapp.py +792 -0
- centralia/courts/indtc.py +575 -0
- centralia/courts/innd.py +44 -0
- centralia/courts/insd.py +44 -0
- centralia/courts/iowa.py +601 -0
- centralia/courts/iowactapp.py +642 -0
- centralia/courts/kan.py +562 -0
- centralia/courts/kanctapp.py +597 -0
- centralia/courts/ksd.py +44 -0
- centralia/courts/ky.py +391 -0
- centralia/courts/kyctapp.py +480 -0
- centralia/courts/kyed.py +53 -0
- centralia/courts/kywd.py +44 -0
- centralia/courts/la.py +692 -0
- centralia/courts/lactapp.py +681 -0
- centralia/courts/laed.py +44 -0
- centralia/courts/lamd.py +44 -0
- centralia/courts/lawd.py +44 -0
- centralia/courts/mad.py +44 -0
- centralia/courts/mass.py +655 -0
- centralia/courts/massappct.py +751 -0
- centralia/courts/md.py +897 -0
- centralia/courts/mdag.py +379 -0
- centralia/courts/mdctspecapp.py +902 -0
- centralia/courts/mdd.py +44 -0
- centralia/courts/me.py +484 -0
- centralia/courts/med.py +44 -0
- centralia/courts/mesuperct.py +559 -0
- centralia/courts/mich.py +698 -0
- centralia/courts/michctapp.py +572 -0
- centralia/courts/mied.py +44 -0
- centralia/courts/minn.py +352 -0
- centralia/courts/minnag.py +652 -0
- centralia/courts/minnctapp.py +452 -0
- centralia/courts/miss.py +455 -0
- centralia/courts/missctapp.py +405 -0
- centralia/courts/miwd.py +44 -0
- centralia/courts/mnd.py +44 -0
- centralia/courts/mo.py +800 -0
- centralia/courts/moctapp.py +830 -0
- centralia/courts/moed.py +44 -0
- centralia/courts/mont.py +333 -0
- centralia/courts/mowd.py +44 -0
- centralia/courts/msnd.py +44 -0
- centralia/courts/mspb.py +425 -0
- centralia/courts/mssd.py +44 -0
- centralia/courts/mtd.py +44 -0
- centralia/courts/nc.py +622 -0
- centralia/courts/ncbizct.py +856 -0
- centralia/courts/ncctapp.py +692 -0
- centralia/courts/nced.py +44 -0
- centralia/courts/ncmd.py +44 -0
- centralia/courts/ncwd.py +44 -0
- centralia/courts/nd.py +682 -0
- centralia/courts/ndd.py +44 -0
- centralia/courts/neb.py +546 -0
- centralia/courts/nebctapp.py +905 -0
- centralia/courts/ned.py +44 -0
- centralia/courts/nev.py +659 -0
- centralia/courts/nevapp.py +448 -0
- centralia/courts/nh.py +393 -0
- centralia/courts/nhd.py +44 -0
- centralia/courts/nj.py +883 -0
- centralia/courts/njd.py +44 -0
- centralia/courts/njsuperctappdiv.py +689 -0
- centralia/courts/njtaxct.py +703 -0
- centralia/courts/nm.py +496 -0
- centralia/courts/nmariana.py +357 -0
- centralia/courts/nmcca.py +778 -0
- centralia/courts/nmctapp.py +594 -0
- centralia/courts/nmd.py +44 -0
- centralia/courts/nvd.py +44 -0
- centralia/courts/nycivct.py +486 -0
- centralia/courts/nyed.py +44 -0
- centralia/courts/nyfamct.py +783 -0
- centralia/courts/nynd.py +44 -0
- centralia/courts/nysd.py +44 -0
- centralia/courts/nysupct.py +525 -0
- centralia/courts/nysurct.py +409 -0
- centralia/courts/nywd.py +44 -0
- centralia/courts/ohio.py +765 -0
- centralia/courts/ohioctapp.py +1506 -0
- centralia/courts/ohioctcl.py +754 -0
- centralia/courts/ohnd.py +44 -0
- centralia/courts/ohsd.py +44 -0
- centralia/courts/oked.py +44 -0
- centralia/courts/oknd.py +44 -0
- centralia/courts/okwd.py +44 -0
- centralia/courts/olc.py +466 -0
- centralia/courts/or.py +739 -0
- centralia/courts/orctapp.py +524 -0
- centralia/courts/ord.py +44 -0
- centralia/courts/ortc.py +877 -0
- centralia/courts/pa.py +734 -0
- centralia/courts/pacommwct.py +969 -0
- centralia/courts/paed.py +44 -0
- centralia/courts/pamd.py +44 -0
- centralia/courts/pasuperct.py +722 -0
- centralia/courts/pawd.py +44 -0
- centralia/courts/prapp.py +388 -0
- centralia/courts/prsupreme.py +353 -0
- centralia/courts/ri.py +952 -0
- centralia/courts/rid.py +44 -0
- centralia/courts/sc.py +738 -0
- centralia/courts/scctapp.py +776 -0
- centralia/courts/scd.py +44 -0
- centralia/courts/scotus.py +668 -0
- centralia/courts/sd.py +549 -0
- centralia/courts/sdd.py +44 -0
- centralia/courts/tax.py +460 -0
- centralia/courts/tenn.py +809 -0
- centralia/courts/tenncrimapp.py +892 -0
- centralia/courts/tennctapp.py +756 -0
- centralia/courts/tex.py +482 -0
- centralia/courts/texag.py +423 -0
- centralia/courts/texapp.py +849 -0
- centralia/courts/texbizct.py +942 -0
- centralia/courts/texcrimapp.py +674 -0
- centralia/courts/tned.py +44 -0
- centralia/courts/tnmd.py +44 -0
- centralia/courts/tnwd.py +44 -0
- centralia/courts/ttab.py +636 -0
- centralia/courts/txed.py +44 -0
- centralia/courts/txnd.py +44 -0
- centralia/courts/txsd.py +44 -0
- centralia/courts/txwd.py +44 -0
- centralia/courts/uscfc.py +692 -0
- centralia/courts/uscgcoca.py +706 -0
- centralia/courts/utah.py +870 -0
- centralia/courts/utahctapp.py +947 -0
- centralia/courts/utd.py +44 -0
- centralia/courts/va.py +688 -0
- centralia/courts/vactapp.py +552 -0
- centralia/courts/vaed.py +44 -0
- centralia/courts/vawd.py +44 -0
- centralia/courts/virginislands.py +966 -0
- centralia/courts/vt.py +733 -0
- centralia/courts/vtd.py +44 -0
- centralia/courts/vtsuperct.py +829 -0
- centralia/courts/waed.py +44 -0
- centralia/courts/wash.py +1020 -0
- centralia/courts/washctapp.py +1056 -0
- centralia/courts/wawd.py +44 -0
- centralia/courts/wied.py +44 -0
- centralia/courts/wis.py +631 -0
- centralia/courts/wisctapp.py +899 -0
- centralia/courts/wiwd.py +44 -0
- centralia/courts/wva.py +1162 -0
- centralia/courts/wvactapp.py +527 -0
- centralia/courts/wvnd.py +44 -0
- centralia/courts/wvsd.py +44 -0
- centralia/courts/wyd.py +44 -0
- centralia/courts/wyo.py +625 -0
- centralia/dates.py +125 -0
- centralia/districts/__init__.py +16 -0
- centralia/districts/ecf.py +2302 -0
- centralia/geometry.py +117 -0
- centralia/model.py +366 -0
- centralia/pdfio/__init__.py +12 -0
- centralia/pdfio/build.py +286 -0
- centralia/pdfio/model.py +239 -0
- centralia/pdfio/quirks.py +726 -0
- centralia/pdfio/rules.py +139 -0
- centralia/pdfio/tables.py +172 -0
- centralia/pdfio/text.py +170 -0
- centralia/pipeline.py +2688 -0
- centralia/profile.py +62 -0
- centralia/released.py +261 -0
- centralia/render/__init__.py +6 -0
- centralia/render/casebody.py +124 -0
- centralia/render/facsimile.py +74 -0
- centralia/render/html.py +562 -0
- centralia/render/inline.py +23 -0
- centralia/resolve/__init__.py +0 -0
- centralia/resolve/assemble.py +2673 -0
- centralia/resolve/bylines.py +1076 -0
- centralia/resolve/captions.py +257 -0
- centralia/resolve/evidence.py +157 -0
- centralia/resolve/footnotes.py +1239 -0
- centralia/resolve/furniture.py +605 -0
- centralia/resolve/headmatter.py +1089 -0
- centralia/resolve/segments.py +381 -0
- centralia/sections.py +133 -0
- centralia/settings.py +45 -0
- centralia/styles.py +56 -0
- centralia-0.0.1.dist-info/METADATA +60 -0
- centralia-0.0.1.dist-info/RECORD +277 -0
- centralia-0.0.1.dist-info/WHEEL +4 -0
centralia/__init__.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""centralia — court PDF opinion extractor: PDF + court id -> typed Document.
|
|
2
|
+
|
|
3
|
+
from centralia import read
|
|
4
|
+
|
|
5
|
+
r = read("opinion.pdf", court_id="nmariana") # path, bytes, or file object
|
|
6
|
+
r["status"] # valid | review | scanned | failed
|
|
7
|
+
r["cluster"] # the case: citation, docket, dates, panel, parties …
|
|
8
|
+
r["opinions"] # one entry per writing, each with its author and its html
|
|
9
|
+
r["diagnostics"] # page facts and what went unplaced — reported, not judged
|
|
10
|
+
r["html"] # the document's text, no review furniture
|
|
11
|
+
r["review_html"] # the reviewer's page: criteria box, Removed panel, tints
|
|
12
|
+
r["casebody"] # Harvard casebody XML
|
|
13
|
+
|
|
14
|
+
Lower-level, for callers that want the objects:
|
|
15
|
+
|
|
16
|
+
from centralia import extract, render_html, render_opinion
|
|
17
|
+
result = extract(pdf_path, court_id="mont")
|
|
18
|
+
result.document # typed Document
|
|
19
|
+
result.trace # per-decision evidence chains
|
|
20
|
+
result.status
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import dataclasses as _dc
|
|
26
|
+
import enum as _enum
|
|
27
|
+
import os as _os
|
|
28
|
+
import tempfile as _tempfile
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from .courts import PROFILES
|
|
32
|
+
from .dates import all_iso, to_iso
|
|
33
|
+
from .model import Criteria, Document, Meta, Opinion
|
|
34
|
+
from .pipeline import ExtractionResult, extract
|
|
35
|
+
from .released import HELD_BACK, RELEASED
|
|
36
|
+
from .render import (opinion_text, render_body, render_casebody,
|
|
37
|
+
render_headmatter, render_html, render_opinion)
|
|
38
|
+
from .render.facsimile import render_hm_items
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"read", "extract", "ExtractionResult", "released_courts",
|
|
42
|
+
"UnknownCourt", "CourtNotReleased",
|
|
43
|
+
"Document", "Criteria", "Meta", "Opinion",
|
|
44
|
+
"render_html", "render_body", "render_opinion", "render_casebody",
|
|
45
|
+
"render_headmatter", "opinion_text", "to_iso", "all_iso", "__version__",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
__version__ = "0.0.1"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class UnknownCourt(KeyError):
|
|
52
|
+
"""No such court id. Raised rather than falling back, because an
|
|
53
|
+
unregistered id silently gets core's GENERIC reader — the record still
|
|
54
|
+
extracts and still says `status: valid`, it is just read worse. A typo
|
|
55
|
+
should not look like a thin court."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CourtNotReleased(RuntimeError):
|
|
59
|
+
"""The court is still being worked on. See `centralia/released.py`."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def released_courts() -> frozenset[str]:
|
|
63
|
+
"""The court ids the public API will read. See `centralia/released.py`."""
|
|
64
|
+
return RELEASED
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _as_path(src) -> tuple[str, bool]:
|
|
68
|
+
"""(path, is_temp). Bytes and file objects are spilled to a temp file.
|
|
69
|
+
|
|
70
|
+
pdfplumber can read a stream, but the pipeline threads a PATH through for
|
|
71
|
+
provenance (`Meta.source_path`) and for the stapled-document split, so
|
|
72
|
+
normalising here keeps one code path in the engine instead of two.
|
|
73
|
+
"""
|
|
74
|
+
if isinstance(src, (str, _os.PathLike)):
|
|
75
|
+
return str(src), False
|
|
76
|
+
data = src.read() if hasattr(src, "read") else src
|
|
77
|
+
if isinstance(data, str):
|
|
78
|
+
raise TypeError("read(): expected bytes or a binary file object, got str")
|
|
79
|
+
fd, path = _tempfile.mkstemp(suffix=".pdf", prefix="centralia-")
|
|
80
|
+
with _os.fdopen(fd, "wb") as fh:
|
|
81
|
+
fh.write(data)
|
|
82
|
+
return path, True
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _plain(value: Any) -> Any:
|
|
86
|
+
"""A JSON-safe copy: enums to their value, everything else as it stands."""
|
|
87
|
+
if isinstance(value, dict):
|
|
88
|
+
return {k: _plain(v) for k, v in value.items()}
|
|
89
|
+
if isinstance(value, (list, tuple)):
|
|
90
|
+
return [_plain(v) for v in value]
|
|
91
|
+
# isinstance, NOT a walk of __mro__: DocType is a StrEnum, so its second
|
|
92
|
+
# base is StrEnum and a name check for "Enum" missed it entirely. And
|
|
93
|
+
# because a StrEnum IS a str, json.dumps serialised it happily — so the
|
|
94
|
+
# payload looked fine while the Python value handed to a caller was still
|
|
95
|
+
# an enum (the user, 2026-08-21). The JSON test could not see that; the
|
|
96
|
+
# type test below can.
|
|
97
|
+
if isinstance(value, _enum.Enum):
|
|
98
|
+
return value.value
|
|
99
|
+
if _dc.is_dataclass(value) and not isinstance(value, type):
|
|
100
|
+
return {f.name: _plain(getattr(value, f.name))
|
|
101
|
+
for f in _dc.fields(value)}
|
|
102
|
+
return value
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _bare(text: str) -> str:
|
|
106
|
+
"""A row's words without the model's inline markup. `HmLine.text` carries
|
|
107
|
+
the <strong>/<em>/<u> vocabulary, which a consumer reading a FIELD does not
|
|
108
|
+
want — 'ALABAMA COURT OF CIVIL APPEALS' is the value, not
|
|
109
|
+
'<strong>ALABAMA COURT OF CIVIL APPEALS</strong>'."""
|
|
110
|
+
import re as _re
|
|
111
|
+
from html import unescape as _un
|
|
112
|
+
return _un(_re.sub(r"<[^>]+>", "", text or "")).strip()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _blocks_text(blocks) -> str:
|
|
116
|
+
"""Plain text for a run of blocks — a footnote's body, a section's prose."""
|
|
117
|
+
import re as _re
|
|
118
|
+
from html import unescape as _un
|
|
119
|
+
out = []
|
|
120
|
+
for b in blocks:
|
|
121
|
+
t = getattr(b, "text", "") or ""
|
|
122
|
+
if not t and getattr(b, "rows", None):
|
|
123
|
+
t = " ".join(" ".join(r) for r in b.rows)
|
|
124
|
+
if t:
|
|
125
|
+
out.append(_un(_re.sub(r"<[^>]+>", "", t)))
|
|
126
|
+
return "\n\n".join(out)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
_PRECIS_ROLES = ("syllabus", "summary", "headnotes")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _precis(doc: Document) -> dict:
|
|
133
|
+
"""syllabus / summary / headnotes as text, FROM BOTH PLACES THEY LIVE.
|
|
134
|
+
|
|
135
|
+
The same thing arrives two ways depending on how a court reader emitted
|
|
136
|
+
it, and a consumer should not have to know which court does which:
|
|
137
|
+
|
|
138
|
+
* as flow Blocks on `doc.syllabus` / `.summary` / `.headnotes` —
|
|
139
|
+
orctapp's disposition summary, ca9's staff summary
|
|
140
|
+
* as headmatter ROWS carrying that role — nebctapp sets 100 syllabus
|
|
141
|
+
rows on its cover, bia one summary row, and `sections` was empty for
|
|
142
|
+
both while the text sat in `headmatter.by_role` (the user, 2026-08-21)
|
|
143
|
+
|
|
144
|
+
Merged here, flow first then rows, because that is the order the page
|
|
145
|
+
prints them in. The rows stay in `headmatter` too — this is a derived
|
|
146
|
+
view, not a second home.
|
|
147
|
+
"""
|
|
148
|
+
out: dict = {}
|
|
149
|
+
for role in _PRECIS_ROLES:
|
|
150
|
+
parts = [_bare(getattr(b, "text", ""))
|
|
151
|
+
for b in getattr(doc, role, ()) or ()]
|
|
152
|
+
parts += [_bare(getattr(i, "text", ""))
|
|
153
|
+
for i in doc.headmatter
|
|
154
|
+
if getattr(i, "role", "") == role
|
|
155
|
+
and getattr(i, "text", "")]
|
|
156
|
+
out[role] = "\n".join(p for p in parts if p) or None
|
|
157
|
+
return out
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _cluster(doc: Document) -> dict:
|
|
161
|
+
"""The case-level facts, named as an ingest names them.
|
|
162
|
+
|
|
163
|
+
DATES COME BOTH WAYS: as the court printed them, and ISO where that can be
|
|
164
|
+
read without guessing (`centralia.dates`). `date_argued`/`date_submitted`
|
|
165
|
+
fall back to `submitted`, which is still the field 63 court files write —
|
|
166
|
+
the split is a separate migration, and until it lands `submitted_kind`
|
|
167
|
+
says only that the distinction is not yet known for this record.
|
|
168
|
+
"""
|
|
169
|
+
c = doc.criteria
|
|
170
|
+
argued = c.date_argued or None
|
|
171
|
+
submitted = c.date_submitted or None
|
|
172
|
+
if argued is None and submitted is None and c.submitted:
|
|
173
|
+
submitted = c.submitted # unsplit: see the note above
|
|
174
|
+
return {
|
|
175
|
+
**_precis(doc),
|
|
176
|
+
"court_id": doc.meta.court_id,
|
|
177
|
+
"court": c.court or doc.meta.court_label or None,
|
|
178
|
+
"case_name": c.case_name,
|
|
179
|
+
"case_name_short": c.short_case_name,
|
|
180
|
+
"citation": c.citation,
|
|
181
|
+
"docket_number": c.docket_number,
|
|
182
|
+
"other_dockets": list(c.other_dockets),
|
|
183
|
+
"date_filed": c.decision_date,
|
|
184
|
+
"date_filed_iso": to_iso(c.decision_date),
|
|
185
|
+
"date_argued": argued,
|
|
186
|
+
"date_argued_iso": to_iso(argued),
|
|
187
|
+
"date_submitted": submitted,
|
|
188
|
+
"date_submitted_iso": to_iso(submitted),
|
|
189
|
+
"date_reargued": c.date_reargued,
|
|
190
|
+
"date_reargued_iso": to_iso(c.date_reargued),
|
|
191
|
+
"submitted_split": bool(c.date_argued or c.date_submitted),
|
|
192
|
+
"precedential_status": c.publication_status,
|
|
193
|
+
"judges": c.judges,
|
|
194
|
+
"panel": list(c.panel),
|
|
195
|
+
"author": c.author,
|
|
196
|
+
"attorneys": c.attorneys,
|
|
197
|
+
"parties": list(c.parties),
|
|
198
|
+
"caption": list(c.caption),
|
|
199
|
+
"disposition": c.disposition,
|
|
200
|
+
"history": c.history,
|
|
201
|
+
"lower_court": c.lower_court,
|
|
202
|
+
"lower_court_docket": list(c.lower_court_docket),
|
|
203
|
+
"lower_court_judge": c.lower_court_judge,
|
|
204
|
+
"title": c.title,
|
|
205
|
+
# PREVIOUSLY DROPPED. `Criteria` carries 27 fields and this dict named
|
|
206
|
+
# 25 of them; `panel_line` (the roster exactly as printed) and `motion`
|
|
207
|
+
# fell through in silence — the same way the headmatter did. The test
|
|
208
|
+
# `test_every_criteria_field_is_exposed` now makes that impossible.
|
|
209
|
+
"panel_line": c.panel_line,
|
|
210
|
+
"motion": c.motion,
|
|
211
|
+
"headmatter_style": c.headmatter_style,
|
|
212
|
+
"n_pages": doc.meta.n_pages,
|
|
213
|
+
"doc_type": _plain(doc.meta.doc_type),
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _opinions(doc: Document) -> list[dict]:
|
|
218
|
+
"""One entry per writing, IN THE ORDER THE DOCUMENT PRINTS THEM.
|
|
219
|
+
|
|
220
|
+
`order` is that position, 1-based — the sequence a court's papers were
|
|
221
|
+
filed in and the sequence an ingest has to preserve, since a dissent means
|
|
222
|
+
nothing without the opinion it dissents from. The list is already ordered;
|
|
223
|
+
the field makes it survive a caller that sorts, filters or round-trips
|
|
224
|
+
through a store that does not preserve sequence.
|
|
225
|
+
|
|
226
|
+
NOTE ON THE NAME: `order` here is a POSITION, while `type` may itself be
|
|
227
|
+
the string "order" (this court files its order as a separate paper). They
|
|
228
|
+
are different things sharing a word — say so if you would rather have
|
|
229
|
+
`ordering_key`, which is what CourtListener calls the same field.
|
|
230
|
+
"""
|
|
231
|
+
out = []
|
|
232
|
+
for n, op in enumerate(doc.opinions, start=1):
|
|
233
|
+
pages = sorted({p for b in (*op.blocks, *op.signature)
|
|
234
|
+
for p in ([getattr(b, "prov", None).page]
|
|
235
|
+
if getattr(b, "prov", None) else [])})
|
|
236
|
+
out.append({
|
|
237
|
+
"order": n,
|
|
238
|
+
"type": op.type,
|
|
239
|
+
"author": op.author or None, # as the page prints it
|
|
240
|
+
"author_name": op.author_name or None, # parsed
|
|
241
|
+
"author_title": op.author_title or None,
|
|
242
|
+
"pages": pages,
|
|
243
|
+
"n_blocks": len(op.blocks),
|
|
244
|
+
"html": render_opinion(op),
|
|
245
|
+
"text": opinion_text(op),
|
|
246
|
+
"footnotes": [{"label": f.label, "text": _blocks_text(f.blocks)}
|
|
247
|
+
for f in op.footnotes],
|
|
248
|
+
})
|
|
249
|
+
return out
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _hm_block(items, html: str) -> dict:
|
|
253
|
+
"""A ROLE-BEARING BLOCK, as its own section: the rows the court printed,
|
|
254
|
+
grouped by the role each was read as, plus the block's own HTML.
|
|
255
|
+
|
|
256
|
+
Two of the document's sections are shaped this way, not as flowing prose —
|
|
257
|
+
`headmatter` (the cover) and `endmatter` (the appearances). `sections.py`
|
|
258
|
+
gives them both the 'hm' style, and flattening them to a list of strings
|
|
259
|
+
threw away the roles, which are the product of a court port.
|
|
260
|
+
"""
|
|
261
|
+
rows = [{"role": getattr(i, "role", "") or None,
|
|
262
|
+
"text": _bare(getattr(i, "text", "")),
|
|
263
|
+
"html": getattr(i, "text", ""),
|
|
264
|
+
"page": i.prov.page}
|
|
265
|
+
for i in items if getattr(i, "text", "")]
|
|
266
|
+
by_role: dict = {}
|
|
267
|
+
for row in rows:
|
|
268
|
+
by_role.setdefault(row["role"] or "untinted", []).append(row["text"])
|
|
269
|
+
return {
|
|
270
|
+
"rows": rows,
|
|
271
|
+
"by_role": by_role,
|
|
272
|
+
"html": html,
|
|
273
|
+
"text": "\n".join(r["text"] for r in rows),
|
|
274
|
+
"untinted": sum(1 for r in rows if not r["role"]),
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _headmatter(doc: Document) -> dict:
|
|
279
|
+
"""The cover, with the notes it carries."""
|
|
280
|
+
out = _hm_block(doc.headmatter, render_headmatter(doc))
|
|
281
|
+
out["footnotes"] = [{"label": f.label, "text": _blocks_text(f.blocks)}
|
|
282
|
+
for f in doc.headmatter_footnotes]
|
|
283
|
+
return out
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _diagnostics(doc: Document, status: str) -> dict:
|
|
287
|
+
"""Facts, not verdicts. Nothing here changes `status`; the caller decides
|
|
288
|
+
(the user, 2026-08-21)."""
|
|
289
|
+
mt = doc.meta
|
|
290
|
+
removed: dict = {}
|
|
291
|
+
for d in doc.dropped:
|
|
292
|
+
removed[d.kind] = removed.get(d.kind, 0) + 1
|
|
293
|
+
hm_rows = [i for i in doc.headmatter if hasattr(i, "role")]
|
|
294
|
+
return {
|
|
295
|
+
"status": status,
|
|
296
|
+
"rollout": "released" if doc.meta.court_id in RELEASED else "pending",
|
|
297
|
+
"source_kind": mt.source_kind,
|
|
298
|
+
"n_pages": mt.n_pages,
|
|
299
|
+
"scan_pages": list(mt.scan_pages),
|
|
300
|
+
"text_missing_pages": list(mt.text_missing_pages),
|
|
301
|
+
"cid_pages": list(mt.cid_pages),
|
|
302
|
+
"residual": [{"kind": r.kind, "page": r.prov.page, "text": r.text}
|
|
303
|
+
for r in doc.residual],
|
|
304
|
+
"residual_content": sum(1 for r in doc.residual if r.kind == "content"),
|
|
305
|
+
"removed_counts": removed,
|
|
306
|
+
"opinion_count": len(doc.opinions),
|
|
307
|
+
"unbylined_opinions": sum(1 for op in doc.opinions
|
|
308
|
+
if not (op.author or op.author_name)),
|
|
309
|
+
"footnote_count": sum(len(op.footnotes) for op in doc.opinions)
|
|
310
|
+
+ len(doc.headmatter_footnotes),
|
|
311
|
+
"headmatter_rows": len(hm_rows),
|
|
312
|
+
"headmatter_untinted": sum(1 for i in hm_rows if not i.role),
|
|
313
|
+
"dates_unparsed": [k for k, v in (
|
|
314
|
+
("date_filed", doc.criteria.decision_date),
|
|
315
|
+
("date_submitted", doc.criteria.date_submitted
|
|
316
|
+
or doc.criteria.submitted),
|
|
317
|
+
("date_argued", doc.criteria.date_argued),
|
|
318
|
+
) if v and not to_iso(v)],
|
|
319
|
+
"warnings": list(doc.warnings),
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def read(src, court_id: str, *,
|
|
324
|
+
include_document: bool = False,
|
|
325
|
+
allow_pending: bool = False) -> dict:
|
|
326
|
+
"""Read one PDF. ``src`` is a path, bytes, or a binary file object.
|
|
327
|
+
|
|
328
|
+
THE COURT ID IS CHECKED, and both ways of getting it wrong now fail loudly
|
|
329
|
+
instead of quietly reading worse:
|
|
330
|
+
|
|
331
|
+
* an id no court declares raises `UnknownCourt` — it would otherwise get
|
|
332
|
+
core's generic reader, and the record comes back valid but thin (the
|
|
333
|
+
user hit this with 'ala' for an 'alacivapp' record: no case name, no
|
|
334
|
+
filing date, no parties, and all 16 headmatter rows unclaimed).
|
|
335
|
+
* a court still being worked on raises `CourtNotReleased`. Pass
|
|
336
|
+
`allow_pending=True` to read it anyway; `diagnostics['rollout']` then
|
|
337
|
+
says which you got, so a consumer that overrode still knows.
|
|
338
|
+
|
|
339
|
+
Nothing else is gated: `harness.cli extract`, `render`, the guard and the
|
|
340
|
+
viewer all read every court, released or not.
|
|
341
|
+
"""
|
|
342
|
+
if court_id not in PROFILES:
|
|
343
|
+
raise UnknownCourt(
|
|
344
|
+
f"{court_id!r} is not a registered court id; "
|
|
345
|
+
f"{len(PROFILES)} are. Did you mean a sibling court?")
|
|
346
|
+
if court_id not in RELEASED and not allow_pending:
|
|
347
|
+
n, seen, bad = HELD_BACK.get(court_id, (0, 0, 0))
|
|
348
|
+
raise CourtNotReleased(
|
|
349
|
+
f"{court_id!r} is not released yet ({seen}/{n} records reviewed, "
|
|
350
|
+
f"{bad} marked bad). Pass allow_pending=True to read it anyway.")
|
|
351
|
+
path, is_temp = _as_path(src)
|
|
352
|
+
try:
|
|
353
|
+
result = extract(path, court_id)
|
|
354
|
+
finally:
|
|
355
|
+
if is_temp:
|
|
356
|
+
try:
|
|
357
|
+
_os.unlink(path)
|
|
358
|
+
except OSError:
|
|
359
|
+
pass
|
|
360
|
+
doc = result.document
|
|
361
|
+
out = {
|
|
362
|
+
"status": result.status,
|
|
363
|
+
"court_id": court_id,
|
|
364
|
+
"versions": dict(result.versions),
|
|
365
|
+
"cluster": _cluster(doc),
|
|
366
|
+
"opinions": _opinions(doc),
|
|
367
|
+
"headmatter": _headmatter(doc),
|
|
368
|
+
# THE SIBLING SECTIONS, words and markup both. `attorneys` is the
|
|
369
|
+
# appearances block — the counsel the page prints, which `cluster`
|
|
370
|
+
# carries only as the court's own one-line summary.
|
|
371
|
+
# ENDMATTER IS THE APPEARANCES, and it is role-bearing rows like the
|
|
372
|
+
# cover — not prose. `sections.py` names it 'endmatter' over
|
|
373
|
+
# `doc.attorneys`; flattened in with the flowing sections its roles
|
|
374
|
+
# were lost (the user, 2026-08-21: 'also endmatter is a thing').
|
|
375
|
+
"endmatter": _hm_block(doc.attorneys,
|
|
376
|
+
render_hm_items(doc.attorneys)),
|
|
377
|
+
# The sections that really are flowing prose.
|
|
378
|
+
"sections": {name: {"text": [_bare(getattr(b, "text", ""))
|
|
379
|
+
for b in getattr(doc, name)],
|
|
380
|
+
"html": [getattr(b, "text", "")
|
|
381
|
+
for b in getattr(doc, name)]}
|
|
382
|
+
for name in ("syllabus", "headnotes", "summary",
|
|
383
|
+
"signature", "trailer")
|
|
384
|
+
if getattr(doc, name)},
|
|
385
|
+
"removed": [{"kind": d.kind, "page": d.prov.page, "text": d.text}
|
|
386
|
+
for d in doc.dropped],
|
|
387
|
+
"diagnostics": _diagnostics(doc, result.status),
|
|
388
|
+
"html": render_body(doc),
|
|
389
|
+
"review_html": render_html(doc),
|
|
390
|
+
"casebody": render_casebody(doc),
|
|
391
|
+
"warnings": list(doc.warnings),
|
|
392
|
+
}
|
|
393
|
+
if include_document:
|
|
394
|
+
out["document"] = doc
|
|
395
|
+
return out
|
centralia/audit.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Coverage + correctness auditing.
|
|
2
|
+
|
|
3
|
+
This module starts with the NORMALIZATION layer, ported verbatim in behavior
|
|
4
|
+
from the old repo's ``centralia/audit.py`` (`_norm` and helpers). Those rules
|
|
5
|
+
are battle-tested against the full corpus: ligatures, spaced letterforms,
|
|
6
|
+
hyphen/dash deletion, box-drawing glyphs, footnote-mark glyphs (including
|
|
7
|
+
Symbol-font Private Use codepoints), the Hawaiian ʻokina's five spellings, and
|
|
8
|
+
the renderer's five XML escapes. Every rule is applied to BOTH sides of a
|
|
9
|
+
match, so nothing can hide behind it.
|
|
10
|
+
|
|
11
|
+
Coverage and correctness gates are added in Phase 2 — they iterate the typed
|
|
12
|
+
model via ``sections.iter_text`` instead of sentinel-sniffing dicts.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
|
|
20
|
+
_KNOWN_TAGS = frozenset(
|
|
21
|
+
# 'table'/'tr'/'th'/'td' appear when a footnote carries a printed table;
|
|
22
|
+
# 'centered'/'flushright' are alignment markers — layout, not content.
|
|
23
|
+
("em", "strong", "u", "footnotemark", "pagenumber", "sup", "sub",
|
|
24
|
+
"table", "tr", "th", "td", "centered", "flushright")
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def strip_tags(s: str) -> str:
|
|
29
|
+
"""Remove the extractor's inline markup tags ONLY. Literal angle-bracket
|
|
30
|
+
source text ('<the insurer will indemnify ...>' in a quoted policy) is
|
|
31
|
+
content, not markup, and must survive on both sides of the match."""
|
|
32
|
+
out, i = [], 0
|
|
33
|
+
while True:
|
|
34
|
+
j = s.find("<", i)
|
|
35
|
+
if j == -1:
|
|
36
|
+
out.append(s[i:])
|
|
37
|
+
break
|
|
38
|
+
k = s.find(">", j)
|
|
39
|
+
name = ""
|
|
40
|
+
if k != -1:
|
|
41
|
+
# An EMPTY pair of brackets is source text, not markup — a redacted
|
|
42
|
+
# span typed as '<>' or '< >' leaves nothing between them.
|
|
43
|
+
parts = s[j + 1 : k].strip("/").split()
|
|
44
|
+
if parts:
|
|
45
|
+
name = parts[0].split("=")[0].lower()
|
|
46
|
+
if k != -1 and name in _KNOWN_TAGS:
|
|
47
|
+
out.append(s[i:j])
|
|
48
|
+
i = k + 1
|
|
49
|
+
else:
|
|
50
|
+
out.append(s[i : j + 1])
|
|
51
|
+
i = j + 1
|
|
52
|
+
return "".join(out)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def unescape_xml(s: str) -> str:
|
|
56
|
+
"""Reverse only the XML escapes the renderer produces. ``html.unescape``
|
|
57
|
+
would also fire on legacy no-semicolon entities inside real source text
|
|
58
|
+
('TENN.COMP.R.®S.' → 'TENN.COMP.R.®S.'), breaking coverage matching.
|
|
59
|
+
'&' is reversed last so escaped-escape sequences don't double-decode."""
|
|
60
|
+
for ent, ch in (("<", "<"), (">", ">"), (""", '"'),
|
|
61
|
+
("'", "'"), ("&", "&")):
|
|
62
|
+
s = s.replace(ent, ch)
|
|
63
|
+
return s
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_LIGATURES = (("ff", "ff"), ("fi", "fi"), ("fl", "fl"), ("ffi", "ffi"), ("ffl", "ffl"))
|
|
67
|
+
|
|
68
|
+
# The Hawaiian ʻokina has no single encoding across the corpus: extractors keep
|
|
69
|
+
# 'HAWAIʻI' while extract_text reports the same glyph as a SPACE ('HAWAI I'),
|
|
70
|
+
# and other files spell it '‘', '#' or the unmapped '(cid:35)'. norm() removes
|
|
71
|
+
# whitespace anyway, so deleting the mark makes every spelling converge on
|
|
72
|
+
# 'HAWAII' — applied to both sides, it only reconciles the same word with
|
|
73
|
+
# itself.
|
|
74
|
+
_OKINA = ("(cid:35)", "ʻ", "‘", "ʼ", "`")
|
|
75
|
+
|
|
76
|
+
# Punctuation that PDF text layers spell inconsistently between the extractor's
|
|
77
|
+
# output and extract_text: not significant for coverage matching.
|
|
78
|
+
_DASHES = ("-", "—", "–", "―", "‒", "‑")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _is_box_glyph(c: str) -> bool:
|
|
82
|
+
"""Unicode Box Drawing (U+2500–U+257F): courts that draw the caption box
|
|
83
|
+
with glyphs emit these on the text layer; they are layout, never prose."""
|
|
84
|
+
return "─" <= c <= "╿"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _is_mark_glyph(c: str) -> bool:
|
|
88
|
+
"""A footnote MARK glyph — the star family, daggers, section/pilcrow, and
|
|
89
|
+
the Private Use codepoints a Symbol-font asterisk arrives as
|
|
90
|
+
(U+F000–U+F0FF). A note's label is lifted into ``Footnote.label`` and a
|
|
91
|
+
reference is wrapped in <footnotemark>, so the glyph on the source line
|
|
92
|
+
frequently has no counterpart at all in the kept text."""
|
|
93
|
+
return c in "*∗⁎﹡*†‡§¶" or "\uf000" <= c <= "\uf0ff"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_SPACED_LETTERFORMS = re.compile(r"\b(?:[a-z]-){2,}[a-z]-?", re.IGNORECASE)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def norm(s: str) -> str:
|
|
100
|
+
"""Whitespace-removed, tag-stripped, unescaped, ligature-expanded,
|
|
101
|
+
lowercased — one text layer keeps 'Plaintiff' where another says
|
|
102
|
+
'Plaintiff', so both sides expand."""
|
|
103
|
+
s = unescape_xml(strip_tags(s))
|
|
104
|
+
for mark in _OKINA:
|
|
105
|
+
if mark in s:
|
|
106
|
+
s = s.replace(mark, "")
|
|
107
|
+
# A few embedded fonts expose spaced letterforms as ``a-n-y-`` while the
|
|
108
|
+
# page text layer reports ``any``; ordinary compounds are unaffected.
|
|
109
|
+
s = _SPACED_LETTERFORMS.sub(lambda m: m.group(0).replace("-", ""), s)
|
|
110
|
+
# Discretionary hyphenation and the inline-byline em-dash both leave a dash
|
|
111
|
+
# on exactly one side of the match; punctuation, not content.
|
|
112
|
+
for dash in _DASHES:
|
|
113
|
+
if dash in s:
|
|
114
|
+
s = s.replace(dash, "")
|
|
115
|
+
if any(_is_box_glyph(c) for c in s):
|
|
116
|
+
s = "".join(c for c in s if not _is_box_glyph(c))
|
|
117
|
+
if any(_is_mark_glyph(c) for c in s):
|
|
118
|
+
s = "".join(c for c in s if not _is_mark_glyph(c))
|
|
119
|
+
for lig, exp in _LIGATURES:
|
|
120
|
+
if lig in s:
|
|
121
|
+
s = s.replace(lig, exp)
|
|
122
|
+
return "".join(s.split()).lower()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# --------------------------------------------------------------------------
|
|
126
|
+
# coverage — every source line lands somewhere
|
|
127
|
+
# --------------------------------------------------------------------------
|
|
128
|
+
#
|
|
129
|
+
# Unlike the old system, coverage consumes the SAME PdfModel the extraction
|
|
130
|
+
# read (no second parse with re-applied geometry corrections): ground truth
|
|
131
|
+
# and output share one line model by construction.
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class AuditResult:
|
|
135
|
+
total: int = 0
|
|
136
|
+
missing: list = field(default_factory=list) # (page, text) — the failure
|
|
137
|
+
dropped: list = field(default_factory=list) # matched the Removed box
|
|
138
|
+
furniture: list = field(default_factory=list) # identified by shape
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def covered(self) -> int:
|
|
142
|
+
return self.total - len(self.missing)
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def ok(self) -> bool:
|
|
146
|
+
return not self.missing
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _is_folio_text(text: str) -> bool:
|
|
150
|
+
t = text.strip()
|
|
151
|
+
if t.lower().startswith("page "):
|
|
152
|
+
t = t[5:].strip()
|
|
153
|
+
core = t.strip("-–— ")
|
|
154
|
+
return core.isdigit() and len(core) <= 4
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _is_rule_text(text: str) -> bool:
|
|
158
|
+
bare = (text or "").strip().strip("xX ").strip()
|
|
159
|
+
return len(bare) >= 3 and set(bare) <= set("_-—–=* ")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def coverage(doc, pdf_model) -> AuditResult:
|
|
163
|
+
"""Line-coverage proof. Remember: this is the WEAK signal (see
|
|
164
|
+
docs/lessons/coverage-vs-correctness.md) — the correctness gates in the
|
|
165
|
+
harness compare opinion counts and per-section word mass."""
|
|
166
|
+
from .sections import SECTIONS, section_text
|
|
167
|
+
|
|
168
|
+
kept = "".join(
|
|
169
|
+
norm(chunk)
|
|
170
|
+
for spec in SECTIONS if spec.audited
|
|
171
|
+
for chunk in section_text(doc, spec)
|
|
172
|
+
)
|
|
173
|
+
dropped_hay = "".join(norm(d.text) for d in doc.dropped)
|
|
174
|
+
|
|
175
|
+
result = AuditResult()
|
|
176
|
+
for line in pdf_model.all_lines():
|
|
177
|
+
plain = line.plain
|
|
178
|
+
t = norm(plain)
|
|
179
|
+
if not t:
|
|
180
|
+
continue # blanks, box glyphs, bare dashes normalize away
|
|
181
|
+
result.total += 1
|
|
182
|
+
if t in kept:
|
|
183
|
+
continue
|
|
184
|
+
if _is_folio_text(plain) or _is_rule_text(plain):
|
|
185
|
+
result.furniture.append((line.page, plain))
|
|
186
|
+
continue
|
|
187
|
+
if t in dropped_hay:
|
|
188
|
+
result.dropped.append((line.page, plain))
|
|
189
|
+
continue
|
|
190
|
+
result.missing.append((line.page, plain))
|
|
191
|
+
return result
|