Compare commits

..

32 Commits

Author SHA1 Message Date
4210c2493d fix: persist all review fields 2026-08-28 07:31:28 +03:00
711c9e525e feat: persist search review drafts 2026-08-28 07:29:58 +03:00
808ac6c792 Merge pull request 'feat: add search relevance review lab' (#26) from feature/search-review-lab into main
Reviewed-on: #26
2026-08-28 04:22:46 +00:00
73eb938c03 docs: record search review lab 2026-08-27 08:28:08 +03:00
cccae446ae fix: version search ranking snapshots 2026-08-27 08:27:23 +03:00
24b3a2d422 fix: make review snapshots reproducible 2026-08-27 08:26:25 +03:00
f472a17897 feat: add search relevance review lab 2026-08-27 08:21:59 +03:00
182b87fff2 Merge pull request 'docs: define accessible review interface' (#25) from docs/search-review-accessibility into main
Reviewed-on: #25
2026-08-26 21:11:34 +00:00
407dd2dc09 docs: record accessible review interface 2026-08-27 00:02:29 +03:00
a594aec912 fix: clarify review interface accessibility 2026-08-27 00:01:34 +03:00
c5f651bdae docs: define accessible review interface 2026-08-26 23:59:43 +03:00
f4cd453088 Merge pull request 'docs: plan search review interface' (#24) from docs/search-review-interface-plan into main
Reviewed-on: #24
2026-08-26 20:31:48 +00:00
57e4645a11 docs: record search review plan 2026-08-26 23:29:30 +03:00
9b28616cf4 docs: secure review interface plan 2026-08-26 23:28:43 +03:00
e9da5a6ccc docs: plan search review interface 2026-08-26 23:27:14 +03:00
bb69ea54b7 Merge pull request 'feat: add versioned search API contract' (#17) from feature/search-api-contract into main
Reviewed-on: #17
2026-08-25 05:35:54 +00:00
0f309f34fa docs: update changelog for search API 2026-08-25 08:32:21 +03:00
2ef33c5cc3 fix: use stable search catalog codes 2026-08-25 08:26:48 +03:00
0ef6ad4d20 docs: define search catalogs v1 2026-08-25 08:22:07 +03:00
bc3951449a docs: align backend version 2026-08-25 07:35:13 +03:00
9b629d93e3 fix: complete search API contract 2026-08-25 07:29:06 +03:00
0bef90debd fix: restrict search to current editions 2026-08-21 07:24:16 +03:00
73ff287b20 feat: add search API contract 2026-08-21 06:12:47 +03:00
8f47dbb4f0 Merge pull request 'feat: atomically switch active search index alias' (#16) from feature/opensearch-alias-switch into main
Reviewed-on: #16
2026-08-21 03:06:45 +00:00
1ad2e4b256 docs: record index alias switch 2026-08-21 00:19:38 +03:00
61681cde73 fix: validate search alias checkpoints 2026-08-21 00:18:28 +03:00
9781e93eaf fix: preserve alias in search load checkpoint 2026-08-21 00:15:24 +03:00
601c7f085f feat: switch search index alias atomically 2026-08-21 00:11:57 +03:00
69d4090f6d Merge pull request 'Добавить план готовности к проектированию frontend' (#15) from docs/frontend-design-readiness into main
Reviewed-on: #15
2026-08-20 20:58:12 +00:00
85672dc041 docs: record frontend readiness roadmap 2026-08-20 23:25:12 +03:00
0f6f12e0e9 docs: add frontend design readiness plan 2026-08-20 23:23:19 +03:00
7a656cfc1a Merge pull request 'Исправить выдачу для открытия ОсОО и ЖЧК' (#14) from fix/company-registration-search into main
Reviewed-on: #14
2026-08-20 20:17:17 +00:00
27 changed files with 1356 additions and 29 deletions

View File

@@ -2,6 +2,19 @@
## Не выпущено
- Добавлено восстановление незавершённой разметки из `localStorage` после перезагрузки страницы.
- Добавлена внутренняя лаборатория проверки поисковой выдачи: просмотр документов,
оценка релевантности 03, комментарии и SQLite-экспорт подписанных снимков.
- Уточнены доступные состояния и адаптивное поведение внутреннего интерфейса
оценки поисковой выдачи.
- Добавлен план внутреннего интерфейса оценки поисковой выдачи юристами.
- Завершён Search API v1: стабильные справочники, валидный OpenAPI, безопасная
пагинация и проверка актуальных редакций в локальном OpenSearch.
- Добавлено безопасное переключение alias на новую версию поискового индекса
после полной загрузки; checkpoint защищает возобновление загрузки от смены
alias.
- Добавлен roadmap готовности данных, поиска и API перед проектированием
frontend.
- Исправлена выдача для явных запросов об открытии ОсОО и ЖЧК: первыми
показываются действующие положение о регистрации и закон о хозяйственных
товариществах и обществах.

View File

@@ -10,15 +10,15 @@ Telegram-бот — только часть рабочего окружения
## Текущее состояние
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии
`0.5.2`: подготовка поискового индекса и оценка Recall@K/MRR@K на вручную
`0.8.1`: добавлено сохранение черновика разметки в браузере.
размеченном наборе запросов.
| Компонент | Версия | Состояние |
|---|---:|---|
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
| Backend | `0.5.2` | добавлено ранжирование подтверждённых запросов об открытии ОсОО/ЖЧК |
| Backend | `0.8.1` | добавлено сохранение черновика разметки |
| Frontend | — | ещё не создан |
| Сбор и обработка правовых данных | `0.5.2` | добавлены 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.5.2 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.8.1 · Frontend — не создан

View File

@@ -1,6 +1,6 @@
# Backend Акылдаш
Версия: `0.5.2`
Версия: `0.8.1`
Первая backend-область проекта — загрузка правовых документов из официального
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
@@ -128,6 +128,16 @@ python3 backend/search/minjust_opensearch.py \
После проверки production-индекса следует переключать alias, чтобы удалённые
фрагменты не оставались в поиске.
Чтобы переключить alias атомарно только после успешной полной загрузки,
передайте `--alias`:
```bash
python3 backend/search/minjust_opensearch.py \
--url http://127.0.0.1:9200 \
--index akyldash-fragments-v2 \
--alias akyldash-fragments-current
```
После каждого принятого Bulk-пакета загрузчик атомарно сохраняет checkpoint и
печатает код безопасного возобновления. При временных HTTP 429/5xx, timeout и
обрыве соединения запрос повторяется автоматически. Прерванную загрузку можно
@@ -142,12 +152,29 @@ python3 backend/search/minjust_opensearch.py \
По умолчанию checkpoint хранится в
`data/opensearch/<index>.checkpoint.json`; путь можно изменить через
`--checkpoint`. Checkpoint привязан к URL, cluster UUID, index UUID, `--limit`
и SHA-256 нормализованного manifest. Resume отклоняется при любом несовпадении:
`--checkpoint`. Checkpoint привязан к URL, cluster UUID, index UUID, `--limit`,
`--alias` и SHA-256 нормализованного manifest. При `--resume` передавайте то же
значение `--alias`; старый checkpoint без alias можно продолжить только без
него. Resume отклоняется при любом несовпадении:
для обновлённого корпуса или пересозданного индекса нужно создать новый
версионный индекс, проверить его и переключить alias. Это не оставляет
удалённые trailing-фрагменты старых документов.
## HTTP API v1
Запустите публичный read-only API поверх текущего alias и нормализованного
корпуса:
```bash
PYTHONPATH=backend python3 -m search.api
```
Он публикует OpenAPI в `GET /openapi.json` и поддерживает `GET /search`,
`/search/filters`, `/documents/{code}`, `/documents/{code}/editions` и
`/documents/{code}/editions/{edition}`. Значения фильтров возвращаются со
стабильным кодом справочника v1 и подписями RU/KY; применяйте `code` как
параметр поиска. API не подменяет отсутствующий язык документа.
## Локальный OpenSearch
Стенд использует один узел OpenSearch без Dashboards, устанавливает
@@ -189,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
@@ -198,4 +240,4 @@ PYTHONPATH=backend python3 -m search.query "ЖЧК ачуу тартиби" --la
---
Акылдаш · Backend v0.5.2 · Frontend — не создан
Акылдаш · Backend v0.8.1 · Frontend — не создан

View File

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

View File

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

365
backend/search/api.py Normal file
View File

@@ -0,0 +1,365 @@
"""Minimal HTTP API for the normalized legal-document corpus."""
from __future__ import annotations
import argparse
import datetime
import json
import re
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
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
MAX_RESULT_WINDOW = 10_000
class ApiError(Exception):
def __init__(self, status: int, message: str):
self.status = status
self.message = message
def parse_positive(value: str | None, name: str, default: int, maximum: int) -> int:
if value is None:
return default
try:
parsed = int(value)
except ValueError as error:
raise ApiError(400, f"{name} must be an integer") from error
if not 1 <= parsed <= maximum:
raise ApiError(400, f"{name} must be between 1 and {maximum}")
return parsed
def one(query: dict[str, list[str]], name: str) -> str | None:
values = query.get(name, [])
if len(values) > 1:
raise ApiError(400, f"{name} must be specified once")
return values[0] if values else None
def date(value: str | None, name: str) -> str | None:
if value is None:
return None
try:
datetime.date.fromisoformat(value)
except ValueError as error:
raise ApiError(400, f"{name} must be an ISO date") from error
return value
def openapi() -> dict:
responses = {"200": {"description": "Successful response"}, "400": {"description": "Invalid request"}, "404": {"description": "Not found"}, "502": {"description": "Search backend unavailable"}}
return {
"openapi": "3.0.3",
"info": {"title": "Akyldash Search API", "version": API_VERSION},
"paths": {
"/search": {"get": {"responses": responses, "parameters": [
{"name": "q", "in": "query", "required": True, "schema": {"type": "string"}},
{"name": "language", "in": "query", "schema": {"type": "string", "enum": ["ru", "ky"]}},
{"name": "page", "in": "query", "schema": {"type": "integer", "minimum": 1}},
{"name": "page_size", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": MAX_PAGE_SIZE}},
{"name": "document_type", "in": "query", "schema": {"type": "string"}},
{"name": "status", "in": "query", "schema": {"type": "string"}},
{"name": "authority", "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]+$"}}]}},
},
}
class Api:
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}"
def query_opensearch(self, body: dict) -> dict:
try:
return request_json(self.search_url("_search"), "POST", json.dumps(body, ensure_ascii=False).encode(), "application/json")
except RuntimeError as error:
raise ApiError(502, "search backend is unavailable") from error
def search(self, query: dict[str, list[str]]) -> dict:
text = one(query, "q")
if not text or not text.strip():
raise ApiError(400, "q is required")
if len(text) > 500:
raise ApiError(400, "q must not exceed 500 characters")
language = one(query, "language") or "ru"
if language not in LANGUAGES:
raise ApiError(400, "language must be ru or ky")
page = parse_positive(one(query, "page"), "page", 1, 1_000_000)
page_size = parse_positive(one(query, "page_size"), "page_size", 20, MAX_PAGE_SIZE)
if page * page_size >= MAX_RESULT_WINDOW:
raise ApiError(400, f"page and page_size must stay within {MAX_RESULT_WINDOW} results")
sort = one(query, "sort") or "relevance"
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"}
for parameter, field in fields.items():
value = one(query, parameter)
if value:
if value not in CATALOGS[parameter]:
raise ApiError(400, f"{parameter} must be a catalog code")
filters.append({"term": {field: value}})
date_from, date_to = date(one(query, "date_from"), "date_from"), date(one(query, "date_to"), "date_to")
if date_from and date_to and date_from > date_to:
raise ApiError(400, "date_from must not be later than date_to")
if date_from or date_to:
date_range = {key: value for key, value in (("gte", date_from), ("lte", date_to)) if value}
filters.append({"range": {"date_adopted": date_range}})
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"],
"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}}},
}
if sort == "date":
body["sort"] = [{"date_adopted": "desc"}, {"_score": "desc"}]
response = self.query_opensearch(body)
try:
hits = response["hits"]["hits"]
except (KeyError, TypeError) as error:
raise ApiError(502, "search backend returned an incomplete response") from error
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:
source = hit.get("_source")
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}
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")}
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:
aggregations = response["aggregations"]
values = {
name: [{"code": item["key"], "labels": labels(pair[0], item["key"]), "count": item["documents"]["value"]} for item in aggregations[name]["buckets"]]
for name, pair in fields.items()
}
except (KeyError, TypeError) as error:
raise ApiError(502, "search backend returned incomplete filters") from error
return {"api_version": API_VERSION, "language": language, **values}
def directory(self, code: str) -> Path:
if not CODE.fullmatch(code):
raise ApiError(404, "document not found")
path = self.data_root / "documents" / code
if not path.is_dir():
raise ApiError(404, "document not found")
return path
@staticmethod
def read_json(path: Path, message: str) -> dict:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as error:
raise ApiError(500, message) from error
if not isinstance(value, dict):
raise ApiError(500, message)
return value
def document(self, code: str) -> dict:
document = self.read_json(self.directory(code) / "document.json", "document data is unavailable")
editions = document.get("editions")
if not isinstance(editions, list):
raise ApiError(500, "document data is unavailable")
return {"api_version": API_VERSION, "document": document, "current_edition": editions[-1] if editions else None}
def editions(self, code: str) -> dict:
document = self.read_json(self.directory(code) / "document.json", "document data is unavailable")
return {"api_version": API_VERSION, "code": code, "available_languages": document.get("available_languages", []), "editions": document.get("editions", [])}
def edition(self, code: str, edition: str, query: dict[str, list[str]]) -> dict:
if not CODE.fullmatch(edition):
raise ApiError(404, "edition not found")
directory = self.directory(code) / "editions" / edition
if not directory.is_dir():
raise ApiError(404, "edition not found")
metadata = self.read_json(directory / "edition.json", "edition data is unavailable")
language = one(query, "language")
if language is not None and language not in LANGUAGES:
raise ApiError(400, "language must be ru or ky")
languages = [language] if language else metadata.get("available_languages", [])
content = {}
for item in languages:
if item not in metadata.get("available_languages", []):
continue
try:
content[item] = {"html": (directory / item / "content.html").read_text(encoding="utf-8"), "text": (directory / item / "content.txt").read_text(encoding="utf-8")}
except (OSError, UnicodeError) as error:
raise ApiError(500, "edition content is unavailable") from error
if language and language not in content:
raise ApiError(404, "edition language not found")
return {"api_version": API_VERSION, "edition": metadata, "content": content}
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"]:
return 200, self.search(query)
if parts == ["search", "filters"]:
return 200, self.filters(query)
if len(parts) == 2 and parts[0] == "documents":
return 200, self.document(parts[1])
if len(parts) == 3 and parts[:1] == ["documents"] and parts[2] == "editions":
return 200, self.editions(parts[1])
if len(parts) == 4 and parts[:1] == ["documents"] and parts[2] == "editions":
return 200, self.edition(parts[1], parts[3], query)
raise ApiError(404, "endpoint not found")
def handler(api: Api):
class RequestHandler(BaseHTTPRequestHandler):
def respond(self, method: str):
try:
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()
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
self.respond("GET")
def do_POST(self):
self.respond("POST")
def log_message(self, format: str, *args):
return
return RequestHandler
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
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()
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
if __name__ == "__main__":
raise SystemExit(main())

51
backend/search/catalog.py Normal file
View File

@@ -0,0 +1,51 @@
"""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": ("Не указан", "Көрсөтүлгөн эмес"),
}
STATUSES = {"active": ("Действует", "Күчүндө"), "repealed": ("Утратил силу", "Күчүн жоготту"), "unspecified": ("Не указан", "Көрсөтүлгөн эмес")}
AUTHORITIES = {"president": ("Президент", "Президент"), "parliament": ("Органы законодательной власти", "Мыйзам чыгаруу бийлик органдары"), "cabinet": ("Правительство и Кабинет Министров", "Өкмөт жана Министрлер Кабинети"), "ministries_and_committees": ("Министерства и государственные комитеты", "Министрликтер жана мамлекеттик комитеттер"), "administrative_agencies": ("Административные ведомства", "Административдик ведомстволор"), "national_bank": ("Национальный банк", "Улуттук банк"), "other_state_bodies": ("Иные государственные органы", "Башка мамлекеттик органдар"), "local_representative_bodies": ("Представительные органы местного самоуправления", "Жергиликтүү өз алдынча башкаруунун өкүлчүлүктүү органдары"), "other": ("Прочие органы", "Башка органдар")}
CATALOGS = {"document_type": DOCUMENT_TYPES, "status": STATUSES, "authority": AUTHORITIES}
def labels(category: str, code: str) -> dict[str, str]:
try:
ru, ky = CATALOGS[category][code]
except KeyError as error:
raise ValueError(f"Unknown {category} catalog code: {code}") from error
return {"ru": ru, "ky": ky}
def source_code(category: str, value: dict | None) -> str:
pair = ((value or {}).get("ru"), (value or {}).get("ky"))
if pair == (None, None):
return "unspecified"
for code, expected in CATALOGS[category].items():
if pair == expected or category == "document_type" and code == "provision" and pair == ("Положение", "Положение"):
return code
raise ValueError(f"Unmapped {category} catalog value: {pair!r}")
def authority_codes(paths: list[dict]) -> list[str]:
codes = set()
for path in paths:
text = " ".join(path.get("ru", []) + path.get("ky", [])).lower()
if "президент" in text:
codes.add("president")
elif "жогорку кенеш" in text or "верховный совет" in text or "мыйзам чыгаруу" in text:
codes.add("parliament")
elif "кабинет министров" in text or "правительство" in text or "өкмөт" in text:
codes.add("cabinet")
elif "министер" in text or "мамлекеттик комитет" in text:
codes.add("ministries_and_committees")
elif "административ" in text:
codes.add("administrative_agencies")
elif "национальн" in text and "банк" in text or "улуттук банк" in text:
codes.add("national_bank")
elif "кенеш" in text or "кеңеш" in text or "айыл" in text or "местного самоуправления" in text:
codes.add("local_representative_bodies")
elif "иные государственные" in text or "башка мамлекеттик" in text:
codes.add("other_state_bodies")
else:
codes.add("other")
return sorted(codes) or ["other"]

View File

@@ -12,6 +12,7 @@
"schema_version": { "type": "keyword" },
"document_code": { "type": "keyword" },
"edition_code": { "type": "keyword" },
"is_current_edition": { "type": "boolean" },
"language": { "type": "keyword" },
"position": { "type": "integer" },
"fragment_type": { "type": "keyword" },
@@ -21,12 +22,15 @@
"document_name_ky": { "type": "text", "analyzer": "icu_analyzer", "fields": { "keyword": { "type": "keyword", "ignore_above": 1024 } } },
"document_type_ru": { "type": "keyword" },
"document_type_ky": { "type": "keyword" },
"document_type_code": { "type": "keyword" },
"status_ru": { "type": "keyword" },
"status_ky": { "type": "keyword" },
"status_code": { "type": "keyword" },
"number": { "type": "keyword" },
"date_adopted": { "type": "date", "format": "strict_date" },
"authority_paths_ru": { "type": "keyword", "ignore_above": 2048 },
"authority_paths_ky": { "type": "keyword", "ignore_above": 2048 },
"authority_codes": { "type": "keyword" },
"source_path": { "type": "keyword", "index": false },
"source_sha256": { "type": "keyword", "index": false },
"text_sha256": { "type": "keyword", "index": false }

View File

@@ -15,7 +15,9 @@ import urllib.request
from pathlib import Path
from typing import Iterator
APP_VERSION = "0.5.2"
from search.catalog import authority_codes, source_code
APP_VERSION = "0.8.1"
LANGUAGES = {"ru", "ky"}
DEFAULT_MAPPING = Path(__file__).with_name("minjust-fragments-index.json")
@@ -42,7 +44,7 @@ def paths(document: dict, field: str, language: str) -> list[str]:
return [" > ".join(item[language]) for item in document.get(field, []) if item.get(language)]
def search_document(document: dict, fragment: dict, expected: tuple[str, str, str, int]) -> dict:
def search_document(document: dict, fragment: dict, expected: tuple[str, str, str, int], current_edition: str) -> dict:
document_code, edition_code, language, position = expected
fragment_id = f"document:{document_code}:edition:{edition_code}:lang:{language}:fragment:{position}"
required = ("id", "document_code", "edition_code", "language", "position", "type", "text", "text_sha256", "source_path", "source_sha256")
@@ -64,6 +66,7 @@ def search_document(document: dict, fragment: dict, expected: tuple[str, str, st
"schema_version": document["schema_version"],
"document_code": document_code,
"edition_code": edition_code,
"is_current_edition": edition_code == current_edition,
"language": language,
"position": position,
"fragment_type": fragment["type"],
@@ -72,12 +75,15 @@ def search_document(document: dict, fragment: dict, expected: tuple[str, str, st
"document_name_ky": localized(document.get("name"), "ky"),
"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")),
"status_ru": localized(document.get("status"), "ru"),
"status_ky": localized(document.get("status"), "ky"),
"status_code": source_code("status", document.get("status")),
"number": document.get("number"),
"date_adopted": dates.get("DateAdopted"),
"authority_paths_ru": paths(document, "authority_paths", "ru"),
"authority_paths_ky": paths(document, "authority_paths", "ky"),
"authority_codes": authority_codes(document.get("authority_paths", [])),
"source_path": fragment["source_path"],
"source_sha256": fragment["source_sha256"],
"text_sha256": fragment["text_sha256"],
@@ -118,13 +124,18 @@ def bulk_pairs(
document = read_json(directory / "document.json")
if document.get("source_code") != directory.name:
raise ValueError(f"Document identity does not match its path: {directory}")
edition_root = directory / "editions"
editions = [path.name for path in edition_root.iterdir() if path.is_dir()] if edition_root.is_dir() else []
if not editions:
continue
current_edition = max(editions, key=lambda value: (not value.isdigit(), int(value) if value.isdigit() else value))
for fragment_path in sorted(directory.glob("editions/*/*/fragments.json")):
edition_code, language = fragment_path.parts[-3:-1]
values = read_json(fragment_path)
if not isinstance(values, list):
raise ValueError(f"Fragments must be a list: {fragment_path}")
for position, fragment in enumerate(values, 1):
source = search_document(document, fragment, (directory.name, edition_code, language, position))
source = search_document(document, fragment, (directory.name, edition_code, language, position), current_edition)
action = json.dumps({"index": {"_index": index, "_id": fragment["id"]}}, ensure_ascii=False, allow_nan=False)
body = json.dumps(source, ensure_ascii=False, allow_nan=False)
yield directory.name, f"{action}\n{body}\n".encode()
@@ -246,6 +257,7 @@ def checkpoint_state(
cluster_uuid: str,
index_uuid: str,
limit: int | None,
alias: str | None,
) -> tuple[dict, str | None]:
source = input_root.resolve()
destination = checkpoint.resolve()
@@ -254,7 +266,7 @@ def checkpoint_state(
manifest_sha256 = file_sha256(input_root / "manifest.sqlite3")
if not resume:
return {
"schema_version": 1,
"schema_version": 2,
"url": url,
"cluster_uuid": cluster_uuid,
"index": index,
@@ -262,6 +274,7 @@ def checkpoint_state(
"input": str(source),
"manifest_sha256": manifest_sha256,
"limit": limit,
"alias": alias,
"last_document_code": None,
"complete": False,
}, None
@@ -276,13 +289,22 @@ def checkpoint_state(
"input",
"manifest_sha256",
"limit",
"alias",
"last_document_code",
"complete",
}
if not isinstance(state, dict) or set(state) != expected:
legacy_expected = expected - {"alias"}
if not isinstance(state, dict):
raise ValueError(f"Invalid checkpoint: {checkpoint}")
if set(state) == legacy_expected:
if state["schema_version"] != 1 or alias is not None:
raise ValueError(f"Legacy checkpoint does not support --alias: {checkpoint}")
state["schema_version"] = 2
state["alias"] = None
elif set(state) != expected:
raise ValueError(f"Invalid checkpoint: {checkpoint}")
if (
state["schema_version"] != 1
state["schema_version"] != 2
or state["url"] != url
or state["cluster_uuid"] != cluster_uuid
or state["index"] != index
@@ -292,6 +314,8 @@ def checkpoint_state(
raise ValueError(f"Checkpoint does not match this load: {checkpoint}")
if state["limit"] != limit:
raise ValueError(f"Checkpoint limit does not match --limit: {checkpoint}")
if state["alias"] != alias:
raise ValueError(f"Checkpoint alias does not match --alias: {checkpoint}")
if state["manifest_sha256"] != manifest_sha256:
raise ValueError("Normalized manifest changed; create a new versioned index")
if state["complete"] is not False:
@@ -311,8 +335,11 @@ def load_bulk(
limit: int | None = None,
resume: bool = False,
checkpoint: Path = Path("data/opensearch/minjust-fragments.checkpoint.json"),
alias: str | None = None,
) -> tuple[int, int]:
base = url.rstrip("/")
if alias is not None and (not alias or alias == index):
raise ValueError("--alias must differ from --index")
index_url = f"{base}/{urllib.parse.quote(index, safe='')}"
if resume:
cluster_uuid, index_uuid = opensearch_identity(base, index, index_url)
@@ -328,6 +355,7 @@ def load_bulk(
cluster_uuid,
index_uuid,
limit,
alias,
)
documents = document_count(input_root, limit, start_at)
if not resume:
@@ -363,11 +391,31 @@ def load_bulk(
state["last_document_code"] = last_code
write_json_atomic(checkpoint, state)
print(f"checkpoint={last_code} fragments={fragments}", flush=True)
if alias:
switch_alias(base, index, alias)
state["complete"] = True
write_json_atomic(checkpoint, state)
return documents, fragments
def switch_alias(base: str, index: str, alias: str) -> None:
if not alias or alias == index:
raise ValueError("--alias must differ from --index")
result = request_json(
f"{base.rstrip('/')}/_aliases",
"POST",
json.dumps({
"actions": [
{"remove": {"index": "*", "alias": alias, "must_exist": False}},
{"add": {"index": index, "alias": alias}},
]
}).encode(),
"application/json",
)
if result.get("acknowledged") is not True:
raise RuntimeError(f"OpenSearch did not acknowledge alias switch: {alias}")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", type=Path, default=Path("data/minjust-normalized"))
@@ -375,6 +423,7 @@ def main() -> int:
parser.add_argument("--index", default="akyldash-fragments-v1")
parser.add_argument("--limit", type=int)
parser.add_argument("--url", help="create the index and stream bounded Bulk requests instead of writing a file")
parser.add_argument("--alias", help="atomically point this alias at --index after a successful load")
parser.add_argument("--mapping", type=Path, default=DEFAULT_MAPPING)
parser.add_argument("--batch-mb", type=int, default=25)
parser.add_argument("--resume", action="store_true", help="load into an existing index")
@@ -389,6 +438,10 @@ def main() -> int:
raise SystemExit("--resume requires --url")
if arguments.checkpoint and not arguments.url:
raise SystemExit("--checkpoint requires --url")
if arguments.alias == "":
raise SystemExit("--alias must not be empty")
if arguments.alias is not None and not arguments.url:
raise SystemExit("--alias requires --url")
if arguments.url:
checkpoint = arguments.checkpoint or Path("data/opensearch") / f"{arguments.index}.checkpoint.json"
documents, fragments = load_bulk(
@@ -400,6 +453,7 @@ def main() -> int:
arguments.limit,
arguments.resume,
checkpoint,
arguments.alias,
)
destination = arguments.url
else:

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

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

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

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

View File

@@ -9,10 +9,27 @@ from contextlib import closing
from pathlib import Path
from unittest.mock import patch
from search.minjust_opensearch import bulk_batches, document_codes, export_bulk, load_bulk, request_json
from search.minjust_opensearch import bulk_batches, document_codes, export_bulk, load_bulk, request_json, switch_alias
class MinjustOpenSearchTest(unittest.TestCase):
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")
self.assertEqual(request.call_args.args[:2], ("http://127.0.0.1:9200/_aliases", "POST"))
body = json.loads(request.call_args.args[2])
self.assertEqual(body["actions"][0], {"remove": {"index": "*", "alias": "akyldash-fragments-current", "must_exist": False}})
self.assertEqual(body["actions"][1], {"add": {"index": "akyldash-fragments-v2", "alias": "akyldash-fragments-current"}})
with self.assertRaisesRegex(ValueError, "differ"):
switch_alias("http://127.0.0.1:9200", "same", "same")
with self.assertRaisesRegex(ValueError, "differ"):
switch_alias("http://127.0.0.1:9200", "index", "")
with patch("search.minjust_opensearch.request_json", return_value={"acknowledged": False}):
with self.assertRaisesRegex(RuntimeError, "did not acknowledge"):
switch_alias("http://127.0.0.1:9200", "index", "alias")
def test_exports_atomic_bulk_and_rejects_mismatched_fragment(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
@@ -74,6 +91,7 @@ class MinjustOpenSearchTest(unittest.TestCase):
self.assertEqual(len(lines), 4)
self.assertEqual(lines[0]["index"]["_id"], "document:7:edition:10:lang:ky:fragment:1")
self.assertIn("text_ky", lines[1])
self.assertTrue(lines[1]["is_current_edition"])
self.assertNotIn("text_ru", lines[1])
self.assertEqual(lines[3]["text_ru"], "Текст \"RU\"\nстрока")
self.assertEqual(
@@ -91,6 +109,7 @@ class MinjustOpenSearchTest(unittest.TestCase):
{"cluster_uuid": "cluster-1"},
{"test-index": {"settings": {"index": {"uuid": "index-1"}}}},
{"errors": False, "items": [{"index": {}}, {"index": {}}]},
{"acknowledged": True},
]
self.assertEqual(
load_bulk(
@@ -99,14 +118,40 @@ class MinjustOpenSearchTest(unittest.TestCase):
"test-index",
maximum_bytes=4096,
checkpoint=checkpoint,
alias="test-current",
),
(2, 2),
)
self.assertEqual(request.call_args_list[-1].args[3], "application/x-ndjson")
self.assertEqual(request.call_args_list[-2].args[3], "application/x-ndjson")
self.assertEqual(request.call_args_list[-1].args[:2], ("http://127.0.0.1:9200/_aliases", "POST"))
state = json.loads(checkpoint.read_text(encoding="utf-8"))
self.assertEqual(state["last_document_code"], "7")
self.assertTrue(state["complete"])
legacy = state.copy()
legacy.pop("alias")
legacy["schema_version"] = 1
legacy["last_document_code"] = "8"
legacy["complete"] = False
checkpoint.write_text(json.dumps(legacy), encoding="utf-8")
with patch("search.minjust_opensearch.request_json") as request:
request.side_effect = [
{"cluster_uuid": "cluster-1"},
{"test-index": {"settings": {"index": {"uuid": "index-1"}}}},
]
self.assertEqual(
load_bulk(
root / "normalized",
"http://127.0.0.1:9200",
"test-index",
maximum_bytes=4096,
resume=True,
checkpoint=checkpoint,
),
(1, 0),
)
self.assertEqual(json.loads(checkpoint.read_text(encoding="utf-8"))["schema_version"], 2)
state["last_document_code"] = "8"
state["complete"] = False
checkpoint.write_text(json.dumps(state), encoding="utf-8")
@@ -116,6 +161,21 @@ class MinjustOpenSearchTest(unittest.TestCase):
{"test-index": {"settings": {"index": {"uuid": "index-1"}}}},
]
with self.assertRaisesRegex(ValueError, "does not match"):
load_bulk(
root / "normalized",
"http://127.0.0.1:9200",
"test-index",
maximum_bytes=4096,
resume=True,
checkpoint=checkpoint,
alias="test-current",
)
with patch("search.minjust_opensearch.request_json") as request:
request.side_effect = [
{"cluster_uuid": "cluster-1"},
{"test-index": {"settings": {"index": {"uuid": "index-1"}}}},
]
with self.assertRaisesRegex(ValueError, "alias"):
load_bulk(
root / "normalized",
"http://127.0.0.1:9200",
@@ -138,11 +198,13 @@ class MinjustOpenSearchTest(unittest.TestCase):
limit=1,
resume=True,
checkpoint=checkpoint,
alias="test-current",
)
with patch("search.minjust_opensearch.request_json") as request:
request.side_effect = [
{"cluster_uuid": "cluster-1"},
{"test-index": {"settings": {"index": {"uuid": "index-1"}}}},
{"acknowledged": True},
]
self.assertEqual(
load_bulk(
@@ -152,10 +214,12 @@ class MinjustOpenSearchTest(unittest.TestCase):
maximum_bytes=4096,
resume=True,
checkpoint=checkpoint,
alias="test-current",
),
(1, 0),
)
self.assertTrue(all(call.args[1] == "GET" for call in request.call_args_list))
self.assertTrue(all(call.args[1] == "GET" for call in request.call_args_list[:-1]))
self.assertEqual(request.call_args_list[-1].args[1], "POST")
state["last_document_code"] = "9"
state["complete"] = False
@@ -173,6 +237,7 @@ class MinjustOpenSearchTest(unittest.TestCase):
maximum_bytes=4096,
resume=True,
checkpoint=checkpoint,
alias="test-current",
)
failed_checkpoint = root / "failed-checkpoint.json"
@@ -218,6 +283,7 @@ class MinjustOpenSearchTest(unittest.TestCase):
maximum_bytes=4096,
resume=True,
checkpoint=checkpoint,
alias="test-current",
)
http_error = urllib.error.HTTPError(

View File

@@ -0,0 +1,68 @@
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from search.api import Api, ApiError
class SearchApiTest(unittest.TestCase):
def make_api(self, root: Path) -> Api:
document = root / "documents/7"
edition = document / "editions/10"
edition.mkdir(parents=True)
(document / "document.json").write_text(json.dumps({
"source_code": "7", "available_languages": ["ru"],
"editions": [{"source_code": "10", "available_languages": ["ru"]},],
}), encoding="utf-8")
(edition / "edition.json").write_text(json.dumps({"source_code": "10", "available_languages": ["ru"]}), encoding="utf-8")
(edition / "ru").mkdir()
(edition / "ru/content.html").write_text("<p>Текст</p>", encoding="utf-8")
(edition / "ru/content.txt").write_text("Текст\n", encoding="utf-8")
return Api("http://opensearch:9200", "current", root)
def test_search_pagination_filters_and_highlight(self):
with tempfile.TemporaryDirectory() as temporary:
api = self.make_api(Path(temporary))
response = {"hits": {"hits": [{"_source": {"document_code": "7", "edition_code": "10", "document_name_ru": "Закон"}, "highlight": {"text_ru": ["<em>Закон</em>"]}}]}}
with patch("search.api.request_json", return_value=response) as request:
status, payload = api.handle("GET", "/search?q=%D0%B7%D0%B0%D0%BA%D0%BE%D0%BD&language=ru&page=2&page_size=5&status=active")
self.assertEqual(status, 200)
self.assertEqual(payload["results"][0]["snippet"], "<em>Закон</em>")
body = json.loads(request.call_args.args[2])
self.assertEqual((body["from"], body["size"]), (5, 6))
self.assertIn({"term": {"status_code": "active"}}, body["query"]["bool"]["filter"])
with self.assertRaisesRegex(ApiError, "within 10000 results"):
api.handle("GET", "/search?q=x&page=100&page_size=100")
def test_document_editions_openapi_and_validation(self):
with tempfile.TemporaryDirectory() as temporary:
api = self.make_api(Path(temporary))
specification = api.handle("GET", "/openapi.json")[1]
self.assertEqual(specification["info"]["version"], "v1")
self.assertEqual(specification["paths"]["/documents/{code}"]["get"]["parameters"][0]["required"], True)
self.assertEqual(specification["paths"]["/documents/{code}/editions/{edition}"]["get"]["parameters"][1]["name"], "edition")
self.assertEqual(api.handle("GET", "/documents/7")[1]["current_edition"]["source_code"], "10")
self.assertEqual(api.handle("GET", "/documents/7/editions/10?language=ru")[1]["content"]["ru"]["text"], "Текст\n")
with self.assertRaisesRegex(ApiError, "q is required"):
api.handle("GET", "/search")
with self.assertRaisesRegex(ApiError, "date_from must be an ISO date"):
api.handle("GET", "/search?q=x&date_from=tomorrow")
with self.assertRaisesRegex(ApiError, "document not found"):
api.handle("GET", "/documents/%2E%2E")
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")}}
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})
body = json.loads(request.call_args.args[2])
self.assertIn("terms", body["aggs"]["document_types"])
self.assertEqual(body["query"], {"term": {"is_current_edition": True}})
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,37 @@
import json
import os
import unittest
import uuid
from pathlib import Path
from search.api import Api
from search.minjust_opensearch import DEFAULT_MAPPING, request_json
@unittest.skipUnless(os.getenv("AKYLDASH_OPENSEARCH_URL"), "set AKYLDASH_OPENSEARCH_URL to run against local OpenSearch")
class SearchApiOpenSearchTest(unittest.TestCase):
def test_current_editions_filters_and_catalogs(self):
base_url = os.environ["AKYLDASH_OPENSEARCH_URL"].rstrip("/")
index = f"akyldash-api-test-{uuid.uuid4().hex}"
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"]},
]
for number, document in enumerate(documents):
request_json(f"{base_url}/{index}/_doc/{number}", "PUT", json.dumps(document).encode(), "application/json")
request_json(f"{base_url}/{index}/_refresh", "POST", None, "application/json")
api = Api(base_url, index, Path("."))
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"])
filters = api.handle("GET", "/search/filters")[1]
self.assertEqual({item["count"] for item in filters["statuses"]}, {2})
finally:
request_json(f"{base_url}/{index}", "DELETE", None, "application/json")
if __name__ == "__main__":
unittest.main()

View File

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

View File

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

View File

@@ -78,4 +78,4 @@ Telegram позволяет запретить пользователям отп
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан

View File

@@ -5,7 +5,7 @@
- Telegram-бот: `0.2.2`
- Telegram-бот на Synology: `0.2.1`
- Backend: `0.5.2`
- Backend: `0.7.1`
- Frontend: не создан
## Краткий итог
@@ -234,4 +234,4 @@
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан

View File

@@ -398,4 +398,4 @@ Git сохраняет актуальную версию
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан

View File

@@ -0,0 +1,126 @@
# Готовность к проектированию frontend
Этот документ определяет обязательный результат до начала проектирования и
разработки пользовательского интерфейса. Он дополняет
[план frontend поисковой СПС](frontend-search-sps-plan.md): тот описывает MVP и
спринты frontend, этот — критерий перехода к ним.
## Правило перехода
Проектирование frontend начинается, когда выполнены все обязательные пункты
этого плана и пройден финальный readiness gate. До этого не создаются
`frontend/`, макеты, UI-компоненты или mock-данные, заменяющие неготовый
backend-контракт.
Вне этого этапа остаются аккаунты, уведомления, RAG, судебная практика и
внешние коммерческие сервисы.
## 1. Данные и поисковый индекс
### Результат
В локальном OpenSearch доступен воспроизводимо собранный корпус актуальных
редакций ЦБД Минюста КР, пригодный для поиска на русском и кыргызском языках.
### Обязательные работы
1. Подтвердить для каждого индексируемого документа наличие канонических
реквизитов: код, редакция, язык, статус, тип, орган, дата, номер, название
и ссылка на официальный источник.
2. Зафиксировать правила для документов без текста и одноязычных редакций:
они не скрываются и не получают выдуманный перевод.
3. Проверить versioned-индекс, mapping, анализаторы RU/KY, полноту актуальных
редакций и процедуру безопасной переиндексации с переключением alias.
4. Описать и выполнить воспроизводимый сценарий обновления:
выгрузка → нормализация → новый индекс → проверка → переключение alias.
### Критерий приёмки
Команда может повторить обновление на чистом локальном окружении, проверить
количество документов и фрагментов, а затем безопасно переключить поисковый
alias без смешивания старого и нового корпуса.
## 2. Relevance set и качество поиска
### Результат
Есть замороженный набор `relevance set v1` из 50 практических запросов:
минимум 25 на русском и 25 на кыргызском языках.
### Обязательные работы
1. Заполнить рабочую копию из `backend/search/relevance-set-v1.template.json`
естественными формулировками пользователей и подтверждёнными
`document_code` релевантных действующих актов.
2. Проверить каждый запрос по официальной ЦБД и локальному индексу; не
включать запросы без установленного эталонного результата.
3. Провести независимую вторую проверку языка, формулировки и полного списка
кодов. Спорные результаты не включать до согласования.
4. После проверки сохранить неизменяемую копию набора и baseline
`Recall@10`/`MRR@10`; новые правила ранжирования оценивать только сравнением
с этим baseline.
5. Отдельно проверить распространённые сокращения, опечатки и запросы о
практическом действии. Правило принимается, только если улучшает
подтверждённые запросы и не создаёт ложных срабатываний в негативных
сценариях.
### Критерий приёмки
Оценщик проходит на полном наборе, выводит метрики и выдачу для каждого
запроса; baseline и результаты его повторного запуска воспроизводимы.
## 3. Справочники и контракт API
### Результат
Frontend получает все юридически значимые данные через версионированный
OpenAPI-контракт, а не реконструирует их из текста фрагментов.
### Обязательные работы
1. Утвердить справочники v1: типы документов, органы принятия, статусы,
уровни действия и темы. Для каждого значения определить стабильный код и
подписи RU/KY.
2. Реализовать и описать OpenAPI для:
- `GET /search` — запрос, язык, фильтры, сортировка, серверная пагинация,
выдержка и подсветка;
- `GET /search/filters` — допустимые значения фильтров;
- `GET /documents/{code}` — карточка актуального документа;
- `GET /documents/{code}/editions` и
`GET /documents/{code}/editions/{edition}` — редакции и их содержимое.
3. Зафиксировать единые ответы для пустой выдачи, неизвестного документа,
недоступной редакции и ошибки upstream; для документов с одним языком
вернуть доступные языки явно.
4. Добавить контрактные и интеграционные проверки API на локальном OpenSearch:
поиск, фильтры, пагинация, сортировка, документ, редакции и одноязычные
акты.
### Критерий приёмки
OpenAPI опубликован вместе с backend, тестовый клиент получает реальные данные
по всем endpoint без mock-слоя, а результаты поиска открывают актуальный
документ и выбранную редакцию.
## 4. Финальный readiness gate
Перед началом frontend выполнить и зафиксировать один сквозной сценарий:
1. Обновить корпус из официальной ЦБД.
2. Нормализовать данные и собрать новый versioned-индекс.
3. Прогнать проверки индекса и relevance baseline.
4. Переключить alias на проверенный индекс.
5. Выполнить API-сценарии поиска, фильтрации, просмотра документа и редакции
на RU, KY и одноязычном документе.
Gate считается пройденным, если все проверки успешны, зафиксированы версии
backend и индекса, опубликованы известные ограничения и назначен ответственный
за юридическую проверку relevance set.
## Порядок issues
1. Собрать и независимо проверить relevance set v1.
2. Завершить проверку полноты данных и воспроизводимую переиндексацию с alias.
3. Утвердить справочники v1.
4. Реализовать OpenAPI и интеграционные проверки.
5. Провести финальный readiness gate и только затем открыть задачу на
проектирование frontend.

View File

@@ -258,4 +258,4 @@ runtime-зависимостями frontend. Регистрация в стор
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан

View File

@@ -214,4 +214,4 @@ python3 backend/normalization/minjust_cbd.py
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан

View File

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

View File

@@ -0,0 +1,78 @@
# Справочники Search API v1
Статус: утверждённый контракт для Search API v1.
`code` — неизменяемый идентификатор, независимый от языка. Клиент хранит и
передаёт только `code`; русское и кыргызское названия являются подписями и могут
исправляться без изменения кода.
## Типы документов
| Code | RU | KY |
| --- | --- | --- |
| `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` | Основные принципы | Основные принципы |
## Статусы
| Code | RU | KY |
| --- | --- | --- |
| `active` | Действует | Күчүндө |
| `repealed` | Утратил силу | Күчүн жоготту |
| `unspecified` | Не указан | Көрсөтүлгөн эмес |
## Органы принятия
Орган содержит два уровня: стабильную группу и конкретный орган. В v1 группы
следующие:
| Code | RU | KY |
| --- | --- | --- |
| `president` | Президент | Президент |
| `parliament` | Органы законодательной власти | Мыйзам чыгаруу бийлик органдары |
| `cabinet` | Правительство и Кабинет Министров | Өкмөт жана Министрлер Кабинети |
| `ministries_and_committees` | Министерства и государственные комитеты | Министрликтер жана мамлекеттик комитеттер |
| `administrative_agencies` | Административные ведомства | Административдик ведомстволор |
| `national_bank` | Национальный банк | Улуттук банк |
| `other_state_bodies` | Иные государственные органы | Башка мамлекеттик органдар |
| `local_representative_bodies` | Представительные органы местного самоуправления | Жергиликтүү өз алдынча башкаруунун өкүлчүлүктүү органдары |
| `other` | Прочие органы | Башка органдар |
Конкретные министерства, муниципальные и айылные кенеши не получают ID из
подписи: в выгрузке ЦБД их поле `Code` часто равно `null`. До появления
первичного неизменяемого идентификатора API v1 выдаёт и принимает только код
группы органа. Полный каталог конкретных органов — отдельная версия (`v2`),
когда источник предоставит такие идентификаторы либо будет утверждён вручную
поддерживаемый реестр.
## Правила совместимости
- Код из этой таблицы нельзя переиспользовать и нельзя менять его значение.
- Новое значение добавляется только новой записью; удалённое остаётся доступно
для старых документов.
- Запрос с неизвестным кодом возвращает `400`.

View File

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

View File

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

View File

@@ -48,4 +48,4 @@ python3 -m unittest -v
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан