diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5e3b8b8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.gitignore +.env +.env.* +*.json +!backend/ +!backend/search/ +!backend/search/*.json +*.xlsx +*.pdf +*.jpg +data/ +docs/ +tools/ +__pycache__/ +*.pyc diff --git a/CHANGELOG.md b/CHANGELOG.md index 4369ad2..d17dcb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,15 @@ ## Не выпущено -- Добавлены отдельные Excel-бланки независимой юридической верификации - relevance set v1 для русского и кыргызского языков и PDF-инструкция для - юриста. +- Исключены секреты и локальные данные из Docker build context; синхронизирована версия интерфейса. +- Добавлен production deployment baseline для Search API и OpenSearch с + постоянными хранилищами и healthcheck. +- Добавлено восстановление незавершённой разметки из `localStorage` после перезагрузки страницы. +- Добавлена внутренняя лаборатория проверки поисковой выдачи: просмотр документов, + оценка релевантности 0–3, комментарии и SQLite-экспорт подписанных снимков. +- Уточнены доступные состояния и адаптивное поведение внутреннего интерфейса + оценки поисковой выдачи. +- Добавлен план внутреннего интерфейса оценки поисковой выдачи юристами. - Завершён Search API v1: стабильные справочники, валидный OpenAPI, безопасная пагинация и проверка актуальных редакций в локальном OpenSearch. - Добавлено безопасное переключение alias на новую версию поискового индекса diff --git a/README.md b/README.md index 2b94a77..deb2842 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,14 @@ Telegram-бот — только часть рабочего окружения ## Текущее состояние Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии -`0.7.1`: исправления контракта Search API v1 и его ограничений OpenSearch. -размеченном наборе запросов. +`0.8.3`: исправлена безопасность production Docker build context. | Компонент | Версия | Состояние | |---|---:|---| | Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния | -| Backend | `0.7.1` | исправлен контракт Search API v1 | +| Backend | `0.8.3` | исправлена безопасность production build context | | Frontend | — | ещё не создан | -| Сбор и обработка правовых данных | `0.7.1` | добавлены relevance set и baseline-метрики | +| Сбор и обработка правовых данных | `0.8.0` | добавлены relevance set и оценка выдачи | | RAG и база знаний | — | ещё не созданы | ## Структура репозитория @@ -59,4 +58,4 @@ python3 -m unittest discover -s tools/telegram-bot -v --- -Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан +Акылдаш · Telegram-бот v0.2.2 · Backend v0.8.3 · Frontend — не создан diff --git a/backend/README.md b/backend/README.md index 36950aa..50c246e 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,6 +1,6 @@ # Backend Акылдаш -Версия: `0.7.1` +Версия: `0.8.3` Первая backend-область проекта — загрузка правовых документов из официального Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в @@ -216,6 +216,21 @@ PYTHONPATH=backend python3 -m search.evaluate_relevance \ Менять веса или анализаторы следует только после фиксации этого baseline и разбора ошибок выдачи. +## Внутренняя лаборатория релевантности + +Запустите Search API на localhost и откройте `http://127.0.0.1:8080/review`: + +```bash +PYTHONPATH=backend python3 -m search.api \\ + --reviews-db data/search-reviews.sqlite3 +``` + +Лаборатория показывает фактический порядок выдачи OpenSearch, позволяет открыть +текст редакции, поставить результату оценку от 0 до 3 и сохранить снимок с +комментариями. Оценки сохраняются в SQLite, экспорт доступен через +`GET /search-reviews/export`. Интерфейс предназначен только для локальной сети +или защищённого reverse proxy; не публикуйте его напрямую в интернет. + Для проверки текущей выдачи без будущего HTTP API используйте CLI: ```bash @@ -225,4 +240,4 @@ PYTHONPATH=backend python3 -m search.query "ЖЧК ачуу тартиби" --la --- -Акылдаш · Backend v0.7.1 · Frontend — не создан +Акылдаш · Backend v0.8.3 · Frontend — не создан diff --git a/backend/ingestion/minjust_cbd.py b/backend/ingestion/minjust_cbd.py index 684e2cf..2556d2e 100644 --- a/backend/ingestion/minjust_cbd.py +++ b/backend/ingestion/minjust_cbd.py @@ -21,7 +21,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Callable, Iterable -APP_VERSION = "0.7.1" +APP_VERSION = "0.8.3" API_BASE_URL = "https://cbd.minjust.gov.kg/api/v1/OpenData/" LANGUAGES = {"Rus": "ru", "Kyr": "ky"} IMAGE_LANGUAGES = {"Russian": "ru", "Kyrgyz": "ky"} diff --git a/backend/normalization/minjust_cbd.py b/backend/normalization/minjust_cbd.py index 776ffe4..25366b9 100644 --- a/backend/normalization/minjust_cbd.py +++ b/backend/normalization/minjust_cbd.py @@ -23,7 +23,7 @@ from pathlib import Path from typing import Callable from urllib.parse import urlsplit -APP_VERSION = "0.7.1" +APP_VERSION = "0.8.3" SCHEMA_VERSION = "1" NORMALIZER_VERSION = "1.0.0" LANGUAGES = ("ru", "ky") diff --git a/backend/search/RELEVANCE_ANNOTATION.md b/backend/search/RELEVANCE_ANNOTATION.md index beb6a1b..6a725c5 100644 --- a/backend/search/RELEVANCE_ANNOTATION.md +++ b/backend/search/RELEVANCE_ANNOTATION.md @@ -56,17 +56,19 @@ cp backend/search/relevance-set-v1.template.json data/search/relevance-set-v1.js ## Разметка одного запроса -1. До просмотра выдачи зафиксируйте информационную потребность и формулировку - `query`. -2. Найдите кандидатов в локальном OpenSearch и в официальной ЦБД Минюста. - Проверьте исходный запрос, его короткий вариант и вариант с юридическим - термином или известным номером акта. -3. Просмотрите не только заголовки, но и текст, статус, дату и редакцию каждого - кандидата. -4. Запишите уникальные `document_code` всех документов, удовлетворяющих - критерию релевантности. -5. Повторите поиск по ключевым терминам найденных документов, чтобы обнаружить - пропущенные альтернативные акты. +1. Зафиксируйте исходную формулировку `query` и информационную потребность до + оценки выдачи нашего поиска. +2. Юрист устанавливает релевантные акты по содержанию и реквизитам документов. + Используйте ЦБД Минюста как авторитетный источник для проверки текста, + статуса и редакции акта. Порядок выдачи ЦБД не является объектом оценки и не + переносится в эталон. +3. Запишите уникальные `document_code` всех документов, которые прямо отвечают + на запрос. Спорные документы передайте на независимую проверку. +4. Зафиксируйте согласованный набор до просмотра результатов OpenSearch и + сохраните его отдельно от рабочих данных. +5. Проверьте запрос во внутренней лаборатории (`/review`): оцените фактические + результаты OpenSearch в исходном порядке, сохраните снимок и комментарии. + Лаборатория проверяет качество нашей поисковой системы, а не ЦБД Минюста. Пример структуры (код условный): diff --git a/backend/search/api.py b/backend/search/api.py index 883dd2b..9a6183d 100644 --- a/backend/search/api.py +++ b/backend/search/api.py @@ -12,9 +12,11 @@ from pathlib import Path from search.minjust_opensearch import APP_VERSION, request_json from search.catalog import CATALOGS, labels +from search.reviews import ReviewSnapshots, ReviewStore API_VERSION = "v1" +SEARCH_ALGORITHM_VERSION = "search-1" LANGUAGES = {"ru", "ky"} CODE = re.compile(r"^[0-9]+$") MAX_PAGE_SIZE = 100 @@ -75,6 +77,9 @@ def openapi() -> dict: {"name": "sort", "in": "query", "schema": {"type": "string", "enum": ["relevance", "date"]}}, ]}}, "/search/filters": {"get": {"responses": responses}}, + "/search-reviews": {"post": {"responses": {"201": {"description": "Review saved"}, "400": {"description": "Invalid review"}}}}, + "/search-reviews/export": {"get": {"responses": responses}}, + "/review": {"get": {"responses": {"200": {"description": "Review interface"}}}}, "/documents/{code}": {"get": {"responses": responses, "parameters": [{"name": "code", "in": "path", "required": True, "schema": {"type": "string", "pattern": "^[0-9]+$"}}]}}, "/documents/{code}/editions": {"get": {"responses": responses, "parameters": [{"name": "code", "in": "path", "required": True, "schema": {"type": "string", "pattern": "^[0-9]+$"}}]}}, "/documents/{code}/editions/{edition}": {"get": {"responses": responses, "parameters": [{"name": "code", "in": "path", "required": True, "schema": {"type": "string", "pattern": "^[0-9]+$"}}, {"name": "edition", "in": "path", "required": True, "schema": {"type": "string", "pattern": "^[0-9]+$"}}]}}, @@ -83,10 +88,12 @@ def openapi() -> dict: class Api: - def __init__(self, base_url: str, index: str, data_root: Path): + def __init__(self, base_url: str, index: str, data_root: Path, reviews_db: Path | str = ":memory:", review_secret: bytes | None = None): self.base_url = base_url.rstrip("/") self.index = index self.data_root = data_root + self.review_store = ReviewStore(reviews_db) + self.review_snapshots = ReviewSnapshots(review_secret) def search_url(self, suffix: str) -> str: return f"{self.base_url}/{urllib.parse.quote(self.index, safe='')}/{suffix}" @@ -142,7 +149,11 @@ class Api: hits = response["hits"]["hits"] except (KeyError, TypeError) as error: raise ApiError(502, "search backend returned an incomplete response") from error - return {"api_version": API_VERSION, "query": text, "language": language, "page": page, "page_size": page_size, "has_next": len(hits) > page_size, "results": [self.search_hit(hit, language) for hit in hits[:page_size]]} + results = [self.search_hit(hit, language) for hit in hits[:page_size]] + snapshot_results = [{"rank": rank, **result} for rank, result in enumerate(results, 1)] + concrete_indexes = {hit.get("_index") for hit in hits[:page_size] if hit.get("_index")} + index_name = next(iter(concrete_indexes)) if len(concrete_indexes) == 1 else self.index + return {"api_version": API_VERSION, "query": text, "language": language, "page": page, "page_size": page_size, "has_next": len(hits) > page_size, "results": results, "review_token": self.review_snapshots.create(text, language, index_name, snapshot_results, SEARCH_ALGORITHM_VERSION)} @staticmethod def search_hit(hit: dict, language: str) -> dict: @@ -221,12 +232,62 @@ class Api: raise ApiError(404, "edition language not found") return {"api_version": API_VERSION, "edition": metadata, "content": content} - def handle(self, method: str, path: str) -> tuple[int, dict]: - if method != "GET": - raise ApiError(405, "method not allowed") + def save_review(self, body: dict) -> dict: + if not isinstance(body, dict): + raise ApiError(400, "request body must be an object") + try: + snapshot = self.review_snapshots.verify(body["review_token"]) + reviewer = body["reviewer"].strip() + overall_comment = body.get("overall_comment", "").strip() + submitted = body["results"] + except (KeyError, AttributeError, TypeError, ValueError) as error: + raise ApiError(400, "review_token, reviewer and results are required") from error + if not reviewer or len(reviewer) > 120: + raise ApiError(400, "reviewer must be between 1 and 120 characters") + if len(overall_comment) > 4000: + raise ApiError(400, "overall_comment is too long") + if not isinstance(submitted, list): + raise ApiError(400, "results must be an array") + by_rank = {item["rank"]: item for item in snapshot["results"]} + if len(submitted) != len(by_rank) or {item.get("rank") for item in submitted if isinstance(item, dict)} != set(by_rank): + raise ApiError(400, "all search results must be reviewed exactly once") + results = [] + for item in submitted: + if not isinstance(item, dict) or not isinstance(item.get("rank"), int) or item["rank"] not in by_rank: + raise ApiError(400, "review result rank is invalid") + source = by_rank[item["rank"]] + if item.get("code") != source["code"]: + raise ApiError(400, "review result document does not match the search snapshot") + rating = item.get("rating") + if rating is not None and (isinstance(rating, bool) or not isinstance(rating, int) or not 0 <= rating <= 3): + raise ApiError(400, "rating must be an integer from 0 to 3") + comment = item.get("comment", "") + if not isinstance(comment, str) or len(comment) > 4000: + raise ApiError(400, "result comment is too long") + results.append({**source, "rating": rating, "comment": comment.strip()}) + if not results: + raise ApiError(400, "at least one result must be reviewed") + review = {"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), "reviewer": reviewer, "query": snapshot["query"], "language": snapshot["language"], "index_name": snapshot["index_name"], "algorithm_version": snapshot["algorithm_version"], "top_result_code": snapshot["results"][0]["code"] if snapshot["results"] else None, "results": results, "overall_comment": overall_comment} + review_id = self.review_store.save(review) + return {"api_version": API_VERSION, "id": review_id, "created_at": review["created_at"]} + + @staticmethod + def review_page() -> str: + try: + return Path(__file__).with_name("review.html").read_text(encoding="utf-8") + except (OSError, UnicodeError) as error: + raise ApiError(500, "review interface is unavailable") from error + + def handle(self, method: str, path: str, body: dict | None = None) -> tuple[int, dict]: parsed = urllib.parse.urlsplit(path) query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True) parts = [urllib.parse.unquote(part) for part in parsed.path.split("/") if part] + if method == "POST" and parts == ["search-reviews"]: + return 201, self.save_review(body) + if method == "GET" and parts == ["search-reviews", "export"]: + return 200, {"api_version": API_VERSION, "reviews": self.review_store.export()} + if method != "GET": + raise ApiError(405, "method not allowed") if parts == ["openapi.json"]: return 200, openapi() if parts == ["search"]: @@ -246,7 +307,23 @@ def handler(api: Api): class RequestHandler(BaseHTTPRequestHandler): def respond(self, method: str): try: - status, payload = api.handle(method, self.path) + if method == "GET" and urllib.parse.urlsplit(self.path).path == "/review": + body = api.review_page().encode() + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + body = None + if method == "POST": + length = int(self.headers.get("Content-Length", "0")) + if length > 1_000_000: + raise ApiError(413, "request body is too large") + body = json.loads(self.rfile.read(length) or b"{}") + status, payload = api.handle(method, self.path, body) + except json.JSONDecodeError: + status, payload = 400, {"api_version": API_VERSION, "error": "request body must be valid JSON"} except ApiError as error: status, payload = error.status, {"api_version": API_VERSION, "error": error.message} body = json.dumps(payload, ensure_ascii=False).encode() @@ -273,11 +350,14 @@ def main() -> int: parser.add_argument("--url", default="http://127.0.0.1:9200") parser.add_argument("--index", default="akyldash-fragments-current") parser.add_argument("--data", type=Path, default=Path("data/minjust-normalized")) + parser.add_argument("--reviews-db", type=Path, default=Path("data/search-reviews.sqlite3")) + parser.add_argument("--review-secret", default=None) parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=8080) parser.add_argument("--version", action="version", version=APP_VERSION) arguments = parser.parse_args() - ThreadingHTTPServer((arguments.host, arguments.port), handler(Api(arguments.url, arguments.index, arguments.data))).serve_forever() + secret = arguments.review_secret.encode() if arguments.review_secret else None + ThreadingHTTPServer((arguments.host, arguments.port), handler(Api(arguments.url, arguments.index, arguments.data, arguments.reviews_db, secret))).serve_forever() return 0 diff --git a/backend/search/minjust_opensearch.py b/backend/search/minjust_opensearch.py index d96794b..cef6db6 100644 --- a/backend/search/minjust_opensearch.py +++ b/backend/search/minjust_opensearch.py @@ -17,7 +17,7 @@ from typing import Iterator from search.catalog import authority_codes, source_code -APP_VERSION = "0.7.1" +APP_VERSION = "0.8.3" LANGUAGES = {"ru", "ky"} DEFAULT_MAPPING = Path(__file__).with_name("minjust-fragments-index.json") diff --git a/backend/search/review.html b/backend/search/review.html new file mode 100644 index 0000000..1cdc6ec --- /dev/null +++ b/backend/search/review.html @@ -0,0 +1,114 @@ + + + + + + Оценка поисковой выдачи · Акылдаш + + + + Перейти к результатам +

Оценка поисковой выдачи

Проверяйте результаты нашего 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`; выбранный результат +обозначается текстом и визуальным состоянием. + +На узком экране список результатов занимает всю страницу. Кнопка «Открыть +документ» открывает полноэкранный нативный `` с явной кнопкой +«Закрыть». При закрытии фокус возвращается на исходную кнопку «Открыть +документ». + +### Состояния + +- Во время поиска и сохранения показывается состояние загрузки; повторная + отправка на это время недоступна. Стабильная пустая область `role="status"` + в DOM объявляет начало поиска, число результатов и успешное сохранение. +- Пустая выдача сообщает: «По запросу „…“ ничего не найдено» и предлагает + «Изменить запрос». +- Ошибка поиска сообщает причину и предлагает «Повторить поиск»; текст ошибки + выводится в `role="alert"`. +- Ошибка сохранения сообщает: «Не удалось сохранить. Проверьте подключение и + повторите». Черновик остаётся в браузере, текст ошибки выводится в + `role="alert"`. +- После сохранения выводится: «Оценка сохранена · № … · дата и время» в + указанной стабильной области `role="status"`. + +До сохранения черновик хранится в `localStorage`. После успешного сохранения +интерфейс показывает ID записи и время сохранения. + +## Backend и хранение + +Использовать существующие endpoint: + +- `GET /search` — получить ранжированный список результатов; +- `GET /documents/{code}/editions/{edition}` — получить текст выбранной + редакции. + +Добавить два endpoint: + +- `POST /search-reviews` — валидирует и сохраняет оценку; +- `GET /search-reviews/export` — отдаёт накопленные записи в JSON. + +Для первой версии достаточно отдельной SQLite-базы. Это стандартная библиотека +Python, данные переживают перезапуск и легко выгружаются для анализа. Доступ к +интерфейсу и всем endpoint `search-reviews`, включая экспорт, должен быть +ограничен локальной сетью/VPN или аутентификацией reverse proxy. + +Одна запись представляет один сохранённый поисковый сеанс: + +| Поле | Назначение | +| --- | --- | +| `id`, `created_at` | Идентификатор и время сохранения. | +| `reviewer` | Имя или псевдоним проверяющего. | +| `query`, `language` | Исходный запрос и язык поиска. | +| `index_name`, `algorithm_version` | Версия индекса и алгоритма на момент оценки. | +| `top_result_code` | Код документа в позиции `#1`. | +| `results_json` | Снимок результатов в исходном порядке с оценками и комментариями. | +| `overall_comment` | Общий комментарий к выдаче. | + +В `results_json` для каждого результата сохраняются: `rank`, `document_code`, +`edition_code`, название, реквизиты, фрагмент, оценка и комментарий. Снимок +выдачи обязателен: после изменения алгоритма можно будет восстановить именно +тот результат, который видел юрист. + +## Правила сохранения + +- Запрос не пустой, не длиннее 500 символов. +- Оценка может быть только целым числом от 0 до 3 либо отсутствовать у + непросмотренного результата. +- Комментарии имеют ограничение длины; пользовательские значения не вставляются + в HTML. +- `GET /search` возвращает краткоживущий HMAC-подписанный снимок выдачи: + запрос, язык, индекс, результаты и их позиции. `POST /search-reviews` + принимает этот снимок и только оценки с комментариями. Сервер проверяет + подпись и срок, самостоятельно формирует `results_json` и отклоняет оценки + для отсутствующих либо подменённых позиций и документов. +- Нельзя передавать персональные данные или закрытые материалы в комментариях. +- Кнопка «Сохранить оценку» остаётся доступной до отправки: отсутствующие + обязательные поля проверяются после нажатия, ошибка показана рядом с полем и + фокус переводится на первое некорректное поле. + +## Анализ данных + +Экспорт должен содержать исходный JSON-снимок, чтобы его можно было обработать +скриптом или открыть в табличном инструменте. Первый отчёт строит: + +- среднюю оценку для каждой позиции выдачи; +- долю результатов с оценкой `3` в top-1, top-3 и top-10; +- запросы, где нет результатов с оценкой `2` или `3`; +- документы, которые часто получают низкую оценку в первых позициях; +- комментарии для ручного разбора ошибок. + +Сырые оценки не должны автоматически менять веса поиска. Сначала команда +разбирает причины: анализатор, синонимы, статус, отсутствие документа, +неправильная формулировка запроса или юридическая неоднозначность. + +## Этапы реализации + +1. Утвердить шкалу 0–3, обязательность имени проверяющего и правила доступа. +2. Добавить SQLite-хранилище, валидацию, сохранение и JSON-экспорт. +3. Добавить статическую внутреннюю страницу в существующий Python-сервер, без + Next.js и отдельного публичного приложения. +4. Подключить поиск, правую панель документа, черновик, адаптивный режим и + сохранение в закреплённой панели действий на широком экране. +5. Добавить минимальные backend-проверки сохранения, повторного запуска, + экспорта и недопустимых оценок. +6. Провести ручный прогон на десяти русскоязычных практических запросах с + двумя юристами. +7. На собранных записях настроить OpenSearch и повторить тот же набор запросов. + +## Критерий готовности + +Юрист вводит запрос, видит порядок выдачи, открывает документ, выставляет +оценки и комментарии, сохраняет их. Экспорт содержит запрос, язык, документ +на позиции `#1`, полный порядок результатов, оценки, комментарии и версию +индекса. Данные можно сравнить до и после изменения алгоритма. + +--- + +Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан diff --git a/docs/product/Выборка-ru.xlsx b/docs/product/Выборка-ru.xlsx deleted file mode 100644 index 163add4..0000000 Binary files a/docs/product/Выборка-ru.xlsx and /dev/null differ diff --git a/docs/product/Инструкция.pdf b/docs/product/Инструкция.pdf deleted file mode 100644 index fb0195c..0000000 Binary files a/docs/product/Инструкция.pdf and /dev/null differ