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.
Files changed (277) hide show
  1. centralia/__init__.py +395 -0
  2. centralia/audit.py +191 -0
  3. centralia/classify.py +317 -0
  4. centralia/courts/__init__.py +1013 -0
  5. centralia/courts/acca.py +537 -0
  6. centralia/courts/afcca.py +962 -0
  7. centralia/courts/akd.py +40 -0
  8. centralia/courts/ala.py +805 -0
  9. centralia/courts/alacivapp.py +482 -0
  10. centralia/courts/alacrimapp.py +632 -0
  11. centralia/courts/alaska.py +944 -0
  12. centralia/courts/alaskactapp.py +801 -0
  13. centralia/courts/almd.py +37 -0
  14. centralia/courts/alnd.py +44 -0
  15. centralia/courts/alsd.py +44 -0
  16. centralia/courts/ared.py +44 -0
  17. centralia/courts/ariz.py +582 -0
  18. centralia/courts/arizctapp.py +788 -0
  19. centralia/courts/ark.py +572 -0
  20. centralia/courts/arkctapp.py +636 -0
  21. centralia/courts/armfor.py +671 -0
  22. centralia/courts/arwd.py +44 -0
  23. centralia/courts/asbca.py +695 -0
  24. centralia/courts/azd.py +44 -0
  25. centralia/courts/bap1.py +641 -0
  26. centralia/courts/bap10.py +973 -0
  27. centralia/courts/bap6.py +1119 -0
  28. centralia/courts/bap8.py +652 -0
  29. centralia/courts/bap9.py +768 -0
  30. centralia/courts/bia.py +495 -0
  31. centralia/courts/ca1.py +481 -0
  32. centralia/courts/ca10.py +818 -0
  33. centralia/courts/ca11.py +554 -0
  34. centralia/courts/ca2.py +1462 -0
  35. centralia/courts/ca3.py +675 -0
  36. centralia/courts/ca4.py +678 -0
  37. centralia/courts/ca5.py +664 -0
  38. centralia/courts/ca6.py +989 -0
  39. centralia/courts/ca7.py +1008 -0
  40. centralia/courts/ca8.py +560 -0
  41. centralia/courts/ca9.py +1101 -0
  42. centralia/courts/cacd.py +44 -0
  43. centralia/courts/cadc.py +833 -0
  44. centralia/courts/caed.py +44 -0
  45. centralia/courts/cafc.py +1017 -0
  46. centralia/courts/cal.py +822 -0
  47. centralia/courts/calag.py +543 -0
  48. centralia/courts/calctapp.py +1091 -0
  49. centralia/courts/cand.py +44 -0
  50. centralia/courts/casd.py +44 -0
  51. centralia/courts/cavc.py +635 -0
  52. centralia/courts/cit.py +968 -0
  53. centralia/courts/cod.py +59 -0
  54. centralia/courts/coloctapp.py +603 -0
  55. centralia/courts/conn.py +531 -0
  56. centralia/courts/connappct.py +566 -0
  57. centralia/courts/ctd.py +44 -0
  58. centralia/courts/dc.py +730 -0
  59. centralia/courts/dcd.py +46 -0
  60. centralia/courts/ded.py +44 -0
  61. centralia/courts/del.py +529 -0
  62. centralia/courts/delch.py +1022 -0
  63. centralia/courts/delctcompl.py +546 -0
  64. centralia/courts/delsuperct.py +1184 -0
  65. centralia/courts/fla.py +606 -0
  66. centralia/courts/fladistctapp.py +755 -0
  67. centralia/courts/flmd.py +44 -0
  68. centralia/courts/flnd.py +44 -0
  69. centralia/courts/flsd.py +44 -0
  70. centralia/courts/ga.py +663 -0
  71. centralia/courts/gactapp.py +647 -0
  72. centralia/courts/gamd.py +44 -0
  73. centralia/courts/gand.py +44 -0
  74. centralia/courts/gasd.py +44 -0
  75. centralia/courts/guam.py +576 -0
  76. centralia/courts/haw.py +1281 -0
  77. centralia/courts/hawapp.py +1237 -0
  78. centralia/courts/hid.py +44 -0
  79. centralia/courts/iand.py +44 -0
  80. centralia/courts/iasd.py +44 -0
  81. centralia/courts/idaho.py +949 -0
  82. centralia/courts/idahoctapp.py +860 -0
  83. centralia/courts/idd.py +44 -0
  84. centralia/courts/ilcd.py +44 -0
  85. centralia/courts/ill.py +604 -0
  86. centralia/courts/illappct.py +1190 -0
  87. centralia/courts/ilnd.py +44 -0
  88. centralia/courts/ilsd.py +44 -0
  89. centralia/courts/ind.py +724 -0
  90. centralia/courts/indctapp.py +792 -0
  91. centralia/courts/indtc.py +575 -0
  92. centralia/courts/innd.py +44 -0
  93. centralia/courts/insd.py +44 -0
  94. centralia/courts/iowa.py +601 -0
  95. centralia/courts/iowactapp.py +642 -0
  96. centralia/courts/kan.py +562 -0
  97. centralia/courts/kanctapp.py +597 -0
  98. centralia/courts/ksd.py +44 -0
  99. centralia/courts/ky.py +391 -0
  100. centralia/courts/kyctapp.py +480 -0
  101. centralia/courts/kyed.py +53 -0
  102. centralia/courts/kywd.py +44 -0
  103. centralia/courts/la.py +692 -0
  104. centralia/courts/lactapp.py +681 -0
  105. centralia/courts/laed.py +44 -0
  106. centralia/courts/lamd.py +44 -0
  107. centralia/courts/lawd.py +44 -0
  108. centralia/courts/mad.py +44 -0
  109. centralia/courts/mass.py +655 -0
  110. centralia/courts/massappct.py +751 -0
  111. centralia/courts/md.py +897 -0
  112. centralia/courts/mdag.py +379 -0
  113. centralia/courts/mdctspecapp.py +902 -0
  114. centralia/courts/mdd.py +44 -0
  115. centralia/courts/me.py +484 -0
  116. centralia/courts/med.py +44 -0
  117. centralia/courts/mesuperct.py +559 -0
  118. centralia/courts/mich.py +698 -0
  119. centralia/courts/michctapp.py +572 -0
  120. centralia/courts/mied.py +44 -0
  121. centralia/courts/minn.py +352 -0
  122. centralia/courts/minnag.py +652 -0
  123. centralia/courts/minnctapp.py +452 -0
  124. centralia/courts/miss.py +455 -0
  125. centralia/courts/missctapp.py +405 -0
  126. centralia/courts/miwd.py +44 -0
  127. centralia/courts/mnd.py +44 -0
  128. centralia/courts/mo.py +800 -0
  129. centralia/courts/moctapp.py +830 -0
  130. centralia/courts/moed.py +44 -0
  131. centralia/courts/mont.py +333 -0
  132. centralia/courts/mowd.py +44 -0
  133. centralia/courts/msnd.py +44 -0
  134. centralia/courts/mspb.py +425 -0
  135. centralia/courts/mssd.py +44 -0
  136. centralia/courts/mtd.py +44 -0
  137. centralia/courts/nc.py +622 -0
  138. centralia/courts/ncbizct.py +856 -0
  139. centralia/courts/ncctapp.py +692 -0
  140. centralia/courts/nced.py +44 -0
  141. centralia/courts/ncmd.py +44 -0
  142. centralia/courts/ncwd.py +44 -0
  143. centralia/courts/nd.py +682 -0
  144. centralia/courts/ndd.py +44 -0
  145. centralia/courts/neb.py +546 -0
  146. centralia/courts/nebctapp.py +905 -0
  147. centralia/courts/ned.py +44 -0
  148. centralia/courts/nev.py +659 -0
  149. centralia/courts/nevapp.py +448 -0
  150. centralia/courts/nh.py +393 -0
  151. centralia/courts/nhd.py +44 -0
  152. centralia/courts/nj.py +883 -0
  153. centralia/courts/njd.py +44 -0
  154. centralia/courts/njsuperctappdiv.py +689 -0
  155. centralia/courts/njtaxct.py +703 -0
  156. centralia/courts/nm.py +496 -0
  157. centralia/courts/nmariana.py +357 -0
  158. centralia/courts/nmcca.py +778 -0
  159. centralia/courts/nmctapp.py +594 -0
  160. centralia/courts/nmd.py +44 -0
  161. centralia/courts/nvd.py +44 -0
  162. centralia/courts/nycivct.py +486 -0
  163. centralia/courts/nyed.py +44 -0
  164. centralia/courts/nyfamct.py +783 -0
  165. centralia/courts/nynd.py +44 -0
  166. centralia/courts/nysd.py +44 -0
  167. centralia/courts/nysupct.py +525 -0
  168. centralia/courts/nysurct.py +409 -0
  169. centralia/courts/nywd.py +44 -0
  170. centralia/courts/ohio.py +765 -0
  171. centralia/courts/ohioctapp.py +1506 -0
  172. centralia/courts/ohioctcl.py +754 -0
  173. centralia/courts/ohnd.py +44 -0
  174. centralia/courts/ohsd.py +44 -0
  175. centralia/courts/oked.py +44 -0
  176. centralia/courts/oknd.py +44 -0
  177. centralia/courts/okwd.py +44 -0
  178. centralia/courts/olc.py +466 -0
  179. centralia/courts/or.py +739 -0
  180. centralia/courts/orctapp.py +524 -0
  181. centralia/courts/ord.py +44 -0
  182. centralia/courts/ortc.py +877 -0
  183. centralia/courts/pa.py +734 -0
  184. centralia/courts/pacommwct.py +969 -0
  185. centralia/courts/paed.py +44 -0
  186. centralia/courts/pamd.py +44 -0
  187. centralia/courts/pasuperct.py +722 -0
  188. centralia/courts/pawd.py +44 -0
  189. centralia/courts/prapp.py +388 -0
  190. centralia/courts/prsupreme.py +353 -0
  191. centralia/courts/ri.py +952 -0
  192. centralia/courts/rid.py +44 -0
  193. centralia/courts/sc.py +738 -0
  194. centralia/courts/scctapp.py +776 -0
  195. centralia/courts/scd.py +44 -0
  196. centralia/courts/scotus.py +668 -0
  197. centralia/courts/sd.py +549 -0
  198. centralia/courts/sdd.py +44 -0
  199. centralia/courts/tax.py +460 -0
  200. centralia/courts/tenn.py +809 -0
  201. centralia/courts/tenncrimapp.py +892 -0
  202. centralia/courts/tennctapp.py +756 -0
  203. centralia/courts/tex.py +482 -0
  204. centralia/courts/texag.py +423 -0
  205. centralia/courts/texapp.py +849 -0
  206. centralia/courts/texbizct.py +942 -0
  207. centralia/courts/texcrimapp.py +674 -0
  208. centralia/courts/tned.py +44 -0
  209. centralia/courts/tnmd.py +44 -0
  210. centralia/courts/tnwd.py +44 -0
  211. centralia/courts/ttab.py +636 -0
  212. centralia/courts/txed.py +44 -0
  213. centralia/courts/txnd.py +44 -0
  214. centralia/courts/txsd.py +44 -0
  215. centralia/courts/txwd.py +44 -0
  216. centralia/courts/uscfc.py +692 -0
  217. centralia/courts/uscgcoca.py +706 -0
  218. centralia/courts/utah.py +870 -0
  219. centralia/courts/utahctapp.py +947 -0
  220. centralia/courts/utd.py +44 -0
  221. centralia/courts/va.py +688 -0
  222. centralia/courts/vactapp.py +552 -0
  223. centralia/courts/vaed.py +44 -0
  224. centralia/courts/vawd.py +44 -0
  225. centralia/courts/virginislands.py +966 -0
  226. centralia/courts/vt.py +733 -0
  227. centralia/courts/vtd.py +44 -0
  228. centralia/courts/vtsuperct.py +829 -0
  229. centralia/courts/waed.py +44 -0
  230. centralia/courts/wash.py +1020 -0
  231. centralia/courts/washctapp.py +1056 -0
  232. centralia/courts/wawd.py +44 -0
  233. centralia/courts/wied.py +44 -0
  234. centralia/courts/wis.py +631 -0
  235. centralia/courts/wisctapp.py +899 -0
  236. centralia/courts/wiwd.py +44 -0
  237. centralia/courts/wva.py +1162 -0
  238. centralia/courts/wvactapp.py +527 -0
  239. centralia/courts/wvnd.py +44 -0
  240. centralia/courts/wvsd.py +44 -0
  241. centralia/courts/wyd.py +44 -0
  242. centralia/courts/wyo.py +625 -0
  243. centralia/dates.py +125 -0
  244. centralia/districts/__init__.py +16 -0
  245. centralia/districts/ecf.py +2302 -0
  246. centralia/geometry.py +117 -0
  247. centralia/model.py +366 -0
  248. centralia/pdfio/__init__.py +12 -0
  249. centralia/pdfio/build.py +286 -0
  250. centralia/pdfio/model.py +239 -0
  251. centralia/pdfio/quirks.py +726 -0
  252. centralia/pdfio/rules.py +139 -0
  253. centralia/pdfio/tables.py +172 -0
  254. centralia/pdfio/text.py +170 -0
  255. centralia/pipeline.py +2688 -0
  256. centralia/profile.py +62 -0
  257. centralia/released.py +261 -0
  258. centralia/render/__init__.py +6 -0
  259. centralia/render/casebody.py +124 -0
  260. centralia/render/facsimile.py +74 -0
  261. centralia/render/html.py +562 -0
  262. centralia/render/inline.py +23 -0
  263. centralia/resolve/__init__.py +0 -0
  264. centralia/resolve/assemble.py +2673 -0
  265. centralia/resolve/bylines.py +1076 -0
  266. centralia/resolve/captions.py +257 -0
  267. centralia/resolve/evidence.py +157 -0
  268. centralia/resolve/footnotes.py +1239 -0
  269. centralia/resolve/furniture.py +605 -0
  270. centralia/resolve/headmatter.py +1089 -0
  271. centralia/resolve/segments.py +381 -0
  272. centralia/sections.py +133 -0
  273. centralia/settings.py +45 -0
  274. centralia/styles.py +56 -0
  275. centralia-0.0.1.dist-info/METADATA +60 -0
  276. centralia-0.0.1.dist-info/RECORD +277 -0
  277. 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.&REGS.' → 'TENN.COMP.R.®S.'), breaking coverage matching.
59
+ '&amp;' is reversed last so escaped-escape sequences don't double-decode."""
60
+ for ent, ch in (("&lt;", "<"), ("&gt;", ">"), ("&quot;", '"'),
61
+ ("&#39;", "'"), ("&amp;", "&")):
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