Compare commits
48 Commits
feature/se
...
feature/po
| Author | SHA1 | Date | |
|---|---|---|---|
| 90298fa6cc | |||
| 503cb1f074 | |||
| 4d276e0776 | |||
| 298191173e | |||
| 81b085dd9d | |||
| b6ad392ab7 | |||
| bb5edf3ae2 | |||
| 000cf467df | |||
| f8f98dfe46 | |||
| 5f44981bd3 | |||
| a1b4787930 | |||
| b412315ac6 | |||
| 7e07f3ab8d | |||
| 70c70fb7dd | |||
| e18d477852 | |||
| 062aaa0074 | |||
| 470745fce3 | |||
| b2274fee6f | |||
| abd244fefb | |||
| 8302c6b782 | |||
| bbf8e7a9c8 | |||
| bf0b6ff070 | |||
| b91bc98611 | |||
| 4210c2493d | |||
| 711c9e525e | |||
| 808ac6c792 | |||
| 73eb938c03 | |||
| cccae446ae | |||
| 24b3a2d422 | |||
| f472a17897 | |||
| 182b87fff2 | |||
| 407dd2dc09 | |||
| a594aec912 | |||
| c5f651bdae | |||
| f4cd453088 | |||
| 57e4645a11 | |||
| 9b28616cf4 | |||
| e9da5a6ccc | |||
| cb58e91d6b | |||
| 39896c74de | |||
| f3f564ab08 | |||
| ccbb2f0944 | |||
| d468c869c0 | |||
| 47e03bbc4d | |||
| eafc06f0bc | |||
| 9dcbcad9aa | |||
| 028062bd14 | |||
| bb69ea54b7 |
16
.dockerignore
Normal file
16
.dockerignore
Normal file
@@ -0,0 +1,16 @@
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.*
|
||||
*.json
|
||||
!backend/
|
||||
!backend/search/
|
||||
!backend/search/*.json
|
||||
*.xlsx
|
||||
*.pdf
|
||||
*.jpg
|
||||
data/
|
||||
docs/
|
||||
tools/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
22
CHANGELOG.md
22
CHANGELOG.md
@@ -2,8 +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` после перезагрузки страницы.
|
||||
- Добавлена внутренняя лаборатория проверки поисковой выдачи: просмотр документов,
|
||||
оценка релевантности 0–3, комментарии и SQLite-экспорт подписанных снимков.
|
||||
- Уточнены доступные состояния и адаптивное поведение внутреннего интерфейса
|
||||
оценки поисковой выдачи.
|
||||
- Добавлен план внутреннего интерфейса оценки поисковой выдачи юристами.
|
||||
- Завершён Search API v1: стабильные справочники, валидный OpenAPI, безопасная
|
||||
пагинация и проверка актуальных редакций в локальном OpenSearch.
|
||||
- Добавлен фильтр Search API v1 по укрупнённой юридической силе. Полная
|
||||
иерархия GeneralClassifiers экспортирована в отдельный черновой каталог;
|
||||
тематическая навигация остаётся вне API v1 до ручного утверждения рубрик и
|
||||
подписей.
|
||||
- Добавлено безопасное переключение alias на новую версию поискового индекса
|
||||
после полной загрузки; checkpoint защищает возобновление загрузки от смены
|
||||
alias.
|
||||
|
||||
10
README.md
10
README.md
@@ -10,15 +10,15 @@ Telegram-бот — только часть рабочего окружения
|
||||
## Текущее состояние
|
||||
|
||||
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии
|
||||
`0.7.1`: исправления контракта Search API v1 и его ограничений OpenSearch.
|
||||
размеченном наборе запросов.
|
||||
`0.9.2`: внутренняя лаборатория получила tutorial для новых проверяющих, а
|
||||
каталог сохраняет документы с неучтёнными исходными типами.
|
||||
|
||||
| Компонент | Версия | Состояние |
|
||||
|---|---:|---|
|
||||
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
|
||||
| Backend | `0.7.1` | исправлен контракт Search API v1 |
|
||||
| Backend | `0.9.2` | внутренняя лаборатория получила tutorial; фильтры поиска по юридической силе и статусу документа |
|
||||
| Frontend | — | ещё не создан |
|
||||
| Сбор и обработка правовых данных | `0.7.1` | добавлены relevance set и baseline-метрики |
|
||||
| Сбор и обработка правовых данных | `0.8.0` | добавлены relevance set и оценка выдачи |
|
||||
| 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 — не создан
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Backend Акылдаш
|
||||
|
||||
Версия: `0.7.1`
|
||||
Версия: `0.9.2`
|
||||
|
||||
Первая backend-область проекта — загрузка правовых документов из официального
|
||||
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
|
||||
@@ -216,6 +216,21 @@ PYTHONPATH=backend python3 -m search.evaluate_relevance \
|
||||
Менять веса или анализаторы следует только после фиксации этого 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:
|
||||
|
||||
```bash
|
||||
@@ -225,4 +240,4 @@ PYTHONPATH=backend python3 -m search.query "ЖЧК ачуу тартиби" --la
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Backend v0.7.1 · Frontend — не создан
|
||||
Акылдаш · Backend v0.9.2 · Frontend — не создан
|
||||
|
||||
@@ -21,7 +21,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
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/"
|
||||
LANGUAGES = {"Rus": "ru", "Kyr": "ky"}
|
||||
IMAGE_LANGUAGES = {"Russian": "ru", "Kyrgyz": "ky"}
|
||||
|
||||
@@ -23,7 +23,7 @@ from pathlib import Path
|
||||
from typing import Callable
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
APP_VERSION = "0.7.1"
|
||||
APP_VERSION = "0.9.2"
|
||||
SCHEMA_VERSION = "1"
|
||||
NORMALIZER_VERSION = "1.0.0"
|
||||
LANGUAGES = ("ru", "ky")
|
||||
|
||||
@@ -56,17 +56,19 @@ cp backend/search/relevance-set-v1.template.json data/search/relevance-set-v1.js
|
||||
|
||||
## Разметка одного запроса
|
||||
|
||||
1. До просмотра выдачи зафиксируйте информационную потребность и формулировку
|
||||
`query`.
|
||||
2. Найдите кандидатов в локальном OpenSearch и в официальной ЦБД Минюста.
|
||||
Проверьте исходный запрос, его короткий вариант и вариант с юридическим
|
||||
термином или известным номером акта.
|
||||
3. Просмотрите не только заголовки, но и текст, статус, дату и редакцию каждого
|
||||
кандидата.
|
||||
4. Запишите уникальные `document_code` всех документов, удовлетворяющих
|
||||
критерию релевантности.
|
||||
5. Повторите поиск по ключевым терминам найденных документов, чтобы обнаружить
|
||||
пропущенные альтернативные акты.
|
||||
1. Зафиксируйте исходную формулировку `query` и информационную потребность до
|
||||
оценки выдачи нашего поиска.
|
||||
2. Юрист устанавливает релевантные акты по содержанию и реквизитам документов.
|
||||
Используйте ЦБД Минюста как авторитетный источник для проверки текста,
|
||||
статуса и редакции акта. Порядок выдачи ЦБД не является объектом оценки и не
|
||||
переносится в эталон.
|
||||
3. Запишите уникальные `document_code` всех документов, которые прямо отвечают
|
||||
на запрос. Спорные документы передайте на независимую проверку.
|
||||
4. Зафиксируйте согласованный набор до просмотра результатов OpenSearch и
|
||||
сохраните его отдельно от рабочих данных.
|
||||
5. Проверьте запрос во внутренней лаборатории (`/review`): оцените фактические
|
||||
результаты OpenSearch в исходном порядке, сохраните снимок и комментарии.
|
||||
Лаборатория проверяет качество нашей поисковой системы, а не ЦБД Минюста.
|
||||
|
||||
Пример структуры (код условный):
|
||||
|
||||
|
||||
@@ -12,9 +12,11 @@ from pathlib import Path
|
||||
|
||||
from search.minjust_opensearch import APP_VERSION, request_json
|
||||
from search.catalog import CATALOGS, labels
|
||||
from search.reviews import ReviewSnapshots, ReviewStore
|
||||
|
||||
|
||||
API_VERSION = "v1"
|
||||
SEARCH_ALGORITHM_VERSION = "search-1"
|
||||
LANGUAGES = {"ru", "ky"}
|
||||
CODE = re.compile(r"^[0-9]+$")
|
||||
MAX_PAGE_SIZE = 100
|
||||
@@ -70,11 +72,15 @@ def openapi() -> dict:
|
||||
{"name": "document_type", "in": "query", "schema": {"type": "string"}},
|
||||
{"name": "status", "in": "query", "schema": {"type": "string"}},
|
||||
{"name": "authority", "in": "query", "schema": {"type": "string"}},
|
||||
{"name": "legal_force", "in": "query", "schema": {"type": "string"}},
|
||||
{"name": "date_from", "in": "query", "schema": {"type": "string", "format": "date"}},
|
||||
{"name": "date_to", "in": "query", "schema": {"type": "string", "format": "date"}},
|
||||
{"name": "sort", "in": "query", "schema": {"type": "string", "enum": ["relevance", "date"]}},
|
||||
]}},
|
||||
"/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}/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]+$"}}]}},
|
||||
@@ -83,10 +89,12 @@ def openapi() -> dict:
|
||||
|
||||
|
||||
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.index = index
|
||||
self.data_root = data_root
|
||||
self.review_store = ReviewStore(reviews_db)
|
||||
self.review_snapshots = ReviewSnapshots(review_secret)
|
||||
|
||||
def search_url(self, suffix: str) -> str:
|
||||
return f"{self.base_url}/{urllib.parse.quote(self.index, safe='')}/{suffix}"
|
||||
@@ -114,7 +122,7 @@ class Api:
|
||||
if sort not in {"relevance", "date"}:
|
||||
raise ApiError(400, "sort must be relevance or date")
|
||||
filters: list[dict] = [{"term": {"language": language}}, {"term": {"is_current_edition": True}}]
|
||||
fields = {"document_type": "document_type_code", "status": "status_code", "authority": "authority_codes"}
|
||||
fields = {"document_type": "document_type_code", "status": "status_code", "authority": "authority_codes", "legal_force": "legal_force_code"}
|
||||
for parameter, field in fields.items():
|
||||
value = one(query, parameter)
|
||||
if value:
|
||||
@@ -130,7 +138,7 @@ class Api:
|
||||
body = {
|
||||
"from": (page - 1) * page_size,
|
||||
"size": page_size + 1,
|
||||
"_source": ["document_code", "edition_code", "document_name_ru", "document_name_ky", "document_type_ru", "document_type_ky", "status_ru", "status_ky", "date_adopted", "number"],
|
||||
"_source": ["document_code", "edition_code", "document_name_ru", "document_name_ky", "document_type_ru", "document_type_ky", "status_ru", "status_ky", "legal_force_code", "date_adopted", "number"],
|
||||
"query": {"bool": {"filter": filters, "must": {"multi_match": {"query": text, "fields": [f"document_name_{language}", f"text_{language}"], "type": "cross_fields"}}}},
|
||||
"collapse": {"field": "document_code"},
|
||||
"highlight": {"fields": {f"text_{language}": {"number_of_fragments": 1}}},
|
||||
@@ -142,7 +150,11 @@ class Api:
|
||||
hits = response["hits"]["hits"]
|
||||
except (KeyError, TypeError) as error:
|
||||
raise ApiError(502, "search backend returned an incomplete response") from error
|
||||
return {"api_version": API_VERSION, "query": text, "language": language, "page": page, "page_size": page_size, "has_next": len(hits) > page_size, "results": [self.search_hit(hit, language) for hit in hits[:page_size]]}
|
||||
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
|
||||
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"):
|
||||
raise ApiError(502, "search backend returned an incomplete result")
|
||||
highlight = hit.get("highlight", {}).get(f"text_{language}", [])
|
||||
return {"code": source["document_code"], "edition": source.get("edition_code"), "name": source.get(f"document_name_{language}"), "type": source.get(f"document_type_{language}"), "status": source.get(f"status_{language}"), "date_adopted": source.get("date_adopted"), "number": source.get("number"), "snippet": highlight[0] if highlight else None}
|
||||
legal_force = source.get("legal_force_code")
|
||||
return {"code": source["document_code"], "edition": source.get("edition_code"), "name": source.get(f"document_name_{language}"), "type": source.get(f"document_type_{language}"), "status": source.get(f"status_{language}"), "legal_force": labels("legal_force", legal_force)[language] if legal_force else None, "date_adopted": source.get("date_adopted"), "number": source.get("number"), "snippet": highlight[0] if highlight else None}
|
||||
|
||||
def filters(self, query: dict[str, list[str]]) -> dict:
|
||||
language = one(query, "language") or "ru"
|
||||
if language not in LANGUAGES:
|
||||
raise ApiError(400, "language must be ru or ky")
|
||||
fields = {"document_types": ("document_type", "document_type_code"), "statuses": ("status", "status_code"), "authorities": ("authority", "authority_codes")}
|
||||
fields = {"document_types": ("document_type", "document_type_code"), "statuses": ("status", "status_code"), "authorities": ("authority", "authority_codes"), "legal_forces": ("legal_force", "legal_force_code")}
|
||||
body = {"size": 0, "query": {"term": {"is_current_edition": True}}, "aggs": {name: {"terms": {"field": pair[1], "size": 1000}, "aggs": {"documents": {"cardinality": {"field": "document_code", "precision_threshold": 40000}}}} for name, pair in fields.items()}}
|
||||
response = self.query_opensearch(body)
|
||||
try:
|
||||
@@ -221,12 +234,62 @@ class Api:
|
||||
raise ApiError(404, "edition language not found")
|
||||
return {"api_version": API_VERSION, "edition": metadata, "content": content}
|
||||
|
||||
def handle(self, method: str, path: str) -> tuple[int, dict]:
|
||||
if method != "GET":
|
||||
raise ApiError(405, "method not allowed")
|
||||
def save_review(self, body: dict) -> dict:
|
||||
if not isinstance(body, dict):
|
||||
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)
|
||||
query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
|
||||
parts = [urllib.parse.unquote(part) for part in parsed.path.split("/") if part]
|
||||
if 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"]:
|
||||
return 200, openapi()
|
||||
if parts == ["search"]:
|
||||
@@ -246,7 +309,23 @@ def handler(api: Api):
|
||||
class RequestHandler(BaseHTTPRequestHandler):
|
||||
def respond(self, method: str):
|
||||
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:
|
||||
status, payload = error.status, {"api_version": API_VERSION, "error": error.message}
|
||||
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("--index", default="akyldash-fragments-current")
|
||||
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("--port", type=int, default=8080)
|
||||
parser.add_argument("--version", action="version", version=APP_VERSION)
|
||||
arguments = parser.parse_args()
|
||||
ThreadingHTTPServer((arguments.host, arguments.port), handler(Api(arguments.url, arguments.index, arguments.data))).serve_forever()
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
"""Immutable v1 search catalogs."""
|
||||
|
||||
DOCUMENT_TYPES = {
|
||||
"constitution": ("Конституция", "Конституция"), "constitutional_law": ("Конституционный Закон", "Конституциалык Мыйзам"), "code": ("Кодекс", "Кодекс"), "law": ("Закон", "Мыйзам"), "decree": ("Указ", "Жарлык"), "resolution": ("Постановление", "Токтом"), "order": ("Распоряжение", "Распоряжение"), "instruction": ("Инструкция", "Инструкция"), "rules": ("Правила", "Правила"), "procedure": ("Порядок", "Порядок"), "provision": ("Положение", "Жобо"), "regulation": ("Регламент", "Регламент"), "charter": ("Устав", "Жобо (Устав)"), "program": ("Программа", "Программа"), "plan": ("План", "План"), "strategy": ("Стратегия", "Стратегия"), "concept": ("Концепция", "Концепция"), "doctrine": ("Доктрина", "Доктрина"), "agreement": ("Соглашение", "Соглашение"), "declaration": ("Декларация", "Декларация"), "registry": ("Реестр", "Реестр"), "norms": ("Нормативы", "Нормативы"), "model": ("Модель", "Модель"), "matrix": ("Матрица", "Матрица"), "study": ("Исследование", "Исследование"), "report": ("Доклад", "Доклад"), "principles": ("Основные принципы", "Основные принципы"), "unspecified": ("Не указан", "Көрсөтүлгөн эмес"),
|
||||
"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": ("Прочие органы", "Башка органдар")}
|
||||
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]:
|
||||
@@ -20,9 +39,14 @@ def source_code(category: str, value: dict | None) -> str:
|
||||
pair = ((value or {}).get("ru"), (value or {}).get("ky"))
|
||||
if pair == (None, None):
|
||||
return "unspecified"
|
||||
if category == "document_type" and pair in DOCUMENT_TYPE_ALIASES:
|
||||
return DOCUMENT_TYPE_ALIASES[pair]
|
||||
for code, expected in CATALOGS[category].items():
|
||||
if pair == expected or category == "document_type" and code == "provision" and pair == ("Положение", "Положение"):
|
||||
if pair == expected:
|
||||
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}")
|
||||
|
||||
|
||||
@@ -49,3 +73,7 @@ def authority_codes(paths: list[dict]) -> list[str]:
|
||||
else:
|
||||
codes.add("other")
|
||||
return sorted(codes) or ["other"]
|
||||
|
||||
|
||||
def legal_force_code(document_type: dict | None) -> str | None:
|
||||
return LEGAL_FORCE_BY_DOCUMENT_TYPE.get(source_code("document_type", document_type))
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"document_type_ru": { "type": "keyword" },
|
||||
"document_type_ky": { "type": "keyword" },
|
||||
"document_type_code": { "type": "keyword" },
|
||||
"legal_force_code": { "type": "keyword" },
|
||||
"status_ru": { "type": "keyword" },
|
||||
"status_ky": { "type": "keyword" },
|
||||
"status_code": { "type": "keyword" },
|
||||
|
||||
@@ -15,9 +15,9 @@ import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
from search.catalog import authority_codes, source_code
|
||||
from search.catalog import authority_codes, legal_force_code, source_code
|
||||
|
||||
APP_VERSION = "0.7.1"
|
||||
APP_VERSION = "0.9.2"
|
||||
LANGUAGES = {"ru", "ky"}
|
||||
DEFAULT_MAPPING = Path(__file__).with_name("minjust-fragments-index.json")
|
||||
|
||||
@@ -76,6 +76,7 @@ def search_document(document: dict, fragment: dict, expected: tuple[str, str, st
|
||||
"document_type_ru": localized(document.get("type"), "ru"),
|
||||
"document_type_ky": localized(document.get("type"), "ky"),
|
||||
"document_type_code": source_code("document_type", document.get("type")),
|
||||
"legal_force_code": legal_force_code(document.get("type")),
|
||||
"status_ru": localized(document.get("status"), "ru"),
|
||||
"status_ky": localized(document.get("status"), "ky"),
|
||||
"status_code": source_code("status", document.get("status")),
|
||||
|
||||
176
backend/search/review.html
Normal file
176
backend/search/review.html
Normal 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
83
backend/search/reviews.py
Normal 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
|
||||
@@ -9,10 +9,22 @@ from contextlib import closing
|
||||
from pathlib import Path
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
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")
|
||||
@@ -93,6 +105,7 @@ class MinjustOpenSearchTest(unittest.TestCase):
|
||||
self.assertIn("text_ky", lines[1])
|
||||
self.assertTrue(lines[1]["is_current_edition"])
|
||||
self.assertNotIn("text_ru", lines[1])
|
||||
self.assertEqual(lines[1]["legal_force_code"], "legislative")
|
||||
self.assertEqual(lines[3]["text_ru"], "Текст \"RU\"\nстрока")
|
||||
self.assertEqual(
|
||||
list(bulk_batches(iter((("7", b"a\nb\n"), ("8", b"c\nd\n"))), 4)),
|
||||
|
||||
@@ -55,12 +55,15 @@ class SearchApiTest(unittest.TestCase):
|
||||
def test_filters_count_documents_and_return_bilingual_labels(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = self.make_api(Path(temporary))
|
||||
response = {"aggregations": {name: {"buckets": [{"key": "law" if name == "document_types" else "active" if name == "statuses" else "parliament", "documents": {"value": 3}}]} for name in ("document_types", "statuses", "authorities")}}
|
||||
keys = {"document_types": "law", "statuses": "active", "authorities": "parliament", "legal_forces": "legislative"}
|
||||
response = {"aggregations": {name: {"buckets": [{"key": keys[name], "documents": {"value": 3}}]} for name in keys}}
|
||||
with patch("search.api.request_json", return_value=response) as request:
|
||||
payload = api.handle("GET", "/search/filters?language=ky")[1]
|
||||
self.assertEqual(payload["document_types"][0], {"code": "law", "labels": {"ru": "Закон", "ky": "Мыйзам"}, "count": 3})
|
||||
self.assertEqual(payload["legal_forces"][0]["labels"]["ky"], "Мыйзам деңгээли")
|
||||
body = json.loads(request.call_args.args[2])
|
||||
self.assertIn("terms", body["aggs"]["document_types"])
|
||||
self.assertIn("legal_force_code", body["aggs"]["legal_forces"]["terms"]["field"])
|
||||
self.assertEqual(body["query"], {"term": {"is_current_edition": True}})
|
||||
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@ class SearchApiOpenSearchTest(unittest.TestCase):
|
||||
request_json(f"{base_url}/{index}", "PUT", DEFAULT_MAPPING.read_bytes(), "application/json")
|
||||
try:
|
||||
documents = [
|
||||
{"document_code": "1", "edition_code": "1", "is_current_edition": False, "language": "ru", "position": 1, "fragment_type": "paragraph", "text_ru": "historic", "document_name_ru": "Old law", "document_type_ru": "Закон", "document_type_ky": "Мыйзам", "document_type_code": "law", "status_ru": "Утратил силу", "status_ky": "Күчүн жоготту", "status_code": "repealed", "date_adopted": "2020-01-01", "authority_paths_ru": ["Парламент"], "authority_paths_ky": ["Парламент"], "authority_codes": ["parliament"]},
|
||||
{"document_code": "1", "edition_code": "2", "is_current_edition": True, "language": "ru", "position": 1, "fragment_type": "paragraph", "text_ru": "needle", "document_name_ru": "Current law", "document_type_ru": "Закон", "document_type_ky": "Мыйзам", "document_type_code": "law", "status_ru": "Действует", "status_ky": "Күчүндө", "status_code": "active", "date_adopted": "2021-01-01", "authority_paths_ru": ["Парламент"], "authority_paths_ky": ["Парламент"], "authority_codes": ["parliament"]},
|
||||
{"document_code": "2", "edition_code": "1", "is_current_edition": True, "language": "ru", "position": 1, "fragment_type": "paragraph", "text_ru": "needle", "document_name_ru": "Current decree", "document_type_ru": "Указ", "document_type_ky": "Жарлык", "document_type_code": "decree", "status_ru": "Действует", "status_ky": "Күчүндө", "status_code": "active", "date_adopted": "2022-01-01", "authority_paths_ru": ["Президент"], "authority_paths_ky": ["Президент"], "authority_codes": ["president"]},
|
||||
{"document_code": "1", "edition_code": "1", "is_current_edition": False, "language": "ru", "position": 1, "fragment_type": "paragraph", "text_ru": "historic", "document_name_ru": "Old law", "document_type_ru": "Закон", "document_type_ky": "Мыйзам", "document_type_code": "law", "legal_force_code": "legislative", "status_ru": "Утратил силу", "status_ky": "Күчүн жоготту", "status_code": "repealed", "date_adopted": "2020-01-01", "authority_paths_ru": ["Парламент"], "authority_paths_ky": ["Парламент"], "authority_codes": ["parliament"]},
|
||||
{"document_code": "1", "edition_code": "2", "is_current_edition": True, "language": "ru", "position": 1, "fragment_type": "paragraph", "text_ru": "needle", "document_name_ru": "Current law", "document_type_ru": "Закон", "document_type_ky": "Мыйзам", "document_type_code": "law", "legal_force_code": "legislative", "status_ru": "Действует", "status_ky": "Күчүндө", "status_code": "active", "date_adopted": "2021-01-01", "authority_paths_ru": ["Парламент"], "authority_paths_ky": ["Парламент"], "authority_codes": ["parliament"]},
|
||||
{"document_code": "2", "edition_code": "1", "is_current_edition": True, "language": "ru", "position": 1, "fragment_type": "paragraph", "text_ru": "needle", "document_name_ru": "Current decree", "document_type_ru": "Указ", "document_type_ky": "Жарлык", "document_type_code": "decree", "legal_force_code": "subordinate", "status_ru": "Действует", "status_ky": "Күчүндө", "status_code": "active", "date_adopted": "2022-01-01", "authority_paths_ru": ["Президент"], "authority_paths_ky": ["Президент"], "authority_codes": ["president"]},
|
||||
]
|
||||
for number, document in enumerate(documents):
|
||||
request_json(f"{base_url}/{index}/_doc/{number}", "PUT", json.dumps(document).encode(), "application/json")
|
||||
@@ -27,8 +27,12 @@ class SearchApiOpenSearchTest(unittest.TestCase):
|
||||
self.assertEqual(api.handle("GET", "/search?q=historic")[1]["results"], [])
|
||||
filtered = api.handle("GET", "/search?q=needle&document_type=law")[1]
|
||||
self.assertEqual([result["code"] for result in filtered["results"]], ["1"])
|
||||
force_filtered = api.handle("GET", "/search?q=needle&legal_force=subordinate")[1]
|
||||
self.assertEqual([result["code"] for result in force_filtered["results"]], ["2"])
|
||||
self.assertEqual(force_filtered["results"][0]["legal_force"], "Подзаконный уровень")
|
||||
filters = api.handle("GET", "/search/filters")[1]
|
||||
self.assertEqual({item["count"] for item in filters["statuses"]}, {2})
|
||||
self.assertEqual({item["code"] for item in filters["legal_forces"]}, {"legislative", "subordinate"})
|
||||
finally:
|
||||
request_json(f"{base_url}/{index}", "DELETE", None, "application/json")
|
||||
|
||||
|
||||
45
backend/test_search_reviews.py
Normal file
45
backend/test_search_reviews.py
Normal 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()
|
||||
102
backend/test_search_topic_taxonomy.py
Normal file
102
backend/test_search_topic_taxonomy.py
Normal 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()
|
||||
8
deploy/production/.env.example
Normal file
8
deploy/production/.env.example
Normal 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
|
||||
11
deploy/production/Dockerfile.backend
Normal file
11
deploy/production/Dockerfile.backend
Normal 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)"]
|
||||
3
deploy/production/Dockerfile.opensearch
Normal file
3
deploy/production/Dockerfile.opensearch
Normal file
@@ -0,0 +1,3 @@
|
||||
FROM opensearchproject/opensearch:3.7.0
|
||||
|
||||
RUN /usr/share/opensearch/bin/opensearch-plugin install --batch analysis-icu
|
||||
37
deploy/production/README.md
Normal file
37
deploy/production/README.md
Normal 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.
|
||||
64
deploy/production/compose.yaml
Normal file
64
deploy/production/compose.yaml
Normal 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:
|
||||
@@ -8,6 +8,10 @@
|
||||
MVP, зависимости и спринты.
|
||||
- [Готовность к проектированию frontend](product/frontend-design-readiness-plan.md) —
|
||||
обязательные работы и критерии перехода к frontend.
|
||||
- [План интерфейса оценки поисковой выдачи](product/search-relevance-review-interface-plan.md) —
|
||||
внутренняя лаборатория сбора оценок юристов для настройки OpenSearch.
|
||||
- [Разметка relevance set](../backend/search/RELEVANCE_ANNOTATION.md) — подготовка
|
||||
эталонных документов и воспроизводимая оценка собственного поиска.
|
||||
- [Задание по нормализации документов](product/minjust-document-normalization-agent-task.md) —
|
||||
требования и критерии приёмки нормализатора ЦБД Минюста КР.
|
||||
|
||||
@@ -35,4 +39,4 @@
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.9.2 · Frontend — не создан
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
# Статус проекта
|
||||
|
||||
Последняя проверка: 2026-08-14
|
||||
Последняя проверка: 2026-09-14
|
||||
Назначение документа: быстро восстановить контекст проекта для участников команды и будущих агентов.
|
||||
|
||||
- Telegram-бот: `0.2.2`
|
||||
- Telegram-бот на Synology: `0.2.1`
|
||||
- Backend: `0.7.1`
|
||||
- Backend: `0.9.2`
|
||||
- Frontend: не создан
|
||||
|
||||
## Краткий итог
|
||||
|
||||
Репозиторий переориентирован с отдельного бота на весь проект юридической информационно-аналитической платформы. Telegram-бот выделен в инструмент рабочего окружения. Реализованы возобновляемая выгрузка документов из официального Open Data API ЦБД Минюста КР и их локальная воспроизводимая нормализация.
|
||||
|
||||
Ближайшая цель — оценить полный локальный индекс на 50–100 запросах RU/KY и
|
||||
настроить ранжирование до начала разработки поискового API.
|
||||
Поисковая система и внутренняя лаборатория оценки выдачи реализованы. Ближайшая
|
||||
цель — проверить собственную выдачу на практических RU/KY-запросах, разобрать
|
||||
оценки юристов и зафиксировать baseline. ЦБД Минюста служит источником для
|
||||
проверки документов и редакций, а не системой для сравнения поисковой выдачи.
|
||||
|
||||
## Уже сделано
|
||||
|
||||
@@ -77,6 +79,8 @@
|
||||
- Полный проход завершён: 209 958 документов нормализованы без ошибок.
|
||||
- Контрольная выборка RU/KY прошла проверки текста, фрагментов, ID и SHA-256.
|
||||
- Добавлены строгий mapping и атомарный Bulk NDJSON-экспорт для OpenSearch.
|
||||
- На предыдущей итерации использовались Excel/PDF-бланки юридической проверки;
|
||||
текущий процесс опирается на relevance set и внутреннюю лабораторию.
|
||||
|
||||
### Развёртывание
|
||||
|
||||
@@ -112,6 +116,17 @@
|
||||
- Не настроены уведомления Gitea.
|
||||
- Не проведён полный пользовательский тест: сообщение → сохранение → экспорт → анализ в 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. Настроить резервное копирование `/volume1/docker/akyldash/data`.
|
||||
2. Зафиксировать состав MVP с командой и закрыть тему `MVP`.
|
||||
3. Провести одно реальное обсуждение с ответами, правками и вложением.
|
||||
4. Экспортировать обсуждение и вручную проанализировать Markdown в ChatGPT.
|
||||
5. Вернуть подтверждённый итог в тему `Решения` и зафиксировать его в Git/Gitea.
|
||||
6. Записать обнаруженные неудобства и только после этого выбирать следующую доработку.
|
||||
1. Подготовить согласованный RU/KY relevance set: фиксировать потребность
|
||||
запроса и подтверждённые `document_code`, не ориентируясь на порядок выдачи.
|
||||
2. Проверить практические запросы во внутренней лаборатории `/review`, сохранить
|
||||
снимки и оценки фактических результатов OpenSearch.
|
||||
3. Разобрать ошибки ранжирования, сохранить baseline и повторить ту же проверку
|
||||
после изменений.
|
||||
4. Затем пройти readiness gate issue #21 для корпуса, индекса и Search API перед
|
||||
проектированием публичного frontend.
|
||||
|
||||
## История изменений статуса
|
||||
|
||||
### 2026-09-12
|
||||
|
||||
- Внутренняя лаборатория оценивает фактическую выдачу собственного OpenSearch;
|
||||
ЦБД Минюста используется только для проверки источников и редакций актов.
|
||||
- Backend `0.8.3`; закрытая внутренняя лаборатория готова к пилоту.
|
||||
|
||||
### 2026-09-06
|
||||
|
||||
- Подготовлены бланки независимой юридической проверки relevance set v1 для
|
||||
русского и кыргызского языков и инструкция для юриста.
|
||||
- Ближайшим этапом зафиксированы юридическая верификация, baseline качества
|
||||
поиска и финальный readiness gate перед frontend.
|
||||
|
||||
### 2026-08-18
|
||||
|
||||
- Оценщик поиска использует `cross_fields` для совместного сопоставления
|
||||
@@ -234,4 +262,4 @@
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.9.2 · Frontend — не создан
|
||||
|
||||
@@ -52,13 +52,14 @@ alias без смешивания старого и нового корпуса.
|
||||
1. Заполнить рабочую копию из `backend/search/relevance-set-v1.template.json`
|
||||
естественными формулировками пользователей и подтверждёнными
|
||||
`document_code` релевантных действующих актов.
|
||||
2. Проверить каждый запрос по официальной ЦБД и локальному индексу; не
|
||||
включать запросы без установленного эталонного результата.
|
||||
2. Подтвердить документы по официальной ЦБД и проверить их наличие в локальном
|
||||
индексе по `document_code`, не оценивая порядок поисковой выдачи. Не включать
|
||||
запросы без установленного эталонного результата.
|
||||
3. Провести независимую вторую проверку языка, формулировки и полного списка
|
||||
кодов. Спорные результаты не включать до согласования.
|
||||
4. После проверки сохранить неизменяемую копию набора и baseline
|
||||
`Recall@10`/`MRR@10`; новые правила ранжирования оценивать только сравнением
|
||||
с этим baseline.
|
||||
4. После независимой проверки сохранить неизменяемую копию набора, затем
|
||||
оценить собственную выдачу и сохранить baseline `Recall@10`/`MRR@10`. Новые
|
||||
правила ранжирования проверять на том же наборе и сравнивать с baseline.
|
||||
5. Отдельно проверить распространённые сокращения, опечатки и запросы о
|
||||
практическом действии. Правило принимается, только если улучшает
|
||||
подтверждённые запросы и не создаёт ложных срабатываний в негативных
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Справочники Search API v1
|
||||
|
||||
Статус: утверждённый контракт для Search API v1.
|
||||
Статус: рабочий контракт Search API v1. Тематическая иерархия вынесена в
|
||||
отдельный черновой каталог и не входит в фильтры API v1.
|
||||
|
||||
`code` — неизменяемый идентификатор, независимый от языка. Клиент хранит и
|
||||
передаёт только `code`; русское и кыргызское названия являются подписями и могут
|
||||
@@ -37,6 +38,7 @@
|
||||
| `study` | Исследование | Исследование |
|
||||
| `report` | Доклад | Доклад |
|
||||
| `principles` | Основные принципы | Основные принципы |
|
||||
| `unspecified` | Не указан | Көрсөтүлгөн эмес |
|
||||
|
||||
## Статусы
|
||||
|
||||
@@ -46,6 +48,25 @@
|
||||
| `repealed` | Утратил силу | Күчүн жоготту |
|
||||
| `unspecified` | Не указан | Көрсөтүлгөн эмес |
|
||||
|
||||
## Юридическая сила
|
||||
|
||||
Это укрупнённая группировка для фильтра Search API, а не полная иерархия
|
||||
нормативных правовых актов из статьи 6 Закона КР «О нормативных правовых
|
||||
актах». В этой группировке Конституция и конституционные законы относятся к
|
||||
конституционному уровню, кодексы и законы — к законодательному, указы и
|
||||
постановления, перечисленные законом как виды НПА, — к подзаконному.
|
||||
|
||||
| Code | RU | KY |
|
||||
| --- | --- | --- |
|
||||
| `constitutional` | Конституционный уровень | Конституциялык деңгээл |
|
||||
| `legislative` | Законодательный уровень | Мыйзам деңгээли |
|
||||
| `subordinate` | Подзаконный уровень | Мыйзам алдындагы деңгээл |
|
||||
|
||||
Для прочих типов значение не назначается автоматически: одного названия типа
|
||||
недостаточно, чтобы установить юридическую силу утверждающего НПА. Основание:
|
||||
[статьи 4 и 6 Закона КР «О нормативных правовых актах»](https://cbd.minjust.gov.kg/4-3987/edition/53304/ru).
|
||||
Эта группировка реализована как фильтр `legal_force` Search API v1.
|
||||
|
||||
## Органы принятия
|
||||
|
||||
Орган содержит два уровня: стабильную группу и конкретный орган. В v1 группы
|
||||
@@ -70,6 +91,35 @@
|
||||
когда источник предоставит такие идентификаторы либо будет утверждён вручную
|
||||
поддерживаемый реестр.
|
||||
|
||||
## Темы
|
||||
|
||||
Полная исходная иерархия `GeneralClassifiers` сохранена отдельно в
|
||||
[`search-topic-taxonomy-v1.json`](search-topic-taxonomy-v1.json). Над исходными
|
||||
ветвями добавлены предложенные смысловые группы; исходные подписи и вложенность
|
||||
не переставляются. Поле `document_count` показывает число документов, где узел
|
||||
встречается. Предлагаются группы: конституционный строй и права; государство и
|
||||
публичное управление; право, суд и правопорядок; международные отношения;
|
||||
оборона и безопасность; экономика, финансы и предпринимательство; труд и
|
||||
социальная защита; здравоохранение; образование, наука и культура; земля,
|
||||
природные ресурсы и окружающая среда; сельское хозяйство; транспорт, связь,
|
||||
строительство и жильё; информация и СМИ; гражданские, семейные и жилищные
|
||||
отношения; общие и межотраслевые вопросы.
|
||||
|
||||
Каталог является черновиком: группа корневой рубрики предложена по русской
|
||||
подписи, рубрики с несколькими совпадениями правил или без совпадений помещены
|
||||
в `review_required` и отмечены `manual_review`; остальные помечены
|
||||
`provisional_lexical`. В корпусе 2 065 узлов и 687 вариантов корневых рубрик;
|
||||
328 корневых вариантов ожидают ручной классификации. Отсутствующие в источнике
|
||||
кыргызские подписи оставлены пустыми: они отсутствуют у 1 524 узлов, новые
|
||||
переводы не придуманы. Внутренние `source-*` ID являются
|
||||
временными хешами кода источника и цепочки подписей; при `Code: null` они
|
||||
зависят от подписей и не утверждаются как публичные стабильные коды.
|
||||
|
||||
Тематическая навигация и фильтр не входят в Search API v1. До включения в API
|
||||
нужно вручную утвердить смысловые группы, идентификаторы и недостающие KY-
|
||||
подписи. Источник — нормализованные данные ЦБД Минюста; команда для пересборки
|
||||
каталога находится в `tools/build_search_topic_taxonomy.py`.
|
||||
|
||||
## Правила совместимости
|
||||
|
||||
- Код из этой таблицы нельзя переиспользовать и нельзя менять его значение.
|
||||
|
||||
179
docs/product/search-relevance-review-interface-plan.md
Normal file
179
docs/product/search-relevance-review-interface-plan.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# План внутреннего интерфейса оценки поисковой выдачи
|
||||
|
||||
## Цель
|
||||
|
||||
Создать закрытую лабораторию релевантности: юрист оценивает фактическую выдачу
|
||||
нашего OpenSearch по практическим запросам. Цель — улучшать собственное
|
||||
ранжирование, а не воспроизводить алгоритм сайта Минюста КР.
|
||||
|
||||
ЦБД Минюста используется как официальный источник текста, реквизитов, статуса
|
||||
и редакции акта. Порядок результатов и оценка их полезности определяются в
|
||||
нашем сервисе.
|
||||
|
||||
Это внутренний рабочий инструмент, а не публичный frontend MVP. Он не включает
|
||||
регистрацию, личные кабинеты, публичный дизайн, сложные фильтры или сравнение
|
||||
редакций.
|
||||
|
||||
## Сценарий юриста
|
||||
|
||||
1. Указать поисковый запрос и язык.
|
||||
2. Получить первые 10–20 результатов в точном порядке OpenSearch.
|
||||
3. Увидеть позицию каждого результата: `#1`, `#2` и далее.
|
||||
4. Открыть выбранный документ по клику на название в правой панели страницы.
|
||||
5. Поставить каждому просмотренному результату оценку и комментарий.
|
||||
6. Сохранить снимок выдачи и оценок.
|
||||
7. Передать накопленные записи на анализ ранжирования.
|
||||
|
||||
## Оценка результата
|
||||
|
||||
| Балл | Значение |
|
||||
| ---: | --- |
|
||||
| 0 | Нерелевантен: совпали слова, но акт не отвечает на вопрос. |
|
||||
| 1 | Косвенно полезен: относится к теме, но прямого ответа нет. |
|
||||
| 2 | Частично полезен: отвечает не полностью или требует другого акта. |
|
||||
| 3 | Прямо и достаточно отвечает на запрос. |
|
||||
|
||||
Оценка относится к отдельному документу, а не ко всей выдаче. Результат без
|
||||
оценки считается непросмотренным, а не нерелевантным.
|
||||
|
||||
## Интерфейс
|
||||
|
||||
Рекомендуемый экран — две панели.
|
||||
|
||||
- Вверху: поле запроса, переключатель RU/KY, выбор числа результатов и кнопка
|
||||
«Найти».
|
||||
- Слева: карточки результатов в порядке выдачи. Карточка содержит позицию,
|
||||
название, тип, статус, дату, номер и фрагмент текста.
|
||||
- Справа: заголовок, реквизиты, очищенный HTML выбранной редакции и ссылка на
|
||||
официальный источник.
|
||||
- В карточке: кнопки оценки `0`, `1`, `2`, `3` и раскрываемое поле
|
||||
комментария.
|
||||
- Внизу: имя или псевдоним проверяющего, общий комментарий и кнопка
|
||||
«Сохранить оценку».
|
||||
|
||||
Правая панель предпочтительнее popup: она не блокируется браузером, сохраняет
|
||||
контекст выдачи и работает на одном экране с оценкой.
|
||||
|
||||
### Доступность оценки и документа
|
||||
|
||||
Оценка реализуется нативной группой `radio` внутри `fieldset` с `legend`
|
||||
«Оценка результата». У каждого значения есть видимая подпись: «0 —
|
||||
нерелевантен», «1 — косвенно полезен», «2 — частично полезен», «3 — прямо
|
||||
отвечает». Нельзя передавать смысл оценки только цветом. Все элементы управления
|
||||
доступны с клавиатуры, имеют видимый `:focus-visible`; выбранный результат
|
||||
обозначается текстом и визуальным состоянием.
|
||||
|
||||
На узком экране список результатов занимает всю страницу. Кнопка «Открыть
|
||||
документ» открывает полноэкранный нативный `<dialog>` с явной кнопкой
|
||||
«Закрыть». При закрытии фокус возвращается на исходную кнопку «Открыть
|
||||
документ».
|
||||
|
||||
### Состояния
|
||||
|
||||
- Во время поиска и сохранения показывается состояние загрузки; повторная
|
||||
отправка на это время недоступна. Стабильная пустая область `role="status"`
|
||||
в DOM объявляет начало поиска, число результатов и успешное сохранение.
|
||||
- Пустая выдача сообщает: «По запросу „…“ ничего не найдено» и предлагает
|
||||
«Изменить запрос».
|
||||
- Ошибка поиска сообщает причину и предлагает «Повторить поиск»; текст ошибки
|
||||
выводится в `role="alert"`.
|
||||
- Ошибка сохранения сообщает: «Не удалось сохранить. Проверьте подключение и
|
||||
повторите». Черновик остаётся в браузере, текст ошибки выводится в
|
||||
`role="alert"`.
|
||||
- После сохранения выводится: «Оценка сохранена · № … · дата и время» в
|
||||
указанной стабильной области `role="status"`.
|
||||
|
||||
До сохранения черновик хранится в `localStorage`. После успешного сохранения
|
||||
интерфейс показывает ID записи и время сохранения.
|
||||
|
||||
## Backend и хранение
|
||||
|
||||
Использовать существующие endpoint:
|
||||
|
||||
- `GET /search` — получить ранжированный список результатов;
|
||||
- `GET /documents/{code}/editions/{edition}` — получить текст выбранной
|
||||
редакции.
|
||||
|
||||
Добавить два endpoint:
|
||||
|
||||
- `POST /search-reviews` — валидирует и сохраняет оценку;
|
||||
- `GET /search-reviews/export` — отдаёт накопленные записи в JSON.
|
||||
|
||||
Для первой версии достаточно отдельной SQLite-базы. Это стандартная библиотека
|
||||
Python, данные переживают перезапуск и легко выгружаются для анализа. Доступ к
|
||||
интерфейсу и всем endpoint `search-reviews`, включая экспорт, должен быть
|
||||
ограничен локальной сетью/VPN или аутентификацией reverse proxy.
|
||||
|
||||
Одна запись представляет один сохранённый поисковый сеанс:
|
||||
|
||||
| Поле | Назначение |
|
||||
| --- | --- |
|
||||
| `id`, `created_at` | Идентификатор и время сохранения. |
|
||||
| `reviewer` | Имя или псевдоним проверяющего. |
|
||||
| `query`, `language` | Исходный запрос и язык поиска. |
|
||||
| `index_name`, `algorithm_version` | Версия индекса и алгоритма на момент оценки. |
|
||||
| `top_result_code` | Код документа в позиции `#1`. |
|
||||
| `results_json` | Снимок результатов в исходном порядке с оценками и комментариями. |
|
||||
| `overall_comment` | Общий комментарий к выдаче. |
|
||||
|
||||
В `results_json` для каждого результата сохраняются: `rank`, `document_code`,
|
||||
`edition_code`, название, реквизиты, фрагмент, оценка и комментарий. Снимок
|
||||
выдачи обязателен: после изменения алгоритма можно будет восстановить именно
|
||||
тот результат, который видел юрист.
|
||||
|
||||
## Правила сохранения
|
||||
|
||||
- Запрос не пустой, не длиннее 500 символов.
|
||||
- Оценка может быть только целым числом от 0 до 3 либо отсутствовать у
|
||||
непросмотренного результата.
|
||||
- Комментарии имеют ограничение длины; пользовательские значения не вставляются
|
||||
в HTML.
|
||||
- `GET /search` возвращает краткоживущий HMAC-подписанный снимок выдачи:
|
||||
запрос, язык, индекс, результаты и их позиции. `POST /search-reviews`
|
||||
принимает этот снимок и только оценки с комментариями. Сервер проверяет
|
||||
подпись и срок, самостоятельно формирует `results_json` и отклоняет оценки
|
||||
для отсутствующих либо подменённых позиций и документов.
|
||||
- Нельзя передавать персональные данные или закрытые материалы в комментариях.
|
||||
- Кнопка «Сохранить оценку» остаётся доступной до отправки: отсутствующие
|
||||
обязательные поля проверяются после нажатия, ошибка показана рядом с полем и
|
||||
фокус переводится на первое некорректное поле.
|
||||
|
||||
## Анализ данных
|
||||
|
||||
Экспорт должен содержать исходный JSON-снимок, чтобы его можно было обработать
|
||||
скриптом или открыть в табличном инструменте. Первый отчёт строит:
|
||||
|
||||
- среднюю оценку для каждой позиции выдачи;
|
||||
- долю результатов с оценкой `3` в top-1, top-3 и top-10;
|
||||
- запросы, где нет результатов с оценкой `2` или `3`;
|
||||
- документы, которые часто получают низкую оценку в первых позициях;
|
||||
- комментарии для ручного разбора ошибок.
|
||||
|
||||
Сырые оценки не должны автоматически менять веса поиска. Сначала команда
|
||||
разбирает причины: анализатор, синонимы, статус, отсутствие документа,
|
||||
неправильная формулировка запроса или юридическая неоднозначность.
|
||||
|
||||
## Этапы реализации
|
||||
|
||||
1. Утвердить шкалу 0–3, обязательность имени проверяющего и правила доступа.
|
||||
2. Добавить SQLite-хранилище, валидацию, сохранение и JSON-экспорт.
|
||||
3. Добавить статическую внутреннюю страницу в существующий Python-сервер, без
|
||||
Next.js и отдельного публичного приложения.
|
||||
4. Подключить поиск, правую панель документа, черновик, адаптивный режим и
|
||||
сохранение в закреплённой панели действий на широком экране.
|
||||
5. Добавить минимальные backend-проверки сохранения, повторного запуска,
|
||||
экспорта и недопустимых оценок.
|
||||
6. Провести ручный прогон на десяти русскоязычных практических запросах с
|
||||
двумя юристами.
|
||||
7. На собранных записях настроить OpenSearch и повторить тот же набор запросов.
|
||||
|
||||
## Критерий готовности
|
||||
|
||||
Юрист вводит запрос, видит порядок выдачи, открывает документ, выставляет
|
||||
оценки и комментарии, сохраняет их. Экспорт содержит запрос, язык, документ
|
||||
на позиции `#1`, полный порядок результатов, оценки, комментарии и версию
|
||||
индекса. Данные можно сравнить до и после изменения алгоритма.
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан
|
||||
21811
docs/product/search-topic-taxonomy-v1.json
Normal file
21811
docs/product/search-topic-taxonomy-v1.json
Normal file
File diff suppressed because it is too large
Load Diff
197
tools/build_search_topic_taxonomy.py
Normal file
197
tools/build_search_topic_taxonomy.py
Normal 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()
|
||||
Reference in New Issue
Block a user