Compare commits

...

16 Commits

Author SHA1 Message Date
94ba04ff56 Merge pull request 'fix: hide search highlight markup in snippets' (#34) from fix/plain-search-snippets into main
Reviewed-on: #34
2026-09-16 06:22:34 +00:00
9fb266e8ac fix: hide search highlight markup in snippets 2026-09-16 09:16:43 +03:00
f045233a50 Merge pull request 'feat: polish search review interface' (#33) from feature/polish-review-interface into main
Reviewed-on: #33
2026-09-16 06:11:48 +00:00
6a187760db fix: restore local search review flow 2026-09-16 06:54:37 +03:00
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
20 changed files with 22366 additions and 74 deletions

View File

@@ -2,6 +2,17 @@
## Не выпущено ## Не выпущено
- Backend обновлён до `0.9.4`: из поисковых фрагментов убраны технические маркеры
подсветки OpenSearch.
- Backend обновлён до `0.9.3`: исправлен локальный индекс production Search API,
ошибки поиска показываются в общем status-блоке, а панель сохранения и footer
выстроены в обычном порядке документа.
- Backend обновлён до `0.9.2`: внутренняя лаборатория получила визуальный
polish-проход и короткий повторно запускаемый tutorial для новых проверяющих.
- Backend обновлён до `0.9.1`: все статусы из нормализованного корпуса доступны
как отдельные фильтры, варианты конституционного закона и указа сопоставляются
с подтверждённой юридической силой, неизвестные типы документов сохраняются
под общим фильтром без вывода юридической силы.
- Уточнено, что внутренняя лаборатория оценивает выдачу собственного OpenSearch; - Уточнено, что внутренняя лаборатория оценивает выдачу собственного OpenSearch;
ЦБД Минюста служит источником для подтверждения документов, а прежние Excel/PDF ЦБД Минюста служит источником для подтверждения документов, а прежние Excel/PDF
бланки удалены. бланки удалены.
@@ -16,6 +27,10 @@
- Добавлен план внутреннего интерфейса оценки поисковой выдачи юристами. - Добавлен план внутреннего интерфейса оценки поисковой выдачи юристами.
- Завершён 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,12 +10,12 @@ Telegram-бот — только часть рабочего окружения
## Текущее состояние ## Текущее состояние
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии
`0.8.3`: исправлена безопасность production Docker build context. `0.9.4`. В поисковых фрагментах скрыты технические маркеры подсветки OpenSearch.
| Компонент | Версия | Состояние | | Компонент | Версия | Состояние |
|---|---:|---| |---|---:|---|
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния | | Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
| Backend | `0.8.3` | исправлена безопасность production build context | | Backend | `0.9.4` | в поисковых фрагментах скрыты технические маркеры подсветки OpenSearch |
| Frontend | — | ещё не создан | | Frontend | — | ещё не создан |
| Сбор и обработка правовых данных | `0.8.0` | добавлены relevance set и оценка выдачи | | Сбор и обработка правовых данных | `0.8.0` | добавлены relevance set и оценка выдачи |
| RAG и база знаний | — | ещё не созданы | | RAG и база знаний | — | ещё не созданы |
@@ -58,4 +58,4 @@ python3 -m unittest discover -s tools/telegram-bot -v
--- ---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.8.3 · Frontend — не создан Акылдаш · Telegram-бот v0.2.2 · Backend v0.9.4 · Frontend — не создан

View File

@@ -1,6 +1,6 @@
# Backend Акылдаш # Backend Акылдаш
Версия: `0.8.3` Версия: `0.9.4`
Первая backend-область проекта — загрузка правовых документов из официального Первая backend-область проекта — загрузка правовых документов из официального
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
@@ -240,4 +240,4 @@ PYTHONPATH=backend python3 -m search.query "ЖЧК ачуу тартиби" --la
--- ---
Акылдаш · Backend v0.8.3 · Frontend — не создан Акылдаш · Backend v0.9.4 · 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.8.3" APP_VERSION = "0.9.3"
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.8.3" APP_VERSION = "0.9.3"
SCHEMA_VERSION = "1" SCHEMA_VERSION = "1"
NORMALIZER_VERSION = "1.0.0" NORMALIZER_VERSION = "1.0.0"
LANGUAGES = ("ru", "ky") LANGUAGES = ("ru", "ky")

View File

@@ -72,6 +72,7 @@ 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"]}},
@@ -121,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:
@@ -137,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}}},
@@ -161,13 +162,15 @@ 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")
snippet = highlight[0].replace("<em>", "").replace("</em>", "") if highlight else None
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": snippet}
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:

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.8.3" APP_VERSION = "0.9.4"
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")),

View File

@@ -5,89 +5,143 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Оценка поисковой выдачи · Акылдаш</title> <title>Оценка поисковой выдачи · Акылдаш</title>
<style> <style>
:root { color-scheme: light; font: 16px/1.5 system-ui, sans-serif; color: #17202a; background: #f5f7fa; } :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; } * { box-sizing: border-box; }
body { margin: 0; } 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: #fff; } .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__skip:focus { inset-inline-start: 1rem; z-index: 2; }
.review__header, .review__main { max-width: 1440px; margin: auto; padding-inline: 1rem; } .review__header, .review__main { max-width: 1320px; margin: auto; padding-inline: clamp(1rem, 3vw, 3rem); }
.review__header { padding-block: 1.5rem 1rem; } .review__header { padding-block: clamp(2rem, 5vw, 4rem) 1.5rem; }
.review__main { padding-block-end: 8rem; } .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__search { display: flex; flex-wrap: wrap; align-items: end; gap: .75rem; padding: 1rem; background: #fff; border: 1px solid #d9e0e7; border-radius: .75rem; } .review__header p { max-width: 58ch; margin: 0; color: var(--muted); font-size: 1.05rem; }
.review__field { display: grid; gap: .25rem; min-width: 12rem; flex: 1; } .review__header-top { display: flex; justify-content: space-between; gap: 1rem; align-items: start; }
.review__field--small { flex: 0 0 7rem; min-width: 7rem; } .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: 2rem; }
.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, button { font: inherit; }
input, select, textarea { border: 1px solid #8d9aaa; border-radius: .4rem; padding: .6rem .7rem; background: #fff; } input, select, textarea { width: 100%; border: 1px solid #b8c4ce; border-radius: .6rem; padding: .7rem .8rem; background: var(--surface); color: var(--ink); }
input:focus-visible, select:focus-visible, textarea:focus-visible, button:focus-visible { outline: 3px solid #1769aa; outline-offset: 2px; } input::placeholder, textarea::placeholder { color: #87939e; }
button { cursor: pointer; border: 1px solid #536273; border-radius: .4rem; padding: .6rem .9rem; background: #fff; color: #17202a; } input:focus-visible, select:focus-visible, textarea:focus-visible, button:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
button:hover { background: #edf3f8; } 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); }
.review__button--primary { background: #145a86; color: #fff; border-color: #145a86; } button:hover { background: #edf4f7; border-color: #7d9aaa; }
.review__button--primary:hover { background: #0e4669; } button:active { scale: .96; }
.review__status { min-height: 1.7rem; margin-block: .75rem; } 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__status--error { color: #9b1c1c; }
.review__workspace { display: grid; grid-template-columns: minmax(22rem, 1fr) minmax(24rem, 1.1fr); gap: 1rem; align-items: start; } .review__workspace { display: grid; grid-template-columns: minmax(24rem, .9fr) minmax(28rem, 1.1fr); gap: 1.25rem; align-items: start; }
.review__results, .review__document { background: #fff; border: 1px solid #d9e0e7; border-radius: .75rem; padding: 1rem; } .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__results-list { display: grid; gap: 1rem; margin: 0; padding: 0; list-style: none; } .review__panel-heading { display: flex; justify-content: space-between; gap: 1rem; align-items: baseline; margin-block-end: 1rem; }
.review__result { border-block-start: 1px solid #d9e0e7; padding-block-start: 1rem; } .review__panel-heading h2 { margin: 0; font-size: 1.1rem; letter-spacing: -.01em; }
.review__result:first-child { border-block-start: 0; padding-block-start: 0; } .review__panel-hint { color: var(--muted); font-size: .8rem; }
.review__result-title { display: flex; gap: .5rem; align-items: baseline; width: 100%; text-align: start; font-weight: 700; border: 0; padding: 0; color: #124f78; } .review__results-list { display: grid; gap: .75rem; margin: 0; padding: 0; list-style: none; }
.review__rank { flex: 0 0 auto; color: #526272; font-variant-numeric: tabular-nums; } .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__meta, .review__snippet { margin-block: .35rem; color: #526272; } .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__snippet { overflow-wrap: anywhere; }
.review__rating { display: flex; flex-wrap: wrap; gap: .45rem; margin-block: .65rem; padding: 0; border: 0; } .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%; font-weight: 600; } .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: 3.5rem; text-align: center; } .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 input { accent-color: #145a86; } .review__rating label:has(input:checked) { border-color: var(--brand); background: var(--brand); color: #fff; }
.review__comment { width: 100%; min-height: 4rem; resize: vertical; } .review__rating label:hover { border-color: #7d9aaa; }
.review__document { position: sticky; inset-block-start: 1rem; min-height: 20rem; } .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 { max-width: 75ch; overflow-wrap: anywhere; }
.review__document-body img { max-width: 100%; height: auto; } .review__document-body img { max-width: 100%; height: auto; }
.review__actions { position: fixed; inset-block-end: 0; inset-inline: 0; padding: .75rem 1rem; background: rgb(255 255 255 / .96); border-block-start: 1px solid #d9e0e7; text-align: end; } .review__actions { max-width: 1320px; margin: 1.5rem auto 0; padding-inline: clamp(1rem, 3vw, 3rem); }
dialog { max-width: min(56rem, calc(100% - 2rem)); max-height: calc(100% - 2rem); border: 1px solid #8d9aaa; border-radius: .75rem; padding: 1rem; } .review__actions-inner { display: flex; align-items: end; gap: .75rem; padding: 1rem; background: var(--surface); border: 1px solid var(--line); border-radius: 1rem; box-shadow: 0 .75rem 2rem rgb(24 42 54 / .05); }
.review__actions label { display: grid; gap: .35rem; flex: 1; color: var(--muted); font-size: .8rem; font-weight: 700; }
.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); } dialog::backdrop { background: rgb(10 20 30 / .5); }
.review__dialog-close { float: inline-end; } .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) { @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__workspace { grid-template-columns: 1fr; }
.review__main { padding-block-end: 2rem; }
.review__document { display: none; } .review__document { display: none; }
.review__search { align-items: stretch; } .review__document--tour-visible { display: block; }
.review__field, .review__field--small { flex-basis: 100%; } .review__search button { grid-column: 1 / -1; }
.review__search button { width: 100%; } .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; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } }
</style> </style>
</head> </head>
<body> <body>
<a class="review__skip" href="#results">Перейти к результатам</a> <a class="review__skip" href="#results">Перейти к результатам</a>
<header class="review__header"><h1>Оценка поисковой выдачи</h1><p>Проверяйте результаты нашего OpenSearch по практическим юридическим запросам.</p></header> <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"> <main class="review__main" id="review">
<form class="review__search" id="search-form"> <form class="review__search" id="search-form">
<label class="review__field">Запрос<input id="query" name="q" maxlength="500" required autocomplete="off"></label> <label class="review__field">Юридический запрос<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="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> <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> <button class="review__button--primary" type="submit">Показать результаты</button>
</form> </form>
<div class="review__status" id="status" role="status" aria-live="polite"></div> <div class="review__status" id="status" role="status" aria-live="polite"></div>
<div class="review__workspace"> <div class="review__workspace">
<section class="review__results" aria-labelledby="results-heading"><h2 id="results-heading">Результаты</h2><ol class="review__results-list" id="results"></ol></section> <section class="review__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" aria-labelledby="document-heading"><h2 id="document-heading">Документ</h2><div id="document-meta">Выберите результат, чтобы открыть текст.</div><article class="review__document-body" id="document-body"></article></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> </div>
</main> </main>
<div class="review__actions"><label>Проверяющий <input id="reviewer" maxlength="120" autocomplete="name"></label> <label>Общий комментарий <input id="overall-comment" maxlength="4000"></label> <button class="review__button--primary" id="save" type="button">Сохранить оценку</button></div> <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> <dialog id="document-dialog"><button class="review__dialog-close" id="dialog-close" type="button">Закрыть</button><h2 id="dialog-heading">Документ</h2><div class="review__document-body" id="dialog-body"></div></dialog>
<div class="review__status review__status--error" id="error" role="alert" aria-live="assertive"></div> <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>
<footer class="review__header">Акылдаш · Backend v0.8.3 · Внутренняя лаборатория релевантности</footer> <footer class="review__header">Акылдаш · Backend v0.9.4 · Внутренняя лаборатория релевантности</footer>
<script> <script>
const DRAFT_KEY = 'akyldash-search-review-draft'; 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 state = { results: [], token: '', selected: null, query: '' };
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const setStatus = (text) => { $('status').textContent = text; $('error').textContent = ''; }; const setStatus = (text, error = false) => { $('status').textContent = text; $('status').classList.toggle('review__status--error', error); $('status').setAttribute('role', error ? 'alert' : 'status'); $('status').setAttribute('aria-live', error ? 'assertive' : 'polite'); };
const setError = (text) => { $('error').textContent = text; }; const setError = (text) => setStatus(text, true);
const escapeText = (value) => value == null ? '' : String(value); const escapeText = (value) => value == null ? '' : String(value);
const readDraft = () => { try { return JSON.parse(localStorage.getItem(DRAFT_KEY) || 'null'); } catch (_) { return null; } }; 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 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 || ''; }); }; 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() { function renderResults() {
$('results').replaceChildren(); $('results').replaceChildren();
if (!state.results.length) { $('results').innerHTML = '<li class="review__empty"><strong>Результатов пока нет</strong>Введите другой запрос и попробуйте снова.</li>'; return; }
state.results.forEach((result, index) => { 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 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 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));
@@ -101,14 +155,21 @@
applyDraft(); applyDraft();
} }
async function openDocument(index, trigger) { async function openDocument(index, trigger) {
const result = state.results[index]; state.selected = trigger; setStatus('Загрузка документа…'); 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('Не удалось загрузить документ. Повторите попытку.'); } 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(); }); $('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; } }); $('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); 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; } }); $('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 || ''; } 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> </script>
</body> </body>
</html> </html>

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

@@ -29,7 +29,7 @@ class SearchApiTest(unittest.TestCase):
with patch("search.api.request_json", return_value=response) as request: with patch("search.api.request_json", return_value=response) as request:
status, payload = api.handle("GET", "/search?q=%D0%B7%D0%B0%D0%BA%D0%BE%D0%BD&language=ru&page=2&page_size=5&status=active") status, payload = api.handle("GET", "/search?q=%D0%B7%D0%B0%D0%BA%D0%BE%D0%BD&language=ru&page=2&page_size=5&status=active")
self.assertEqual(status, 200) self.assertEqual(status, 200)
self.assertEqual(payload["results"][0]["snippet"], "<em>Закон</em>") self.assertEqual(payload["results"][0]["snippet"], "Закон")
body = json.loads(request.call_args.args[2]) body = json.loads(request.call_args.args[2])
self.assertEqual((body["from"], body["size"]), (5, 6)) self.assertEqual((body["from"], body["size"]), (5, 6))
self.assertIn({"term": {"status_code": "active"}}, body["query"]["bool"]["filter"]) self.assertIn({"term": {"status_code": "active"}}, body["query"]["bool"]["filter"])
@@ -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

@@ -36,6 +36,9 @@ class SearchReviewTest(unittest.TestCase):
page = api.review_page() page = api.review_page()
self.assertIn("Оценка поисковой выдачи", page) self.assertIn("Оценка поисковой выдачи", page)
self.assertIn("akyldash-search-review-draft", page) self.assertIn("akyldash-search-review-draft", page)
self.assertIn("tutorial-dialog", page)
self.assertIn("Как это работает", page)
self.assertNotIn('id="error"', page)
self.assertIn("localStorage", page) self.assertIn("localStorage", page)

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

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

View File

@@ -1,11 +1,11 @@
# Статус проекта # Статус проекта
Последняя проверка: 2026-09-12 Последняя проверка: 2026-09-14
Назначение документа: быстро восстановить контекст проекта для участников команды и будущих агентов. Назначение документа: быстро восстановить контекст проекта для участников команды и будущих агентов.
- Telegram-бот: `0.2.2` - Telegram-бот: `0.2.2`
- Telegram-бот на Synology: `0.2.1` - Telegram-бот на Synology: `0.2.1`
- Backend: `0.8.3` - Backend: `0.9.4`
- Frontend: не создан - Frontend: не создан
## Краткий итог ## Краткий итог
@@ -262,4 +262,4 @@
--- ---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.8.3 · Frontend — не создан Акылдаш · Telegram-бот v0.2.2 · Backend v0.9.4 · Frontend — не создан

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