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.
- sud_cli/__init__.py +3 -0
- sud_cli/__main__.py +4 -0
- sud_cli/_wm.py +118 -0
- sud_cli/agenda.py +90 -0
- sud_cli/autofill.py +192 -0
- sud_cli/autofill_markers.json +56 -0
- sud_cli/backup.py +256 -0
- sud_cli/calendar_ics.py +191 -0
- sud_cli/case_diff.py +165 -0
- sud_cli/case_search_bridge.py +1322 -0
- sud_cli/chain.py +226 -0
- sud_cli/cli.py +3721 -0
- sud_cli/completeness.py +105 -0
- sud_cli/config.py +20 -0
- sud_cli/conflict.py +210 -0
- sud_cli/conflicts.py +207 -0
- sud_cli/court_alerts.py +156 -0
- sud_cli/court_source.py +108 -0
- sud_cli/data/completeness_rules.json +18 -0
- sud_cli/data/key_rates.json +59 -0
- sud_cli/data/npa_historical_editions.json +19 -0
- sud_cli/db.py +2072 -0
- sud_cli/deadline_matrix.py +99 -0
- sud_cli/deadline_state.py +25 -0
- sud_cli/deadlines.py +8 -0
- sud_cli/deadlines_state.py +1 -0
- sud_cli/deferral.py +5 -0
- sud_cli/efrsb_manual.py +85 -0
- sud_cli/efrsb_public.py +210 -0
- sud_cli/egrul_nalog.py +150 -0
- sud_cli/explain.py +190 -0
- sud_cli/fees_extra.py +104 -0
- sud_cli/forms.py +204 -0
- sud_cli/fssp_manual.py +91 -0
- sud_cli/fssp_semi.py +285 -0
- sud_cli/fssp_stats.py +105 -0
- sud_cli/government_transfers.json +8 -0
- sud_cli/holidays_rf.json +62 -0
- sud_cli/idempotency.py +78 -0
- sud_cli/importing.py +575 -0
- sud_cli/interest.py +144 -0
- sud_cli/jurisdiction.py +177 -0
- sud_cli/jurisdiction_rules.json +265 -0
- sud_cli/legal_deadlines.py +278 -0
- sud_cli/legal_engine.py +558 -0
- sud_cli/legal_rules.json +1058 -0
- sud_cli/limitation.py +70 -0
- sud_cli/mcp_server.py +1594 -0
- sud_cli/metrics.py +142 -0
- sud_cli/npa_as_of.py +21 -0
- sud_cli/output.py +89 -0
- sud_cli/payment.py +24 -0
- sud_cli/pdn.py +259 -0
- sud_cli/plenum_corpus.py +134 -0
- sud_cli/plenums_seed.json +45 -0
- sud_cli/practice_status.py +136 -0
- sud_cli/pravo_publication.py +361 -0
- sud_cli/pretrial.py +151 -0
- sud_cli/production_calendar.py +233 -0
- sud_cli/providers.py +95 -0
- sud_cli/readiness.py +234 -0
- sud_cli/regional_holidays.py +37 -0
- sud_cli/reminders.py +92 -0
- sud_cli/reports.py +349 -0
- sud_cli/sud_netguard.py +943 -0
- sud_cli/timeline_audit.py +80 -0
- sud_cli/verify.py +201 -0
- sud_cli-0.1.1.dist-info/METADATA +119 -0
- sud_cli-0.1.1.dist-info/RECORD +74 -0
- sud_cli-0.1.1.dist-info/WHEEL +4 -0
- sud_cli-0.1.1.dist-info/entry_points.txt +2 -0
- sud_cli-0.1.1.dist-info/licenses/AUTHORS +5 -0
- sud_cli-0.1.1.dist-info/licenses/LICENSE.md +661 -0
- 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
|
sud_cli/plenum_corpus.py
ADDED
|
@@ -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"])}
|