Compare commits
28 Commits
docs/searc
...
docs/relev
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e07f3ab8d | |||
| 70c70fb7dd | |||
| e18d477852 | |||
| 062aaa0074 | |||
| 470745fce3 | |||
| b2274fee6f | |||
| abd244fefb | |||
| 8302c6b782 | |||
| bbf8e7a9c8 | |||
| bf0b6ff070 | |||
| b91bc98611 | |||
| 4210c2493d | |||
| 711c9e525e | |||
| 808ac6c792 | |||
| 73eb938c03 | |||
| cccae446ae | |||
| 24b3a2d422 | |||
| f472a17897 | |||
| 182b87fff2 | |||
| cb58e91d6b | |||
| 39896c74de | |||
| f3f564ab08 | |||
| ccbb2f0944 | |||
| d468c869c0 | |||
| 47e03bbc4d | |||
| eafc06f0bc | |||
| 9dcbcad9aa | |||
| 028062bd14 |
16
.dockerignore
Normal file
16
.dockerignore
Normal file
@@ -0,0 +1,16 @@
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.*
|
||||
*.json
|
||||
!backend/
|
||||
!backend/search/
|
||||
!backend/search/*.json
|
||||
*.xlsx
|
||||
*.pdf
|
||||
*.jpg
|
||||
data/
|
||||
docs/
|
||||
tools/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Уточнено, что внутренняя лаборатория оценивает выдачу собственного OpenSearch;
|
||||
ЦБД Минюста служит источником для подтверждения документов, а прежние Excel/PDF
|
||||
бланки удалены.
|
||||
- Исключены секреты и локальные данные из Docker build context; синхронизирована версия интерфейса.
|
||||
- Добавлен production deployment baseline для Search API и OpenSearch с
|
||||
постоянными хранилищами и healthcheck.
|
||||
- Добавлено восстановление незавершённой разметки из `localStorage` после перезагрузки страницы.
|
||||
- Добавлена внутренняя лаборатория проверки поисковой выдачи: просмотр документов,
|
||||
оценка релевантности 0–3, комментарии и SQLite-экспорт подписанных снимков.
|
||||
- Уточнены доступные состояния и адаптивное поведение внутреннего интерфейса
|
||||
оценки поисковой выдачи.
|
||||
- Добавлен план внутреннего интерфейса оценки поисковой выдачи юристами.
|
||||
|
||||
@@ -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 — не создан
|
||||
|
||||
@@ -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 — не создан
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -56,17 +56,19 @@ cp backend/search/relevance-set-v1.template.json data/search/relevance-set-v1.js
|
||||
|
||||
## Разметка одного запроса
|
||||
|
||||
1. До просмотра выдачи зафиксируйте информационную потребность и формулировку
|
||||
`query`.
|
||||
2. Найдите кандидатов в локальном OpenSearch и в официальной ЦБД Минюста.
|
||||
Проверьте исходный запрос, его короткий вариант и вариант с юридическим
|
||||
термином или известным номером акта.
|
||||
3. Просмотрите не только заголовки, но и текст, статус, дату и редакцию каждого
|
||||
кандидата.
|
||||
4. Запишите уникальные `document_code` всех документов, удовлетворяющих
|
||||
критерию релевантности.
|
||||
5. Повторите поиск по ключевым терминам найденных документов, чтобы обнаружить
|
||||
пропущенные альтернативные акты.
|
||||
1. Зафиксируйте исходную формулировку `query` и информационную потребность до
|
||||
оценки выдачи нашего поиска.
|
||||
2. Юрист устанавливает релевантные акты по содержанию и реквизитам документов.
|
||||
Используйте ЦБД Минюста как авторитетный источник для проверки текста,
|
||||
статуса и редакции акта. Порядок выдачи ЦБД не является объектом оценки и не
|
||||
переносится в эталон.
|
||||
3. Запишите уникальные `document_code` всех документов, которые прямо отвечают
|
||||
на запрос. Спорные документы передайте на независимую проверку.
|
||||
4. Зафиксируйте согласованный набор до просмотра результатов OpenSearch и
|
||||
сохраните его отдельно от рабочих данных.
|
||||
5. Проверьте запрос во внутренней лаборатории (`/review`): оцените фактические
|
||||
результаты OpenSearch в исходном порядке, сохраните снимок и комментарии.
|
||||
Лаборатория проверяет качество нашей поисковой системы, а не ЦБД Минюста.
|
||||
|
||||
Пример структуры (код условный):
|
||||
|
||||
|
||||
@@ -12,9 +12,11 @@ from pathlib import Path
|
||||
|
||||
from search.minjust_opensearch import APP_VERSION, request_json
|
||||
from search.catalog import CATALOGS, labels
|
||||
from search.reviews import ReviewSnapshots, ReviewStore
|
||||
|
||||
|
||||
API_VERSION = "v1"
|
||||
SEARCH_ALGORITHM_VERSION = "search-1"
|
||||
LANGUAGES = {"ru", "ky"}
|
||||
CODE = re.compile(r"^[0-9]+$")
|
||||
MAX_PAGE_SIZE = 100
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
114
backend/search/review.html
Normal file
114
backend/search/review.html
Normal file
@@ -0,0 +1,114 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Оценка поисковой выдачи · Акылдаш</title>
|
||||
<style>
|
||||
:root { color-scheme: light; font: 16px/1.5 system-ui, sans-serif; color: #17202a; background: #f5f7fa; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; }
|
||||
.review__skip { position: absolute; inset-block-start: 0; inset-inline-start: -10000px; padding: .5rem 1rem; background: #fff; }
|
||||
.review__skip:focus { inset-inline-start: 1rem; z-index: 2; }
|
||||
.review__header, .review__main { max-width: 1440px; margin: auto; padding-inline: 1rem; }
|
||||
.review__header { padding-block: 1.5rem 1rem; }
|
||||
.review__main { padding-block-end: 8rem; }
|
||||
.review__search { display: flex; flex-wrap: wrap; align-items: end; gap: .75rem; padding: 1rem; background: #fff; border: 1px solid #d9e0e7; border-radius: .75rem; }
|
||||
.review__field { display: grid; gap: .25rem; min-width: 12rem; flex: 1; }
|
||||
.review__field--small { flex: 0 0 7rem; min-width: 7rem; }
|
||||
input, select, textarea, button { font: inherit; }
|
||||
input, select, textarea { border: 1px solid #8d9aaa; border-radius: .4rem; padding: .6rem .7rem; background: #fff; }
|
||||
input:focus-visible, select:focus-visible, textarea:focus-visible, button:focus-visible { outline: 3px solid #1769aa; outline-offset: 2px; }
|
||||
button { cursor: pointer; border: 1px solid #536273; border-radius: .4rem; padding: .6rem .9rem; background: #fff; color: #17202a; }
|
||||
button:hover { background: #edf3f8; }
|
||||
.review__button--primary { background: #145a86; color: #fff; border-color: #145a86; }
|
||||
.review__button--primary:hover { background: #0e4669; }
|
||||
.review__status { min-height: 1.7rem; margin-block: .75rem; }
|
||||
.review__status--error { color: #9b1c1c; }
|
||||
.review__workspace { display: grid; grid-template-columns: minmax(22rem, 1fr) minmax(24rem, 1.1fr); gap: 1rem; align-items: start; }
|
||||
.review__results, .review__document { background: #fff; border: 1px solid #d9e0e7; border-radius: .75rem; padding: 1rem; }
|
||||
.review__results-list { display: grid; gap: 1rem; margin: 0; padding: 0; list-style: none; }
|
||||
.review__result { border-block-start: 1px solid #d9e0e7; padding-block-start: 1rem; }
|
||||
.review__result:first-child { border-block-start: 0; padding-block-start: 0; }
|
||||
.review__result-title { display: flex; gap: .5rem; align-items: baseline; width: 100%; text-align: start; font-weight: 700; border: 0; padding: 0; color: #124f78; }
|
||||
.review__rank { flex: 0 0 auto; color: #526272; font-variant-numeric: tabular-nums; }
|
||||
.review__meta, .review__snippet { margin-block: .35rem; color: #526272; }
|
||||
.review__snippet { overflow-wrap: anywhere; }
|
||||
.review__rating { display: flex; flex-wrap: wrap; gap: .45rem; margin-block: .65rem; padding: 0; border: 0; }
|
||||
.review__rating legend { width: 100%; font-weight: 600; }
|
||||
.review__rating label { min-width: 3.5rem; text-align: center; }
|
||||
.review__rating input { accent-color: #145a86; }
|
||||
.review__comment { width: 100%; min-height: 4rem; resize: vertical; }
|
||||
.review__document { position: sticky; inset-block-start: 1rem; min-height: 20rem; }
|
||||
.review__document-body { max-width: 75ch; overflow-wrap: anywhere; }
|
||||
.review__document-body img { max-width: 100%; height: auto; }
|
||||
.review__actions { position: fixed; inset-block-end: 0; inset-inline: 0; padding: .75rem 1rem; background: rgb(255 255 255 / .96); border-block-start: 1px solid #d9e0e7; text-align: end; }
|
||||
dialog { max-width: min(56rem, calc(100% - 2rem)); max-height: calc(100% - 2rem); border: 1px solid #8d9aaa; border-radius: .75rem; padding: 1rem; }
|
||||
dialog::backdrop { background: rgb(10 20 30 / .5); }
|
||||
.review__dialog-close { float: inline-end; }
|
||||
@media (max-width: 800px) {
|
||||
.review__workspace { grid-template-columns: 1fr; }
|
||||
.review__document { display: none; }
|
||||
.review__search { align-items: stretch; }
|
||||
.review__field, .review__field--small { flex-basis: 100%; }
|
||||
.review__search button { width: 100%; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="review__skip" href="#results">Перейти к результатам</a>
|
||||
<header class="review__header"><h1>Оценка поисковой выдачи</h1><p>Проверяйте результаты нашего OpenSearch по практическим юридическим запросам.</p></header>
|
||||
<main class="review__main" id="review">
|
||||
<form class="review__search" id="search-form">
|
||||
<label class="review__field">Запрос<input id="query" name="q" maxlength="500" required autocomplete="off"></label>
|
||||
<label class="review__field review__field--small">Язык<select id="language" name="language"><option value="ru">Русский</option><option value="ky">Кыргызский</option></select></label>
|
||||
<label class="review__field review__field--small">Результатов<select id="page-size" name="page_size"><option>10</option><option>20</option></select></label>
|
||||
<button class="review__button--primary" type="submit">Найти</button>
|
||||
</form>
|
||||
<div class="review__status" id="status" role="status" aria-live="polite"></div>
|
||||
<div class="review__workspace">
|
||||
<section class="review__results" aria-labelledby="results-heading"><h2 id="results-heading">Результаты</h2><ol class="review__results-list" id="results"></ol></section>
|
||||
<section class="review__document" aria-labelledby="document-heading"><h2 id="document-heading">Документ</h2><div id="document-meta">Выберите результат, чтобы открыть текст.</div><article class="review__document-body" id="document-body"></article></section>
|
||||
</div>
|
||||
</main>
|
||||
<div class="review__actions"><label>Проверяющий <input id="reviewer" maxlength="120" autocomplete="name"></label> <label>Общий комментарий <input id="overall-comment" maxlength="4000"></label> <button class="review__button--primary" id="save" type="button">Сохранить оценку</button></div>
|
||||
<dialog id="document-dialog"><button class="review__dialog-close" id="dialog-close" type="button">Закрыть</button><h2 id="dialog-heading">Документ</h2><div class="review__document-body" id="dialog-body"></div></dialog>
|
||||
<div class="review__status review__status--error" id="error" role="alert" aria-live="assertive"></div>
|
||||
<footer class="review__header">Акылдаш · Backend v0.8.3 · Внутренняя лаборатория релевантности</footer>
|
||||
<script>
|
||||
const DRAFT_KEY = 'akyldash-search-review-draft';
|
||||
const state = { results: [], token: '', selected: null, query: '' };
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const setStatus = (text) => { $('status').textContent = text; $('error').textContent = ''; };
|
||||
const setError = (text) => { $('error').textContent = text; };
|
||||
const escapeText = (value) => value == null ? '' : String(value);
|
||||
const readDraft = () => { try { return JSON.parse(localStorage.getItem(DRAFT_KEY) || 'null'); } catch (_) { return null; } };
|
||||
const saveDraft = () => { if (!state.token) return; const results = [...$('results').children].map((item, index) => ({rank: index + 1, code: item.dataset.code, rating: item.querySelector('input:checked')?.value ?? null, comment: item.querySelector('textarea').value})); try { localStorage.setItem(DRAFT_KEY, JSON.stringify({query: state.query, language: $('language').value, pageSize: $('page-size').value, reviewer: $('reviewer').value, overallComment: $('overall-comment').value, results})); } catch (_) {} };
|
||||
const applyDraft = () => { const draft = readDraft(); if (!draft || draft.query !== state.query || draft.language !== $('language').value || draft.pageSize !== $('page-size').value) return; $('reviewer').value = draft.reviewer || ''; $('overall-comment').value = draft.overallComment || ''; (draft.results || []).forEach((saved) => { const item = [...$('results').children].find((candidate) => candidate.dataset.rank === String(saved.rank) && candidate.dataset.code === saved.code); if (!item) return; if (saved.rating != null) { const input = item.querySelector(`input[value="${CSS.escape(String(saved.rating))}"]`); if (input) input.checked = true; } item.querySelector('textarea').value = saved.comment || ''; }); };
|
||||
function renderResults() {
|
||||
$('results').replaceChildren();
|
||||
state.results.forEach((result, index) => {
|
||||
const item = document.createElement('li'); item.className = 'review__result'; item.dataset.rank = index + 1; item.dataset.code = result.code;
|
||||
const title = document.createElement('button'); title.type = 'button'; title.className = 'review__result-title'; title.innerHTML = `<span class="review__rank">#${index + 1}</span><span></span>`; title.lastElementChild.textContent = escapeText(result.name || 'Название не указано'); title.addEventListener('click', () => openDocument(index, title));
|
||||
const meta = document.createElement('div'); meta.className = 'review__meta'; meta.textContent = [result.type, result.status, result.date_adopted, result.number].filter(Boolean).join(' · ');
|
||||
const snippet = document.createElement('div'); snippet.className = 'review__snippet'; snippet.textContent = result.snippet || 'Фрагмент не найден.';
|
||||
const rating = document.createElement('fieldset'); rating.className = 'review__rating'; rating.innerHTML = `<legend>Оценка результата</legend>`;
|
||||
[['0', 'нерелевантен'], ['1', 'косвенно полезен'], ['2', 'частично полезен'], ['3', 'прямо отвечает']].forEach(([value, label]) => { const id = `rating-${index}-${value}`; const wrapper = document.createElement('label'); wrapper.htmlFor = id; wrapper.textContent = `${value} — ${label}`; const input = document.createElement('input'); input.type = 'radio'; input.name = `rating-${index}`; input.id = id; input.value = value; wrapper.prepend(input); rating.append(wrapper); });
|
||||
const comment = document.createElement('textarea'); comment.className = 'review__comment'; comment.maxLength = 4000; comment.placeholder = 'Комментарий к результату (необязательно)'; comment.setAttribute('aria-label', `Комментарий к результату #${index + 1}`);
|
||||
item.append(title, meta, snippet, rating, comment); $('results').append(item);
|
||||
});
|
||||
applyDraft();
|
||||
}
|
||||
async function openDocument(index, trigger) {
|
||||
const result = state.results[index]; state.selected = trigger; setStatus('Загрузка документа…');
|
||||
try { const response = await fetch(`/documents/${encodeURIComponent(result.code)}/editions/${encodeURIComponent(result.edition)}?language=${encodeURIComponent($('language').value)}`); if (!response.ok) throw new Error('Документ недоступен'); const payload = await response.json(); const content = payload.content[$('language').value]; const meta = $('document-meta'); meta.textContent = [result.name, result.status, result.date_adopted].filter(Boolean).join(' · '); const source = document.createElement('a'); source.href = `https://cbd.minjust.gov.kg/${encodeURIComponent(result.code)}/edition/${encodeURIComponent(result.edition)}/${encodeURIComponent($('language').value)}`; source.target = '_blank'; source.rel = 'noreferrer'; source.textContent = ' Официальный источник'; meta.append(source); $('document-body').innerHTML = content.html; $('dialog-heading').textContent = result.name || 'Документ'; $('dialog-body').innerHTML = content.html; if (matchMedia('(max-width: 800px)').matches) $('document-dialog').showModal(); setStatus('Документ загружен.'); } catch (error) { setError('Не удалось загрузить документ. Повторите попытку.'); }
|
||||
}
|
||||
$('dialog-close').addEventListener('click', () => { $('document-dialog').close(); if (state.selected) state.selected.focus(); });
|
||||
$('search-form').addEventListener('submit', async (event) => { event.preventDefault(); const query = $('query').value.trim(); if (!query) return; state.query = query; setStatus('Поиск выполняется…'); $('save').disabled = true; try { const params = new URLSearchParams({q: query, language: $('language').value, page_size: $('page-size').value}); const response = await fetch(`/search?${params}`); if (!response.ok) throw new Error(); const payload = await response.json(); state.results = payload.results; state.token = payload.review_token; renderResults(); setStatus(state.results.length ? `Найдено результатов: ${state.results.length}.` : `По запросу «${query}» ничего не найдено. Измените запрос.`); } catch (error) { state.results = []; state.token = ''; renderResults(); setError('Не удалось выполнить поиск. Повторите поиск.'); } finally { $('save').disabled = false; } });
|
||||
document.addEventListener('input', saveDraft); document.addEventListener('change', saveDraft);
|
||||
$('save').addEventListener('click', async () => { if (!state.token) { setError('Сначала выполните поиск.'); return; } const reviewer = $('reviewer').value.trim(); if (!reviewer) { $('reviewer').focus(); setError('Укажите проверяющего.'); return; } const results = [...$('results').children].map((item, index) => ({rank: index + 1, code: item.dataset.code, rating: item.querySelector('input:checked') ? Number(item.querySelector('input:checked').value) : null, comment: item.querySelector('textarea').value})); $('save').disabled = true; setStatus('Сохранение выполняется…'); try { const response = await fetch('/search-reviews', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({review_token: state.token, reviewer, overall_comment: $('overall-comment').value, results})}); if (!response.ok) throw new Error(); const payload = await response.json(); localStorage.removeItem(DRAFT_KEY); setStatus(`Оценка сохранена · № ${payload.id}.`); } catch (error) { setError('Не удалось сохранить. Проверьте подключение и повторите.'); } finally { $('save').disabled = false; } });
|
||||
const draft = readDraft(); if (draft) { $('query').value = draft.query || ''; $('language').value = draft.language || 'ru'; $('page-size').value = draft.pageSize || '20'; $('reviewer').value = draft.reviewer || ''; $('overall-comment').value = draft.overallComment || ''; }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
83
backend/search/reviews.py
Normal file
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
|
||||
43
backend/test_search_reviews.py
Normal file
43
backend/test_search_reviews.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from search.api import Api, ApiError
|
||||
|
||||
|
||||
class SearchReviewTest(unittest.TestCase):
|
||||
def test_review_must_cover_each_snapshot_rank_once(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = Api("http://opensearch:9200", "current", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
|
||||
response = {"hits": {"hits": [{"_index": "search-20260827", "_source": {"document_code": "7", "edition_code": "10", "document_name_ru": "Закон"}}, {"_index": "search-20260827", "_source": {"document_code": "8", "edition_code": "11", "document_name_ru": "Кодекс"}}]}}
|
||||
with patch("search.api.request_json", return_value=response):
|
||||
result = api.handle("GET", "/search?q=test&language=ru&page_size=2")[1]
|
||||
with self.assertRaisesRegex(ApiError, "exactly once"):
|
||||
api.handle("POST", "/search-reviews", {"review_token": result["review_token"], "reviewer": "Юрист", "results": [{"rank": 1, "code": "7", "rating": 3}]})
|
||||
|
||||
def test_saves_signed_search_snapshot_and_rejects_tampering(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = Api("http://opensearch:9200", "current", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
|
||||
response = {"hits": {"hits": [{"_source": {"document_code": "7", "edition_code": "10", "document_name_ru": "Закон"}}]}}
|
||||
with patch("search.api.request_json", return_value=response):
|
||||
result = api.handle("GET", "/search?q=%D0%B7%D0%B0%D0%BA%D0%BE%D0%BD&language=ru")[1]
|
||||
saved = api.handle("POST", "/search-reviews", {"review_token": result["review_token"], "reviewer": "Юрист", "results": [{"rank": 1, "code": "7", "rating": 3, "comment": "Прямой ответ"}]})
|
||||
self.assertEqual(saved[0], 201)
|
||||
exported = api.handle("GET", "/search-reviews/export")[1]["reviews"]
|
||||
self.assertEqual(exported[0]["top_result_code"], "7")
|
||||
self.assertEqual(exported[0]["results"][0]["rating"], 3)
|
||||
with self.assertRaisesRegex(ApiError, "does not match"):
|
||||
api.handle("POST", "/search-reviews", {"review_token": result["review_token"], "reviewer": "Юрист", "results": [{"rank": 1, "code": "8", "rating": 3}]})
|
||||
|
||||
def test_snapshot_and_review_page_are_available(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = Api("http://opensearch:9200", "current", Path(temporary))
|
||||
page = api.review_page()
|
||||
self.assertIn("Оценка поисковой выдачи", page)
|
||||
self.assertIn("akyldash-search-review-draft", page)
|
||||
self.assertIn("localStorage", page)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
8
deploy/production/.env.example
Normal file
8
deploy/production/.env.example
Normal file
@@ -0,0 +1,8 @@
|
||||
# Absolute path on the production host containing minjust-normalized/.
|
||||
DATA_ROOT=/volume1/docker/akyldash/data
|
||||
BACKEND_PORT=8080
|
||||
SEARCH_INDEX=akyldash-fragments-current
|
||||
OPENSEARCH_MEM_LIMIT=4g
|
||||
OPENSEARCH_JAVA_OPTS=-Xms2g -Xmx2g
|
||||
# Generate with: openssl rand -hex 32
|
||||
REVIEW_SECRET=replace-with-a-random-secret
|
||||
11
deploy/production/Dockerfile.backend
Normal file
11
deploy/production/Dockerfile.backend
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY backend /app/backend
|
||||
|
||||
ENV PYTHONPATH=/app/backend
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["python", "-m", "search.api", "--host", "0.0.0.0", "--port", "8080", "--url", "http://opensearch:9200", "--index", "akyldash-fragments-current", "--data", "/app/data/minjust-normalized", "--reviews-db", "/app/data/search-reviews.sqlite3"]
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/review', timeout=3)"]
|
||||
3
deploy/production/Dockerfile.opensearch
Normal file
3
deploy/production/Dockerfile.opensearch
Normal file
@@ -0,0 +1,3 @@
|
||||
FROM opensearchproject/opensearch:3.7.0
|
||||
|
||||
RUN /usr/share/opensearch/bin/opensearch-plugin install --batch analysis-icu
|
||||
37
deploy/production/README.md
Normal file
37
deploy/production/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Production deployment
|
||||
|
||||
This compose project runs the Search API and its private OpenSearch node. It
|
||||
binds the API only to `127.0.0.1`; publish it through an authenticated reverse
|
||||
proxy or VPN. OpenSearch is not published outside the compose network.
|
||||
|
||||
The current compose intentionally disables the OpenSearch security plugin to
|
||||
match the existing API client. Keep both services on a private host/network
|
||||
until authenticated OpenSearch support is implemented.
|
||||
|
||||
## First deployment
|
||||
|
||||
1. Copy this directory to the host with the repository source.
|
||||
2. Copy `.env.example` to `.env`, set an absolute `DATA_ROOT`, and replace
|
||||
`REVIEW_SECRET` with a random value. Do not commit `.env`.
|
||||
3. Put the normalized dataset under `$DATA_ROOT/minjust-normalized/`.
|
||||
4. Check the rendered configuration:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env -f compose.yaml config
|
||||
```
|
||||
|
||||
5. Start the services:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env -f compose.yaml up -d --build
|
||||
docker compose --env-file .env -f compose.yaml ps
|
||||
curl -fsS http://127.0.0.1:${BACKEND_PORT:-8080}/review >/dev/null
|
||||
```
|
||||
|
||||
6. Load the versioned index and switch its alias only after the import and
|
||||
validation succeed. Back up `DATA_ROOT` and the `opensearch-data` volume
|
||||
before the first import.
|
||||
|
||||
This is a deployment baseline, not a public internet exposure recipe. TLS,
|
||||
authentication, backups, monitoring, and a production OpenSearch security
|
||||
configuration must be provided by the host reverse proxy/operations setup.
|
||||
64
deploy/production/compose.yaml
Normal file
64
deploy/production/compose.yaml
Normal file
@@ -0,0 +1,64 @@
|
||||
services:
|
||||
opensearch:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: deploy/production/Dockerfile.opensearch
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
discovery.type: single-node
|
||||
bootstrap.memory_lock: "true"
|
||||
DISABLE_SECURITY_PLUGIN: "true"
|
||||
OPENSEARCH_JAVA_OPTS: ${OPENSEARCH_JAVA_OPTS:--Xms2g -Xmx2g}
|
||||
mem_limit: ${OPENSEARCH_MEM_LIMIT:-4g}
|
||||
expose:
|
||||
- "9200"
|
||||
ulimits:
|
||||
memlock:
|
||||
soft: -1
|
||||
hard: -1
|
||||
nofile:
|
||||
soft: 65536
|
||||
hard: 65536
|
||||
volumes:
|
||||
- opensearch-data:/usr/share/opensearch/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:9200/_cluster/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: deploy/production/Dockerfile.backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
REVIEW_SECRET: ${REVIEW_SECRET:?set REVIEW_SECRET in .env}
|
||||
command:
|
||||
- python
|
||||
- -m
|
||||
- search.api
|
||||
- --host
|
||||
- 0.0.0.0
|
||||
- --port
|
||||
- "8080"
|
||||
- --url
|
||||
- http://opensearch:9200
|
||||
- --index
|
||||
- ${SEARCH_INDEX:-akyldash-fragments-current}
|
||||
- --data
|
||||
- /app/data/minjust-normalized
|
||||
- --reviews-db
|
||||
- /app/data/search-reviews.sqlite3
|
||||
- --review-secret
|
||||
- ${REVIEW_SECRET}
|
||||
ports:
|
||||
- "127.0.0.1:${BACKEND_PORT:-8080}:8080"
|
||||
depends_on:
|
||||
opensearch:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ${DATA_ROOT:?set DATA_ROOT in .env}:/app/data
|
||||
|
||||
volumes:
|
||||
opensearch-data:
|
||||
@@ -9,7 +9,9 @@
|
||||
- [Готовность к проектированию frontend](product/frontend-design-readiness-plan.md) —
|
||||
обязательные работы и критерии перехода к frontend.
|
||||
- [План интерфейса оценки поисковой выдачи](product/search-relevance-review-interface-plan.md) —
|
||||
внутренний инструмент сбора оценок юристов для настройки OpenSearch.
|
||||
внутренняя лаборатория сбора оценок юристов для настройки OpenSearch.
|
||||
- [Разметка relevance set](../backend/search/RELEVANCE_ANNOTATION.md) — подготовка
|
||||
эталонных документов и воспроизводимая оценка собственного поиска.
|
||||
- [Задание по нормализации документов](product/minjust-document-normalization-agent-task.md) —
|
||||
требования и критерии приёмки нормализатора ЦБД Минюста КР.
|
||||
|
||||
@@ -37,4 +39,4 @@
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.8.3 · Frontend — не создан
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
# Статус проекта
|
||||
|
||||
Последняя проверка: 2026-08-14
|
||||
Последняя проверка: 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 ЦБД Минюста КР и их локальная воспроизводимая нормализация.
|
||||
|
||||
Ближайшая цель — оценить полный локальный индекс на 50–100 запросах RU/KY и
|
||||
настроить ранжирование до начала разработки поискового API.
|
||||
Поисковая система и внутренняя лаборатория оценки выдачи реализованы. Ближайшая
|
||||
цель — проверить собственную выдачу на практических RU/KY-запросах, разобрать
|
||||
оценки юристов и зафиксировать baseline. ЦБД Минюста служит источником для
|
||||
проверки документов и редакций, а не системой для сравнения поисковой выдачи.
|
||||
|
||||
## Уже сделано
|
||||
|
||||
@@ -77,6 +79,8 @@
|
||||
- Полный проход завершён: 209 958 документов нормализованы без ошибок.
|
||||
- Контрольная выборка RU/KY прошла проверки текста, фрагментов, ID и SHA-256.
|
||||
- Добавлены строгий mapping и атомарный Bulk NDJSON-экспорт для OpenSearch.
|
||||
- На предыдущей итерации использовались Excel/PDF-бланки юридической проверки;
|
||||
текущий процесс опирается на relevance set и внутреннюю лабораторию.
|
||||
|
||||
### Развёртывание
|
||||
|
||||
@@ -112,6 +116,17 @@
|
||||
- Не настроены уведомления Gitea.
|
||||
- Не проведён полный пользовательский тест: сообщение → сохранение → экспорт → анализ в ChatGPT → возврат отчёта.
|
||||
|
||||
### Готовность поиска к frontend
|
||||
|
||||
- Реализована внутренняя лаборатория для просмотра и оценки фактической
|
||||
выдачи OpenSearch; пилот с юристами ещё не проведён.
|
||||
- Не завершена независимая юридическая проверка всех 50 запросов relevance set
|
||||
v1; спорные строки нельзя включать в baseline.
|
||||
- Не зафиксированы неизменяемая копия подтверждённого relevance set и baseline
|
||||
`Recall@10`/`MRR@10`.
|
||||
- Не пройден финальный readiness gate: обновление корпуса, переиндексация,
|
||||
проверка выдачи и API-сценарии для RU, KY и одноязычных актов.
|
||||
|
||||
## Важные неизвестные
|
||||
|
||||
По мере реального использования нужно принять решения по следующим вопросам:
|
||||
@@ -122,19 +137,32 @@
|
||||
|
||||
## Следующий этап
|
||||
|
||||
### Фиксация MVP и проверка полного цикла
|
||||
### Проверка собственной поисковой выдачи
|
||||
|
||||
Рекомендуемый порядок:
|
||||
|
||||
1. Настроить резервное копирование `/volume1/docker/akyldash/data`.
|
||||
2. Зафиксировать состав MVP с командой и закрыть тему `MVP`.
|
||||
3. Провести одно реальное обсуждение с ответами, правками и вложением.
|
||||
4. Экспортировать обсуждение и вручную проанализировать Markdown в ChatGPT.
|
||||
5. Вернуть подтверждённый итог в тему `Решения` и зафиксировать его в Git/Gitea.
|
||||
6. Записать обнаруженные неудобства и только после этого выбирать следующую доработку.
|
||||
1. Подготовить согласованный RU/KY relevance set: фиксировать потребность
|
||||
запроса и подтверждённые `document_code`, не ориентируясь на порядок выдачи.
|
||||
2. Проверить практические запросы во внутренней лаборатории `/review`, сохранить
|
||||
снимки и оценки фактических результатов OpenSearch.
|
||||
3. Разобрать ошибки ранжирования, сохранить baseline и повторить ту же проверку
|
||||
после изменений.
|
||||
4. Затем пройти readiness gate issue #21 для корпуса, индекса и Search API перед
|
||||
проектированием публичного frontend.
|
||||
|
||||
## История изменений статуса
|
||||
|
||||
### 2026-09-12
|
||||
|
||||
- Внутренняя лаборатория оценивает фактическую выдачу собственного OpenSearch;
|
||||
ЦБД Минюста используется только для проверки источников и редакций актов.
|
||||
- Backend `0.8.3`; закрытая внутренняя лаборатория готова к пилоту.
|
||||
|
||||
### 2026-09-06
|
||||
|
||||
- Подготовлены бланки независимой юридической проверки relevance set v1 для
|
||||
русского и кыргызского языков и инструкция для юриста.
|
||||
- Ближайшим этапом зафиксированы юридическая верификация, baseline качества
|
||||
поиска и финальный readiness gate перед frontend.
|
||||
|
||||
### 2026-08-18
|
||||
|
||||
- Оценщик поиска использует `cross_fields` для совместного сопоставления
|
||||
@@ -234,4 +262,4 @@
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.8.3 · Frontend — не создан
|
||||
|
||||
@@ -52,13 +52,14 @@ alias без смешивания старого и нового корпуса.
|
||||
1. Заполнить рабочую копию из `backend/search/relevance-set-v1.template.json`
|
||||
естественными формулировками пользователей и подтверждёнными
|
||||
`document_code` релевантных действующих актов.
|
||||
2. Проверить каждый запрос по официальной ЦБД и локальному индексу; не
|
||||
включать запросы без установленного эталонного результата.
|
||||
2. Подтвердить документы по официальной ЦБД и проверить их наличие в локальном
|
||||
индексе по `document_code`, не оценивая порядок поисковой выдачи. Не включать
|
||||
запросы без установленного эталонного результата.
|
||||
3. Провести независимую вторую проверку языка, формулировки и полного списка
|
||||
кодов. Спорные результаты не включать до согласования.
|
||||
4. После проверки сохранить неизменяемую копию набора и baseline
|
||||
`Recall@10`/`MRR@10`; новые правила ранжирования оценивать только сравнением
|
||||
с этим baseline.
|
||||
4. После независимой проверки сохранить неизменяемую копию набора, затем
|
||||
оценить собственную выдачу и сохранить baseline `Recall@10`/`MRR@10`. Новые
|
||||
правила ранжирования проверять на том же наборе и сравнивать с baseline.
|
||||
5. Отдельно проверить распространённые сокращения, опечатки и запросы о
|
||||
практическом действии. Правило принимается, только если улучшает
|
||||
подтверждённые запросы и не создаёт ложных срабатываний в негативных
|
||||
|
||||
Reference in New Issue
Block a user