Compare commits
3 Commits
refactor/p
...
feature/cb
| Author | SHA1 | Date | |
|---|---|---|---|
| efa55fe74b | |||
| 0eedcfcfbd | |||
| 1d398ebb00 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,5 +11,5 @@ __pycache__/
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Local bot archive
|
||||
# Local application archives
|
||||
data/
|
||||
|
||||
12
README.md
12
README.md
@@ -9,15 +9,15 @@ Telegram-бот — только часть рабочего окружения
|
||||
|
||||
## Текущее состояние
|
||||
|
||||
Сейчас реализован Telegram-бот-секретарь версии `0.2.2`. Он сохраняет сообщения
|
||||
рабочих тем, формирует Markdown-экспорты и отмечает завершённые обсуждения.
|
||||
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и первая backend-функция
|
||||
версии `0.1.2`: возобновляемая выгрузка документов из ЦБД Минюста КР.
|
||||
|
||||
| Компонент | Версия | Состояние |
|
||||
|---|---:|---|
|
||||
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
|
||||
| Backend | — | ещё не создан |
|
||||
| Backend | `0.1.2` | реализована выгрузка документов ЦБД Минюста КР |
|
||||
| Frontend | — | ещё не создан |
|
||||
| Сбор и обработка правовых данных | — | ещё не созданы |
|
||||
| Сбор и обработка правовых данных | `0.1.2` | реализован архиватор ЦБД Минюста КР |
|
||||
| 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.2 · Frontend — не создан
|
||||
|
||||
64
backend/README.md
Normal file
64
backend/README.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Backend Акылдаш
|
||||
|
||||
Версия: `0.1.2`
|
||||
|
||||
Первая 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` принудительно проверяет их заново.
|
||||
Если временный идентификатор списка API истечёт во время многодневной загрузки,
|
||||
скрипт пересоздаст список на текущей странице и продолжит автоматически.
|
||||
|
||||
В версии `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.2 · Frontend — не создан
|
||||
373
backend/ingestion/minjust_cbd.py
Normal file
373
backend/ingestion/minjust_cbd.py
Normal file
@@ -0,0 +1,373 @@
|
||||
#!/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.2"
|
||||
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):
|
||||
def query_page(page_number: int):
|
||||
return self.request_json(
|
||||
"GetDocumentListByQuery",
|
||||
(
|
||||
("Property", "Code"),
|
||||
("PageSize", page_size),
|
||||
("PageNumber", page_number),
|
||||
),
|
||||
)
|
||||
|
||||
first = query_page(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
|
||||
try:
|
||||
page = self.request_json(
|
||||
"GetDocumentListById",
|
||||
(
|
||||
("DocumentListId", first["Id"]),
|
||||
("Property", "Code"),
|
||||
("PageSize", page_size),
|
||||
("PageNumber", page_number),
|
||||
),
|
||||
)
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code != 404:
|
||||
raise
|
||||
first = page = query_page(page_number)
|
||||
self.total_documents = min(
|
||||
first["TotalCount"], limit or first["TotalCount"]
|
||||
)
|
||||
|
||||
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 not progress and 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())
|
||||
133
backend/test_minjust_cbd.py
Normal file
133
backend/test_minjust_cbd.py
Normal file
@@ -0,0 +1,133 @@
|
||||
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_recreates_expired_list_at_current_page(self):
|
||||
client = CbdClient(requests_per_second=1000)
|
||||
client.request_json = Mock(
|
||||
side_effect=[
|
||||
{
|
||||
"Id": "expired-list",
|
||||
"TotalCount": 3,
|
||||
"Documents": [{"Code": 1}, {"Code": 2}],
|
||||
},
|
||||
urllib.error.HTTPError("https://example.test", 404, "", {}, None),
|
||||
{
|
||||
"Id": "new-list",
|
||||
"TotalCount": 3,
|
||||
"Documents": [{"Code": 3}],
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(list(client.document_codes(page_size=2)), [1, 2, 3])
|
||||
recovery_call = client.request_json.call_args_list[2]
|
||||
self.assertEqual(recovery_call.args[0], "GetDocumentListByQuery")
|
||||
self.assertIn(("PageNumber", 2), recovery_call.args[1])
|
||||
|
||||
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()
|
||||
@@ -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.2 · Frontend — не создан
|
||||
|
||||
@@ -78,4 +78,4 @@ Telegram позволяет запретить пользователям отп
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.1.2 · Frontend — не создан
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
# Статус проекта
|
||||
|
||||
Последняя проверка: 2026-08-03
|
||||
Последняя проверка: 2026-08-06
|
||||
Назначение документа: быстро восстановить контекст проекта для участников команды и будущих агентов.
|
||||
|
||||
- Telegram-бот: `0.2.2`
|
||||
- Telegram-бот на Synology: `0.2.1`
|
||||
- Backend: не создан
|
||||
- Backend: `0.1.2`
|
||||
- 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.2` без внешних зависимостей.
|
||||
- Загрузчик сохраняет метаданные, редакции RU/KY и изображения, а прогресс — в SQLite.
|
||||
- Пилотная выгрузка двух документов и возобновление без повторного скачивания проверены на живом API.
|
||||
|
||||
### Развёртывание
|
||||
|
||||
@@ -125,6 +128,19 @@
|
||||
|
||||
## История изменений статуса
|
||||
|
||||
### 2026-08-06
|
||||
|
||||
- Добавлен терминальный прогрессбар со скоростью и расчётным временем завершения.
|
||||
- Обработка HTTP 429 учитывает рекомендованную сервером задержку `Retry-After`.
|
||||
- Добавлено автоматическое восстановление после истечения серверного списка документов.
|
||||
- Версия backend обновлена до `0.1.2`.
|
||||
|
||||
### 2026-08-05
|
||||
|
||||
- Создана область `backend/ingestion` для получения правовых источников.
|
||||
- Реализована возобновляемая выгрузка ЦБД Минюста КР версии `0.1.0`.
|
||||
- Проверены двуязычные редакции, изображения, SQLite-манифест и повторный запуск.
|
||||
|
||||
### 2026-08-03
|
||||
|
||||
- Репозиторий перестроен под весь проект юридической платформы; Telegram-бот перенесён в `tools/telegram-bot`.
|
||||
@@ -162,4 +178,4 @@
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend — не создан · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.1.2 · Frontend — не создан
|
||||
|
||||
@@ -398,4 +398,4 @@ Git сохраняет актуальную версию
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.1.2 · Frontend — не создан
|
||||
|
||||
@@ -44,4 +44,4 @@
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend — не создан · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.1.2 · Frontend — не создан
|
||||
|
||||
@@ -180,4 +180,4 @@
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.1.2 · Frontend — не создан
|
||||
|
||||
@@ -48,4 +48,4 @@ python3 -m unittest -v
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.1.2 · Frontend — не создан
|
||||
|
||||
BIN
План_выгрузки_документов_ЦБД_Минюста_КР_v0.1.0.pdf
Normal file
BIN
План_выгрузки_документов_ЦБД_Минюста_КР_v0.1.0.pdf
Normal file
Binary file not shown.
Reference in New Issue
Block a user