Compare commits

...

11 Commits

Author SHA1 Message Date
752bd38c9b fix: harden document normalization
Reject overlapping source and destination roots before any write so normalization cannot replace the raw Ministry archive.

Recover an interrupted directory publication before resume checks and require the normalized target to exist before skipping a manifest success.

Treat malformed link and image URLs as unsafe attributes, add regressions for all review findings, and bump the backend version to 0.2.2.
2026-08-12 09:49:40 +03:00
07381ad70f feat: normalize Ministry of Justice documents
Add a resumable standard-library normalization pipeline for the downloaded CBD archive. It produces canonical bilingual metadata, sanitized HTML, plain text, deterministic fragments, checksums, quality markers, and an SQLite processing manifest while preserving the raw source.

Recover document-list pagination when the Ministry API exhausts request retries, and cover that scenario with a regression test.

Document the normalization workflow and frontend-search MVP plan, include the source functional specification, ignore local runtime logs, and bump the backend version to 0.2.1.
2026-08-12 08:32:12 +03:00
35cae8bcbe Merge pull request 'Добавить синхронизацию документов ЦБД Минюста КР' (#7) from feature/cbd-document-sync into main
Reviewed-on: #7
2026-08-06 20:49:42 +00:00
efa55fe74b fix: recover expired Ministry document lists 2026-08-06 23:48:26 +03:00
0eedcfcfbd feat: add Ministry of Justice document sync 2026-08-06 08:25:14 +03:00
1d398ebb00 Merge pull request 'Перестроить репозиторий под юридическую платформу' (#6) from refactor/project-structure into main
Reviewed-on: #6
2026-08-02 22:37:30 +00:00
9cf6a7e940 docs: clarify deployed bot version 2026-08-03 01:34:53 +03:00
b8b9ec75b3 refactor: organize repository around legal platform 2026-08-03 01:33:06 +03:00
c94131effc Merge pull request 'feat: export messages after last cutoff' (#5) from feature/export-from-last-cutoff into main
Reviewed-on: agent/telegram-ai-infrastructure#5
2026-08-02 21:51:15 +00:00
d9b4f19af3 feat: export messages after last cutoff 2026-08-03 00:49:35 +03:00
d4ee76948f Merge pull request 'docs: update project status and AI guidance' (#4) from docs/ai-skills-for-beginners into main
Reviewed-on: agent/telegram-ai-infrastructure#4
2026-08-02 21:43:28 +00:00
22 changed files with 2354 additions and 50 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

5
.gitignore vendored
View File

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

View File

@@ -1,44 +1,61 @@
# Акылдаш — бот-секретарь
# Акылдаш
Версия: `0.2.0`
Акылдаш — проект юридической информационно-аналитической платформы. Цель —
собирать правовые источники на законных основаниях, сохранять их происхождение
и версии, готовить данные для поиска и RAG, а затем предоставлять результаты
через API и пользовательский интерфейс со ссылками на первоисточники.
Бот сохраняет сообщения разрешённых тем Telegram в локальную SQLite-базу и
выгружает обсуждения в Markdown. Внешние Python-зависимости не требуются.
Telegram-бот — только часть рабочего окружения команды, а не основной продукт.
## Запуск
## Текущее состояние
Требуется Python 3.11 или новее.
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии
`0.2.2`: возобновляемая выгрузка и нормализация документов ЦБД Минюста КР.
```bash
export TELEGRAM_BOT_TOKEN='...'
export TELEGRAM_CHAT_ID='-1004242041275'
export TELEGRAM_OWNER_ID='87262245'
export TELEGRAM_REPORT_THREAD_ID='37'
export TELEGRAM_ALLOWED_THREAD_IDS='0,2,4,6,8'
python3 bot.py
| Компонент | Версия | Состояние |
|---|---:|---|
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
| Backend | `0.2.2` | реализованы выгрузка и нормализация документов ЦБД Минюста КР |
| Frontend | — | ещё не создан |
| Сбор и обработка правовых данных | `0.2.2` | реализованы архиватор и нормализатор ЦБД Минюста КР |
| RAG и база знаний | — | ещё не созданы |
## Структура репозитория
```text
docs/ структурированная документация проекта
decisions/ принятые архитектурные и продуктовые решения
operations/ состояние проекта и рабочие процессы
product/ назначение, границы и развитие продукта
team/ материалы для команды
tools/
telegram-bot/ бот рабочего Telegram-пространства
backend/
ingestion/ получение и обновление правовых источников
normalization/ воспроизводимая нормализация исходного архива
```
Доступные команды: `/help`, `/status`, `/export [дней]`, `/export все`.
Экспорт доступен только пользователю с Telegram ID из `TELEGRAM_OWNER_ID`.
Markdown публикуется в теме `TELEGRAM_REPORT_THREAD_ID`, а в исходной теме
остаётся отсечка со ссылкой и общим хэштегом отчёта.
Каталоги для загрузки и обработки источников, RAG, backend и frontend будут
создаваться с первой реальной задачей в соответствующей области. Это позволит
выбрать структуру по фактическим требованиям, а не поддерживать пустой каркас.
База по умолчанию хранится в `data/secretary.sqlite3`. Сообщения из других
групп и тем не сохраняются. Полный ответ Telegram сохраняется в базе, поэтому
метаданные вложений остаются доступными для последующего скачивания.
Начать знакомство с проектом: [документация](docs/README.md) и
[обзор продукта](docs/product/project-overview.md).
## Проверка
## Конфиденциальные материалы
Секреты, персональные данные, договоры и материалы по правовой защите проекта
не должны храниться в этом репозитории. Для них нужен отдельный закрытый
репозиторий или защищённое хранилище с минимально необходимыми правами доступа,
журналированием и резервным копированием. Здесь допустимы только несекретные
правила и ссылки на такие материалы без раскрытия их содержания.
## Проверка Telegram-бота
```bash
python3 -m unittest -v
python3 -m unittest discover -s tools/telegram-bot -v
```
## Synology
Контейнер `akyldash-bot` работает на образе `python:3.11-slim` с политикой
перезапуска `unless-stopped`. Постоянные данные находятся в
`/volume1/docker/akyldash/data`.
---
Акылдаш v0.2.0
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан

103
backend/README.md Normal file
View File

@@ -0,0 +1,103 @@
# Backend Акылдаш
Версия: `0.2.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-фреймворка.
## Нормализация архива
Нормализатор читает исходный архив без изменений и создаёт отдельный набор
данных для будущих поиска, API и RAG:
```bash
python3 backend/normalization/minjust_cbd.py --limit 10
python3 backend/normalization/minjust_cbd.py
```
По умолчанию источник читается из `data/minjust-cbd`, а результат записывается
в `data/minjust-normalized`. Пути можно изменить параметрами `--input` и
`--output`; `--refresh` принудительно обрабатывает неизменившиеся документы,
`--log-level` задаёт уровень журнала.
Каталоги `--input` и `--output` не должны совпадать, содержать друг друга или
пересекаться через разрешённые абсолютные пути.
```text
data/minjust-normalized/
manifest.sqlite3
documents/<code>/document.json
documents/<code>/editions/<edition>/edition.json
documents/<code>/editions/<edition>/<lang>/content.html
documents/<code>/editions/<edition>/<lang>/content.txt
documents/<code>/editions/<edition>/<lang>/fragments.json
```
`content.html` содержит только разрешённую безопасную разметку, `content.txt`
текст для поиска, а `fragments.json` — адресуемые блоки с детерминированными ID
и SHA-256. Манифест пропускает документы с неизменившимися исходниками и
повторяет документы, обработка которых завершилась ошибкой.
Первая версия не выполняет OCR, перевод, юридические выводы о редакциях,
сопоставление фрагментов, загрузку в PostgreSQL/OpenSearch и построение RAG.
Внешние и встроенные `data:`-изображения из HTML удаляются; сведения и пути к
локальным изображениям исходного архива сохраняются в `edition.json`.
## Проверка
```bash
PYTHONPATH=backend python3 -m unittest backend/test_minjust_cbd.py -v
PYTHONPATH=backend python3 -m unittest backend/test_minjust_normalization.py -v
```
---
Акылдаш · Backend v0.2.2 · Frontend — не создан

View 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.2.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, RuntimeError) as error:
if isinstance(error, urllib.error.HTTPError) and 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())

View File

@@ -0,0 +1,729 @@
#!/usr/bin/env python3
"""Normalize the local Ministry of Justice CBD archive."""
from __future__ import annotations
import argparse
import hashlib
import html
import json
import logging
import os
import re
import shutil
import sqlite3
import sys
import tempfile
import time
import unicodedata
from dataclasses import dataclass
from datetime import datetime, timezone
from html.parser import HTMLParser
from pathlib import Path
from typing import Callable
from urllib.parse import urlsplit
APP_VERSION = "0.2.2"
SCHEMA_VERSION = "1"
NORMALIZER_VERSION = "1.0.0"
LANGUAGES = ("ru", "ky")
LOGGER = logging.getLogger(__name__)
ALLOWED_TAGS = {
"a", "b", "blockquote", "br", "div", "em", "h1", "h2", "h3", "h4",
"h5", "h6", "i", "img", "li", "ol", "p", "pre", "span", "strong",
"sub", "sup", "table", "tbody", "td", "tfoot", "th", "thead", "tr",
"u", "ul",
}
VOID_TAGS = {"br", "img"}
DROP_CONTENT_TAGS = {"applet", "iframe", "noscript", "object", "script", "style", "svg"}
DROP_ELEMENT_TAGS = {"link", "meta"}
BLOCK_TAGS = {"blockquote", "h1", "h2", "h3", "h4", "h5", "h6", "li", "p", "pre", "td", "th"}
AUTO_CLOSE = {
"li": {"li"},
"p": {"blockquote", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "ol", "p", "pre", "table", "ul"},
"td": {"td", "th"},
"th": {"td", "th"},
"tr": {"tr"},
}
@dataclass(frozen=True)
class NormalizeResult:
discovered: int = 0
normalized: int = 0
skipped: int = 0
failed: int = 0
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
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("utf-8")
def sha256_bytes(content: bytes) -> str:
return hashlib.sha256(content).hexdigest()
def source_inventory(document_directory: Path, input_root: Path) -> tuple[list[dict], str]:
files = []
combined = hashlib.sha256()
for path in sorted(item for item in document_directory.rglob("*") if item.is_file()):
relative = path.relative_to(input_root).as_posix()
digest = hashlib.sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
checksum = digest.hexdigest()
files.append({"path": relative, "sha256": checksum})
combined.update(relative.encode("utf-8"))
combined.update(b"\0")
combined.update(checksum.encode("ascii"))
combined.update(b"\0")
return files, combined.hexdigest()
def clean_value(value):
if isinstance(value, str):
normalized = unicodedata.normalize("NFC", value)
return normalized if normalized.strip() else None
if isinstance(value, dict):
return {key: clean_value(item) for key, item in value.items()}
if isinstance(value, list):
return [clean_value(item) for item in value]
return value
def bilingual(value) -> dict[str, object]:
value = value if isinstance(value, dict) else {}
return {"ru": clean_value(value.get("Rus")), "ky": clean_value(value.get("Kyr"))}
def hierarchy_paths(items: object, child_key: str) -> list[dict]:
paths: list[dict] = []
def visit(nodes: object, ancestors: dict[str, list[str]]) -> None:
for node in nodes if isinstance(nodes, list) else []:
if not isinstance(node, dict):
continue
names = bilingual(node.get("Name"))
current = {language: list(ancestors[language]) for language in LANGUAGES}
for language in LANGUAGES:
name = names[language]
if name:
current[language].append(str(name))
children = node.get(child_key)
if children:
visit(children, current)
else:
paths.append(current)
visit(items, {"ru": [], "ky": []})
return paths
class SafeHtmlParser(HTMLParser):
def __init__(self, edition_directory: Path) -> None:
super().__init__(convert_charrefs=True)
self.edition_directory = edition_directory.resolve()
self.parts: list[str] = []
self.stack: list[str] = []
self.drop_depth = 0
self.removed_elements = 0
self.removed_attributes = 0
self.removed_images = 0
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
tag = tag.lower()
if self.drop_depth:
if tag in DROP_CONTENT_TAGS:
self.drop_depth += 1
return
if tag in DROP_CONTENT_TAGS:
self.drop_depth = 1
self.removed_elements += 1
return
if tag in DROP_ELEMENT_TAGS:
self.removed_elements += 1
return
if tag not in ALLOWED_TAGS:
self.removed_elements += 1
return
for open_tag, closing_tags in AUTO_CLOSE.items():
if tag in closing_tags and open_tag in self.stack:
self._close(open_tag)
safe_attrs = self._attributes(tag, attrs)
if tag == "img" and not any(name == "src" for name, _ in safe_attrs):
self.removed_images += 1
return
rendered = "".join(
f' {name}="{html.escape(value, quote=True)}"' for name, value in safe_attrs
)
self.parts.append(f"<{tag}{rendered}>")
if tag not in VOID_TAGS:
self.stack.append(tag)
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
self.handle_starttag(tag, attrs)
if tag.lower() not in VOID_TAGS:
self.handle_endtag(tag)
def handle_endtag(self, tag: str) -> None:
tag = tag.lower()
if self.drop_depth:
if tag in DROP_CONTENT_TAGS:
self.drop_depth -= 1
return
if tag in self.stack:
self._close(tag)
def handle_data(self, data: str) -> None:
if not self.drop_depth:
self.parts.append(html.escape(unicodedata.normalize("NFC", data), quote=False))
def close(self) -> None:
super().close()
while self.stack:
self.parts.append(f"</{self.stack.pop()}>")
def _close(self, tag: str) -> None:
while self.stack:
current = self.stack.pop()
self.parts.append(f"</{current}>")
if current == tag:
break
def _attributes(self, tag: str, attrs: list[tuple[str, str | None]]) -> list[tuple[str, str]]:
allowed = {"title"}
if tag == "a":
allowed |= {"href"}
elif tag == "img":
allowed |= {"alt", "src"}
elif tag in {"td", "th"}:
allowed |= {"colspan", "rowspan"}
safe = []
for raw_name, raw_value in attrs:
name = raw_name.lower()
value = unicodedata.normalize("NFC", raw_value or "")
if name not in allowed:
self.removed_attributes += 1
continue
if name == "href" and not safe_link(value):
self.removed_attributes += 1
continue
if name == "src" and not self.safe_image(value):
self.removed_attributes += 1
continue
if name in {"colspan", "rowspan"} and not value.isdigit():
self.removed_attributes += 1
continue
safe.append((name, value))
if tag == "a" and any(name == "href" and urlsplit(value).scheme in {"http", "https"} for name, value in safe):
safe.append(("rel", "noopener noreferrer"))
return safe
def safe_image(self, value: str) -> bool:
try:
parsed = urlsplit(value)
except ValueError:
return False
if parsed.scheme or parsed.netloc or not parsed.path or parsed.path.startswith(("/", "\\")):
return False
candidate = (self.edition_directory / parsed.path.replace("\\", "/")).resolve()
try:
candidate.relative_to(self.edition_directory)
except ValueError:
return False
return candidate.is_file()
def safe_link(value: str) -> bool:
value = value.strip()
if not value or value.startswith(("//", "\\\\")):
return False
try:
parsed = urlsplit(value)
except ValueError:
return False
return parsed.scheme.lower() in {"", "http", "https", "mailto"} and not (
not parsed.scheme and parsed.netloc
)
def sanitize_html(source: str, edition_directory: Path) -> tuple[str, dict]:
parser = SafeHtmlParser(edition_directory)
parser.feed(source)
parser.close()
return unicodedata.normalize("NFC", "".join(parser.parts)), {
"removed_elements": parser.removed_elements,
"removed_attributes": parser.removed_attributes,
"removed_images": parser.removed_images,
}
class TextBlockParser(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.blocks: list[tuple[str, str]] = []
self.active_tag: str | None = None
self.active: list[str] = []
self.loose: list[str] = []
def handle_starttag(self, tag: str, attrs) -> None:
if tag in BLOCK_TAGS:
self._flush_active()
self._flush_loose()
self.active_tag = tag
elif tag == "br":
(self.active if self.active_tag else self.loose).append("\n")
def handle_endtag(self, tag: str) -> None:
if tag == self.active_tag:
self._flush_active()
def handle_data(self, data: str) -> None:
(self.active if self.active_tag else self.loose).append(data)
def close(self) -> None:
super().close()
self._flush_active()
self._flush_loose()
def _flush_active(self) -> None:
if self.active_tag:
text = clean_text("".join(self.active))
if text:
self.blocks.append((self.active_tag, text))
self.active_tag = None
self.active = []
def _flush_loose(self) -> None:
text = clean_text("".join(self.loose))
if text:
self.blocks.append(("p", text))
self.loose = []
def clean_text(value: str) -> str:
lines = []
for line in unicodedata.normalize("NFC", value).replace("\xa0", " ").splitlines():
line = re.sub(r"[ \t\f\v]+", " ", line).strip()
if line:
lines.append(line)
return "\n".join(lines)
def fragment_type(tag: str, text: str, language: str) -> str:
lowered = text.casefold()
article_words = ("статья", "ст.") if language == "ru" else ("берене", "статья")
if any(re.match(rf"^{re.escape(word)}\s*\d", lowered) for word in article_words):
return "article"
if re.match(r"^\d+(?:\.\d+)*[.)]?\s+", text):
return "point"
if tag.startswith("h"):
return "heading"
return {"li": "list_item", "td": "table_cell", "th": "table_header"}.get(tag, "paragraph")
def extract_text_and_fragments(
sanitized: str,
document_code: str,
edition_code: str,
language: str,
source_path: str,
source_sha256: str,
) -> tuple[str, list[dict]]:
parser = TextBlockParser()
parser.feed(sanitized)
parser.close()
fragments = []
for position, (tag, text) in enumerate(parser.blocks, 1):
fragments.append(
{
"id": f"document:{document_code}:edition:{edition_code}:lang:{language}:fragment:{position}",
"document_code": document_code,
"edition_code": edition_code,
"language": language,
"position": position,
"type": fragment_type(tag, text, language),
"text": text,
"text_sha256": sha256_bytes(text.encode("utf-8")),
"source_path": source_path,
"source_sha256": source_sha256,
}
)
return "\n\n".join(fragment["text"] for fragment in fragments), fragments
def load_json(path: Path) -> dict:
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ValueError(f"Expected JSON object: {path}")
return value
def edition_summary(edition_directory: Path, input_root: Path) -> dict:
metadata = load_json(edition_directory / "metadata.json")
languages = [language for language in LANGUAGES if (edition_directory / f"{language}.html").is_file()]
return {
"source_code": str(metadata.get("Code", edition_directory.name)),
"name": bilingual(metadata.get("Name")),
"source_type": clean_value(metadata.get("Type")),
"available_languages": languages,
"source_path": edition_directory.relative_to(input_root).as_posix(),
}
def normalize_document(
document_directory: Path,
input_root: Path,
destination: Path,
files: list[dict] | None = None,
source_checksum: str | None = None,
) -> None:
metadata_path = document_directory / "metadata.json"
metadata = load_json(metadata_path)
document_code = str(metadata.get("Code", document_directory.name))
if document_code != document_directory.name:
raise ValueError(f"Document code mismatch in {metadata_path}")
if files is None or source_checksum is None:
files, source_checksum = source_inventory(document_directory, input_root)
file_checksums = {item["path"]: item["sha256"] for item in files}
edition_root = document_directory / "editions"
edition_directories = sorted(
(path for path in edition_root.iterdir() if path.is_dir()),
key=lambda path: (not path.name.isdigit(), int(path.name) if path.name.isdigit() else path.name),
) if edition_root.is_dir() else []
summaries = [edition_summary(path, input_root) for path in edition_directories]
available_languages = [language for language in LANGUAGES if any(language in item["available_languages"] for item in summaries)]
normalized_metadata = clean_value(metadata)
document = {
"schema_version": SCHEMA_VERSION,
"source_code": document_code,
"class": bilingual(metadata.get("Class")),
"type": bilingual(metadata.get("Type")),
"title": bilingual(metadata.get("Title")),
"name": bilingual(metadata.get("Name")),
"status": bilingual(metadata.get("Status")),
"number": clean_value(metadata.get("Number")),
"dates": {key: value for key, value in normalized_metadata.items() if key.startswith("Date")},
"registration_number": clean_value(metadata.get("NumberRegistration")),
"publication_number": clean_value(metadata.get("NumberPublication")),
"is_public_in_cdb": metadata.get("IsPublicInCdb"),
"is_public_in_register": metadata.get("IsPublicInRegister"),
"authorities": normalized_metadata.get("Authorities") or [],
"authority_paths": hierarchy_paths(metadata.get("Authorities"), "Authorities"),
"source_publications": normalized_metadata.get("SourcePublications") or [],
"source_publication_paths": hierarchy_paths(metadata.get("SourcePublications"), "SourcePublications"),
"keywords": normalized_metadata.get("Keywords") or [],
"keyword_paths": hierarchy_paths(metadata.get("Keywords"), "Keywords"),
"general_classifiers": normalized_metadata.get("GeneralClassifiers") or [],
"general_classifier_paths": hierarchy_paths(metadata.get("GeneralClassifiers"), "GeneralClassifiers"),
"references": normalized_metadata.get("References") or [],
"source_metadata": normalized_metadata,
"available_languages": available_languages,
"editions": summaries,
"source": {
"path": document_directory.relative_to(input_root).as_posix(),
"files": files,
"sha256": source_checksum,
},
"normalizer": {"version": NORMALIZER_VERSION, "processed_at": utc_now()},
}
atomic_write(destination / "document.json", json_bytes(document))
for edition_directory, summary in zip(edition_directories, summaries):
edition_code = summary["source_code"]
edition_metadata = load_json(edition_directory / "metadata.json")
edition_destination = destination / "editions" / edition_directory.name
image_records = []
for image in edition_metadata.get("Images") or []:
language = {"Russian": "ru", "Kyrgyz": "ky"}.get(image.get("Lang"), "unknown")
name = Path(str(image.get("Name") or "").replace("\\", "/")).name
source_path = edition_directory / "images" / language / name
relative = source_path.relative_to(input_root).as_posix()
image_records.append(
{
"language": language,
"name": clean_value(image.get("Name")),
"source_path": relative if source_path.is_file() else None,
"source_sha256": file_checksums.get(relative),
"source_metadata": clean_value(image),
}
)
quality = {"has_html": bool(summary["available_languages"]), "languages": {}}
for language in summary["available_languages"]:
html_path = edition_directory / f"{language}.html"
relative = html_path.relative_to(input_root).as_posix()
raw = html_path.read_text(encoding="utf-8")
sanitized, sanitizer_quality = sanitize_html(raw, edition_directory)
text, fragments = extract_text_and_fragments(
sanitized, document_code, edition_code, language, relative, file_checksums[relative]
)
language_destination = edition_destination / language
atomic_write(language_destination / "content.html", sanitized.encode("utf-8"))
atomic_write(language_destination / "content.txt", (text + ("\n" if text else "")).encode("utf-8"))
atomic_write(language_destination / "fragments.json", json_bytes(fragments))
quality["languages"][language] = {
**sanitizer_quality,
"empty_text": not bool(text),
"fragment_count": len(fragments),
}
edition = {
"schema_version": SCHEMA_VERSION,
"source_code": edition_code,
"name": summary["name"],
"source_type": summary["source_type"],
"available_languages": summary["available_languages"],
"images": image_records,
"source_metadata": clean_value(edition_metadata),
"source": {
"path": summary["source_path"],
"files": [item for item in files if item["path"].startswith(summary["source_path"] + "/")],
},
"quality": quality,
}
atomic_write(edition_destination / "edition.json", json_bytes(edition))
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 TEXT PRIMARY KEY,
source_sha256 TEXT,
schema_version TEXT NOT NULL,
normalizer_version TEXT NOT NULL,
processed_at TEXT,
state TEXT NOT NULL,
error TEXT,
failed_at TEXT
)
"""
)
columns = {row[1] for row in connection.execute("PRAGMA table_info(documents)")}
if "failed_at" not in columns:
connection.execute("ALTER TABLE documents ADD COLUMN failed_at TEXT")
return connection
def publish_directory(staged: Path, target: Path) -> None:
target.parent.mkdir(parents=True, exist_ok=True)
backup = target.parent / f".{target.name}.previous"
if backup.exists():
shutil.rmtree(backup)
if target.exists():
os.replace(target, backup)
try:
os.replace(staged, target)
except Exception:
if backup.exists():
os.replace(backup, target)
raise
if backup.exists():
shutil.rmtree(backup)
def recover_directory(target: Path) -> None:
backup = target.parent / f".{target.name}.previous"
if not backup.exists():
return
if target.exists():
shutil.rmtree(backup)
else:
os.replace(backup, target)
def validate_roots(input_root: Path, output: Path) -> None:
source = input_root.resolve()
destination = output.resolve()
if source == destination or source.is_relative_to(destination) or destination.is_relative_to(source):
raise ValueError("--input and --output must not overlap")
def normalize_archive(
input_root: Path = Path("data/minjust-cbd"),
output: Path = Path("data/minjust-normalized"),
limit: int | None = None,
refresh: bool = False,
progress: Callable[[NormalizeResult, int], None] | None = None,
) -> NormalizeResult:
validate_roots(input_root, output)
document_root = input_root / "documents"
if not document_root.is_dir():
raise FileNotFoundError(f"Document directory not found: {document_root}")
output.mkdir(parents=True, exist_ok=True)
connection = connect_manifest(output / "manifest.sqlite3")
known = {
row[0]: (row[1], row[2], row[3], row[4])
for row in connection.execute(
"SELECT code, source_sha256, schema_version, normalizer_version, state FROM documents"
)
}
directories = sorted(
(path for path in document_root.iterdir() if path.is_dir()),
key=lambda path: (not path.name.isdigit(), int(path.name) if path.name.isdigit() else path.name),
)
if limit is not None:
directories = directories[:limit]
total = len(directories)
discovered = normalized = skipped = failed = 0
staging_root = output / ".staging"
staging_root.mkdir(exist_ok=True)
try:
for source_directory in directories:
discovered += 1
code = source_directory.name
target = output / "documents" / code
checksum = None
try:
recover_directory(target)
files, checksum = source_inventory(source_directory, input_root)
if target.is_dir() and not refresh and known.get(code) == (
checksum, SCHEMA_VERSION, NORMALIZER_VERSION, "success"
):
skipped += 1
else:
with tempfile.TemporaryDirectory(dir=staging_root) as temporary:
staged = Path(temporary) / code
normalize_document(source_directory, input_root, staged, files, checksum)
publish_directory(staged, target)
with connection:
connection.execute(
"""
INSERT INTO documents (
code, source_sha256, schema_version,
normalizer_version, processed_at, state, error,
failed_at
) VALUES (?, ?, ?, ?, ?, 'success', NULL, NULL)
ON CONFLICT(code) DO UPDATE SET
source_sha256=excluded.source_sha256,
schema_version=excluded.schema_version,
normalizer_version=excluded.normalizer_version,
processed_at=excluded.processed_at,
state='success', error=NULL, failed_at=NULL
""",
(code, checksum, SCHEMA_VERSION, NORMALIZER_VERSION, utc_now()),
)
normalized += 1
except Exception as error: # Keep a corpus run alive after one malformed record.
LOGGER.exception("Failed to normalize document %s", code)
with connection:
connection.execute(
"""
INSERT INTO documents (
code, source_sha256, schema_version,
normalizer_version, processed_at, state, error,
failed_at
) VALUES (?, ?, ?, ?, NULL, 'error', ?, ?)
ON CONFLICT(code) DO UPDATE SET
source_sha256=excluded.source_sha256,
schema_version=excluded.schema_version,
normalizer_version=excluded.normalizer_version,
state='error', error=excluded.error,
failed_at=excluded.failed_at
""",
(
code,
checksum,
SCHEMA_VERSION,
NORMALIZER_VERSION,
str(error),
utc_now(),
),
)
failed += 1
result = NormalizeResult(discovered, normalized, skipped, failed)
if progress:
progress(result, total)
elif discovered % 100 == 0:
LOGGER.info("discovered=%s normalized=%s skipped=%s failed=%s", discovered, normalized, skipped, failed)
finally:
connection.close()
try:
staging_root.rmdir()
except OSError:
pass
return NormalizeResult(discovered, normalized, skipped, failed)
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: NormalizeResult, total: int, elapsed: float, width: int = 24) -> str:
fraction = result.discovered / total if total else 0
filled = min(width, round(width * fraction))
rate = result.discovered / elapsed if elapsed > 0 else 0
eta = (total - result.discovered) / rate if rate else 0
return (
f"[{'#' * filled}{'-' * (width - filled)}] {fraction:6.2%} "
f"{result.discovered}/{total} normalized={result.normalized} "
f"skipped={result.skipped} failed={result.failed} "
f"rate={rate:.2f}/s ETA={format_duration(eta)}"
)
def terminal_progress() -> Callable[[NormalizeResult, int], None]:
started = time.monotonic()
def update(result: NormalizeResult, total: int) -> None:
print(
f"\r{progress_line(result, total, time.monotonic() - started)}",
end="\n" if result.discovered >= total else "",
file=sys.stderr,
flush=True,
)
return update
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", type=Path, default=Path("data/minjust-cbd"))
parser.add_argument("--output", type=Path, default=Path("data/minjust-normalized"))
parser.add_argument("--limit", type=int, help="normalize only the first N documents")
parser.add_argument("--refresh", action="store_true", help="renormalize unchanged documents")
parser.add_argument("--log-level", choices=("DEBUG", "INFO", "WARNING", "ERROR"), default="INFO")
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=arguments.log_level, format="%(asctime)s %(levelname)s %(message)s")
result = normalize_archive(
arguments.input,
arguments.output,
arguments.limit,
arguments.refresh,
terminal_progress() if sys.stderr.isatty() else None,
)
print(
f"discovered={result.discovered} normalized={result.normalized} "
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())

156
backend/test_minjust_cbd.py Normal file
View File

@@ -0,0 +1,156 @@
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_recreates_list_after_page_retries_are_exhausted(self):
client = CbdClient(requests_per_second=1000)
client.request_json = Mock(
side_effect=[
{
"Id": "failed-list",
"TotalCount": 3,
"Documents": [{"Code": 1}, {"Code": 2}],
},
RuntimeError("Ministry of Justice API request failed"),
{
"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()

View File

@@ -0,0 +1,152 @@
import json
import os
import sqlite3
import tempfile
import unittest
from pathlib import Path
from normalization.minjust_cbd import normalize_archive
class MinjustNormalizationTest(unittest.TestCase):
def write_json(self, path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
def edition(self, root: Path, code: int, languages: dict[str, str]) -> None:
directory = root / "editions" / str(code)
self.write_json(
directory / "metadata.json",
{"Code": code, "Name": {"Rus": "Редакция", "Kyr": "Редакция"}, "Type": "edition", "Images": []},
)
for language, content in languages.items():
(directory / f"{language}.html").write_text(content, encoding="utf-8")
def document(self, root: Path, code: int = 1) -> Path:
directory = root / "documents" / str(code)
self.write_json(
directory / "metadata.json",
{
"Code": code,
"Class": {"Rus": "Акты", "Kyr": "Актылар"},
"Type": {"Rus": "Закон", "Kyr": "Мыйзам"},
"Title": {"Rus": " ", "Kyr": None},
"Name": {"Rus": "Документ", "Kyr": "Документ"},
"Status": {"Rus": "Действует", "Kyr": "Күчүндө"},
"Number": "1",
"DateAdopted": "2026-01-01",
"IsPublicInCdb": True,
"IsPublicInRegister": True,
"Authorities": [],
"SourcePublications": [],
"Keywords": [],
"GeneralClassifiers": [],
"References": [],
},
)
return directory
def test_languages_safety_empty_document_and_deterministic_fragments(self):
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
source = base / "source"
output = base / "normalized"
document = self.document(source)
self.edition(
document,
10,
{
"ru": '<meta charset=unicode><style>x</style><p style="color:red">Статья 1 Закон<script>bad()</script></p><a href="javascript:bad">ссылка</a><a href="http://[">сломанная ссылка</a><img src="http://[">',
},
)
self.edition(document, 20, {"ky": "<p>1. Кыргызча жобо</p>"})
self.edition(document, 30, {"ru": "<p>Русский</p>", "ky": "<p>Кыргызча</p>"})
self.edition(document, 40, {})
first = normalize_archive(source, output)
fragments_path = output / "documents/1/editions/10/ru/fragments.json"
fragments = fragments_path.read_bytes()
second = normalize_archive(source, output)
self.assertEqual((first.normalized, second.skipped), (1, 1))
self.assertEqual(fragments, fragments_path.read_bytes())
safe_html = (output / "documents/1/editions/10/ru/content.html").read_text(encoding="utf-8")
self.assertNotIn("script", safe_html)
self.assertNotIn("style=", safe_html)
self.assertNotIn("javascript:", safe_html)
self.assertNotIn("http://[", safe_html)
self.assertIn("Статья 1 Закон", safe_html)
parsed = json.loads(fragments)
self.assertEqual(parsed[0]["type"], "article")
self.assertEqual(parsed[0]["id"], "document:1:edition:10:lang:ru:fragment:1")
self.assertEqual(len(parsed[0]["text_sha256"]), 64)
canonical = json.loads((output / "documents/1/document.json").read_text(encoding="utf-8"))
self.assertEqual(canonical["available_languages"], ["ru", "ky"])
self.assertIsNone(canonical["title"]["ru"])
empty = json.loads((output / "documents/1/editions/40/edition.json").read_text(encoding="utf-8"))
self.assertFalse(empty["quality"]["has_html"])
def test_continues_after_bad_document_and_clears_repaired_error(self):
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
source = base / "source"
output = base / "normalized"
bad = source / "documents/1"
bad.mkdir(parents=True)
(bad / "metadata.json").write_text("not json", encoding="utf-8")
good = self.document(source, 2)
self.edition(good, 10, {"ky": "<p>Берене 1 Текст</p>"})
with self.assertLogs("normalization.minjust_cbd", level="ERROR"):
failed = normalize_archive(source, output)
with sqlite3.connect(output / "manifest.sqlite3") as connection:
state, failed_at = connection.execute(
"SELECT state, failed_at FROM documents WHERE code='1'"
).fetchone()
self.assertEqual(state, "error")
self.assertIsNotNone(failed_at)
self.write_json(bad / "metadata.json", {"Code": 1, "Name": {"Rus": "Исправлен", "Kyr": None}})
repaired = normalize_archive(source, output)
self.assertEqual((failed.failed, failed.normalized), (1, 1))
self.assertEqual((repaired.normalized, repaired.skipped, repaired.failed), (1, 1, 0))
with sqlite3.connect(output / "manifest.sqlite3") as connection:
self.assertEqual(
connection.execute(
"SELECT state, error, failed_at FROM documents WHERE code='1'"
).fetchone(),
("success", None, None),
)
def test_rejects_overlapping_input_and_output(self):
with tempfile.TemporaryDirectory() as temporary:
source = Path(temporary) / "source"
metadata = self.document(source) / "metadata.json"
original = metadata.read_bytes()
for output in (source, source / "normalized", source.parent):
with self.subTest(output=output):
with self.assertRaisesRegex(ValueError, "must not overlap"):
normalize_archive(source, output)
self.assertEqual(metadata.read_bytes(), original)
def test_recovers_interrupted_directory_publication_before_skip(self):
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
source = base / "source"
output = base / "normalized"
self.document(source)
first = normalize_archive(source, output)
target = output / "documents/1"
backup = output / "documents/.1.previous"
os.replace(target, backup)
second = normalize_archive(source, output)
self.assertEqual((first.normalized, second.skipped), (1, 1))
self.assertTrue((target / "document.json").is_file())
self.assertFalse(backup.exists())
if __name__ == "__main__":
unittest.main()

36
docs/README.md Normal file
View File

@@ -0,0 +1,36 @@
# Документация Акылдаш
## Продукт
- [Обзор проекта](product/project-overview.md) — назначение, основные области и
правила работы с данными.
- [План frontend поисковой СПС](product/frontend-search-sps-plan.md) — границы
MVP, зависимости и спринты.
- [Задание по нормализации документов](product/minjust-document-normalization-agent-task.md) —
требования и критерии приёмки нормализатора ЦБД Минюста КР.
## Решения
- [Решение 001](decisions/001-telegram-workspace-mvp.md) — границы MVP
Telegram-инфраструктуры.
## Эксплуатация и рабочий процесс
- [Текущий статус](operations/project-status.md) — выполненные шаги, риски и
ближайшие действия.
- [План Telegram-пространства](operations/telegram-workspace-plan.md) — темы,
роли и этапы развития командного окружения.
## Backend
- [Выгрузка и нормализация ЦБД Минюста КР](../backend/README.md) — запуск,
хранение и проверка конвейера правовых документов.
## Команда
- [Навыки работы с ИИ](team/ai-skills-for-beginners.md) — короткие практики для
начинающих.
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан

View File

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

View File

@@ -1,15 +1,19 @@
# Статус проекта
Последняя проверка: 2026-08-03
Последняя проверка: 2026-08-10
Назначение документа: быстро восстановить контекст проекта для участников команды и будущих агентов.
Версия приложения: `0.2.0`
- Telegram-бот: `0.2.2`
- Telegram-бот на Synology: `0.2.1`
- Backend: `0.2.2`
- Frontend: не создан
## Краткий итог
Telegram-инфраструктура и первая версия бота-секретаря подготовлены. Бот сохраняет сообщения разрешённых тем в SQLite, экспортирует обсуждения в Markdown и постоянно запущен в Container Manager на Synology.
Репозиторий переориентирован с отдельного бота на весь проект юридической информационно-аналитической платформы. Telegram-бот выделен в инструмент рабочего окружения. Реализованы возобновляемая выгрузка документов из официального Open Data API ЦБД Минюста КР и их локальная воспроизводимая нормализация.
Ближайшая цель — 2026-08-04 зафиксировать MVP с командой, настроить резервное копирование SQLite и затем проверить полный рабочий цикл на реальном обсуждении.
Ближайшая цель — выполнить полный проход нормализатора, проверить отчёт ошибок
и качество контрольной выборки RU/KY, затем подготовить индекс OpenSearch.
## Уже сделано
@@ -36,8 +40,9 @@ Telegram-инфраструктура и первая версия бота-се
### Документация и правила
- Подготовлен общий план реализации в `TEAM_AI_TELEGRAM_IMPLEMENTATION_PLAN.md`.
- Зафиксированы границы MVP и правила работы с данными в `docs/decisions/001-mvp-boundaries-and-rules.md`.
- Подготовлен общий план реализации в `docs/operations/telegram-workspace-plan.md`.
- Зафиксированы границы MVP и правила работы с данными в `docs/decisions/001-telegram-workspace-mvp.md`.
- Добавлены обзор всей платформы и единый индекс документации.
- В MVP не входит автоматический вызов API языковой модели.
- Анализ экспортов выполняется вручную в обычном ChatGPT.
- Telegram используется как рабочий штаб, Git/Gitea — как источник утверждённых материалов.
@@ -52,20 +57,30 @@ Telegram-инфраструктура и первая версия бота-се
### Репозиторий и приложение
- Реализован бот-секретарь версии `0.2.0` без внешних Python-зависимостей.
- Реализован бот-секретарь версии `0.2.2` без внешних Python-зависимостей.
- Код бота выделен из корня репозитория в `tools/telegram-bot`.
- Сообщения и полные Telegram-метаданные сохраняются в SQLite.
- Добавлены команды `/help`, `/status`, `/export [дней]` и `/export все`.
- Добавлены команды `/help`, `/status` и `/export`.
- Экспорт доступен только владельцу, указанному в `TELEGRAM_OWNER_ID`.
- Первый `/export` охватывает всю сохранённую тему, последующие начинаются после последней успешно созданной отсечки.
- Отчёты публикуются в закрытой теме `Отчеты` (`message_thread_id=37`).
- Исходное обсуждение завершается заметной отсечкой со ссылкой и хэштегом отчёта.
- Локальный Git-репозиторий восстановлен и привязан к Gitea.
- Репозиторий организован как основа всего проекта, а не отдельного бота.
- Бот развёрнут в Container Manager на Synology; автозапуск после перезапуска менеджера проверен.
- Реализован backend-загрузчик ЦБД Минюста КР версии `0.2.2` без внешних зависимостей.
- Загрузчик сохраняет метаданные, редакции RU/KY и изображения, а прогресс — в SQLite.
- Пилотная выгрузка двух документов и возобновление без повторного скачивания проверены на живом API.
- Реализован backend-нормализатор версии `0.2.2` без внешних зависимостей.
- Нормализатор создаёт канонические метаданные, безопасный HTML, чистый текст и адресуемые фрагменты RU/KY.
- SQLite-манифест обеспечивает возобновление, повтор ошибок и пропуск неизменившихся документов.
### Развёртывание
- Бот постоянно запущен на Synology в контейнере `akyldash-bot`.
- Используется официальный образ `python:3.11-slim` и long polling.
- Контейнер работает без root, с read-only root filesystem и политикой `unless-stopped`.
- Доступ к Telegram Bot API идёт через отдельный закрытый прокси-контейнер без опубликованных наружу портов.
- Код, закрытый env-файл и SQLite хранятся в `/volume1/docker/akyldash`.
## Пока не сделано
@@ -117,8 +132,41 @@ Telegram-инфраструктура и первая версия бота-се
## История изменений статуса
### 2026-08-12
- Нормализатор запрещает пересекающиеся каталоги источника и результата,
восстанавливает прерванную публикацию и отбрасывает некорректные URL.
- Версия backend обновлена до `0.2.2`.
- Загрузчик пересоздаёт временный список документов на текущей странице не
только после HTTP 404, но и после исчерпания повторов запроса списка.
- Версия backend обновлена до `0.2.1`.
### 2026-08-10
- Добавлена первая версия воспроизводимой нормализации локального архива ЦБД.
- Добавлены атомарная публикация результатов, контрольные суммы, карантин ошибок и терминальный прогресс.
- Версия backend обновлена до `0.2.0`.
### 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`.
- Созданы обзор продукта и структурированный индекс документации; будущие каталоги решено создавать по фактическим задачам.
- Закрытые юридические материалы решено хранить отдельно от основного репозитория.
- Версия Telegram-бота обновлена до `0.2.2` из-за изменения структуры запуска.
- Экспорт без параметров переведён с периода в семь дней на диапазон после предыдущей отсечки.
- Бот развёрнут в Container Manager на Synology; автозапуск проверен перезапуском.
- В теме `Работа с ИИ` опубликована первоначальная библиотека практик и отдельный материал о безопасной работе с Codex.
- В `Общее` опубликовано и закреплено приветствие; тема закрыта для сообщений.
@@ -149,4 +197,4 @@ Telegram-инфраструктура и первая версия бота-се
---
Акылдаш v0.2.0
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан

View File

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

View File

@@ -0,0 +1,261 @@
# План реализации frontend поисковой СПС
Основание: `docs/Функциональныеозможности_поисковой_СПС.docx`.
Документ с требованиями описывает не только frontend, но и поиск, юридическую
обработку, персональные данные, уведомления и внешние источники. Поэтому
frontend следует начинать после появления нормализованной базы, поискового API
и API документов.
Требования необходимо адаптировать под Кыргызскую Республику: в исходном
документе используются примеры ТК РФ и деление
«федеральный/региональный/муниципальный», которое нельзя переносить без
изменений.
## Задачи до начала frontend-разработки
### Обязательные для MVP
1. Нормализовать архив Минюста:
- очистить HTML;
- выделить структуру документа и редакций;
- унифицировать статусы, органы, виды документов и даты;
- сохранить ссылки на официальный источник и дату получения;
- определить правила отображения документов без текста.
2. Подготовить backend API:
- `GET /search`;
- `GET /search/filters`;
- `GET /documents/{code}`;
- `GET /documents/{code}/editions`;
- `GET /documents/{code}/editions/{edition}`;
- описание API в OpenAPI;
- серверную пагинацию, фильтрацию и сортировку.
3. Развернуть поисковый сервис. Рекомендуемый вариант — self-hosted OpenSearch:
- отдельные поля и анализаторы для русского и кыргызского текстов;
- русский морфологический анализ;
- ICU-нормализация кыргызского текста;
- словари синонимов и сокращений;
- подсветка совпадений;
- индексирование всех редакций.
4. Подготовить тестовый набор из 50100 реальных запросов на русском и
кыргызском языках и вручную отметить ожидаемые результаты.
5. Утвердить справочники:
- виды документов;
- органы принятия;
- статусы;
- уровни действия;
- тематический классификатор первой версии.
OpenSearch имеет встроенный
[русский морфологический анализатор](https://docs.opensearch.org/latest/analyzers/language-analyzers/russian/),
поддерживает [синонимы и нечёткий поиск](https://docs.opensearch.org/latest/query-dsl/full-text/match/)
и [подсветку результатов](https://docs.opensearch.org/latest/search-plugins/searching-data/highlight).
Встроенного кыргызского морфологического анализатора в перечне нет, поэтому
нужно отдельно проверить ICU и словари на реальных запросах.
[ICU-анализатор](https://docs.opensearch.org/latest/analyzers/language-analyzers/icu/)
обеспечивает Unicode-нормализацию, но сам по себе не гарантирует кыргызскую
морфологию.
### Сторонние сервисы, не нужные для MVP
Их не следует подключать заранее:
- Keycloak или другой OIDC-провайдер — перед закладками, папками и ролями;
- SMTP, Telegram или Web Push — перед «документами на контроле»;
- LibreOffice или Gotenberg — перед экспортом в Word, PDF и RTF;
- поставщики судебной практики и экспертных комментариев — после проверки
лицензий;
- источники курсов, календарей и справочных данных — перед соответствующим
разделом;
- Sentry или аналог — опционально перед публичным запуском.
## Граница MVP
MVP — публичная справочно-поисковая система без регистрации и персональных
функций.
В MVP входят:
- интерфейс на русском и кыргызском языках;
- строка полнотекстового поиска;
- исправление распространённых опечаток;
- базовые синонимы и сокращения;
- список результатов с подсвеченными фрагментами;
- фильтры по языку, виду документа, органу, статусу и дате;
- сортировка по релевантности и дате;
- пагинация;
- карточка документа;
- актуальная редакция, статус и дата актуальности;
- переключение между доступными языками;
- поиск внутри открытого документа;
- список редакций и открытие выбранной редакции;
- ссылка на официальный источник и сведения о происхождении данных;
- адаптивность, доступность, состояния загрузки и ошибок.
Сравнение редакций, аккаунты, заметки, уведомления, RAG и судебная практика в
MVP не входят.
Интерфейс не должен предполагать наличие обоих языков. На момент полного
скачивания архива распределение следующее:
- только русский язык — 29 432 документа;
- только кыргызский язык — 98 797 документов;
- оба языка — 80 901 документ;
- нет HTML-текста — 681 документ.
## Рекомендуемая основа frontend
- Next.js App Router и TypeScript;
- CSS Modules с BEM-именованием;
- дизайн-токены для цветов, отступов, типографики и состояний;
- серверный `fetch` и URL-параметры вместо отдельного глобального хранилища;
- Playwright для основных пользовательских сценариев;
- адаптивный web-интерфейс без отдельного мобильного приложения.
Next.js App Router поддерживает серверные компоненты, маршрутизацию и
TypeScript в стандартной конфигурации. См.
[официальную документацию](https://nextjs.org/docs/app).
## Дизайн-процесс и внешние ориентиры
При проектировании и проверке интерфейса используются следующие источники:
- [jakubkrehel/skills](https://github.com/jakubkrehel/skills) — обязательная
комплексная проверка интерфейса через `better-interface`, включая UI,
типографику, цвета, доступность, layout и тексты;
- [UI Skills](https://www.ui-skills.com/) — каталог практик и узких skills,
которые подключаются только под конкретную задачу после проверки их
содержания и лицензии;
- [Refero Styles](https://styles.refero.design/) — библиотека визуальных
направлений и примеров `DESIGN.md` для поиска референсов.
Правила применения:
1. До разработки экранов выбрать в Refero не более трёх подходящих направлений
и на их основе утвердить одно собственное направление Акылдаша.
2. Не копировать чужую дизайн-систему целиком. Цвета, типографика, плотность и
компоненты должны учитывать длинные юридические тексты, два языка и
доступность.
3. Зафиксировать утверждённое направление в `frontend/DESIGN.md` и перенести
значения в дизайн-токены проекта.
4. Дизайн-токены и компоненты Акылдаша являются источником истины. Внешние
рекомендации не могут отменять BEM, доступность, требования безопасности и
продуктовые ограничения проекта.
5. Каждый завершённый пользовательский сценарий проходит `better-interface`
review. Перед выпуском MVP выполняется полный review поиска, фильтров и
просмотра документа.
6. UI Skills используется для точечного поиска решения, а не для одновременного
смешивания нескольких визуальных стилей.
Эти ресурсы используются на этапе проектирования и review и не являются
runtime-зависимостями frontend. Регистрация в стороннем SaaS для MVP не нужна.
## План спринтов MVP
### Спринт 0 — фундамент, 1 неделя
- создать `frontend/`;
- настроить Next.js, TypeScript, lint и сборку;
- выбрать до трёх референсов в Refero Styles и утвердить одно визуальное
направление;
- создать `frontend/DESIGN.md` с правилами выбранного направления;
- установить полный набор `jakubkrehel/skills` для проектных design review;
- определить маршруты и типы API;
- создать дизайн-токены;
- реализовать базовые компоненты: кнопка, поле, селект, статус, карточка,
пагинация;
- создать общий layout и двуязычную навигацию;
- добавить footer с версиями frontend и backend;
- подготовить макеты поиска, результатов и документа;
- провести первый `better-interface` review макетов;
- настроить CI.
Результат: интерфейсный каркас работает на mock-ответах API.
### Спринт 1 — быстрый поиск, 2 недели
- главная страница с поиском;
- интеграция с `/search`;
- список результатов;
- подсветка совпадений;
- URL, которым можно поделиться;
- переключение RU/KY;
- состояния загрузки, отсутствия результатов и ошибки API;
- базовая мобильная версия.
Результат: пользователь может найти документ и открыть результат.
### Спринт 2 — точный отбор, 2 недели
- фильтры по реквизитам;
- сортировка;
- пагинация;
- отображение числа результатов;
- сброс отдельных и всех фильтров;
- сохранение состояния в URL;
- доступное управление с клавиатуры;
- адаптивная панель фильтров.
Результат: поддерживается быстрый и реквизитный поиск.
### Спринт 3 — просмотр документа, 2 недели
- заголовок, реквизиты, статус и дата актуальности;
- безопасное отображение очищенного HTML;
- переключение языка;
- поиск внутри документа;
- навигация по найденным фрагментам;
- список редакций;
- открытие предыдущей редакции;
- ссылка на ЦБД Минюста;
- печать средствами браузера.
Результат: пользователь может проверить текст и его происхождение.
### Спринт 4 — стабилизация и выпуск, 2 недели
- сквозные тесты поиска и просмотра;
- проверка русских, кыргызских и одноязычных документов;
- соответствие WCAG 2.2 AA;
- защита от внедрения небезопасного HTML;
- проверка производительности;
- корректные метаданные страниц;
- обработка недоступности API;
- production-сборка и развёртывание;
- пользовательское тестирование на 1015 реальных юридических задачах.
Результат: публичный MVP.
Оценка frontend-части после готовности API: **9 недель**.
## Спринты после MVP
### Спринт 5 — персональный кабинет
Авторизация, закладки, заметки, подборки и сохранённые фильтры.
### Спринт 6 — контроль изменений
Документы на контроле, подписки на редакции и уведомления.
### Спринт 7 — юридические связи
Сравнение редакций, прямые и обратные ссылки, утратившие силу фрагменты.
### Спринт 8 — практические материалы
Формы, образцы, инструкции, чек-листы, календари и справочные данные.
### Спринт 9 — расширенный анализ
Судебная практика, экспертные комментарии, дерево связей и RAG с обязательными
ссылками на источники.
### Спринт 10 — корпоративные функции
Роли, журналирование, API, интеграция с СЭД, расширенный экспорт и
персонализация.
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан

View File

@@ -0,0 +1,217 @@
# Задание агенту: нормализация документов ЦБД Минюста КР
## Цель
Реализовать первую рабочую версию воспроизводимого нормализатора локального
архива `data/minjust-cbd`. Нормализованные данные должны быть пригодны для
последующей загрузки в OpenSearch, backend API и RAG, но подключение этих
сервисов в текущую задачу не входит.
## Текущее состояние
- загрузчик находится в `backend/ingestion/minjust_cbd.py`;
- сырой архив хранится в `data/minjust-cbd` и исключён из Git;
- в манифесте 209 811 успешно загруженных документов;
- 13 кодов остаются в таблице `errors` и отсутствуют в таблице `documents`;
- документ содержит `metadata.json` и каталог `editions`;
- редакция содержит `metadata.json`, `ru.html` и/или `ky.html`, иногда
изображения;
- HTML создан Microsoft Word, может содержать некорректный
`<meta charset=unicode>`, служебные стили и неполную разметку;
- файлы архива записаны загрузчиком в UTF-8;
- часть документов одноязычная, а часть не содержит HTML-текста.
## Обязательные ограничения
1. Не изменять и не перезаписывать `data/minjust-cbd`.
2. Не запускать полный проход по архиву во время автоматических тестов.
3. Не подключать PostgreSQL, OpenSearch, OCR, embeddings, машинный перевод и
сетевые API.
4. Сначала использовать стандартную библиотеку. Новая зависимость допустима
только если на реальных образцах доказано, что стандартный HTML-парсер не
обеспечивает корректность или безопасность.
5. Все записи выполнять атомарно.
6. Ошибка одного документа не должна останавливать длительный прогон.
7. Повторный запуск должен пропускать неизменившиеся документы.
8. Не изменять пользовательские файлы `logs/`, исходный DOCX и несвязанные
незакоммиченные изменения.
## Размещение
Использовать существующую backend-структуру:
```text
backend/
normalization/
minjust_cbd.py
```
Результат по умолчанию:
```text
data/minjust-normalized/
manifest.sqlite3
documents/<document_code>/document.json
documents/<document_code>/editions/<edition_code>/edition.json
documents/<document_code>/editions/<edition_code>/<lang>/content.html
documents/<document_code>/editions/<edition_code>/<lang>/content.txt
documents/<document_code>/editions/<edition_code>/<lang>/fragments.json
```
Каталог уже покрывается правилом игнорирования `data/`.
## Канонические данные
### `document.json`
Сохранить как минимум:
- `schema_version`;
- `source_code`;
- двуязычные `class`, `type`, `title`, `name`, `status`;
- номера и даты без юридически неподтверждённых выводов;
- флаги публичности;
- органы, публикации, ключевые слова и классификаторы с сохранением дерева;
- пути листьев иерархий для будущих фильтров;
- ссылки из `References` без выдумывания связей;
- `available_languages`;
- список редакций;
- путь к источнику и SHA-256 исходных файлов;
- версию нормализатора и время обработки.
Пустые строки привести к `null`, но не переводить значения и не заменять
официальные формулировки собственными.
### `edition.json`
Сохранить:
- исходный код редакции;
- двуязычное название;
- исходный тип;
- доступные языки;
- изображения без бинарных данных;
- контрольные суммы источников;
- признаки качества.
Не считать дату из `Name` датой вступления редакции в силу и не назначать
актуальную редакцию без подтверждённого правила источника.
### Языковой вариант
Для каждого имеющегося `ru.html` или `ky.html` сформировать:
- `content.html` — безопасный HTML для frontend;
- `content.txt` — извлечённый текст с сохранением смысловых переносов;
- `fragments.json` — упорядоченные адресуемые блоки.
Сырой HTML всегда читать как UTF-8, не доверяя его meta charset.
## Очистка HTML
- удалить `script`, `style`, `meta`, `link`, комментарии и служебные элементы;
- удалить обработчики событий, inline-стили и опасные URL;
- разрешить минимальный набор структурных тегов: заголовки, абзацы, `pre`,
списки, таблицы, безопасные ссылки, изображения и базовое текстовое
выделение;
- нормализовать Unicode в NFC;
- преобразовать неразрывные пробелы и избыточные пробелы только в
`content.txt`, не искажая отображаемый юридический текст;
- не загружать внешние ресурсы;
- относительные изображения связывать только с файлами внутри редакции;
- неизвестную или сломанную разметку сохранять как текст, а не терять молча.
## Фрагменты
Минимальная версия должна создавать фрагмент для каждого содержательного
блочного элемента. Распознавание статей и пунктов допускается только простыми
проверяемыми правилами RU/KY; обычный абзац является fallback.
Каждый фрагмент содержит:
- стабильный `id`;
- `document_code`, `edition_code`, `language`;
- порядковую позицию;
- тип блока;
- чистый текст;
- SHA-256 текста.
Идентификатор должен быть детерминированным и включать документ, редакцию,
язык и позицию. Не добавлять сложное сопоставление фрагментов между
редакциями — это отдельная будущая задача.
## Манифест и возобновление
SQLite-манифест должен хранить:
- код документа;
- SHA-256 набора исходных файлов;
- версию схемы и нормализатора;
- время успешной обработки;
- состояние и текст последней ошибки.
Если checksum и версия нормализатора не изменились, документ пропускается.
После успешной повторной обработки ошибка удаляется. Добавить `--limit` и
понятный терминальный прогресс, пригодный для долгого запуска.
## CLI и импорт из будущего backend
CLI запускается из корня репозитория:
```bash
python3 backend/normalization/minjust_cbd.py
```
Предусмотреть параметры:
- `--input`;
- `--output`;
- `--limit`;
- `--refresh`;
- `--log-level`.
Основную функцию можно импортировать без запуска CLI. Не создавать
планировщик, очередь задач или framework-интеграцию.
## Проверки
Добавить один компактный тестовый модуль, который проверяет:
1. русскую редакцию;
2. кыргызскую редакцию;
3. двуязычную редакцию;
4. Word HTML с опасным `script`, inline-стилем и `javascript:` URL;
5. документ без HTML;
6. повторный запуск и пропуск неизменившегося документа;
7. продолжение после ошибки одного документа;
8. детерминированные fragment ID и checksums.
Провести пилотный read-only запуск на небольшой реальной выборке через
`--limit`, не нормализовать весь архив в рамках разработки.
## Документация и версия
- описать запуск, структуру результата и ограничения в `backend/README.md`;
- отметить реализацию в `docs/operations/project-status.md`;
- это новая обратно совместимая backend-функция: увеличить minor-версию
backend по SemVer;
- обновить все отображаемые backend-версии и footer, не меняя версию
Telegram-бота;
- frontend по-прежнему помечать как не созданный.
## Критерии приёмки
- сырой архив не изменён;
- тесты проходят;
- `git diff --check` проходит;
- пилотный запуск завершается без остановки на отдельных ошибках;
- повторный пилотный запуск пропускает неизменившиеся документы;
- unsafe HTML не попадает в `content.html`;
- каждый фрагмент прослеживается до документа, редакции, языка и исходного
файла;
- отсутствуют молча потерянные HTML или ошибки;
- реализация не содержит PostgreSQL/OpenSearch/RAG-кода «на будущее».
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан

View File

@@ -0,0 +1,47 @@
# Обзор проекта
## Назначение
Акылдаш — юридическая информационно-аналитическая платформа. Она должна помочь
пользователю находить применимые нормы и материалы, понимать их актуальность и
получать ответ со ссылками на проверяемые первоисточники. Платформа не заменяет
профессиональное юридическое заключение.
## Основные области
1. Получение открытых или лицензированных законов, актов и иных правовых
источников.
2. Очистка, нормализация, связывание редакций и сохранение происхождения данных.
3. Хранение структурированных документов и поисковых индексов.
4. Формирование базы знаний и RAG с обязательными ссылками на источники.
5. Backend для доступа к данным и функциям платформы.
6. Frontend для поиска, анализа и работы с результатами.
7. Инструменты рабочего окружения команды, включая Telegram-бота.
Физическая структура каждой области появится вместе с её первой задачей. До
этого список служит картой продукта, а не обещанием заранее выбранной
архитектуры.
## Обязательные принципы
- Источник, дата получения, редакция и лицензия документа должны быть
прослеживаемыми.
- Ответ системы должен отделять найденный факт от вывода модели и ссылаться на
конкретный первоисточник.
- Актуальность правовых данных должна проверяться до использования в ответе.
- Существенные юридические выводы проверяет человек.
- Секреты и персональные данные не попадают в Git, логи и тестовые наборы.
- Данные загружаются только при наличии законного основания и с соблюдением
условий источника.
## Правовая защита проекта
Договоры, заявки, материалы об интеллектуальной собственности и другие
закрытые юридические документы следует хранить отдельно от исходного кода: в
закрытом репозитории или защищённом документном хранилище. Доступ выдаётся
поимённо и только тем, кому он нужен. В основном репозитории можно хранить
несекретный реестр документов, правила доступа и ссылки на место хранения.
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан

View File

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

View File

@@ -0,0 +1,51 @@
# Telegram-бот Акылдаш
Версия: `0.2.2`
Бот сохраняет сообщения разрешённых тем Telegram в локальную SQLite-базу и
выгружает обсуждения в Markdown. Внешние Python-зависимости не требуются.
## Запуск
Требуется Python 3.11 или новее.
```bash
cd tools/telegram-bot
export TELEGRAM_BOT_TOKEN='...'
export TELEGRAM_CHAT_ID='-1004242041275'
export TELEGRAM_OWNER_ID='87262245'
export TELEGRAM_REPORT_THREAD_ID='37'
export TELEGRAM_ALLOWED_THREAD_IDS='0,2,4,6,8'
python3 bot.py
```
Доступные команды: `/help`, `/status`, `/export`. Экспорт доступен только
пользователю с Telegram ID из `TELEGRAM_OWNER_ID`. Первый `/export` выгружает
всю сохранённую тему, последующие — сообщения после предыдущей успешно
созданной отсечки.
Markdown публикуется в теме `TELEGRAM_REPORT_THREAD_ID`, а в исходной теме
остаётся отсечка со ссылкой и общим хэштегом отчёта.
База по умолчанию хранится в `data/secretary.sqlite3`. Сообщения из других
групп и тем не сохраняются. Полный ответ Telegram сохраняется в базе, поэтому
метаданные вложений остаются доступными для последующего скачивания.
## Проверка
```bash
python3 -m unittest -v
```
## Synology
Контейнер `akyldash-bot` работает на образе `python:3.11-slim` через закрытый
прокси-контейнер, с политикой перезапуска `unless-stopped`. Постоянные данные
находятся в `/volume1/docker/akyldash/data`.
При обновлении развёртывания файл `tools/telegram-bot/bot.py` копируется в
каталог контейнера как `bot.py`.
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан

View File

@@ -18,7 +18,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
APP_NAME = "Акылдаш"
APP_VERSION = "0.2.0"
APP_VERSION = "0.2.2"
FOOTER = f"{APP_NAME} v{APP_VERSION}"
@@ -146,6 +146,29 @@ def next_update_id(connection: sqlite3.Connection) -> int:
return int(row["value"]) if row else 0
def export_checkpoint(
connection: sqlite3.Connection, chat_id: int, thread_id: int
) -> int:
row = connection.execute(
"SELECT value FROM state WHERE key = ?",
(f"export_checkpoint:{chat_id}:{thread_id}",),
).fetchone()
return int(row["value"]) if row else 0
def save_export_checkpoint(
connection: sqlite3.Connection, chat_id: int, thread_id: int, message_id: int
) -> None:
with connection:
connection.execute(
"""
INSERT INTO state (key, value) VALUES (?, ?)
ON CONFLICT (key) DO UPDATE SET value = excluded.value
""",
(f"export_checkpoint:{chat_id}:{thread_id}", str(message_id)),
)
def telegram_request(
config: Config, method: str, payload: dict, timeout: int = 65
) -> object:
@@ -248,10 +271,14 @@ def export_markdown(
thread_id: int,
days: int | None,
now: int | None = None,
after_message_id: int | None = None,
) -> str:
parameters: list[int] = [config.chat_id, thread_id]
condition = ""
if days is not None:
if after_message_id is not None:
condition = " AND message_id > ?"
parameters.append(after_message_id)
elif days is not None:
condition = " AND sent_at >= ?"
parameters.append((now or int(time.time())) - days * 86400)
rows = connection.execute(
@@ -263,7 +290,14 @@ def export_markdown(
parameters,
).fetchall()
period = "за всё время" if days is None else f"за последние {days} дн."
if after_message_id is not None:
period = (
"с начала архива"
if after_message_id == 0
else "после предыдущей отсечки"
)
else:
period = "за всё время" if days is None else f"за последние {days} дн."
lines = [
"# Обсуждение",
"",
@@ -328,8 +362,7 @@ def handle_command(
message,
"Я сохраняю обсуждения этой группы.\n"
"/status — количество сохранённых сообщений\n"
"/export [дней] — экспорт текущей темы за 7 дней или указанный срок\n"
"/export все — экспорт текущей темы целиком",
"/export — экспорт текущей темы после предыдущей отсечки",
)
elif command == "/status":
topic_count = connection.execute(
@@ -349,11 +382,22 @@ def handle_command(
send_text(config, message, "Экспорт доступен только владельцу бота.")
return
try:
days = parse_export_days(parts)
days = parse_export_days(parts) if len(parts) > 1 else None
except (ValueError, IndexError):
send_text(config, message, "Использование: /export [13650|все]")
return
markdown = export_markdown(connection, config, thread_id, days)
checkpoint = (
None
if len(parts) > 1
else export_checkpoint(connection, config.chat_id, thread_id)
)
markdown = export_markdown(
connection,
config,
thread_id,
days,
after_message_id=checkpoint,
)
stamp = datetime.now(config.timezone).strftime("%Y%m%d-%H%M")
report_tag = f"#report_{message['message_id']}"
report = send_document(
@@ -373,6 +417,9 @@ def handle_command(
f"Отчёт: {message_link(config.chat_id, report['message_id'])}\n"
"━━━━━━━━━━━━━━━━",
)
save_export_checkpoint(
connection, config.chat_id, thread_id, int(message["message_id"])
)
def run() -> None:

View File

@@ -153,7 +153,7 @@ class SecretaryTest(unittest.TestCase):
"date": 1,
"chat": {"id": config.chat_id},
"from": {"id": config.owner_id},
"text": "/export все",
"text": "/export",
}
with patch("bot.send_text") as send_text, patch(
@@ -170,6 +170,69 @@ class SecretaryTest(unittest.TestCase):
self.assertIn("#report_12", marker)
self.assertIn("https://t.me/c/123/99", marker)
def test_next_export_starts_after_previous_cutoff(self):
with tempfile.TemporaryDirectory() as directory:
config = Config(
token="test",
chat_id=-100123,
owner_id=7,
report_thread_id=37,
thread_ids=frozenset({2}),
database=Path(directory) / "bot.sqlite3",
timezone=ZoneInfo("UTC"),
)
connection = connect(config.database)
def archive(message_id, text):
archive_update(
connection,
{
"update_id": message_id,
"message": {
"message_id": message_id,
"message_thread_id": 2,
"date": message_id,
"chat": {"id": config.chat_id},
"from": {"id": config.owner_id},
"text": text,
},
},
config,
)
archive(10, "Первое обсуждение")
with patch("bot.send_text"), patch(
"bot.send_document",
side_effect=[{"message_id": 90}, {"message_id": 91}],
) as send_document:
handle_command(
connection,
config,
{
"message_id": 12,
"message_thread_id": 2,
"from": {"id": config.owner_id},
"text": "/export",
},
)
archive(13, "Второе обсуждение")
handle_command(
connection,
config,
{
"message_id": 14,
"message_thread_id": 2,
"from": {"id": config.owner_id},
"text": "/export",
},
)
first_export = send_document.call_args_list[0].args[3].decode()
second_export = send_document.call_args_list[1].args[3].decode()
self.assertIn("Первое обсуждение", first_export)
self.assertNotIn("Первое обсуждение", second_export)
self.assertIn("Второе обсуждение", second_export)
if __name__ == "__main__":
unittest.main()