diff --git a/.gitignore b/.gitignore index 123debb..f6430cd 100755 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ __pycache__/ # Local application archives data/ + +# Local runtime logs +logs/ diff --git a/README.md b/README.md index 50c1790..b12b3fa 100644 --- a/README.md +++ b/README.md @@ -9,15 +9,15 @@ Telegram-бот — только часть рабочего окружения ## Текущее состояние -Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и первая backend-функция -версии `0.1.2`: возобновляемая выгрузка документов из ЦБД Минюста КР. +Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии +`0.2.2`: возобновляемая выгрузка и нормализация документов ЦБД Минюста КР. | Компонент | Версия | Состояние | |---|---:|---| | Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния | -| Backend | `0.1.2` | реализована выгрузка документов ЦБД Минюста КР | +| Backend | `0.2.2` | реализованы выгрузка и нормализация документов ЦБД Минюста КР | | Frontend | — | ещё не создан | -| Сбор и обработка правовых данных | `0.1.2` | реализован архиватор ЦБД Минюста КР | +| Сбор и обработка правовых данных | `0.2.2` | реализованы архиватор и нормализатор ЦБД Минюста КР | | RAG и база знаний | — | ещё не созданы | ## Структура репозитория @@ -32,6 +32,7 @@ tools/ telegram-bot/ бот рабочего Telegram-пространства backend/ ingestion/ получение и обновление правовых источников + normalization/ воспроизводимая нормализация исходного архива ``` Каталоги для загрузки и обработки источников, RAG, backend и frontend будут @@ -57,4 +58,4 @@ python3 -m unittest discover -s tools/telegram-bot -v --- -Акылдаш · Telegram-бот v0.2.2 · Backend v0.1.2 · Frontend — не создан +Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан diff --git a/backend/README.md b/backend/README.md index 163afbe..1d36a0f 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,6 +1,6 @@ # Backend Акылдаш -Версия: `0.1.2` +Версия: `0.2.2` Первая backend-область проекта — загрузка правовых документов из официального Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в @@ -21,8 +21,9 @@ python3 backend/ingestion/minjust_cbd.py --limit 10 Полная загрузка выполняется без `--limit`. По умолчанию архив сохраняется в `data/minjust-cbd`, который исключён из Git. Повторный запуск пропускает уже загруженные документы; `--refresh` принудительно проверяет их заново. -Если временный идентификатор списка API истечёт во время многодневной загрузки, -скрипт пересоздаст список на текущей странице и продолжит автоматически. +Если временный идентификатор списка API истечёт или запрос страницы исчерпает +повторы во время многодневной загрузки, скрипт пересоздаст список на текущей +странице и продолжит автоматически. В версии `0.1.0` обновление существующих документов выполняется полной проверкой через `--refresh`. Инкрементальную проверку по `lastmod` из sitemap следует @@ -53,12 +54,50 @@ 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//document.json + documents//editions//edition.json + documents//editions///content.html + documents//editions///content.txt + documents//editions///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.1.2 · Frontend — не создан +Акылдаш · Backend v0.2.2 · Frontend — не создан diff --git a/backend/ingestion/minjust_cbd.py b/backend/ingestion/minjust_cbd.py index 82899af..e40db76 100644 --- a/backend/ingestion/minjust_cbd.py +++ b/backend/ingestion/minjust_cbd.py @@ -21,7 +21,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Callable, Iterable -APP_VERSION = "0.1.2" +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"} @@ -121,8 +121,8 @@ class CbdClient: ("PageNumber", page_number), ), ) - except urllib.error.HTTPError as error: - if error.code != 404: + 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( diff --git a/backend/normalization/minjust_cbd.py b/backend/normalization/minjust_cbd.py new file mode 100644 index 0000000..9631b21 --- /dev/null +++ b/backend/normalization/minjust_cbd.py @@ -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"") + + def _close(self, tag: str) -> None: + while self.stack: + current = self.stack.pop() + self.parts.append(f"") + 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()) diff --git a/backend/test_minjust_cbd.py b/backend/test_minjust_cbd.py index e300858..96f9ada 100644 --- a/backend/test_minjust_cbd.py +++ b/backend/test_minjust_cbd.py @@ -99,6 +99,29 @@ class MinjustCbdTest(unittest.TestCase): 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), diff --git a/backend/test_minjust_normalization.py b/backend/test_minjust_normalization.py new file mode 100644 index 0000000..bfc54a6 --- /dev/null +++ b/backend/test_minjust_normalization.py @@ -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": '

Статья 1 Закон

ссылкасломанная ссылка', + }, + ) + self.edition(document, 20, {"ky": "

1. Кыргызча жобо

"}) + self.edition(document, 30, {"ru": "

Русский

", "ky": "

Кыргызча

"}) + 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": "

Берене 1 Текст

"}) + + 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() diff --git a/docs/README.md b/docs/README.md index 424e6a7..13b2d5c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,6 +4,10 @@ - [Обзор проекта](product/project-overview.md) — назначение, основные области и правила работы с данными. +- [План frontend поисковой СПС](product/frontend-search-sps-plan.md) — границы + MVP, зависимости и спринты. +- [Задание по нормализации документов](product/minjust-document-normalization-agent-task.md) — + требования и критерии приёмки нормализатора ЦБД Минюста КР. ## Решения @@ -19,8 +23,8 @@ ## Backend -- [Выгрузка ЦБД Минюста КР](../backend/README.md) — запуск, хранение и проверка - загрузчика правовых документов. +- [Выгрузка и нормализация ЦБД Минюста КР](../backend/README.md) — запуск, + хранение и проверка конвейера правовых документов. ## Команда @@ -29,4 +33,4 @@ --- -Акылдаш · Telegram-бот v0.2.2 · Backend v0.1.2 · Frontend — не создан +Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан diff --git a/docs/decisions/001-telegram-workspace-mvp.md b/docs/decisions/001-telegram-workspace-mvp.md index ac5e127..616363a 100644 --- a/docs/decisions/001-telegram-workspace-mvp.md +++ b/docs/decisions/001-telegram-workspace-mvp.md @@ -78,4 +78,4 @@ Telegram позволяет запретить пользователям отп --- -Акылдаш · Telegram-бот v0.2.2 · Backend v0.1.2 · Frontend — не создан +Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан diff --git a/docs/operations/project-status.md b/docs/operations/project-status.md index 35aaddf..a2cf417 100644 --- a/docs/operations/project-status.md +++ b/docs/operations/project-status.md @@ -1,18 +1,19 @@ # Статус проекта -Последняя проверка: 2026-08-06 +Последняя проверка: 2026-08-10 Назначение документа: быстро восстановить контекст проекта для участников команды и будущих агентов. - Telegram-бот: `0.2.2` - Telegram-бот на Synology: `0.2.1` -- Backend: `0.1.2` +- Backend: `0.2.2` - Frontend: не создан ## Краткий итог -Репозиторий переориентирован с отдельного бота на весь проект юридической информационно-аналитической платформы. Telegram-бот выделен в инструмент рабочего окружения. Создана первая backend-функция: возобновляемая выгрузка документов из официального Open Data API ЦБД Минюста КР. +Репозиторий переориентирован с отдельного бота на весь проект юридической информационно-аналитической платформы. Telegram-бот выделен в инструмент рабочего окружения. Реализованы возобновляемая выгрузка документов из официального Open Data API ЦБД Минюста КР и их локальная воспроизводимая нормализация. -Ближайшая цель — расширить пилотную выборку ЦБД, затем добавить инкрементальную проверку sitemap при создании backend-планировщика. +Ближайшая цель — выполнить полный проход нормализатора, проверить отчёт ошибок +и качество контрольной выборки RU/KY, затем подготовить индекс OpenSearch. ## Уже сделано @@ -67,9 +68,12 @@ - Локальный Git-репозиторий восстановлен и привязан к Gitea. - Репозиторий организован как основа всего проекта, а не отдельного бота. - Бот развёрнут в Container Manager на Synology; автозапуск после перезапуска менеджера проверен. -- Реализован backend-загрузчик ЦБД Минюста КР версии `0.1.2` без внешних зависимостей. +- Реализован backend-загрузчик ЦБД Минюста КР версии `0.2.2` без внешних зависимостей. - Загрузчик сохраняет метаданные, редакции RU/KY и изображения, а прогресс — в SQLite. - Пилотная выгрузка двух документов и возобновление без повторного скачивания проверены на живом API. +- Реализован backend-нормализатор версии `0.2.2` без внешних зависимостей. +- Нормализатор создаёт канонические метаданные, безопасный HTML, чистый текст и адресуемые фрагменты RU/KY. +- SQLite-манифест обеспечивает возобновление, повтор ошибок и пропуск неизменившихся документов. ### Развёртывание @@ -128,6 +132,21 @@ ## История изменений статуса +### 2026-08-12 + +- Нормализатор запрещает пересекающиеся каталоги источника и результата, + восстанавливает прерванную публикацию и отбрасывает некорректные URL. +- Версия backend обновлена до `0.2.2`. +- Загрузчик пересоздаёт временный список документов на текущей странице не + только после HTTP 404, но и после исчерпания повторов запроса списка. +- Версия backend обновлена до `0.2.1`. + +### 2026-08-10 + +- Добавлена первая версия воспроизводимой нормализации локального архива ЦБД. +- Добавлены атомарная публикация результатов, контрольные суммы, карантин ошибок и терминальный прогресс. +- Версия backend обновлена до `0.2.0`. + ### 2026-08-06 - Добавлен терминальный прогрессбар со скоростью и расчётным временем завершения. @@ -178,4 +197,4 @@ --- -Акылдаш · Telegram-бот v0.2.2 · Backend v0.1.2 · Frontend — не создан +Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан diff --git a/docs/operations/telegram-workspace-plan.md b/docs/operations/telegram-workspace-plan.md index 17a2287..7dabeaa 100644 --- a/docs/operations/telegram-workspace-plan.md +++ b/docs/operations/telegram-workspace-plan.md @@ -398,4 +398,4 @@ Git сохраняет актуальную версию --- -Акылдаш · Telegram-бот v0.2.2 · Backend v0.1.2 · Frontend — не создан +Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан diff --git a/docs/product/frontend-search-sps-plan.md b/docs/product/frontend-search-sps-plan.md new file mode 100644 index 0000000..552e0a9 --- /dev/null +++ b/docs/product/frontend-search-sps-plan.md @@ -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 — не создан diff --git a/docs/product/minjust-document-normalization-agent-task.md b/docs/product/minjust-document-normalization-agent-task.md new file mode 100644 index 0000000..ff85ecc --- /dev/null +++ b/docs/product/minjust-document-normalization-agent-task.md @@ -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, может содержать некорректный + ``, служебные стили и неполную разметку; +- файлы архива записаны загрузчиком в 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.json + documents//editions//edition.json + documents//editions///content.html + documents//editions///content.txt + documents//editions///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 — не создан diff --git a/docs/product/project-overview.md b/docs/product/project-overview.md index 40c4726..8c8b926 100644 --- a/docs/product/project-overview.md +++ b/docs/product/project-overview.md @@ -44,4 +44,4 @@ --- -Акылдаш · Telegram-бот v0.2.2 · Backend v0.1.2 · Frontend — не создан +Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан diff --git a/docs/team/ai-skills-for-beginners.md b/docs/team/ai-skills-for-beginners.md index 732708f..0ad0508 100644 --- a/docs/team/ai-skills-for-beginners.md +++ b/docs/team/ai-skills-for-beginners.md @@ -180,4 +180,4 @@ --- -Акылдаш · Telegram-бот v0.2.2 · Backend v0.1.2 · Frontend — не создан +Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан diff --git a/docs/Функциональные_возможности_поисковой_СПС.docx b/docs/Функциональные_возможности_поисковой_СПС.docx new file mode 100644 index 0000000..d11d239 Binary files /dev/null and b/docs/Функциональные_возможности_поисковой_СПС.docx differ diff --git a/tools/telegram-bot/README.md b/tools/telegram-bot/README.md index 042caff..066a23a 100644 --- a/tools/telegram-bot/README.md +++ b/tools/telegram-bot/README.md @@ -48,4 +48,4 @@ python3 -m unittest -v --- -Акылдаш · Telegram-бот v0.2.2 · Backend v0.1.2 · Frontend — не создан +Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан