Проверяйте результаты нашего OpenSearch по практическим юридическим запросам.
+
+
+
+
+
Результаты
+
Документ
Выберите результат, чтобы открыть текст.
+
+
+
+
+
+
+
+
+
diff --git a/backend/search/reviews.py b/backend/search/reviews.py
new file mode 100644
index 0000000..c965813
--- /dev/null
+++ b/backend/search/reviews.py
@@ -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
diff --git a/backend/test_search_reviews.py b/backend/test_search_reviews.py
new file mode 100644
index 0000000..52492e2
--- /dev/null
+++ b/backend/test_search_reviews.py
@@ -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()
diff --git a/deploy/production/.env.example b/deploy/production/.env.example
new file mode 100644
index 0000000..bf593d3
--- /dev/null
+++ b/deploy/production/.env.example
@@ -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
diff --git a/deploy/production/Dockerfile.backend b/deploy/production/Dockerfile.backend
new file mode 100644
index 0000000..9d63647
--- /dev/null
+++ b/deploy/production/Dockerfile.backend
@@ -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)"]
diff --git a/deploy/production/Dockerfile.opensearch b/deploy/production/Dockerfile.opensearch
new file mode 100644
index 0000000..d1f050b
--- /dev/null
+++ b/deploy/production/Dockerfile.opensearch
@@ -0,0 +1,3 @@
+FROM opensearchproject/opensearch:3.7.0
+
+RUN /usr/share/opensearch/bin/opensearch-plugin install --batch analysis-icu
diff --git a/deploy/production/README.md b/deploy/production/README.md
new file mode 100644
index 0000000..5f200b1
--- /dev/null
+++ b/deploy/production/README.md
@@ -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.
diff --git a/deploy/production/compose.yaml b/deploy/production/compose.yaml
new file mode 100644
index 0000000..116897c
--- /dev/null
+++ b/deploy/production/compose.yaml
@@ -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:
diff --git a/docs/README.md b/docs/README.md
index 6af5c58..88458e6 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -8,11 +8,10 @@
MVP, зависимости и спринты.
- [Готовность к проектированию frontend](product/frontend-design-readiness-plan.md) —
обязательные работы и критерии перехода к frontend.
-- [Бланк юридической проверки relevance set v1](product/relevance-set-v1-legal-review.md) —
- независимая верификация запросов и кодов актов; включает отдельные Excel-файлы
- для русского и кыргызского языков.
-- [Инструкция для юриста](product/Инструкция.pdf) — пошаговая
- проверка запросов и кодов в ЦБД Минюста КР.
+- [План интерфейса оценки поисковой выдачи](product/search-relevance-review-interface-plan.md) —
+ внутренняя лаборатория сбора оценок юристов для настройки OpenSearch.
+- [Разметка relevance set](../backend/search/RELEVANCE_ANNOTATION.md) — подготовка
+ эталонных документов и воспроизводимая оценка собственного поиска.
- [Задание по нормализации документов](product/minjust-document-normalization-agent-task.md) —
требования и критерии приёмки нормализатора ЦБД Минюста КР.
@@ -40,4 +39,4 @@
---
-Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан
+Акылдаш · Telegram-бот v0.2.2 · Backend v0.8.3 · Frontend — не создан
diff --git a/docs/operations/project-status.md b/docs/operations/project-status.md
index d4571e4..43f24f5 100644
--- a/docs/operations/project-status.md
+++ b/docs/operations/project-status.md
@@ -1,20 +1,21 @@
# Статус проекта
-Последняя проверка: 2026-09-06
+Последняя проверка: 2026-09-12
Назначение документа: быстро восстановить контекст проекта для участников команды и будущих агентов.
- Telegram-бот: `0.2.2`
- Telegram-бот на Synology: `0.2.1`
-- Backend: `0.7.1`
+- Backend: `0.8.3`
- Frontend: не создан
## Краткий итог
Репозиторий переориентирован с отдельного бота на весь проект юридической информационно-аналитической платформы. Telegram-бот выделен в инструмент рабочего окружения. Реализованы возобновляемая выгрузка документов из официального Open Data API ЦБД Минюста КР и их локальная воспроизводимая нормализация.
-Ближайшая цель — провести независимую юридическую верификацию relevance set
-v1, зафиксировать воспроизводимый baseline и только затем переходить к
-readiness gate frontend.
+Поисковая система и внутренняя лаборатория оценки выдачи реализованы. Ближайшая
+цель — проверить собственную выдачу на практических RU/KY-запросах, разобрать
+оценки юристов и зафиксировать baseline. ЦБД Минюста служит источником для
+проверки документов и редакций, а не системой для сравнения поисковой выдачи.
## Уже сделано
@@ -117,6 +118,8 @@ readiness gate frontend.
### Готовность поиска к frontend
+- Реализована внутренняя лаборатория для просмотра и оценки фактической
+ выдачи OpenSearch; пилот с юристами ещё не проведён.
- Не завершена независимая юридическая проверка всех 50 запросов relevance set
v1; спорные строки нельзя включать в baseline.
- Не зафиксированы неизменяемая копия подтверждённого relevance set и baseline
@@ -134,22 +137,25 @@ readiness gate frontend.
## Следующий этап
-### Юридическая верификация relevance set и readiness gate frontend
+### Проверка собственной поисковой выдачи
-Рекомендуемый порядок:
-
-1. Передать `Выборка-ru.xlsx` и кыргызский бланк двум независимым
- проверяющим и согласовать спорные строки по официальной ЦБД Минюста КР.
-2. Перенести только подтверждённые `document_code` в
- `data/search/relevance-set-v1.json` и сохранить неизменяемую копию набора.
-3. Рассчитать и зафиксировать baseline `Recall@10`/`MRR@10`.
-4. Выполнить воспроизводимое обновление корпуса, проверку versioned-индекса и
- переключение alias.
-5. Прогнать API-сценарии поиска, фильтрации и просмотра документа для RU, KY
- и одноязычных актов; после успешного gate открыть задачу на frontend.
+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 для
@@ -256,4 +262,4 @@ readiness gate frontend.
---
-Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан
+Акылдаш · Telegram-бот v0.2.2 · Backend v0.8.3 · Frontend — не создан
diff --git a/docs/product/legal-review-instructions.md b/docs/product/legal-review-instructions.md
deleted file mode 100644
index ef9d632..0000000
--- a/docs/product/legal-review-instructions.md
+++ /dev/null
@@ -1,109 +0,0 @@
-# Инструкция для юридической проверки relevance set v1
-
-Эта инструкция нужна для проверки того, какие нормативные акты действительно
-отвечают на реальные вопросы пользователей. Она не требует программирования,
-доступа к локальной базе или знания внутреннего устройства проекта.
-
-## Что нужно открыть
-
-Для русскоязычных запросов используйте файл
-`Выборка-ru.xlsx` и официальный поиск ЦБД Минюста КР:
-
-
-
-Кыргызский файл предназначен для отдельного проверяющего, владеющего кыргызским
-языком. Не нужно заполнять его, если вы не можете уверенно оценить смысл
-кыргызских запросов и актов.
-
-## Что означают столбцы Excel
-
-- **Запрос** — вопрос, который мог задать пользователь.
-- **Предварительные коды** — первичная техническая выборка. Она не является
- юридическим выводом и может содержать ошибки или неполный список.
-- **Подтверждённые коды** — итог вашей проверки. Здесь указываются все акты,
- прямо отвечающие на вопрос.
-- **Результат и комментарий** — напишите `подтверждено`, `исправлено` или
- `спорно`, а при исправлении или споре кратко объясните причину.
-
-## Как проверить одну строку
-
-1. Прочитайте запрос и предварительные коды, но не считайте их правильными
- заранее.
-2. Откройте официальный поиск и введите запрос или его ключевые слова. Начните
- с короткой естественной формулировки; при необходимости используйте поиск
- по наименованию, тексту, номеру документа, виду документа или органу.
-3. Для вопроса о действующем правиле включите в поиске опцию исключения
- недействующих документов. Если вопрос касается исторической редакции,
- наоборот проверьте нужную дату и редакцию.
-4. Откройте карточку каждого подходящего документа. Проверьте название,
- реквизиты, статус, дату и текст нормы. Совпадения отдельных слов или
- близкой темы недостаточно.
-5. Внесите в «Подтверждённые коды» все акты, которые прямо отвечают на вопрос.
- Несколько кодов записывайте через запятую.
-6. Запишите результат. Если предварительный список не менялся, укажите
- `подтверждено`. Если что-то добавили или исключили — `исправлено` и причину.
-
-## Где взять код документа
-
-После открытия карточки посмотрите на адресную строку браузера. В её пути код
-документа расположен перед словом `edition`, а номер редакции — сразу после
-этого слова. В Excel нужно перенести **код документа**, но не номер редакции.
-Копируйте код точно, включая дефисы, если они есть.
-
-Если адресная строка не видна или формат отличается, откройте в карточке
-раздел «Реквизиты», скопируйте ссылку на документ в комментарий и укажите его
-название, дату и номер. Техническая команда затем сопоставит карточку с кодом.
-
-## Если код или документ не удалось найти
-
-Не подбирайте код наугад и не оставляйте предварительный код как подтверждённый.
-В «Результат и комментарий» напишите `спорно — код не найден`, затем добавьте:
-
-- точный текст запроса, который вводили;
-- ссылку на страницу поиска или карточку, если она открылась;
-- название, дату и номер акта, если они известны;
-- почему акт кажется релевантным или почему результаты не позволяют решить.
-
-Такую строку передают на повторный поиск технической команде и второму юристу.
-Она не попадёт в итоговый набор, пока вопрос не будет решён.
-
-## Когда акт считать релевантным
-
-Включайте акт, если его текст или реквизиты прямо отвечают на информационную
-потребность запроса. Например, для вопроса о порядке регистрации нужны акты,
-которые устанавливают этот порядок или обязательные условия регистрации.
-
-Не включайте акт только потому, что он:
-
-- содержит отдельные слова из запроса;
-- упоминает нужную тему без ответа на вопрос;
-- относится к смежной, но другой процедуре;
-- утратил силу, если пользователь ищет действующее правило.
-
-Если у вопроса несколько самостоятельных правильных источников, внесите все
-релевантные коды, а не только первый найденный документ.
-
-## Передача результата
-
-Сохраните Excel под новым именем, например
-`Выборка-ru-иванова.xlsx`, и не меняйте ID запросов,
-сам текст запросов и предварительные коды. Передайте файл вместе с вопросами,
-помеченными как `спорно`.
-
-Для независимой проверки другой юрист должен заново просмотреть все строки или
-как минимум все исправленные и спорные строки. Только согласованный результат
-переносится в итоговый набор для оценки поиска.
-
-## Полезные ссылки
-
-- Официальный поиск ЦБД Минюста КР:
-- Руководство пользователя и описание поиска ЦБД:
-
-- Государственный реестр НПА:
-
-Не добавляйте в Excel персональные данные, закрытые документы или логины и
-пароли. Для проверки достаточно публичной правовой информации ЦБД.
-
----
-
-Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан
diff --git a/docs/product/relevance-set-v1-legal-review-ky.xlsx b/docs/product/relevance-set-v1-legal-review-ky.xlsx
deleted file mode 100644
index 0ca76bd..0000000
Binary files a/docs/product/relevance-set-v1-legal-review-ky.xlsx and /dev/null differ
diff --git a/docs/product/relevance-set-v1-legal-review.md b/docs/product/relevance-set-v1-legal-review.md
deleted file mode 100644
index 5649360..0000000
--- a/docs/product/relevance-set-v1-legal-review.md
+++ /dev/null
@@ -1,93 +0,0 @@
-# Юридическая верификация relevance set v1
-
-**Назначение:** проверить предварительные запросы и коды актов перед фиксацией
-baseline поиска. Это рабочий бланк: он не является юридическим заключением и
-не должен использоваться для настройки ранжирования до завершения проверки.
-
-Для заполнения используйте Excel-файлы: [русский бланк](Выборка-ru.xlsx)
-и [кыргызский бланк](relevance-set-v1-legal-review-ky.xlsx)
-для проверки носителем кыргызского языка.
-
-## Как заполнить
-
-Для каждой строки:
-
-1. Проверьте запрос и каждый предварительный код по официальной ЦБД Минюста КР.
-2. Внесите в «Подтверждённые коды» все действующие акты, которые напрямую
- отвечают на запрос. Удалите нерелевантные предварительные коды, добавьте
- пропущенные.
-3. Отметьте результат: `подтверждено`, `исправлено` или `спорно`.
-4. Для `исправлено` и `спорно` кратко укажите причину.
-
-Акт релевантен, если его текст или реквизиты прямо отвечают на потребность
-запроса. Простого совпадения слов, близкой темы или упоминания другого акта
-недостаточно. Для запроса о действующей норме не включайте утратившие силу
-редакции. Коды указывайте как `document_code`, не ID фрагмента или редакции.
-
-После заполнения нужен второй независимый проверяющий. Затем согласованный
-результат переносится в локальный `data/search/relevance-set-v1.json` и
-используется для расчёта baseline.
-
-## Русский язык
-
-| ID | Запрос | Предварительные коды | Подтверждённые коды | Результат и комментарий |
-| --- | --- | --- | --- | --- |
-| ru-01 | как зарегистрировать общественное объединение | 274, 230044970 | | |
-| ru-02 | как открыть ОсОО в Кыргызстане | 230044970, 667 | | |
-| ru-03 | какие документы нужны для регистрации ИП | 112340, 159109 | | |
-| ru-04 | как получить статус безработного | 111258, 158006, 230011260, 200832 | | |
-| ru-05 | пособие по безработице | 111258, 14206 | | |
-| ru-06 | алименты на ребенка | 1327, 98934 | | |
-| ru-07 | отпуск по уходу за ребенком | 230021083 | | |
-| ru-08 | увольнение по собственному желанию | 230021083 | | |
-| ru-09 | минимальная заработная плата | 230021083, 230042095 | | |
-| ru-10 | штраф за превышение скорости | 112306 | | |
-| ru-11 | срок действия водительских прав | 230035520, 230021301 | | |
-| ru-12 | как оформить наследство | 5 | | |
-| ru-13 | регистрация права собственности на квартиру | 160, 230032352 | | |
-| ru-14 | можно ли обрабатывать персональные данные без согласия | 230029914, 230030911 | | |
-| ru-15 | срок ответа на обращение гражданина | 202100 | | |
-| ru-16 | доступ к публичной информации | 230009705 | | |
-| ru-17 | порядок проведения мирного собрания | 203664 | | |
-| ru-18 | как зарегистрировать брак | 1327, 112094 | | |
-| ru-19 | расторжение брака через суд | 1327 | | |
-| ru-20 | как усыновить ребенка | 1327, 203700, 98146, 200573 | | |
-| ru-21 | как получить гражданство Кыргызстана | 202103, 4798 | | |
-| ru-22 | как получить разрешение на строительство | 230029221 | | |
-| ru-23 | как рассчитывается земельный налог | 112340 | | |
-| ru-24 | как обжаловать государственную закупку | 112361, 160429 | | |
-| ru-25 | лицензия на образовательную деятельность | 205058, 112665, 230000631 | | |
-
-## Кыргыз тили
-
-| ID | Суроо | Алдын ала коддор | Тастыкталган коддор | Натыйжа жана комментарий |
-| --- | --- | --- | --- | --- |
-| ky-01 | коомдук бирикмени кантип каттоого болот | 274, 230044970 | | |
-| ky-02 | Кыргызстанда ЖЧК кантип ачылат | 230044970, 667 | | |
-| ky-03 | жеке ишкерди каттоо үчүн кандай документтер керек | 112340, 159109 | | |
-| ky-04 | жумушсуз статусун кантип алса болот | 111258, 158006, 230011260, 200832 | | |
-| ky-05 | жумушсуздук боюнча жөлөкпул | 111258, 14206 | | |
-| ky-06 | балага алимент өндүрүү | 1327, 98934 | | |
-| ky-07 | бала багуу боюнча өргүү | 230021083 | | |
-| ky-08 | өз каалоосу менен жумуштан чыгуу | 230021083 | | |
-| ky-09 | эң төмөнкү эмгек акы | 230021083, 230042095 | | |
-| ky-10 | ылдамдыкты ашыргандыгы үчүн айып пул | 112306 | | |
-| ky-11 | айдоочулук күбөлүктүн колдонуу мөөнөтү | 230035520, 230021301 | | |
-| ky-12 | мурасты кантип тариздөө керек | 5 | | |
-| ky-13 | батирге менчик укугун каттоо | 160, 230032352 | | |
-| ky-14 | жеке маалыматтарды макулдуксуз иштетүүгө болобу | 230029914, 230030911 | | |
-| ky-15 | жарандардын кайрылуусуна жооп берүү мөөнөтү | 202100 | | |
-| ky-16 | коомдук маалыматка жетүү | 230009705 | | |
-| ky-17 | тынч чогулуш өткөрүүнүн тартиби | 203664 | | |
-| ky-18 | никени кантип каттоого болот | 1327, 112094 | | |
-| ky-19 | сот аркылуу никени бузуу | 1327 | | |
-| ky-20 | баланы кантип асырап алууга болот | 1327, 203700, 98146, 200573 | | |
-| ky-21 | Кыргызстандын жарандыгын кантип алса болот | 202103, 4798 | | |
-| ky-22 | курулушка уруксатты кантип алса болот | 230029221 | | |
-| ky-23 | жер салыгы кантип эсептелет | 112340 | | |
-| ky-24 | мамлекеттик сатып алууга кантип даттанса болот | 112361, 160429 | | |
-| ky-25 | билим берүү ишмердигине лицензия | 205058, 112665, 230000631 | | |
-
----
-
-Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан
diff --git a/docs/product/search-relevance-review-interface-plan.md b/docs/product/search-relevance-review-interface-plan.md
new file mode 100644
index 0000000..a45b8b6
--- /dev/null
+++ b/docs/product/search-relevance-review-interface-plan.md
@@ -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`; выбранный результат
+обозначается текстом и визуальным состоянием.
+
+На узком экране список результатов занимает всю страницу. Кнопка «Открыть
+документ» открывает полноэкранный нативный `