Compare commits

...

13 Commits

22 changed files with 704 additions and 29 deletions

View File

@@ -2,6 +2,11 @@
## Не выпущено
- Завершён Search API v1: стабильные справочники, валидный OpenAPI, безопасная
пагинация и проверка актуальных редакций в локальном OpenSearch.
- Добавлено безопасное переключение alias на новую версию поискового индекса
после полной загрузки; checkpoint защищает возобновление загрузки от смены
alias.
- Добавлен roadmap готовности данных, поиска и API перед проектированием
frontend.
- Исправлена выдача для явных запросов об открытии ОсОО и ЖЧК: первыми

View File

@@ -10,15 +10,15 @@ Telegram-бот — только часть рабочего окружения
## Текущее состояние
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии
`0.5.2`: подготовка поискового индекса и оценка Recall@K/MRR@K на вручную
`0.7.1`: исправления контракта Search API v1 и его ограничений OpenSearch.
размеченном наборе запросов.
| Компонент | Версия | Состояние |
|---|---:|---|
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
| Backend | `0.5.2` | добавлено ранжирование подтверждённых запросов об открытии ОсОО/ЖЧК |
| Backend | `0.7.1` | исправлен контракт Search API v1 |
| Frontend | — | ещё не создан |
| Сбор и обработка правовых данных | `0.5.2` | добавлены relevance set и baseline-метрики |
| Сбор и обработка правовых данных | `0.7.1` | добавлены relevance set и baseline-метрики |
| RAG и база знаний | — | ещё не созданы |
## Структура репозитория
@@ -59,4 +59,4 @@ python3 -m unittest discover -s tools/telegram-bot -v
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан

View File

@@ -1,6 +1,6 @@
# Backend Акылдаш
Версия: `0.5.2`
Версия: `0.7.1`
Первая backend-область проекта — загрузка правовых документов из официального
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
@@ -128,6 +128,16 @@ python3 backend/search/minjust_opensearch.py \
После проверки production-индекса следует переключать alias, чтобы удалённые
фрагменты не оставались в поиске.
Чтобы переключить alias атомарно только после успешной полной загрузки,
передайте `--alias`:
```bash
python3 backend/search/minjust_opensearch.py \
--url http://127.0.0.1:9200 \
--index akyldash-fragments-v2 \
--alias akyldash-fragments-current
```
После каждого принятого Bulk-пакета загрузчик атомарно сохраняет checkpoint и
печатает код безопасного возобновления. При временных HTTP 429/5xx, timeout и
обрыве соединения запрос повторяется автоматически. Прерванную загрузку можно
@@ -142,12 +152,29 @@ python3 backend/search/minjust_opensearch.py \
По умолчанию checkpoint хранится в
`data/opensearch/<index>.checkpoint.json`; путь можно изменить через
`--checkpoint`. Checkpoint привязан к URL, cluster UUID, index UUID, `--limit`
и SHA-256 нормализованного manifest. Resume отклоняется при любом несовпадении:
`--checkpoint`. Checkpoint привязан к URL, cluster UUID, index UUID, `--limit`,
`--alias` и SHA-256 нормализованного manifest. При `--resume` передавайте то же
значение `--alias`; старый checkpoint без alias можно продолжить только без
него. Resume отклоняется при любом несовпадении:
для обновлённого корпуса или пересозданного индекса нужно создать новый
версионный индекс, проверить его и переключить alias. Это не оставляет
удалённые trailing-фрагменты старых документов.
## HTTP API v1
Запустите публичный read-only API поверх текущего alias и нормализованного
корпуса:
```bash
PYTHONPATH=backend python3 -m search.api
```
Он публикует OpenAPI в `GET /openapi.json` и поддерживает `GET /search`,
`/search/filters`, `/documents/{code}`, `/documents/{code}/editions` и
`/documents/{code}/editions/{edition}`. Значения фильтров возвращаются со
стабильным кодом справочника v1 и подписями RU/KY; применяйте `code` как
параметр поиска. API не подменяет отсутствующий язык документа.
## Локальный OpenSearch
Стенд использует один узел OpenSearch без Dashboards, устанавливает
@@ -198,4 +225,4 @@ PYTHONPATH=backend python3 -m search.query "ЖЧК ачуу тартиби" --la
---
Акылдаш · Backend v0.5.2 · Frontend — не создан
Акылдаш · Backend v0.7.1 · Frontend — не создан

View File

@@ -21,7 +21,7 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Callable, Iterable
APP_VERSION = "0.5.2"
APP_VERSION = "0.7.1"
API_BASE_URL = "https://cbd.minjust.gov.kg/api/v1/OpenData/"
LANGUAGES = {"Rus": "ru", "Kyr": "ky"}
IMAGE_LANGUAGES = {"Russian": "ru", "Kyrgyz": "ky"}

View File

@@ -23,7 +23,7 @@ from pathlib import Path
from typing import Callable
from urllib.parse import urlsplit
APP_VERSION = "0.5.2"
APP_VERSION = "0.7.1"
SCHEMA_VERSION = "1"
NORMALIZER_VERSION = "1.0.0"
LANGUAGES = ("ru", "ky")

285
backend/search/api.py Normal file
View File

@@ -0,0 +1,285 @@
"""Minimal HTTP API for the normalized legal-document corpus."""
from __future__ import annotations
import argparse
import datetime
import json
import re
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from search.minjust_opensearch import APP_VERSION, request_json
from search.catalog import CATALOGS, labels
API_VERSION = "v1"
LANGUAGES = {"ru", "ky"}
CODE = re.compile(r"^[0-9]+$")
MAX_PAGE_SIZE = 100
MAX_RESULT_WINDOW = 10_000
class ApiError(Exception):
def __init__(self, status: int, message: str):
self.status = status
self.message = message
def parse_positive(value: str | None, name: str, default: int, maximum: int) -> int:
if value is None:
return default
try:
parsed = int(value)
except ValueError as error:
raise ApiError(400, f"{name} must be an integer") from error
if not 1 <= parsed <= maximum:
raise ApiError(400, f"{name} must be between 1 and {maximum}")
return parsed
def one(query: dict[str, list[str]], name: str) -> str | None:
values = query.get(name, [])
if len(values) > 1:
raise ApiError(400, f"{name} must be specified once")
return values[0] if values else None
def date(value: str | None, name: str) -> str | None:
if value is None:
return None
try:
datetime.date.fromisoformat(value)
except ValueError as error:
raise ApiError(400, f"{name} must be an ISO date") from error
return value
def openapi() -> dict:
responses = {"200": {"description": "Successful response"}, "400": {"description": "Invalid request"}, "404": {"description": "Not found"}, "502": {"description": "Search backend unavailable"}}
return {
"openapi": "3.0.3",
"info": {"title": "Akyldash Search API", "version": API_VERSION},
"paths": {
"/search": {"get": {"responses": responses, "parameters": [
{"name": "q", "in": "query", "required": True, "schema": {"type": "string"}},
{"name": "language", "in": "query", "schema": {"type": "string", "enum": ["ru", "ky"]}},
{"name": "page", "in": "query", "schema": {"type": "integer", "minimum": 1}},
{"name": "page_size", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": MAX_PAGE_SIZE}},
{"name": "document_type", "in": "query", "schema": {"type": "string"}},
{"name": "status", "in": "query", "schema": {"type": "string"}},
{"name": "authority", "in": "query", "schema": {"type": "string"}},
{"name": "date_from", "in": "query", "schema": {"type": "string", "format": "date"}},
{"name": "date_to", "in": "query", "schema": {"type": "string", "format": "date"}},
{"name": "sort", "in": "query", "schema": {"type": "string", "enum": ["relevance", "date"]}},
]}},
"/search/filters": {"get": {"responses": responses}},
"/documents/{code}": {"get": {"responses": responses, "parameters": [{"name": "code", "in": "path", "required": True, "schema": {"type": "string", "pattern": "^[0-9]+$"}}]}},
"/documents/{code}/editions": {"get": {"responses": responses, "parameters": [{"name": "code", "in": "path", "required": True, "schema": {"type": "string", "pattern": "^[0-9]+$"}}]}},
"/documents/{code}/editions/{edition}": {"get": {"responses": responses, "parameters": [{"name": "code", "in": "path", "required": True, "schema": {"type": "string", "pattern": "^[0-9]+$"}}, {"name": "edition", "in": "path", "required": True, "schema": {"type": "string", "pattern": "^[0-9]+$"}}]}},
},
}
class Api:
def __init__(self, base_url: str, index: str, data_root: Path):
self.base_url = base_url.rstrip("/")
self.index = index
self.data_root = data_root
def search_url(self, suffix: str) -> str:
return f"{self.base_url}/{urllib.parse.quote(self.index, safe='')}/{suffix}"
def query_opensearch(self, body: dict) -> dict:
try:
return request_json(self.search_url("_search"), "POST", json.dumps(body, ensure_ascii=False).encode(), "application/json")
except RuntimeError as error:
raise ApiError(502, "search backend is unavailable") from error
def search(self, query: dict[str, list[str]]) -> dict:
text = one(query, "q")
if not text or not text.strip():
raise ApiError(400, "q is required")
if len(text) > 500:
raise ApiError(400, "q must not exceed 500 characters")
language = one(query, "language") or "ru"
if language not in LANGUAGES:
raise ApiError(400, "language must be ru or ky")
page = parse_positive(one(query, "page"), "page", 1, 1_000_000)
page_size = parse_positive(one(query, "page_size"), "page_size", 20, MAX_PAGE_SIZE)
if page * page_size >= MAX_RESULT_WINDOW:
raise ApiError(400, f"page and page_size must stay within {MAX_RESULT_WINDOW} results")
sort = one(query, "sort") or "relevance"
if sort not in {"relevance", "date"}:
raise ApiError(400, "sort must be relevance or date")
filters: list[dict] = [{"term": {"language": language}}, {"term": {"is_current_edition": True}}]
fields = {"document_type": "document_type_code", "status": "status_code", "authority": "authority_codes"}
for parameter, field in fields.items():
value = one(query, parameter)
if value:
if value not in CATALOGS[parameter]:
raise ApiError(400, f"{parameter} must be a catalog code")
filters.append({"term": {field: value}})
date_from, date_to = date(one(query, "date_from"), "date_from"), date(one(query, "date_to"), "date_to")
if date_from and date_to and date_from > date_to:
raise ApiError(400, "date_from must not be later than date_to")
if date_from or date_to:
date_range = {key: value for key, value in (("gte", date_from), ("lte", date_to)) if value}
filters.append({"range": {"date_adopted": date_range}})
body = {
"from": (page - 1) * page_size,
"size": page_size + 1,
"_source": ["document_code", "edition_code", "document_name_ru", "document_name_ky", "document_type_ru", "document_type_ky", "status_ru", "status_ky", "date_adopted", "number"],
"query": {"bool": {"filter": filters, "must": {"multi_match": {"query": text, "fields": [f"document_name_{language}", f"text_{language}"], "type": "cross_fields"}}}},
"collapse": {"field": "document_code"},
"highlight": {"fields": {f"text_{language}": {"number_of_fragments": 1}}},
}
if sort == "date":
body["sort"] = [{"date_adopted": "desc"}, {"_score": "desc"}]
response = self.query_opensearch(body)
try:
hits = response["hits"]["hits"]
except (KeyError, TypeError) as error:
raise ApiError(502, "search backend returned an incomplete response") from error
return {"api_version": API_VERSION, "query": text, "language": language, "page": page, "page_size": page_size, "has_next": len(hits) > page_size, "results": [self.search_hit(hit, language) for hit in hits[:page_size]]}
@staticmethod
def search_hit(hit: dict, language: str) -> dict:
source = hit.get("_source")
if not isinstance(source, dict) or not source.get("document_code"):
raise ApiError(502, "search backend returned an incomplete result")
highlight = hit.get("highlight", {}).get(f"text_{language}", [])
return {"code": source["document_code"], "edition": source.get("edition_code"), "name": source.get(f"document_name_{language}"), "type": source.get(f"document_type_{language}"), "status": source.get(f"status_{language}"), "date_adopted": source.get("date_adopted"), "number": source.get("number"), "snippet": highlight[0] if highlight else None}
def filters(self, query: dict[str, list[str]]) -> dict:
language = one(query, "language") or "ru"
if language not in LANGUAGES:
raise ApiError(400, "language must be ru or ky")
fields = {"document_types": ("document_type", "document_type_code"), "statuses": ("status", "status_code"), "authorities": ("authority", "authority_codes")}
body = {"size": 0, "query": {"term": {"is_current_edition": True}}, "aggs": {name: {"terms": {"field": pair[1], "size": 1000}, "aggs": {"documents": {"cardinality": {"field": "document_code", "precision_threshold": 40000}}}} for name, pair in fields.items()}}
response = self.query_opensearch(body)
try:
aggregations = response["aggregations"]
values = {
name: [{"code": item["key"], "labels": labels(pair[0], item["key"]), "count": item["documents"]["value"]} for item in aggregations[name]["buckets"]]
for name, pair in fields.items()
}
except (KeyError, TypeError) as error:
raise ApiError(502, "search backend returned incomplete filters") from error
return {"api_version": API_VERSION, "language": language, **values}
def directory(self, code: str) -> Path:
if not CODE.fullmatch(code):
raise ApiError(404, "document not found")
path = self.data_root / "documents" / code
if not path.is_dir():
raise ApiError(404, "document not found")
return path
@staticmethod
def read_json(path: Path, message: str) -> dict:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as error:
raise ApiError(500, message) from error
if not isinstance(value, dict):
raise ApiError(500, message)
return value
def document(self, code: str) -> dict:
document = self.read_json(self.directory(code) / "document.json", "document data is unavailable")
editions = document.get("editions")
if not isinstance(editions, list):
raise ApiError(500, "document data is unavailable")
return {"api_version": API_VERSION, "document": document, "current_edition": editions[-1] if editions else None}
def editions(self, code: str) -> dict:
document = self.read_json(self.directory(code) / "document.json", "document data is unavailable")
return {"api_version": API_VERSION, "code": code, "available_languages": document.get("available_languages", []), "editions": document.get("editions", [])}
def edition(self, code: str, edition: str, query: dict[str, list[str]]) -> dict:
if not CODE.fullmatch(edition):
raise ApiError(404, "edition not found")
directory = self.directory(code) / "editions" / edition
if not directory.is_dir():
raise ApiError(404, "edition not found")
metadata = self.read_json(directory / "edition.json", "edition data is unavailable")
language = one(query, "language")
if language is not None and language not in LANGUAGES:
raise ApiError(400, "language must be ru or ky")
languages = [language] if language else metadata.get("available_languages", [])
content = {}
for item in languages:
if item not in metadata.get("available_languages", []):
continue
try:
content[item] = {"html": (directory / item / "content.html").read_text(encoding="utf-8"), "text": (directory / item / "content.txt").read_text(encoding="utf-8")}
except (OSError, UnicodeError) as error:
raise ApiError(500, "edition content is unavailable") from error
if language and language not in content:
raise ApiError(404, "edition language not found")
return {"api_version": API_VERSION, "edition": metadata, "content": content}
def handle(self, method: str, path: str) -> tuple[int, dict]:
if method != "GET":
raise ApiError(405, "method not allowed")
parsed = urllib.parse.urlsplit(path)
query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
parts = [urllib.parse.unquote(part) for part in parsed.path.split("/") if part]
if parts == ["openapi.json"]:
return 200, openapi()
if parts == ["search"]:
return 200, self.search(query)
if parts == ["search", "filters"]:
return 200, self.filters(query)
if len(parts) == 2 and parts[0] == "documents":
return 200, self.document(parts[1])
if len(parts) == 3 and parts[:1] == ["documents"] and parts[2] == "editions":
return 200, self.editions(parts[1])
if len(parts) == 4 and parts[:1] == ["documents"] and parts[2] == "editions":
return 200, self.edition(parts[1], parts[3], query)
raise ApiError(404, "endpoint not found")
def handler(api: Api):
class RequestHandler(BaseHTTPRequestHandler):
def respond(self, method: str):
try:
status, payload = api.handle(method, self.path)
except ApiError as error:
status, payload = error.status, {"api_version": API_VERSION, "error": error.message}
body = json.dumps(payload, ensure_ascii=False).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
self.respond("GET")
def do_POST(self):
self.respond("POST")
def log_message(self, format: str, *args):
return
return RequestHandler
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--url", default="http://127.0.0.1:9200")
parser.add_argument("--index", default="akyldash-fragments-current")
parser.add_argument("--data", type=Path, default=Path("data/minjust-normalized"))
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8080)
parser.add_argument("--version", action="version", version=APP_VERSION)
arguments = parser.parse_args()
ThreadingHTTPServer((arguments.host, arguments.port), handler(Api(arguments.url, arguments.index, arguments.data))).serve_forever()
return 0
if __name__ == "__main__":
raise SystemExit(main())

51
backend/search/catalog.py Normal file
View File

@@ -0,0 +1,51 @@
"""Immutable v1 search catalogs."""
DOCUMENT_TYPES = {
"constitution": ("Конституция", "Конституция"), "constitutional_law": ("Конституционный Закон", "Конституциалык Мыйзам"), "code": ("Кодекс", "Кодекс"), "law": ("Закон", "Мыйзам"), "decree": ("Указ", "Жарлык"), "resolution": ("Постановление", "Токтом"), "order": ("Распоряжение", "Распоряжение"), "instruction": ("Инструкция", "Инструкция"), "rules": ("Правила", "Правила"), "procedure": ("Порядок", "Порядок"), "provision": ("Положение", "Жобо"), "regulation": ("Регламент", "Регламент"), "charter": ("Устав", "Жобо (Устав)"), "program": ("Программа", "Программа"), "plan": ("План", "План"), "strategy": ("Стратегия", "Стратегия"), "concept": ("Концепция", "Концепция"), "doctrine": ("Доктрина", "Доктрина"), "agreement": ("Соглашение", "Соглашение"), "declaration": ("Декларация", "Декларация"), "registry": ("Реестр", "Реестр"), "norms": ("Нормативы", "Нормативы"), "model": ("Модель", "Модель"), "matrix": ("Матрица", "Матрица"), "study": ("Исследование", "Исследование"), "report": ("Доклад", "Доклад"), "principles": ("Основные принципы", "Основные принципы"), "unspecified": ("Не указан", "Көрсөтүлгөн эмес"),
}
STATUSES = {"active": ("Действует", "Күчүндө"), "repealed": ("Утратил силу", "Күчүн жоготту"), "unspecified": ("Не указан", "Көрсөтүлгөн эмес")}
AUTHORITIES = {"president": ("Президент", "Президент"), "parliament": ("Органы законодательной власти", "Мыйзам чыгаруу бийлик органдары"), "cabinet": ("Правительство и Кабинет Министров", "Өкмөт жана Министрлер Кабинети"), "ministries_and_committees": ("Министерства и государственные комитеты", "Министрликтер жана мамлекеттик комитеттер"), "administrative_agencies": ("Административные ведомства", "Административдик ведомстволор"), "national_bank": ("Национальный банк", "Улуттук банк"), "other_state_bodies": ("Иные государственные органы", "Башка мамлекеттик органдар"), "local_representative_bodies": ("Представительные органы местного самоуправления", "Жергиликтүү өз алдынча башкаруунун өкүлчүлүктүү органдары"), "other": ("Прочие органы", "Башка органдар")}
CATALOGS = {"document_type": DOCUMENT_TYPES, "status": STATUSES, "authority": AUTHORITIES}
def labels(category: str, code: str) -> dict[str, str]:
try:
ru, ky = CATALOGS[category][code]
except KeyError as error:
raise ValueError(f"Unknown {category} catalog code: {code}") from error
return {"ru": ru, "ky": ky}
def source_code(category: str, value: dict | None) -> str:
pair = ((value or {}).get("ru"), (value or {}).get("ky"))
if pair == (None, None):
return "unspecified"
for code, expected in CATALOGS[category].items():
if pair == expected or category == "document_type" and code == "provision" and pair == ("Положение", "Положение"):
return code
raise ValueError(f"Unmapped {category} catalog value: {pair!r}")
def authority_codes(paths: list[dict]) -> list[str]:
codes = set()
for path in paths:
text = " ".join(path.get("ru", []) + path.get("ky", [])).lower()
if "президент" in text:
codes.add("president")
elif "жогорку кенеш" in text or "верховный совет" in text or "мыйзам чыгаруу" in text:
codes.add("parliament")
elif "кабинет министров" in text or "правительство" in text or "өкмөт" in text:
codes.add("cabinet")
elif "министер" in text or "мамлекеттик комитет" in text:
codes.add("ministries_and_committees")
elif "административ" in text:
codes.add("administrative_agencies")
elif "национальн" in text and "банк" in text or "улуттук банк" in text:
codes.add("national_bank")
elif "кенеш" in text or "кеңеш" in text or "айыл" in text or "местного самоуправления" in text:
codes.add("local_representative_bodies")
elif "иные государственные" in text or "башка мамлекеттик" in text:
codes.add("other_state_bodies")
else:
codes.add("other")
return sorted(codes) or ["other"]

View File

@@ -12,6 +12,7 @@
"schema_version": { "type": "keyword" },
"document_code": { "type": "keyword" },
"edition_code": { "type": "keyword" },
"is_current_edition": { "type": "boolean" },
"language": { "type": "keyword" },
"position": { "type": "integer" },
"fragment_type": { "type": "keyword" },
@@ -21,12 +22,15 @@
"document_name_ky": { "type": "text", "analyzer": "icu_analyzer", "fields": { "keyword": { "type": "keyword", "ignore_above": 1024 } } },
"document_type_ru": { "type": "keyword" },
"document_type_ky": { "type": "keyword" },
"document_type_code": { "type": "keyword" },
"status_ru": { "type": "keyword" },
"status_ky": { "type": "keyword" },
"status_code": { "type": "keyword" },
"number": { "type": "keyword" },
"date_adopted": { "type": "date", "format": "strict_date" },
"authority_paths_ru": { "type": "keyword", "ignore_above": 2048 },
"authority_paths_ky": { "type": "keyword", "ignore_above": 2048 },
"authority_codes": { "type": "keyword" },
"source_path": { "type": "keyword", "index": false },
"source_sha256": { "type": "keyword", "index": false },
"text_sha256": { "type": "keyword", "index": false }

View File

@@ -15,7 +15,9 @@ import urllib.request
from pathlib import Path
from typing import Iterator
APP_VERSION = "0.5.2"
from search.catalog import authority_codes, source_code
APP_VERSION = "0.7.1"
LANGUAGES = {"ru", "ky"}
DEFAULT_MAPPING = Path(__file__).with_name("minjust-fragments-index.json")
@@ -42,7 +44,7 @@ def paths(document: dict, field: str, language: str) -> list[str]:
return [" > ".join(item[language]) for item in document.get(field, []) if item.get(language)]
def search_document(document: dict, fragment: dict, expected: tuple[str, str, str, int]) -> dict:
def search_document(document: dict, fragment: dict, expected: tuple[str, str, str, int], current_edition: str) -> dict:
document_code, edition_code, language, position = expected
fragment_id = f"document:{document_code}:edition:{edition_code}:lang:{language}:fragment:{position}"
required = ("id", "document_code", "edition_code", "language", "position", "type", "text", "text_sha256", "source_path", "source_sha256")
@@ -64,6 +66,7 @@ def search_document(document: dict, fragment: dict, expected: tuple[str, str, st
"schema_version": document["schema_version"],
"document_code": document_code,
"edition_code": edition_code,
"is_current_edition": edition_code == current_edition,
"language": language,
"position": position,
"fragment_type": fragment["type"],
@@ -72,12 +75,15 @@ def search_document(document: dict, fragment: dict, expected: tuple[str, str, st
"document_name_ky": localized(document.get("name"), "ky"),
"document_type_ru": localized(document.get("type"), "ru"),
"document_type_ky": localized(document.get("type"), "ky"),
"document_type_code": source_code("document_type", document.get("type")),
"status_ru": localized(document.get("status"), "ru"),
"status_ky": localized(document.get("status"), "ky"),
"status_code": source_code("status", document.get("status")),
"number": document.get("number"),
"date_adopted": dates.get("DateAdopted"),
"authority_paths_ru": paths(document, "authority_paths", "ru"),
"authority_paths_ky": paths(document, "authority_paths", "ky"),
"authority_codes": authority_codes(document.get("authority_paths", [])),
"source_path": fragment["source_path"],
"source_sha256": fragment["source_sha256"],
"text_sha256": fragment["text_sha256"],
@@ -118,13 +124,18 @@ def bulk_pairs(
document = read_json(directory / "document.json")
if document.get("source_code") != directory.name:
raise ValueError(f"Document identity does not match its path: {directory}")
edition_root = directory / "editions"
editions = [path.name for path in edition_root.iterdir() if path.is_dir()] if edition_root.is_dir() else []
if not editions:
continue
current_edition = max(editions, key=lambda value: (not value.isdigit(), int(value) if value.isdigit() else value))
for fragment_path in sorted(directory.glob("editions/*/*/fragments.json")):
edition_code, language = fragment_path.parts[-3:-1]
values = read_json(fragment_path)
if not isinstance(values, list):
raise ValueError(f"Fragments must be a list: {fragment_path}")
for position, fragment in enumerate(values, 1):
source = search_document(document, fragment, (directory.name, edition_code, language, position))
source = search_document(document, fragment, (directory.name, edition_code, language, position), current_edition)
action = json.dumps({"index": {"_index": index, "_id": fragment["id"]}}, ensure_ascii=False, allow_nan=False)
body = json.dumps(source, ensure_ascii=False, allow_nan=False)
yield directory.name, f"{action}\n{body}\n".encode()
@@ -246,6 +257,7 @@ def checkpoint_state(
cluster_uuid: str,
index_uuid: str,
limit: int | None,
alias: str | None,
) -> tuple[dict, str | None]:
source = input_root.resolve()
destination = checkpoint.resolve()
@@ -254,7 +266,7 @@ def checkpoint_state(
manifest_sha256 = file_sha256(input_root / "manifest.sqlite3")
if not resume:
return {
"schema_version": 1,
"schema_version": 2,
"url": url,
"cluster_uuid": cluster_uuid,
"index": index,
@@ -262,6 +274,7 @@ def checkpoint_state(
"input": str(source),
"manifest_sha256": manifest_sha256,
"limit": limit,
"alias": alias,
"last_document_code": None,
"complete": False,
}, None
@@ -276,13 +289,22 @@ def checkpoint_state(
"input",
"manifest_sha256",
"limit",
"alias",
"last_document_code",
"complete",
}
if not isinstance(state, dict) or set(state) != expected:
legacy_expected = expected - {"alias"}
if not isinstance(state, dict):
raise ValueError(f"Invalid checkpoint: {checkpoint}")
if set(state) == legacy_expected:
if state["schema_version"] != 1 or alias is not None:
raise ValueError(f"Legacy checkpoint does not support --alias: {checkpoint}")
state["schema_version"] = 2
state["alias"] = None
elif set(state) != expected:
raise ValueError(f"Invalid checkpoint: {checkpoint}")
if (
state["schema_version"] != 1
state["schema_version"] != 2
or state["url"] != url
or state["cluster_uuid"] != cluster_uuid
or state["index"] != index
@@ -292,6 +314,8 @@ def checkpoint_state(
raise ValueError(f"Checkpoint does not match this load: {checkpoint}")
if state["limit"] != limit:
raise ValueError(f"Checkpoint limit does not match --limit: {checkpoint}")
if state["alias"] != alias:
raise ValueError(f"Checkpoint alias does not match --alias: {checkpoint}")
if state["manifest_sha256"] != manifest_sha256:
raise ValueError("Normalized manifest changed; create a new versioned index")
if state["complete"] is not False:
@@ -311,8 +335,11 @@ def load_bulk(
limit: int | None = None,
resume: bool = False,
checkpoint: Path = Path("data/opensearch/minjust-fragments.checkpoint.json"),
alias: str | None = None,
) -> tuple[int, int]:
base = url.rstrip("/")
if alias is not None and (not alias or alias == index):
raise ValueError("--alias must differ from --index")
index_url = f"{base}/{urllib.parse.quote(index, safe='')}"
if resume:
cluster_uuid, index_uuid = opensearch_identity(base, index, index_url)
@@ -328,6 +355,7 @@ def load_bulk(
cluster_uuid,
index_uuid,
limit,
alias,
)
documents = document_count(input_root, limit, start_at)
if not resume:
@@ -363,11 +391,31 @@ def load_bulk(
state["last_document_code"] = last_code
write_json_atomic(checkpoint, state)
print(f"checkpoint={last_code} fragments={fragments}", flush=True)
if alias:
switch_alias(base, index, alias)
state["complete"] = True
write_json_atomic(checkpoint, state)
return documents, fragments
def switch_alias(base: str, index: str, alias: str) -> None:
if not alias or alias == index:
raise ValueError("--alias must differ from --index")
result = request_json(
f"{base.rstrip('/')}/_aliases",
"POST",
json.dumps({
"actions": [
{"remove": {"index": "*", "alias": alias, "must_exist": False}},
{"add": {"index": index, "alias": alias}},
]
}).encode(),
"application/json",
)
if result.get("acknowledged") is not True:
raise RuntimeError(f"OpenSearch did not acknowledge alias switch: {alias}")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", type=Path, default=Path("data/minjust-normalized"))
@@ -375,6 +423,7 @@ def main() -> int:
parser.add_argument("--index", default="akyldash-fragments-v1")
parser.add_argument("--limit", type=int)
parser.add_argument("--url", help="create the index and stream bounded Bulk requests instead of writing a file")
parser.add_argument("--alias", help="atomically point this alias at --index after a successful load")
parser.add_argument("--mapping", type=Path, default=DEFAULT_MAPPING)
parser.add_argument("--batch-mb", type=int, default=25)
parser.add_argument("--resume", action="store_true", help="load into an existing index")
@@ -389,6 +438,10 @@ def main() -> int:
raise SystemExit("--resume requires --url")
if arguments.checkpoint and not arguments.url:
raise SystemExit("--checkpoint requires --url")
if arguments.alias == "":
raise SystemExit("--alias must not be empty")
if arguments.alias is not None and not arguments.url:
raise SystemExit("--alias requires --url")
if arguments.url:
checkpoint = arguments.checkpoint or Path("data/opensearch") / f"{arguments.index}.checkpoint.json"
documents, fragments = load_bulk(
@@ -400,6 +453,7 @@ def main() -> int:
arguments.limit,
arguments.resume,
checkpoint,
arguments.alias,
)
destination = arguments.url
else:

View File

@@ -9,10 +9,27 @@ from contextlib import closing
from pathlib import Path
from unittest.mock import patch
from search.minjust_opensearch import bulk_batches, document_codes, export_bulk, load_bulk, request_json
from search.minjust_opensearch import bulk_batches, document_codes, export_bulk, load_bulk, request_json, switch_alias
class MinjustOpenSearchTest(unittest.TestCase):
def test_switches_alias_atomically_after_successful_load(self):
with patch("search.minjust_opensearch.request_json", return_value={"acknowledged": True}) as request:
switch_alias("http://127.0.0.1:9200/", "akyldash-fragments-v2", "akyldash-fragments-current")
self.assertEqual(request.call_args.args[:2], ("http://127.0.0.1:9200/_aliases", "POST"))
body = json.loads(request.call_args.args[2])
self.assertEqual(body["actions"][0], {"remove": {"index": "*", "alias": "akyldash-fragments-current", "must_exist": False}})
self.assertEqual(body["actions"][1], {"add": {"index": "akyldash-fragments-v2", "alias": "akyldash-fragments-current"}})
with self.assertRaisesRegex(ValueError, "differ"):
switch_alias("http://127.0.0.1:9200", "same", "same")
with self.assertRaisesRegex(ValueError, "differ"):
switch_alias("http://127.0.0.1:9200", "index", "")
with patch("search.minjust_opensearch.request_json", return_value={"acknowledged": False}):
with self.assertRaisesRegex(RuntimeError, "did not acknowledge"):
switch_alias("http://127.0.0.1:9200", "index", "alias")
def test_exports_atomic_bulk_and_rejects_mismatched_fragment(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
@@ -74,6 +91,7 @@ class MinjustOpenSearchTest(unittest.TestCase):
self.assertEqual(len(lines), 4)
self.assertEqual(lines[0]["index"]["_id"], "document:7:edition:10:lang:ky:fragment:1")
self.assertIn("text_ky", lines[1])
self.assertTrue(lines[1]["is_current_edition"])
self.assertNotIn("text_ru", lines[1])
self.assertEqual(lines[3]["text_ru"], "Текст \"RU\"\nстрока")
self.assertEqual(
@@ -91,6 +109,7 @@ class MinjustOpenSearchTest(unittest.TestCase):
{"cluster_uuid": "cluster-1"},
{"test-index": {"settings": {"index": {"uuid": "index-1"}}}},
{"errors": False, "items": [{"index": {}}, {"index": {}}]},
{"acknowledged": True},
]
self.assertEqual(
load_bulk(
@@ -99,14 +118,40 @@ class MinjustOpenSearchTest(unittest.TestCase):
"test-index",
maximum_bytes=4096,
checkpoint=checkpoint,
alias="test-current",
),
(2, 2),
)
self.assertEqual(request.call_args_list[-1].args[3], "application/x-ndjson")
self.assertEqual(request.call_args_list[-2].args[3], "application/x-ndjson")
self.assertEqual(request.call_args_list[-1].args[:2], ("http://127.0.0.1:9200/_aliases", "POST"))
state = json.loads(checkpoint.read_text(encoding="utf-8"))
self.assertEqual(state["last_document_code"], "7")
self.assertTrue(state["complete"])
legacy = state.copy()
legacy.pop("alias")
legacy["schema_version"] = 1
legacy["last_document_code"] = "8"
legacy["complete"] = False
checkpoint.write_text(json.dumps(legacy), encoding="utf-8")
with patch("search.minjust_opensearch.request_json") as request:
request.side_effect = [
{"cluster_uuid": "cluster-1"},
{"test-index": {"settings": {"index": {"uuid": "index-1"}}}},
]
self.assertEqual(
load_bulk(
root / "normalized",
"http://127.0.0.1:9200",
"test-index",
maximum_bytes=4096,
resume=True,
checkpoint=checkpoint,
),
(1, 0),
)
self.assertEqual(json.loads(checkpoint.read_text(encoding="utf-8"))["schema_version"], 2)
state["last_document_code"] = "8"
state["complete"] = False
checkpoint.write_text(json.dumps(state), encoding="utf-8")
@@ -116,6 +161,21 @@ class MinjustOpenSearchTest(unittest.TestCase):
{"test-index": {"settings": {"index": {"uuid": "index-1"}}}},
]
with self.assertRaisesRegex(ValueError, "does not match"):
load_bulk(
root / "normalized",
"http://127.0.0.1:9200",
"test-index",
maximum_bytes=4096,
resume=True,
checkpoint=checkpoint,
alias="test-current",
)
with patch("search.minjust_opensearch.request_json") as request:
request.side_effect = [
{"cluster_uuid": "cluster-1"},
{"test-index": {"settings": {"index": {"uuid": "index-1"}}}},
]
with self.assertRaisesRegex(ValueError, "alias"):
load_bulk(
root / "normalized",
"http://127.0.0.1:9200",
@@ -138,11 +198,13 @@ class MinjustOpenSearchTest(unittest.TestCase):
limit=1,
resume=True,
checkpoint=checkpoint,
alias="test-current",
)
with patch("search.minjust_opensearch.request_json") as request:
request.side_effect = [
{"cluster_uuid": "cluster-1"},
{"test-index": {"settings": {"index": {"uuid": "index-1"}}}},
{"acknowledged": True},
]
self.assertEqual(
load_bulk(
@@ -152,10 +214,12 @@ class MinjustOpenSearchTest(unittest.TestCase):
maximum_bytes=4096,
resume=True,
checkpoint=checkpoint,
alias="test-current",
),
(1, 0),
)
self.assertTrue(all(call.args[1] == "GET" for call in request.call_args_list))
self.assertTrue(all(call.args[1] == "GET" for call in request.call_args_list[:-1]))
self.assertEqual(request.call_args_list[-1].args[1], "POST")
state["last_document_code"] = "9"
state["complete"] = False
@@ -173,6 +237,7 @@ class MinjustOpenSearchTest(unittest.TestCase):
maximum_bytes=4096,
resume=True,
checkpoint=checkpoint,
alias="test-current",
)
failed_checkpoint = root / "failed-checkpoint.json"
@@ -218,6 +283,7 @@ class MinjustOpenSearchTest(unittest.TestCase):
maximum_bytes=4096,
resume=True,
checkpoint=checkpoint,
alias="test-current",
)
http_error = urllib.error.HTTPError(

View File

@@ -0,0 +1,68 @@
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from search.api import Api, ApiError
class SearchApiTest(unittest.TestCase):
def make_api(self, root: Path) -> Api:
document = root / "documents/7"
edition = document / "editions/10"
edition.mkdir(parents=True)
(document / "document.json").write_text(json.dumps({
"source_code": "7", "available_languages": ["ru"],
"editions": [{"source_code": "10", "available_languages": ["ru"]},],
}), encoding="utf-8")
(edition / "edition.json").write_text(json.dumps({"source_code": "10", "available_languages": ["ru"]}), encoding="utf-8")
(edition / "ru").mkdir()
(edition / "ru/content.html").write_text("<p>Текст</p>", encoding="utf-8")
(edition / "ru/content.txt").write_text("Текст\n", encoding="utf-8")
return Api("http://opensearch:9200", "current", root)
def test_search_pagination_filters_and_highlight(self):
with tempfile.TemporaryDirectory() as temporary:
api = self.make_api(Path(temporary))
response = {"hits": {"hits": [{"_source": {"document_code": "7", "edition_code": "10", "document_name_ru": "Закон"}, "highlight": {"text_ru": ["<em>Закон</em>"]}}]}}
with patch("search.api.request_json", return_value=response) as request:
status, payload = api.handle("GET", "/search?q=%D0%B7%D0%B0%D0%BA%D0%BE%D0%BD&language=ru&page=2&page_size=5&status=active")
self.assertEqual(status, 200)
self.assertEqual(payload["results"][0]["snippet"], "<em>Закон</em>")
body = json.loads(request.call_args.args[2])
self.assertEqual((body["from"], body["size"]), (5, 6))
self.assertIn({"term": {"status_code": "active"}}, body["query"]["bool"]["filter"])
with self.assertRaisesRegex(ApiError, "within 10000 results"):
api.handle("GET", "/search?q=x&page=100&page_size=100")
def test_document_editions_openapi_and_validation(self):
with tempfile.TemporaryDirectory() as temporary:
api = self.make_api(Path(temporary))
specification = api.handle("GET", "/openapi.json")[1]
self.assertEqual(specification["info"]["version"], "v1")
self.assertEqual(specification["paths"]["/documents/{code}"]["get"]["parameters"][0]["required"], True)
self.assertEqual(specification["paths"]["/documents/{code}/editions/{edition}"]["get"]["parameters"][1]["name"], "edition")
self.assertEqual(api.handle("GET", "/documents/7")[1]["current_edition"]["source_code"], "10")
self.assertEqual(api.handle("GET", "/documents/7/editions/10?language=ru")[1]["content"]["ru"]["text"], "Текст\n")
with self.assertRaisesRegex(ApiError, "q is required"):
api.handle("GET", "/search")
with self.assertRaisesRegex(ApiError, "date_from must be an ISO date"):
api.handle("GET", "/search?q=x&date_from=tomorrow")
with self.assertRaisesRegex(ApiError, "document not found"):
api.handle("GET", "/documents/%2E%2E")
def test_filters_count_documents_and_return_bilingual_labels(self):
with tempfile.TemporaryDirectory() as temporary:
api = self.make_api(Path(temporary))
response = {"aggregations": {name: {"buckets": [{"key": "law" if name == "document_types" else "active" if name == "statuses" else "parliament", "documents": {"value": 3}}]} for name in ("document_types", "statuses", "authorities")}}
with patch("search.api.request_json", return_value=response) as request:
payload = api.handle("GET", "/search/filters?language=ky")[1]
self.assertEqual(payload["document_types"][0], {"code": "law", "labels": {"ru": "Закон", "ky": "Мыйзам"}, "count": 3})
body = json.loads(request.call_args.args[2])
self.assertIn("terms", body["aggs"]["document_types"])
self.assertEqual(body["query"], {"term": {"is_current_edition": True}})
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,37 @@
import json
import os
import unittest
import uuid
from pathlib import Path
from search.api import Api
from search.minjust_opensearch import DEFAULT_MAPPING, request_json
@unittest.skipUnless(os.getenv("AKYLDASH_OPENSEARCH_URL"), "set AKYLDASH_OPENSEARCH_URL to run against local OpenSearch")
class SearchApiOpenSearchTest(unittest.TestCase):
def test_current_editions_filters_and_catalogs(self):
base_url = os.environ["AKYLDASH_OPENSEARCH_URL"].rstrip("/")
index = f"akyldash-api-test-{uuid.uuid4().hex}"
request_json(f"{base_url}/{index}", "PUT", DEFAULT_MAPPING.read_bytes(), "application/json")
try:
documents = [
{"document_code": "1", "edition_code": "1", "is_current_edition": False, "language": "ru", "position": 1, "fragment_type": "paragraph", "text_ru": "historic", "document_name_ru": "Old law", "document_type_ru": "Закон", "document_type_ky": "Мыйзам", "document_type_code": "law", "status_ru": "Утратил силу", "status_ky": "Күчүн жоготту", "status_code": "repealed", "date_adopted": "2020-01-01", "authority_paths_ru": ["Парламент"], "authority_paths_ky": ["Парламент"], "authority_codes": ["parliament"]},
{"document_code": "1", "edition_code": "2", "is_current_edition": True, "language": "ru", "position": 1, "fragment_type": "paragraph", "text_ru": "needle", "document_name_ru": "Current law", "document_type_ru": "Закон", "document_type_ky": "Мыйзам", "document_type_code": "law", "status_ru": "Действует", "status_ky": "Күчүндө", "status_code": "active", "date_adopted": "2021-01-01", "authority_paths_ru": ["Парламент"], "authority_paths_ky": ["Парламент"], "authority_codes": ["parliament"]},
{"document_code": "2", "edition_code": "1", "is_current_edition": True, "language": "ru", "position": 1, "fragment_type": "paragraph", "text_ru": "needle", "document_name_ru": "Current decree", "document_type_ru": "Указ", "document_type_ky": "Жарлык", "document_type_code": "decree", "status_ru": "Действует", "status_ky": "Күчүндө", "status_code": "active", "date_adopted": "2022-01-01", "authority_paths_ru": ["Президент"], "authority_paths_ky": ["Президент"], "authority_codes": ["president"]},
]
for number, document in enumerate(documents):
request_json(f"{base_url}/{index}/_doc/{number}", "PUT", json.dumps(document).encode(), "application/json")
request_json(f"{base_url}/{index}/_refresh", "POST", None, "application/json")
api = Api(base_url, index, Path("."))
self.assertEqual(api.handle("GET", "/search?q=historic")[1]["results"], [])
filtered = api.handle("GET", "/search?q=needle&document_type=law")[1]
self.assertEqual([result["code"] for result in filtered["results"]], ["1"])
filters = api.handle("GET", "/search/filters")[1]
self.assertEqual({item["count"] for item in filters["statuses"]}, {2})
finally:
request_json(f"{base_url}/{index}", "DELETE", None, "application/json")
if __name__ == "__main__":
unittest.main()

View File

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

View File

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

View File

@@ -5,7 +5,7 @@
- Telegram-бот: `0.2.2`
- Telegram-бот на Synology: `0.2.1`
- Backend: `0.5.2`
- Backend: `0.7.1`
- Frontend: не создан
## Краткий итог
@@ -234,4 +234,4 @@
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан

View File

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

View File

@@ -258,4 +258,4 @@ runtime-зависимостями frontend. Регистрация в стор
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан

View File

@@ -214,4 +214,4 @@ python3 backend/normalization/minjust_cbd.py
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан

View File

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

View File

@@ -0,0 +1,78 @@
# Справочники Search API v1
Статус: утверждённый контракт для Search API v1.
`code` — неизменяемый идентификатор, независимый от языка. Клиент хранит и
передаёт только `code`; русское и кыргызское названия являются подписями и могут
исправляться без изменения кода.
## Типы документов
| Code | RU | KY |
| --- | --- | --- |
| `constitution` | Конституция | Конституция |
| `constitutional_law` | Конституционный Закон | Конституциалык Мыйзам |
| `code` | Кодекс | Кодекс |
| `law` | Закон | Мыйзам |
| `decree` | Указ | Жарлык |
| `resolution` | Постановление | Токтом |
| `order` | Распоряжение | Распоряжение |
| `instruction` | Инструкция | Инструкция |
| `rules` | Правила | Правила |
| `procedure` | Порядок | Порядок |
| `provision` | Положение | Жобо |
| `regulation` | Регламент | Регламент |
| `charter` | Устав | Жобо (Устав) |
| `program` | Программа | Программа |
| `plan` | План | План |
| `strategy` | Стратегия | Стратегия |
| `concept` | Концепция | Концепция |
| `doctrine` | Доктрина | Доктрина |
| `agreement` | Соглашение | Соглашение |
| `declaration` | Декларация | Декларация |
| `registry` | Реестр | Реестр |
| `norms` | Нормативы | Нормативы |
| `model` | Модель | Модель |
| `matrix` | Матрица | Матрица |
| `study` | Исследование | Исследование |
| `report` | Доклад | Доклад |
| `principles` | Основные принципы | Основные принципы |
## Статусы
| Code | RU | KY |
| --- | --- | --- |
| `active` | Действует | Күчүндө |
| `repealed` | Утратил силу | Күчүн жоготту |
| `unspecified` | Не указан | Көрсөтүлгөн эмес |
## Органы принятия
Орган содержит два уровня: стабильную группу и конкретный орган. В v1 группы
следующие:
| Code | RU | KY |
| --- | --- | --- |
| `president` | Президент | Президент |
| `parliament` | Органы законодательной власти | Мыйзам чыгаруу бийлик органдары |
| `cabinet` | Правительство и Кабинет Министров | Өкмөт жана Министрлер Кабинети |
| `ministries_and_committees` | Министерства и государственные комитеты | Министрликтер жана мамлекеттик комитеттер |
| `administrative_agencies` | Административные ведомства | Административдик ведомстволор |
| `national_bank` | Национальный банк | Улуттук банк |
| `other_state_bodies` | Иные государственные органы | Башка мамлекеттик органдар |
| `local_representative_bodies` | Представительные органы местного самоуправления | Жергиликтүү өз алдынча башкаруунун өкүлчүлүктүү органдары |
| `other` | Прочие органы | Башка органдар |
Конкретные министерства, муниципальные и айылные кенеши не получают ID из
подписи: в выгрузке ЦБД их поле `Code` часто равно `null`. До появления
первичного неизменяемого идентификатора API v1 выдаёт и принимает только код
группы органа. Полный каталог конкретных органов — отдельная версия (`v2`),
когда источник предоставит такие идентификаторы либо будет утверждён вручную
поддерживаемый реестр.
## Правила совместимости
- Код из этой таблицы нельзя переиспользовать и нельзя менять его значение.
- Новое значение добавляется только новой записью; удалённое остаётся доступно
для старых документов.
- Запрос с неизвестным кодом возвращает `400`.

View File

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

View File

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