feat(search): add legal force catalog and taxonomy draft
This commit is contained in:
@@ -16,6 +16,8 @@
|
||||
- Добавлен план внутреннего интерфейса оценки поисковой выдачи юристами.
|
||||
- Завершён Search API v1: стабильные справочники, валидный OpenAPI, безопасная
|
||||
пагинация и проверка актуальных редакций в локальном OpenSearch.
|
||||
- Добавлен фильтр Search API v1 по укрупнённой юридической силе; полная
|
||||
иерархия GeneralClassifiers опубликована как черновой каталог вне API v1.
|
||||
- Добавлено безопасное переключение alias на новую версию поискового индекса
|
||||
после полной загрузки; checkpoint защищает возобновление загрузки от смены
|
||||
alias.
|
||||
|
||||
@@ -10,12 +10,12 @@ Telegram-бот — только часть рабочего окружения
|
||||
## Текущее состояние
|
||||
|
||||
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии
|
||||
`0.8.3`: исправлена безопасность production Docker build context.
|
||||
`0.9.0`: добавлена фильтрация поиска по юридической силе.
|
||||
|
||||
| Компонент | Версия | Состояние |
|
||||
|---|---:|---|
|
||||
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
|
||||
| Backend | `0.8.3` | исправлена безопасность production build context |
|
||||
| Backend | `0.9.0` | фильтр поиска по юридической силе и черновик тематического каталога |
|
||||
| Frontend | — | ещё не создан |
|
||||
| Сбор и обработка правовых данных | `0.8.0` | добавлены relevance set и оценка выдачи |
|
||||
| RAG и база знаний | — | ещё не созданы |
|
||||
@@ -58,4 +58,4 @@ python3 -m unittest discover -s tools/telegram-bot -v
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.8.3 · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.9.0 · Frontend — не создан
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Backend Акылдаш
|
||||
|
||||
Версия: `0.8.3`
|
||||
Версия: `0.9.0`
|
||||
|
||||
Первая backend-область проекта — загрузка правовых документов из официального
|
||||
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
|
||||
@@ -240,4 +240,4 @@ PYTHONPATH=backend python3 -m search.query "ЖЧК ачуу тартиби" --la
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Backend v0.8.3 · Frontend — не создан
|
||||
Акылдаш · Backend v0.9.0 · Frontend — не создан
|
||||
|
||||
@@ -21,7 +21,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable
|
||||
|
||||
APP_VERSION = "0.8.3"
|
||||
APP_VERSION = "0.9.0"
|
||||
API_BASE_URL = "https://cbd.minjust.gov.kg/api/v1/OpenData/"
|
||||
LANGUAGES = {"Rus": "ru", "Kyr": "ky"}
|
||||
IMAGE_LANGUAGES = {"Russian": "ru", "Kyrgyz": "ky"}
|
||||
|
||||
@@ -23,7 +23,7 @@ from pathlib import Path
|
||||
from typing import Callable
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
APP_VERSION = "0.8.3"
|
||||
APP_VERSION = "0.9.0"
|
||||
SCHEMA_VERSION = "1"
|
||||
NORMALIZER_VERSION = "1.0.0"
|
||||
LANGUAGES = ("ru", "ky")
|
||||
|
||||
@@ -72,6 +72,7 @@ def openapi() -> dict:
|
||||
{"name": "document_type", "in": "query", "schema": {"type": "string"}},
|
||||
{"name": "status", "in": "query", "schema": {"type": "string"}},
|
||||
{"name": "authority", "in": "query", "schema": {"type": "string"}},
|
||||
{"name": "legal_force", "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"]}},
|
||||
@@ -121,7 +122,7 @@ class Api:
|
||||
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"}
|
||||
fields = {"document_type": "document_type_code", "status": "status_code", "authority": "authority_codes", "legal_force": "legal_force_code"}
|
||||
for parameter, field in fields.items():
|
||||
value = one(query, parameter)
|
||||
if value:
|
||||
@@ -137,7 +138,7 @@ class Api:
|
||||
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"],
|
||||
"_source": ["document_code", "edition_code", "document_name_ru", "document_name_ky", "document_type_ru", "document_type_ky", "status_ru", "status_ky", "legal_force_code", "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}}},
|
||||
@@ -161,13 +162,14 @@ class Api:
|
||||
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}
|
||||
legal_force = source.get("legal_force_code")
|
||||
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}"), "legal_force": labels("legal_force", legal_force)[language] if legal_force else None, "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")}
|
||||
fields = {"document_types": ("document_type", "document_type_code"), "statuses": ("status", "status_code"), "authorities": ("authority", "authority_codes"), "legal_forces": ("legal_force", "legal_force_code")}
|
||||
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:
|
||||
|
||||
@@ -5,7 +5,20 @@ DOCUMENT_TYPES = {
|
||||
}
|
||||
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}
|
||||
LEGAL_FORCE_LEVELS = {
|
||||
"constitutional": ("Конституционный уровень", "Конституциялык деңгээл"),
|
||||
"legislative": ("Законодательный уровень", "Мыйзам деңгээли"),
|
||||
"subordinate": ("Подзаконный уровень", "Мыйзам алдындагы деңгээл"),
|
||||
}
|
||||
LEGAL_FORCE_BY_DOCUMENT_TYPE = {
|
||||
"constitution": "constitutional",
|
||||
"constitutional_law": "constitutional",
|
||||
"code": "legislative",
|
||||
"law": "legislative",
|
||||
"decree": "subordinate",
|
||||
"resolution": "subordinate",
|
||||
}
|
||||
CATALOGS = {"document_type": DOCUMENT_TYPES, "status": STATUSES, "authority": AUTHORITIES, "legal_force": LEGAL_FORCE_LEVELS}
|
||||
|
||||
|
||||
def labels(category: str, code: str) -> dict[str, str]:
|
||||
@@ -49,3 +62,7 @@ def authority_codes(paths: list[dict]) -> list[str]:
|
||||
else:
|
||||
codes.add("other")
|
||||
return sorted(codes) or ["other"]
|
||||
|
||||
|
||||
def legal_force_code(document_type: dict | None) -> str | None:
|
||||
return LEGAL_FORCE_BY_DOCUMENT_TYPE.get(source_code("document_type", document_type))
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"document_type_ru": { "type": "keyword" },
|
||||
"document_type_ky": { "type": "keyword" },
|
||||
"document_type_code": { "type": "keyword" },
|
||||
"legal_force_code": { "type": "keyword" },
|
||||
"status_ru": { "type": "keyword" },
|
||||
"status_ky": { "type": "keyword" },
|
||||
"status_code": { "type": "keyword" },
|
||||
|
||||
@@ -15,9 +15,9 @@ import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
from search.catalog import authority_codes, source_code
|
||||
from search.catalog import authority_codes, legal_force_code, source_code
|
||||
|
||||
APP_VERSION = "0.8.3"
|
||||
APP_VERSION = "0.9.0"
|
||||
LANGUAGES = {"ru", "ky"}
|
||||
DEFAULT_MAPPING = Path(__file__).with_name("minjust-fragments-index.json")
|
||||
|
||||
@@ -76,6 +76,7 @@ def search_document(document: dict, fragment: dict, expected: tuple[str, str, st
|
||||
"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")),
|
||||
"legal_force_code": legal_force_code(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")),
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
<div class="review__actions"><label>Проверяющий <input id="reviewer" maxlength="120" autocomplete="name"></label> <label>Общий комментарий <input id="overall-comment" maxlength="4000"></label> <button class="review__button--primary" id="save" type="button">Сохранить оценку</button></div>
|
||||
<dialog id="document-dialog"><button class="review__dialog-close" id="dialog-close" type="button">Закрыть</button><h2 id="dialog-heading">Документ</h2><div class="review__document-body" id="dialog-body"></div></dialog>
|
||||
<div class="review__status review__status--error" id="error" role="alert" aria-live="assertive"></div>
|
||||
<footer class="review__header">Акылдаш · Backend v0.8.3 · Внутренняя лаборатория релевантности</footer>
|
||||
<footer class="review__header">Акылдаш · Backend v0.9.0 · Внутренняя лаборатория релевантности</footer>
|
||||
<script>
|
||||
const DRAFT_KEY = 'akyldash-search-review-draft';
|
||||
const state = { results: [], token: '', selected: null, query: '' };
|
||||
|
||||
@@ -93,6 +93,7 @@ class MinjustOpenSearchTest(unittest.TestCase):
|
||||
self.assertIn("text_ky", lines[1])
|
||||
self.assertTrue(lines[1]["is_current_edition"])
|
||||
self.assertNotIn("text_ru", lines[1])
|
||||
self.assertEqual(lines[1]["legal_force_code"], "legislative")
|
||||
self.assertEqual(lines[3]["text_ru"], "Текст \"RU\"\nстрока")
|
||||
self.assertEqual(
|
||||
list(bulk_batches(iter((("7", b"a\nb\n"), ("8", b"c\nd\n"))), 4)),
|
||||
|
||||
@@ -55,12 +55,15 @@ class SearchApiTest(unittest.TestCase):
|
||||
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")}}
|
||||
keys = {"document_types": "law", "statuses": "active", "authorities": "parliament", "legal_forces": "legislative"}
|
||||
response = {"aggregations": {name: {"buckets": [{"key": keys[name], "documents": {"value": 3}}]} for name in keys}}
|
||||
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})
|
||||
self.assertEqual(payload["legal_forces"][0]["labels"]["ky"], "Мыйзам деңгээли")
|
||||
body = json.loads(request.call_args.args[2])
|
||||
self.assertIn("terms", body["aggs"]["document_types"])
|
||||
self.assertIn("legal_force_code", body["aggs"]["legal_forces"]["terms"]["field"])
|
||||
self.assertEqual(body["query"], {"term": {"is_current_edition": True}})
|
||||
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@ class SearchApiOpenSearchTest(unittest.TestCase):
|
||||
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"]},
|
||||
{"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", "legal_force_code": "legislative", "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", "legal_force_code": "legislative", "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", "legal_force_code": "subordinate", "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")
|
||||
@@ -27,8 +27,12 @@ class SearchApiOpenSearchTest(unittest.TestCase):
|
||||
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"])
|
||||
force_filtered = api.handle("GET", "/search?q=needle&legal_force=subordinate")[1]
|
||||
self.assertEqual([result["code"] for result in force_filtered["results"]], ["2"])
|
||||
self.assertEqual(force_filtered["results"][0]["legal_force"], "Подзаконный уровень")
|
||||
filters = api.handle("GET", "/search/filters")[1]
|
||||
self.assertEqual({item["count"] for item in filters["statuses"]}, {2})
|
||||
self.assertEqual({item["code"] for item in filters["legal_forces"]}, {"legislative", "subordinate"})
|
||||
finally:
|
||||
request_json(f"{base_url}/{index}", "DELETE", None, "application/json")
|
||||
|
||||
|
||||
56
backend/test_search_topic_taxonomy.py
Normal file
56
backend/test_search_topic_taxonomy.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import json
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
|
||||
from tools.build_search_topic_taxonomy import build
|
||||
|
||||
|
||||
class SearchTopicTaxonomyTest(unittest.TestCase):
|
||||
def test_preserves_source_tree_counts_and_marks_unclassified_roots(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
(root / "documents/1").mkdir(parents=True)
|
||||
(root / "documents/2").mkdir(parents=True)
|
||||
with closing(sqlite3.connect(root / "manifest.sqlite3")) as db:
|
||||
db.execute("CREATE TABLE documents (code TEXT, state TEXT)")
|
||||
db.executemany("INSERT INTO documents VALUES (?, 'success')", [("1",), ("2",)])
|
||||
db.commit()
|
||||
classifier = {
|
||||
"Code": None,
|
||||
"Name": {"Rus": "ЗАКОНОДАТЕЛЬСТВО О ТРУДЕ", "Kyr": None},
|
||||
"GeneralClassifiers": [{
|
||||
"Code": "child-1",
|
||||
"Name": {"Rus": "Рабочее время", "Kyr": "Иш убактысы"},
|
||||
"GeneralClassifiers": [],
|
||||
}],
|
||||
}
|
||||
unknown = {
|
||||
"Code": None,
|
||||
"Name": {"Rus": "НЕИЗВЕСТНАЯ РУБРИКА", "Kyr": None},
|
||||
"GeneralClassifiers": [],
|
||||
}
|
||||
(root / "documents/1/document.json").write_text(
|
||||
json.dumps({"general_classifiers": [classifier]}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(root / "documents/2/document.json").write_text(
|
||||
json.dumps({"general_classifiers": [classifier, unknown]}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
catalog = build(root)
|
||||
groups = {group["code"]: group for group in catalog["groups"]}
|
||||
labor_root = groups["labor_social"]["children"][0]
|
||||
|
||||
self.assertEqual((catalog["source_document_count"], catalog["source_node_count"], catalog["source_root_count"]), (2, 3, 2))
|
||||
self.assertEqual(labor_root["document_count"], 2)
|
||||
self.assertEqual(labor_root["children"][0]["document_count"], 2)
|
||||
self.assertEqual(groups["review_required"]["children"][0]["grouping_status"], "manual_review")
|
||||
self.assertEqual(catalog["nodes_missing_kyrgyz_label"], 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -39,4 +39,4 @@
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.8.3 · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.9.0 · Frontend — не создан
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# Статус проекта
|
||||
|
||||
Последняя проверка: 2026-09-12
|
||||
Последняя проверка: 2026-09-14
|
||||
Назначение документа: быстро восстановить контекст проекта для участников команды и будущих агентов.
|
||||
|
||||
- Telegram-бот: `0.2.2`
|
||||
- Telegram-бот на Synology: `0.2.1`
|
||||
- Backend: `0.8.3`
|
||||
- Backend: `0.9.0`
|
||||
- Frontend: не создан
|
||||
|
||||
## Краткий итог
|
||||
@@ -262,4 +262,4 @@
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.8.3 · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.9.0 · Frontend — не создан
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Справочники Search API v1
|
||||
|
||||
Статус: утверждённый контракт для Search API v1.
|
||||
Статус: рабочий контракт Search API v1. Тематическая иерархия вынесена в
|
||||
отдельный черновой каталог и не входит в фильтры API v1.
|
||||
|
||||
`code` — неизменяемый идентификатор, независимый от языка. Клиент хранит и
|
||||
передаёт только `code`; русское и кыргызское названия являются подписями и могут
|
||||
@@ -37,6 +38,7 @@
|
||||
| `study` | Исследование | Исследование |
|
||||
| `report` | Доклад | Доклад |
|
||||
| `principles` | Основные принципы | Основные принципы |
|
||||
| `unspecified` | Не указан | Көрсөтүлгөн эмес |
|
||||
|
||||
## Статусы
|
||||
|
||||
@@ -46,6 +48,25 @@
|
||||
| `repealed` | Утратил силу | Күчүн жоготту |
|
||||
| `unspecified` | Не указан | Көрсөтүлгөн эмес |
|
||||
|
||||
## Юридическая сила
|
||||
|
||||
Это укрупнённая группировка для фильтра Search API, а не полная иерархия
|
||||
нормативных правовых актов из статьи 6 Закона КР «О нормативных правовых
|
||||
актах». В этой группировке Конституция и конституционные законы относятся к
|
||||
конституционному уровню, кодексы и законы — к законодательному, указы и
|
||||
постановления, перечисленные законом как виды НПА, — к подзаконному.
|
||||
|
||||
| Code | RU | KY |
|
||||
| --- | --- | --- |
|
||||
| `constitutional` | Конституционный уровень | Конституциялык деңгээл |
|
||||
| `legislative` | Законодательный уровень | Мыйзам деңгээли |
|
||||
| `subordinate` | Подзаконный уровень | Мыйзам алдындагы деңгээл |
|
||||
|
||||
Для прочих типов значение не назначается автоматически: одного названия типа
|
||||
недостаточно, чтобы установить юридическую силу утверждающего НПА. Основание:
|
||||
[статьи 4 и 6 Закона КР «О нормативных правовых актах»](https://cbd.minjust.gov.kg/4-3987/edition/53304/ru).
|
||||
Эта группировка реализована как фильтр `legal_force` Search API v1.
|
||||
|
||||
## Органы принятия
|
||||
|
||||
Орган содержит два уровня: стабильную группу и конкретный орган. В v1 группы
|
||||
@@ -70,6 +91,35 @@
|
||||
когда источник предоставит такие идентификаторы либо будет утверждён вручную
|
||||
поддерживаемый реестр.
|
||||
|
||||
## Темы
|
||||
|
||||
Полная исходная иерархия `GeneralClassifiers` сохранена отдельно в
|
||||
[`search-topic-taxonomy-v1.json`](search-topic-taxonomy-v1.json). Над исходными
|
||||
ветвями добавлены предложенные смысловые группы; исходные подписи и вложенность
|
||||
не переставляются. Поле `document_count` показывает число документов, где узел
|
||||
встречается. Предлагаются группы: конституционный строй и права; государство и
|
||||
публичное управление; право, суд и правопорядок; международные отношения;
|
||||
оборона и безопасность; экономика, финансы и предпринимательство; труд и
|
||||
социальная защита; здравоохранение; образование, наука и культура; земля,
|
||||
природные ресурсы и окружающая среда; сельское хозяйство; транспорт, связь,
|
||||
строительство и жильё; информация и СМИ; гражданские, семейные и жилищные
|
||||
отношения; общие и межотраслевые вопросы.
|
||||
|
||||
Каталог является черновиком: группа корневой рубрики предложена по русской
|
||||
подписи, неоднозначные и нераспознанные рубрики помещены в
|
||||
`review_required` и отмечены `manual_review`; остальные помечены
|
||||
`provisional_lexical`. В корпусе 2 065 узлов и 687 вариантов корневых рубрик;
|
||||
251 корневой вариант ожидает ручной классификации. Отсутствующие в источнике
|
||||
кыргызские подписи оставлены пустыми: они отсутствуют у 1 524 узлов, новые
|
||||
переводы не придуманы. Внутренние `source-*` ID являются
|
||||
временными хешами кода источника и цепочки подписей; при `Code: null` они
|
||||
зависят от подписей и не утверждаются как публичные стабильные коды.
|
||||
|
||||
Тематическая навигация и фильтр не входят в Search API v1. До включения в API
|
||||
нужно вручную утвердить смысловые группы, идентификаторы и недостающие KY-
|
||||
подписи. Источник — нормализованные данные ЦБД Минюста; команда для пересборки
|
||||
каталога находится в `tools/build_search_topic_taxonomy.py`.
|
||||
|
||||
## Правила совместимости
|
||||
|
||||
- Код из этой таблицы нельзя переиспользовать и нельзя менять его значение.
|
||||
|
||||
21811
docs/product/search-topic-taxonomy-v1.json
Normal file
21811
docs/product/search-topic-taxonomy-v1.json
Normal file
File diff suppressed because it is too large
Load Diff
190
tools/build_search_topic_taxonomy.py
Normal file
190
tools/build_search_topic_taxonomy.py
Normal file
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a draft grouped view of the source GeneralClassifiers hierarchy."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import unicodedata
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
GROUPS = {
|
||||
"constitutional_order": ("Конституционный строй и права", "Конституциялык түзүлүш жана укуктар"),
|
||||
"government": ("Государство и публичное управление", "Мамлекет жана мамлекеттик башкаруу"),
|
||||
"justice": ("Право, суд и правопорядок", "Укук, сот жана укук тартиби"),
|
||||
"international": ("Международные отношения", "Эл аралык мамилелер"),
|
||||
"security": ("Оборона и безопасность", "Коргонуу жана коопсуздук"),
|
||||
"economy": ("Экономика, финансы и предпринимательство", "Экономика, финансы жана ишкердик"),
|
||||
"labor_social": ("Труд и социальная защита", "Эмгек жана социалдык коргоо"),
|
||||
"health": ("Здравоохранение", "Саламаттыкты сактоо"),
|
||||
"education_culture": ("Образование, наука и культура", "Билим берүү, илим жана маданият"),
|
||||
"land_environment": ("Земля, природные ресурсы и окружающая среда", "Жер, жаратылыш ресурстары жана айлана-чөйрө"),
|
||||
"agriculture": ("Сельское хозяйство", "Айыл чарба"),
|
||||
"infrastructure": ("Транспорт, связь, строительство и жильё", "Транспорт, байланыш, курулуш жана турак жай"),
|
||||
"information": ("Информация и средства массовой информации", "Маалымат жана жалпыга маалымдоо каражаттары"),
|
||||
"civil_family": ("Гражданские, семейные и жилищные отношения", "Жарандык, үй-бүлөлүк жана турак жай мамилелери"),
|
||||
"cross_cutting": ("Общие и межотраслевые вопросы", "Жалпы жана тармактар аралык маселелер"),
|
||||
"review_required": ("Требуется смысловая проверка", "Маанисин текшерүү талап кылынат"),
|
||||
}
|
||||
|
||||
RULES = [
|
||||
("international", r"международ|внешн(яя|ей) полит|дипломат|консул|иностранн|снг|содружеств|эл аралык"),
|
||||
("security", r"оборон|военн|арм|мобилизац|государственн(ая|ой) безопасност|чрезвыча|гражданск(ая|ой) оборон|погранич"),
|
||||
("health", r"здравоохран|медицин|лекарств|санитар|эпидеми|трансплантац|донорств|охрана здоровья"),
|
||||
("education_culture", r"образован|просвещен|наук|культур|искусств|(?<![а-я])спорт|туризм|библиотек|музе|архив|издательств"),
|
||||
("labor_social", r"труд|занятост|пенси|социальн|соцстрах|безработ|инвалид|пособи|охрана труда|семейн(ая|ые) поддержк"),
|
||||
("land_environment", r"земл|природн(ые|ых) ресурс|окружающ|охрана природы|недр|водн(ые|ых) ресурс|атмосферн|животн(ый|ого) мир|лесн(ой|ое) хозяйств|эколог"),
|
||||
("agriculture", r"сельск|аграр|растениевод|животновод|земледел|рыболов|рыбовод|зерновод|семеновод|ветеринар"),
|
||||
("infrastructure", r"транспорт|связи|дорог|железнодорож|авиац|строительств|градостро|жилищ|коммунальн|энергетик|почтов|телекоммуникац"),
|
||||
("information", r"информац|средств массовой информац|радио|телевиден|персональн(ые|ых) данные|средства массовой информации"),
|
||||
("economy", r"финанс|бюджет|налог|кредит|эконом|предприним|хозяйственн|промышлен|торгов|тамож|внешнеэконом|банк|страхован|инвестиц|ценн(ые|ых) бумаг|лицензировани|валют|конкуренц|монопол|государственн(ая|ой) собственност"),
|
||||
("justice", r"(?<![а-я])суд(?![а-я])|юстици|прокуратур|адвокат|нотариат|уголов|правонаруш|правопоряд|общественн(ого|ый) поряд|административн(ой|ая) ответственност|исполнени(е|я) наказан|следств|розыск|правоохран|систематизац.*норматив|законодательств о нормативн|арбитражн(ый|ого) процесс"),
|
||||
("constitutional_order", r"конституц|государственн(ого|ый) стро|права и свобод|гражданств|выбор|референдум|парламент|жогорку кенеш|избирательн|символ|государственн(ый|ого) язык|законодательная деятельность"),
|
||||
("government", r"государственн(ая|ой) служ|государственн(ое|ого) управлен|местн(ое|ого) самоуправлен|местная государственная администрация|орган(ы|ов) государственн|административн(ое|ого) управлен|государственн(ая|ой) власть|муниципальн|территориальн|государственн(ая|ой) наград|государственн(ый|ого) архив|министерств|кабинет министров|правительство|государственн(ого|ая) аппарата|нормотворческая деятельность|решения по кадровым|назначение на должность|освобождение от должности"),
|
||||
("civil_family", r"гражданск|семейн|брачн|жилищ|собственност|наследован|договор|обязательств|авторск|интеллектуальн|потребител|личн(ые|ых) неимущественн|опек|попечительств"),
|
||||
("cross_cutting", r"общ(ие|им) вопрос|межотрасл|общие положения|классификатор|учёт норматив|учет норматив|праздник|увековеч|по другим вопросам|^законодательство$"),
|
||||
]
|
||||
|
||||
|
||||
def clean(value):
|
||||
return unicodedata.normalize("NFC", value.strip()) if isinstance(value, str) and value.strip() else None
|
||||
|
||||
|
||||
def group_for(label):
|
||||
text = (label or "").casefold()
|
||||
matches = [code for code, pattern in RULES if re.search(pattern, text)]
|
||||
return matches[0] if matches else "review_required"
|
||||
|
||||
|
||||
def node_id(path):
|
||||
raw = json.dumps(path, ensure_ascii=False, separators=(",", ":"))
|
||||
return "source-" + hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def add_nodes(nodes, classifiers, parent_path, seen):
|
||||
if not isinstance(classifiers, list):
|
||||
return
|
||||
for source in classifiers:
|
||||
if not isinstance(source, dict):
|
||||
continue
|
||||
name = source.get("Name") if isinstance(source.get("Name"), dict) else {}
|
||||
labels = {"ru": clean(name.get("Rus")), "ky": clean(name.get("Kyr"))}
|
||||
code = source.get("Code")
|
||||
identity = (str(code) if code is not None else None, labels["ru"], labels["ky"])
|
||||
path = parent_path + [identity]
|
||||
identifier = node_id(path)
|
||||
if identifier not in nodes:
|
||||
nodes[identifier] = {
|
||||
"id": identifier,
|
||||
"source_codes": set(),
|
||||
"labels": labels,
|
||||
"document_count": 0,
|
||||
"children": {},
|
||||
"_path": path,
|
||||
}
|
||||
node = nodes[identifier]
|
||||
if code is not None:
|
||||
node["source_codes"].add(str(code))
|
||||
if identifier not in seen:
|
||||
node["document_count"] += 1
|
||||
seen.add(identifier)
|
||||
add_nodes(nodes, source.get("GeneralClassifiers"), path, seen)
|
||||
|
||||
|
||||
def emit_node(node, children):
|
||||
result = {
|
||||
"id": node["id"],
|
||||
"source_codes": sorted(node["source_codes"]),
|
||||
"labels": node["labels"],
|
||||
"document_count": node["document_count"],
|
||||
"children": children,
|
||||
}
|
||||
if "grouping_status" in node:
|
||||
result["grouping_status"] = node["grouping_status"]
|
||||
return result
|
||||
|
||||
|
||||
def build(normalized_root):
|
||||
db_path = normalized_root / "manifest.sqlite3"
|
||||
with closing(sqlite3.connect(db_path)) as connection:
|
||||
codes = [row[0] for row in connection.execute("SELECT code FROM documents WHERE state='success' ORDER BY code")]
|
||||
nodes = {}
|
||||
roots = {}
|
||||
source_root = normalized_root / "documents"
|
||||
processed = 0
|
||||
|
||||
def read(code):
|
||||
path = source_root / code / "document.json"
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return code, []
|
||||
return code, data.get("general_classifiers", [])
|
||||
|
||||
with ThreadPoolExecutor(max_workers=24) as pool:
|
||||
for offset in range(0, len(codes), 512):
|
||||
for code, classifiers in pool.map(read, codes[offset:offset + 512]):
|
||||
seen = set()
|
||||
add_nodes(nodes, classifiers, [], seen)
|
||||
for source in classifiers if isinstance(classifiers, list) else []:
|
||||
if not isinstance(source, dict):
|
||||
continue
|
||||
name = source.get("Name") if isinstance(source.get("Name"), dict) else {}
|
||||
labels = {"ru": clean(name.get("Rus")), "ky": clean(name.get("Kyr"))}
|
||||
identity = (str(source.get("Code")) if source.get("Code") is not None else None, labels["ru"], labels["ky"])
|
||||
key = node_id([identity])
|
||||
roots.setdefault(key, nodes[key])
|
||||
processed += 1
|
||||
|
||||
by_parent = {}
|
||||
for node in nodes.values():
|
||||
parent = node["_path"][:-1]
|
||||
parent_id = node_id(parent) if parent else None
|
||||
by_parent.setdefault(parent_id, []).append(node)
|
||||
|
||||
groups = {code: {"code": code, "labels": {"ru": names[0], "ky": names[1]}, "source_roots": []} for code, names in GROUPS.items()}
|
||||
for root in roots.values():
|
||||
code = group_for(root["labels"]["ru"])
|
||||
root["grouping_status"] = "manual_review" if code == "review_required" else "provisional_lexical"
|
||||
groups[code]["source_roots"].append(root)
|
||||
|
||||
def render(node):
|
||||
children = sorted(by_parent.get(node["id"], []), key=lambda item: (item["labels"]["ru"] or "", item["labels"]["ky"] or "", item["id"]))
|
||||
return emit_node(node, [render(child) for child in children])
|
||||
|
||||
missing_ky_count = sum(not node["labels"]["ky"] for node in nodes.values())
|
||||
assigned_roots = [root["id"] for group in groups.values() for root in group["source_roots"]]
|
||||
assert len(assigned_roots) == len(roots) == len(set(assigned_roots))
|
||||
catalog = {
|
||||
"status": "draft; semantic group assignment requires review",
|
||||
"source": "Ministry of Justice GeneralClassifiers from normalized documents",
|
||||
"source_document_count": processed,
|
||||
"source_node_count": len(nodes),
|
||||
"source_root_count": len(roots),
|
||||
"nodes_missing_kyrgyz_label": missing_ky_count,
|
||||
"id_policy": "provisional SHA-256 of source code and bilingual ancestor labels; null source codes are common",
|
||||
"groups": [
|
||||
{"code": code, "labels": group["labels"], "source_root_count": len(group["source_roots"]), "children": [render(root) for root in sorted(group["source_roots"], key=lambda item: (item["labels"]["ru"] or "", item["labels"]["ky"] or "", item["id"]))]}
|
||||
for code, group in groups.items()
|
||||
],
|
||||
}
|
||||
return catalog
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("normalized_root", type=Path)
|
||||
parser.add_argument("output", type=Path)
|
||||
args = parser.parse_args()
|
||||
catalog = build(args.normalized_root)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(catalog, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"documents={catalog['source_document_count']} nodes={catalog['source_node_count']} roots={catalog['source_root_count']} groups={len(catalog['groups'])} missing_ky={catalog['nodes_missing_kyrgyz_label']} review_roots={next(group['source_root_count'] for group in catalog['groups'] if group['code'] == 'review_required')}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user