Compare commits

...

44 Commits

Author SHA1 Message Date
81b085dd9d Record catalog normalization in changelog 2026-09-15 01:04:22 +03:00
b6ad392ab7 Handle unmapped source catalog values 2026-09-15 00:54:09 +03:00
bb5edf3ae2 Merge pull request 'feat(search): add legal force filter and topic taxonomy draft' (#31) from feature/search-catalog-v1 into main
Reviewed-on: #31
2026-09-14 07:56:14 +00:00
000cf467df docs: clarify search catalog changelog 2026-09-14 10:37:58 +03:00
f8f98dfe46 fix(search): validate topic hierarchy input 2026-09-14 10:36:08 +03:00
5f44981bd3 fix(search): review ambiguous topic assignments 2026-09-14 10:31:45 +03:00
a1b4787930 feat(search): add legal force catalog and taxonomy draft 2026-09-14 10:25:38 +03:00
b412315ac6 Merge pull request 'docs: align relevance review with internal search' (#30) from docs/relevance-legal-review into main
Reviewed-on: #30
2026-09-12 06:23:02 +00:00
7e07f3ab8d docs: record internal search review workflow 2026-09-12 08:54:32 +03:00
70c70fb7dd docs: clarify relevance set validation workflow 2026-09-12 08:52:55 +03:00
e18d477852 docs: align relevance review with internal search 2026-09-12 08:49:38 +03:00
062aaa0074 docs: remove stale review example reference 2026-09-06 23:41:56 +03:00
470745fce3 docs: record project status and next steps 2026-09-06 23:37:40 +03:00
b2274fee6f Merge pull request 'fix: harden production image builds' (#29) from fix/production-build-safety into main
Reviewed-on: #29
2026-08-28 20:09:01 +00:00
abd244fefb fix: include search index mapping in builds 2026-08-28 22:51:12 +03:00
8302c6b782 fix: harden production image builds 2026-08-28 08:59:57 +03:00
bbf8e7a9c8 Merge pull request 'feat: add production deployment baseline' (#28) from feature/production-deployment-baseline into main
Reviewed-on: #28
2026-08-28 05:58:55 +00:00
bf0b6ff070 feat: add production deployment baseline 2026-08-28 08:56:12 +03:00
b91bc98611 Merge pull request 'feat: persist search review drafts' (#27) from feature/search-review-drafts into main
Reviewed-on: #27
2026-08-28 05:13:08 +00:00
4210c2493d fix: persist all review fields 2026-08-28 07:31:28 +03:00
711c9e525e feat: persist search review drafts 2026-08-28 07:29:58 +03:00
808ac6c792 Merge pull request 'feat: add search relevance review lab' (#26) from feature/search-review-lab into main
Reviewed-on: #26
2026-08-28 04:22:46 +00:00
73eb938c03 docs: record search review lab 2026-08-27 08:28:08 +03:00
cccae446ae fix: version search ranking snapshots 2026-08-27 08:27:23 +03:00
24b3a2d422 fix: make review snapshots reproducible 2026-08-27 08:26:25 +03:00
f472a17897 feat: add search relevance review lab 2026-08-27 08:21:59 +03:00
182b87fff2 Merge pull request 'docs: define accessible review interface' (#25) from docs/search-review-accessibility into main
Reviewed-on: #25
2026-08-26 21:11:34 +00:00
407dd2dc09 docs: record accessible review interface 2026-08-27 00:02:29 +03:00
a594aec912 fix: clarify review interface accessibility 2026-08-27 00:01:34 +03:00
c5f651bdae docs: define accessible review interface 2026-08-26 23:59:43 +03:00
f4cd453088 Merge pull request 'docs: plan search review interface' (#24) from docs/search-review-interface-plan into main
Reviewed-on: #24
2026-08-26 20:31:48 +00:00
57e4645a11 docs: record search review plan 2026-08-26 23:29:30 +03:00
9b28616cf4 docs: secure review interface plan 2026-08-26 23:28:43 +03:00
e9da5a6ccc docs: plan search review interface 2026-08-26 23:27:14 +03:00
cb58e91d6b docs: record legal review guide 2026-08-26 01:06:57 +03:00
39896c74de fix: remove placeholder link from legal guide 2026-08-26 01:06:02 +03:00
f3f564ab08 docs: add legal review guide 2026-08-26 01:03:34 +03:00
ccbb2f0944 fix: rebuild legal review workbooks 2026-08-26 00:39:14 +03:00
d468c869c0 docs: describe legal review workbooks 2026-08-26 00:31:28 +03:00
47e03bbc4d fix: neutralize legal review example 2026-08-26 00:30:27 +03:00
eafc06f0bc docs: add language-specific legal review workbooks 2026-08-26 00:28:34 +03:00
9dcbcad9aa docs: record legal review form 2026-08-25 13:37:15 +03:00
028062bd14 docs: add legal relevance review form 2026-08-25 13:35:41 +03:00
bb69ea54b7 Merge pull request 'feat: add versioned search API contract' (#17) from feature/search-api-contract into main
Reviewed-on: #17
2026-08-25 05:35:54 +00:00
30 changed files with 22982 additions and 62 deletions

16
.dockerignore Normal file
View File

@@ -0,0 +1,16 @@
.git
.gitignore
.env
.env.*
*.json
!backend/
!backend/search/
!backend/search/*.json
*.xlsx
*.pdf
*.jpg
data/
docs/
tools/
__pycache__/
*.pyc

View File

@@ -2,8 +2,28 @@
## Не выпущено ## Не выпущено
- Backend обновлён до `0.9.1`: все статусы из нормализованного корпуса доступны
как отдельные фильтры, варианты конституционного закона и указа сопоставляются
с подтверждённой юридической силой, неизвестные типы документов сохраняются
под общим фильтром без вывода юридической силы.
- Уточнено, что внутренняя лаборатория оценивает выдачу собственного OpenSearch;
ЦБД Минюста служит источником для подтверждения документов, а прежние Excel/PDF
бланки удалены.
- Исключены секреты и локальные данные из Docker build context; синхронизирована версия интерфейса.
- Добавлен production deployment baseline для Search API и OpenSearch с
постоянными хранилищами и healthcheck.
- Добавлено восстановление незавершённой разметки из `localStorage` после перезагрузки страницы.
- Добавлена внутренняя лаборатория проверки поисковой выдачи: просмотр документов,
оценка релевантности 03, комментарии и SQLite-экспорт подписанных снимков.
- Уточнены доступные состояния и адаптивное поведение внутреннего интерфейса
оценки поисковой выдачи.
- Добавлен план внутреннего интерфейса оценки поисковой выдачи юристами.
- Завершён Search API v1: стабильные справочники, валидный OpenAPI, безопасная - Завершён Search API v1: стабильные справочники, валидный OpenAPI, безопасная
пагинация и проверка актуальных редакций в локальном OpenSearch. пагинация и проверка актуальных редакций в локальном OpenSearch.
- Добавлен фильтр Search API v1 по укрупнённой юридической силе. Полная
иерархия GeneralClassifiers экспортирована в отдельный черновой каталог;
тематическая навигация остаётся вне API v1 до ручного утверждения рубрик и
подписей.
- Добавлено безопасное переключение alias на новую версию поискового индекса - Добавлено безопасное переключение alias на новую версию поискового индекса
после полной загрузки; checkpoint защищает возобновление загрузки от смены после полной загрузки; checkpoint защищает возобновление загрузки от смены
alias. alias.

View File

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

View File

@@ -1,6 +1,6 @@
# Backend Акылдаш # Backend Акылдаш
Версия: `0.7.1` Версия: `0.9.1`
Первая backend-область проекта — загрузка правовых документов из официального Первая backend-область проекта — загрузка правовых документов из официального
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
@@ -216,6 +216,21 @@ PYTHONPATH=backend python3 -m search.evaluate_relevance \
Менять веса или анализаторы следует только после фиксации этого baseline и Менять веса или анализаторы следует только после фиксации этого baseline и
разбора ошибок выдачи. разбора ошибок выдачи.
## Внутренняя лаборатория релевантности
Запустите Search API на localhost и откройте `http://127.0.0.1:8080/review`:
```bash
PYTHONPATH=backend python3 -m search.api \\
--reviews-db data/search-reviews.sqlite3
```
Лаборатория показывает фактический порядок выдачи OpenSearch, позволяет открыть
текст редакции, поставить результату оценку от 0 до 3 и сохранить снимок с
комментариями. Оценки сохраняются в SQLite, экспорт доступен через
`GET /search-reviews/export`. Интерфейс предназначен только для локальной сети
или защищённого reverse proxy; не публикуйте его напрямую в интернет.
Для проверки текущей выдачи без будущего HTTP API используйте CLI: Для проверки текущей выдачи без будущего HTTP API используйте CLI:
```bash ```bash
@@ -225,4 +240,4 @@ PYTHONPATH=backend python3 -m search.query "ЖЧК ачуу тартиби" --la
--- ---
Акылдаш · Backend v0.7.1 · Frontend — не создан Акылдаш · Backend v0.9.1 · Frontend — не создан

View File

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

View File

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

View File

@@ -56,17 +56,19 @@ cp backend/search/relevance-set-v1.template.json data/search/relevance-set-v1.js
## Разметка одного запроса ## Разметка одного запроса
1. До просмотра выдачи зафиксируйте информационную потребность и формулировку 1. Зафиксируйте исходную формулировку `query` и информационную потребность до
`query`. оценки выдачи нашего поиска.
2. Найдите кандидатов в локальном OpenSearch и в официальной ЦБД Минюста. 2. Юрист устанавливает релевантные акты по содержанию и реквизитам документов.
Проверьте исходный запрос, его короткий вариант и вариант с юридическим Используйте ЦБД Минюста как авторитетный источник для проверки текста,
термином или известным номером акта. статуса и редакции акта. Порядок выдачи ЦБД не является объектом оценки и не
3. Просмотрите не только заголовки, но и текст, статус, дату и редакцию каждого переносится в эталон.
кандидата. 3. Запишите уникальные `document_code` всех документов, которые прямо отвечают
4. Запишите уникальные `document_code` всех документов, удовлетворяющих на запрос. Спорные документы передайте на независимую проверку.
критерию релевантности. 4. Зафиксируйте согласованный набор до просмотра результатов OpenSearch и
5. Повторите поиск по ключевым терминам найденных документов, чтобы обнаружить сохраните его отдельно от рабочих данных.
пропущенные альтернативные акты. 5. Проверьте запрос во внутренней лаборатории (`/review`): оцените фактические
результаты OpenSearch в исходном порядке, сохраните снимок и комментарии.
Лаборатория проверяет качество нашей поисковой системы, а не ЦБД Минюста.
Пример структуры (код условный): Пример структуры (код условный):

View File

@@ -12,9 +12,11 @@ from pathlib import Path
from search.minjust_opensearch import APP_VERSION, request_json from search.minjust_opensearch import APP_VERSION, request_json
from search.catalog import CATALOGS, labels from search.catalog import CATALOGS, labels
from search.reviews import ReviewSnapshots, ReviewStore
API_VERSION = "v1" API_VERSION = "v1"
SEARCH_ALGORITHM_VERSION = "search-1"
LANGUAGES = {"ru", "ky"} LANGUAGES = {"ru", "ky"}
CODE = re.compile(r"^[0-9]+$") CODE = re.compile(r"^[0-9]+$")
MAX_PAGE_SIZE = 100 MAX_PAGE_SIZE = 100
@@ -70,11 +72,15 @@ def openapi() -> dict:
{"name": "document_type", "in": "query", "schema": {"type": "string"}}, {"name": "document_type", "in": "query", "schema": {"type": "string"}},
{"name": "status", "in": "query", "schema": {"type": "string"}}, {"name": "status", "in": "query", "schema": {"type": "string"}},
{"name": "authority", "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_from", "in": "query", "schema": {"type": "string", "format": "date"}},
{"name": "date_to", "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"]}}, {"name": "sort", "in": "query", "schema": {"type": "string", "enum": ["relevance", "date"]}},
]}}, ]}},
"/search/filters": {"get": {"responses": responses}}, "/search/filters": {"get": {"responses": responses}},
"/search-reviews": {"post": {"responses": {"201": {"description": "Review saved"}, "400": {"description": "Invalid review"}}}},
"/search-reviews/export": {"get": {"responses": responses}},
"/review": {"get": {"responses": {"200": {"description": "Review interface"}}}},
"/documents/{code}": {"get": {"responses": responses, "parameters": [{"name": "code", "in": "path", "required": True, "schema": {"type": "string", "pattern": "^[0-9]+$"}}]}}, "/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": {"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]+$"}}]}}, "/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]+$"}}]}},
@@ -83,10 +89,12 @@ def openapi() -> dict:
class Api: class Api:
def __init__(self, base_url: str, index: str, data_root: Path): def __init__(self, base_url: str, index: str, data_root: Path, reviews_db: Path | str = ":memory:", review_secret: bytes | None = None):
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")
self.index = index self.index = index
self.data_root = data_root self.data_root = data_root
self.review_store = ReviewStore(reviews_db)
self.review_snapshots = ReviewSnapshots(review_secret)
def search_url(self, suffix: str) -> str: def search_url(self, suffix: str) -> str:
return f"{self.base_url}/{urllib.parse.quote(self.index, safe='')}/{suffix}" return f"{self.base_url}/{urllib.parse.quote(self.index, safe='')}/{suffix}"
@@ -114,7 +122,7 @@ class Api:
if sort not in {"relevance", "date"}: if sort not in {"relevance", "date"}:
raise ApiError(400, "sort must be relevance or date") raise ApiError(400, "sort must be relevance or date")
filters: list[dict] = [{"term": {"language": language}}, {"term": {"is_current_edition": True}}] 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(): for parameter, field in fields.items():
value = one(query, parameter) value = one(query, parameter)
if value: if value:
@@ -130,7 +138,7 @@ class Api:
body = { body = {
"from": (page - 1) * page_size, "from": (page - 1) * page_size,
"size": page_size + 1, "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"}}}}, "query": {"bool": {"filter": filters, "must": {"multi_match": {"query": text, "fields": [f"document_name_{language}", f"text_{language}"], "type": "cross_fields"}}}},
"collapse": {"field": "document_code"}, "collapse": {"field": "document_code"},
"highlight": {"fields": {f"text_{language}": {"number_of_fragments": 1}}}, "highlight": {"fields": {f"text_{language}": {"number_of_fragments": 1}}},
@@ -142,7 +150,11 @@ class Api:
hits = response["hits"]["hits"] hits = response["hits"]["hits"]
except (KeyError, TypeError) as error: except (KeyError, TypeError) as error:
raise ApiError(502, "search backend returned an incomplete response") from 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]]} results = [self.search_hit(hit, language) for hit in hits[:page_size]]
snapshot_results = [{"rank": rank, **result} for rank, result in enumerate(results, 1)]
concrete_indexes = {hit.get("_index") for hit in hits[:page_size] if hit.get("_index")}
index_name = next(iter(concrete_indexes)) if len(concrete_indexes) == 1 else self.index
return {"api_version": API_VERSION, "query": text, "language": language, "page": page, "page_size": page_size, "has_next": len(hits) > page_size, "results": results, "review_token": self.review_snapshots.create(text, language, index_name, snapshot_results, SEARCH_ALGORITHM_VERSION)}
@staticmethod @staticmethod
def search_hit(hit: dict, language: str) -> dict: def search_hit(hit: dict, language: str) -> dict:
@@ -150,13 +162,14 @@ class Api:
if not isinstance(source, dict) or not source.get("document_code"): if not isinstance(source, dict) or not source.get("document_code"):
raise ApiError(502, "search backend returned an incomplete result") raise ApiError(502, "search backend returned an incomplete result")
highlight = hit.get("highlight", {}).get(f"text_{language}", []) 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: def filters(self, query: dict[str, list[str]]) -> dict:
language = one(query, "language") or "ru" language = one(query, "language") or "ru"
if language not in LANGUAGES: if language not in LANGUAGES:
raise ApiError(400, "language must be ru or ky") 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()}} 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) response = self.query_opensearch(body)
try: try:
@@ -221,12 +234,62 @@ class Api:
raise ApiError(404, "edition language not found") raise ApiError(404, "edition language not found")
return {"api_version": API_VERSION, "edition": metadata, "content": content} return {"api_version": API_VERSION, "edition": metadata, "content": content}
def handle(self, method: str, path: str) -> tuple[int, dict]: def save_review(self, body: dict) -> dict:
if method != "GET": if not isinstance(body, dict):
raise ApiError(405, "method not allowed") raise ApiError(400, "request body must be an object")
try:
snapshot = self.review_snapshots.verify(body["review_token"])
reviewer = body["reviewer"].strip()
overall_comment = body.get("overall_comment", "").strip()
submitted = body["results"]
except (KeyError, AttributeError, TypeError, ValueError) as error:
raise ApiError(400, "review_token, reviewer and results are required") from error
if not reviewer or len(reviewer) > 120:
raise ApiError(400, "reviewer must be between 1 and 120 characters")
if len(overall_comment) > 4000:
raise ApiError(400, "overall_comment is too long")
if not isinstance(submitted, list):
raise ApiError(400, "results must be an array")
by_rank = {item["rank"]: item for item in snapshot["results"]}
if len(submitted) != len(by_rank) or {item.get("rank") for item in submitted if isinstance(item, dict)} != set(by_rank):
raise ApiError(400, "all search results must be reviewed exactly once")
results = []
for item in submitted:
if not isinstance(item, dict) or not isinstance(item.get("rank"), int) or item["rank"] not in by_rank:
raise ApiError(400, "review result rank is invalid")
source = by_rank[item["rank"]]
if item.get("code") != source["code"]:
raise ApiError(400, "review result document does not match the search snapshot")
rating = item.get("rating")
if rating is not None and (isinstance(rating, bool) or not isinstance(rating, int) or not 0 <= rating <= 3):
raise ApiError(400, "rating must be an integer from 0 to 3")
comment = item.get("comment", "")
if not isinstance(comment, str) or len(comment) > 4000:
raise ApiError(400, "result comment is too long")
results.append({**source, "rating": rating, "comment": comment.strip()})
if not results:
raise ApiError(400, "at least one result must be reviewed")
review = {"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), "reviewer": reviewer, "query": snapshot["query"], "language": snapshot["language"], "index_name": snapshot["index_name"], "algorithm_version": snapshot["algorithm_version"], "top_result_code": snapshot["results"][0]["code"] if snapshot["results"] else None, "results": results, "overall_comment": overall_comment}
review_id = self.review_store.save(review)
return {"api_version": API_VERSION, "id": review_id, "created_at": review["created_at"]}
@staticmethod
def review_page() -> str:
try:
return Path(__file__).with_name("review.html").read_text(encoding="utf-8")
except (OSError, UnicodeError) as error:
raise ApiError(500, "review interface is unavailable") from error
def handle(self, method: str, path: str, body: dict | None = None) -> tuple[int, dict]:
parsed = urllib.parse.urlsplit(path) parsed = urllib.parse.urlsplit(path)
query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True) query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
parts = [urllib.parse.unquote(part) for part in parsed.path.split("/") if part] parts = [urllib.parse.unquote(part) for part in parsed.path.split("/") if part]
if method == "POST" and parts == ["search-reviews"]:
return 201, self.save_review(body)
if method == "GET" and parts == ["search-reviews", "export"]:
return 200, {"api_version": API_VERSION, "reviews": self.review_store.export()}
if method != "GET":
raise ApiError(405, "method not allowed")
if parts == ["openapi.json"]: if parts == ["openapi.json"]:
return 200, openapi() return 200, openapi()
if parts == ["search"]: if parts == ["search"]:
@@ -246,7 +309,23 @@ def handler(api: Api):
class RequestHandler(BaseHTTPRequestHandler): class RequestHandler(BaseHTTPRequestHandler):
def respond(self, method: str): def respond(self, method: str):
try: try:
status, payload = api.handle(method, self.path) if method == "GET" and urllib.parse.urlsplit(self.path).path == "/review":
body = api.review_page().encode()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
body = None
if method == "POST":
length = int(self.headers.get("Content-Length", "0"))
if length > 1_000_000:
raise ApiError(413, "request body is too large")
body = json.loads(self.rfile.read(length) or b"{}")
status, payload = api.handle(method, self.path, body)
except json.JSONDecodeError:
status, payload = 400, {"api_version": API_VERSION, "error": "request body must be valid JSON"}
except ApiError as error: except ApiError as error:
status, payload = error.status, {"api_version": API_VERSION, "error": error.message} status, payload = error.status, {"api_version": API_VERSION, "error": error.message}
body = json.dumps(payload, ensure_ascii=False).encode() body = json.dumps(payload, ensure_ascii=False).encode()
@@ -273,11 +352,14 @@ def main() -> int:
parser.add_argument("--url", default="http://127.0.0.1:9200") parser.add_argument("--url", default="http://127.0.0.1:9200")
parser.add_argument("--index", default="akyldash-fragments-current") parser.add_argument("--index", default="akyldash-fragments-current")
parser.add_argument("--data", type=Path, default=Path("data/minjust-normalized")) parser.add_argument("--data", type=Path, default=Path("data/minjust-normalized"))
parser.add_argument("--reviews-db", type=Path, default=Path("data/search-reviews.sqlite3"))
parser.add_argument("--review-secret", default=None)
parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8080) parser.add_argument("--port", type=int, default=8080)
parser.add_argument("--version", action="version", version=APP_VERSION) parser.add_argument("--version", action="version", version=APP_VERSION)
arguments = parser.parse_args() arguments = parser.parse_args()
ThreadingHTTPServer((arguments.host, arguments.port), handler(Api(arguments.url, arguments.index, arguments.data))).serve_forever() secret = arguments.review_secret.encode() if arguments.review_secret else None
ThreadingHTTPServer((arguments.host, arguments.port), handler(Api(arguments.url, arguments.index, arguments.data, arguments.reviews_db, secret))).serve_forever()
return 0 return 0

View File

@@ -1,11 +1,30 @@
"""Immutable v1 search catalogs.""" """Immutable v1 search catalogs."""
DOCUMENT_TYPES = { 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": ("Не указан", "Көрсөтүлгөн эмес"), "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": ("Основные принципы", "Основные принципы"), "other": ("Прочие документы", "Башка документтер"), "unspecified": ("Не указан", "Көрсөтүлгөн эмес"),
} }
STATUSES = {"active": ("Действует", "Күчүндө"), "repealed": ("Утратил силу", "Күчүн жоготту"), "unspecified": ("Не указан", "Көрсөтүлгөн эмес")} STATUSES = {"active": ("Действует", "Күчүндө"), "repealed": ("Утратил силу", "Күчүн жоготту"), "not_yet_effective": ("Не вступил в силу", "Күчүнө кире элек"), "canceled": ("Отменено", "Жокко чыгарылды"), "inactive": ("Не действует", "Күчүндө эмес же Колдонулбайт"), "terminated": ("Прекратило действие", "Күчүн токтотту же Колдонуу токтотулган"), "suspended": ("Действие приостановлено", "Күчүнө кирүүсү токтотулган"), "unspecified": ("Не указан", "Көрсөтүлгөн эмес")}
AUTHORITIES = {"president": ("Президент", "Президент"), "parliament": ("Органы законодательной власти", "Мыйзам чыгаруу бийлик органдары"), "cabinet": ("Правительство и Кабинет Министров", "Өкмөт жана Министрлер Кабинети"), "ministries_and_committees": ("Министерства и государственные комитеты", "Министрликтер жана мамлекеттик комитеттер"), "administrative_agencies": ("Административные ведомства", "Административдик ведомстволор"), "national_bank": ("Национальный банк", "Улуттук банк"), "other_state_bodies": ("Иные государственные органы", "Башка мамлекеттик органдар"), "local_representative_bodies": ("Представительные органы местного самоуправления", "Жергиликтүү өз алдынча башкаруунун өкүлчүлүктүү органдары"), "other": ("Прочие органы", "Башка органдар")} 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}
DOCUMENT_TYPE_ALIASES = {
("Положение", "Положение"): "provision",
("Конституционный закон", "Конституционный закон"): "constitutional_law",
("Указ", "Жарлыгы"): "decree",
("устав", None): "charter",
}
def labels(category: str, code: str) -> dict[str, str]: def labels(category: str, code: str) -> dict[str, str]:
@@ -20,9 +39,14 @@ def source_code(category: str, value: dict | None) -> str:
pair = ((value or {}).get("ru"), (value or {}).get("ky")) pair = ((value or {}).get("ru"), (value or {}).get("ky"))
if pair == (None, None): if pair == (None, None):
return "unspecified" return "unspecified"
if category == "document_type" and pair in DOCUMENT_TYPE_ALIASES:
return DOCUMENT_TYPE_ALIASES[pair]
for code, expected in CATALOGS[category].items(): for code, expected in CATALOGS[category].items():
if pair == expected or category == "document_type" and code == "provision" and pair == ("Положение", "Положение"): if pair == expected:
return code return code
# ponytail: uncurated source types share one filter; add reviewed codes when separate filtering is needed.
if category == "document_type":
return "other"
raise ValueError(f"Unmapped {category} catalog value: {pair!r}") raise ValueError(f"Unmapped {category} catalog value: {pair!r}")
@@ -49,3 +73,7 @@ def authority_codes(paths: list[dict]) -> list[str]:
else: else:
codes.add("other") codes.add("other")
return sorted(codes) or ["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))

View File

@@ -23,6 +23,7 @@
"document_type_ru": { "type": "keyword" }, "document_type_ru": { "type": "keyword" },
"document_type_ky": { "type": "keyword" }, "document_type_ky": { "type": "keyword" },
"document_type_code": { "type": "keyword" }, "document_type_code": { "type": "keyword" },
"legal_force_code": { "type": "keyword" },
"status_ru": { "type": "keyword" }, "status_ru": { "type": "keyword" },
"status_ky": { "type": "keyword" }, "status_ky": { "type": "keyword" },
"status_code": { "type": "keyword" }, "status_code": { "type": "keyword" },

View File

@@ -15,9 +15,9 @@ import urllib.request
from pathlib import Path from pathlib import Path
from typing import Iterator 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.7.1" APP_VERSION = "0.9.1"
LANGUAGES = {"ru", "ky"} LANGUAGES = {"ru", "ky"}
DEFAULT_MAPPING = Path(__file__).with_name("minjust-fragments-index.json") 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_ru": localized(document.get("type"), "ru"),
"document_type_ky": localized(document.get("type"), "ky"), "document_type_ky": localized(document.get("type"), "ky"),
"document_type_code": source_code("document_type", document.get("type")), "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_ru": localized(document.get("status"), "ru"),
"status_ky": localized(document.get("status"), "ky"), "status_ky": localized(document.get("status"), "ky"),
"status_code": source_code("status", document.get("status")), "status_code": source_code("status", document.get("status")),

114
backend/search/review.html Normal file
View File

@@ -0,0 +1,114 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Оценка поисковой выдачи · Акылдаш</title>
<style>
:root { color-scheme: light; font: 16px/1.5 system-ui, sans-serif; color: #17202a; background: #f5f7fa; }
* { box-sizing: border-box; }
body { margin: 0; }
.review__skip { position: absolute; inset-block-start: 0; inset-inline-start: -10000px; padding: .5rem 1rem; background: #fff; }
.review__skip:focus { inset-inline-start: 1rem; z-index: 2; }
.review__header, .review__main { max-width: 1440px; margin: auto; padding-inline: 1rem; }
.review__header { padding-block: 1.5rem 1rem; }
.review__main { padding-block-end: 8rem; }
.review__search { display: flex; flex-wrap: wrap; align-items: end; gap: .75rem; padding: 1rem; background: #fff; border: 1px solid #d9e0e7; border-radius: .75rem; }
.review__field { display: grid; gap: .25rem; min-width: 12rem; flex: 1; }
.review__field--small { flex: 0 0 7rem; min-width: 7rem; }
input, select, textarea, button { font: inherit; }
input, select, textarea { border: 1px solid #8d9aaa; border-radius: .4rem; padding: .6rem .7rem; background: #fff; }
input:focus-visible, select:focus-visible, textarea:focus-visible, button:focus-visible { outline: 3px solid #1769aa; outline-offset: 2px; }
button { cursor: pointer; border: 1px solid #536273; border-radius: .4rem; padding: .6rem .9rem; background: #fff; color: #17202a; }
button:hover { background: #edf3f8; }
.review__button--primary { background: #145a86; color: #fff; border-color: #145a86; }
.review__button--primary:hover { background: #0e4669; }
.review__status { min-height: 1.7rem; margin-block: .75rem; }
.review__status--error { color: #9b1c1c; }
.review__workspace { display: grid; grid-template-columns: minmax(22rem, 1fr) minmax(24rem, 1.1fr); gap: 1rem; align-items: start; }
.review__results, .review__document { background: #fff; border: 1px solid #d9e0e7; border-radius: .75rem; padding: 1rem; }
.review__results-list { display: grid; gap: 1rem; margin: 0; padding: 0; list-style: none; }
.review__result { border-block-start: 1px solid #d9e0e7; padding-block-start: 1rem; }
.review__result:first-child { border-block-start: 0; padding-block-start: 0; }
.review__result-title { display: flex; gap: .5rem; align-items: baseline; width: 100%; text-align: start; font-weight: 700; border: 0; padding: 0; color: #124f78; }
.review__rank { flex: 0 0 auto; color: #526272; font-variant-numeric: tabular-nums; }
.review__meta, .review__snippet { margin-block: .35rem; color: #526272; }
.review__snippet { overflow-wrap: anywhere; }
.review__rating { display: flex; flex-wrap: wrap; gap: .45rem; margin-block: .65rem; padding: 0; border: 0; }
.review__rating legend { width: 100%; font-weight: 600; }
.review__rating label { min-width: 3.5rem; text-align: center; }
.review__rating input { accent-color: #145a86; }
.review__comment { width: 100%; min-height: 4rem; resize: vertical; }
.review__document { position: sticky; inset-block-start: 1rem; min-height: 20rem; }
.review__document-body { max-width: 75ch; overflow-wrap: anywhere; }
.review__document-body img { max-width: 100%; height: auto; }
.review__actions { position: fixed; inset-block-end: 0; inset-inline: 0; padding: .75rem 1rem; background: rgb(255 255 255 / .96); border-block-start: 1px solid #d9e0e7; text-align: end; }
dialog { max-width: min(56rem, calc(100% - 2rem)); max-height: calc(100% - 2rem); border: 1px solid #8d9aaa; border-radius: .75rem; padding: 1rem; }
dialog::backdrop { background: rgb(10 20 30 / .5); }
.review__dialog-close { float: inline-end; }
@media (max-width: 800px) {
.review__workspace { grid-template-columns: 1fr; }
.review__document { display: none; }
.review__search { align-items: stretch; }
.review__field, .review__field--small { flex-basis: 100%; }
.review__search button { width: 100%; }
}
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } }
</style>
</head>
<body>
<a class="review__skip" href="#results">Перейти к результатам</a>
<header class="review__header"><h1>Оценка поисковой выдачи</h1><p>Проверяйте результаты нашего OpenSearch по практическим юридическим запросам.</p></header>
<main class="review__main" id="review">
<form class="review__search" id="search-form">
<label class="review__field">Запрос<input id="query" name="q" maxlength="500" required autocomplete="off"></label>
<label class="review__field review__field--small">Язык<select id="language" name="language"><option value="ru">Русский</option><option value="ky">Кыргызский</option></select></label>
<label class="review__field review__field--small">Результатов<select id="page-size" name="page_size"><option>10</option><option>20</option></select></label>
<button class="review__button--primary" type="submit">Найти</button>
</form>
<div class="review__status" id="status" role="status" aria-live="polite"></div>
<div class="review__workspace">
<section class="review__results" aria-labelledby="results-heading"><h2 id="results-heading">Результаты</h2><ol class="review__results-list" id="results"></ol></section>
<section class="review__document" aria-labelledby="document-heading"><h2 id="document-heading">Документ</h2><div id="document-meta">Выберите результат, чтобы открыть текст.</div><article class="review__document-body" id="document-body"></article></section>
</div>
</main>
<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.9.1 · Внутренняя лаборатория релевантности</footer>
<script>
const DRAFT_KEY = 'akyldash-search-review-draft';
const state = { results: [], token: '', selected: null, query: '' };
const $ = (id) => document.getElementById(id);
const setStatus = (text) => { $('status').textContent = text; $('error').textContent = ''; };
const setError = (text) => { $('error').textContent = text; };
const escapeText = (value) => value == null ? '' : String(value);
const readDraft = () => { try { return JSON.parse(localStorage.getItem(DRAFT_KEY) || 'null'); } catch (_) { return null; } };
const saveDraft = () => { if (!state.token) return; const results = [...$('results').children].map((item, index) => ({rank: index + 1, code: item.dataset.code, rating: item.querySelector('input:checked')?.value ?? null, comment: item.querySelector('textarea').value})); try { localStorage.setItem(DRAFT_KEY, JSON.stringify({query: state.query, language: $('language').value, pageSize: $('page-size').value, reviewer: $('reviewer').value, overallComment: $('overall-comment').value, results})); } catch (_) {} };
const applyDraft = () => { const draft = readDraft(); if (!draft || draft.query !== state.query || draft.language !== $('language').value || draft.pageSize !== $('page-size').value) return; $('reviewer').value = draft.reviewer || ''; $('overall-comment').value = draft.overallComment || ''; (draft.results || []).forEach((saved) => { const item = [...$('results').children].find((candidate) => candidate.dataset.rank === String(saved.rank) && candidate.dataset.code === saved.code); if (!item) return; if (saved.rating != null) { const input = item.querySelector(`input[value="${CSS.escape(String(saved.rating))}"]`); if (input) input.checked = true; } item.querySelector('textarea').value = saved.comment || ''; }); };
function renderResults() {
$('results').replaceChildren();
state.results.forEach((result, index) => {
const item = document.createElement('li'); item.className = 'review__result'; item.dataset.rank = index + 1; item.dataset.code = result.code;
const title = document.createElement('button'); title.type = 'button'; title.className = 'review__result-title'; title.innerHTML = `<span class="review__rank">#${index + 1}</span><span></span>`; title.lastElementChild.textContent = escapeText(result.name || 'Название не указано'); title.addEventListener('click', () => openDocument(index, title));
const meta = document.createElement('div'); meta.className = 'review__meta'; meta.textContent = [result.type, result.status, result.date_adopted, result.number].filter(Boolean).join(' · ');
const snippet = document.createElement('div'); snippet.className = 'review__snippet'; snippet.textContent = result.snippet || 'Фрагмент не найден.';
const rating = document.createElement('fieldset'); rating.className = 'review__rating'; rating.innerHTML = `<legend>Оценка результата</legend>`;
[['0', 'нерелевантен'], ['1', 'косвенно полезен'], ['2', 'частично полезен'], ['3', 'прямо отвечает']].forEach(([value, label]) => { const id = `rating-${index}-${value}`; const wrapper = document.createElement('label'); wrapper.htmlFor = id; wrapper.textContent = `${value}${label}`; const input = document.createElement('input'); input.type = 'radio'; input.name = `rating-${index}`; input.id = id; input.value = value; wrapper.prepend(input); rating.append(wrapper); });
const comment = document.createElement('textarea'); comment.className = 'review__comment'; comment.maxLength = 4000; comment.placeholder = 'Комментарий к результату (необязательно)'; comment.setAttribute('aria-label', `Комментарий к результату #${index + 1}`);
item.append(title, meta, snippet, rating, comment); $('results').append(item);
});
applyDraft();
}
async function openDocument(index, trigger) {
const result = state.results[index]; state.selected = trigger; setStatus('Загрузка документа…');
try { const response = await fetch(`/documents/${encodeURIComponent(result.code)}/editions/${encodeURIComponent(result.edition)}?language=${encodeURIComponent($('language').value)}`); if (!response.ok) throw new Error('Документ недоступен'); const payload = await response.json(); const content = payload.content[$('language').value]; const meta = $('document-meta'); meta.textContent = [result.name, result.status, result.date_adopted].filter(Boolean).join(' · '); const source = document.createElement('a'); source.href = `https://cbd.minjust.gov.kg/${encodeURIComponent(result.code)}/edition/${encodeURIComponent(result.edition)}/${encodeURIComponent($('language').value)}`; source.target = '_blank'; source.rel = 'noreferrer'; source.textContent = ' Официальный источник'; meta.append(source); $('document-body').innerHTML = content.html; $('dialog-heading').textContent = result.name || 'Документ'; $('dialog-body').innerHTML = content.html; if (matchMedia('(max-width: 800px)').matches) $('document-dialog').showModal(); setStatus('Документ загружен.'); } catch (error) { setError('Не удалось загрузить документ. Повторите попытку.'); }
}
$('dialog-close').addEventListener('click', () => { $('document-dialog').close(); if (state.selected) state.selected.focus(); });
$('search-form').addEventListener('submit', async (event) => { event.preventDefault(); const query = $('query').value.trim(); if (!query) return; state.query = query; setStatus('Поиск выполняется…'); $('save').disabled = true; try { const params = new URLSearchParams({q: query, language: $('language').value, page_size: $('page-size').value}); const response = await fetch(`/search?${params}`); if (!response.ok) throw new Error(); const payload = await response.json(); state.results = payload.results; state.token = payload.review_token; renderResults(); setStatus(state.results.length ? `Найдено результатов: ${state.results.length}.` : `По запросу «${query}» ничего не найдено. Измените запрос.`); } catch (error) { state.results = []; state.token = ''; renderResults(); setError('Не удалось выполнить поиск. Повторите поиск.'); } finally { $('save').disabled = false; } });
document.addEventListener('input', saveDraft); document.addEventListener('change', saveDraft);
$('save').addEventListener('click', async () => { if (!state.token) { setError('Сначала выполните поиск.'); return; } const reviewer = $('reviewer').value.trim(); if (!reviewer) { $('reviewer').focus(); setError('Укажите проверяющего.'); return; } const results = [...$('results').children].map((item, index) => ({rank: index + 1, code: item.dataset.code, rating: item.querySelector('input:checked') ? Number(item.querySelector('input:checked').value) : null, comment: item.querySelector('textarea').value})); $('save').disabled = true; setStatus('Сохранение выполняется…'); try { const response = await fetch('/search-reviews', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({review_token: state.token, reviewer, overall_comment: $('overall-comment').value, results})}); if (!response.ok) throw new Error(); const payload = await response.json(); localStorage.removeItem(DRAFT_KEY); setStatus(`Оценка сохранена · № ${payload.id}.`); } catch (error) { setError('Не удалось сохранить. Проверьте подключение и повторите.'); } finally { $('save').disabled = false; } });
const draft = readDraft(); if (draft) { $('query').value = draft.query || ''; $('language').value = draft.language || 'ru'; $('page-size').value = draft.pageSize || '20'; $('reviewer').value = draft.reviewer || ''; $('overall-comment').value = draft.overallComment || ''; }
</script>
</body>
</html>

83
backend/search/reviews.py Normal file
View File

@@ -0,0 +1,83 @@
"""Persistence and signed snapshots for search relevance reviews."""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import secrets
import sqlite3
import threading
import time
from pathlib import Path
MAX_COMMENT = 4000
MAX_REVIEWER = 120
class ReviewStore:
def __init__(self, path: Path | str = ":memory:"):
if path != ":memory:":
Path(path).parent.mkdir(parents=True, exist_ok=True)
self.connection = sqlite3.connect(path, check_same_thread=False)
self.connection.row_factory = sqlite3.Row
# ponytail: one SQLite lock; split connections only if review throughput matters.
self._lock = threading.Lock()
self.connection.execute("""
CREATE TABLE IF NOT EXISTS search_reviews (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
reviewer TEXT NOT NULL,
query TEXT NOT NULL,
language TEXT NOT NULL,
index_name TEXT NOT NULL,
algorithm_version TEXT NOT NULL,
top_result_code TEXT,
results_json TEXT NOT NULL,
overall_comment TEXT NOT NULL
)
""")
self.connection.commit()
def save(self, review: dict) -> int:
with self._lock:
cursor = self.connection.execute(
"INSERT INTO search_reviews(created_at, reviewer, query, language, index_name, algorithm_version, top_result_code, results_json, overall_comment) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)",
(review["created_at"], review["reviewer"], review["query"], review["language"], review["index_name"], review["algorithm_version"], review["top_result_code"], json.dumps(review["results"], ensure_ascii=False), review["overall_comment"]),
)
self.connection.commit()
return int(cursor.lastrowid)
def export(self) -> list[dict]:
with self._lock:
return [
{**dict(row), "results": json.loads(row["results_json"])}
for row in self.connection.execute("SELECT * FROM search_reviews ORDER BY id")
]
class ReviewSnapshots:
def __init__(self, secret: bytes | None = None, ttl: int = 3600):
self.secret = secret or secrets.token_bytes(32)
self.ttl = ttl
def create(self, query: str, language: str, index: str, results: list[dict], algorithm_version: str) -> str:
payload = {"query": query, "language": language, "index_name": index, "algorithm_version": algorithm_version, "results": results, "expires_at": int(time.time()) + self.ttl}
encoded = base64.urlsafe_b64encode(json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode()).decode().rstrip("=")
signature = hmac.new(self.secret, encoded.encode(), hashlib.sha256).hexdigest()
return f"{encoded}.{signature}"
def verify(self, token: str) -> dict:
try:
encoded, signature = token.split(".", 1)
expected = hmac.new(self.secret, encoded.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
raise ValueError
payload = json.loads(base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)))
if payload["expires_at"] < int(time.time()):
raise ValueError
return payload
except (ValueError, KeyError, TypeError, json.JSONDecodeError, UnicodeError) as error:
raise ValueError("invalid or expired search snapshot") from error

View File

@@ -9,10 +9,22 @@ from contextlib import closing
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from search.catalog import legal_force_code, labels, source_code
from search.minjust_opensearch import bulk_batches, document_codes, export_bulk, load_bulk, request_json, switch_alias from search.minjust_opensearch import bulk_batches, document_codes, export_bulk, load_bulk, request_json, switch_alias
class MinjustOpenSearchTest(unittest.TestCase): class MinjustOpenSearchTest(unittest.TestCase):
def test_unknown_document_type_keeps_source_label_and_has_no_inferred_force(self):
value = {"ru": "Решение", "ky": "Решение"}
self.assertEqual(source_code("document_type", value), "other")
self.assertEqual(labels("document_type", "other"), {"ru": "Прочие документы", "ky": "Башка документтер"})
self.assertIsNone(legal_force_code(value))
self.assertEqual(source_code("status", {"ru": "Не вступил в силу", "ky": "Күчүнө кире элек"}), "not_yet_effective")
self.assertEqual(legal_force_code({"ru": "Конституционный закон", "ky": "Конституционный закон"}), "constitutional")
self.assertEqual(legal_force_code({"ru": "Указ", "ky": "Жарлыгы"}), "subordinate")
self.assertEqual(source_code("document_type", {"ru": "устав"}), "charter")
def test_switches_alias_atomically_after_successful_load(self): def test_switches_alias_atomically_after_successful_load(self):
with patch("search.minjust_opensearch.request_json", return_value={"acknowledged": True}) as request: 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") switch_alias("http://127.0.0.1:9200/", "akyldash-fragments-v2", "akyldash-fragments-current")
@@ -93,6 +105,7 @@ class MinjustOpenSearchTest(unittest.TestCase):
self.assertIn("text_ky", lines[1]) self.assertIn("text_ky", lines[1])
self.assertTrue(lines[1]["is_current_edition"]) self.assertTrue(lines[1]["is_current_edition"])
self.assertNotIn("text_ru", lines[1]) self.assertNotIn("text_ru", lines[1])
self.assertEqual(lines[1]["legal_force_code"], "legislative")
self.assertEqual(lines[3]["text_ru"], "Текст \"RU\"\nстрока") self.assertEqual(lines[3]["text_ru"], "Текст \"RU\"\nстрока")
self.assertEqual( self.assertEqual(
list(bulk_batches(iter((("7", b"a\nb\n"), ("8", b"c\nd\n"))), 4)), list(bulk_batches(iter((("7", b"a\nb\n"), ("8", b"c\nd\n"))), 4)),

View File

@@ -55,12 +55,15 @@ class SearchApiTest(unittest.TestCase):
def test_filters_count_documents_and_return_bilingual_labels(self): def test_filters_count_documents_and_return_bilingual_labels(self):
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary:
api = self.make_api(Path(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: with patch("search.api.request_json", return_value=response) as request:
payload = api.handle("GET", "/search/filters?language=ky")[1] 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["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]) body = json.loads(request.call_args.args[2])
self.assertIn("terms", body["aggs"]["document_types"]) 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}}) self.assertEqual(body["query"], {"term": {"is_current_edition": True}})

View File

@@ -16,9 +16,9 @@ class SearchApiOpenSearchTest(unittest.TestCase):
request_json(f"{base_url}/{index}", "PUT", DEFAULT_MAPPING.read_bytes(), "application/json") request_json(f"{base_url}/{index}", "PUT", DEFAULT_MAPPING.read_bytes(), "application/json")
try: try:
documents = [ 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": "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", "status_ru": "Действует", "status_ky": "Күчүндө", "status_code": "active", "date_adopted": "2021-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", "status_ru": "Действует", "status_ky": "Күчүндө", "status_code": "active", "date_adopted": "2022-01-01", "authority_paths_ru": ["Президент"], "authority_paths_ky": ["Президент"], "authority_codes": ["president"]}, {"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): 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}/_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"], []) self.assertEqual(api.handle("GET", "/search?q=historic")[1]["results"], [])
filtered = api.handle("GET", "/search?q=needle&document_type=law")[1] filtered = api.handle("GET", "/search?q=needle&document_type=law")[1]
self.assertEqual([result["code"] for result in filtered["results"]], ["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] filters = api.handle("GET", "/search/filters")[1]
self.assertEqual({item["count"] for item in filters["statuses"]}, {2}) self.assertEqual({item["count"] for item in filters["statuses"]}, {2})
self.assertEqual({item["code"] for item in filters["legal_forces"]}, {"legislative", "subordinate"})
finally: finally:
request_json(f"{base_url}/{index}", "DELETE", None, "application/json") request_json(f"{base_url}/{index}", "DELETE", None, "application/json")

View File

@@ -0,0 +1,43 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from search.api import Api, ApiError
class SearchReviewTest(unittest.TestCase):
def test_review_must_cover_each_snapshot_rank_once(self):
with tempfile.TemporaryDirectory() as temporary:
api = Api("http://opensearch:9200", "current", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
response = {"hits": {"hits": [{"_index": "search-20260827", "_source": {"document_code": "7", "edition_code": "10", "document_name_ru": "Закон"}}, {"_index": "search-20260827", "_source": {"document_code": "8", "edition_code": "11", "document_name_ru": "Кодекс"}}]}}
with patch("search.api.request_json", return_value=response):
result = api.handle("GET", "/search?q=test&language=ru&page_size=2")[1]
with self.assertRaisesRegex(ApiError, "exactly once"):
api.handle("POST", "/search-reviews", {"review_token": result["review_token"], "reviewer": "Юрист", "results": [{"rank": 1, "code": "7", "rating": 3}]})
def test_saves_signed_search_snapshot_and_rejects_tampering(self):
with tempfile.TemporaryDirectory() as temporary:
api = Api("http://opensearch:9200", "current", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
response = {"hits": {"hits": [{"_source": {"document_code": "7", "edition_code": "10", "document_name_ru": "Закон"}}]}}
with patch("search.api.request_json", return_value=response):
result = api.handle("GET", "/search?q=%D0%B7%D0%B0%D0%BA%D0%BE%D0%BD&language=ru")[1]
saved = api.handle("POST", "/search-reviews", {"review_token": result["review_token"], "reviewer": "Юрист", "results": [{"rank": 1, "code": "7", "rating": 3, "comment": "Прямой ответ"}]})
self.assertEqual(saved[0], 201)
exported = api.handle("GET", "/search-reviews/export")[1]["reviews"]
self.assertEqual(exported[0]["top_result_code"], "7")
self.assertEqual(exported[0]["results"][0]["rating"], 3)
with self.assertRaisesRegex(ApiError, "does not match"):
api.handle("POST", "/search-reviews", {"review_token": result["review_token"], "reviewer": "Юрист", "results": [{"rank": 1, "code": "8", "rating": 3}]})
def test_snapshot_and_review_page_are_available(self):
with tempfile.TemporaryDirectory() as temporary:
api = Api("http://opensearch:9200", "current", Path(temporary))
page = api.review_page()
self.assertIn("Оценка поисковой выдачи", page)
self.assertIn("akyldash-search-review-draft", page)
self.assertIn("localStorage", page)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,102 @@
import json
import sqlite3
import tempfile
import unittest
from contextlib import closing
from pathlib import Path
from tools.build_search_topic_taxonomy import build, group_for
class SearchTopicTaxonomyTest(unittest.TestCase):
def test_ambiguous_roots_are_left_for_manual_review(self):
self.assertEqual(group_for("Иностранные инвестиции"), "review_required")
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)
def test_rebuild_fails_instead_of_omitting_successful_manifest_document(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
(root / "documents/1").mkdir(parents=True)
with closing(sqlite3.connect(root / "manifest.sqlite3")) as db:
db.execute("CREATE TABLE documents (code TEXT, state TEXT)")
db.execute("INSERT INTO documents VALUES ('1', 'success')")
db.commit()
with self.assertRaisesRegex(RuntimeError, "normalized document 1"):
build(root)
def test_rebuild_rejects_normalized_document_without_classifier_list(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
document = root / "documents/1"
document.mkdir(parents=True)
with closing(sqlite3.connect(root / "manifest.sqlite3")) as db:
db.execute("CREATE TABLE documents (code TEXT, state TEXT)")
db.execute("INSERT INTO documents VALUES ('1', 'success')")
db.commit()
(document / "document.json").write_text("{}", encoding="utf-8")
with self.assertRaisesRegex(RuntimeError, "general_classifiers list"):
build(root)
def test_rebuild_rejects_invalid_nested_classifier_branch(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
document = root / "documents/1"
document.mkdir(parents=True)
with closing(sqlite3.connect(root / "manifest.sqlite3")) as db:
db.execute("CREATE TABLE documents (code TEXT, state TEXT)")
db.execute("INSERT INTO documents VALUES ('1', 'success')")
db.commit()
(document / "document.json").write_text(
json.dumps({"general_classifiers": [{"Name": {"Rus": "Корень"}, "GeneralClassifiers": "bad"}]}),
encoding="utf-8",
)
with self.assertRaisesRegex(RuntimeError, "non-list GeneralClassifiers branch"):
build(root)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,8 @@
# Absolute path on the production host containing minjust-normalized/.
DATA_ROOT=/volume1/docker/akyldash/data
BACKEND_PORT=8080
SEARCH_INDEX=akyldash-fragments-current
OPENSEARCH_MEM_LIMIT=4g
OPENSEARCH_JAVA_OPTS=-Xms2g -Xmx2g
# Generate with: openssl rand -hex 32
REVIEW_SECRET=replace-with-a-random-secret

View File

@@ -0,0 +1,11 @@
FROM python:3.12-slim
WORKDIR /app
COPY backend /app/backend
ENV PYTHONPATH=/app/backend
EXPOSE 8080
CMD ["python", "-m", "search.api", "--host", "0.0.0.0", "--port", "8080", "--url", "http://opensearch:9200", "--index", "akyldash-fragments-current", "--data", "/app/data/minjust-normalized", "--reviews-db", "/app/data/search-reviews.sqlite3"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/review', timeout=3)"]

View File

@@ -0,0 +1,3 @@
FROM opensearchproject/opensearch:3.7.0
RUN /usr/share/opensearch/bin/opensearch-plugin install --batch analysis-icu

View File

@@ -0,0 +1,37 @@
# Production deployment
This compose project runs the Search API and its private OpenSearch node. It
binds the API only to `127.0.0.1`; publish it through an authenticated reverse
proxy or VPN. OpenSearch is not published outside the compose network.
The current compose intentionally disables the OpenSearch security plugin to
match the existing API client. Keep both services on a private host/network
until authenticated OpenSearch support is implemented.
## First deployment
1. Copy this directory to the host with the repository source.
2. Copy `.env.example` to `.env`, set an absolute `DATA_ROOT`, and replace
`REVIEW_SECRET` with a random value. Do not commit `.env`.
3. Put the normalized dataset under `$DATA_ROOT/minjust-normalized/`.
4. Check the rendered configuration:
```bash
docker compose --env-file .env -f compose.yaml config
```
5. Start the services:
```bash
docker compose --env-file .env -f compose.yaml up -d --build
docker compose --env-file .env -f compose.yaml ps
curl -fsS http://127.0.0.1:${BACKEND_PORT:-8080}/review >/dev/null
```
6. Load the versioned index and switch its alias only after the import and
validation succeed. Back up `DATA_ROOT` and the `opensearch-data` volume
before the first import.
This is a deployment baseline, not a public internet exposure recipe. TLS,
authentication, backups, monitoring, and a production OpenSearch security
configuration must be provided by the host reverse proxy/operations setup.

View File

@@ -0,0 +1,64 @@
services:
opensearch:
build:
context: ../..
dockerfile: deploy/production/Dockerfile.opensearch
restart: unless-stopped
environment:
discovery.type: single-node
bootstrap.memory_lock: "true"
DISABLE_SECURITY_PLUGIN: "true"
OPENSEARCH_JAVA_OPTS: ${OPENSEARCH_JAVA_OPTS:--Xms2g -Xmx2g}
mem_limit: ${OPENSEARCH_MEM_LIMIT:-4g}
expose:
- "9200"
ulimits:
memlock:
soft: -1
hard: -1
nofile:
soft: 65536
hard: 65536
volumes:
- opensearch-data:/usr/share/opensearch/data
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:9200/_cluster/health || exit 1"]
interval: 30s
timeout: 10s
retries: 10
backend:
build:
context: ../..
dockerfile: deploy/production/Dockerfile.backend
restart: unless-stopped
environment:
REVIEW_SECRET: ${REVIEW_SECRET:?set REVIEW_SECRET in .env}
command:
- python
- -m
- search.api
- --host
- 0.0.0.0
- --port
- "8080"
- --url
- http://opensearch:9200
- --index
- ${SEARCH_INDEX:-akyldash-fragments-current}
- --data
- /app/data/minjust-normalized
- --reviews-db
- /app/data/search-reviews.sqlite3
- --review-secret
- ${REVIEW_SECRET}
ports:
- "127.0.0.1:${BACKEND_PORT:-8080}:8080"
depends_on:
opensearch:
condition: service_healthy
volumes:
- ${DATA_ROOT:?set DATA_ROOT in .env}:/app/data
volumes:
opensearch-data:

View File

@@ -8,6 +8,10 @@
MVP, зависимости и спринты. MVP, зависимости и спринты.
- [Готовность к проектированию frontend](product/frontend-design-readiness-plan.md) — - [Готовность к проектированию frontend](product/frontend-design-readiness-plan.md) —
обязательные работы и критерии перехода к frontend. обязательные работы и критерии перехода к frontend.
- [План интерфейса оценки поисковой выдачи](product/search-relevance-review-interface-plan.md) —
внутренняя лаборатория сбора оценок юристов для настройки OpenSearch.
- [Разметка relevance set](../backend/search/RELEVANCE_ANNOTATION.md) — подготовка
эталонных документов и воспроизводимая оценка собственного поиска.
- [Задание по нормализации документов](product/minjust-document-normalization-agent-task.md) — - [Задание по нормализации документов](product/minjust-document-normalization-agent-task.md) —
требования и критерии приёмки нормализатора ЦБД Минюста КР. требования и критерии приёмки нормализатора ЦБД Минюста КР.
@@ -35,4 +39,4 @@
--- ---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан Акылдаш · Telegram-бот v0.2.2 · Backend v0.9.1 · Frontend — не создан

View File

@@ -1,19 +1,21 @@
# Статус проекта # Статус проекта
Последняя проверка: 2026-08-14 Последняя проверка: 2026-09-14
Назначение документа: быстро восстановить контекст проекта для участников команды и будущих агентов. Назначение документа: быстро восстановить контекст проекта для участников команды и будущих агентов.
- Telegram-бот: `0.2.2` - Telegram-бот: `0.2.2`
- Telegram-бот на Synology: `0.2.1` - Telegram-бот на Synology: `0.2.1`
- Backend: `0.7.1` - Backend: `0.9.1`
- Frontend: не создан - Frontend: не создан
## Краткий итог ## Краткий итог
Репозиторий переориентирован с отдельного бота на весь проект юридической информационно-аналитической платформы. Telegram-бот выделен в инструмент рабочего окружения. Реализованы возобновляемая выгрузка документов из официального Open Data API ЦБД Минюста КР и их локальная воспроизводимая нормализация. Репозиторий переориентирован с отдельного бота на весь проект юридической информационно-аналитической платформы. Telegram-бот выделен в инструмент рабочего окружения. Реализованы возобновляемая выгрузка документов из официального Open Data API ЦБД Минюста КР и их локальная воспроизводимая нормализация.
Ближайшая цель — оценить полный локальный индекс на 50100 запросах RU/KY и Поисковая система и внутренняя лаборатория оценки выдачи реализованы. Ближайшая
настроить ранжирование до начала разработки поискового API. цель — проверить собственную выдачу на практических RU/KY-запросах, разобрать
оценки юристов и зафиксировать baseline. ЦБД Минюста служит источником для
проверки документов и редакций, а не системой для сравнения поисковой выдачи.
## Уже сделано ## Уже сделано
@@ -77,6 +79,8 @@
- Полный проход завершён: 209 958 документов нормализованы без ошибок. - Полный проход завершён: 209 958 документов нормализованы без ошибок.
- Контрольная выборка RU/KY прошла проверки текста, фрагментов, ID и SHA-256. - Контрольная выборка RU/KY прошла проверки текста, фрагментов, ID и SHA-256.
- Добавлены строгий mapping и атомарный Bulk NDJSON-экспорт для OpenSearch. - Добавлены строгий mapping и атомарный Bulk NDJSON-экспорт для OpenSearch.
- На предыдущей итерации использовались Excel/PDF-бланки юридической проверки;
текущий процесс опирается на relevance set и внутреннюю лабораторию.
### Развёртывание ### Развёртывание
@@ -112,6 +116,17 @@
- Не настроены уведомления Gitea. - Не настроены уведомления Gitea.
- Не проведён полный пользовательский тест: сообщение → сохранение → экспорт → анализ в ChatGPT → возврат отчёта. - Не проведён полный пользовательский тест: сообщение → сохранение → экспорт → анализ в ChatGPT → возврат отчёта.
### Готовность поиска к frontend
- Реализована внутренняя лаборатория для просмотра и оценки фактической
выдачи OpenSearch; пилот с юристами ещё не проведён.
- Не завершена независимая юридическая проверка всех 50 запросов relevance set
v1; спорные строки нельзя включать в baseline.
- Не зафиксированы неизменяемая копия подтверждённого relevance set и baseline
`Recall@10`/`MRR@10`.
- Не пройден финальный readiness gate: обновление корпуса, переиндексация,
проверка выдачи и API-сценарии для RU, KY и одноязычных актов.
## Важные неизвестные ## Важные неизвестные
По мере реального использования нужно принять решения по следующим вопросам: По мере реального использования нужно принять решения по следующим вопросам:
@@ -122,19 +137,32 @@
## Следующий этап ## Следующий этап
### Фиксация MVP и проверка полного цикла ### Проверка собственной поисковой выдачи
Рекомендуемый порядок: 1. Подготовить согласованный RU/KY relevance set: фиксировать потребность
запроса и подтверждённые `document_code`, не ориентируясь на порядок выдачи.
1. Настроить резервное копирование `/volume1/docker/akyldash/data`. 2. Проверить практические запросы во внутренней лаборатории `/review`, сохранить
2. Зафиксировать состав MVP с командой и закрыть тему `MVP`. снимки и оценки фактических результатов OpenSearch.
3. Провести одно реальное обсуждение с ответами, правками и вложением. 3. Разобрать ошибки ранжирования, сохранить baseline и повторить ту же проверку
4. Экспортировать обсуждение и вручную проанализировать Markdown в ChatGPT. после изменений.
5. Вернуть подтверждённый итог в тему `Решения` и зафиксировать его в Git/Gitea. 4. Затем пройти readiness gate issue #21 для корпуса, индекса и Search API перед
6. Записать обнаруженные неудобства и только после этого выбирать следующую доработку. проектированием публичного frontend.
## История изменений статуса ## История изменений статуса
### 2026-09-12
- Внутренняя лаборатория оценивает фактическую выдачу собственного OpenSearch;
ЦБД Минюста используется только для проверки источников и редакций актов.
- Backend `0.8.3`; закрытая внутренняя лаборатория готова к пилоту.
### 2026-09-06
- Подготовлены бланки независимой юридической проверки relevance set v1 для
русского и кыргызского языков и инструкция для юриста.
- Ближайшим этапом зафиксированы юридическая верификация, baseline качества
поиска и финальный readiness gate перед frontend.
### 2026-08-18 ### 2026-08-18
- Оценщик поиска использует `cross_fields` для совместного сопоставления - Оценщик поиска использует `cross_fields` для совместного сопоставления
@@ -234,4 +262,4 @@
--- ---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан Акылдаш · Telegram-бот v0.2.2 · Backend v0.9.1 · Frontend — не создан

View File

@@ -52,13 +52,14 @@ alias без смешивания старого и нового корпуса.
1. Заполнить рабочую копию из `backend/search/relevance-set-v1.template.json` 1. Заполнить рабочую копию из `backend/search/relevance-set-v1.template.json`
естественными формулировками пользователей и подтверждёнными естественными формулировками пользователей и подтверждёнными
`document_code` релевантных действующих актов. `document_code` релевантных действующих актов.
2. Проверить каждый запрос по официальной ЦБД и локальному индексу; не 2. Подтвердить документы по официальной ЦБД и проверить их наличие в локальном
включать запросы без установленного эталонного результата. индексе по `document_code`, не оценивая порядок поисковой выдачи. Не включать
запросы без установленного эталонного результата.
3. Провести независимую вторую проверку языка, формулировки и полного списка 3. Провести независимую вторую проверку языка, формулировки и полного списка
кодов. Спорные результаты не включать до согласования. кодов. Спорные результаты не включать до согласования.
4. После проверки сохранить неизменяемую копию набора и baseline 4. После независимой проверки сохранить неизменяемую копию набора, затем
`Recall@10`/`MRR@10`; новые правила ранжирования оценивать только сравнением оценить собственную выдачу и сохранить baseline `Recall@10`/`MRR@10`. Новые
с этим baseline. правила ранжирования проверять на том же наборе и сравнивать с baseline.
5. Отдельно проверить распространённые сокращения, опечатки и запросы о 5. Отдельно проверить распространённые сокращения, опечатки и запросы о
практическом действии. Правило принимается, только если улучшает практическом действии. Правило принимается, только если улучшает
подтверждённые запросы и не создаёт ложных срабатываний в негативных подтверждённые запросы и не создаёт ложных срабатываний в негативных

View File

@@ -1,6 +1,7 @@
# Справочники Search API v1 # Справочники Search API v1
Статус: утверждённый контракт для Search API v1. Статус: рабочий контракт Search API v1. Тематическая иерархия вынесена в
отдельный черновой каталог и не входит в фильтры API v1.
`code` — неизменяемый идентификатор, независимый от языка. Клиент хранит и `code` — неизменяемый идентификатор, независимый от языка. Клиент хранит и
передаёт только `code`; русское и кыргызское названия являются подписями и могут передаёт только `code`; русское и кыргызское названия являются подписями и могут
@@ -37,6 +38,7 @@
| `study` | Исследование | Исследование | | `study` | Исследование | Исследование |
| `report` | Доклад | Доклад | | `report` | Доклад | Доклад |
| `principles` | Основные принципы | Основные принципы | | `principles` | Основные принципы | Основные принципы |
| `unspecified` | Не указан | Көрсөтүлгөн эмес |
## Статусы ## Статусы
@@ -46,6 +48,25 @@
| `repealed` | Утратил силу | Күчүн жоготту | | `repealed` | Утратил силу | Күчүн жоготту |
| `unspecified` | Не указан | Көрсөтүлгөн эмес | | `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 группы Орган содержит два уровня: стабильную группу и конкретный орган. В 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 вариантов корневых рубрик;
328 корневых вариантов ожидают ручной классификации. Отсутствующие в источнике
кыргызские подписи оставлены пустыми: они отсутствуют у 1 524 узлов, новые
переводы не придуманы. Внутренние `source-*` ID являются
временными хешами кода источника и цепочки подписей; при `Code: null` они
зависят от подписей и не утверждаются как публичные стабильные коды.
Тематическая навигация и фильтр не входят в Search API v1. До включения в API
нужно вручную утвердить смысловые группы, идентификаторы и недостающие KY-
подписи. Источник — нормализованные данные ЦБД Минюста; команда для пересборки
каталога находится в `tools/build_search_topic_taxonomy.py`.
## Правила совместимости ## Правила совместимости
- Код из этой таблицы нельзя переиспользовать и нельзя менять его значение. - Код из этой таблицы нельзя переиспользовать и нельзя менять его значение.

View File

@@ -0,0 +1,179 @@
# План внутреннего интерфейса оценки поисковой выдачи
## Цель
Создать закрытую лабораторию релевантности: юрист оценивает фактическую выдачу
нашего OpenSearch по практическим запросам. Цель — улучшать собственное
ранжирование, а не воспроизводить алгоритм сайта Минюста КР.
ЦБД Минюста используется как официальный источник текста, реквизитов, статуса
и редакции акта. Порядок результатов и оценка их полезности определяются в
нашем сервисе.
Это внутренний рабочий инструмент, а не публичный frontend MVP. Он не включает
регистрацию, личные кабинеты, публичный дизайн, сложные фильтры или сравнение
редакций.
## Сценарий юриста
1. Указать поисковый запрос и язык.
2. Получить первые 1020 результатов в точном порядке OpenSearch.
3. Увидеть позицию каждого результата: `#1`, `#2` и далее.
4. Открыть выбранный документ по клику на название в правой панели страницы.
5. Поставить каждому просмотренному результату оценку и комментарий.
6. Сохранить снимок выдачи и оценок.
7. Передать накопленные записи на анализ ранжирования.
## Оценка результата
| Балл | Значение |
| ---: | --- |
| 0 | Нерелевантен: совпали слова, но акт не отвечает на вопрос. |
| 1 | Косвенно полезен: относится к теме, но прямого ответа нет. |
| 2 | Частично полезен: отвечает не полностью или требует другого акта. |
| 3 | Прямо и достаточно отвечает на запрос. |
Оценка относится к отдельному документу, а не ко всей выдаче. Результат без
оценки считается непросмотренным, а не нерелевантным.
## Интерфейс
Рекомендуемый экран — две панели.
- Вверху: поле запроса, переключатель RU/KY, выбор числа результатов и кнопка
«Найти».
- Слева: карточки результатов в порядке выдачи. Карточка содержит позицию,
название, тип, статус, дату, номер и фрагмент текста.
- Справа: заголовок, реквизиты, очищенный HTML выбранной редакции и ссылка на
официальный источник.
- В карточке: кнопки оценки `0`, `1`, `2`, `3` и раскрываемое поле
комментария.
- Внизу: имя или псевдоним проверяющего, общий комментарий и кнопка
«Сохранить оценку».
Правая панель предпочтительнее popup: она не блокируется браузером, сохраняет
контекст выдачи и работает на одном экране с оценкой.
### Доступность оценки и документа
Оценка реализуется нативной группой `radio` внутри `fieldset` с `legend`
«Оценка результата». У каждого значения есть видимая подпись: «0 —
нерелевантен», «1 — косвенно полезен», «2 — частично полезен», «3 — прямо
отвечает». Нельзя передавать смысл оценки только цветом. Все элементы управления
доступны с клавиатуры, имеют видимый `:focus-visible`; выбранный результат
обозначается текстом и визуальным состоянием.
На узком экране список результатов занимает всю страницу. Кнопка «Открыть
документ» открывает полноэкранный нативный `<dialog>` с явной кнопкой
«Закрыть». При закрытии фокус возвращается на исходную кнопку «Открыть
документ».
### Состояния
- Во время поиска и сохранения показывается состояние загрузки; повторная
отправка на это время недоступна. Стабильная пустая область `role="status"`
в DOM объявляет начало поиска, число результатов и успешное сохранение.
- Пустая выдача сообщает: «По запросу „…“ ничего не найдено» и предлагает
«Изменить запрос».
- Ошибка поиска сообщает причину и предлагает «Повторить поиск»; текст ошибки
выводится в `role="alert"`.
- Ошибка сохранения сообщает: «Не удалось сохранить. Проверьте подключение и
повторите». Черновик остаётся в браузере, текст ошибки выводится в
`role="alert"`.
- После сохранения выводится: «Оценка сохранена · № … · дата и время» в
указанной стабильной области `role="status"`.
До сохранения черновик хранится в `localStorage`. После успешного сохранения
интерфейс показывает ID записи и время сохранения.
## Backend и хранение
Использовать существующие endpoint:
- `GET /search` — получить ранжированный список результатов;
- `GET /documents/{code}/editions/{edition}` — получить текст выбранной
редакции.
Добавить два endpoint:
- `POST /search-reviews` — валидирует и сохраняет оценку;
- `GET /search-reviews/export` — отдаёт накопленные записи в JSON.
Для первой версии достаточно отдельной SQLite-базы. Это стандартная библиотека
Python, данные переживают перезапуск и легко выгружаются для анализа. Доступ к
интерфейсу и всем endpoint `search-reviews`, включая экспорт, должен быть
ограничен локальной сетью/VPN или аутентификацией reverse proxy.
Одна запись представляет один сохранённый поисковый сеанс:
| Поле | Назначение |
| --- | --- |
| `id`, `created_at` | Идентификатор и время сохранения. |
| `reviewer` | Имя или псевдоним проверяющего. |
| `query`, `language` | Исходный запрос и язык поиска. |
| `index_name`, `algorithm_version` | Версия индекса и алгоритма на момент оценки. |
| `top_result_code` | Код документа в позиции `#1`. |
| `results_json` | Снимок результатов в исходном порядке с оценками и комментариями. |
| `overall_comment` | Общий комментарий к выдаче. |
В `results_json` для каждого результата сохраняются: `rank`, `document_code`,
`edition_code`, название, реквизиты, фрагмент, оценка и комментарий. Снимок
выдачи обязателен: после изменения алгоритма можно будет восстановить именно
тот результат, который видел юрист.
## Правила сохранения
- Запрос не пустой, не длиннее 500 символов.
- Оценка может быть только целым числом от 0 до 3 либо отсутствовать у
непросмотренного результата.
- Комментарии имеют ограничение длины; пользовательские значения не вставляются
в HTML.
- `GET /search` возвращает краткоживущий HMAC-подписанный снимок выдачи:
запрос, язык, индекс, результаты и их позиции. `POST /search-reviews`
принимает этот снимок и только оценки с комментариями. Сервер проверяет
подпись и срок, самостоятельно формирует `results_json` и отклоняет оценки
для отсутствующих либо подменённых позиций и документов.
- Нельзя передавать персональные данные или закрытые материалы в комментариях.
- Кнопка «Сохранить оценку» остаётся доступной до отправки: отсутствующие
обязательные поля проверяются после нажатия, ошибка показана рядом с полем и
фокус переводится на первое некорректное поле.
## Анализ данных
Экспорт должен содержать исходный JSON-снимок, чтобы его можно было обработать
скриптом или открыть в табличном инструменте. Первый отчёт строит:
- среднюю оценку для каждой позиции выдачи;
- долю результатов с оценкой `3` в top-1, top-3 и top-10;
- запросы, где нет результатов с оценкой `2` или `3`;
- документы, которые часто получают низкую оценку в первых позициях;
- комментарии для ручного разбора ошибок.
Сырые оценки не должны автоматически менять веса поиска. Сначала команда
разбирает причины: анализатор, синонимы, статус, отсутствие документа,
неправильная формулировка запроса или юридическая неоднозначность.
## Этапы реализации
1. Утвердить шкалу 03, обязательность имени проверяющего и правила доступа.
2. Добавить SQLite-хранилище, валидацию, сохранение и JSON-экспорт.
3. Добавить статическую внутреннюю страницу в существующий Python-сервер, без
Next.js и отдельного публичного приложения.
4. Подключить поиск, правую панель документа, черновик, адаптивный режим и
сохранение в закреплённой панели действий на широком экране.
5. Добавить минимальные backend-проверки сохранения, повторного запуска,
экспорта и недопустимых оценок.
6. Провести ручный прогон на десяти русскоязычных практических запросах с
двумя юристами.
7. На собранных записях настроить OpenSearch и повторить тот же набор запросов.
## Критерий готовности
Юрист вводит запрос, видит порядок выдачи, открывает документ, выставляет
оценки и комментарии, сохраняет их. Экспорт содержит запрос, язык, документ
на позиции `#1`, полный порядок результатов, оценки, комментарии и версию
индекса. Данные можно сравнить до и после изменения алгоритма.
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,197 @@
#!/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 len(matches) == 1 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, document_code):
if classifiers is None:
return
if not isinstance(classifiers, list):
raise RuntimeError(f"normalized document {document_code} contains a non-list GeneralClassifiers branch")
for source in classifiers:
if not isinstance(source, dict):
raise RuntimeError(f"normalized document {document_code} contains a non-object GeneralClassifiers node")
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, document_code)
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) as error:
raise RuntimeError(f"cannot read normalized document {code}: {error}") from error
if not isinstance(data, dict):
raise RuntimeError(f"normalized document {code} must contain a JSON object")
classifiers = data.get("general_classifiers")
if not isinstance(classifiers, list):
raise RuntimeError(f"normalized document {code} must contain a general_classifiers list")
return code, 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, code)
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()