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/metrics.py ADDED
@@ -0,0 +1,142 @@
1
+ """история §5 (S-86): метрики state-dir — счётчики из таблиц, без выдуманных KPI.
2
+
3
+ Границы:
4
+ - ни одного «индекса здоровья», «риска» или «прогресса»: поле за полем это
5
+ COUNT(*)/GROUP BY по реальным таблицам sud-cli;
6
+ - письма/журнал не трогаем — это шина a2a, не данные sud (bus-free);
7
+ - last backup берётся из ``meta``: путь пишет сам ``backup create``, и ничего
8
+ не вынюхивается по диску;
9
+ - пустая таблица даёт 0, а отсутствующая секция — []/{}: числа выводятся
10
+ только из запросов, «среднего» не выдумываем.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from datetime import date as _date
15
+ from typing import Any, Dict, Optional
16
+
17
+ from .db import (
18
+ LEGAL_CASE_STATUSES,
19
+ get_connection,
20
+ init_db,
21
+ list_all_deadline_states,
22
+ list_cases,
23
+ )
24
+
25
+ #: Окна совпадают с agenda/reminders: своё «среднее» окно здесь не изобретается.
26
+ NEAR_DAYS = 30
27
+ REMINDERS_DAYS = 7
28
+
29
+ METRICS_DISCLAIMER = (
30
+ "Метрики — счётчики строк локального реестра (state tables). Это не KPI "
31
+ "и не оценка качества работы: sud-cli наблюдает только внесённые данные."
32
+ )
33
+
34
+ _TABLES_WITH_CASE = (
35
+ "events",
36
+ "case_amounts",
37
+ "case_documents",
38
+ "case_notes",
39
+ "deadline_states",
40
+ "evidence_items",
41
+ "case_representatives",
42
+ )
43
+
44
+
45
+ def _counts_by_status(cases) -> Dict[str, int]:
46
+ """Все статусы enum'а всегда на месте: пустой портфель даёт нули, не {}."""
47
+ out = {s: 0 for s in LEGAL_CASE_STATUSES}
48
+ for c in cases:
49
+ key = c.get("status") if c.get("status") is not None else "unknown"
50
+ out[key] = out.get(key, 0) + 1
51
+ return out
52
+
53
+
54
+ def metrics_payload(state_dir: Any, today: Optional[_date] = None,
55
+ near_days: int = NEAR_DAYS) -> Dict[str, Any]:
56
+ init_db(state_dir)
57
+ conn = get_connection(state_dir)
58
+ try:
59
+ today = today or _date.today()
60
+ cases = list_cases(conn)
61
+ states = list_all_deadline_states(conn)
62
+
63
+ states_by_state: Dict[str, int] = {}
64
+ for s in states:
65
+ key = str(s.get("state"))
66
+ states_by_state[key] = states_by_state.get(key, 0) + 1
67
+
68
+ from .reminders import due_items
69
+ from .reports import _near_overdue
70
+
71
+ reminders_pending = len(due_items(states, today, REMINDERS_DAYS))
72
+ near, over, _agenda = _near_overdue(state_dir, today, near_days)
73
+
74
+ rows_per_table: Dict[str, int] = {}
75
+ for table in _TABLES_WITH_CASE:
76
+ rows_per_table[table] = int(
77
+ conn.execute(f"SELECT COUNT(*) AS n FROM {table}").fetchone()["n"]
78
+ )
79
+
80
+ docs_revisions = int(conn.execute(
81
+ "SELECT COUNT(*) AS n FROM case_documents WHERE supersedes IS NOT NULL"
82
+ ).fetchone()["n"])
83
+ evidence_without_hash = int(conn.execute(
84
+ "SELECT COUNT(*) AS n FROM evidence_items WHERE sha256 IS NULL"
85
+ ).fetchone()["n"])
86
+ reps_by_side = {
87
+ str(row["side"]): int(row["n"])
88
+ for row in conn.execute(
89
+ "SELECT side, COUNT(*) AS n FROM case_representatives "
90
+ "GROUP BY side ORDER BY side ASC"
91
+ ).fetchall()
92
+ }
93
+
94
+ from .backup import last_backup
95
+
96
+ last = last_backup(state_dir)
97
+ finally:
98
+ conn.close()
99
+
100
+ return {
101
+ "as_of": today.isoformat(),
102
+ "cases": {"total": len(cases), "by_status": _counts_by_status(cases)},
103
+ "events": rows_per_table["events"],
104
+ "deadline_states": {"total": len(states), "by_state": states_by_state},
105
+ "deadlines_window": {
106
+ "near_days": near_days,
107
+ "near": len(near),
108
+ "overdue": len(over),
109
+ },
110
+ "documents": {"total": rows_per_table["case_documents"],
111
+ "revisions": docs_revisions},
112
+ "evidence": {"total": rows_per_table["evidence_items"],
113
+ "without_hash": evidence_without_hash},
114
+ "representatives": {"total": rows_per_table["case_representatives"],
115
+ "by_side": reps_by_side},
116
+ "notes": rows_per_table["case_notes"],
117
+ "amounts": rows_per_table["case_amounts"],
118
+ "reminders": {"pending_days": REMINDERS_DAYS, "pending": reminders_pending},
119
+ "rows_per_table": rows_per_table,
120
+ "backup": {
121
+ "last": last,
122
+ "recorded_in": "meta",
123
+ },
124
+ "disclaimer": METRICS_DISCLAIMER,
125
+ }
126
+
127
+
128
+ def doctor_metrics(state_dir: Any) -> Dict[str, Any]:
129
+ """Сжатая секция для doctor: те же счётчики, без вложенных окон."""
130
+ payload = metrics_payload(state_dir)
131
+ return {
132
+ "cases": payload["cases"],
133
+ "events": payload["events"],
134
+ "deadline_states": payload["deadline_states"],
135
+ "deadlines_window": payload["deadlines_window"],
136
+ "documents": payload["documents"],
137
+ "evidence": payload["evidence"],
138
+ "representatives": payload["representatives"],
139
+ "reminders": payload["reminders"],
140
+ "backup": payload["backup"],
141
+ "disclaimer": payload["disclaimer"],
142
+ }
sud_cli/npa_as_of.py ADDED
@@ -0,0 +1,21 @@
1
+ """Lookup of bundled historical editions of selected Russian codes."""
2
+
3
+ import json
4
+ from datetime import date
5
+ from pathlib import Path
6
+
7
+ DATA_PATH = Path(__file__).with_name("data") / "npa_historical_editions.json"
8
+
9
+
10
+ def lookup_as_of(act, article, on_date, path=None):
11
+ try:
12
+ target = date.fromisoformat(on_date) if isinstance(on_date, str) else on_date
13
+ records = json.loads(Path(path or DATA_PATH).read_text(encoding="utf-8"))
14
+ rows = records.get(act, {}).get(str(article), [])
15
+ eligible = [row for row in rows if date.fromisoformat(row["effective_date"]) <= target]
16
+ if not eligible:
17
+ return {"status": "error", "detail": "historical edition not found for requested date"}
18
+ row = max(eligible, key=lambda item: item["effective_date"])
19
+ return {"status": "ok", "act": act, "article": str(article), **row}
20
+ except (OSError, json.JSONDecodeError, ValueError, TypeError) as exc:
21
+ return {"status": "error", "detail": str(exc)}
sud_cli/output.py ADDED
@@ -0,0 +1,89 @@
1
+ """Stable output formatting for sud-cli."""
2
+
3
+ import json
4
+ import sys
5
+
6
+
7
+ HUMAN = "human"
8
+ JSON = "json"
9
+
10
+
11
+ def emit(data, json_mode=False, stream=None):
12
+ """Emit a stable result document to *stream*.
13
+
14
+ In JSON mode the document is always a single JSON object with at least:
15
+ - status: "ok" | "error" | "blocked"
16
+ - data: the payload (None for errors/blocks without payload)
17
+ - meta: runtime metadata (command, state_dir, version)
18
+ In human mode a concise plain-text rendering is printed.
19
+ """
20
+ if stream is None:
21
+ stream = sys.stdout
22
+ if json_mode:
23
+ stream.write(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True))
24
+ stream.write("\n")
25
+ else:
26
+ _render_human(data, stream)
27
+
28
+
29
+ def _render_human(data, stream):
30
+ status = data.get("status")
31
+ payload = data.get("data")
32
+ meta = data.get("meta", {})
33
+
34
+ if status == "ok":
35
+ if isinstance(payload, list):
36
+ stream.write(f"{len(payload)} result(s)\n")
37
+ for item in payload:
38
+ stream.write(_format_item(item) + "\n")
39
+ elif isinstance(payload, dict):
40
+ for key, value in sorted(payload.items()):
41
+ stream.write(f"{key}: {value}\n")
42
+ else:
43
+ stream.write(str(payload) + "\n")
44
+ elif status == "blocked":
45
+ stream.write(f"BLOCKED / NOT_IMPLEMENTED: {payload}\n")
46
+ elif status == "error":
47
+ stream.write(f"ERROR: {payload}\n")
48
+ else:
49
+ stream.write(str(payload) + "\n")
50
+
51
+ if meta.get("truncated"):
52
+ stream.write("(results truncated)\n")
53
+ if "limit" in meta:
54
+ stream.write(f"limit: {meta['limit']}\n")
55
+ if "total" in meta:
56
+ stream.write(f"total: {meta['total']}\n")
57
+
58
+
59
+ def _format_item(item):
60
+ if isinstance(item, dict):
61
+ return " | ".join(f"{k}={v}" for k, v in sorted(item.items()))
62
+ return str(item)
63
+
64
+
65
+ def result(payload, meta=None, json_mode=False, stream=None):
66
+ meta = meta or {}
67
+ emit({"status": "ok", "data": payload, "meta": meta}, json_mode=json_mode, stream=stream)
68
+
69
+
70
+ def error(message, meta=None, json_mode=False, stream=None, code=1):
71
+ meta = meta or {}
72
+ if stream is None:
73
+ stream = sys.stderr
74
+ emit({"status": "error", "data": message, "meta": meta}, json_mode=json_mode, stream=stream)
75
+ return code
76
+
77
+
78
+ def blocked(message, meta=None, json_mode=False, stream=None):
79
+ """Report a command that was not attempted (gate closed, capability absent).
80
+
81
+ Writes to stderr like ``error``: the project convention is that stdout
82
+ carries only successful payloads, so an agent parsing stdout must not meet
83
+ a refusal there.
84
+ """
85
+ meta = meta or {}
86
+ if stream is None:
87
+ stream = sys.stderr
88
+ emit({"status": "blocked", "data": message, "meta": meta}, json_mode=json_mode, stream=stream)
89
+ return 1
sud_cli/payment.py ADDED
@@ -0,0 +1,24 @@
1
+ """Payment details and purpose text for state fees."""
2
+
3
+ KBK = {
4
+ "eks": "182 1 08 01000 01 1050 110",
5
+ "arbitration": "182 1 08 01000 01 1050 110",
6
+ "general_jurisdiction": "182 1 08 03010 01 1050 110",
7
+ "soyu": "182 1 08 03010 01 1050 110",
8
+ "supreme_court": "182 1 08 03000 01 1050 110",
9
+ }
10
+
11
+
12
+ def payment_details(jurisdiction, amount, court, defendant):
13
+ key = "general_jurisdiction" if jurisdiction in {"soyu", "general_jurisdiction", "kas"} else jurisdiction
14
+ if key not in KBK:
15
+ raise ValueError(f"unknown jurisdiction: {jurisdiction}")
16
+ amount = int(amount)
17
+ return {
18
+ "status": "ok",
19
+ "kbk": KBK[key],
20
+ "eks": "Единый казначейский счет",
21
+ "jurisdiction": jurisdiction,
22
+ "amount_rub": amount,
23
+ "purpose": f"Государственная пошлина за рассмотрение искового заявления в {court} к {defendant}. Сумма: {amount} руб. Без НДС.",
24
+ }
sud_cli/pdn.py ADDED
@@ -0,0 +1,259 @@
1
+ """история §5 (S-07): права субъекта — маш-читаемый экспорт дела и полное уничтожение.
2
+
3
+ Границы:
4
+ - НИКАКИХ нормативных текстов от себя: основания, срок хранения и перечень
5
+ прав берутся дословно из блока ``pdn_policy`` правил (legal_rules.json,
6
+ v1.4.0 финальная) — строки не печатаются по памяти и не хардкодятся;
7
+ - экспорт = данные для субъекта/юриста: полные, без выдуманных сокращений и
8
+ без «маскирования на глаз»; audit-записи по делу включаются как есть,
9
+ потому что вырезание части данных — это и есть сокращение;
10
+ - purge = уничтожение: каскад строк всех таблиц дела + физические файлы из
11
+ case_documents/evidence, а в audit — только ФАКТ (id/дата/actor/счётчики).
12
+ ПДн в audit не попадает: ни имён сторон, ни текстов, ни путей файлов.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, Dict, List, Optional
17
+
18
+ from .db import (
19
+ PURGE_TABLES,
20
+ case_file_paths,
21
+ case_row_counts,
22
+ get_case,
23
+ get_case_card,
24
+ get_connection,
25
+ init_db,
26
+ list_case_documents,
27
+ purge_case,
28
+ read_deadline_audit,
29
+ verify_case_evidence,
30
+ evidence_summary,
31
+ )
32
+
33
+ EXPORT_FORMATS = ("json", "md")
34
+
35
+ #: Что именно выгружается — список секций печатается в конверте, чтобы
36
+ #: приёмщик видел покрытие, а не догадывался о нём.
37
+ EXPORT_SECTIONS = (
38
+ "case",
39
+ "events",
40
+ "amounts",
41
+ "documents",
42
+ "evidence",
43
+ "representatives",
44
+ "deadline_states",
45
+ "notes",
46
+ "audit",
47
+ )
48
+
49
+
50
+ def pdn_policy(rules: Optional[dict] = None) -> Dict[str, Any]:
51
+ """Блок pdn_policy из правил (дословно, без правок и без дописываний)."""
52
+ if rules is None:
53
+ from .legal_engine import load_rules
54
+
55
+ rules = load_rules()
56
+ return rules.get("pdn_policy", {}) or {}
57
+
58
+
59
+ def pdn_reference_block(policy: Dict[str, Any]) -> Dict[str, Any]:
60
+ """Ссылочный блок для конвертов: только поля pdn_policy из правил."""
61
+ return {
62
+ "source": "legal_rules.json:pdn_policy",
63
+ "legal_status": policy.get("legal_status"),
64
+ "grounds": list(policy.get("grounds") or []),
65
+ "retention": policy.get("retention"),
66
+ "subject_rights": list(policy.get("subject_rights") or []),
67
+ }
68
+
69
+
70
+ def export_case(state_dir: Any, case_id: str, fmt: str = "json",
71
+ rules: Optional[dict] = None) -> Optional[Dict[str, Any]]:
72
+ """Полный дамп дела + ссылочный блок pdn_policy (дословно из правил)."""
73
+ if fmt not in EXPORT_FORMATS:
74
+ raise ValueError(f"unknown format: {fmt} (допустимо: {', '.join(EXPORT_FORMATS)})")
75
+ if rules is None:
76
+ from .legal_engine import load_rules
77
+
78
+ rules = load_rules()
79
+ init_db(state_dir)
80
+ conn = get_connection(state_dir)
81
+ try:
82
+ if get_case(conn, case_id) is None:
83
+ return None
84
+ card = get_case_card(conn, case_id)
85
+ docs = list_case_documents(conn, case_id)
86
+ evidence_items = verify_case_evidence(conn, case_id)
87
+ summary = evidence_summary(evidence_items)
88
+ rows = case_row_counts(conn, case_id)
89
+ audit = read_deadline_audit(state_dir, case_id)
90
+ finally:
91
+ conn.close()
92
+
93
+ policy = pdn_policy(rules)
94
+ payload: Dict[str, Any] = {
95
+ "case_id": case_id,
96
+ "format": fmt,
97
+ "sections": list(EXPORT_SECTIONS),
98
+ "row_counts": {t: rows.get(t, 0) for t in PURGE_TABLES},
99
+ "case": card.get("case"),
100
+ "parties": card.get("parties"),
101
+ "events": card.get("events"),
102
+ "amounts": card.get("amounts"),
103
+ "documents": docs,
104
+ "evidence": {
105
+ "items": evidence_items,
106
+ "summary": {k: v for k, v in summary.items() if k != "problem_items"},
107
+ },
108
+ "representatives": card.get("representatives"),
109
+ "deadline_states": card.get("deadlines"),
110
+ "notes": card.get("notes"),
111
+ # audit включается целиком: вырезание записей = сокращение данных,
112
+ # а экспорт для субъекта/юриста по определению без сокращений.
113
+ "audit": audit,
114
+ "pdn": pdn_reference_block(policy),
115
+ "completeness_note": (
116
+ "Дамп содержит все строки реестра по этому делу: ни одна секция не "
117
+ "сокрощена, не «обезличена на лету» и не усечена лимитом. Числа "
118
+ "row_counts — фактическое количество удалённых/выгруженных строк, "
119
+ "а не оценка."
120
+ ),
121
+ }
122
+ return payload
123
+
124
+
125
+ def render_export_md(payload: Dict[str, Any]) -> str:
126
+ """md-рендер дампа: заголовки секций + таблицы/списки из фактических строк."""
127
+ case = payload.get("case") or {}
128
+ lines: List[str] = [
129
+ f"# Экспорт дела {payload['case_id']}",
130
+ "",
131
+ f"- номер: {case.get('case_number')}",
132
+ f"- суд: {case.get('court')}",
133
+ f"- статус: {case.get('status')}",
134
+ f"- instance: {case.get('instance')}",
135
+ f"- owner: {case.get('owner')}",
136
+ f"- истец: {(payload.get('parties') or {}).get('plaintiff')}",
137
+ f"- ответчик: {(payload.get('parties') or {}).get('defendant')}",
138
+ "",
139
+ "## Покрытие (row_counts)",
140
+ "",
141
+ "| таблица | строк |",
142
+ "| --- | --- |",
143
+ ]
144
+ for table in PURGE_TABLES:
145
+ lines.append(f"| {table} | {payload['row_counts'].get(table, 0)} |")
146
+
147
+ def _block(title: str, items: Any) -> None:
148
+ lines.append("")
149
+ lines.append(f"## {title}")
150
+ lines.append("")
151
+ if not items:
152
+ lines.append("_пусто_")
153
+ return
154
+ if isinstance(items, dict):
155
+ items = [items]
156
+ for item in items:
157
+ if isinstance(item, dict):
158
+ for key in sorted(item):
159
+ value = item[key]
160
+ if value is None or value == "" or value == []:
161
+ continue
162
+ lines.append(f"- **{key}**: {value}")
163
+ lines.append("")
164
+ else:
165
+ lines.append(f"- {item}")
166
+
167
+ _block("События", payload.get("events"))
168
+ _block("Суммы", payload.get("amounts"))
169
+ _block("Документы (с хэшами)", payload.get("documents"))
170
+ _block("Доказательства", (payload.get("evidence") or {}).get("items"))
171
+ _block("Представители", (payload.get("representatives") or {}).get("items"))
172
+ _block("Состояния сроков", payload.get("deadline_states"))
173
+ _block("Заметки", payload.get("notes"))
174
+ _block("Журнал правок сроков (audit)", payload.get("audit"))
175
+
176
+ pdn = payload.get("pdn") or {}
177
+ lines += [
178
+ "",
179
+ "## ПДн: основания и срок (дословно из pdn_policy правил)",
180
+ "",
181
+ f"Источник: {pdn.get('source')}",
182
+ f"Статус блока: {pdn.get('legal_status')}",
183
+ "",
184
+ "Основания обработки:",
185
+ ]
186
+ lines += [f"- {g}" for g in (pdn.get("grounds") or [])] or ["- (в правилах нет)"]
187
+ lines += ["", "Срок хранения:", f"- {pdn.get('retention')}", "",
188
+ "Права субъекта:"]
189
+ lines += [f"- {r}" for r in (pdn.get("subject_rights") or [])] or ["- (в правилах нет)"]
190
+ lines += ["", payload.get("completeness_note", "")]
191
+ return "\n".join(lines) + "\n"
192
+
193
+
194
+ def purge_preview(state_dir: Any, case_id: str,
195
+ rules: Optional[dict] = None) -> Optional[Dict[str, Any]]:
196
+ """Превью уничтожения: счётчики строк + файлы + retention-hint из правил."""
197
+ if rules is None:
198
+ from .legal_engine import load_rules
199
+
200
+ rules = load_rules()
201
+ init_db(state_dir)
202
+ conn = get_connection(state_dir)
203
+ try:
204
+ if get_case(conn, case_id) is None:
205
+ return None
206
+ counts = case_row_counts(conn, case_id)
207
+ files = case_file_paths(conn, case_id)
208
+ finally:
209
+ conn.close()
210
+ policy = pdn_policy(rules)
211
+ return {
212
+ "case_id": case_id,
213
+ "will_delete_rows": {t: counts.get(t, 0) for t in PURGE_TABLES},
214
+ "will_delete_files": files,
215
+ "file_count": len(files),
216
+ "audit_after_purge": (
217
+ "в audit попадёт только запись о факте: case id, дата, actor, "
218
+ "счётчики; содержимое дела в журнал не пишется"
219
+ ),
220
+ "retention_hint": policy.get("retention"),
221
+ "pdn": pdn_reference_block(policy),
222
+ "requires": "--yes (деструктив без подтверждения не исполняется)",
223
+ }
224
+
225
+
226
+ def purge_apply(state_dir: Any, case_id: str, confirmed: bool = False,
227
+ actor: str = "cli", rules: Optional[dict] = None,
228
+ remove_files: bool = True) -> Dict[str, Any]:
229
+ """Уничтожение дела. Без confirmed — только превью, ноль изменений.
230
+
231
+ remove_files=False (--keep-files) оставляет строки case_documents нетронутыми
232
+ по отношению к диску: файлы чужие (общий каталог сканов), а строк дела это
233
+ не меняет — каскад таблиц всё равно полный.
234
+ """
235
+ if rules is None:
236
+ from .legal_engine import load_rules
237
+
238
+ rules = load_rules()
239
+ preview = purge_preview(state_dir, case_id, rules=rules)
240
+ if preview is None:
241
+ return {"status": "not_found", "case_id": case_id}
242
+ if not confirmed:
243
+ return {
244
+ "status": "needs_confirmation",
245
+ "detail": "purge уничтожает данные дела безвозвратно: нужен --yes",
246
+ "preview": preview,
247
+ }
248
+ init_db(state_dir)
249
+ conn = get_connection(state_dir)
250
+ try:
251
+ result = purge_case(conn, state_dir, case_id, actor=actor,
252
+ remove_files=remove_files)
253
+ finally:
254
+ conn.close()
255
+ result["status"] = "purged"
256
+ result["files_kept"] = not remove_files
257
+ result["retention_hint"] = preview["retention_hint"]
258
+ result["pdn"] = preview["pdn"]
259
+ return result
@@ -0,0 +1,134 @@
1
+ """Корпус разъяснений высших судов (S-55, акт 25.10 §3).
2
+
3
+ Карточка судебного акта жёстко структурирована, цитаты несут sha256 фрагмента.
4
+ Наполнение — только нормативно-выверенными цитатами официальных публикаций
5
+ ВС РФ / ВАС РФ (источники: vsrf.ru/documents/, pravo.gov.ru, «Бюллетень ВС РФ»,
6
+ ФКЗ № 8-ФКЗ о сохраняющей силе актов ВАС РФ).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import re
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ SEED_FILE = Path(__file__).with_name("plenums_seed.json")
17
+ CARD_KEYS = ("doc_id", "court", "instance", "doc_type", "number", "date", "title",
18
+ "official_sources", "status", "paragraphs")
19
+ _DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
20
+ _ID_RE = re.compile(r"^[A-Z0-9_]+$")
21
+
22
+
23
+ class PlenumError(ValueError):
24
+ pass
25
+
26
+
27
+ def sha256_text(text: str) -> str:
28
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
29
+
30
+
31
+ def validate_card(card: dict) -> list[str]:
32
+ """Вернуть список нарушений (пусто = карточка валидна)."""
33
+ errs: list[str] = []
34
+ for k in CARD_KEYS:
35
+ if k not in card:
36
+ errs.append(f"нет поля {k}")
37
+ if not errs:
38
+ if not _ID_RE.match(str(card["doc_id"])):
39
+ errs.append("doc_id должен быть CAPS_UNDERSCORE")
40
+ if not _DATE_RE.match(str(card["date"])):
41
+ errs.append("date в формате YYYY-MM-DD")
42
+ if not isinstance(card["official_sources"], list) or not card["official_sources"]:
43
+ errs.append("official_sources: непустой список")
44
+ if not isinstance(card["paragraphs"], list) or not card["paragraphs"]:
45
+ errs.append("paragraphs: непустой список")
46
+ for p in card.get("paragraphs", []):
47
+ if "verbatim_text" not in p or "sha256" not in p:
48
+ errs.append("в параграфе нужны verbatim_text+sha256")
49
+ elif sha256_text(p["verbatim_text"]) != p["sha256"]:
50
+ errs.append(f"параграф {p.get('paragraph_no')}: sha256 не сходится")
51
+ return errs
52
+
53
+
54
+ def import_card(card: dict, *, rehash: bool = False) -> dict:
55
+ """Импортировать карточку. rehash=True — пересчитать sha256 (при расхождении
56
+ с источником-актом; исходный хэш сохраняется как act_recorded_sha256)."""
57
+ errs = validate_card(card)
58
+ if errs and rehash:
59
+ only_hash_mismatch = all("sha256" in e for e in errs)
60
+ if only_hash_mismatch:
61
+ for p in card["paragraphs"]:
62
+ p["act_recorded_sha256"] = p["sha256"]
63
+ p["sha256"] = sha256_text(p["verbatim_text"])
64
+ p["hash_note"] = "пересчитано при импорте 25.10; хэш акта сохранён"
65
+ errs = validate_card(card)
66
+ if errs:
67
+ raise PlenumError("; ".join(errs))
68
+ return card
69
+
70
+
71
+ def store_path(state_dir: Path) -> Path:
72
+ return Path(state_dir) / "plenums.json"
73
+
74
+
75
+ def _load(state_dir: Path) -> list[dict]:
76
+ sp = store_path(state_dir)
77
+ if sp.exists():
78
+ return json.loads(sp.read_text(encoding="utf-8")).get("cards", [])
79
+ seed = json.loads(SEED_FILE.read_text(encoding="utf-8")).get("cards", [])
80
+ sp.parent.mkdir(parents=True, exist_ok=True)
81
+ sp.write_text(json.dumps({"cards": seed}, ensure_ascii=False, indent=2), encoding="utf-8")
82
+ return json.loads(sp.read_text(encoding="utf-8")).get("cards", [])
83
+
84
+
85
+ def list_cards(state_dir: Path) -> list[dict]:
86
+ out = []
87
+ for c in _load(state_dir):
88
+ out.append({"doc_id": c["doc_id"], "date": c["date"], "number": c["number"],
89
+ "title": c["title"], "status": c["status"],
90
+ "instance": c.get("instance", ""), "doc_type": c.get("doc_type", "")})
91
+ return out
92
+
93
+
94
+ def get_card(state_dir: Path, doc_id: str) -> dict:
95
+ for c in _load(state_dir):
96
+ if c["doc_id"] == doc_id:
97
+ return c
98
+ raise PlenumError(f"карточка {doc_id} не найдена")
99
+
100
+
101
+ def search(state_dir: Path, text: str) -> list[dict]:
102
+ needle = (text or "").lower()
103
+ hits = []
104
+ for c in _load(state_dir):
105
+ for p in c.get("paragraphs", []):
106
+ blob = " ".join([str(p.get("key_topic", "")), p.get("verbatim_text", ""),
107
+ c.get("title", "")]).lower()
108
+ if needle and needle in blob:
109
+ hits.append({"doc_id": c["doc_id"], "paragraph_no": p.get("paragraph_no"),
110
+ "key_topic": p.get("key_topic", ""),
111
+ "norm_links": p.get("norm_links", []),
112
+ "excerpt": p["verbatim_text"][:240]})
113
+ return hits
114
+
115
+
116
+ def verify(state_dir: Path) -> dict:
117
+ cards = _load(state_dir)
118
+ bad = []
119
+ for c in cards:
120
+ for p in c.get("paragraphs", []):
121
+ if sha256_text(p.get("verbatim_text", "")) != p.get("sha256"):
122
+ bad.append(f"{c['doc_id']}#{p.get('paragraph_no')}")
123
+ return {"cards": len(cards), "ok": not bad, "broken": bad}
124
+
125
+
126
+ def add_card(state_dir: Path, card: dict, *, rehash: bool = True) -> dict:
127
+ card = import_card(card, rehash=rehash)
128
+ sp = store_path(state_dir)
129
+ cards = _load(state_dir)
130
+ if any(c["doc_id"] == card["doc_id"] for c in cards):
131
+ raise PlenumError(f"карточка {card['doc_id']} уже в корпусе")
132
+ cards.append(card)
133
+ sp.write_text(json.dumps({"cards": cards}, ensure_ascii=False, indent=2), encoding="utf-8")
134
+ return {"doc_id": card["doc_id"], "paragraphs": len(card["paragraphs"])}