feat: add search relevance review lab #26
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
## Не выпущено
|
## Не выпущено
|
||||||
|
|
||||||
|
- Добавлена внутренняя лаборатория проверки поисковой выдачи: просмотр документов,
|
||||||
|
оценка релевантности 0–3, комментарии и SQLite-экспорт подписанных снимков.
|
||||||
- Уточнены доступные состояния и адаптивное поведение внутреннего интерфейса
|
- Уточнены доступные состояния и адаптивное поведение внутреннего интерфейса
|
||||||
оценки поисковой выдачи.
|
оценки поисковой выдачи.
|
||||||
- Добавлен план внутреннего интерфейса оценки поисковой выдачи юристами.
|
- Добавлен план внутреннего интерфейса оценки поисковой выдачи юристами.
|
||||||
|
|||||||
@@ -10,15 +10,15 @@ Telegram-бот — только часть рабочего окружения
|
|||||||
## Текущее состояние
|
## Текущее состояние
|
||||||
|
|
||||||
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии
|
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии
|
||||||
`0.7.1`: исправления контракта Search API v1 и его ограничений OpenSearch.
|
`0.8.0`: добавлены внутреннее сохранение и оценка поисковой выдачи.
|
||||||
размеченном наборе запросов.
|
размеченном наборе запросов.
|
||||||
|
|
||||||
| Компонент | Версия | Состояние |
|
| Компонент | Версия | Состояние |
|
||||||
|---|---:|---|
|
|---|---:|---|
|
||||||
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
|
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
|
||||||
| Backend | `0.7.1` | исправлен контракт Search API v1 |
|
| Backend | `0.8.0` | добавлена лаборатория оценки поисковой выдачи |
|
||||||
| Frontend | — | ещё не создан |
|
| Frontend | — | ещё не создан |
|
||||||
| Сбор и обработка правовых данных | `0.7.1` | добавлены relevance set и baseline-метрики |
|
| Сбор и обработка правовых данных | `0.8.0` | добавлены relevance set и оценка выдачи |
|
||||||
| RAG и база знаний | — | ещё не созданы |
|
| RAG и база знаний | — | ещё не созданы |
|
||||||
|
|
||||||
## Структура репозитория
|
## Структура репозитория
|
||||||
@@ -59,4 +59,4 @@ python3 -m unittest discover -s tools/telegram-bot -v
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.8.0 · Frontend — не создан
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Backend Акылдаш
|
# Backend Акылдаш
|
||||||
|
|
||||||
Версия: `0.7.1`
|
Версия: `0.8.0`
|
||||||
|
|
||||||
Первая backend-область проекта — загрузка правовых документов из официального
|
Первая backend-область проекта — загрузка правовых документов из официального
|
||||||
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
|
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
|
||||||
@@ -216,6 +216,21 @@ PYTHONPATH=backend python3 -m search.evaluate_relevance \
|
|||||||
Менять веса или анализаторы следует только после фиксации этого baseline и
|
Менять веса или анализаторы следует только после фиксации этого 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:
|
Для проверки текущей выдачи без будущего HTTP API используйте CLI:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -225,4 +240,4 @@ PYTHONPATH=backend python3 -m search.query "ЖЧК ачуу тартиби" --la
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Акылдаш · Backend v0.7.1 · Frontend — не создан
|
Акылдаш · Backend v0.8.0 · Frontend — не создан
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Iterable
|
from typing import Callable, Iterable
|
||||||
|
|
||||||
APP_VERSION = "0.7.1"
|
APP_VERSION = "0.8.0"
|
||||||
API_BASE_URL = "https://cbd.minjust.gov.kg/api/v1/OpenData/"
|
API_BASE_URL = "https://cbd.minjust.gov.kg/api/v1/OpenData/"
|
||||||
LANGUAGES = {"Rus": "ru", "Kyr": "ky"}
|
LANGUAGES = {"Rus": "ru", "Kyr": "ky"}
|
||||||
IMAGE_LANGUAGES = {"Russian": "ru", "Kyrgyz": "ky"}
|
IMAGE_LANGUAGES = {"Russian": "ru", "Kyrgyz": "ky"}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from pathlib import Path
|
|||||||
from typing import Callable
|
from typing import Callable
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
APP_VERSION = "0.7.1"
|
APP_VERSION = "0.8.0"
|
||||||
SCHEMA_VERSION = "1"
|
SCHEMA_VERSION = "1"
|
||||||
NORMALIZER_VERSION = "1.0.0"
|
NORMALIZER_VERSION = "1.0.0"
|
||||||
LANGUAGES = ("ru", "ky")
|
LANGUAGES = ("ru", "ky")
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ from pathlib import Path
|
|||||||
|
|
||||||
from search.minjust_opensearch import APP_VERSION, request_json
|
from search.minjust_opensearch import APP_VERSION, request_json
|
||||||
from search.catalog import CATALOGS, labels
|
from search.catalog import CATALOGS, labels
|
||||||
|
from search.reviews import ReviewSnapshots, ReviewStore
|
||||||
|
|
||||||
|
|
||||||
API_VERSION = "v1"
|
API_VERSION = "v1"
|
||||||
|
SEARCH_ALGORITHM_VERSION = "search-1"
|
||||||
LANGUAGES = {"ru", "ky"}
|
LANGUAGES = {"ru", "ky"}
|
||||||
CODE = re.compile(r"^[0-9]+$")
|
CODE = re.compile(r"^[0-9]+$")
|
||||||
MAX_PAGE_SIZE = 100
|
MAX_PAGE_SIZE = 100
|
||||||
@@ -75,6 +77,9 @@ def openapi() -> dict:
|
|||||||
{"name": "sort", "in": "query", "schema": {"type": "string", "enum": ["relevance", "date"]}},
|
{"name": "sort", "in": "query", "schema": {"type": "string", "enum": ["relevance", "date"]}},
|
||||||
]}},
|
]}},
|
||||||
"/search/filters": {"get": {"responses": responses}},
|
"/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}": {"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": {"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]+$"}}]}},
|
"/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:
|
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.base_url = base_url.rstrip("/")
|
||||||
self.index = index
|
self.index = index
|
||||||
self.data_root = data_root
|
self.data_root = data_root
|
||||||
|
self.review_store = ReviewStore(reviews_db)
|
||||||
|
self.review_snapshots = ReviewSnapshots(review_secret)
|
||||||
|
|
||||||
def search_url(self, suffix: str) -> str:
|
def search_url(self, suffix: str) -> str:
|
||||||
return f"{self.base_url}/{urllib.parse.quote(self.index, safe='')}/{suffix}"
|
return f"{self.base_url}/{urllib.parse.quote(self.index, safe='')}/{suffix}"
|
||||||
@@ -142,7 +149,11 @@ class Api:
|
|||||||
hits = response["hits"]["hits"]
|
hits = response["hits"]["hits"]
|
||||||
except (KeyError, TypeError) as error:
|
except (KeyError, TypeError) as error:
|
||||||
raise ApiError(502, "search backend returned an incomplete response") from 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
|
@staticmethod
|
||||||
def search_hit(hit: dict, language: str) -> dict:
|
def search_hit(hit: dict, language: str) -> dict:
|
||||||
@@ -221,12 +232,62 @@ class Api:
|
|||||||
raise ApiError(404, "edition language not found")
|
raise ApiError(404, "edition language not found")
|
||||||
return {"api_version": API_VERSION, "edition": metadata, "content": content}
|
return {"api_version": API_VERSION, "edition": metadata, "content": content}
|
||||||
|
|
||||||
def handle(self, method: str, path: str) -> tuple[int, dict]:
|
def save_review(self, body: dict) -> dict:
|
||||||
if method != "GET":
|
if not isinstance(body, dict):
|
||||||
raise ApiError(405, "method not allowed")
|
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)
|
parsed = urllib.parse.urlsplit(path)
|
||||||
query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
|
query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
|
||||||
parts = [urllib.parse.unquote(part) for part in parsed.path.split("/") if part]
|
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"]:
|
if parts == ["openapi.json"]:
|
||||||
return 200, openapi()
|
return 200, openapi()
|
||||||
if parts == ["search"]:
|
if parts == ["search"]:
|
||||||
@@ -246,7 +307,23 @@ def handler(api: Api):
|
|||||||
class RequestHandler(BaseHTTPRequestHandler):
|
class RequestHandler(BaseHTTPRequestHandler):
|
||||||
def respond(self, method: str):
|
def respond(self, method: str):
|
||||||
try:
|
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:
|
except ApiError as error:
|
||||||
status, payload = error.status, {"api_version": API_VERSION, "error": error.message}
|
status, payload = error.status, {"api_version": API_VERSION, "error": error.message}
|
||||||
body = json.dumps(payload, ensure_ascii=False).encode()
|
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("--url", default="http://127.0.0.1:9200")
|
||||||
parser.add_argument("--index", default="akyldash-fragments-current")
|
parser.add_argument("--index", default="akyldash-fragments-current")
|
||||||
parser.add_argument("--data", type=Path, default=Path("data/minjust-normalized"))
|
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("--host", default="127.0.0.1")
|
||||||
parser.add_argument("--port", type=int, default=8080)
|
parser.add_argument("--port", type=int, default=8080)
|
||||||
parser.add_argument("--version", action="version", version=APP_VERSION)
|
parser.add_argument("--version", action="version", version=APP_VERSION)
|
||||||
arguments = parser.parse_args()
|
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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from typing import Iterator
|
|||||||
|
|
||||||
from search.catalog import authority_codes, source_code
|
from search.catalog import authority_codes, source_code
|
||||||
|
|
||||||
APP_VERSION = "0.7.1"
|
APP_VERSION = "0.8.0"
|
||||||
LANGUAGES = {"ru", "ky"}
|
LANGUAGES = {"ru", "ky"}
|
||||||
DEFAULT_MAPPING = Path(__file__).with_name("minjust-fragments-index.json")
|
DEFAULT_MAPPING = Path(__file__).with_name("minjust-fragments-index.json")
|
||||||
|
|
||||||
|
|||||||
107
backend/search/review.html
Normal file
107
backend/search/review.html
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<!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">
|
||||||
|
<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.0 · Внутренняя лаборатория релевантности</footer>
|
||||||
|
<script>
|
||||||
|
const state = { results: [], token: '', selected: null };
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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; 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; } });
|
||||||
|
$('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(); setStatus(`Оценка сохранена · № ${payload.id}.`); } catch (error) { setError('Не удалось сохранить. Проверьте подключение и повторите.'); } finally { $('save').disabled = false; } });
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
83
backend/search/reviews.py
Normal file
83
backend/search/reviews.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""Persistence and signed snapshots for search relevance reviews."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
MAX_COMMENT = 4000
|
||||||
|
MAX_REVIEWER = 120
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewStore:
|
||||||
|
def __init__(self, path: Path | str = ":memory:"):
|
||||||
|
if path != ":memory:":
|
||||||
|
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.connection = sqlite3.connect(path, check_same_thread=False)
|
||||||
|
self.connection.row_factory = sqlite3.Row
|
||||||
|
# ponytail: one SQLite lock; split connections only if review throughput matters.
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self.connection.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS search_reviews (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
reviewer TEXT NOT NULL,
|
||||||
|
query TEXT NOT NULL,
|
||||||
|
language TEXT NOT NULL,
|
||||||
|
index_name TEXT NOT NULL,
|
||||||
|
algorithm_version TEXT NOT NULL,
|
||||||
|
top_result_code TEXT,
|
||||||
|
results_json TEXT NOT NULL,
|
||||||
|
overall_comment TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
self.connection.commit()
|
||||||
|
|
||||||
|
def save(self, review: dict) -> int:
|
||||||
|
with self._lock:
|
||||||
|
cursor = self.connection.execute(
|
||||||
|
"INSERT INTO search_reviews(created_at, reviewer, query, language, index_name, algorithm_version, top_result_code, results_json, overall_comment) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(review["created_at"], review["reviewer"], review["query"], review["language"], review["index_name"], review["algorithm_version"], review["top_result_code"], json.dumps(review["results"], ensure_ascii=False), review["overall_comment"]),
|
||||||
|
)
|
||||||
|
self.connection.commit()
|
||||||
|
return int(cursor.lastrowid)
|
||||||
|
|
||||||
|
def export(self) -> list[dict]:
|
||||||
|
with self._lock:
|
||||||
|
return [
|
||||||
|
{**dict(row), "results": json.loads(row["results_json"])}
|
||||||
|
for row in self.connection.execute("SELECT * FROM search_reviews ORDER BY id")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewSnapshots:
|
||||||
|
def __init__(self, secret: bytes | None = None, ttl: int = 3600):
|
||||||
|
self.secret = secret or secrets.token_bytes(32)
|
||||||
|
self.ttl = ttl
|
||||||
|
|
||||||
|
def create(self, query: str, language: str, index: str, results: list[dict], algorithm_version: str) -> str:
|
||||||
|
payload = {"query": query, "language": language, "index_name": index, "algorithm_version": algorithm_version, "results": results, "expires_at": int(time.time()) + self.ttl}
|
||||||
|
encoded = base64.urlsafe_b64encode(json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode()).decode().rstrip("=")
|
||||||
|
signature = hmac.new(self.secret, encoded.encode(), hashlib.sha256).hexdigest()
|
||||||
|
return f"{encoded}.{signature}"
|
||||||
|
|
||||||
|
def verify(self, token: str) -> dict:
|
||||||
|
try:
|
||||||
|
encoded, signature = token.split(".", 1)
|
||||||
|
expected = hmac.new(self.secret, encoded.encode(), hashlib.sha256).hexdigest()
|
||||||
|
if not hmac.compare_digest(signature, expected):
|
||||||
|
raise ValueError
|
||||||
|
payload = json.loads(base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)))
|
||||||
|
if payload["expires_at"] < int(time.time()):
|
||||||
|
raise ValueError
|
||||||
|
return payload
|
||||||
|
except (ValueError, KeyError, TypeError, json.JSONDecodeError, UnicodeError) as error:
|
||||||
|
raise ValueError("invalid or expired search snapshot") from error
|
||||||
40
backend/test_search_reviews.py
Normal file
40
backend/test_search_reviews.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
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))
|
||||||
|
self.assertIn("Оценка поисковой выдачи", api.review_page())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user