docketry 0.1.0__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.
docketry/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Docketry: a local, gate-enforced port that email flows into.
2
+
3
+ Working name — Docketry is the base layer of a family of small open-source
4
+ legal workflow tools. It never reaches into a mailbox the firm works from;
5
+ it drains a dedicated intake mailbox the firm forwards into, normalizes each
6
+ message, and enforces guardrail gates before anything moves downstream.
7
+ """
8
+ __version__ = "0.1.0"
docketry/cite.py ADDED
@@ -0,0 +1,253 @@
1
+ """Citation verification: the existence + name + quote + pin check.
2
+
3
+ Four factual checks against CourtListener, and nothing else:
4
+
5
+ 1. The cited case exists (volume/reporter/page resolves).
6
+ 2. The case NAME matches what that reporter cite actually resolves to —
7
+ the hallucination class where a real citation carries an invented name.
8
+ 3. Quoted language attributed to the case actually appears in the opinion.
9
+ 4. A pin cite points at the page where the quoted language sits (via the
10
+ opinion's star pagination, when the source provides it).
11
+
12
+ This module never says anything is good law, current, binding, or apt. It
13
+ reports that a citation string does not match the document it points to, and
14
+ it degrades loudly: no network means extraction-only, never a silent pass.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from dataclasses import dataclass, field
20
+
21
+ _GENERIC_TOKENS = {
22
+ "state", "company", "companies", "insurance", "corp", "corporation",
23
+ "inc", "llc", "llp", "co", "of", "the", "and", "in", "re", "ex", "rel",
24
+ "et", "al", "v", "vs", "a", "an", "county", "city", "board", "dept",
25
+ "department", "america", "american", "national", "united", "states",
26
+ }
27
+
28
+ _QUOTE_RX = re.compile(r'[“"]([^“”"]{25,}?)[”"]')
29
+ _STAR_RX = re.compile(r'<span[^>]*class="star-pagination"[^>]*label="(\d+)"[^>]*>')
30
+ _TAG_RX = re.compile(r"<[^>]+>")
31
+
32
+
33
+ class CiteError(RuntimeError):
34
+ pass
35
+
36
+
37
+ @dataclass
38
+ class Citation:
39
+ text: str # the citation as written, e.g. "260 So. 3d 323"
40
+ plaintiff: str
41
+ defendant: str
42
+ pin_page: int | None
43
+ span: tuple[int, int] # character span in the source text
44
+
45
+
46
+ @dataclass
47
+ class Lookup:
48
+ exists: bool
49
+ case_name: str = ""
50
+ cluster_id: int | None = None
51
+
52
+
53
+ @dataclass
54
+ class CiteFinding:
55
+ citation: str
56
+ check: str # exists | name | quote | pin
57
+ severity: str # fail | warn | info
58
+ summary: str
59
+
60
+
61
+ @dataclass
62
+ class Report:
63
+ citations: list[Citation]
64
+ short_citations: int = 0
65
+ findings: list[CiteFinding] = field(default_factory=list)
66
+
67
+ @property
68
+ def failed(self) -> bool:
69
+ return any(f.severity == "fail" for f in self.findings)
70
+
71
+
72
+ def _normalize(s: str) -> str:
73
+ s = s.replace("“", '"').replace("”", '"')
74
+ s = s.replace("‘", "'").replace("’", "'")
75
+ return re.sub(r"\s+", " ", s).strip().lower()
76
+
77
+
78
+ def _tokens(name: str) -> set[str]:
79
+ return {
80
+ t for t in re.findall(r"[a-z0-9]+", name.lower())
81
+ if len(t) > 2 and t not in _GENERIC_TOKENS
82
+ }
83
+
84
+
85
+ def extract_citations(text: str) -> list[Citation]:
86
+ """Full case citations via eyecite. Needs the 'cite' extra."""
87
+ return _extract(text)[0]
88
+
89
+
90
+ def citation_inventory(text: str) -> tuple[list[Citation], int]:
91
+ """(full citations, count of short-form/supra/id citations).
92
+
93
+ Short-form cites ("329 So. 3d at 153") depend on an antecedent full cite;
94
+ when the fulls live outside the document (a redline, an excerpt, a brief
95
+ section), the shorts are UNVERIFIABLE AS WRITTEN — and that must be said
96
+ loudly, never reported as "0 citations found".
97
+ """
98
+ return _extract(text)
99
+
100
+
101
+ def _extract(text: str) -> tuple[list[Citation], int]:
102
+ try:
103
+ import eyecite
104
+ from eyecite.models import (
105
+ FullCaseCitation, IdCitation, ShortCaseCitation, SupraCitation,
106
+ )
107
+ except ImportError:
108
+ raise CiteError(
109
+ "citation extraction needs the 'cite' extra:"
110
+ " pip install 'docketry[cite]'"
111
+ ) from None
112
+
113
+ out: list[Citation] = []
114
+ n_short = 0
115
+ for cite in eyecite.get_citations(text):
116
+ if isinstance(cite, (ShortCaseCitation, SupraCitation, IdCitation)):
117
+ n_short += 1
118
+ continue
119
+ if not isinstance(cite, FullCaseCitation):
120
+ continue
121
+ meta = cite.metadata
122
+ pin = None
123
+ if meta.pin_cite:
124
+ m = re.search(r"\d+", meta.pin_cite)
125
+ pin = int(m.group()) if m else None
126
+ out.append(
127
+ Citation(
128
+ text=cite.corrected_citation(),
129
+ plaintiff=meta.plaintiff or "",
130
+ defendant=meta.defendant or "",
131
+ pin_page=pin,
132
+ span=cite.span(),
133
+ )
134
+ )
135
+ return out, n_short
136
+
137
+
138
+ def name_matches(plaintiff: str, defendant: str, resolved_name: str) -> bool:
139
+ """Side-aware significant-token check of the cited name vs the resolved one.
140
+
141
+ Every cited side that has significant tokens must land at least one of
142
+ them in the corresponding side of the resolved caption (either order, to
143
+ tolerate cross-appeals); a consolidated/inre caption without "v." is
144
+ checked against the whole name.
145
+ """
146
+ cited = [(i, s) for i, s in enumerate((_tokens(plaintiff), _tokens(defendant))) if s]
147
+ if not cited:
148
+ return True # nothing asserted, nothing to contradict
149
+ resolved_sides = [
150
+ _tokens(part) for part in re.split(r"\s+v\.?\s+", resolved_name, maxsplit=1)
151
+ ]
152
+ if len(resolved_sides) == 2:
153
+ if all(toks & resolved_sides[i] for i, toks in cited):
154
+ return True
155
+ # A full swap is a legitimate cross-appeal caption; a single cited
156
+ # side matching only the OPPOSITE side is the classic wrong-name
157
+ # hallucination (e.g. the defendant's surname promoted to plaintiff),
158
+ # so the swap is only honored when BOTH sides swap cleanly.
159
+ if len(cited) == 2 and all(toks & resolved_sides[1 - i] for i, toks in cited):
160
+ return True
161
+ return False
162
+ whole = _tokens(resolved_name)
163
+ return all(toks & whole for _, toks in cited)
164
+
165
+
166
+ def quotes_near(text: str, span: tuple[int, int], *, window: int = 600) -> list[str]:
167
+ """Quoted passages (>=25 chars) in the window before the citation."""
168
+ region = text[max(0, span[0] - window):span[0]]
169
+ return [m.group(1) for m in _QUOTE_RX.finditer(region)]
170
+
171
+
172
+ def star_pages(opinion_html: str) -> list[tuple[int, str]]:
173
+ """(page_label, normalized text of the segment that FOLLOWS it)."""
174
+ out: list[tuple[int, str]] = []
175
+ matches = list(_STAR_RX.finditer(opinion_html))
176
+ for i, m in enumerate(matches):
177
+ end = matches[i + 1].start() if i + 1 < len(matches) else len(opinion_html)
178
+ segment = _TAG_RX.sub(" ", opinion_html[m.end():end])
179
+ out.append((int(m.group(1)), _normalize(segment)))
180
+ return out
181
+
182
+
183
+ def verify(text: str, client) -> Report:
184
+ """Run the four checks over every full citation in `text`.
185
+
186
+ `client` provides lookup(citation_text) -> Lookup and
187
+ opinion_text(cluster_id) -> (plain_text, html_or_None).
188
+ """
189
+ citations, n_short = citation_inventory(text)
190
+ report = Report(citations=citations, short_citations=n_short)
191
+ if n_short and not citations:
192
+ report.findings.append(CiteFinding(
193
+ "(short-form)", "exists", "fail",
194
+ f"{n_short} short-form citation(s) (e.g. '329 So. 3d at 153') with"
195
+ " no full citation anywhere in this document — unverifiable as"
196
+ " written; a filed brief must carry the full cites",
197
+ ))
198
+ elif n_short:
199
+ report.findings.append(CiteFinding(
200
+ "(short-form)", "exists", "info",
201
+ f"{n_short} short-form citation(s) ride on the full citations"
202
+ " verified above",
203
+ ))
204
+ for cite in citations:
205
+ lookup: Lookup = client.lookup(cite.text)
206
+ if not lookup.exists:
207
+ report.findings.append(
208
+ CiteFinding(cite.text, "exists", "fail",
209
+ f"{cite.text}: no case found at this citation")
210
+ )
211
+ continue
212
+ cited_name = f"{cite.plaintiff} v. {cite.defendant}".strip(" v.")
213
+ if not name_matches(cite.plaintiff, cite.defendant, lookup.case_name):
214
+ report.findings.append(
215
+ CiteFinding(
216
+ cite.text, "name", "fail",
217
+ f"cited as \"{cited_name}\" but {cite.text} resolves to"
218
+ f" \"{lookup.case_name}\"",
219
+ )
220
+ )
221
+ quotes = quotes_near(text, cite.span)
222
+ if quotes and lookup.cluster_id is not None:
223
+ opinion_plain, opinion_html = client.opinion_text(lookup.cluster_id)
224
+ norm_opinion = _normalize(opinion_plain)
225
+ pages = star_pages(opinion_html) if opinion_html else []
226
+ for q in quotes:
227
+ nq = _normalize(q).rstrip(".;:,!?")
228
+ if nq not in norm_opinion and not any(nq in seg for _, seg in pages):
229
+ report.findings.append(
230
+ CiteFinding(
231
+ cite.text, "quote", "fail",
232
+ f"quoted language not found in {lookup.case_name}:"
233
+ f" \"{q[:80]}...\"" if len(q) > 80 else
234
+ f"quoted language not found in {lookup.case_name}:"
235
+ f" \"{q}\"",
236
+ )
237
+ )
238
+ elif cite.pin_page is not None and pages:
239
+ hit = [label for label, seg in pages if nq in seg]
240
+ if hit and cite.pin_page not in hit:
241
+ report.findings.append(
242
+ CiteFinding(
243
+ cite.text, "pin", "warn",
244
+ f"pin cite says p. {cite.pin_page} but the"
245
+ f" quoted language sits at p. {hit[0]}",
246
+ )
247
+ )
248
+ if not report.findings or report.findings[-1].citation != cite.text:
249
+ report.findings.append(
250
+ CiteFinding(cite.text, "exists", "info",
251
+ f"{cite.text} -> {lookup.case_name}")
252
+ )
253
+ return report
@@ -0,0 +1,68 @@
1
+ """CourtListener client for citation verification. Needs the 'cite' extra.
2
+
3
+ Thin and honest: the user's own token (COURTLISTENER_TOKEN), tight timeouts,
4
+ and every network failure surfaces as CiteError — offline degrades to
5
+ extraction-only in the CLI, never to a silent pass.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+
11
+ from .cite import CiteError, Lookup
12
+
13
+ BASE = "https://www.courtlistener.com/api/rest/v4"
14
+
15
+
16
+ class CourtListenerClient:
17
+ def __init__(self, token: str | None = None, base_url: str = BASE, timeout: float = 30.0):
18
+ try:
19
+ import httpx
20
+ except ImportError:
21
+ raise CiteError(
22
+ "network verification needs the 'cite' extra:"
23
+ " pip install 'docketry[cite]'"
24
+ ) from None
25
+ headers = {"User-Agent": "docketry cite-verify"}
26
+ token = token or os.environ.get("COURTLISTENER_TOKEN")
27
+ if token:
28
+ headers["Authorization"] = f"Token {token}"
29
+ self._http = httpx.Client(base_url=base_url, headers=headers, timeout=timeout)
30
+
31
+ def close(self) -> None:
32
+ self._http.close()
33
+
34
+ def lookup(self, citation_text: str) -> Lookup:
35
+ try:
36
+ resp = self._http.post("/citation-lookup/", data={"text": citation_text})
37
+ resp.raise_for_status()
38
+ results = resp.json()
39
+ except Exception as e:
40
+ raise CiteError(f"citation lookup failed for {citation_text!r}: {e}") from e
41
+ for row in results:
42
+ if row.get("status") == 200 and row.get("clusters"):
43
+ cluster = row["clusters"][0]
44
+ return Lookup(
45
+ exists=True,
46
+ case_name=cluster.get("case_name", ""),
47
+ cluster_id=cluster.get("id"),
48
+ )
49
+ return Lookup(exists=False)
50
+
51
+ def opinion_text(self, cluster_id: int) -> tuple[str, str | None]:
52
+ try:
53
+ resp = self._http.get(
54
+ "/opinions/", params={"cluster__id": cluster_id, "fields": "plain_text,xml_harvard,html_with_citations"}
55
+ )
56
+ resp.raise_for_status()
57
+ rows = resp.json().get("results", [])
58
+ except Exception as e:
59
+ raise CiteError(f"opinion fetch failed for cluster {cluster_id}: {e}") from e
60
+ if not rows:
61
+ return "", None
62
+ op = rows[0]
63
+ html = op.get("xml_harvard") or op.get("html_with_citations") or None
64
+ plain = op.get("plain_text") or ""
65
+ if not plain and html:
66
+ import re
67
+ plain = re.sub(r"<[^>]+>", " ", html)
68
+ return plain, html
docketry/classify.py ADDED
@@ -0,0 +1,60 @@
1
+ """Deterministic litigation-document classifier.
2
+
3
+ Types a filed document from its title first (high confidence), body anchors
4
+ second (medium), and falls back to correspondence (low). No model calls, no
5
+ network, no cost — LLMs are a last resort that this module deliberately is
6
+ not. The classifier only ever PROPOSES: writes are staged for approval and
7
+ applied fill-only by a human with the declared authority (see store/CLI).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ LABELS = (
14
+ "amended_complaint", "complaint", "answer",
15
+ "motion_msj", "motion_dismiss", "motion_compel", "motion",
16
+ "order", "notice_of_hearing", "notice",
17
+ "discovery_request", "discovery_response",
18
+ "subpoena", "deposition", "correspondence",
19
+ )
20
+
21
+ _T = [ # title anchors, ordered: first match wins
22
+ ("amended_complaint", r"amended\s+(?:complaint|petition)"),
23
+ ("order", r"^(?:agreed\s+|proposed\s+)?order\b|order\s+(?:granting|denying|on|setting)"),
24
+ ("motion_msj", r"motion\s+for\s+(?:final\s+)?summary\s+judgment"),
25
+ ("motion_dismiss", r"motion\s+to\s+dismiss"),
26
+ ("motion_compel", r"motion\s+to\s+compel"),
27
+ ("deposition", r"(?:notice\s+of\s+taking\s+)?deposition\s+of|depo(?:sition)?\s+transcript"),
28
+ ("notice_of_hearing", r"notice\s+of\s+hearing"),
29
+ ("subpoena", r"subpoena"),
30
+ ("discovery_response", r"(?:response|objection)s?\s+to\s+.*(?:interrogator|production|admission|discovery)"),
31
+ ("discovery_request", r"interrogator|request\s+(?:for|to)\s+produc|request\s+for\s+admission"),
32
+ ("answer", r"\banswer\b.*affirmative\s+defenses|\banswer\s+to\b|defendant'?s?\s+answer"),
33
+ ("motion", r"\bmotion\b"),
34
+ ("complaint", r"\b(?:complaint|petition)\b"),
35
+ ("notice", r"\bnotice\b"),
36
+ ]
37
+ _TITLE_ANCHORS = [(label, re.compile(rx, re.IGNORECASE)) for label, rx in _T]
38
+
39
+ _B = [ # body anchors, checked in the first 3000 chars
40
+ ("order", r"ORDERED\s+AND\s+ADJUDGED|it\s+is\s+hereby\s+ORDERED"),
41
+ ("complaint", r"COMES\s+NOW.{0,200}(?:complaint|sues?\s+defendant)"),
42
+ ("discovery_request", r"propounds?\s+the\s+following\s+interrogator"),
43
+ ("answer", r"answers?\s+the\s+complaint\s+and\s+(?:asserts?\s+)?affirmative\s+defenses"),
44
+ ("deposition", r"APPEARANCES.{0,600}(?:COURT\s+REPORTER|STENOGRAPH)"),
45
+ ]
46
+ _BODY_ANCHORS = [(label, re.compile(rx, re.IGNORECASE | re.DOTALL)) for label, rx in _B]
47
+
48
+
49
+ def classify(title: str, text: str = "") -> tuple[str, str]:
50
+ """(label, tier) — tier: high (title anchor) / medium (body) / low."""
51
+ title = (title or "").replace("_", " ").replace("-", " ")
52
+ for label, rx in _TITLE_ANCHORS:
53
+ if rx.search(title):
54
+ return label, "high"
55
+ head = (text or "")[:3000]
56
+ if head:
57
+ for label, rx in _BODY_ANCHORS:
58
+ if rx.search(head):
59
+ return label, "medium"
60
+ return "correspondence", "low"