-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpdrbot.py
More file actions
executable file
·3836 lines (3317 loc) · 169 KB
/
Copy pathpdrbot.py
File metadata and controls
executable file
·3836 lines (3317 loc) · 169 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/./.venv/bin/python
"""
PDRBot - Daily Criminal Opinions Scraper
Downloads criminal law opinions from all 14 Texas Courts of Appeals
for the previous business day and stores them in data/ with date-based organization.
Runs Tuesday-Saturday at 12:01 AM to collect opinions from the previous day.
"""
import requests
from bs4 import BeautifulSoup
import os
import shutil
import re
import sys
import json
import time
import logging
import sqlite3
import smtplib
import imaplib
import email
import hashlib
from contextlib import contextmanager
from datetime import datetime, timedelta
from urllib.parse import urljoin, quote
from pathlib import Path
from PyPDF2 import PdfReader, PdfWriter
from dotenv import load_dotenv
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
from email import encoders
from email.utils import parsedate_to_datetime
from reportlab.lib.pagesizes import letter
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak, Table, TableStyle
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import inch
from reportlab.lib import colors
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_JUSTIFY
# Add mwb_common to path
sys.path.insert(0, os.path.expanduser('~/github/mwb_common'))
from mwb_claude import call_claude_with_retry, get_current_model
import case_styles # noqa: E402 -- local sibling module
import defense_wins # noqa: E402 -- local sibling module
import brief_harvest # noqa: E402 -- local sibling module
# Set up logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
ANALYSIS_JSON_SCHEMA = {
"type": "object",
"properties": {
"appellant_name": {
"type": "string",
"description": "Full name of the appellant as identified on page 1 of the opinion."
},
"case_numbers": {
"type": "array",
"items": {"type": "string"},
"description": "All COA cause numbers for the opinion(s) analyzed."
},
"issues": {
"type": "array",
"items": {
"type": "object",
"properties": {
"headline": {"type": "string", "description": "One sentence, max 15 words, why the issue is PDR-worthy."},
"issue_description": {"type": "string", "description": "The novel or controversial legal question."},
"discussion": {"type": "string", "description": "Quotes or specific references from the opinion showing novelty or controversy."},
"authority_conflicts": {"type": "string", "description": "Which courts take each side; whether the split is acknowledged; recency; CCA status. Empty string if none."},
"relevant_precedent": {"type": "string", "description": "Other cases, statutes, or principles cited in the opinion that frame the controversy. Empty string if none."},
"pdr_score": {"type": "integer", "minimum": 1, "maximum": 10, "description": "1-3 routine; 4-5 arguable; 6-7 unsettled; 8-10 split/unresolved-constitutional/logical-flaw."},
"matched_open_questions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"type": "integer", "description": "Catalog open-question id from cca-judges catalog."},
"explanation": {"type": "string", "description": "One-sentence explanation of how the COA opinion implicates the cataloged question."}
},
"required": ["id", "explanation"]
},
"description": "Optional. Cataloged CCA-flagged open questions whose resolution the opinion implicates."
}
},
"required": ["headline", "issue_description", "discussion", "pdr_score"]
}
},
"issue_count": {"type": "integer", "minimum": 0},
"state_is_appellant": {
"type": "boolean",
"description": "True when the State of Texas is the appellant — i.e., the State, not the defendant, brought the appeal. False for ordinary defense appeals and for original proceedings styled \"In re ...\"."
},
"disposition": {
"type": "string",
"enum": [
"affirmed",
"reversed",
"reversed_in_part",
"modified_and_affirmed",
"vacated",
"remanded",
"dismissed",
"petition_granted",
"petition_denied",
"abated",
"other"
],
"description": "Outcome the court of appeals ordered for the appellant. Use \"reversed\" for any judgment fully set aside (reversed-and-remanded, reversed-and-rendered, reversed-and-acquitted). Use \"reversed_in_part\" only when the court reversed some counts/issues and affirmed others. Use \"modified_and_affirmed\" when the court modified the judgment (e.g., struck a fee or court cost) but affirmed the conviction. Use \"vacated\" for a vacatur not styled as a reversal. Use \"dismissed\" for jurisdictional dismissals (untimely notice, Anders that becomes a dismissal, plea-bargain waiver dismissal). For mandamus or habeas original proceedings use \"petition_granted\" or \"petition_denied\". \"abated\" for abatements. \"other\" only when no listed value fits."
}
},
"required": ["appellant_name", "case_numbers", "issues", "issue_count"]
}
def _is_defense_win(disposition, state_is_appellant):
"""Defense wins when the State appealed and lost (affirmance), or
when the defense appealed and won (any flavor of reversal/vacatur).
Returns False for unknown or mixed dispositions; the stamp is
intentionally conservative."""
if disposition is None or state_is_appellant is None:
return False
if state_is_appellant:
return disposition == "affirmed"
return disposition in ("reversed", "reversed_in_part", "vacated")
def render_analysis_prose(text):
"""Render analysis_text for display. Accepts either JSON (new) or prose
(legacy/stub). Returns a TERSE REPORT-formatted string."""
if not text:
return text
stripped = text.strip()
if not (stripped.startswith("{") and stripped.endswith("}")):
return text
try:
data = json.loads(stripped)
except ValueError:
return text
lines = []
count = data.get("issue_count", 0)
header = "TERSE REPORT: INTERESTING LEGAL ISSUES" if count else "TERSE REPORT: NO INTERESTING ISSUES"
lines.append(header)
lines.append("")
name = data.get("appellant_name", "")
nums = ", ".join(data.get("case_numbers", []) or [])
if name or nums:
lines.append(f"Appellant: {name}" + (f" Case No.: {nums}" if nums else ""))
lines.append("")
disp = data.get("disposition")
state_app = data.get("state_is_appellant")
if disp or state_app is not None:
bits = []
if disp:
bits.append(f"Disposition: {disp}")
if state_app is True:
bits.append("Appellant: State")
elif state_app is False:
bits.append("Appellant: defendant")
if _is_defense_win(disp, state_app):
bits.append("DEFENSE WIN")
lines.append(" · ".join(bits))
lines.append("")
for i, issue in enumerate(data.get("issues", []) or [], 1):
lines.append(f"Issue {i}:")
lines.append(f" Headline: {issue.get('headline', '')}")
lines.append(f" Description: {issue.get('issue_description', '')}")
lines.append(f" Discussion: {issue.get('discussion', '')}")
if issue.get("authority_conflicts"):
lines.append(f" Authority Conflicts: {issue['authority_conflicts']}")
if issue.get("relevant_precedent"):
lines.append(f" Relevant Precedent: {issue['relevant_precedent']}")
matches = issue.get("matched_open_questions") or []
if matches:
lines.append(" Matched Open Questions:")
for mq in matches:
qid = mq.get("id", "?")
expl = mq.get("explanation", "")
lines.append(f" [{qid}] {expl}")
lines.append(f" PDR Score: {issue.get('pdr_score', 0)}")
lines.append("")
lines.append(f"Issue Count: {count}")
return "\n".join(lines)
def parse_analysis_json(text):
"""Return the structured dict if text is JSON; None if it is prose or invalid."""
if not text:
return None
stripped = text.strip()
if not (stripped.startswith("{") and stripped.endswith("}")):
return None
try:
return json.loads(stripped)
except ValueError:
return None
class PDRBot:
def __init__(self, data_dir="data"):
# Load environment variables
load_dotenv()
self.base_url = os.getenv('BASE_URL', "https://search.txcourts.gov/")
self.data_dir = data_dir
self.db_path = os.path.join(data_dir, os.getenv('DB_NAME', "pdrbot.db"))
self.session = requests.Session()
self.session.headers.update({
'User-Agent': os.getenv('USER_AGENT', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36')
})
# Claude CLI configuration
self.analysis_enabled = os.getenv('ANALYSIS_ENABLED', 'true').lower() == 'true'
self.claude_model = get_current_model()
logger.info(f"Using claude CLI with Max subscription for analysis (model: {self.claude_model})")
# Load analysis prompt
self.analysis_prompt = self.load_analysis_prompt()
# Configuration from environment
self.request_timeout = int(os.getenv('REQUEST_TIMEOUT', '30'))
self.max_retries = int(os.getenv('MAX_RETRIES', '3'))
self.download_delay = int(os.getenv('DOWNLOAD_DELAY', '1'))
# Email configuration
self.email_enabled = os.getenv('EMAIL_ENABLED', 'false').lower() == 'true'
self.email_smtp_host = os.getenv('EMAIL_SMTP_HOST', 'smtp.gmail.com')
self.email_smtp_port = int(os.getenv('EMAIL_SMTP_PORT', '587'))
self.email_from = os.getenv('EMAIL_FROM')
self.email_auth_user = os.getenv('EMAIL_AUTH_USER', self.email_from) # Default to FROM if not specified
self.email_password = os.getenv('EMAIL_PASSWORD')
# Support multiple email recipients (comma-separated)
email_to_raw = os.getenv('EMAIL_TO')
if email_to_raw:
self.email_to = [email.strip() for email in email_to_raw.split(',')]
else:
self.email_to = []
self.email_subject_prefix = os.getenv('EMAIL_SUBJECT_PREFIX', 'PDRBot Daily Report')
# Subscription management configuration
self.subscription_email = os.getenv('SUBSCRIPTION_EMAIL')
self.subscription_auth_user = os.getenv('SUBSCRIPTION_AUTH_USER', self.subscription_email)
self.subscription_password = os.getenv('SUBSCRIPTION_PASSWORD')
self.subscription_imap_host = os.getenv('SUBSCRIPTION_IMAP_HOST', 'imap.fastmail.com')
self.subscription_imap_port = int(os.getenv('SUBSCRIPTION_IMAP_PORT', '993'))
self.members_file = os.getenv('MEMBERS_FILE', 'data/members.json')
self.last_check_file = os.getenv('LAST_CHECK_FILE', 'data/last_subscription_check.txt')
# Ensure data directory exists
os.makedirs(data_dir, exist_ok=True)
# Initialize database
self.init_database()
def init_database(self):
"""Initialize SQLite database with required tables"""
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
# Create opinions table
cursor.execute('''
CREATE TABLE IF NOT EXISTS opinions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
case_number TEXT NOT NULL,
court TEXT NOT NULL,
opinion_date DATE NOT NULL,
opinion_type TEXT NOT NULL,
justice_name TEXT,
filename TEXT NOT NULL,
file_path TEXT NOT NULL,
case_url TEXT NOT NULL,
pdf_url TEXT,
download_timestamp TIMESTAMP DEFAULT (datetime('now', 'localtime')),
UNIQUE(case_number, opinion_type, justice_name)
)
''')
# Create daily_runs table to track execution
cursor.execute('''
CREATE TABLE IF NOT EXISTS daily_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_date DATE NOT NULL,
target_date DATE NOT NULL,
total_courts_checked INTEGER DEFAULT 0,
total_cases_found INTEGER DEFAULT 0,
total_files_downloaded INTEGER DEFAULT 0,
run_timestamp TIMESTAMP DEFAULT (datetime('now', 'localtime')),
status TEXT DEFAULT 'running',
error_message TEXT
)
''')
# Create court_rollover table to track courts with no opinions for next day checking
cursor.execute('''
CREATE TABLE IF NOT EXISTS court_rollover (
id INTEGER PRIMARY KEY AUTOINCREMENT,
court_number INTEGER NOT NULL,
original_date DATE NOT NULL,
created_timestamp TIMESTAMP DEFAULT (datetime('now', 'localtime')),
UNIQUE(court_number, original_date)
)
''')
# Create analysis table
cursor.execute('''
CREATE TABLE IF NOT EXISTS analysis (
id INTEGER PRIMARY KEY AUTOINCREMENT,
opinion_id INTEGER NOT NULL,
case_number TEXT NOT NULL,
court TEXT NOT NULL,
opinion_date DATE NOT NULL,
analysis_text TEXT NOT NULL,
has_interesting_issues BOOLEAN NOT NULL DEFAULT 0,
issue_count INTEGER DEFAULT 0,
analysis_timestamp TIMESTAMP DEFAULT (datetime('now', 'localtime')),
claude_model TEXT NOT NULL,
FOREIGN KEY (opinion_id) REFERENCES opinions (id),
UNIQUE(opinion_id)
)
''')
# Create representatives table
cursor.execute('''
CREATE TABLE IF NOT EXISTS representatives (
id INTEGER PRIMARY KEY AUTOINCREMENT,
case_number TEXT NOT NULL,
court TEXT NOT NULL,
opinion_date DATE NOT NULL,
party_name TEXT NOT NULL,
party_type TEXT NOT NULL,
representative_names TEXT NOT NULL,
scrape_timestamp TIMESTAMP DEFAULT (datetime('now', 'localtime')),
UNIQUE(case_number, court, party_name)
)
''')
# Add columns if they don't exist (for existing databases)
for col, table in [('pdf_url', 'opinions'), ('pdr_score', 'analysis')]:
try:
cursor.execute(f'ALTER TABLE {table} ADD COLUMN {col} {"TEXT" if col == "pdf_url" else "INTEGER"}')
logger.info(f"Added {col} column to existing {table} table")
except sqlite3.OperationalError:
pass # Column already exists
cursor.execute('''
CREATE TABLE IF NOT EXISTS imap_state (
mailbox TEXT PRIMARY KEY,
last_uid INTEGER DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
conn.commit()
conn.close()
def _get_last_imap_uid(self, mailbox):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('SELECT last_uid FROM imap_state WHERE mailbox = ?', (mailbox,))
row = cursor.fetchone()
conn.close()
return row[0] if row else 0
def _set_last_imap_uid(self, mailbox, uid):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
INSERT INTO imap_state (mailbox, last_uid, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(mailbox) DO UPDATE SET
last_uid = excluded.last_uid,
updated_at = CURRENT_TIMESTAMP
''', (mailbox, uid))
conn.commit()
conn.close()
def get_current_business_day(self):
"""Get the current business day (skip weekends)"""
today = datetime.now().date()
# If today is weekend, go back to Friday
while today.weekday() >= 5: # Saturday=5, Sunday=6
today -= timedelta(days=1)
return today
def add_court_to_rollover(self, court_number, original_date):
"""Add a court to rollover list for checking tomorrow"""
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
try:
cursor.execute('''
INSERT OR IGNORE INTO court_rollover (court_number, original_date)
VALUES (?, ?)
''', (court_number, original_date))
conn.commit()
except Exception as e:
logger.error(f"Failed to add court {court_number} to rollover: {e}")
finally:
conn.close()
def get_rollover_courts(self, original_date):
"""Get courts that need to be checked from previous day"""
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
SELECT court_number FROM court_rollover
WHERE original_date = ?
ORDER BY court_number
''', (original_date,))
results = [row[0] for row in cursor.fetchall()]
conn.close()
return results
def clear_rollover_courts(self, original_date):
"""Clear rollover courts for a specific date after processing"""
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
DELETE FROM court_rollover WHERE original_date = ?
''', (original_date,))
conn.commit()
conn.close()
def load_analysis_prompt(self):
"""Load the analysis prompt from the pdrbot-prompt file, then append
the current CCA open-questions catalog so the analyzer can flag
opinions that implicate questions current CCA judges have written
separately about."""
prompt_file = Path("pdrbot-prompt")
if prompt_file.exists():
base = prompt_file.read_text().strip()
else:
logger.warning("pdrbot-prompt file not found, using default prompt")
base = "Analyze this legal opinion for interesting legal issues."
catalog_path = Path(
"/home/ubuntu/github/cca-opinions/reports/special-interests/catalog.json"
)
if not catalog_path.exists():
logger.warning("open-questions catalog not found at %s — skipping",
catalog_path)
return base
try:
catalog = json.loads(catalog_path.read_text())
except Exception as e:
logger.warning("could not parse catalog (%s) — skipping", e)
return base
questions = catalog.get("questions", [])
if not questions:
return base
lines = [
base,
"",
"## Known PDR-Worthy Open Questions (from CCA side opinions)",
"",
f"Texas Court of Criminal Appeals judges have flagged the "
f"following {len(questions)} open questions in concurrences and "
f"dissents (2015–present). These are issues a future PDR could "
f"pick up.",
"",
"When an issue you identify in the COA opinion implicates one or "
"more of these questions — the opinion's reasoning or holding "
"turns on the question's resolution, applies contested doctrine "
"the question targets, or sits in tension with how a CCA judge "
"has framed the question — populate the issue's "
"`matched_open_questions` array with the catalog id(s) and a "
"one-sentence explanation of how. A match is a strong "
"PDR-worthiness signal; multiple matches usually warrant a "
"higher PDR score. Catalog: " + catalog.get("source", "") + ".",
"",
"Catalog:",
]
for q in questions:
examples = q.get("examples", []) or []
ex_bits = []
for ex in examples[:2]:
style = ex.get("case_style") or ""
cn = ex.get("case_number") or ""
ex_bits.append(f"{style}, {cn}" if style else cn)
ex_str = "; ".join(ex_bits) if ex_bits else ""
line = (
f"{q['id']}. [{q['judge']}] {q['question']}"
+ (f" — example: {ex_str}" if ex_str else "")
)
lines.append(line)
return "\n".join(lines)
@contextmanager
def smtp_connection(self):
"""Context manager for SMTP connections"""
if self.email_smtp_port == 465:
server = smtplib.SMTP_SSL(self.email_smtp_host, self.email_smtp_port)
else:
server = smtplib.SMTP(self.email_smtp_host, self.email_smtp_port)
server.starttls()
server.login(self.email_auth_user, self.email_password)
try:
yield server
finally:
server.quit()
def extract_pdr_score(self, analysis_text):
"""Extract PDR Score (1-10) from analysis text.
Returns an integer score, or None if not found/parseable.
"""
_d = parse_analysis_json(analysis_text)
if _d is not None:
scores = [i.get("pdr_score") for i in _d.get("issues", []) or [] if i.get("pdr_score")]
return max(scores) if scores else None
patterns = [
r'▪\s*PDR Score[^:]*:\s*(\d+)',
r'\*\*PDR Score[^:]*:\*\*\s*(\d+)',
r'(?m)^PDR Score[^:]*:\s*(\d+)',
]
for pattern in patterns:
match = re.search(pattern, analysis_text)
if match:
score = int(match.group(1))
if 1 <= score <= 10:
return score
return None
def extract_text_from_pdf(self, file_path):
"""Extract text content from a PDF file"""
try:
with open(file_path, 'rb') as file:
pdf_reader = PdfReader(file)
text = ""
for page in pdf_reader.pages:
text += page.extract_text() + "\n"
return text.strip()
except Exception as e:
logger.error(f"Failed to extract text from {file_path}: {e}")
return None
def analyze_opinion_with_claude(self, text_content, case_number):
"""Two-pass analysis: Haiku triage → Opus on hits.
Haiku 4.5 classifies the opinion as INTERESTING or ROUTINE. ROUTINE saves
a stub and skips the expensive pass; INTERESTING gets the full Opus pass.
Haiku failures fall through to Opus (safety — never drop a case silently).
"""
if not self.analysis_enabled:
logger.warning("Analysis not enabled - skipping")
return None
try:
logger.info(f"Analyzing {case_number}: {len(text_content):,} chars")
triage_line = self._triage_with_haiku(text_content, case_number)
if triage_line and triage_line.upper().startswith("ROUTINE"):
logger.info(f"Triage {case_number}: {triage_line[:120]}")
return (
"TERSE REPORT: NO INTERESTING ISSUES\n\n"
f"[Triage: Haiku classified as ROUTINE. {triage_line}]"
)
if triage_line:
logger.info(f"Triage {case_number}: {triage_line[:120]}")
else:
logger.info(f"Triage {case_number}: unavailable — running full analysis")
full_prompt = f"{self.analysis_prompt}\n\n--- OPINION TEXT ---\n{text_content}"
analysis_text = call_claude_with_retry(
prompt=full_prompt,
timeout=300,
max_retries=3,
base_delay=5,
json_schema=ANALYSIS_JSON_SCHEMA,
)
logger.info(f"Completed analysis for {case_number}")
return analysis_text
except Exception as e:
logger.error(f"Failed to analyze {case_number} with Claude: {e}")
return None
def _triage_with_haiku(self, text_content, case_number):
"""Fast Haiku pass. Returns first line of the response, or None on
failure (caller falls through to full Opus pass)."""
triage_prompt = (
"You are the first pass of a two-stage triage for a Texas "
"criminal-defense appellate practice. Cases you mark INTERESTING "
"go to a more capable model (Opus) for full analysis; cases you "
"mark ROUTINE are not analyzed further. Your only job is to "
"filter out truly cookie-cutter dispositions — the deeper model "
"is the authoritative judge of what is PDR-worthy.\n\n"
"Reply with exactly one line.\n\n"
"Default to INTERESTING. Mark ROUTINE only when the opinion is "
"truly cookie-cutter — a boilerplate disposition that could have "
"been written from a template, with no contested legal question, "
"no debatable application of law to fact, no concurrence or "
"dissent, and no discussion beyond reciting settled doctrine and "
"applying it to overwhelming or uncontested facts.\n\n"
"Cookie-cutter patterns (non-exhaustive):\n"
"- Anders affirmance with no arguable grounds and no "
"fine/cost/restitution issue\n"
"- Jurisdictional dismissal for untimely notice of appeal, no "
"tolling argument\n"
"- Habeas dismissed for failure to comply with statutory "
"prerequisites, no merits discussion\n"
"- Mandamus denied solely on want of presentment or want of "
"clear right, no contested record\n"
"- Frivolous-appeal dismissal with no preserved issues briefed\n"
"- Plea-bargain waiver dismissal under Rule 25.2(a)(2) with no "
"certification dispute\n\n"
"Anything else is INTERESTING — including any concurrence or "
"dissent, any sufficiency or burden-of-proof challenge against "
"non-overwhelming facts, any case where the court engages with a "
"doctrinal question even briefly, any case where the court "
"relies on cited authority to defeat a non-frivolous argument, "
"and any case decided on a close or contested record.\n\n"
"Always escalate when you see explicit textual signals like:\n"
"- the opinion itself flags an unresolved or unsettled question "
"('has not determined,' 'has not addressed,' 'we have not "
"found,' 'no controlling authority,' 'unsettled,' 'open "
"question,' or a Pattern Jury Charges committee note flagging "
"a gap)\n"
"- the court relies on pre-2000 Court of Criminal Appeals "
"authority for a contested point\n"
"- the court acknowledges or describes a split among Texas "
"courts of appeals (even where it picks a side)\n"
"- a concurrence or dissent rejects the majority's framework\n\n"
"A false ROUTINE loses a PDR-worthy case; a false INTERESTING "
"costs only one extra Opus call. When in doubt, escalate.\n\n"
"Your entire reply must be a single line, exactly one of:\n"
"- 'INTERESTING.' (no justification — Opus will analyze)\n"
"- 'ROUTINE: <one-sentence reason>'"
)
try:
result = call_claude_with_retry(
prompt=f"{triage_prompt}\n\n--- OPINION TEXT ---\n{text_content}",
timeout=60,
max_retries=2,
base_delay=3,
model="claude-haiku-4-5",
)
if not result:
return None
return result.strip().splitlines()[0]
except Exception as e:
logger.warning(f"Haiku triage failed for {case_number}: {e} — "
f"falling through to full analysis")
return None
def clean_analysis_text(self, analysis_text):
"""Clean analysis text by removing introductory commentary, meta-commentary, and first-person statements"""
import re
# Remove common intro phrases and first-person commentary
# These patterns capture various ways Claude might introduce the analysis
intro_patterns = [
# "I'll/I will/Let me analyze..." patterns
r"^I'll analyze this.*?(?:\n|\.)\s*\n*",
r"^Let me analyze this.*?(?:\n|\.)\s*\n*",
r"^I will analyze this.*?(?:\n|\.)\s*\n*",
r"^I must analyze this.*?(?:\n|\.)\s*\n*",
r"^Analyzing this.*?(?:\n|\.)\s*\n*",
# "Looking at this..." patterns (single and multi-line)
r"^Looking at this.*?(?:\n\n|\*\*)",
# "I need to/I must examine/check..." patterns
r"^I need to (?:examine|check|analyze).*?(?:\n|\.)\s*\n*",
r"^I must (?:examine|check|analyze).*?(?:\n|\.)\s*\n*",
# "I find..." patterns at the beginning
r"^I find (?:no interesting issues|that this).*?(?:\n|\.)\s*\n*",
r"^I do not find.*?(?:\n|\.)\s*\n*",
# Meta-commentary about language requirements/compliance
r"^CRITICAL LANGUAGE REQUIREMENT.*?(?=TERSE REPORT|Appellant Name|\Z)",
r"^I have reviewed the forbidden words.*?(?:\n|\.)\s*\n*",
r"^.*compliance checklist.*?(?:\n\n|\Z)",
r"^.*COMPLIANCE CHECKLIST.*?(?:\n\n|\Z)",
r"^Every sentence will use only approved.*?(?:\n|\.)\s*\n*",
]
cleaned = analysis_text
for pattern in intro_patterns:
cleaned = re.sub(pattern, '', cleaned, flags=re.IGNORECASE | re.DOTALL)
# Remove leading whitespace/newlines
cleaned = cleaned.lstrip()
return cleaned
def extract_headlines_from_analysis(self, analysis_text):
"""Extract Headline field values from analysis text.
Handles multiple formatting variants:
▪ Headline: ...
**Headline:** ...
Headline: ...
Returns a list of headline strings. Returns empty list for older
analyses that pre-date the Headline field (backward compatible).
"""
_d = parse_analysis_json(analysis_text)
if _d is not None:
return [i.get("headline", "") for i in _d.get("issues", []) or [] if i.get("headline")]
patterns = [
r'▪\s*Headline:\s*(.+)',
r'\*\*Headline:\*\*\s*(.+)',
r'(?m)^Headline:\s*(.+)',
]
headlines = []
for pattern in patterns:
matches = re.findall(pattern, analysis_text)
if matches:
headlines = [m.strip() for m in matches]
break
return headlines
def extract_appellant_name(self, analysis_text):
"""Extract the appellant name / case style from the structured header.
Looks for the 'Appellant Name and Case Number' field in various
formatting variants and returns the value, or None if not found.
"""
_d = parse_analysis_json(analysis_text)
if _d is not None:
return _d.get("appellant_name") or None
patterns = [
r'▪\s*Appellant Name[^:]*:\s*(.+)',
r'\*\*Appellant Name[^:]*:\*\*\s*(.+)',
r'(?m)^Appellant Name[^:]*:\s*(.+)',
]
for pattern in patterns:
match = re.search(pattern, analysis_text)
if match:
return match.group(1).strip()
return None
def save_analysis_to_db(self, opinion_id, case_number, court, opinion_date, analysis_text):
"""Save analysis results to database
For consolidated cases (multiple case numbers in same PDF), saves the
analysis to all opinion records that share the same file_path.
"""
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
try:
# Clean the analysis text
cleaned_text = self.clean_analysis_text(analysis_text)
# Check for execution errors
if "execution error" in cleaned_text.lower():
logger.warning(f"Analysis for {case_number} contains execution error - marking as failed")
has_interesting_issues = False
issue_count = 0
else:
# Prefer the structured-JSON envelope emitted by the current
# triage-plus-full-analysis pipeline. If the analysis is not
# JSON, fall back to the legacy "no interesting issues" phrase
# check and markdown issue markers.
parsed_issues = None
try:
parsed = json.loads(cleaned_text)
if isinstance(parsed, dict) and isinstance(parsed.get('issues'), list):
parsed_issues = parsed['issues']
except (json.JSONDecodeError, ValueError):
pass
if parsed_issues is not None:
issue_count = len(parsed_issues)
has_interesting_issues = issue_count > 0
elif "no interesting issues" in cleaned_text.lower():
has_interesting_issues = False
issue_count = 0
else:
issue_patterns = [
r'▪\s*Issue Description:',
r'\*\*Issue Description:\*\*',
r'\*\*Issue \d+:',
r'Issue \d+:',
r'▪\s*Headline:',
r'\*\*Headline:\*\*',
]
issue_count = 0
for pattern in issue_patterns:
count = len(re.findall(pattern, cleaned_text))
issue_count = max(issue_count, count)
has_interesting_issues = issue_count > 0
# Extract PDR score
pdr_score = self.extract_pdr_score(cleaned_text)
# Extract cached disposition / state_is_appellant fields from
# JSON-form analyses so the slip-opinions and triage renderers
# can stamp defense wins without re-parsing analysis_text.
disposition_val = None
state_is_appellant_val = None
_parsed_meta = parse_analysis_json(cleaned_text)
if _parsed_meta is not None:
if isinstance(_parsed_meta.get("disposition"), str):
disposition_val = _parsed_meta["disposition"]
if isinstance(_parsed_meta.get("state_is_appellant"), bool):
state_is_appellant_val = 1 if _parsed_meta["state_is_appellant"] else 0
# Get the file_path for this opinion
cursor.execute('SELECT file_path FROM opinions WHERE id = ?', (opinion_id,))
result = cursor.fetchone()
if not result:
logger.error(f"Opinion ID {opinion_id} not found")
return False
file_path = result[0]
# Find all opinion_ids that share this file_path (consolidated cases)
cursor.execute('SELECT id, case_number, court, opinion_date FROM opinions WHERE file_path = ?', (file_path,))
all_opinions = cursor.fetchall()
# Save analysis for all opinions that share this file
for oid, cnum, crt, odate in all_opinions:
cursor.execute('''
INSERT OR REPLACE INTO analysis
(opinion_id, case_number, court, opinion_date, analysis_text,
has_interesting_issues, issue_count, claude_model, pdr_score,
disposition, state_is_appellant)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (oid, cnum, crt, odate, cleaned_text,
has_interesting_issues, issue_count, self.claude_model, pdr_score,
disposition_val, state_is_appellant_val))
if len(all_opinions) > 1:
logger.info(f"Saved analysis to {len(all_opinions)} consolidated cases sharing {file_path}")
conn.commit()
return True
except Exception as e:
logger.error(f"Error saving analysis to database: {e}")
return False
finally:
conn.close()
def get_unanalyzed_opinions(self):
"""Get opinions that haven't been analyzed yet
Returns only one opinion per unique file_path to avoid analyzing
the same PDF multiple times for consolidated cases.
"""
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
try:
cursor.execute('''
SELECT MIN(o.id) as id, o.case_number, o.court, o.opinion_date, o.file_path
FROM opinions o
LEFT JOIN analysis a ON o.id = a.opinion_id
WHERE a.opinion_id IS NULL
GROUP BY o.file_path
ORDER BY o.opinion_date DESC, o.case_number
''')
return cursor.fetchall()
except Exception as e:
logger.error(f"Error fetching unanalyzed opinions: {e}")
return []
finally:
conn.close()
def process_opinion_analysis(self, opinion_id, case_number, court, opinion_date, file_path):
"""Process a single opinion for analysis"""
logger.info(f"Analyzing opinion: {case_number}")
# Extract text from PDF
text_content = self.extract_text_from_pdf(file_path)
if not text_content:
logger.error(f"Could not extract text from {file_path}")
return False
# Analyze with Claude
analysis_result = self.analyze_opinion_with_claude(text_content, case_number)
if not analysis_result:
logger.error(f"Could not analyze {case_number}")
return False
# Save analysis to database
success = self.save_analysis_to_db(opinion_id, case_number, court, opinion_date, analysis_result)
if success:
logger.info(f"Saved analysis for {case_number}")
# If case has interesting issues, scrape representative information
# Use the cleaned/parsed result from DB to stay consistent
cleaned = self.clean_analysis_text(analysis_result)
issue_count = 0
for pattern in [r'▪\s*Headline:', r'\*\*Headline:\*\*']:
issue_count = max(issue_count, len(re.findall(pattern, cleaned)))
if issue_count > 0 and "no interesting issues" not in cleaned.lower():
case_url = self.generate_case_url(case_number, court)
self.scrape_case_representatives(case_url, case_number, court, opinion_date)
time.sleep(1)
return success
def run_analysis_batch(self, limit=None):
"""Process unanalyzed opinions in batches"""
if not self.analysis_enabled:
logger.info("Analysis is disabled")
return
unanalyzed = self.get_unanalyzed_opinions()
if limit:
unanalyzed = unanalyzed[:limit]
if not unanalyzed:
logger.info("No unanalyzed opinions found")
return
logger.info(f"Processing {len(unanalyzed)} unanalyzed opinions")
processed = 0
failed = 0
for opinion_id, case_number, court, opinion_date, file_path in unanalyzed:
try:
if self.process_opinion_analysis(opinion_id, case_number, court, opinion_date, file_path):
processed += 1
else:
failed += 1
# Rate limiting - be respectful to Claude API
time.sleep(2)
except Exception as e:
logger.error(f"Error processing {case_number}: {e}")
failed += 1
logger.info(f"Analysis batch complete: {processed} processed, {failed} failed")
# Don't generate intermediate reports - only generate after all analysis is complete
def analyze_directory_pdfs(self, directory_path):
"""Analyze all PDF files in a specific directory"""
if not os.path.exists(directory_path):
logger.error(f"Directory not found: {directory_path}")
return
# Find all PDF files in the directory
pdf_files = []
for filename in os.listdir(directory_path):
if filename.endswith('.pdf') and not filename.startswith('analysis_report'):
pdf_path = os.path.join(directory_path, filename)
# Extract case number from filename
case_number = filename.replace('.pdf', '')
pdf_files.append((case_number, pdf_path))
if not pdf_files:
logger.info(f"No PDF files found in {directory_path}")
return
logger.info(f"Found {len(pdf_files)} PDF files in {directory_path}")
processed = 0
failed = 0
for case_number, pdf_path in pdf_files:
try:
# Check if this case is already analyzed
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
SELECT COUNT(*) FROM opinions o
JOIN analysis a ON o.id = a.opinion_id
WHERE o.case_number = ?
''', (case_number,))
already_analyzed = cursor.fetchone()[0] > 0
conn.close()
if already_analyzed:
logger.info(f"Skipping {case_number} - already analyzed")
continue
# Find or create opinion record
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('SELECT id FROM opinions WHERE case_number = ?', (case_number,))
result = cursor.fetchone()
if result:
opinion_id = result[0]
else:
# Create a basic opinion record for this PDF
# Extract court and date from case number (e.g., "01-23-00771-CR")
parts = case_number.split('-')
if len(parts) >= 3:
court = f"COA{parts[0]}"
year = f"20{parts[1]}"
# Use a default date based on directory name
dir_name = os.path.basename(directory_path)
if len(dir_name) == 8 and dir_name.isdigit():