Compare commits
13 Commits
feature/sy
...
feature/mi
| Author | SHA1 | Date | |
|---|---|---|---|
| 752bd38c9b | |||
| 07381ad70f | |||
| 35cae8bcbe | |||
| efa55fe74b | |||
| 0eedcfcfbd | |||
| 1d398ebb00 | |||
| 9cf6a7e940 | |||
| b8b9ec75b3 | |||
| c94131effc | |||
| d9b4f19af3 | |||
| d4ee76948f | |||
| cf54bc2246 | |||
| a67bdbfaa4 |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -11,5 +11,8 @@ __pycache__/
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Local bot archive
|
||||
# Local application archives
|
||||
data/
|
||||
|
||||
# Local runtime logs
|
||||
logs/
|
||||
|
||||
75
README.md
75
README.md
@@ -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
103
backend/README.md
Normal 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 — не создан
|
||||
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.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())
|
||||
729
backend/normalization/minjust_cbd.py
Normal file
729
backend/normalization/minjust_cbd.py
Normal 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
156
backend/test_minjust_cbd.py
Normal 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()
|
||||
152
backend/test_minjust_normalization.py
Normal file
152
backend/test_minjust_normalization.py
Normal 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()
|
||||
@@ -1,133 +0,0 @@
|
||||
# Статус проекта
|
||||
|
||||
Последняя проверка: 2026-08-02
|
||||
Назначение документа: быстро восстановить контекст проекта для участников команды и будущих агентов.
|
||||
|
||||
Версия приложения: `0.2.0`
|
||||
|
||||
## Краткий итог
|
||||
|
||||
Telegram-инфраструктура и первая версия бота-секретаря подготовлены. Бот сохраняет сообщения разрешённых тем в SQLite, переживает перезапуск и экспортирует обсуждения в Markdown.
|
||||
|
||||
Ближайшая цель — проверить экспорт из постоянно работающего контейнера и настроить резервное копирование SQLite.
|
||||
|
||||
## Уже сделано
|
||||
|
||||
### Telegram
|
||||
|
||||
- Создана супергруппа `Акылдаш`.
|
||||
- Создан бот `help_clerk_bot`.
|
||||
- Бот добавлен в супергруппу с необходимыми правами.
|
||||
- Privacy Mode бота отключён.
|
||||
- Созданы и проверены рабочие темы.
|
||||
- Проверено подключение к Telegram Bot API.
|
||||
- Webhook у бота не установлен; доступен режим long polling через `getUpdates`.
|
||||
|
||||
### ID тем
|
||||
|
||||
| Тема | `message_thread_id` | Состояние |
|
||||
|---|---:|---|
|
||||
| Общее | отсутствует | стандартная тема |
|
||||
| MVP | `2` | подтверждено тестовым сообщением |
|
||||
| Решения | `4` | подтверждено тестовым сообщением |
|
||||
| Обсуждение | `6` | подтверждено тестовым сообщением |
|
||||
| Работа с ИИ | `8` | подтверждено тестовым сообщением |
|
||||
| Отчеты | `37` | закрытая тема для Markdown-экспортов |
|
||||
|
||||
### Документация и правила
|
||||
|
||||
- Подготовлен общий план реализации в `TEAM_AI_TELEGRAM_IMPLEMENTATION_PLAN.md`.
|
||||
- Зафиксированы границы MVP и правила работы с данными в `docs/decisions/001-mvp-boundaries-and-rules.md`.
|
||||
- В MVP не входит автоматический вызов API языковой модели.
|
||||
- Анализ экспортов выполняется вручную в обычном ChatGPT.
|
||||
- Telegram используется как рабочий штаб, Git/Gitea — как источник утверждённых материалов.
|
||||
|
||||
### Доступы
|
||||
|
||||
- Локальные доступы находятся в `credentials.json`.
|
||||
- `credentials.json` добавлен в `.gitignore`.
|
||||
- Секреты нельзя добавлять в Git, логи, экспорты или сообщения бота.
|
||||
|
||||
### Репозиторий и приложение
|
||||
|
||||
- Реализован бот-секретарь версии `0.2.0` без внешних Python-зависимостей.
|
||||
- Сообщения и полные Telegram-метаданные сохраняются в SQLite.
|
||||
- Добавлены команды `/help`, `/status`, `/export [дней]` и `/export все`.
|
||||
- Экспорт доступен только владельцу, указанному в `TELEGRAM_OWNER_ID`.
|
||||
- Отчёты публикуются в закрытой теме `Отчеты` (`message_thread_id=37`).
|
||||
- Исходное обсуждение завершается заметной отсечкой со ссылкой и хэштегом отчёта.
|
||||
- Локальный Git-репозиторий восстановлен и привязан к Gitea.
|
||||
|
||||
### Развёртывание
|
||||
|
||||
- Бот постоянно запущен на Synology в контейнере `akyldash-bot`.
|
||||
- Используется официальный образ `python:3.11-slim` и long polling.
|
||||
- Контейнер работает без root, с read-only root filesystem и политикой `unless-stopped`.
|
||||
- Код, закрытый env-файл и SQLite хранятся в `/volume1/docker/akyldash`.
|
||||
|
||||
## Пока не сделано
|
||||
|
||||
- Не настроен CI/CD.
|
||||
|
||||
### Хранилище
|
||||
|
||||
- PostgreSQL не подключён; первая версия использует SQLite.
|
||||
- Не утверждена окончательная схема PostgreSQL.
|
||||
- Не определены сроки хранения сообщений, вложений и экспортов.
|
||||
|
||||
### Функции бота
|
||||
|
||||
- Скачивание файлов вложений; сейчас сохраняются их Telegram-метаданные.
|
||||
- Ручная пометка отдельных сообщений для экспорта.
|
||||
- Формирование готовой инструкции для ручной загрузки в ChatGPT.
|
||||
- Возврат отчёта пользователя в Telegram.
|
||||
|
||||
### Группа и рабочий процесс
|
||||
|
||||
- Не подготовлены закреплённые сообщения с правилами тем.
|
||||
- Не проверены сценарии закрытия темы `MVP` после фиксации состава первой версии.
|
||||
- Не настроены уведомления Gitea.
|
||||
- Не проведён полный пользовательский тест: сообщение → сохранение → экспорт → анализ в ChatGPT → возврат отчёта.
|
||||
|
||||
## Важные неизвестные
|
||||
|
||||
Для следующего этапа нужно получить или принять решения по следующим вопросам:
|
||||
|
||||
1. Адрес и параметры PostgreSQL для разработки и Synology.
|
||||
2. Правила хранения и удаления сообщений и вложений.
|
||||
3. Нужен ли webhook после постоянного развёртывания; первая версия работает через long polling.
|
||||
|
||||
## Следующий этап
|
||||
|
||||
### Проверка постоянного запуска
|
||||
|
||||
Рекомендуемый порядок:
|
||||
|
||||
1. Проверить `/status` и `/export 1` из контейнера Synology.
|
||||
2. Перезапустить контейнер и убедиться, что сообщения не дублируются.
|
||||
3. Настроить резервное копирование `/volume1/docker/akyldash/data`.
|
||||
|
||||
## История изменений статуса
|
||||
|
||||
### 2026-08-02
|
||||
|
||||
- Экспорт ограничен Telegram ID владельца бота.
|
||||
- Технические ответы на корневое сообщение темы исключены из Markdown.
|
||||
- Экспорт перенесён в тему `Отчеты`, добавлены отсечки и навигационные хэштеги.
|
||||
- Проверена живая публикация отчёта и отсечки через Telegram Bot API.
|
||||
- Бот версии `0.2.0` развёрнут в Container Manager на Synology.
|
||||
- Версия приложения обновлена до `0.2.0`.
|
||||
|
||||
### 2026-08-01
|
||||
|
||||
- Восстановлена связь локального каталога с репозиторием Gitea.
|
||||
- Реализован бот-секретарь версии `0.1.0` с SQLite и Markdown-экспортом.
|
||||
- Проверены токен `help_clerk_bot` и доступ к супергруппе `Акылдаш`.
|
||||
|
||||
### 2026-07-31
|
||||
|
||||
- Создана супергруппа и добавлен бот.
|
||||
- Отключён Privacy Mode.
|
||||
- Определены ID тем: `2`, `4`, `6`, `8`.
|
||||
- Подтверждено отсутствие webhook.
|
||||
- Зафиксировано, что код бота и хранилище ещё не реализованы.
|
||||
36
docs/README.md
Normal file
36
docs/README.md
Normal 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 — не создан
|
||||
14
docs/decisions/001-mvp-boundaries-and-rules.md → docs/decisions/001-telegram-workspace-mvp.md
Executable file → Normal file
14
docs/decisions/001-mvp-boundaries-and-rules.md → docs/decisions/001-telegram-workspace-mvp.md
Executable file → Normal file
@@ -54,9 +54,9 @@ Telegram не является хранилищем проекта, а бот и
|
||||
## Граница между ботом и ИИ
|
||||
|
||||
- Бот без обращения к API языковой модели полностью выполняет сбор сообщений, сортировку, добавление метаданных, подготовку вложений и формирование Markdown-экспорта.
|
||||
- Бот добавляет к экспорту готовую инструкцию для ручной загрузки в обычный ChatGPT.
|
||||
- На следующем этапе бот должен добавлять к экспорту готовую инструкцию для ручной загрузки в обычный ChatGPT.
|
||||
- Выделение решений, задач, рисков, противоречий и открытых вопросов выполняется в ChatGPT вручную через подписку участника.
|
||||
- Бот может принять возвращённый человеком отчёт, опубликовать его в Telegram и связать с исходным обсуждением.
|
||||
- На следующем этапе бот должен принимать возвращённый человеком отчёт, публиковать его в Telegram и связывать с исходным обсуждением.
|
||||
- Бот не выдаёт эвристический или шаблонный результат за выполненный ИИ-анализ. Поиск ключевых слов и ручные метки допустимы только как вспомогательная навигация.
|
||||
|
||||
## Техническое замечание о правах тем
|
||||
@@ -65,13 +65,17 @@ Telegram позволяет запретить пользователям отп
|
||||
|
||||
## Инфраструктурные предпосылки
|
||||
|
||||
- Доступы для настройки находятся в `credentials.json`; секреты из этого файла нельзя включать в Git, логи CI/CD, экспорт Telegram или сообщения бота.
|
||||
- Доступы для настройки находятся в локальном `credentials.json`; секреты из этого файла нельзя включать в Git, логи CI/CD, экспорт Telegram или сообщения бота.
|
||||
- Для проекта можно создать открытый репозиторий в Gitea.
|
||||
- В Gitea необходимо настроить защиту `main`: прямые push запрещены, изменения проходят через pull request и проверки CI/CD.
|
||||
- Бот развёрнут в Container Manager на Synology с постоянным SQLite-томом и политикой автоперезапуска.
|
||||
- Бот развёрнут в Container Manager на Synology с постоянным SQLite-томом и политикой автоперезапуска; конфигурацию развёртывания ещё предстоит зафиксировать в репозитории.
|
||||
- PostgreSQL будет использоваться как хранилище бота; параметры доступа к базе будут предоставлены отдельно.
|
||||
- Почта может использоваться как дополнительный канал доставки файлов и отчётов, но не как основное хранилище.
|
||||
|
||||
## Критерий завершения Stage 0
|
||||
|
||||
Границы MVP, состав тем и правила работы с данными подтверждены. Остаётся реализовать жизненный цикл тем и экспортов, после чего можно проектировать схему хранения и команды бота, не меняя назначение первой версии по ходу реализации.
|
||||
Критерий выполнен: границы MVP зафиксированы, темы созданы, первая версия бота и экспортов реализована. Полный рабочий цикл будет принят командой после проверки на реальном обсуждении.
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан
|
||||
200
docs/operations/project-status.md
Normal file
200
docs/operations/project-status.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# Статус проекта
|
||||
|
||||
Последняя проверка: 2026-08-10
|
||||
Назначение документа: быстро восстановить контекст проекта для участников команды и будущих агентов.
|
||||
|
||||
- Telegram-бот: `0.2.2`
|
||||
- Telegram-бот на Synology: `0.2.1`
|
||||
- Backend: `0.2.2`
|
||||
- Frontend: не создан
|
||||
|
||||
## Краткий итог
|
||||
|
||||
Репозиторий переориентирован с отдельного бота на весь проект юридической информационно-аналитической платформы. Telegram-бот выделен в инструмент рабочего окружения. Реализованы возобновляемая выгрузка документов из официального Open Data API ЦБД Минюста КР и их локальная воспроизводимая нормализация.
|
||||
|
||||
Ближайшая цель — выполнить полный проход нормализатора, проверить отчёт ошибок
|
||||
и качество контрольной выборки RU/KY, затем подготовить индекс OpenSearch.
|
||||
|
||||
## Уже сделано
|
||||
|
||||
### Telegram
|
||||
|
||||
- Создана супергруппа `Акылдаш`.
|
||||
- Создан бот `help_clerk_bot`.
|
||||
- Бот добавлен в супергруппу с необходимыми правами.
|
||||
- Privacy Mode бота отключён.
|
||||
- Созданы и проверены рабочие темы.
|
||||
- Проверено подключение к Telegram Bot API.
|
||||
- Webhook у бота не установлен; доступен режим long polling через `getUpdates`.
|
||||
|
||||
### ID тем
|
||||
|
||||
| Тема | `message_thread_id` | Состояние |
|
||||
|---|---:|---|
|
||||
| Общее | отсутствует | приветствие закреплено, тема закрыта |
|
||||
| MVP | `2` | подтверждено тестовым сообщением |
|
||||
| Решения | `4` | подтверждено тестовым сообщением |
|
||||
| Обсуждение | `6` | подтверждено тестовым сообщением |
|
||||
| Работа с ИИ | `8` | материалы опубликованы, тема закрыта |
|
||||
| Отчеты | `37` | экспорт проверен, тема закрыта |
|
||||
|
||||
### Документация и правила
|
||||
|
||||
- Подготовлен общий план реализации в `docs/operations/telegram-workspace-plan.md`.
|
||||
- Зафиксированы границы MVP и правила работы с данными в `docs/decisions/001-telegram-workspace-mvp.md`.
|
||||
- Добавлены обзор всей платформы и единый индекс документации.
|
||||
- В MVP не входит автоматический вызов API языковой модели.
|
||||
- Анализ экспортов выполняется вручную в обычном ChatGPT.
|
||||
- Telegram используется как рабочий штаб, Git/Gitea — как источник утверждённых материалов.
|
||||
- Подготовлены и опубликованы краткие навыки работы с ИИ для начинающих.
|
||||
- В `Общее` опубликовано и закреплено приветствие с назначением тем и описанием бота.
|
||||
|
||||
### Доступы
|
||||
|
||||
- Локальные доступы находятся в `credentials.json`.
|
||||
- `credentials.json` добавлен в `.gitignore`.
|
||||
- Секреты нельзя добавлять в Git, логи, экспорты или сообщения бота.
|
||||
|
||||
### Репозиторий и приложение
|
||||
|
||||
- Реализован бот-секретарь версии `0.2.2` без внешних Python-зависимостей.
|
||||
- Код бота выделен из корня репозитория в `tools/telegram-bot`.
|
||||
- Сообщения и полные Telegram-метаданные сохраняются в SQLite.
|
||||
- Добавлены команды `/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`.
|
||||
|
||||
## Пока не сделано
|
||||
|
||||
### Развёртывание и сопровождение
|
||||
|
||||
- Docker-конфигурация развёртывания не хранится в репозитории.
|
||||
- Не настроен CI/CD.
|
||||
|
||||
### Хранилище
|
||||
|
||||
- PostgreSQL не подключён; первая версия использует SQLite.
|
||||
- Не утверждена окончательная схема PostgreSQL.
|
||||
- Не определены сроки хранения сообщений, вложений и экспортов.
|
||||
|
||||
### Функции бота
|
||||
|
||||
- Скачивание файлов вложений; сейчас сохраняются их Telegram-метаданные.
|
||||
- Ручная пометка отдельных сообщений для экспорта.
|
||||
- Формирование готовой инструкции для ручной загрузки в ChatGPT.
|
||||
- Возврат отчёта пользователя в Telegram.
|
||||
|
||||
### Группа и рабочий процесс
|
||||
|
||||
- Не проверены сценарии закрытия темы `MVP` после фиксации состава первой версии.
|
||||
- Не настроены уведомления Gitea.
|
||||
- Не проведён полный пользовательский тест: сообщение → сохранение → экспорт → анализ в ChatGPT → возврат отчёта.
|
||||
|
||||
## Важные неизвестные
|
||||
|
||||
По мере реального использования нужно принять решения по следующим вопросам:
|
||||
|
||||
1. Адрес и параметры PostgreSQL для разработки и Synology.
|
||||
2. Правила хранения и удаления сообщений и вложений.
|
||||
3. Нужен ли webhook после постоянного развёртывания; первая версия работает через long polling.
|
||||
|
||||
## Следующий этап
|
||||
|
||||
### Фиксация MVP и проверка полного цикла
|
||||
|
||||
Рекомендуемый порядок:
|
||||
|
||||
1. Настроить резервное копирование `/volume1/docker/akyldash/data`.
|
||||
2. Зафиксировать состав MVP с командой и закрыть тему `MVP`.
|
||||
3. Провести одно реальное обсуждение с ответами, правками и вложением.
|
||||
4. Экспортировать обсуждение и вручную проанализировать Markdown в ChatGPT.
|
||||
5. Вернуть подтверждённый итог в тему `Решения` и зафиксировать его в Git/Gitea.
|
||||
6. Записать обнаруженные неудобства и только после этого выбирать следующую доработку.
|
||||
|
||||
## История изменений статуса
|
||||
|
||||
### 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.
|
||||
- В `Общее` опубликовано и закреплено приветствие; тема закрыта для сообщений.
|
||||
- Локальный файл доступов переведён из `credentials.txt` в `credentials.json`.
|
||||
|
||||
### 2026-08-02
|
||||
|
||||
- Экспорт ограничен Telegram ID владельца бота.
|
||||
- Технические ответы на корневое сообщение темы исключены из Markdown.
|
||||
- Экспорт перенесён в тему `Отчеты`, добавлены отсечки и навигационные хэштеги.
|
||||
- Проверена живая публикация отчёта и отсечки через Telegram Bot API.
|
||||
- Бот версии `0.2.0` развёрнут в Container Manager на Synology.
|
||||
- Версия приложения обновлена до `0.2.0`.
|
||||
|
||||
### 2026-08-01
|
||||
|
||||
- Восстановлена связь локального каталога с репозиторием Gitea.
|
||||
- Реализован бот-секретарь версии `0.1.0` с SQLite и Markdown-экспортом.
|
||||
- Проверены токен `help_clerk_bot` и доступ к супергруппе `Акылдаш`.
|
||||
|
||||
### 2026-07-31
|
||||
|
||||
- Создана супергруппа и добавлен бот.
|
||||
- Отключён Privacy Mode.
|
||||
- Определены ID тем: `2`, `4`, `6`, `8`.
|
||||
- Подтверждено отсутствие webhook.
|
||||
- Зафиксировано, что код бота и хранилище ещё не реализованы.
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан
|
||||
68
TEAM_AI_TELEGRAM_IMPLEMENTATION_PLAN.md → docs/operations/telegram-workspace-plan.md
Executable file → Normal file
68
TEAM_AI_TELEGRAM_IMPLEMENTATION_PLAN.md → docs/operations/telegram-workspace-plan.md
Executable file → Normal file
@@ -276,57 +276,59 @@ docs/
|
||||
|
||||
## 9. Этапы реализации
|
||||
|
||||
Обозначения: `[x]` — выполнено, `[ ]` — ещё не выполнено.
|
||||
|
||||
### Этап 0. Границы и правила
|
||||
|
||||
- утвердить назначение Telegram, Git, бота и ChatGPT;
|
||||
- выбрать темы, которые бот слушает;
|
||||
- определить, что можно собирать автоматически;
|
||||
- определить правила обработки персональных и конфиденциальных данных;
|
||||
- определить формат решений, задач и экспортов.
|
||||
- [x] утвердить назначение Telegram, Git, бота и ChatGPT;
|
||||
- [x] выбрать темы, которые бот слушает;
|
||||
- [x] определить, что можно собирать автоматически;
|
||||
- [x] определить правила обработки персональных и конфиденциальных данных;
|
||||
- [x] определить формат решений, задач и экспортов.
|
||||
|
||||
### Этап 1. Рабочая Telegram-группа
|
||||
|
||||
- создать супергруппу и темы;
|
||||
- подготовить закреплённые сообщения;
|
||||
- создать первоначальный FAQ;
|
||||
- согласовать правила обсуждений и фиксации решений.
|
||||
- [x] создать супергруппу и темы;
|
||||
- [x] подготовить и закрепить приветствие с навигацией;
|
||||
- [x] создать первоначальную библиотеку практик работы с ИИ;
|
||||
- [x] согласовать правила обсуждений и фиксации решений.
|
||||
|
||||
### Этап 2. Бот-архиватор
|
||||
|
||||
- реализовать приём сообщений;
|
||||
- сохранить метаданные и вложения;
|
||||
- добавить ручную отметку для экспорта;
|
||||
- сформировать Markdown-экспорт;
|
||||
- проверить работу с темами и ответами.
|
||||
- [x] реализовать приём сообщений;
|
||||
- [x] сохранять сообщения и метаданные вложений;
|
||||
- [ ] добавить ручную отметку отдельных сообщений для экспорта;
|
||||
- [x] сформировать Markdown-экспорт;
|
||||
- [x] проверить работу с темами и ответами.
|
||||
|
||||
### Этап 3. Уведомления
|
||||
|
||||
- подключить события Gitea;
|
||||
- настроить фильтрацию;
|
||||
- направлять уведомления в отдельную тему;
|
||||
- добавить напоминания о задачах и сроках.
|
||||
- [ ] подключить события Gitea;
|
||||
- [ ] настроить фильтрацию;
|
||||
- [ ] направлять уведомления в отдельную тему;
|
||||
- [ ] добавить напоминания о задачах и сроках.
|
||||
|
||||
### Этап 4. ИИ-подготовка без API
|
||||
|
||||
- добавить шаблоны запросов для анализа;
|
||||
- создавать готовые файлы для загрузки в ChatGPT;
|
||||
- добавить возврат отчёта в Telegram;
|
||||
- проверить несколько реальных обсуждений.
|
||||
- [ ] добавить шаблоны запросов для анализа;
|
||||
- [x] создавать готовые файлы для загрузки в ChatGPT;
|
||||
- [ ] добавить возврат отчёта в Telegram;
|
||||
- [ ] проверить несколько реальных обсуждений.
|
||||
|
||||
### Этап 5. Связка с Git/Gitea
|
||||
|
||||
- подготовить структуру документации;
|
||||
- настроить ссылки на задачи, PR и документы;
|
||||
- определить процесс переноса подтверждённых решений в Git;
|
||||
- добавить сценарии обновления документации через ИИ.
|
||||
- [x] подготовить структуру документации;
|
||||
- [ ] настроить ссылки на задачи, PR и документы;
|
||||
- [x] определить процесс переноса подтверждённых решений в Git;
|
||||
- [ ] добавить сценарии обновления документации через ИИ.
|
||||
|
||||
### Этап 6. Улучшение по фактическому использованию
|
||||
|
||||
- удалить невостребованные функции;
|
||||
- расширить FAQ реальными сценариями команды;
|
||||
- улучшить формат отчётов;
|
||||
- добавить новые уведомления только при наличии потребности;
|
||||
- оценить необходимость API и автоматического ИИ-анализа.
|
||||
- [ ] удалить невостребованные функции;
|
||||
- [ ] расширить библиотеку практик реальными сценариями команды;
|
||||
- [ ] улучшить формат отчётов;
|
||||
- [ ] добавить новые уведомления только при наличии потребности;
|
||||
- [ ] оценить необходимость API и автоматического ИИ-анализа.
|
||||
|
||||
---
|
||||
|
||||
@@ -393,3 +395,7 @@ Git сохраняет актуальную версию
|
||||
```
|
||||
|
||||
Цель инфраструктуры — не заставить специалистов изучать больше технологий, а сделать технологии удобным продолжением их профессиональной работы.
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан
|
||||
261
docs/product/frontend-search-sps-plan.md
Normal file
261
docs/product/frontend-search-sps-plan.md
Normal 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. Подготовить тестовый набор из 50–100 реальных запросов на русском и
|
||||
кыргызском языках и вручную отметить ожидаемые результаты.
|
||||
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-сборка и развёртывание;
|
||||
- пользовательское тестирование на 10–15 реальных юридических задачах.
|
||||
|
||||
Результат: публичный MVP.
|
||||
|
||||
Оценка frontend-части после готовности API: **9 недель**.
|
||||
|
||||
## Спринты после MVP
|
||||
|
||||
### Спринт 5 — персональный кабинет
|
||||
|
||||
Авторизация, закладки, заметки, подборки и сохранённые фильтры.
|
||||
|
||||
### Спринт 6 — контроль изменений
|
||||
|
||||
Документы на контроле, подписки на редакции и уведомления.
|
||||
|
||||
### Спринт 7 — юридические связи
|
||||
|
||||
Сравнение редакций, прямые и обратные ссылки, утратившие силу фрагменты.
|
||||
|
||||
### Спринт 8 — практические материалы
|
||||
|
||||
Формы, образцы, инструкции, чек-листы, календари и справочные данные.
|
||||
|
||||
### Спринт 9 — расширенный анализ
|
||||
|
||||
Судебная практика, экспертные комментарии, дерево связей и RAG с обязательными
|
||||
ссылками на источники.
|
||||
|
||||
### Спринт 10 — корпоративные функции
|
||||
|
||||
Роли, журналирование, API, интеграция с СЭД, расширенный экспорт и
|
||||
персонализация.
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан
|
||||
217
docs/product/minjust-document-normalization-agent-task.md
Normal file
217
docs/product/minjust-document-normalization-agent-task.md
Normal 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 — не создан
|
||||
47
docs/product/project-overview.md
Normal file
47
docs/product/project-overview.md
Normal 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 — не создан
|
||||
183
docs/team/ai-skills-for-beginners.md
Normal file
183
docs/team/ai-skills-for-beginners.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# Важные навыки работы с ИИ для начинающих
|
||||
|
||||
Каждый раздел ниже — отдельное готовое сообщение для темы `Работа с ИИ`.
|
||||
Текст можно публиковать без заголовков этого файла: название, хэштеги и пояснение уже входят в сообщение.
|
||||
|
||||
## 1. Ставьте задачу через результат
|
||||
|
||||
#работа_с_ии #промпт #цель
|
||||
|
||||
Пишите не тему, а желаемый результат. Вместо «расскажи о договоре» — «составь краткий список рисков договора для руководителя». Чем яснее результат, тем меньше случайных ответов.
|
||||
|
||||
## 2. Давайте нужный контекст
|
||||
|
||||
#работа_с_ии #контекст #источники
|
||||
|
||||
Укажите, для кого готовится результат, что уже известно и какие файлы или сообщения использовать. Не заставляйте ИИ угадывать факты, которые можно дать сразу.
|
||||
|
||||
## 3. Называйте ограничения
|
||||
|
||||
#работа_с_ии #ограничения #точность
|
||||
|
||||
Сразу укажите важные рамки: объём, срок, язык, допустимые источники и что нельзя менять. Пример: «до одной страницы, только по приложенным документам, без вымышленных данных».
|
||||
|
||||
## 4. Задавайте формат ответа
|
||||
|
||||
#работа_с_ии #формат #результат
|
||||
|
||||
Скажите, в каком виде нужен ответ: список, таблица, письмо, план или Markdown. Укажите желаемую длину и аудиторию. Это полезнее просьбы «ответь красиво».
|
||||
|
||||
## 5. Определяйте, что значит «готово»
|
||||
|
||||
#работа_с_ии #критерии #готовность
|
||||
|
||||
Добавьте проверяемый критерий завершения: «все риски связаны с пунктами договора», «у каждой задачи есть ответственный и срок», «тесты проходят». ИИ должен понимать финиш задачи.
|
||||
|
||||
## 6. Показывайте пример хорошего результата
|
||||
|
||||
#работа_с_ии #пример #качество
|
||||
|
||||
Если важны стиль или структура, приложите короткий образец. Один хороший пример обычно работает лучше длинного описания вроде «сделай профессионально».
|
||||
|
||||
## 7. Просите уточнить неясное
|
||||
|
||||
#работа_с_ии #уточнение #вопросы
|
||||
|
||||
Если задача допускает разные трактовки, напишите: «Сначала задай до трёх важных вопросов, затем приступай». Это снижает риск получить уверенный ответ не на тот вопрос.
|
||||
|
||||
## 8. Отделяйте факты от предположений
|
||||
|
||||
#работа_с_ии #факты #проверка
|
||||
|
||||
Просите отдельно отмечать подтверждённые факты, выводы и неизвестные данные. Для актуальной информации требуйте ссылки и дату проверки. Уверенный тон не является доказательством.
|
||||
|
||||
## 9. Для сложной задачи сначала просите план
|
||||
|
||||
#работа_с_ии #план #сложная_задача
|
||||
|
||||
Сначала попросите изучить материалы и предложить короткий план. После вашего подтверждения ИИ выполняет работу. Так ошибки направления обнаруживаются до больших изменений.
|
||||
|
||||
## 10. Делите большую работу на этапы
|
||||
|
||||
#работа_с_ии #этапы #контроль
|
||||
|
||||
Разбивайте задачу на небольшие проверяемые результаты: анализ → проект → проверка → финал. Подтверждайте важные этапы отдельно, особенно перед публикацией или изменением данных.
|
||||
|
||||
## 11. Используйте персональные инструкции
|
||||
|
||||
#работа_с_ии #персональные_инструкции #настройка
|
||||
|
||||
Персональные инструкции — ваши постоянные предпочтения для всех чатов: язык, стиль, желаемая краткость и способ объяснения. Не храните там пароли и правила одного конкретного проекта.
|
||||
|
||||
## 12. Настраивайте инструкции проекта
|
||||
|
||||
#работа_с_ии #инструкции_проекта #проект
|
||||
|
||||
Для каждого проекта отдельно зафиксируйте цель, термины, источники истины, правила работы и критерии готовности. В ChatGPT используйте инструкции проекта, в Codex — репозиторный `AGENTS.md`.
|
||||
|
||||
## 13. Делайте инструкции короткими и проверяемыми
|
||||
|
||||
#работа_с_ии #инструкции #порядок
|
||||
|
||||
Пишите конкретно: «перед изменением создай ветку», а не «работай правильно». Удаляйте устаревшие и противоречивые правила. Добавляйте новое правило после повторяющейся ошибки, а не заранее.
|
||||
|
||||
## 14. Один чат — один понятный результат
|
||||
|
||||
#работа_с_ии #чат #контекст
|
||||
|
||||
В одном чате держите одну связанную цель. Для отдельной задачи начинайте новый чат; для настоящего ответвления создавайте копию или fork. Перегруженный контекст ухудшает качество.
|
||||
|
||||
## 15. Храните решения вне переписки
|
||||
|
||||
#работа_с_ии #решения #источник_истины
|
||||
|
||||
Чат помогает думать, но не должен быть единственным хранилищем решения. Подтверждённый результат переносите в документ, задачу или Git, где видны актуальная версия и история изменений.
|
||||
|
||||
## 16. Понимайте назначение агентов
|
||||
|
||||
#работа_с_ии #агенты #делегирование
|
||||
|
||||
Агенты — отдельные исполнители для ограниченных частей большой задачи. Они полезны, когда несколько независимых исследований или проверок можно выполнить параллельно.
|
||||
|
||||
## 17. Давайте агенту одну ограниченную задачу
|
||||
|
||||
#работа_с_ии #агенты #постановка_задачи
|
||||
|
||||
Каждому агенту задайте одну цель, входные материалы, границы и формат результата. Хорошо: «проверь только риски безопасности и верни пять находок со ссылками».
|
||||
|
||||
## 18. Не используйте агентов без необходимости
|
||||
|
||||
#работа_с_ии #агенты #простота
|
||||
|
||||
Для маленькой или строго последовательной задачи один исполнитель лучше. Несколько агентов тратят больше ресурсов, могут дублировать работу и конфликтовать при одновременном изменении одних файлов.
|
||||
|
||||
## 19. Оставляйте итог главному исполнителю
|
||||
|
||||
#работа_с_ии #агенты #итог
|
||||
|
||||
Агенты возвращают краткие выводы, а главный исполнитель сравнивает их, устраняет противоречия и готовит единый ответ. Не склеивайте сырые результаты без общей проверки.
|
||||
|
||||
## 20. Заранее задавайте правило остановки
|
||||
|
||||
#работа_с_ии #зацикливание #стоп
|
||||
|
||||
Для сложной задачи напишите: «После двух неудачных попыток остановись, перечисли проверенное, назови блокер и предложи другой подход». Это не даёт ИИ бесконечно повторять одно решение.
|
||||
|
||||
## 21. После неудачи меняйте метод, а не формулировку
|
||||
|
||||
#работа_с_ии #ошибка #диагностика
|
||||
|
||||
Если подход не сработал, попросите назвать причину и собрать новые доказательства. Повтор той же команды другими словами редко помогает. Нужна новая гипотеза или дополнительный источник данных.
|
||||
|
||||
## 22. Очищайте перегруженный контекст
|
||||
|
||||
#работа_с_ии #контекст #перезапуск
|
||||
|
||||
Если ИИ путает старые решения, попросите кратко зафиксировать цель, факты, принятые решения и открытый вопрос. Затем продолжите из этой сводки в новом чате.
|
||||
|
||||
## 23. Всегда проверяйте результат
|
||||
|
||||
#работа_с_ии #проверка #качество
|
||||
|
||||
Просите ИИ выполнить самопроверку, но важные утверждения проверяйте сами по первичным источникам. Для кода нужны тесты, для расчётов — пересчёт, для документов — сверка цитат и реквизитов.
|
||||
|
||||
## 24. Не передавайте лишние данные
|
||||
|
||||
#работа_с_ии #приватность #персональные_данные
|
||||
|
||||
Перед загрузкой удалите лишние ФИО, контакты, пароли, ключи и конфиденциальные сведения. Передавайте минимальный объём данных, необходимый для задачи, и учитывайте правила вашей организации.
|
||||
|
||||
## 25. Отделяйте анализ от действия
|
||||
|
||||
#работа_с_ии #безопасность #подтверждение
|
||||
|
||||
Просьба «проанализируй» не должна означать «отправь, удали или опубликуй». Для внешних и необратимых действий требуйте отдельный план, предварительный просмотр и явное подтверждение человека.
|
||||
|
||||
## 26. Подключайте только нужные инструменты
|
||||
|
||||
#работа_с_ии #инструменты #доступ
|
||||
|
||||
Давайте ИИ доступ только к тем файлам, сервисам и правам, которые нужны сейчас. Начните с чтения; разрешение на запись, отправку и удаление выдавайте отдельно.
|
||||
|
||||
## 27. Превращайте повторяемую работу в шаблон
|
||||
|
||||
#работа_с_ии #шаблон #автоматизация
|
||||
|
||||
Если удачный запрос используется регулярно, сохраните его как шаблон. Когда процесс стал стабильным и проверенным, оформите его как навык или автоматизацию. Не автоматизируйте ещё не понятный процесс.
|
||||
|
||||
## 28. Завершайте работу короткой приёмкой
|
||||
|
||||
#работа_с_ии #приемка #финальная_проверка
|
||||
|
||||
В конце спросите: «Что сделано, что проверено, что осталось и какие есть риски?» Сравните ответ с первоначальной целью и критериями готовности, прежде чем принимать результат.
|
||||
|
||||
## Справочные материалы
|
||||
|
||||
- [Prompting](https://learn.chatgpt.com/docs/prompting)
|
||||
- [Personalize ChatGPT](https://learn.chatgpt.com/docs/personalize)
|
||||
- [Subagents](https://learn.chatgpt.com/docs/agent-configuration/subagents)
|
||||
- [Custom instructions with AGENTS.md](https://learn.chatgpt.com/docs/agent-configuration/agents-md)
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан
|
||||
BIN
docs/Функциональные_возможности_поисковой_СПС.docx
Normal file
BIN
docs/Функциональные_возможности_поисковой_СПС.docx
Normal file
Binary file not shown.
51
tools/telegram-bot/README.md
Normal file
51
tools/telegram-bot/README.md
Normal 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 — не создан
|
||||
@@ -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 [1–3650|все]")
|
||||
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:
|
||||
@@ -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()
|
||||
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