sud-cli 0.1.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 (74) hide show
  1. sud_cli/__init__.py +3 -0
  2. sud_cli/__main__.py +4 -0
  3. sud_cli/_wm.py +118 -0
  4. sud_cli/agenda.py +90 -0
  5. sud_cli/autofill.py +192 -0
  6. sud_cli/autofill_markers.json +56 -0
  7. sud_cli/backup.py +256 -0
  8. sud_cli/calendar_ics.py +191 -0
  9. sud_cli/case_diff.py +165 -0
  10. sud_cli/case_search_bridge.py +1322 -0
  11. sud_cli/chain.py +226 -0
  12. sud_cli/cli.py +3721 -0
  13. sud_cli/completeness.py +105 -0
  14. sud_cli/config.py +20 -0
  15. sud_cli/conflict.py +210 -0
  16. sud_cli/conflicts.py +207 -0
  17. sud_cli/court_alerts.py +156 -0
  18. sud_cli/court_source.py +108 -0
  19. sud_cli/data/completeness_rules.json +18 -0
  20. sud_cli/data/key_rates.json +59 -0
  21. sud_cli/data/npa_historical_editions.json +19 -0
  22. sud_cli/db.py +2072 -0
  23. sud_cli/deadline_matrix.py +99 -0
  24. sud_cli/deadline_state.py +25 -0
  25. sud_cli/deadlines.py +8 -0
  26. sud_cli/deadlines_state.py +1 -0
  27. sud_cli/deferral.py +5 -0
  28. sud_cli/efrsb_manual.py +85 -0
  29. sud_cli/efrsb_public.py +210 -0
  30. sud_cli/egrul_nalog.py +150 -0
  31. sud_cli/explain.py +190 -0
  32. sud_cli/fees_extra.py +104 -0
  33. sud_cli/forms.py +204 -0
  34. sud_cli/fssp_manual.py +91 -0
  35. sud_cli/fssp_semi.py +285 -0
  36. sud_cli/fssp_stats.py +105 -0
  37. sud_cli/government_transfers.json +8 -0
  38. sud_cli/holidays_rf.json +62 -0
  39. sud_cli/idempotency.py +78 -0
  40. sud_cli/importing.py +575 -0
  41. sud_cli/interest.py +144 -0
  42. sud_cli/jurisdiction.py +177 -0
  43. sud_cli/jurisdiction_rules.json +265 -0
  44. sud_cli/legal_deadlines.py +278 -0
  45. sud_cli/legal_engine.py +558 -0
  46. sud_cli/legal_rules.json +1058 -0
  47. sud_cli/limitation.py +70 -0
  48. sud_cli/mcp_server.py +1594 -0
  49. sud_cli/metrics.py +142 -0
  50. sud_cli/npa_as_of.py +21 -0
  51. sud_cli/output.py +89 -0
  52. sud_cli/payment.py +24 -0
  53. sud_cli/pdn.py +259 -0
  54. sud_cli/plenum_corpus.py +134 -0
  55. sud_cli/plenums_seed.json +45 -0
  56. sud_cli/practice_status.py +136 -0
  57. sud_cli/pravo_publication.py +361 -0
  58. sud_cli/pretrial.py +151 -0
  59. sud_cli/production_calendar.py +233 -0
  60. sud_cli/providers.py +95 -0
  61. sud_cli/readiness.py +234 -0
  62. sud_cli/regional_holidays.py +37 -0
  63. sud_cli/reminders.py +92 -0
  64. sud_cli/reports.py +349 -0
  65. sud_cli/sud_netguard.py +943 -0
  66. sud_cli/timeline_audit.py +80 -0
  67. sud_cli/verify.py +201 -0
  68. sud_cli-0.1.1.dist-info/METADATA +119 -0
  69. sud_cli-0.1.1.dist-info/RECORD +74 -0
  70. sud_cli-0.1.1.dist-info/WHEEL +4 -0
  71. sud_cli-0.1.1.dist-info/entry_points.txt +2 -0
  72. sud_cli-0.1.1.dist-info/licenses/AUTHORS +5 -0
  73. sud_cli-0.1.1.dist-info/licenses/LICENSE.md +661 -0
  74. sud_cli-0.1.1.dist-info/licenses/NOTICE +7 -0
sud_cli/importing.py ADDED
@@ -0,0 +1,575 @@
1
+ """Local JSON import pipeline for synthetic court and NPA fixtures."""
2
+
3
+ import datetime
4
+ import json
5
+ import sqlite3
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List
8
+
9
+ MAX_RECORDS = 1000
10
+ MAX_CASES = 1000
11
+ MAX_EVENTS = 1000
12
+ MAX_TEXT_LENGTH = 1_000_000
13
+ MAX_FILE_BYTES = 10 * 1024 * 1024
14
+
15
+
16
+ class ImportError(Exception):
17
+ """Raised when a fixture cannot be imported safely."""
18
+
19
+
20
+ class CourtImportError(Exception):
21
+ """Raised when a native court case/event fixture cannot be imported safely."""
22
+
23
+
24
+ COURT_REQUIRED_FIELDS = {
25
+ "record_id": str,
26
+ "case_number": str,
27
+ "court": str,
28
+ "event_date": str,
29
+ "event_type": str,
30
+ }
31
+
32
+ NPA_REQUIRED_FIELDS = {
33
+ "doc_id": str,
34
+ "edition": str,
35
+ "title": str,
36
+ }
37
+
38
+
39
+ def _now_iso() -> str:
40
+ return datetime.datetime.now(tz=datetime.timezone.utc).replace(microsecond=0).isoformat()
41
+
42
+
43
+ def _validate_path(file_path: Path) -> Path:
44
+ if file_path.is_dir():
45
+ raise ImportError("input path is a directory, not a file")
46
+ if ".." in file_path.parts:
47
+ raise ImportError("path traversal is not allowed")
48
+ if not file_path.exists():
49
+ raise ImportError("input file not found")
50
+ if not file_path.is_file():
51
+ raise ImportError("input path is not a regular file")
52
+ if file_path.stat().st_size > MAX_FILE_BYTES:
53
+ raise ImportError(f"file exceeds maximum size of {MAX_FILE_BYTES} bytes")
54
+ return file_path
55
+
56
+
57
+ def _load_json(file_path: Path) -> Any:
58
+ try:
59
+ text = file_path.read_text(encoding="utf-8")
60
+ except UnicodeDecodeError as exc:
61
+ raise ImportError(f"invalid UTF-8: {exc}") from exc
62
+ try:
63
+ return json.loads(text)
64
+ except json.JSONDecodeError as exc:
65
+ raise ImportError(f"malformed JSON: {exc}") from exc
66
+
67
+
68
+ def _check_text_length(value: str, name: str) -> None:
69
+ if len(value) > MAX_TEXT_LENGTH:
70
+ raise ImportError(f"field '{name}' exceeds maximum length of {MAX_TEXT_LENGTH}")
71
+
72
+
73
+ def _validate_iso_date(value: str, field: str) -> None:
74
+ try:
75
+ datetime.date.fromisoformat(value)
76
+ except (TypeError, ValueError) as exc:
77
+ raise ImportError(f"field '{field}' must be YYYY-MM-DD: {value}") from exc
78
+
79
+
80
+ def validate_court_record(record: Any, index: int) -> Dict[str, Any]:
81
+ if not isinstance(record, dict):
82
+ raise ImportError(f"record {index} is not an object")
83
+ for field, expected_type in COURT_REQUIRED_FIELDS.items():
84
+ if field not in record:
85
+ raise ImportError(f"record {index} missing required field '{field}'")
86
+ value = record[field]
87
+ if not isinstance(value, expected_type):
88
+ raise ImportError(
89
+ f"record {index} field '{field}' must be {expected_type.__name__}"
90
+ )
91
+ _check_text_length(value, field)
92
+ _validate_iso_date(record["event_date"], "event_date")
93
+
94
+ parties = record.get("parties", {})
95
+ if not isinstance(parties, dict):
96
+ raise ImportError(f"record {index} 'parties' must be an object")
97
+
98
+ return {
99
+ "record_id": record["record_id"],
100
+ "case_number": record["case_number"],
101
+ "court": record["court"],
102
+ "plaintiff": str(parties.get("plaintiff", "")),
103
+ "defendant": str(parties.get("defendant", "")),
104
+ "event_date": record["event_date"],
105
+ "event_type": record["event_type"],
106
+ "doc_text": str(record.get("doc_text", "")),
107
+ }
108
+
109
+
110
+ def validate_npa_document(doc: Any, index: int) -> Dict[str, Any]:
111
+ if not isinstance(doc, dict):
112
+ raise ImportError(f"document {index} is not an object")
113
+ for field, expected_type in NPA_REQUIRED_FIELDS.items():
114
+ if field not in doc:
115
+ raise ImportError(f"document {index} missing required field '{field}'")
116
+ value = doc[field]
117
+ if not isinstance(value, expected_type):
118
+ raise ImportError(
119
+ f"document {index} field '{field}' must be {expected_type.__name__}"
120
+ )
121
+ _check_text_length(value, field)
122
+ if "doc_date" in doc:
123
+ _validate_iso_date(doc["doc_date"], "doc_date")
124
+ _check_text_length(str(doc.get("body", "")), "body")
125
+ return {
126
+ "doc_id": doc["doc_id"],
127
+ "edition": doc["edition"],
128
+ "title": doc["title"],
129
+ "doc_date": doc.get("doc_date"),
130
+ "body": str(doc.get("body", "")),
131
+ }
132
+
133
+
134
+ def _prepare_court_records(data: Any) -> List[Dict[str, Any]]:
135
+ if not isinstance(data, dict):
136
+ raise ImportError("top-level JSON value must be an object")
137
+ if "records" not in data:
138
+ raise ImportError("top-level object must contain a 'records' array")
139
+ records = data["records"]
140
+ if not isinstance(records, list):
141
+ raise ImportError("'records' must be an array")
142
+ if len(records) > MAX_RECORDS:
143
+ raise ImportError(
144
+ f"import limit exceeded: {len(records)} records (maximum {MAX_RECORDS})"
145
+ )
146
+ return [validate_court_record(r, i) for i, r in enumerate(records)]
147
+
148
+
149
+ def _prepare_npa_documents(data: Any) -> List[Dict[str, Any]]:
150
+ if not isinstance(data, dict):
151
+ raise ImportError("top-level JSON value must be an object")
152
+ if "documents" not in data:
153
+ raise ImportError("top-level object must contain a 'documents' array")
154
+ documents = data["documents"]
155
+ if not isinstance(documents, list):
156
+ raise ImportError("'documents' must be an array")
157
+ if len(documents) > MAX_RECORDS:
158
+ raise ImportError(
159
+ f"import limit exceeded: {len(documents)} documents (maximum {MAX_RECORDS})"
160
+ )
161
+ return [validate_npa_document(d, i) for i, d in enumerate(documents)]
162
+
163
+
164
+ def _existing_record_ids(conn: sqlite3.Connection, ids: List[str]) -> set:
165
+ if not ids:
166
+ return set()
167
+ placeholders = ",".join("?" * len(ids))
168
+ rows = conn.execute(
169
+ f"SELECT record_id FROM records WHERE record_id IN ({placeholders})", ids
170
+ ).fetchall()
171
+ return {row["record_id"] for row in rows}
172
+
173
+
174
+ def _existing_npa_keys(conn: sqlite3.Connection, keys: List[tuple]) -> set:
175
+ if not keys:
176
+ return set()
177
+ # SQLite supports row values for IN.
178
+ placeholders = ",".join("(?, ?)" for _ in keys)
179
+ flat = [item for pair in keys for item in pair]
180
+ rows = conn.execute(
181
+ f"SELECT doc_id, edition FROM npa_docs WHERE (doc_id, edition) IN ({placeholders})",
182
+ flat,
183
+ ).fetchall()
184
+ return {(row["doc_id"], row["edition"]) for row in rows}
185
+
186
+
187
+ def import_fixture(conn: sqlite3.Connection, kind: str, file_path: Path) -> Dict[str, Any]:
188
+ file_path = _validate_path(file_path)
189
+ data = _load_json(file_path)
190
+
191
+ imported_at = _now_iso()
192
+
193
+ with conn:
194
+ if kind == "court":
195
+ records = _prepare_court_records(data)
196
+ ids = [r["record_id"] for r in records]
197
+ existing = _existing_record_ids(conn, ids)
198
+ inserted = 0
199
+ skipped = 0
200
+ seen = set()
201
+ for r in records:
202
+ key = r["record_id"]
203
+ if key in existing or key in seen:
204
+ skipped += 1
205
+ continue
206
+ conn.execute(
207
+ """
208
+ INSERT OR IGNORE INTO records
209
+ (record_id, case_number, court, plaintiff, defendant,
210
+ event_date, event_type, doc_text, imported_at)
211
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
212
+ """,
213
+ (
214
+ r["record_id"],
215
+ r["case_number"],
216
+ r["court"],
217
+ r["plaintiff"],
218
+ r["defendant"],
219
+ r["event_date"],
220
+ r["event_type"],
221
+ r["doc_text"],
222
+ imported_at,
223
+ ),
224
+ )
225
+ # Keep the native case/event model in sync with legacy records.
226
+ conn.execute(
227
+ """
228
+ INSERT OR IGNORE INTO cases
229
+ (case_id, case_number, court, plaintiff, defendant,
230
+ source_name, source_record_id, fetched_at, imported_at)
231
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
232
+ """,
233
+ (
234
+ r["case_number"],
235
+ r["case_number"],
236
+ r["court"],
237
+ r["plaintiff"],
238
+ r["defendant"],
239
+ "local-json-court",
240
+ r["record_id"],
241
+ imported_at,
242
+ imported_at,
243
+ ),
244
+ )
245
+ conn.execute(
246
+ """
247
+ INSERT OR IGNORE INTO events
248
+ (event_id, case_id, event_date, event_type, doc_text,
249
+ source_name, source_record_id, fetched_at, imported_at)
250
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
251
+ """,
252
+ (
253
+ r["record_id"],
254
+ r["case_number"],
255
+ r["event_date"],
256
+ r["event_type"],
257
+ r["doc_text"],
258
+ "local-json-court",
259
+ r["record_id"],
260
+ imported_at,
261
+ imported_at,
262
+ ),
263
+ )
264
+ seen.add(key)
265
+ inserted += 1
266
+ return {"kind": "court", "inserted": inserted, "skipped": skipped, "total": len(records)}
267
+
268
+ elif kind == "npa":
269
+ docs = _prepare_npa_documents(data)
270
+ keys = [(d["doc_id"], d["edition"]) for d in docs]
271
+ existing = _existing_npa_keys(conn, keys)
272
+ inserted = 0
273
+ skipped = 0
274
+ seen = set()
275
+ for d in docs:
276
+ key = (d["doc_id"], d["edition"])
277
+ if key in existing or key in seen:
278
+ skipped += 1
279
+ continue
280
+ conn.execute(
281
+ """
282
+ INSERT OR IGNORE INTO npa_docs
283
+ (doc_id, edition, title, doc_date, body, imported_at)
284
+ VALUES (?, ?, ?, ?, ?, ?)
285
+ """,
286
+ (
287
+ d["doc_id"],
288
+ d["edition"],
289
+ d["title"],
290
+ d["doc_date"],
291
+ d["body"],
292
+ imported_at,
293
+ ),
294
+ )
295
+ seen.add(key)
296
+ inserted += 1
297
+ return {"kind": "npa", "inserted": inserted, "skipped": skipped, "total": len(docs)}
298
+
299
+ else:
300
+ raise ImportError(f"unsupported import kind: {kind}")
301
+
302
+
303
+ # ---------------------------------------------------------------------------
304
+ # Native court case/event import (Phase 3)
305
+ # ---------------------------------------------------------------------------
306
+
307
+ COURT_CASE_REQUIRED_FIELDS = {
308
+ "case_id": str,
309
+ "case_number": str,
310
+ "court": str,
311
+ "source_name": str,
312
+ }
313
+
314
+ COURT_EVENT_REQUIRED_FIELDS = {
315
+ "event_id": str,
316
+ "event_type": str,
317
+ "source_name": str,
318
+ "source_record_id": str,
319
+ }
320
+
321
+ ALLOWED_SOURCE_SCHEMES = {"http", "https", "file"}
322
+
323
+
324
+ def _validate_datetime(value: str, field: str) -> None:
325
+ try:
326
+ datetime.datetime.fromisoformat(value)
327
+ except (TypeError, ValueError) as exc:
328
+ raise CourtImportError(f"field '{field}' must be ISO-8601 datetime: {value}") from exc
329
+
330
+
331
+ def _validate_source_url(value: Any, record_name: str) -> None:
332
+ if not isinstance(value, str):
333
+ raise CourtImportError(f"{record_name} 'source_url' must be a string")
334
+ if ".." in value:
335
+ raise CourtImportError(f"{record_name} 'source_url' contains path traversal")
336
+ if any(ord(ch) < 32 for ch in value):
337
+ raise CourtImportError(f"{record_name} 'source_url' contains control characters")
338
+ from urllib.parse import urlparse
339
+
340
+ parsed = urlparse(value)
341
+ if parsed.scheme not in ALLOWED_SOURCE_SCHEMES:
342
+ raise CourtImportError(
343
+ f"{record_name} 'source_url' uses unsupported scheme {parsed.scheme!r}"
344
+ )
345
+
346
+
347
+ def validate_court_case(case: Any, index: int) -> Dict[str, Any]:
348
+ if not isinstance(case, dict):
349
+ raise CourtImportError(f"case {index} is not an object")
350
+ for field, expected_type in COURT_CASE_REQUIRED_FIELDS.items():
351
+ if field not in case:
352
+ raise CourtImportError(f"case {index} missing required field '{field}'")
353
+ value = case[field]
354
+ if not isinstance(value, expected_type):
355
+ raise CourtImportError(
356
+ f"case {index} field '{field}' must be {expected_type.__name__}"
357
+ )
358
+ _check_text_length(value, field)
359
+
360
+ parties = case.get("parties", {})
361
+ if not isinstance(parties, dict):
362
+ raise CourtImportError(f"case {index} 'parties' must be an object")
363
+
364
+ source_url = case.get("source_url")
365
+ if source_url is not None:
366
+ _validate_source_url(source_url, f"case {index}")
367
+ fetched_at = case.get("fetched_at")
368
+ if fetched_at is not None:
369
+ _validate_datetime(fetched_at, "fetched_at")
370
+
371
+ source_record_id = case.get("source_record_id")
372
+ if source_record_id is not None and not isinstance(source_record_id, str):
373
+ raise CourtImportError(f"case {index} 'source_record_id' must be a string")
374
+
375
+ return {
376
+ "case_id": case["case_id"],
377
+ "case_number": case["case_number"],
378
+ "court": case["court"],
379
+ "plaintiff": str(parties.get("plaintiff", "")),
380
+ "defendant": str(parties.get("defendant", "")),
381
+ "source_name": case["source_name"],
382
+ "source_record_id": str(source_record_id) if source_record_id is not None else None,
383
+ "fetched_at": fetched_at,
384
+ "source_url": source_url,
385
+ }
386
+
387
+
388
+ def validate_court_event(event: Any, index: int, case_index: int) -> Dict[str, Any]:
389
+ if not isinstance(event, dict):
390
+ raise CourtImportError(f"event {index} of case {case_index} is not an object")
391
+ for field, expected_type in COURT_EVENT_REQUIRED_FIELDS.items():
392
+ if field not in event:
393
+ raise CourtImportError(
394
+ f"event {index} of case {case_index} missing required field '{field}'"
395
+ )
396
+ value = event[field]
397
+ if not isinstance(value, expected_type):
398
+ raise CourtImportError(
399
+ f"event {index} of case {case_index} field '{field}' must be {expected_type.__name__}"
400
+ )
401
+ _check_text_length(value, field)
402
+
403
+ event_date = event.get("event_date")
404
+ if event_date is not None:
405
+ _validate_iso_date(event_date, "event_date")
406
+ fetched_at = event.get("fetched_at")
407
+ if fetched_at is not None:
408
+ _validate_datetime(fetched_at, "fetched_at")
409
+ source_url = event.get("source_url")
410
+ if source_url is not None:
411
+ _validate_source_url(source_url, f"event {index} of case {case_index}")
412
+
413
+ doc_text = str(event.get("doc_text", ""))
414
+ _check_text_length(doc_text, "doc_text")
415
+
416
+ return {
417
+ "event_id": event["event_id"],
418
+ "event_type": event["event_type"],
419
+ "event_date": event_date,
420
+ "doc_text": doc_text,
421
+ "source_name": event["source_name"],
422
+ "source_record_id": event["source_record_id"],
423
+ "fetched_at": fetched_at,
424
+ "source_url": source_url,
425
+ }
426
+
427
+
428
+ def _prepare_court_cases(data: Any) -> List[Dict[str, Any]]:
429
+ if not isinstance(data, dict):
430
+ raise CourtImportError("top-level JSON value must be an object")
431
+ if "cases" not in data:
432
+ raise CourtImportError("top-level object must contain a 'cases' array")
433
+ cases = data["cases"]
434
+ if not isinstance(cases, list):
435
+ raise CourtImportError("'cases' must be an array")
436
+ if len(cases) > MAX_CASES:
437
+ raise CourtImportError(
438
+ f"import limit exceeded: {len(cases)} cases (maximum {MAX_CASES})"
439
+ )
440
+
441
+ total_events = 0
442
+ normalized = []
443
+ for i, case in enumerate(cases):
444
+ normalized_case = validate_court_case(case, i)
445
+ events = case.get("events", [])
446
+ if not isinstance(events, list):
447
+ raise CourtImportError(f"case {i} 'events' must be an array")
448
+ if len(events) > MAX_EVENTS:
449
+ raise CourtImportError(
450
+ f"case {i} event limit exceeded: {len(events)} events (maximum {MAX_EVENTS})"
451
+ )
452
+ total_events += len(events)
453
+ if total_events > MAX_EVENTS:
454
+ raise CourtImportError(
455
+ f"import event limit exceeded: {total_events} events (maximum {MAX_EVENTS})"
456
+ )
457
+ normalized_case["events"] = [
458
+ validate_court_event(e, j, i) for j, e in enumerate(events)
459
+ ]
460
+ normalized.append(normalized_case)
461
+ return normalized
462
+
463
+
464
+ def load_court_case_fixture(file_path: Path) -> List[Dict[str, Any]]:
465
+ file_path = _validate_path(file_path)
466
+ data = _load_json(file_path)
467
+ return _prepare_court_cases(data)
468
+
469
+
470
+ def _existing_case_ids(conn: sqlite3.Connection, ids: List[str]) -> set:
471
+ if not ids:
472
+ return set()
473
+ placeholders = ",".join("?" * len(ids))
474
+ rows = conn.execute(
475
+ f"SELECT case_id FROM cases WHERE case_id IN ({placeholders})", ids
476
+ ).fetchall()
477
+ return {row["case_id"] for row in rows}
478
+
479
+
480
+ def _existing_event_ids(conn: sqlite3.Connection, ids: List[str]) -> set:
481
+ if not ids:
482
+ return set()
483
+ placeholders = ",".join("?" * len(ids))
484
+ rows = conn.execute(
485
+ f"SELECT event_id FROM events WHERE event_id IN ({placeholders})", ids
486
+ ).fetchall()
487
+ return {row["event_id"] for row in rows}
488
+
489
+
490
+ def import_court_cases(conn: sqlite3.Connection, file_path: Path) -> Dict[str, Any]:
491
+ cases = load_court_case_fixture(file_path)
492
+ imported_at = _now_iso()
493
+
494
+ total_events = sum(len(c.get("events", [])) for c in cases)
495
+ case_ids = [c["case_id"] for c in cases]
496
+ event_ids = [e["event_id"] for c in cases for e in c.get("events", [])]
497
+
498
+ with conn:
499
+ existing_cases = _existing_case_ids(conn, case_ids)
500
+ existing_events = _existing_event_ids(conn, event_ids)
501
+
502
+ inserted = 0
503
+ skipped = 0
504
+ events_inserted = 0
505
+ events_skipped = 0
506
+ seen_cases: set = set()
507
+ seen_events: set = set()
508
+
509
+ for case in cases:
510
+ case_id = case["case_id"]
511
+ if case_id in existing_cases or case_id in seen_cases:
512
+ skipped += 1
513
+ events_skipped += len(case.get("events", []))
514
+ continue
515
+
516
+ conn.execute(
517
+ """
518
+ INSERT OR IGNORE INTO cases
519
+ (case_id, case_number, court, plaintiff, defendant,
520
+ source_name, source_record_id, fetched_at, source_url, imported_at)
521
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
522
+ """,
523
+ (
524
+ case_id,
525
+ case["case_number"],
526
+ case["court"],
527
+ case["plaintiff"],
528
+ case["defendant"],
529
+ case["source_name"],
530
+ case["source_record_id"],
531
+ case["fetched_at"],
532
+ case["source_url"],
533
+ imported_at,
534
+ ),
535
+ )
536
+ seen_cases.add(case_id)
537
+ inserted += 1
538
+
539
+ for event in case.get("events", []):
540
+ event_id = event["event_id"]
541
+ if event_id in existing_events or event_id in seen_events:
542
+ events_skipped += 1
543
+ continue
544
+ conn.execute(
545
+ """
546
+ INSERT OR IGNORE INTO events
547
+ (event_id, case_id, event_date, event_type, doc_text,
548
+ source_name, source_record_id, fetched_at, source_url, imported_at)
549
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
550
+ """,
551
+ (
552
+ event_id,
553
+ case_id,
554
+ event["event_date"],
555
+ event["event_type"],
556
+ event["doc_text"],
557
+ event["source_name"],
558
+ event["source_record_id"],
559
+ event["fetched_at"],
560
+ event["source_url"],
561
+ imported_at,
562
+ ),
563
+ )
564
+ seen_events.add(event_id)
565
+ events_inserted += 1
566
+
567
+ return {
568
+ "kind": "court-case",
569
+ "inserted": inserted,
570
+ "skipped": skipped,
571
+ "events_inserted": events_inserted,
572
+ "events_skipped": events_skipped,
573
+ "total_cases": len(cases),
574
+ "total_events": total_events,
575
+ }