feat: add Ministry of Justice document sync

This commit is contained in:
2026-08-06 08:25:14 +03:00
parent 1d398ebb00
commit 0eedcfcfbd
13 changed files with 569 additions and 17 deletions

2
.gitignore vendored
View File

@@ -11,5 +11,5 @@ __pycache__/
.env.*
!.env.example
# Local bot archive
# Local application archives
data/

View File

@@ -9,15 +9,15 @@ Telegram-бот — только часть рабочего окружения
## Текущее состояние
Сейчас реализован Telegram-бот-секретарь версии `0.2.2`. Он сохраняет сообщения
рабочих тем, формирует Markdown-экспорты и отмечает завершённые обсуждения.
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и первая backend-функция
версии `0.1.1`: возобновляемая выгрузка документов из ЦБД Минюста КР.
| Компонент | Версия | Состояние |
|---|---:|---|
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
| Backend | — | ещё не создан |
| Backend | `0.1.1` | реализована выгрузка документов ЦБД Минюста КР |
| Frontend | — | ещё не создан |
| Сбор и обработка правовых данных | — | ещё не созданы |
| Сбор и обработка правовых данных | `0.1.1` | реализован архиватор ЦБД Минюста КР |
| RAG и база знаний | — | ещё не созданы |
## Структура репозитория
@@ -30,6 +30,8 @@ docs/ структурированная документаци
team/ материалы для команды
tools/
telegram-bot/ бот рабочего Telegram-пространства
backend/
ingestion/ получение и обновление правовых источников
```
Каталоги для загрузки и обработки источников, RAG, backend и frontend будут
@@ -55,4 +57,4 @@ python3 -m unittest discover -s tools/telegram-bot -v
---
Акылдаш · Telegram-бот v0.2.2 · Backend — не создан · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.1.1 · Frontend — не создан

62
backend/README.md Normal file
View File

@@ -0,0 +1,62 @@
# Backend Акылдаш
Версия: `0.1.1`
Первая backend-область проекта — загрузка правовых документов из официального
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
`ingestion/minjust_cbd.py`: его можно запускать как CLI сейчас и импортировать
из будущего планировщика backend без запуска отдельного процесса.
## Пробная загрузка
Из корня репозитория:
```bash
python3 backend/ingestion/minjust_cbd.py --limit 10
```
В интерактивном терминале отображаются процент, количество документов,
ошибки, скорость и примерное оставшееся время.
Полная загрузка выполняется без `--limit`. По умолчанию архив сохраняется в
`data/minjust-cbd`, который исключён из Git. Повторный запуск пропускает уже
загруженные документы; `--refresh` принудительно проверяет их заново.
В версии `0.1.0` обновление существующих документов выполняется полной проверкой
через `--refresh`. Инкрементальную проверку по `lastmod` из sitemap следует
добавить вместе с backend-планировщиком, когда будет определена частота запуска.
## Защита API
Запросы выполняются последовательно, по умолчанию не чаще одного в секунду.
Timeout одного ответа — 60 секунд, число попыток — 5, задержка между повторами
растёт экспоненциально. При HTTP 429 учитывается заголовок `Retry-After`.
Лимит действует на один процесс, поэтому одновременно следует запускать только
один экземпляр загрузчика.
Более осторожный режим:
```bash
python3 backend/ingestion/minjust_cbd.py --requests-per-second 0.5
```
## Вызов из будущего backend
```python
from ingestion.minjust_cbd import sync_archive
result = sync_archive(output_path)
```
Планировщик, очередь задач и PostgreSQL пока не добавлены: модуль не зависит от
выбора будущего backend-фреймворка.
## Проверка
```bash
PYTHONPATH=backend python3 -m unittest backend/test_minjust_cbd.py -v
```
---
Акылдаш · Backend v0.1.1 · Frontend — не создан

View File

@@ -0,0 +1,358 @@
#!/usr/bin/env python3
"""Download and archive documents from the Kyrgyz Republic Ministry of Justice."""
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import logging
import os
import sqlite3
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable, Iterable
APP_VERSION = "0.1.1"
API_BASE_URL = "https://cbd.minjust.gov.kg/api/v1/OpenData/"
LANGUAGES = {"Rus": "ru", "Kyr": "ky"}
IMAGE_LANGUAGES = {"Russian": "ru", "Kyrgyz": "ky"}
LOGGER = logging.getLogger(__name__)
class CbdClient:
def __init__(
self,
requests_per_second: float = 1,
timeout: int = 60,
retries: int = 5,
) -> None:
if requests_per_second <= 0:
raise ValueError("requests_per_second must be greater than zero")
if timeout <= 0 or retries <= 0:
raise ValueError("timeout and retries must be greater than zero")
# ponytail: per-process limit; add a distributed lock before multiple workers.
self.interval = 1 / requests_per_second
self.timeout = timeout
self.retries = retries
self.last_request = 0.0
self.total_documents = 0
def request_json(self, method: str, parameters: Iterable[tuple[str, object]] = ()):
query = urllib.parse.urlencode(list(parameters), doseq=True)
extension = "" if method == "CheckAvailable" else ".json"
url = f"{API_BASE_URL}{method}{extension}" + (f"?{query}" if query else "")
for attempt in range(self.retries):
retry_delay = 2**attempt
wait = self.interval - (time.monotonic() - self.last_request)
if wait > 0:
time.sleep(wait)
request = urllib.request.Request(
url,
headers={
"Accept": "application/json",
"User-Agent": f"Akyldash/{APP_VERSION} (+https://cbd.minjust.gov.kg)",
},
)
try:
self.last_request = time.monotonic()
with urllib.request.urlopen(request, timeout=self.timeout) as response:
body = response.read()
return json.loads(body) if body else None
except urllib.error.HTTPError as error:
if error.code != 429 and error.code < 500:
raise
if error.code == 429:
try:
retry_delay = max(
retry_delay, float(error.headers.get("Retry-After", 0))
)
except ValueError:
pass
except (TimeoutError, urllib.error.URLError):
pass
if attempt + 1 < self.retries:
time.sleep(retry_delay)
raise RuntimeError(f"Ministry of Justice API request failed: {url}")
def check_available(self) -> None:
self.request_json("CheckAvailable")
def document_codes(self, limit: int | None = None, page_size: int = 1000):
first = self.request_json(
"GetDocumentListByQuery",
(("Property", "Code"), ("PageSize", page_size), ("PageNumber", 1)),
)
yielded = 0
page = first
page_number = 1
self.total_documents = min(first["TotalCount"], limit or first["TotalCount"])
while True:
for document in page["Documents"]:
yield document["Code"]
yielded += 1
if limit is not None and yielded >= limit:
return
if yielded >= page["TotalCount"]:
return
page_number += 1
page = self.request_json(
"GetDocumentListById",
(
("DocumentListId", first["Id"]),
("Property", "Code"),
("PageSize", page_size),
("PageNumber", page_number),
),
)
def document(self, code: int) -> dict:
return self.request_json(
"GetDocument",
(
("Code", code),
("Editions.Select", "all"),
("Editions.Data", "all"),
("Editions.Images.Select", "all"),
("Editions.Images.Data", "all"),
),
)
@dataclass(frozen=True)
class SyncResult:
discovered: int = 0
downloaded: int = 0
skipped: int = 0
failed: int = 0
def format_duration(seconds: float) -> str:
seconds = max(0, round(seconds))
hours, seconds = divmod(seconds, 3600)
minutes, seconds = divmod(seconds, 60)
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
def progress_line(
result: SyncResult, total: int, elapsed: float, width: int = 24
) -> str:
processed = result.discovered
fraction = processed / total if total else 0
filled = min(width, round(width * fraction))
rate = processed / elapsed if elapsed > 0 else 0
eta = (total - processed) / rate if rate else 0
return (
f"[{'#' * filled}{'-' * (width - filled)}] {fraction:6.2%} "
f"{processed}/{total} downloaded={result.downloaded} "
f"skipped={result.skipped} failed={result.failed} "
f"rate={rate:.2f}/s ETA={format_duration(eta)}"
)
def terminal_progress() -> Callable[[SyncResult, int], None]:
started = time.monotonic()
def update(result: SyncResult, total: int) -> None:
line = progress_line(result, total, time.monotonic() - started)
print(
f"\r{line}",
end="\n" if result.discovered >= total else "",
file=sys.stderr,
flush=True,
)
return update
def connect_manifest(path: Path) -> sqlite3.Connection:
path.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(path)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS documents (
code INTEGER PRIMARY KEY,
source_sha256 TEXT NOT NULL,
fetched_at TEXT NOT NULL
)
"""
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS errors (
code INTEGER PRIMARY KEY,
message TEXT NOT NULL,
failed_at TEXT NOT NULL
)
"""
)
return connection
def atomic_write(path: Path, content: bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as temporary:
temporary.write(content)
temporary_path = Path(temporary.name)
os.replace(temporary_path, path)
def json_bytes(value: object) -> bytes:
return (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode()
def archive_document(root: Path, document: dict) -> str:
code = int(document["Code"])
source = json.dumps(document, ensure_ascii=False, sort_keys=True).encode()
source_sha256 = hashlib.sha256(source).hexdigest()
directory = root / "documents" / str(code)
editions = document.get("Editions") or []
metadata = {key: value for key, value in document.items() if key != "Editions"}
atomic_write(directory / "metadata.json", json_bytes(metadata))
for edition in editions:
edition_directory = directory / "editions" / str(int(edition["Code"]))
edition_metadata = {key: value for key, value in edition.items() if key != "Data"}
edition_metadata["Images"] = [
{key: value for key, value in image.items() if key != "Data"}
for image in edition.get("Images") or []
]
atomic_write(edition_directory / "metadata.json", json_bytes(edition_metadata))
for source_language, filename in LANGUAGES.items():
html = (edition.get("Data") or {}).get(source_language)
if html:
atomic_write(edition_directory / f"{filename}.html", html.encode())
for image in edition.get("Images") or []:
if not image.get("Data"):
continue
language = IMAGE_LANGUAGES.get(image.get("Lang"), "unknown")
filename = Path(str(image.get("Name") or "image").replace("\\", "/")).name
if filename in {"", ".", ".."}:
raise ValueError(f"Invalid image filename in document {code}")
atomic_write(
edition_directory / "images" / language / filename,
base64.b64decode(image["Data"], validate=True),
)
return source_sha256
def sync_archive(
output: Path,
client: CbdClient | None = None,
limit: int | None = None,
refresh: bool = False,
progress: Callable[[SyncResult, int], None] | None = None,
) -> SyncResult:
client = client or CbdClient()
output.mkdir(parents=True, exist_ok=True)
connection = connect_manifest(output / "manifest.sqlite3")
known = {row[0] for row in connection.execute("SELECT code FROM documents")}
discovered = downloaded = skipped = failed = 0
client.check_available()
for code in client.document_codes(limit):
discovered += 1
# ponytail: refresh scans all documents; use sitemap lastmod when scheduled
# update checks become frequent enough for the extra mapping logic to pay off.
if code in known and not refresh:
skipped += 1
if progress:
progress(
SyncResult(discovered, downloaded, skipped, failed),
client.total_documents,
)
continue
try:
document = client.document(code)
checksum = archive_document(output, document)
fetched_at = datetime.now(timezone.utc).isoformat()
with connection:
connection.execute(
"""
INSERT INTO documents (code, source_sha256, fetched_at)
VALUES (?, ?, ?)
ON CONFLICT(code) DO UPDATE SET
source_sha256 = excluded.source_sha256,
fetched_at = excluded.fetched_at
""",
(int(document["Code"]), checksum, fetched_at),
)
connection.execute("DELETE FROM errors WHERE code = ?", (code,))
downloaded += 1
except Exception as error: # Continue the long-running archive after one bad record.
with connection:
connection.execute(
"""
INSERT INTO errors (code, message, failed_at) VALUES (?, ?, ?)
ON CONFLICT(code) DO UPDATE SET
message = excluded.message,
failed_at = excluded.failed_at
""",
(code, str(error), datetime.now(timezone.utc).isoformat()),
)
failed += 1
if progress:
progress(
SyncResult(discovered, downloaded, skipped, failed),
client.total_documents,
)
if discovered % 100 == 0:
LOGGER.info(
"discovered=%s downloaded=%s skipped=%s failed=%s",
discovered,
downloaded,
skipped,
failed,
)
connection.close()
return SyncResult(discovered, downloaded, skipped, failed)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, default=Path("data/minjust-cbd"))
parser.add_argument("--limit", type=int, help="download only the first N documents")
parser.add_argument("--refresh", action="store_true", help="redownload known documents")
parser.add_argument("--requests-per-second", type=float, default=1)
parser.add_argument("--timeout", type=int, default=60)
parser.add_argument("--retries", type=int, default=5)
parser.add_argument("--version", action="version", version=APP_VERSION)
return parser.parse_args()
def main() -> int:
arguments = parse_args()
if arguments.limit is not None and arguments.limit <= 0:
raise SystemExit("--limit must be greater than zero")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
result = sync_archive(
arguments.output,
CbdClient(
requests_per_second=arguments.requests_per_second,
timeout=arguments.timeout,
retries=arguments.retries,
),
arguments.limit,
arguments.refresh,
terminal_progress() if sys.stderr.isatty() else None,
)
print(
f"discovered={result.discovered} downloaded={result.downloaded} "
f"skipped={result.skipped} failed={result.failed}\n"
f"Akyldash Backend v{APP_VERSION} · Frontend — not created"
)
return int(result.failed > 0)
if __name__ == "__main__":
raise SystemExit(main())

110
backend/test_minjust_cbd.py Normal file
View File

@@ -0,0 +1,110 @@
import base64
import tempfile
import unittest
import urllib.error
from email.message import Message
from pathlib import Path
from unittest.mock import Mock, patch
from ingestion.minjust_cbd import CbdClient, SyncResult, progress_line, sync_archive
class MinjustCbdTest(unittest.TestCase):
def test_archives_document_and_resumes(self):
image = base64.b64encode(b"image").decode()
document = {
"Code": 1,
"Name": {"Rus": "Закон", "Kyr": "Мыйзам"},
"Editions": [
{
"Code": 10,
"Name": {"Rus": "01.01.2026", "Kyr": "01.01.2026"},
"Data": {"Rus": "<p>Закон</p>", "Kyr": "<p>Мыйзам</p>"},
"Images": [
{
"Lang": "Russian",
"Name": "image001.jpg",
"Data": image,
}
],
}
],
}
client = Mock()
client.total_documents = 1
client.document_codes.return_value = [1]
client.document.return_value = document
with tempfile.TemporaryDirectory() as directory:
output = Path(directory)
first = sync_archive(output, client)
second = sync_archive(output, client)
refreshed = sync_archive(output, client, refresh=True)
self.assertEqual(first.downloaded, 1)
self.assertEqual(second.skipped, 1)
self.assertEqual(refreshed.downloaded, 1)
self.assertEqual(client.document.call_count, 2)
self.assertEqual(
(output / "documents/1/editions/10/ru.html").read_text(),
"<p>Закон</p>",
)
self.assertEqual(
(output / "documents/1/editions/10/images/ru/image001.jpg").read_bytes(),
b"image",
)
def test_uses_stable_list_id_for_next_page(self):
client = CbdClient(requests_per_second=1000)
client.request_json = Mock(
side_effect=[
{
"Id": "list-id",
"TotalCount": 3,
"Documents": [{"Code": 1}, {"Code": 2}],
},
{
"Id": "list-id",
"TotalCount": 3,
"Documents": [{"Code": 3}],
},
]
)
self.assertEqual(list(client.document_codes(page_size=2)), [1, 2, 3])
self.assertEqual(
client.request_json.call_args_list[1].args[0], "GetDocumentListById"
)
def test_formats_progress_with_rate_and_eta(self):
line = progress_line(
SyncResult(discovered=50, downloaded=48, skipped=1, failed=1),
total=100,
elapsed=25,
)
self.assertIn("50.00%", line)
self.assertIn("rate=2.00/s", line)
self.assertIn("ETA=00:00:25", line)
@patch("ingestion.minjust_cbd.time.sleep")
@patch("ingestion.minjust_cbd.urllib.request.urlopen")
def test_respects_retry_after_on_rate_limit(self, urlopen, sleep):
headers = Message()
headers["Retry-After"] = "7"
response = Mock()
response.read.return_value = b"{}"
response.__enter__ = Mock(return_value=response)
response.__exit__ = Mock(return_value=False)
urlopen.side_effect = [
urllib.error.HTTPError("https://example.test", 429, "", headers, None),
response,
]
CbdClient(requests_per_second=1000, retries=2).request_json("Example")
self.assertIn(((7.0,), {}), [(call.args, call.kwargs) for call in sleep.call_args_list])
if __name__ == "__main__":
unittest.main()

View File

@@ -17,6 +17,11 @@
- [План Telegram-пространства](operations/telegram-workspace-plan.md) — темы,
роли и этапы развития командного окружения.
## Backend
- [Выгрузка ЦБД Минюста КР](../backend/README.md) — запуск, хранение и проверка
загрузчика правовых документов.
## Команда
- [Навыки работы с ИИ](team/ai-skills-for-beginners.md) — короткие практики для
@@ -24,4 +29,4 @@
---
Акылдаш · Telegram-бот v0.2.2 · Backend — не создан · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.1.1 · Frontend — не создан

View File

@@ -78,4 +78,4 @@ Telegram позволяет запретить пользователям отп
---
Акылдаш · Telegram-бот v0.2.2
Акылдаш · Telegram-бот v0.2.2 · Backend v0.1.1 · Frontend — не создан

View File

@@ -1,18 +1,18 @@
# Статус проекта
Последняя проверка: 2026-08-03
Последняя проверка: 2026-08-06
Назначение документа: быстро восстановить контекст проекта для участников команды и будущих агентов.
- Telegram-бот: `0.2.2`
- Telegram-бот на Synology: `0.2.1`
- Backend: не создан
- Backend: `0.1.1`
- Frontend: не создан
## Краткий итог
Репозиторий переориентирован с отдельного бота на весь проект юридической информационно-аналитической платформы. Telegram-бот выделен в инструмент рабочего окружения: он сохраняет сообщения разрешённых тем в SQLite, экспортирует обсуждения в Markdown и постоянно запущен в Container Manager на Synology. Версия `0.2.2` будет развёрнута после слияния изменений; сейчас на Synology работает `0.2.1`.
Репозиторий переориентирован с отдельного бота на весь проект юридической информационно-аналитической платформы. Telegram-бот выделен в инструмент рабочего окружения. Создана первая backend-функция: возобновляемая выгрузка документов из официального Open Data API ЦБД Минюста КР.
Ближайшая цель — 2026-08-04 зафиксировать MVP с командой, настроить резервное копирование SQLite и затем проверить полный рабочий цикл на реальном обсуждении.
Ближайшая цель — расширить пилотную выборку ЦБД, затем добавить инкрементальную проверку sitemap при создании backend-планировщика.
## Уже сделано
@@ -67,6 +67,9 @@
- Локальный Git-репозиторий восстановлен и привязан к Gitea.
- Репозиторий организован как основа всего проекта, а не отдельного бота.
- Бот развёрнут в Container Manager на Synology; автозапуск после перезапуска менеджера проверен.
- Реализован backend-загрузчик ЦБД Минюста КР версии `0.1.1` без внешних зависимостей.
- Загрузчик сохраняет метаданные, редакции RU/KY и изображения, а прогресс — в SQLite.
- Пилотная выгрузка двух документов и возобновление без повторного скачивания проверены на живом API.
### Развёртывание
@@ -125,6 +128,18 @@
## История изменений статуса
### 2026-08-06
- Добавлен терминальный прогрессбар со скоростью и расчётным временем завершения.
- Обработка HTTP 429 учитывает рекомендованную сервером задержку `Retry-After`.
- Версия backend обновлена до `0.1.1`.
### 2026-08-05
- Создана область `backend/ingestion` для получения правовых источников.
- Реализована возобновляемая выгрузка ЦБД Минюста КР версии `0.1.0`.
- Проверены двуязычные редакции, изображения, SQLite-манифест и повторный запуск.
### 2026-08-03
- Репозиторий перестроен под весь проект юридической платформы; Telegram-бот перенесён в `tools/telegram-bot`.
@@ -162,4 +177,4 @@
---
Акылдаш · Telegram-бот v0.2.2 · Backend — не создан · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.1.1 · Frontend — не создан

View File

@@ -398,4 +398,4 @@ Git сохраняет актуальную версию
---
Акылдаш · Telegram-бот v0.2.2
Акылдаш · Telegram-бот v0.2.2 · Backend v0.1.1 · Frontend — не создан

View File

@@ -44,4 +44,4 @@
---
Акылдаш · Telegram-бот v0.2.2 · Backend — не создан · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.1.1 · Frontend — не создан

View File

@@ -180,4 +180,4 @@
---
Акылдаш · Telegram-бот v0.2.2
Акылдаш · Telegram-бот v0.2.2 · Backend v0.1.1 · Frontend — не создан

View File

@@ -48,4 +48,4 @@ python3 -m unittest -v
---
Акылдаш · Telegram-бот v0.2.2
Акылдаш · Telegram-бот v0.2.2 · Backend v0.1.1 · Frontend — не создан