Compare commits

...

40 Commits

Author SHA1 Message Date
90298fa6cc fix: keep tutorial spotlight visible on mobile 2026-09-16 00:26:48 +03:00
503cb1f074 fix: address review findings 2026-09-16 00:20:43 +03:00
4d276e0776 feat: polish search review interface 2026-09-16 00:13:48 +03:00
298191173e Merge pull request 'Сохранять документы с неполными значениями справочников' (#32) from fix/unmapped-search-catalog-values into main
Reviewed-on: #32
2026-09-15 21:00:24 +00:00
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
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
29 changed files with 22865 additions and 63 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,11 +2,30 @@
## Не выпущено ## Не выпущено
- Backend обновлён до `0.9.2`: внутренняя лаборатория получила визуальный
polish-проход и короткий повторно запускаемый tutorial для новых проверяющих.
- 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.2`: внутренняя лаборатория получила tutorial для новых проверяющих, а
размеченном наборе запросов. каталог сохраняет документы с неучтёнными исходными типами.
| Компонент | Версия | Состояние | | Компонент | Версия | Состояние |
|---|---:|---| |---|---:|---|
| 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.2` | внутренняя лаборатория получила tutorial; фильтры поиска по юридической силе и статусу документа |
| 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.2 · Frontend — не создан

View File

@@ -1,6 +1,6 @@
# Backend Акылдаш # Backend Акылдаш
Версия: `0.7.1` Версия: `0.9.2`
Первая 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.2 · 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.2"
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.2"
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.2"
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")),

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

@@ -0,0 +1,176 @@
<!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: #eef2f5; --ink: #17202a; --muted: #5d6b78; --line: #dbe2e8; --surface: #fff; --surface-muted: #f7f9fb; --brand: #0f5b78; --brand-dark: #0a4258; --accent: #e7b84b; --focus: #1769aa; }
* { box-sizing: border-box; }
body { margin: 0; color: var(--ink); background: radial-gradient(circle at 85% -10%, #dcecf0 0, transparent 35rem), #eef2f5; }
.review__skip { position: absolute; inset-block-start: 0; inset-inline-start: -10000px; padding: .5rem 1rem; background: var(--surface); }
.review__skip:focus { inset-inline-start: 1rem; z-index: 2; }
.review__header, .review__main { max-width: 1320px; margin: auto; padding-inline: clamp(1rem, 3vw, 3rem); }
.review__header { padding-block: clamp(2rem, 5vw, 4rem) 1.5rem; }
.review__header h1 { max-width: 18ch; margin: .35rem 0 .75rem; font-size: clamp(2rem, 4vw, 3.5rem); line-height: 1.05; letter-spacing: -.04em; }
.review__header p { max-width: 58ch; margin: 0; color: var(--muted); font-size: 1.05rem; }
.review__header-top { display: flex; justify-content: space-between; gap: 1rem; align-items: start; }
.review__eyebrow { display: inline-flex; align-items: center; gap: .5rem; color: var(--brand); font-size: .75rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
.review__eyebrow::before { content: ''; width: .55rem; height: .55rem; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 4px rgb(231 184 75 / .2); }
.review__main { padding-block-end: 6rem; }
.review__search { display: grid; grid-template-columns: minmax(18rem, 1fr) 9rem 9rem auto; align-items: end; gap: .75rem; padding: 1.25rem; background: var(--surface); border: 1px solid var(--line); border-radius: 1rem; box-shadow: 0 1.25rem 3rem rgb(24 42 54 / .08); }
.review__field { display: grid; gap: .35rem; min-width: 0; }
.review__field span, .review__actions label { color: var(--muted); font-size: .8rem; font-weight: 700; }
.review__field--small { min-width: 0; }
input, select, textarea, button { font: inherit; }
input, select, textarea { width: 100%; border: 1px solid #b8c4ce; border-radius: .6rem; padding: .7rem .8rem; background: var(--surface); color: var(--ink); }
input::placeholder, textarea::placeholder { color: #87939e; }
input:focus-visible, select:focus-visible, textarea:focus-visible, button:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
button { cursor: pointer; border: 1px solid #b8c4ce; border-radius: .6rem; padding: .7rem 1rem; background: var(--surface); color: var(--ink); font-weight: 700; transition-property: background-color, border-color, color, scale; transition-duration: 150ms; transition-timing-function: cubic-bezier(.2, 0, 0, 1); }
button:hover { background: #edf4f7; border-color: #7d9aaa; }
button:active { scale: .96; }
button:disabled { cursor: wait; opacity: .55; }
.review__button--primary { background: var(--brand); color: #fff; border-color: var(--brand); }
.review__button--primary:hover { background: var(--brand-dark); border-color: var(--brand-dark); }
.review__status { min-height: 1.7rem; margin-block: 1rem; color: var(--muted); }
.review__status--error { color: #9b1c1c; }
.review__workspace { display: grid; grid-template-columns: minmax(24rem, .9fr) minmax(28rem, 1.1fr); gap: 1.25rem; align-items: start; }
.review__results, .review__document { min-width: 0; background: var(--surface); border: 1px solid var(--line); border-radius: 1rem; padding: clamp(1rem, 2vw, 1.5rem); box-shadow: 0 .75rem 2rem rgb(24 42 54 / .05); }
.review__panel-heading { display: flex; justify-content: space-between; gap: 1rem; align-items: baseline; margin-block-end: 1rem; }
.review__panel-heading h2 { margin: 0; font-size: 1.1rem; letter-spacing: -.01em; }
.review__panel-hint { color: var(--muted); font-size: .8rem; }
.review__results-list { display: grid; gap: .75rem; margin: 0; padding: 0; list-style: none; }
.review__result { padding: 1rem; border: 1px solid var(--line); border-radius: .75rem; background: var(--surface-muted); transition-property: background-color, border-color, box-shadow, scale; transition-duration: 150ms; transition-timing-function: cubic-bezier(.2, 0, 0, 1); }
.review__result:hover, .review__result--selected { border-color: #8eb3c1; background: #f1f8fa; box-shadow: 0 .5rem 1rem rgb(15 91 120 / .08); }
.review__result:first-child { border-block-start: 1px solid var(--line); }
.review__result-title { display: flex; gap: .65rem; align-items: baseline; width: 100%; text-align: start; font-weight: 800; border: 0; padding: 0; background: none; color: var(--brand-dark); }
.review__result-title:hover { background: none; border-color: transparent; color: var(--brand); }
.review__rank { flex: 0 0 auto; color: var(--brand); font-variant-numeric: tabular-nums; }
.review__meta, .review__snippet { margin-block: .5rem; color: var(--muted); }
.review__meta { font-size: .8rem; }
.review__snippet { overflow-wrap: anywhere; }
.review__rating { display: grid; grid-template-columns: repeat(4, 1fr); gap: .4rem; margin-block: 1rem .75rem; padding: 0; border: 0; }
.review__rating legend { width: 100%; margin-block-end: .35rem; font-size: .8rem; font-weight: 700; color: var(--muted); grid-column: 1 / -1; }
.review__rating label { min-width: 0; padding: .45rem .25rem; border: 1px solid #c5d0d8; border-radius: .5rem; color: var(--muted); text-align: center; font-size: .8rem; cursor: pointer; }
.review__rating label:has(input:checked) { border-color: var(--brand); background: var(--brand); color: #fff; }
.review__rating label:hover { border-color: #7d9aaa; }
.review__rating label:has(input:focus-visible) { outline: 3px solid var(--focus); outline-offset: 2px; }
.review__rating input { position: absolute; opacity: 0; inline-size: 1px; block-size: 1px; }
.review__comment { min-height: 4rem; resize: vertical; background: var(--surface); }
.review__document { position: sticky; inset-block-start: 1rem; min-height: 24rem; }
.review__document-meta { margin-block-end: 1rem; padding-block-end: 1rem; border-block-end: 1px solid var(--line); color: var(--muted); }
.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; display: flex; justify-content: center; gap: .75rem; padding: .75rem max(1rem, env(safe-area-inset-inline-start)) max(.75rem, env(safe-area-inset-bottom)); background: rgb(255 255 255 / .94); border-block-start: 1px solid var(--line); box-shadow: 0 -.75rem 2rem rgb(24 42 54 / .06); backdrop-filter: blur(12px); }
.review__actions-inner { display: flex; align-items: end; gap: .75rem; width: min(1320px, 100%); }
.review__actions label { flex: 1; }
.review__actions label:last-of-type { flex: 2; }
dialog { max-width: min(56rem, calc(100% - 2rem)); max-height: calc(100% - 2rem); border: 1px solid var(--line); border-radius: 1rem; padding: 1.5rem; box-shadow: 0 2rem 5rem rgb(10 20 30 / .2); }
dialog::backdrop { background: rgb(10 20 30 / .5); }
.review__dialog-close { float: inline-end; }
.review__empty { padding: 3rem 1rem; color: var(--muted); text-align: center; }
.review__empty strong { display: block; margin-block-end: .35rem; color: var(--ink); }
.review__tour-spotlight { position: fixed; z-index: 1; pointer-events: none; border: 4px solid var(--accent); border-radius: .75rem; box-shadow: 0 0 0 100vmax rgb(10 30 40 / .58), 0 0 0 8px rgb(231 184 75 / .25); }
.review__tour { width: min(34rem, calc(100% - 2rem)); padding: 0; color: var(--ink); }
.review__tour::backdrop { background: transparent; }
.review__tour-content { padding: clamp(1.25rem, 4vw, 2rem); }
.review__tour-kicker { margin: 0 0 .5rem; color: var(--brand); font-size: .75rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
.review__tour h2 { margin: 0 0 .65rem; font-size: clamp(1.35rem, 3vw, 1.75rem); line-height: 1.1; letter-spacing: -.02em; }
.review__tour p { margin: 0; color: var(--muted); }
.review__tour-footer { display: flex; justify-content: space-between; gap: .75rem; align-items: center; margin-block-start: 1.5rem; }
.review__tour-progress { color: var(--muted); font-size: .8rem; }
.review__tour-actions { display: flex; gap: .5rem; }
@media (max-width: 800px) {
.review__search { grid-template-columns: 1fr 1fr; }
.review__search .review__field:first-child { grid-column: 1 / -1; }
.review__workspace { grid-template-columns: 1fr; }
.review__main { padding-block-end: 14rem; }
.review__document { display: none; }
.review__document--tour-visible { display: block; }
.review__search button { grid-column: 1 / -1; }
.review__actions { padding-inline: 1rem; }
.review__actions-inner { flex-wrap: wrap; }
.review__actions label { flex: 1 1 14rem !important; }
.review__actions button { flex: 1 1 100%; }
}
@media (max-width: 480px) { .review__search { grid-template-columns: 1fr; } .review__search .review__field:first-child, .review__search button { grid-column: auto; } .review__rating label { font-size: .72rem; } }
@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">
<div class="review__header-top"><div class="review__eyebrow">Внутренняя лаборатория</div><button id="tutorial-open" type="button">Как это работает</button></div>
<h1>Проверка качества поиска</h1><p>Помогите проверить, насколько первые результаты отвечают на юридический запрос.</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" placeholder="Например, как открыть ОсОО?"></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"><div class="review__panel-heading"><h2 id="results-heading">Результаты поиска</h2><span class="review__panel-hint">Оцените каждый документ</span></div><ol class="review__results-list" id="results"><li class="review__empty"><strong>Начните с запроса</strong>Введите юридический вопрос выше, чтобы увидеть результаты.</li></ol></section>
<section class="review__document" id="document-panel" aria-labelledby="document-heading"><div class="review__panel-heading"><h2 id="document-heading">Просмотр документа</h2><span class="review__panel-hint">Откройте результат</span></div><div class="review__document-meta" id="document-meta">Выберите название в списке, чтобы прочитать документ.</div><article class="review__document-body" id="document-body"></article></section>
</div>
</main>
<div class="review__actions"><div class="review__actions-inner"><label>Ваше имя или псевдоним<input id="reviewer" maxlength="120" autocomplete="name" placeholder="Например, Айжан К."></label> <label>Комментарий к выдаче <input id="overall-comment" maxlength="4000" placeholder="Что важно учесть? (необязательно)"></label> <button class="review__button--primary" id="save" type="button">Сохранить разметку</button></div></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__tour-spotlight" id="tutorial-spotlight" hidden aria-hidden="true"></div><dialog class="review__tour" id="tutorial-dialog" aria-labelledby="tutorial-heading" aria-describedby="tutorial-text"><div class="review__tour-content"><p class="review__tour-kicker">Короткое знакомство</p><h2 id="tutorial-heading"></h2><p id="tutorial-text"></p><div class="review__tour-footer"><span class="review__tour-progress" id="tutorial-progress"></span><div class="review__tour-actions"><button id="tutorial-skip" type="button">Пропустить</button><button class="review__button--primary" id="tutorial-next" type="button">Далее</button></div></div></div></dialog>
<div class="review__status review__status--error" id="error" role="alert" aria-live="assertive"></div>
<footer class="review__header">Акылдаш · Backend v0.9.2 · Внутренняя лаборатория релевантности</footer>
<script>
const DRAFT_KEY = 'akyldash-search-review-draft';
const TOUR_KEY = 'akyldash-search-review-tour-seen';
const TOUR_STEPS = [
{ target: 'query', title: 'Начните с юридического вопроса', text: 'Введите практический запрос, например «как открыть ОсОО?», выберите язык и нажмите «Показать результаты».', label: 'Шаг 1 из 4' },
{ target: 'results', title: 'Смотрите порядок выдачи', text: 'Документы расположены в том порядке, в котором их нашёл поиск. Номер слева — позиция результата.', label: 'Шаг 2 из 4' },
{ target: 'document-panel', title: 'Читайте документ перед оценкой', text: 'Нажмите на название результата. Справа откроется текст документа и ссылка на официальный источник.', label: 'Шаг 3 из 4' },
{ target: 'save', title: 'Сохраните свою разметку', text: 'Поставьте оценку каждому просмотренному документу, добавьте комментарии и сохраните результат внизу страницы.', label: 'Шаг 4 из 4' }
];
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 hasSeenTour = () => { try { return localStorage.getItem(TOUR_KEY) === '1'; } catch (_) { return false; } };
const markTourSeen = () => { try { localStorage.setItem(TOUR_KEY, '1'); } catch (_) {} };
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();
if (!state.results.length) { $('results').innerHTML = '<li class="review__empty"><strong>Результатов пока нет</strong>Введите другой запрос и попробуйте снова.</li>'; return; }
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; [...$('results').children].forEach((item) => item.classList.remove('review__result--selected')); trigger.closest('.review__result')?.classList.add('review__result--selected'); 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(); });
let tutorialStep = 0; let tutorialReturnFocus = null;
function clearTourTarget() { $('tutorial-spotlight').hidden = true; $('document-panel').classList.remove('review__document--tour-visible'); }
function renderTutorial() { const step = TOUR_STEPS[tutorialStep]; const target = $(step.target); if (step.target === 'document-panel' && getComputedStyle(target).display === 'none') target.classList.add('review__document--tour-visible'); target.scrollIntoView({block: 'nearest', inline: 'nearest'}); const bounds = target.getBoundingClientRect(); $('tutorial-spotlight').style.cssText = `inset-block-start: ${Math.max(8, bounds.top - 4)}px; inset-inline-start: ${Math.max(8, bounds.left - 4)}px; inline-size: ${bounds.width + 8}px; block-size: ${bounds.height + 8}px;`; $('tutorial-spotlight').hidden = false; $('tutorial-heading').textContent = step.title; $('tutorial-text').textContent = step.text; $('tutorial-progress').textContent = step.label; $('tutorial-next').textContent = tutorialStep === TOUR_STEPS.length - 1 ? 'Понятно' : 'Далее'; }
function closeTutorial() { clearTourTarget(); $('tutorial-dialog').close(); markTourSeen(); tutorialReturnFocus?.focus(); }
function openTutorial() { tutorialReturnFocus = document.activeElement; tutorialStep = 0; renderTutorial(); $('tutorial-dialog').showModal(); $('tutorial-next').focus(); }
$('tutorial-open').addEventListener('click', openTutorial); $('tutorial-next').addEventListener('click', () => { if (tutorialStep === TOUR_STEPS.length - 1) closeTutorial(); else { tutorialStep += 1; renderTutorial(); } }); $('tutorial-skip').addEventListener('click', closeTutorial); $('tutorial-dialog').addEventListener('cancel', (event) => { event.preventDefault(); closeTutorial(); });
$('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 || ''; }
if (!hasSeenTour()) setTimeout(openTutorial, 350);
</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,45 @@
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("tutorial-dialog", page)
self.assertIn("Как это работает", 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

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

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()