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 +8 -0
- docketry/cite.py +253 -0
- docketry/cite_client.py +68 -0
- docketry/classify.py +60 -0
- docketry/cli.py +634 -0
- docketry/config.py +77 -0
- docketry/envelope.py +166 -0
- docketry/extract.py +195 -0
- docketry/gates/__init__.py +28 -0
- docketry/gates/builtin.py +164 -0
- docketry/gates/classifier.py +28 -0
- docketry/gates/notice.py +59 -0
- docketry/lint.py +172 -0
- docketry/mailbox.py +77 -0
- docketry/manifest.py +105 -0
- docketry/notices.py +268 -0
- docketry/pipeline.py +157 -0
- docketry/store.py +340 -0
- docketry/webui.py +202 -0
- docketry-0.1.0.dist-info/METADATA +343 -0
- docketry-0.1.0.dist-info/RECORD +25 -0
- docketry-0.1.0.dist-info/WHEEL +4 -0
- docketry-0.1.0.dist-info/entry_points.txt +2 -0
- docketry-0.1.0.dist-info/licenses/LICENSE +202 -0
- docketry-0.1.0.dist-info/licenses/NOTICE +4 -0
docketry/cli.py
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
"""Docketry CLI: init, poll, queue, approve, advance, status."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import os as _os
|
|
6
|
+
|
|
7
|
+
def _color(code: str, s: str) -> str:
|
|
8
|
+
if not sys.stdout.isatty() or _os.environ.get("NO_COLOR"):
|
|
9
|
+
return s
|
|
10
|
+
return f"\033[{code}m{s}\033[0m"
|
|
11
|
+
|
|
12
|
+
def _sev(marker: str) -> str:
|
|
13
|
+
return {"FAIL": _color("31;1", "FAIL"), "ERROR": _color("31;1", "ERROR"),
|
|
14
|
+
"warn": _color("33", "warn"), "WARN": _color("33", "WARN"),
|
|
15
|
+
" ok": _color("32", " ok")}.get(marker, marker)
|
|
16
|
+
import getpass
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from . import __version__
|
|
22
|
+
from . import store as st
|
|
23
|
+
from .config import MANIFEST_NAME, load_home, write_config
|
|
24
|
+
from .envelope import parse_message
|
|
25
|
+
from .mailbox import IntakeMailbox
|
|
26
|
+
from .manifest import DEFAULT_MANIFEST, load_manifest
|
|
27
|
+
from . import notices as notices_mod
|
|
28
|
+
from .pipeline import GateRefusal, Runner
|
|
29
|
+
from .store import Store
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _open(home: str):
|
|
33
|
+
cfg = load_home(home)
|
|
34
|
+
if not cfg.manifest_path.exists():
|
|
35
|
+
sys.exit(f"no {MANIFEST_NAME} in {home} — run: docketry init")
|
|
36
|
+
pipeline = load_manifest(cfg.manifest_path)
|
|
37
|
+
return cfg, pipeline, Store(cfg.store_path)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cmd_init(args) -> None:
|
|
41
|
+
home = Path(args.home)
|
|
42
|
+
host = args.host or input("IMAP host of the intake mailbox (e.g. imap.gmail.com): ").strip()
|
|
43
|
+
user = args.user or input("Intake mailbox address (e.g. intake@yourfirm.com): ").strip()
|
|
44
|
+
folder = args.folder or "INBOX"
|
|
45
|
+
password = None
|
|
46
|
+
if args.store_password:
|
|
47
|
+
password = getpass.getpass("Mailbox password/app password (stored 0600): ")
|
|
48
|
+
write_config(home, host=host, user=user, folder=folder, password=password)
|
|
49
|
+
manifest = home / MANIFEST_NAME
|
|
50
|
+
if not manifest.exists():
|
|
51
|
+
manifest.write_text(DEFAULT_MANIFEST)
|
|
52
|
+
load_manifest(manifest) # refuse to init half-configured
|
|
53
|
+
print(f"initialized {home}")
|
|
54
|
+
print(f" config: {home / 'config.toml'}")
|
|
55
|
+
print(f" manifest: {manifest}")
|
|
56
|
+
if not password:
|
|
57
|
+
print(" password: set DOCKETRY_IMAP_PASSWORD in the environment")
|
|
58
|
+
print("next: point a forwarding rule at the intake mailbox, then run: docketry poll")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def cmd_poll(args) -> None:
|
|
62
|
+
cfg, pipeline, store = _open(args.home)
|
|
63
|
+
if cfg.mailbox is None:
|
|
64
|
+
sys.exit("no [mailbox] configured — run: docketry init")
|
|
65
|
+
if not cfg.mailbox.password:
|
|
66
|
+
sys.exit("no mailbox password (set DOCKETRY_IMAP_PASSWORD)")
|
|
67
|
+
runner = Runner(pipeline, store)
|
|
68
|
+
first_stage = pipeline.stages[0]
|
|
69
|
+
adapters_file = cfg.home / "adapters.toml"
|
|
70
|
+
adapter_stack = notices_mod.stack(adapters_file if adapters_file.exists() else None)
|
|
71
|
+
ingested = held = parsed = 0
|
|
72
|
+
with IntakeMailbox(cfg.mailbox) as mb:
|
|
73
|
+
uidvalidity, last_uid = store.imap_cursor(mb.label)
|
|
74
|
+
current_validity = mb.uidvalidity()
|
|
75
|
+
if uidvalidity is not None and current_validity != uidvalidity:
|
|
76
|
+
print("mailbox UIDVALIDITY changed; resweeping from the start (dedupe is by content hash)")
|
|
77
|
+
last_uid = 0
|
|
78
|
+
max_uid = last_uid
|
|
79
|
+
for uid, raw in mb.new_messages(last_uid):
|
|
80
|
+
env = parse_message(raw, source=mb.label, fetched_at=st.utcnow())
|
|
81
|
+
msg_id = store.ingest(env, first_stage=first_stage)
|
|
82
|
+
max_uid = max(max_uid, uid)
|
|
83
|
+
if msg_id is None:
|
|
84
|
+
continue
|
|
85
|
+
ingested += 1
|
|
86
|
+
from .classify import classify as _classify
|
|
87
|
+
for att in store.attachments_for(msg_id):
|
|
88
|
+
label, tier = _classify(att["filename"])
|
|
89
|
+
if tier != "low":
|
|
90
|
+
store.stage_classification(att["id"], label, tier)
|
|
91
|
+
result = notices_mod.parse(env, adapter_stack)
|
|
92
|
+
if result is not None:
|
|
93
|
+
store.add_notice(msg_id, result.adapter, result.notice_type,
|
|
94
|
+
result.fields, result.missing)
|
|
95
|
+
parsed += 1
|
|
96
|
+
status = runner.enter(msg_id)
|
|
97
|
+
while status == st.OK:
|
|
98
|
+
status = runner.advance(msg_id)
|
|
99
|
+
if status in (st.PENDING_REVIEW, st.BLOCKED):
|
|
100
|
+
held += 1
|
|
101
|
+
store.set_imap_cursor(mb.label, current_validity, max_uid)
|
|
102
|
+
print(f"ingested {ingested} new message(s); {parsed} parsed as court notices; {held} held for review")
|
|
103
|
+
if held:
|
|
104
|
+
print("run: docketry queue")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _print_row(store: Store, row) -> None:
|
|
108
|
+
env = json.loads(row["envelope_json"])
|
|
109
|
+
print(f"[{row['id']}] {row['status']} @ {row['stage']} {env['subject']!r} from {env['from_addr']}")
|
|
110
|
+
for f in store.findings_for(row["id"]):
|
|
111
|
+
if f["severity"] != "info":
|
|
112
|
+
print(f" {f['severity']}: ({f['gate_id']}) {f['summary']}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def cmd_queue(args) -> None:
|
|
116
|
+
_, _, store = _open(args.home)
|
|
117
|
+
rows = store.list_by_status(st.PENDING_REVIEW) + store.list_by_status(st.BLOCKED)
|
|
118
|
+
if not rows:
|
|
119
|
+
print("queue empty")
|
|
120
|
+
return
|
|
121
|
+
for row in rows:
|
|
122
|
+
_print_row(store, row)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def cmd_approve(args) -> None:
|
|
126
|
+
cfg, pipeline, store = _open(args.home)
|
|
127
|
+
row = store.get_message(args.message)
|
|
128
|
+
if row is None:
|
|
129
|
+
sys.exit(f"no message {args.message}")
|
|
130
|
+
stage_bindings = {b.gate.id: b for b in pipeline.bindings_for(row["stage"])}
|
|
131
|
+
binding = stage_bindings.get(args.gate)
|
|
132
|
+
if binding is None:
|
|
133
|
+
sys.exit(f"gate '{args.gate}' is not bound at stage '{row['stage']}'"
|
|
134
|
+
f" (bound here: {', '.join(sorted(stage_bindings)) or 'none'})")
|
|
135
|
+
if args.role != binding.authority:
|
|
136
|
+
sys.exit(f"gate '{args.gate}' at stage '{row['stage']}' requires role"
|
|
137
|
+
f" '{binding.authority}', not '{args.role}' — approval not recorded")
|
|
138
|
+
store.add_approval(
|
|
139
|
+
args.message, row["stage"], args.gate,
|
|
140
|
+
approved_by=args.by, role=args.role, note=args.note or "",
|
|
141
|
+
)
|
|
142
|
+
runner = Runner(pipeline, store)
|
|
143
|
+
try:
|
|
144
|
+
status = runner.advance(args.message)
|
|
145
|
+
print(f"approved; message {args.message} -> {status}")
|
|
146
|
+
except GateRefusal as e:
|
|
147
|
+
print(f"approval recorded, but: {e}")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def cmd_advance(args) -> None:
|
|
151
|
+
_, pipeline, store = _open(args.home)
|
|
152
|
+
runner = Runner(pipeline, store)
|
|
153
|
+
try:
|
|
154
|
+
status = runner.advance(args.message)
|
|
155
|
+
print(f"message {args.message} -> {status}")
|
|
156
|
+
except GateRefusal as e:
|
|
157
|
+
sys.exit(str(e))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def cmd_notices(args) -> None:
|
|
161
|
+
_, _, store = _open(args.home)
|
|
162
|
+
rows = store.list_notices(args.type)
|
|
163
|
+
if not rows:
|
|
164
|
+
print("no notices parsed yet")
|
|
165
|
+
return
|
|
166
|
+
for r in rows:
|
|
167
|
+
fields = json.loads(r["fields_json"])
|
|
168
|
+
missing = json.loads(r["missing_json"])
|
|
169
|
+
line = f"[msg {r['message_id']}] {r['notice_type']} via {r['adapter']}: " + ", ".join(
|
|
170
|
+
f"{k}={v}" for k, v in fields.items()
|
|
171
|
+
)
|
|
172
|
+
if missing:
|
|
173
|
+
line += f" MISSING: {', '.join(missing)}"
|
|
174
|
+
print(line)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def cmd_verify_draft(args) -> None:
|
|
178
|
+
from .cite import CiteError, verify, extract_citations
|
|
179
|
+
from .extract import ExtractionError, extract_path
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
text = extract_path(args.file).full_text
|
|
183
|
+
except ExtractionError as e:
|
|
184
|
+
sys.exit(str(e))
|
|
185
|
+
try:
|
|
186
|
+
if args.offline:
|
|
187
|
+
raise CiteError("offline requested")
|
|
188
|
+
from .cite_client import CourtListenerClient
|
|
189
|
+
client = CourtListenerClient(token=args.token)
|
|
190
|
+
report = verify(text, client)
|
|
191
|
+
client.close()
|
|
192
|
+
except CiteError as e:
|
|
193
|
+
if not args.offline:
|
|
194
|
+
print(f"network verification unavailable ({e}); extraction-only mode")
|
|
195
|
+
try:
|
|
196
|
+
from .cite import citation_inventory
|
|
197
|
+
cites, n_short = citation_inventory(text)
|
|
198
|
+
except CiteError as e2:
|
|
199
|
+
sys.exit(str(e2))
|
|
200
|
+
print(f"{len(cites)} full citation(s) found — NOT verified:")
|
|
201
|
+
for c in cites:
|
|
202
|
+
name = f"{c.plaintiff} v. {c.defendant}".strip(" v.")
|
|
203
|
+
print(f" {name}, {c.text}" + (f" (pin p. {c.pin_page})" if c.pin_page else ""))
|
|
204
|
+
if n_short:
|
|
205
|
+
print(f"plus {n_short} short-form citation(s)"
|
|
206
|
+
+ (" — WITH NO FULL CITATION IN THIS DOCUMENT; unverifiable as written"
|
|
207
|
+
if not cites else " riding on the fulls above"))
|
|
208
|
+
sys.exit(2)
|
|
209
|
+
fails = [f for f in report.findings if f.severity == "fail"]
|
|
210
|
+
warns = [f for f in report.findings if f.severity == "warn"]
|
|
211
|
+
for f in report.findings:
|
|
212
|
+
marker = _sev({"fail": "FAIL", "warn": "warn", "info": " ok"}[f.severity])
|
|
213
|
+
print(f"{marker} [{f.check}] {f.summary}")
|
|
214
|
+
print(f"\n{len(report.citations)} citation(s): {len(fails)} failed,"
|
|
215
|
+
f" {len(warns)} warning(s)")
|
|
216
|
+
if fails:
|
|
217
|
+
sys.exit(1)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def cmd_lint(args) -> None:
|
|
221
|
+
from .extract import ExtractionError, extract_path
|
|
222
|
+
from .lint import RulepackError, lint, load_rulepack
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
text = extract_path(args.file).full_text
|
|
226
|
+
except ExtractionError as e:
|
|
227
|
+
sys.exit(str(e))
|
|
228
|
+
rulepack = None
|
|
229
|
+
if args.rules:
|
|
230
|
+
try:
|
|
231
|
+
rulepack = load_rulepack(args.rules)
|
|
232
|
+
except RulepackError as e:
|
|
233
|
+
sys.exit(f"rulepack refused: {e}")
|
|
234
|
+
findings = lint(text, rulepack)
|
|
235
|
+
errors = [f for f in findings if f.severity == "error"]
|
|
236
|
+
for f in findings:
|
|
237
|
+
loc = f"line {f.line}" if f.line else "document"
|
|
238
|
+
print(f"{_sev(f.severity.upper()):5} {loc:>10} [{f.rule}] {f.message}")
|
|
239
|
+
if f.excerpt:
|
|
240
|
+
print(f" > {f.excerpt}")
|
|
241
|
+
print(f"\n{len(findings)} finding(s), {len(errors)} error(s)")
|
|
242
|
+
if errors:
|
|
243
|
+
sys.exit(1)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def cmd_classify(args) -> None:
|
|
247
|
+
from .classify import classify
|
|
248
|
+
from .extract import ExtractionError, extract_path
|
|
249
|
+
|
|
250
|
+
text = ""
|
|
251
|
+
try:
|
|
252
|
+
text = extract_path(args.file).full_text
|
|
253
|
+
except ExtractionError:
|
|
254
|
+
pass # title-only classification still works
|
|
255
|
+
label, tier = classify(Path(args.file).stem, text)
|
|
256
|
+
print(f"{label} ({tier})")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def cmd_class_queue(args) -> None:
|
|
260
|
+
_, _, store = _open(args.home)
|
|
261
|
+
rows = store.open_classifications()
|
|
262
|
+
if not rows:
|
|
263
|
+
print("no staged classifications")
|
|
264
|
+
return
|
|
265
|
+
for r in rows:
|
|
266
|
+
current = f" (current: {r['doc_type']})" if r["doc_type"] else ""
|
|
267
|
+
print(f"[{r['id']}] {r['filename']} -> {r['label']} ({r['tier']}){current}")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def cmd_class_apply(args) -> None:
|
|
271
|
+
_, _, store = _open(args.home)
|
|
272
|
+
outcome = store.apply_classification(args.id, by=args.by, role=args.role)
|
|
273
|
+
print(outcome)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def cmd_ui(args) -> None:
|
|
277
|
+
from .webui import make_server
|
|
278
|
+
cfg, pipeline, store = _open(args.home)
|
|
279
|
+
store.close()
|
|
280
|
+
server = make_server(cfg.store_path, pipeline, port=args.port)
|
|
281
|
+
print(f"Docketry review UI: http://127.0.0.1:{args.port}/ (Ctrl-C to stop)")
|
|
282
|
+
try:
|
|
283
|
+
server.serve_forever()
|
|
284
|
+
except KeyboardInterrupt:
|
|
285
|
+
print("\nstopped")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def cmd_watch(args) -> None:
|
|
289
|
+
import time
|
|
290
|
+
print(f"sweeping every {args.every}s (Ctrl-C to stop)")
|
|
291
|
+
while True:
|
|
292
|
+
try:
|
|
293
|
+
cmd_poll(args)
|
|
294
|
+
except SystemExit as e:
|
|
295
|
+
raise
|
|
296
|
+
except Exception as e:
|
|
297
|
+
print(f"sweep failed: {e} — retrying in {args.every}s")
|
|
298
|
+
try:
|
|
299
|
+
time.sleep(args.every)
|
|
300
|
+
except KeyboardInterrupt:
|
|
301
|
+
print("\nstopped")
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def cmd_doctor(args) -> None:
|
|
306
|
+
import shutil
|
|
307
|
+
from .config import load_home
|
|
308
|
+
from .manifest import ManifestError, load_manifest as _lm
|
|
309
|
+
|
|
310
|
+
ok = True
|
|
311
|
+
|
|
312
|
+
def report(level, msg):
|
|
313
|
+
nonlocal ok
|
|
314
|
+
if level == "FAIL":
|
|
315
|
+
ok = False
|
|
316
|
+
print(f"{_sev(level if level != 'PASS' else ' ok'):5} {msg}")
|
|
317
|
+
|
|
318
|
+
home = Path(args.home)
|
|
319
|
+
if not home.exists():
|
|
320
|
+
report("FAIL", f"home {home} does not exist — run: docketry init")
|
|
321
|
+
sys.exit(1)
|
|
322
|
+
report("PASS", f"home: {home}")
|
|
323
|
+
cfg = load_home(home)
|
|
324
|
+
if cfg.mailbox is None:
|
|
325
|
+
report("WARN", "no [mailbox] configured (init with --host/--user to poll)")
|
|
326
|
+
else:
|
|
327
|
+
report("PASS", f"mailbox: {cfg.mailbox.user} @ {cfg.mailbox.host} ({cfg.mailbox.folder})")
|
|
328
|
+
if not cfg.mailbox.password:
|
|
329
|
+
report("WARN", "no mailbox password: set DOCKETRY_IMAP_PASSWORD")
|
|
330
|
+
if cfg.manifest_path.exists():
|
|
331
|
+
try:
|
|
332
|
+
pipeline = _lm(cfg.manifest_path)
|
|
333
|
+
report("PASS", f"manifest: stages {pipeline.stages}, "
|
|
334
|
+
f"{len(pipeline.bindings)} gate binding(s)")
|
|
335
|
+
except ManifestError as e:
|
|
336
|
+
report("FAIL", f"manifest refused: {e}")
|
|
337
|
+
else:
|
|
338
|
+
report("FAIL", f"no {cfg.manifest_path.name} — run: docketry init")
|
|
339
|
+
adapters = home / "adapters.toml"
|
|
340
|
+
if adapters.exists():
|
|
341
|
+
from .notices import AdapterError, load_adapters_file
|
|
342
|
+
try:
|
|
343
|
+
n = len(load_adapters_file(adapters))
|
|
344
|
+
report("PASS", f"firm adapters: {n} loaded")
|
|
345
|
+
except AdapterError as e:
|
|
346
|
+
report("FAIL", f"adapters.toml refused: {e}")
|
|
347
|
+
else:
|
|
348
|
+
report("PASS", "no firm adapters.toml (built-ins only)")
|
|
349
|
+
for mod, extra, what in (("pypdf", "pdf", "PDF extraction"),
|
|
350
|
+
("docx", "docx", "DOCX extraction"),
|
|
351
|
+
("eyecite", "cite", "citation extraction"),
|
|
352
|
+
("httpx", "cite", "citation verification")):
|
|
353
|
+
try:
|
|
354
|
+
__import__(mod)
|
|
355
|
+
report("PASS", f"{what} available")
|
|
356
|
+
except ImportError:
|
|
357
|
+
report("WARN", f"{what} missing — pip install 'docketry[{extra}]'")
|
|
358
|
+
for binary, pkg in (("tesseract", "tesseract-ocr"), ("pdftoppm", "poppler-utils")):
|
|
359
|
+
if shutil.which(binary):
|
|
360
|
+
report("PASS", f"{binary} present (OCR possible)")
|
|
361
|
+
else:
|
|
362
|
+
report("WARN", f"{binary} missing — scanned PDFs need {pkg}")
|
|
363
|
+
if _os.environ.get("COURTLISTENER_TOKEN"):
|
|
364
|
+
report("PASS", "COURTLISTENER_TOKEN set (live citation verification)")
|
|
365
|
+
else:
|
|
366
|
+
report("WARN", "COURTLISTENER_TOKEN not set — verify-draft runs extraction-only")
|
|
367
|
+
sys.exit(0 if ok else 1)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def cmd_stats(args) -> None:
|
|
371
|
+
_, _, store = _open(args.home)
|
|
372
|
+
s = store.stats(days=args.days)
|
|
373
|
+
if args.json:
|
|
374
|
+
print(json.dumps(s, indent=2))
|
|
375
|
+
return
|
|
376
|
+
print(f"last {s['days']} day(s): {s['ingested']} message(s) ingested")
|
|
377
|
+
for k, v in sorted(s["by_status"].items()):
|
|
378
|
+
print(f" {k:15} {v}")
|
|
379
|
+
if s["holds_by_gate"]:
|
|
380
|
+
print("holds by gate:")
|
|
381
|
+
for g, n in s["holds_by_gate"].items():
|
|
382
|
+
print(f" {g:20} {n}")
|
|
383
|
+
if s["notices_by_type"]:
|
|
384
|
+
print("court notices:")
|
|
385
|
+
for t, n in sorted(s["notices_by_type"].items()):
|
|
386
|
+
print(f" {t:20} {n}")
|
|
387
|
+
if s["template_drift_messages"]:
|
|
388
|
+
print(f"TEMPLATE DRIFT: {s['template_drift_messages']} message(s) — a court"
|
|
389
|
+
" system may have changed its email format")
|
|
390
|
+
if s["avg_hours_to_release"] is not None:
|
|
391
|
+
print(f"avg hours held before release: {s['avg_hours_to_release']}")
|
|
392
|
+
if s["approvals_by_role"]:
|
|
393
|
+
print("approvals recorded (audit): "
|
|
394
|
+
+ ", ".join(f"{r}={n}" for r, n in sorted(s["approvals_by_role"].items())))
|
|
395
|
+
if s["classifications"]:
|
|
396
|
+
print("doc-type proposals: "
|
|
397
|
+
+ ", ".join(f"{k}={n}" for k, n in sorted(s["classifications"].items())))
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def cmd_digest(args) -> None:
|
|
401
|
+
"""Prints a paste-anywhere summary. Docketry never sends anything."""
|
|
402
|
+
_, _, store = _open(args.home)
|
|
403
|
+
s = store.stats(days=1)
|
|
404
|
+
held = store.list_by_status("pending_review") + store.list_by_status("blocked")
|
|
405
|
+
lines = [f"Docketry intake digest — {len(held)} awaiting review,"
|
|
406
|
+
f" {s['ingested']} ingested in the last day"]
|
|
407
|
+
for row in held[:15]:
|
|
408
|
+
env = json.loads(row["envelope_json"])
|
|
409
|
+
gates = sorted({f["gate_id"] for f in store.findings_for(row["id"])
|
|
410
|
+
if f["severity"] == "fail"})
|
|
411
|
+
lines.append(f" [{row['id']}] {env['subject']!r} from {env['from_addr']}"
|
|
412
|
+
f" — held by {', '.join(gates) or 'unknown'}")
|
|
413
|
+
if s["template_drift_messages"]:
|
|
414
|
+
lines.append(f" NOTE: {s['template_drift_messages']} template-drift event(s)"
|
|
415
|
+
" — check portal formats")
|
|
416
|
+
print("\n".join(lines))
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def cmd_demo(args) -> None:
|
|
420
|
+
"""Seed a disposable home with sample traffic and open the dashboard."""
|
|
421
|
+
import tempfile
|
|
422
|
+
import webbrowser
|
|
423
|
+
from email.message import EmailMessage
|
|
424
|
+
|
|
425
|
+
from . import notices as nmod
|
|
426
|
+
from .classify import classify as _classify
|
|
427
|
+
from .envelope import parse_message
|
|
428
|
+
from .manifest import load_manifest
|
|
429
|
+
from .pipeline import Runner
|
|
430
|
+
from .store import Store
|
|
431
|
+
from .webui import make_server
|
|
432
|
+
|
|
433
|
+
home = Path(tempfile.mkdtemp(prefix="docketry-demo-"))
|
|
434
|
+
(home / "guardrails.toml").write_text(DEMO_MANIFEST)
|
|
435
|
+
pipeline = load_manifest(home / "guardrails.toml")
|
|
436
|
+
store = Store(home / "store")
|
|
437
|
+
runner = Runner(pipeline, store)
|
|
438
|
+
adapter_stack = nmod.stack()
|
|
439
|
+
|
|
440
|
+
def mail(from_addr, subject, body, attach=None):
|
|
441
|
+
m = EmailMessage()
|
|
442
|
+
m["From"] = from_addr
|
|
443
|
+
m["To"] = "intake@demofirm.example"
|
|
444
|
+
m["Subject"] = subject
|
|
445
|
+
m.set_content(body)
|
|
446
|
+
if attach:
|
|
447
|
+
m.add_attachment(b"%PDF-1.4 demo", maintype="application",
|
|
448
|
+
subtype="pdf", filename=attach)
|
|
449
|
+
return bytes(m)
|
|
450
|
+
|
|
451
|
+
samples = [
|
|
452
|
+
mail("eservice@myflcourtaccess.com", "SERVICE OF COURT DOCUMENT",
|
|
453
|
+
"Case Number: 562026CA000123\nCase Style: DOE v. ACME INSURANCE\n"
|
|
454
|
+
"Document: Motion for Summary Judgment\nServed: you@demofirm.example\n",
|
|
455
|
+
attach="Motion for Summary Judgment.pdf"),
|
|
456
|
+
mail("ecf_bounces@flsd.uscourts.gov",
|
|
457
|
+
"Activity in Case 2:26-cv-00123-XYZ Doe v. Acme Order on Motion",
|
|
458
|
+
"Document Number: 45\nDocket Text: ORDER granting in part.\n"
|
|
459
|
+
"https://ecf.flsd.uscourts.gov/doc1/demo (one-time free look —"
|
|
460
|
+
" captured, never fetched)\n"),
|
|
461
|
+
mail("ja@circuit19.example", "Hearing Scheduled",
|
|
462
|
+
"Judicial Automated Calendaring System\nCase Number: 562026CA000123\n"
|
|
463
|
+
"Hearing Date: 09/15/2026\nTime: 10:30 AM\nJudge: Hon. Demo Judge\n"),
|
|
464
|
+
mail("eservice@myflcourtaccess.com", "SERVICE OF COURT DOCUMENT",
|
|
465
|
+
"a redesigned portal template this adapter has never seen\n"),
|
|
466
|
+
mail("stranger@sketchy.example", "Invoice attached — pay today",
|
|
467
|
+
"wire transfer please", attach="invoice.pdf"),
|
|
468
|
+
]
|
|
469
|
+
for raw in samples:
|
|
470
|
+
env = parse_message(raw, source="demo", fetched_at=st.utcnow())
|
|
471
|
+
msg_id = store.ingest(env, first_stage=pipeline.stages[0])
|
|
472
|
+
for att in store.attachments_for(msg_id):
|
|
473
|
+
label, tier = _classify(att["filename"])
|
|
474
|
+
if tier != "low":
|
|
475
|
+
store.stage_classification(att["id"], label, tier)
|
|
476
|
+
res = nmod.parse(env, adapter_stack)
|
|
477
|
+
if res is not None:
|
|
478
|
+
store.add_notice(msg_id, res.adapter, res.notice_type,
|
|
479
|
+
res.fields, res.missing)
|
|
480
|
+
status = runner.enter(msg_id)
|
|
481
|
+
while status == st.OK:
|
|
482
|
+
status = runner.advance(msg_id)
|
|
483
|
+
store.close()
|
|
484
|
+
|
|
485
|
+
server = make_server(home / "store", pipeline, port=args.port)
|
|
486
|
+
url = f"http://127.0.0.1:{server.server_address[1]}/"
|
|
487
|
+
print(f"Demo home: {home} (disposable)")
|
|
488
|
+
print(f"Two messages passed clean; three are held — a drifted portal template,")
|
|
489
|
+
print(f"an unknown sender, and its attachment. Release them from the dashboard:")
|
|
490
|
+
print(f" {url} (Ctrl-C to stop)")
|
|
491
|
+
if not args.no_browser:
|
|
492
|
+
webbrowser.open(url)
|
|
493
|
+
try:
|
|
494
|
+
server.serve_forever()
|
|
495
|
+
except KeyboardInterrupt:
|
|
496
|
+
print("\ndemo stopped; nothing was saved outside", home)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
DEMO_MANIFEST = """\
|
|
500
|
+
[pipeline]
|
|
501
|
+
stages = ["ingest", "review"]
|
|
502
|
+
|
|
503
|
+
[[gate]]
|
|
504
|
+
id = "sender-scope"
|
|
505
|
+
binds_to = ["ingest"]
|
|
506
|
+
on_fail = "bounce"
|
|
507
|
+
authority = "paralegal"
|
|
508
|
+
|
|
509
|
+
[gate.options]
|
|
510
|
+
allow = ["@myflcourtaccess.com", "@uscourts.gov", "@circuit19.example"]
|
|
511
|
+
|
|
512
|
+
[[gate]]
|
|
513
|
+
id = "attachment-policy"
|
|
514
|
+
binds_to = ["ingest"]
|
|
515
|
+
on_fail = "bounce"
|
|
516
|
+
authority = "paralegal"
|
|
517
|
+
|
|
518
|
+
[[gate]]
|
|
519
|
+
id = "notice-parser"
|
|
520
|
+
binds_to = ["ingest"]
|
|
521
|
+
on_fail = "bounce"
|
|
522
|
+
authority = "paralegal"
|
|
523
|
+
|
|
524
|
+
[[gate]]
|
|
525
|
+
id = "provenance-stamp"
|
|
526
|
+
binds_to = ["ingest"]
|
|
527
|
+
on_fail = "warn"
|
|
528
|
+
authority = "paralegal"
|
|
529
|
+
"""
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def cmd_status(args) -> None:
|
|
533
|
+
_, _, store = _open(args.home)
|
|
534
|
+
counts = store.counts()
|
|
535
|
+
if not counts:
|
|
536
|
+
print("no messages yet")
|
|
537
|
+
return
|
|
538
|
+
for status, n in sorted(counts.items()):
|
|
539
|
+
print(f"{status:15} {n}")
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def main(argv=None) -> None:
|
|
543
|
+
p = argparse.ArgumentParser(prog="docketry", description="Local gate-enforced email port")
|
|
544
|
+
p.add_argument("--home", default="./docketry-home", help="installation directory")
|
|
545
|
+
p.add_argument("--version", action="version", version=f"docketry {__version__}")
|
|
546
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
547
|
+
|
|
548
|
+
sp = sub.add_parser("init", help="set up a Docketry home directory")
|
|
549
|
+
sp.add_argument("--host")
|
|
550
|
+
sp.add_argument("--user")
|
|
551
|
+
sp.add_argument("--folder")
|
|
552
|
+
sp.add_argument("--store-password", action="store_true",
|
|
553
|
+
help="prompt for the password and store it in config.toml (0600)")
|
|
554
|
+
sp.set_defaults(fn=cmd_init)
|
|
555
|
+
|
|
556
|
+
sp = sub.add_parser("poll", help="sweep the intake mailbox once")
|
|
557
|
+
sp.set_defaults(fn=cmd_poll)
|
|
558
|
+
|
|
559
|
+
sp = sub.add_parser("queue", help="list messages held for review")
|
|
560
|
+
sp.set_defaults(fn=cmd_queue)
|
|
561
|
+
|
|
562
|
+
sp = sub.add_parser("approve", help="record an authority approval and release a hold")
|
|
563
|
+
sp.add_argument("message", type=int)
|
|
564
|
+
sp.add_argument("--gate", required=True)
|
|
565
|
+
sp.add_argument("--by", required=True, help="name of the approving person")
|
|
566
|
+
sp.add_argument("--role", required=True, help="role granted the authority in the manifest")
|
|
567
|
+
sp.add_argument("--note")
|
|
568
|
+
sp.set_defaults(fn=cmd_approve)
|
|
569
|
+
|
|
570
|
+
sp = sub.add_parser("advance", help="move a message one stage forward")
|
|
571
|
+
sp.add_argument("message", type=int)
|
|
572
|
+
sp.set_defaults(fn=cmd_advance)
|
|
573
|
+
|
|
574
|
+
sp = sub.add_parser("notices", help="list parsed court notices")
|
|
575
|
+
sp.add_argument("--type", choices=["service_notice", "filing_receipt", "hearing_notice"])
|
|
576
|
+
sp.set_defaults(fn=cmd_notices)
|
|
577
|
+
|
|
578
|
+
sp = sub.add_parser("verify-draft", help="verify every citation in a draft (exists/name/quote/pin)")
|
|
579
|
+
sp.add_argument("file", help="draft file (.docx, .pdf, .txt)")
|
|
580
|
+
sp.add_argument("--token", help="CourtListener API token (or COURTLISTENER_TOKEN env)")
|
|
581
|
+
sp.add_argument("--offline", action="store_true", help="extraction-only, no network")
|
|
582
|
+
sp.set_defaults(fn=cmd_verify_draft)
|
|
583
|
+
|
|
584
|
+
sp = sub.add_parser("lint", help="deterministic writing checks for a litigation draft")
|
|
585
|
+
sp.add_argument("file", help="draft file (.docx, .pdf, .txt)")
|
|
586
|
+
sp.add_argument("--rules", help="firm rulepack (TOML)")
|
|
587
|
+
sp.set_defaults(fn=cmd_lint)
|
|
588
|
+
|
|
589
|
+
sp = sub.add_parser("classify", help="classify one document (deterministic, proposes only)")
|
|
590
|
+
sp.add_argument("file")
|
|
591
|
+
sp.set_defaults(fn=cmd_classify)
|
|
592
|
+
|
|
593
|
+
sp = sub.add_parser("class-queue", help="list staged doc-type proposals")
|
|
594
|
+
sp.set_defaults(fn=cmd_class_queue)
|
|
595
|
+
|
|
596
|
+
sp = sub.add_parser("class-apply", help="apply a staged proposal (fill-only)")
|
|
597
|
+
sp.add_argument("id", type=int)
|
|
598
|
+
sp.add_argument("--by", required=True)
|
|
599
|
+
sp.add_argument("--role", required=True)
|
|
600
|
+
sp.set_defaults(fn=cmd_class_apply)
|
|
601
|
+
|
|
602
|
+
sp = sub.add_parser("ui", help="local review dashboard (127.0.0.1 only)")
|
|
603
|
+
sp.add_argument("--port", type=int, default=8642)
|
|
604
|
+
sp.set_defaults(fn=cmd_ui)
|
|
605
|
+
|
|
606
|
+
sp = sub.add_parser("watch", help="sweep the intake mailbox on a loop")
|
|
607
|
+
sp.add_argument("--every", type=int, default=300, help="seconds between sweeps")
|
|
608
|
+
sp.set_defaults(fn=cmd_watch)
|
|
609
|
+
|
|
610
|
+
sp = sub.add_parser("doctor", help="check the installation and say what is missing")
|
|
611
|
+
sp.set_defaults(fn=cmd_doctor)
|
|
612
|
+
|
|
613
|
+
sp = sub.add_parser("stats", help="queue-health stats (volumes, holds, drift, latency)")
|
|
614
|
+
sp.add_argument("--days", type=int, default=7)
|
|
615
|
+
sp.add_argument("--json", action="store_true")
|
|
616
|
+
sp.set_defaults(fn=cmd_stats)
|
|
617
|
+
|
|
618
|
+
sp = sub.add_parser("digest", help="print a paste-anywhere intake summary (never sends)")
|
|
619
|
+
sp.set_defaults(fn=cmd_digest)
|
|
620
|
+
|
|
621
|
+
sp = sub.add_parser("demo", help="seed sample traffic and open the dashboard (no mailbox needed)")
|
|
622
|
+
sp.add_argument("--port", type=int, default=0)
|
|
623
|
+
sp.add_argument("--no-browser", action="store_true")
|
|
624
|
+
sp.set_defaults(fn=cmd_demo)
|
|
625
|
+
|
|
626
|
+
sp = sub.add_parser("status", help="message counts by status")
|
|
627
|
+
sp.set_defaults(fn=cmd_status)
|
|
628
|
+
|
|
629
|
+
args = p.parse_args(argv)
|
|
630
|
+
args.fn(args)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
if __name__ == "__main__":
|
|
634
|
+
main()
|