diff --git a/CHANGELOG.md b/CHANGELOG.md
index d41e71f..1672a3d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,16 @@
## Не выпущено
+- Backend `0.13.4` / Frontend `0.3.4`, алгоритм поиска `search-8`: для явно
+ заданного multi-index поиска включена глобальная статистика DFS; signed review
+ snapshots сохраняют полный index scope, а пустую выдачу можно сохранить с
+ обязательным общим комментарием и точной проверкой рангов. Узкое расширение
+ термина аренды сохранено без глобальных весов. На executable SHA
+ `1c7ddf2` выполнен полный isolated прогон 7 500 запросов: изменились 537
+ порядков относительно исходного baseline, 6 выдач остались пустыми; это
+ наблюдения ранжирования, не экспертная оценка права. Три актуальных акта
+ проверялись только в отдельном локальном supplement; production и исходный
+ индекс не менялись.
- Успешный CI на `push` в `main` запускает release workflow: при настроенных
registry secrets он собирает образы rootless Kaniko, публикует digest
references и сохраняет schema-v4 descriptor с SHA-256 в Gitea artifact;
diff --git a/README.md b/README.md
index b5ec8c2..1be5b54 100644
--- a/README.md
+++ b/README.md
@@ -10,18 +10,21 @@ Telegram-бот — только часть рабочего окружения
## Текущее состояние
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2`, backend версии
-`0.13.2` и frontend `0.3.3` с поисковым экраном, внутренней лабораторией и
+`0.13.4` и frontend `0.3.4` с поисковым экраном, внутренней лабораторией и
согласованным отображением доступных действий учетной записи.
Авторизация хранится в
SQLite; первый пользователь `admin` создаётся интерактивной CLI-командой.
-Поиск расширяет аббревиатуры организационно-правовых форм и поднимает
-действующий Налоговый кодекс для запросов о налоге на прибыль.
+Поиск расширяет аббревиатуры организационно-правовых форм, применяет узкий
+юридический вариант для общего словосочетания «договор аренды» и поднимает
+действующий Налоговый кодекс для запросов о налоге на прибыль. Ограничения
+текущего индекса и подтверждённые тестовые ошибки описаны в
+[отчёте о релевантности](docs/product/search-relevance-findings-2026-11-03.md).
| Компонент | Версия | Состояние |
|---|---:|---|
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
-| Backend | `0.13.2` | авторизация, серверная capability управления пользователями, поиск и статические ресурсы |
-| Frontend | `0.3.3` | поиск по правовой базе, просмотр редакций, внутренняя лаборатория и контекстная навигация кабинета |
+| Backend | `0.13.4` | авторизация, серверная capability управления пользователями, поиск и статические ресурсы |
+| Frontend | `0.3.4` | поиск по правовой базе, просмотр редакций, внутренняя лаборатория и контекстная навигация кабинета |
| Сбор и обработка правовых данных | `0.8.0` | добавлены relevance set и оценка выдачи |
| RAG и база знаний | — | ещё не созданы |
@@ -64,4 +67,4 @@ python3 -m unittest discover -s tools/telegram-bot -v
---
-Акылдаш · Telegram-бот v0.2.2 · Backend v0.13.2 · Frontend v0.3.3
+Акылдаш · Telegram-бот v0.2.2 · Backend v0.13.4 · Frontend v0.3.4
diff --git a/backend/README.md b/backend/README.md
index d7e7e88..04ee2d3 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -274,6 +274,9 @@ PYTHONPATH=backend python3 -m search.evaluate_relevance \
Менять веса или анализаторы следует только после фиксации этого baseline и
разбора ошибок выдачи.
+Диагностический отчёт с измерениями, подтверждёнными ошибками и ограничениями
+текущего корпуса: [search relevance findings](../docs/product/search-relevance-findings-2026-11-03.md).
+
## Внутренняя лаборатория релевантности
Создайте первую учетную запись администратора:
@@ -292,7 +295,8 @@ PYTHONPATH=backend python3 -m search.api \\
Лаборатория показывает фактический порядок выдачи OpenSearch, позволяет открыть
текст редакции, поставить результату оценку от 0 до 3 и сохранить снимок с
-комментариями. Поиск и API доступны только после входа. Пользователь `admin`
+комментариями. Пустую выдачу можно сохранить как query-level оценку с
+обязательным общим комментарием. Поиск и API доступны только после входа. Пользователь `admin`
управляет учетными записями; новые пользователи должны заменить временный
пароль при первом входе. Хранилище пользователей `data/search-users.sqlite3`
нужно резервировать отдельно от поискового индекса. Экспорт доступен
diff --git a/backend/search/api.py b/backend/search/api.py
index d69fe59..f401aaf 100644
--- a/backend/search/api.py
+++ b/backend/search/api.py
@@ -21,7 +21,7 @@ from search.reviews import ReviewSnapshots, ReviewStore
API_VERSION = "v1"
-SEARCH_ALGORITHM_VERSION = "search-6"
+SEARCH_ALGORITHM_VERSION = "search-8"
LANGUAGES = {"ru", "ky"}
CODE = re.compile(r"^[0-9]+$")
MAX_PAGE_SIZE = 100
@@ -125,9 +125,12 @@ class Api:
def search_url(self, suffix: str) -> str:
return f"{self.base_url}/{urllib.parse.quote(self.index, safe='')}/{suffix}"
- def query_opensearch(self, body: dict) -> dict:
+ def query_opensearch(self, body: dict, *, search_type: str | None = None) -> dict:
try:
- return request_json(self.search_url("_search"), "POST", json.dumps(body, ensure_ascii=False).encode(), "application/json")
+ url = self.search_url("_search")
+ if search_type is not None:
+ url = f"{url}?{urllib.parse.urlencode({'search_type': search_type})}"
+ return request_json(url, "POST", json.dumps(body, ensure_ascii=False).encode(), "application/json")
except RuntimeError as error:
raise ApiError(502, "search backend is unavailable") from error
@@ -177,7 +180,8 @@ class Api:
if sort == "date":
body["sort"].append({"date_adopted": "desc"})
body["sort"].append({"_score": "desc"})
- response = self.query_opensearch(body)
+ search_type = "dfs_query_then_fetch" if "," in self.index else None
+ response = self.query_opensearch(body, search_type=search_type)
try:
hits = response["hits"]["hits"]
except (KeyError, TypeError) as error:
@@ -185,7 +189,14 @@ class Api:
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
+ # A comma-separated index expression is the searched dataset scope;
+ # hits may come from only one of its members (or there may be no hits).
+ # For a single configured index/alias, keep the concrete-index provenance
+ # when OpenSearch returns exactly one concrete target.
+ if "," in self.index:
+ index_name = self.index
+ else:
+ 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
@@ -283,9 +294,18 @@ class Api:
raise ApiError(400, "overall_comment is too long")
if not isinstance(submitted, list):
raise ApiError(400, "results must be an array")
+ if len(submitted) > MAX_PAGE_SIZE:
+ raise ApiError(400, f"results must not exceed {MAX_PAGE_SIZE} items")
+ if any(not isinstance(item, dict) for item in submitted):
+ raise ApiError(400, "review result must be an object")
+ ranks = [item.get("rank") for item in submitted]
+ if any(type(rank) is not int for rank in ranks):
+ raise ApiError(400, "review result rank is invalid")
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):
+ if len(ranks) != len(set(ranks)) or len(submitted) != len(by_rank) or set(ranks) != set(by_rank):
raise ApiError(400, "all search results must be reviewed exactly once")
+ if not snapshot["results"] and not overall_comment.strip():
+ raise ApiError(400, "overall_comment is required for an empty search result")
results = []
for item in submitted:
if not isinstance(item, dict) or not isinstance(item.get("rank"), int) or item["rank"] not in by_rank:
@@ -300,8 +320,6 @@ class Api:
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"]}
diff --git a/backend/search/evaluate_relevance.py b/backend/search/evaluate_relevance.py
index e5b2eb8..bd5e48e 100644
--- a/backend/search/evaluate_relevance.py
+++ b/backend/search/evaluate_relevance.py
@@ -82,7 +82,7 @@ def main() -> int:
raise SystemExit("--top-k must be greater than zero")
result = evaluate(load_queries(arguments.relevance_set), arguments.url, arguments.index, arguments.top_k)
print(json.dumps(result, ensure_ascii=False, indent=2))
- print(f"Akyldash · Backend v{APP_VERSION} · Frontend v0.1.2", file=sys.stderr)
+ print(f"Akyldash · Backend v{APP_VERSION} · Frontend v0.3.4", file=sys.stderr)
return 0
diff --git a/backend/search/minjust_opensearch.py b/backend/search/minjust_opensearch.py
index 0e67b8d..f7167af 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, legal_force_code, source_code
-APP_VERSION = "0.13.3"
+APP_VERSION = "0.13.4"
LANGUAGES = {"ru", "ky"}
DEFAULT_MAPPING = Path(__file__).with_name("minjust-fragments-index.json")
@@ -552,7 +552,7 @@ def main() -> int:
else:
documents, fragments = export_bulk(arguments.input, arguments.output, arguments.index, arguments.limit)
destination = arguments.output
- print(f"documents={documents} fragments={fragments} destination={destination}\nAkyldash · Backend v{APP_VERSION} · Frontend v0.3.3")
+ print(f"documents={documents} fragments={fragments} destination={destination}\nAkyldash · Backend v{APP_VERSION} · Frontend v0.3.4")
return 0
diff --git a/backend/search/query.py b/backend/search/query.py
index aca26af..f0f936b 100644
--- a/backend/search/query.py
+++ b/backend/search/query.py
@@ -22,6 +22,19 @@ LEGAL_FORM_ALIASES = {
"жоопкерчилиги чектелген коом": ("жчк",),
},
}
+# The indexed Civil Code uses the legal term "имущественный наем" for a
+# договор аренды. Keep this exact legal phrase expansion narrow: it must not
+# rewrite residential hiring, employment, sublease, or leasing queries.
+LEASE_TERM_ALIAS = re.compile(
+ r"(? str:
return value
+def lease_term_variant(query: str) -> str | None:
+ if LEASE_TERM_ALIAS.search(query) and not LEASE_TERM_ALIAS_EXCLUSIONS.search(query):
+ return LEASE_TERM_ALIAS.sub(r"\1 имущественного найма", query)
+ return None
+
+
def query_variants(language: str, query: str) -> list[str]:
variants = [query]
for alias, expansions in LEGAL_FORM_ALIASES[language].items():
pattern = re.compile(rf"(? dict:
"boost": 0.1,
}
})
+ lease_variant = lease_term_variant(query) if language == "ru" else None
phrase_matches = [
- {"match_phrase": {f"text_{language}": {"query": variant, "boost": 3}}}
- for variant in query_variants(language, query)
+ {"match_phrase": {f"text_{language}": {
+ "query": variant,
+ "boost": LEASE_TERM_PHRASE_BOOST if variant == lease_variant else 3,
+ }}}
+ for variant in variants
]
planned = {"should": [*full_text, *phrase_matches, *relaxed_text, *intent_clauses], "minimum_should_match": 1}
return {"bool": planned}
@@ -208,7 +235,7 @@ def main() -> int:
if arguments.top_k <= 0:
raise SystemExit("--top-k must be greater than zero")
print(json.dumps(search_documents(arguments.url, arguments.index, arguments.language, arguments.query, arguments.top_k), ensure_ascii=False))
- print(f"Akyldash · Backend v{APP_VERSION} · Frontend v0.1.2")
+ print(f"Akyldash · Backend v{APP_VERSION} · Frontend v0.3.4")
return 0
diff --git a/backend/search/review.html b/backend/search/review.html
index d38c2c4..89547af 100644
--- a/backend/search/review.html
+++ b/backend/search/review.html
@@ -657,7 +657,7 @@
@@ -675,7 +675,7 @@
-
+
diff --git a/backend/test_search_api.py b/backend/test_search_api.py
index ddc366f..44bad5c 100644
--- a/backend/test_search_api.py
+++ b/backend/test_search_api.py
@@ -3,6 +3,7 @@ import contextlib
import io
import tempfile
import unittest
+import urllib.parse
from pathlib import Path
from unittest.mock import Mock, patch
@@ -190,6 +191,44 @@ class SearchApiTest(unittest.TestCase):
self.assertIn("legal_force_code", body["aggs"]["legal_forces"]["terms"]["field"])
self.assertEqual(body["query"], {"term": {"is_current_edition": True}})
+ def test_global_dfs_is_only_sent_for_explicit_multi_index_searches(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ multi = Api("http://opensearch:9200", "base-v2,supplement-v1", Path(temporary), review_secret=b"test-secret")
+ alias = Api("http://opensearch:9200", "current", Path(temporary), review_secret=b"test-secret")
+ single = Api("http://opensearch:9200", "single-v2", Path(temporary), review_secret=b"test-secret")
+ one_hit = {"hits": {"hits": [{"_index": "base-v2", "_source": {"document_code": "5", "document_name_ru": "Кодекс", "document_name_ky": "Кодекс"}}]}}
+ empty = {"hits": {"hits": []}}
+ alias_hit = {"hits": {"hits": [{"_index": "current-20261103", "_source": {"document_code": "7", "document_name_ru": "Закон"}}]}}
+ aggregations = {"aggregations": {name: {"buckets": []} for name in ("document_types", "statuses", "authorities", "legal_forces")}}
+ with patch("search.api.request_json", side_effect=[one_hit, empty, alias_hit, one_hit, aggregations]) as request:
+ multi_ru = multi.search({"q": ["test"], "language": ["ru"], "page_size": ["10"]})
+ multi_ky_empty = multi.search({"q": ["test"], "language": ["ky"], "page_size": ["10"]})
+ alias_ru = alias.search({"q": ["test"], "language": ["ru"], "page_size": ["10"]})
+ single_ky = single.search({"q": ["test"], "language": ["ky"], "page_size": ["10"]})
+ alias.filters({"language": ["ru"]})
+
+ calls = request.call_args_list
+ multi_ru_url = urllib.parse.urlsplit(calls[0].args[0])
+ multi_ky_url = urllib.parse.urlsplit(calls[1].args[0])
+ self.assertEqual(multi_ru_url.path, "/base-v2%2Csupplement-v1/_search")
+ self.assertEqual(urllib.parse.parse_qs(multi_ru_url.query), {"search_type": ["dfs_query_then_fetch"]})
+ self.assertEqual(urllib.parse.parse_qs(multi_ky_url.query), {"search_type": ["dfs_query_then_fetch"]})
+ self.assertEqual([item["code"] for item in multi_ru["results"]], ["5"])
+ self.assertEqual(multi_ky_empty["results"], [])
+ self.assertEqual(multi.review_snapshots.verify(multi_ru["review_token"])["index_name"], "base-v2,supplement-v1")
+ self.assertEqual(multi.review_snapshots.verify(multi_ky_empty["review_token"])["index_name"], "base-v2,supplement-v1")
+
+ for call in calls[2:]:
+ parsed = urllib.parse.urlsplit(call.args[0])
+ self.assertEqual(parsed.query, "")
+ self.assertEqual(alias.review_snapshots.verify(alias_ru["review_token"])["index_name"], "current-20261103")
+ self.assertEqual(single.review_snapshots.verify(single_ky["review_token"])["index_name"], "base-v2")
+ multi_body = json.loads(calls[0].args[2])
+ alias_body = json.loads(calls[2].args[2])
+ self.assertEqual(multi_body, alias_body)
+ self.assertNotIn("search_type", multi_body)
+ self.assertEqual(json.loads(calls[1].args[2])["query"]["bool"]["filter"][0], {"term": {"language": "ky"}})
+
if __name__ == "__main__":
unittest.main()
diff --git a/backend/test_search_api_opensearch.py b/backend/test_search_api_opensearch.py
index a0f6232..e4ffa42 100644
--- a/backend/test_search_api_opensearch.py
+++ b/backend/test_search_api_opensearch.py
@@ -37,5 +37,29 @@ class SearchApiOpenSearchTest(unittest.TestCase):
request_json(f"{base_url}/{index}", "DELETE", None, "application/json")
+@unittest.skipUnless(os.getenv("AKYLDASH_OPENSEARCH_URL"), "set AKYLDASH_OPENSEARCH_URL to run against local OpenSearch")
+class SearchRankingOpenSearchTest(unittest.TestCase):
+ def test_generic_lease_query_ranks_general_civil_code_first(self):
+ base_url = os.environ["AKYLDASH_OPENSEARCH_URL"].rstrip("/")
+ index = os.getenv("AKYLDASH_OPENSEARCH_INDEX", "akyldash-fragments-v2")
+ api = Api(base_url, index, Path("."))
+ result = api.search({"q": ["расторжение договора аренды"], "language": ["ru"], "page_size": ["10"]})
+ self.assertEqual(result["results"][0]["code"], "5")
+
+
+@unittest.skipUnless(
+ os.getenv("AKYLDASH_OPENSEARCH_URL") and "," in os.getenv("AKYLDASH_OPENSEARCH_INDEX", ""),
+ "set AKYLDASH_OPENSEARCH_URL and a comma-separated AKYLDASH_OPENSEARCH_INDEX for read-only multi-index search",
+)
+class SearchMultiIndexDfsOpenSearchTest(unittest.TestCase):
+ def test_global_statistics_mode_accepts_read_only_multi_index_search(self):
+ base_url = os.environ["AKYLDASH_OPENSEARCH_URL"].rstrip("/")
+ index = os.environ["AKYLDASH_OPENSEARCH_INDEX"]
+ api = Api(base_url, index, Path("."))
+ result = api.search({"q": ["расторжение договора аренды"], "language": ["ru"], "page_size": ["10"]})
+ self.assertIsInstance(result["results"], list)
+ self.assertEqual(api.review_snapshots.verify(result["review_token"])["index_name"], index)
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/backend/test_search_relevance.py b/backend/test_search_relevance.py
index 510efc0..9254ef6 100644
--- a/backend/test_search_relevance.py
+++ b/backend/test_search_relevance.py
@@ -5,7 +5,7 @@ from pathlib import Path
from unittest.mock import patch
from search.evaluate_relevance import evaluate, load_queries
-from search.query import COMPANY_REGISTRATION_DOCUMENTS, TAX_PROFIT_DOCUMENTS, build_search_body, query_variants
+from search.query import COMPANY_REGISTRATION_DOCUMENTS, LEASE_TERM_PHRASE_BOOST, TAX_PROFIT_DOCUMENTS, build_search_body, query_variants
class SearchRelevanceTest(unittest.TestCase):
@@ -49,6 +49,36 @@ class SearchRelevanceTest(unittest.TestCase):
self.assertIn("регистрация осоо", [variant.casefold() for variant in query_variants("ru", "регистрация общества с ограниченной ответственностью")])
self.assertIn("жчк ачуу тартиби", [variant.casefold() for variant in query_variants("ky", "жоопкерчилиги чектелген коом ачуу тартиби")])
+ def test_russian_lease_phrase_expands_only_the_exact_legal_construction(self):
+ original = "расторжение договора аренды"
+ variants = query_variants("ru", original)
+ self.assertEqual(variants[0], original)
+ self.assertIn("расторжение договора имущественного найма", variants)
+ body = json.loads(build_search_body("ru", original, 10))
+ clauses = body["query"]["bool"]["must"]["bool"]["should"]
+ full_text = [item["multi_match"]["query"] for item in clauses if "multi_match" in item and item["multi_match"].get("operator") == "AND"]
+ self.assertEqual(full_text, variants)
+ phrase_matches = [item["match_phrase"]["text_ru"] for item in clauses if "match_phrase" in item]
+ self.assertEqual([item["boost"] for item in phrase_matches], [3, LEASE_TERM_PHRASE_BOOST])
+
+ for query in (
+ "аренда квартиры",
+ "расторжение договора аренды квартиры",
+ "договор аренды земельного участка",
+ "договор поднайма",
+ "договор аренды с правом поднайма",
+ "наем работника",
+ "договор лизинга",
+ ):
+ self.assertEqual(query_variants("ru", query), [query])
+
+ self.assertEqual(query_variants("ky", "договор аренды"), ["договор аренды"])
+
+ self.assertEqual(
+ query_variants("ru", "договору аренды"),
+ ["договору аренды", "договору имущественного найма"],
+ )
+
def test_relaxed_match_drops_ru_template_prefix_and_stays_low_weight(self):
body = json.loads(build_search_body("ru", "правовое регулирование расторжение договора аренды", 10))
clauses = body["query"]["bool"]["must"]["bool"]["should"]
diff --git a/backend/test_search_reviews.py b/backend/test_search_reviews.py
index 06f8e9d..83358f1 100644
--- a/backend/test_search_reviews.py
+++ b/backend/test_search_reviews.py
@@ -3,33 +3,140 @@ import unittest
from pathlib import Path
from unittest.mock import patch
-from search.api import Api, ApiError
+from search.api import Api, ApiError, SEARCH_ALGORITHM_VERSION
class SearchReviewTest(unittest.TestCase):
+ def test_multi_index_snapshot_and_saved_review_preserve_configured_scope(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ api = Api("http://opensearch:9200", "A,B", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
+ cases = (
+ ("no hits", []),
+ ("A only", [{"_index": "A", "_source": {"document_code": "7", "document_name_ru": "Закон A"}}]),
+ ("B only", [{"_index": "B", "_source": {"document_code": "8", "document_name_ru": "Закон B"}}]),
+ ("mixed", [
+ {"_index": "A", "_source": {"document_code": "7", "document_name_ru": "Закон A"}},
+ {"_index": "B", "_source": {"document_code": "8", "document_name_ru": "Закон B"}},
+ ]),
+ )
+ for label, hits in cases:
+ with self.subTest(case=label):
+ with patch("search.api.request_json", return_value={"hits": {"hits": hits}}):
+ result = api.handle("GET", "/search?q=test&language=ru")[1]
+ token = result["review_token"]
+ self.assertEqual(api.review_snapshots.verify(token)["index_name"], "A,B")
+ review_results = [
+ {"rank": rank, "code": item["code"], "rating": 3}
+ for rank, item in enumerate(result["results"], 1)
+ ]
+ api.handle("POST", "/search-reviews", {
+ "review_token": token,
+ "reviewer": "Юрист",
+ "results": review_results,
+ "overall_comment": "Проверена выдача для настроенного scope.",
+ })
+ saved = api.handle("GET", "/search-reviews/export")[1]["reviews"][-1]
+ self.assertEqual(saved["index_name"], "A,B")
+
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": []})
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_invalid_rank_types_and_sets_are_rejected_as_bad_requests(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ api = Api("http://opensearch:9200", "current", Path(temporary), review_secret=b"test-secret")
+ response = {"hits": {"hits": [
+ {"_source": {"document_code": "7", "edition_code": "10", "document_name_ru": "Закон"}},
+ {"_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]
+ token = result["review_token"]
+
+ malformed = [
+ {"rank": True, "code": "7", "rating": 3},
+ {"rank": [1], "code": "7", "rating": 3},
+ {"rank": {"value": 1}, "code": "7", "rating": 3},
+ {"rank": None, "code": "7", "rating": 3},
+ {"rank": "1", "code": "7", "rating": 3},
+ {"rank": 1.0, "code": "7", "rating": 3},
+ None,
+ ]
+ for item in malformed:
+ with self.subTest(item=item):
+ with self.assertRaises(ApiError) as raised:
+ api.handle("POST", "/search-reviews", {"review_token": token, "reviewer": "Юрист", "results": [item]})
+ self.assertEqual(raised.exception.status, 400)
+
+ for submitted in (
+ [],
+ [{"rank": 1, "code": "7", "rating": 3}, {"rank": 1, "code": "7", "rating": 3}],
+ [{"rank": 1, "code": "7", "rating": 3}, {"rank": 3, "code": "8", "rating": 3}],
+ [{"rank": 1, "code": "7", "rating": 3}],
+ {"rank": 1, "code": "7", "rating": 3},
+ ):
+ with self.subTest(submitted=submitted):
+ with self.assertRaises(ApiError) as raised:
+ api.handle("POST", "/search-reviews", {"review_token": token, "reviewer": "Юрист", "results": submitted})
+ self.assertEqual(raised.exception.status, 400)
+
+ oversized = [{"rank": 1, "code": "7", "rating": 3}] * 101
+ with self.assertRaises(ApiError) as raised:
+ api.handle("POST", "/search-reviews", {"review_token": token, "reviewer": "Юрист", "results": oversized})
+ self.assertEqual(raised.exception.status, 400)
+
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": "Закон"}}]}}
+ response = {"hits": {"hits": [{"_index": "search-20260827", "_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]
+ self.assertEqual(api.review_snapshots.verify(result["review_token"])["index_name"], "search-20260827")
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]["index_name"], "search-20260827")
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_saves_signed_empty_snapshot_with_query_level_comment(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ api = Api("http://opensearch:9200", "current", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
+ with patch("search.api.request_json", return_value={"hits": {"hits": []}}):
+ result = api.handle("GET", "/search?q=редкий+запрос&language=ru")[1]
+ self.assertEqual(api.review_snapshots.verify(result["review_token"])["index_name"], "current")
+ body = {
+ "review_token": result["review_token"],
+ "reviewer": "Юрист",
+ "overall_comment": "Пустая выдача подтверждена.",
+ "results": [],
+ }
+ with self.assertRaisesRegex(ApiError, "overall_comment is required"):
+ api.handle("POST", "/search-reviews", {**body, "overall_comment": ""})
+ saved = api.handle("POST", "/search-reviews", body)
+ self.assertEqual(saved[0], 201)
+ review = api.handle("GET", "/search-reviews/export")[1]["reviews"][0]
+ self.assertIsNone(review["top_result_code"])
+ self.assertEqual(review["results"], [])
+ self.assertEqual(review["overall_comment"], body["overall_comment"])
+
+ def test_review_page_enables_query_level_review_for_empty_results(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ api = Api("http://opensearch:9200", "current", Path(temporary))
+ page = api.review_page()
+ self.assertIn("state.results.length ? 'Сохранить оценку' : 'Сохранить пустую выдачу'", page)
+ self.assertIn("aria-required', String(!state.results.length)", page)
+ self.assertIn("const results = state.results.map((result, index) => { const item", page)
+
def test_snapshot_and_review_page_are_available(self):
with tempfile.TemporaryDirectory() as temporary:
api = Api("http://opensearch:9200", "current", Path(temporary))
@@ -41,6 +148,27 @@ class SearchReviewTest(unittest.TestCase):
self.assertNotIn('id="error"', page)
self.assertIn("localStorage", page)
+ def test_new_search_snapshots_use_search_8_without_invalidating_search_7(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ api = Api("http://opensearch:9200", "current", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
+ with patch("search.api.request_json", return_value={"hits": {"hits": [{"_index": "current-v2", "_source": {"document_code": "7", "document_name_ru": "Закон"}}]}}):
+ result = api.handle("GET", "/search?q=test&language=ru")[1]
+ current = api.review_snapshots.verify(result["review_token"])
+ self.assertEqual(SEARCH_ALGORITHM_VERSION, "search-8")
+ self.assertEqual(current["algorithm_version"], "search-8")
+
+ legacy_token = api.review_snapshots.create(
+ "legacy query", "ru", "current-v1", [{"rank": 1, "code": "7"}], "search-7"
+ )
+ legacy_saved = api.handle("POST", "/search-reviews", {
+ "review_token": legacy_token,
+ "reviewer": "Юрист",
+ "results": [{"rank": 1, "code": "7", "rating": 3}],
+ })
+ self.assertEqual(legacy_saved[0], 201)
+ exported = api.handle("GET", "/search-reviews/export")[1]["reviews"]
+ self.assertEqual(exported[-1]["algorithm_version"], "search-7")
+
if __name__ == "__main__":
unittest.main()
diff --git a/docs/product/search-relevance-findings-2026-11-03.md b/docs/product/search-relevance-findings-2026-11-03.md
new file mode 100644
index 0000000..41ff8b9
--- /dev/null
+++ b/docs/product/search-relevance-findings-2026-11-03.md
@@ -0,0 +1,318 @@
+# Диагностика релевантности поиска — 3 ноября 2026 года
+
+## Решения и границы
+
+Эта заметка фиксирует исходные измерения от 2 ноября, узкое исправление
+терминологии, условный multi-index scoring и контракт для закрытия пробела
+корпуса. Выдачи и AI-аннотации не являются юридической оценкой. Целевой A/B
+прогон и полный benchmark для итогового SHA описаны ниже; они не заменяют
+экспертную оценку.
+
+Изменён backend `0.13.3` → `0.13.4`, frontend внутренней лаборатории
+`0.3.3` → `0.3.4`, версия алгоритма `search-6` → `search-8`. `search-7`
+относится к предшествующему составу ranking semantics; новые подписанные
+снимки этого PR помечаются `search-8`. Сохранённые отзывы не переписываются.
+Финальный runtime/batch прогон кода выполнен на `1c7ddf2`; последующий коммит
+этого отчёта и changelog меняет только документацию.
+
+## Исходный benchmark
+
+Зафиксированный набор содержит 7 500 русских формулировок: 15 тематических
+групп, по 500 запросов в каждой. Это 750 тем с десятью вариантами формулировки,
+а не 7 500 независимых правовых ситуаций. Исходный индекс
+`akyldash-fragments-v2` содержал около 42,6 млн фрагментов и занимал 11,2 ГБ.
+
+| Показатель Акылдаш, исходный прогон | Результат |
+|---|---:|
+| Собранные выдачи | 7 500 / 7 500 |
+| Пустые выдачи | 6 |
+| Медиана / P95 ответа | 21,67 / 77,36 мс |
+| Совпадение основ темы в видимых названии/фрагменте, top 1 | 40,57% |
+| То же, top 3 / top 10 | 47,05% / 51,40% |
+
+Сравнение с КонсультантПлюс в исходном отчёте — только диагностическое:
+юрисдикции и корпуса различаются. Совпадение основ темы не измеряет Recall,
+MRR, полноту ответа или юридическую правильность; российская выдача не является
+эталоном права Кыргызстана. Для вывода о качестве нужны экспертные релевантные
+коды, контрольный набор и отдельные метрики.
+
+## Подтверждённые классы ошибок
+
+14 коротких документов прочитаны целиком; Codex (ИИ) подготовил по ним 79
+предварительных оценок пар «запрос—документ» в 71 сессии: 59 помечены как
+нерелевантные и 20 как косвенно полезные. Это тематическая AI-разметка для
+триажа, а не заключение юриста или независимая экспертная проверка. Она не
+подтверждает полноту правового ответа. Неоценённые результаты не считались
+ошибками.
+
+| Тема | Наблюдавшийся неверный приоритет | Вывод |
+|---|---:|---|
+| Расторжение договора аренды | 135066 | Решение по одной сделке с землёй не даёт общих оснований расторжения |
+| Международный коммерческий арбитраж | 26411 | Продажа земельных участков не регулирует арбитраж и отказ в признании решения |
+| Испытательный срок работника | 94603 | Земельный участок под завод/испытательный полигон совпадает словами, но не предметом |
+| Место жительства ребёнка | 50760 | Финансирование приюта не устанавливает порядок спора родителей |
+| Перевод студента в другой вуз | 14165 | Разовое финансирование студентов не устанавливает процедуру перевода |
+| Таможенные пошлины | 41854 | Индивидуальная отсрочка одному предприятию не задаёт общие правила |
+| Общий порядок предоставления земли | 54735, 54571 | Решения по отдельным участкам не являются общей процедурой |
+| Защита критической инфраструктуры | 59082 | Предоставление земли под завод не устанавливает защитные требования |
+| Купля-продажа / расторжение договора | 60053 | Расторжение одной сделки с госакциями не является общей нормой |
+| Подача искового заявления | 18516, 7115 | Отдельный спор и узкая льгота по пошлине не дают общего порядка подачи иска |
+| Принятие нормативных актов | 77190 | Первое чтение одного законопроекта не устанавливает общий процесс |
+| Подключение к водоснабжению | 135061 | Целевая помощь отдельным домохозяйствам не задаёт технические условия |
+
+Проверенные варианты глобального повышения веса кодексов отвергнуты: вес 2 не
+исправил аренду и подачу иска, а вес 4 поднял Налоговый кодекс выше профильных
+источников по воинской обязанности и подаче иска. Общие веса или жёсткие
+приоритеты кодексов не добавлялись.
+
+## Узкая терминологическая поправка
+
+В исследованной редакции ГК КР II (код 5, редакция 460) глава 27 и статья 542
+связывают договор аренды с имущественным наймом; статьи 558–559 используют
+термин «имущественный наем». В полном тексте этой редакции буквальная фраза
+«расторжение договора аренды» не встречалась. Исходный запрос возвращал код
+135066 на первом месте, а формулировка «расторжение договора имущественного
+найма» — код 5.
+
+Backend теперь сохраняет исходную строку и добавляет только для точного
+сочетания «договор [арендная форма]» вариант «договор [та же форма]
+имущественного найма». В частности, `договора аренды` превращается в
+`договора имущественного найма`. Расширение не срабатывает для аренды квартиры
+или земельного участка, поднайма, найма работника или лизинга. Точный запрос
+остаётся в строгой лексической ветке, поэтому его профильный муниципальный акт
+не исключается из поиска.
+
+В read-only пробе предыдущей версии `aa6fabe` для общего запроса код 5 поднялся
+с позиции за пределами top 10 на #5, но нерелевантный индивидуальный акт 135066
+оставался первым. На SHA `726b1ee` isolated code-only прогон на исходном индексе
+поставил код 5 первым для общего запроса; из 7 500 запросов изменились 19
+выдач, 7 481 совпала с baseline, число пустых осталось 6. Этот результат
+проверяет `search-7` с `match_phrase` boost 12 для синтетической юридической
+фразы, исходный phrase boost 3; он не описывает multi-index DFS. После
+подключения трёх актов в отдельном supplement на `e653f3b` изменились 184
+выдачи против code-only варианта и 38 top-1; все шесть пустых выдач
+сохранились. Разные наборы индексов нельзя считать одним benchmark. Итоговый
+conditional-DFS прогон на `1c7ddf2` приведён ниже. Контроль запросов об аренде
+жилья/земли, поднайме, трудовых отношениях, международном праве и KY
+формулировках выполнялся отдельно; rank-метрики не являются юридической
+оценкой.
+
+## Условный scoring для явного multi-index scope
+
+При поиске по явно настроенному comma-separated scope (`base,supplement`) API
+передаёт `search_type=dfs_query_then_fetch` как URL query parameter. Запросы по
+одному настроенному индексу или alias сохраняют прежний `query_then_fetch`;
+одиночные wildcard и alias не классифицируются по результату их expansion.
+DFS включается только при запятой в настроенной строке scope.
+Тело DSL, фильтры, query weights, сортировка, индексы и документы не меняются.
+Параметр применяется только к `/search`; фильтры и остальные API вызовы
+используют прежний режим.
+
+OpenSearch описывает `query_then_fetch` как scoring по локальным term/document
+frequencies шардов, а DFS — по глобальным frequencies через шарды; DFS обычно
+точнее, но может быть медленнее. `search_type` поддерживается именно как
+параметр Search API URL ([OpenSearch Search API](https://docs.opensearch.org/latest/api-reference/search-apis/search/)).
+Поэтому ограниченный режим использует глобальные statistics для явной
+multi-index конфигурации и не меняет behavior одиночного поиска.
+
+На изолированном OpenSearch 3.7.0 оба режима сравнили на одинаковых DSL bytes и
+одном неизменном наборе данных: 300 заранее выбранных запросов, максимум 300.
+Порядок менялся в 93 случаях, top-1 — в 29; из 9 именованных риск-кейсов
+изменились 3, все 5 выбранных positive controls сохранили top-1. Контроль с
+индивидуальным актом 135066 сохранил этот акт первым. В пяти запросах о
+педагогических/научных работниках и одном запросе о болезнях животных top-1 не
+изменился. Эти шесть случаев требуют предметной оценки и не объявляются
+автоматически нерелевантными: статья 150(8) закона об охране здоровья связывает
+аттестацию педагогических/научных работников с медицинской деятельностью, а
+случай с болезнями животных остаётся без экспертной оценки. Новые оценки пар не
+выставлялись; специальные веса или document IDs не добавлялись. Сравнение
+ограничено выборкой и не является разметкой юридической правильности.
+Медианы 22,955 мс для default и 16,115 мс для DFS не используются как оценка
+скорости: default всегда исполнялся первым, возможен cache/order bias.
+
+Frozen experiment metadata is stored outside the repository in
+`reports/search-benchmark-2026-11-02/dfs-probe-e653-summary.json`. The paired
+response-capture artifact `dfs-probe-e653.jsonl` has SHA-256
+`4a9373ba5236c5c5a1d85139b6dbe67bcf01df02216f8b2f292e5c451e6432c8`.
+Выборка содержала base index (42 624 501 fragments) и read-only supplement
+(8 934 fragments); UUID/count guards прошли, основной индекс и сохранённые
+отзывы во время эксперимента не менялись. В PR не включены сырые ответы,
+персональные данные или выдачи документов.
+
+Кодовый тест проверяет URL, encoded multi-index path, RU/KY, пустую выдачу,
+прежнее тело запроса и неизменность single alias/index и filters. Read-only
+integration gate для root-owned runtime:
+
+```bash
+AKYLDASH_OPENSEARCH_URL=http://127.0.0.1:8081 \
+AKYLDASH_OPENSEARCH_INDEX='akyldash-fragments-v2,akyldash-benchmark-supplement-20261103-r1' \
+PYTHONPATH=backend python3 -m unittest \
+ backend.test_search_api_opensearch.SearchMultiIndexDfsOpenSearchTest.test_global_statistics_mode_accepts_read_only_multi_index_search -v
+```
+
+Root выполнил этот read-only gate на 8081 для `1c7ddf2`: фактический multi-index
+URI использовал DFS и подписанный token сохранял полный scope. Gate выполняет
+только поиск и не создаёт/удаляет индексы. Полный benchmark на том же code SHA
+и frozen baseline приведён ниже; 300-query experiment и более ранние прогоны
+остаются отдельными сравнительными срезами.
+
+Откат к предшествующему приложению возвращает `query_then_fetch` для multi-index
+поиска. Индекс, документы, saved reviews и старые подписанные токены при этом
+не требуют изменений или миграции; новые отзывы, сохранённые до rollback,
+остаются помечены `search-8`. Для supplement действует отдельный rollback
+контракт ниже: отключение его подключения скрывает только добавленные редакции,
+не удаляя подготовленные файлы и базовый индекс.
+
+## Итоговый pinned прогон search-8
+
+Полный isolated прогон для executable SHA `1c7ddf2fcbd7c55920470e7ac52339fdab0366db`
+собрал 7 500 уникальных запросов с версией `search-8`. SHA-256 выдачи:
+`fc02e22e5b3d612813ffc0bdaf9892bb16ee4c29af3e1b77f79e58a0a45c8ec1`. Итог
+сопоставлен с frozen baseline и двумя явно указанными промежуточными режимами:
+
+| Сравнение | Изменён порядок | Изменён top-1 |
+|---|---:|---:|
+| Исходный baseline `search-6` | 537 | 103 |
+| Code-only `726b1ee`, `search-7` | 521 | 93 |
+| Code + три акта, `e653f3b`, без DFS | 425 | 63 |
+
+Это число изменений порядка, а не улучшений. Все шесть пустых выдач сохранились
+во всех четырёх прогонах. Среди ранее проверенных Codex (ИИ) пар с нулевой
+оценкой top-1 стало 26 вместо 28 в baseline; остальные 26 не объявлены
+исправленными, и новые выдачи автоматически не оценивались.
+
+| Добавленный акт | Наблюдался в top-1 / top-3 / top-10 |
+|---|---:|
+| Трудовой кодекс КР `230021083` | 24 / 58 / 105 |
+| Земельный кодекс КР `230029465` | 4 / 11 / 26 |
+| Закон об охране здоровья `230000918` | 34 / 51 / 92 |
+
+Все шесть точных названий новых актов на RU/KY дали ожидаемый текущий акт первым.
+Все 284 запроса, общие с целевым DFS экспериментом, совпали с ним по полному
+порядку кодов и редакций. Проверки dataset guard подтвердили неизменность
+исходного индекса, manifest, mappings и UUID/count, а также 115 подключённых
+файлов. 7 503 записи локальной лаборатории совпали с backup; обе SQLite
+integrity checks прошли. Записи оценок, комментарии и пользователи не менялись.
+
+Шесть межтематических контекстов нельзя автоматически считать нерелевантными:
+медицинский закон остался первым в пяти запросах о педагогических/научных
+работниках и в одном запросе о болезнях животных. Для педагогических случаев
+проверенная статья 150(8) связывает аттестацию этих работников с медицинской
+деятельностью; новые оценки пар не выставлялись. Вопрос о животных также
+остался без экспертной оценки. ИИ-аннотации не доказывают полноту правового
+ответа или юридическую правильность ранжирования.
+
+Режим и scope дополнительно проверены на runtime `8081`: для multi-index `/search`
+уходит DFS, для одиночного индекса и filters — прежний default; тело DSL
+одинаково. Alias проверен transport mock без создания alias. Runtime mode/API
+smoke включал HMAC/TTL, signed scope, сохранение пустого результата и
+отрицательные rank/code сценарии. Browser UI fixture прошёл на `726b1ee`; UI
+побайтно не менялся в следующих SHA, но отдельный browser прогон на `1c7ddf2`
+не выполнялся. Медиана backend `23.685 мс` описательна и не является SLO или
+доказательством ускорения.
+
+Эксперимент подключал только три акта в отдельном supplement и не доказывает
+полноту всего корпуса, актуальность всех редакций, наличие вложений или
+production-готовность. Отключение overlay/supplement скроет эти новые редакции
+из локального поиска/API, но сохранит подготовленные файлы, review-записи и
+исходный индекс. Production не менялся. Подробный технический отчёт и raw
+benchmark остаются во внешнем рабочем наборе и в PR не включены.
+
+## Пустые выдачи в лаборатории
+
+Исходный контракт блокировал сохранение шести подписанных пустых снимков:
+API отвергал `results: []`, а интерфейс читал поля из пустого placeholder.
+Теперь signed snapshot может сохранить пустую оценку с обязательным общим
+комментарием. В записи `top_result_code` остаётся `null`, список результатов
+пустой. Непустой снимок по-прежнему обязан получить оценку каждого ранга ровно
+один раз, совпадающую с подписанным кодом документа. HMAC, TTL и текущая
+аутентификация не меняются.
+
+Подписанный снимок теперь сохраняет scope запроса при явно настроенном
+multi-index выражении (`A,B`): provenance не сужается до индекса, который
+случайно вернул единственный результат, и сохраняется при пустой выдаче. Для
+одного настроенного индекса или alias остаётся прежнее поведение: единственный
+concrete `_index` записывается как provenance; если OpenSearch не сообщил его,
+сохраняется настроенный scope. Новые отзывы получают эту метку при сохранении.
+SQLite-схема, исторические записи и формат подписанных токенов не меняются.
+Откат к старому коду не требует миграции и не переписывает существующие
+отзывы; пока работает откатанная версия, новые multi-index отзывы снова могут
+получить суженную метку, поэтому исправление provenance следует вернуть вместе
+с основным выпуском.
+
+Существующая отдельная база локальной лаборатории содержала 7 494 сохранённых
+сессии, 79 AI-оценок и 55 516 диагностических комментариев; 74 715
+оценок результатов остались пустыми намеренно. Integrity check прошёл,
+порядок кодов и редакций baseline не менялся. Эти сохранённые оценки не
+переписывались. В последующем изолированном trial шесть реальных подписанных
+пустых отзывов с общими комментариями были сохранены как записи 7498–7503;
+итоговые 7 503 строки лаборатории совпали с backup, integrity check прошёл.
+Пустые query-level отзывы не являются оценками отдельных документов.
+
+## Пробел актуального корпуса и безопасный контракт закрытия
+
+Отдельно от ранжирования, в исходном base index не было трёх
+подтверждённых действующих актов (все три canonical ID имели `_count = 0`):
+
+| Акт | Canonical ID | Последняя проверенная редакция |
+|---|---:|---|
+| Трудовой кодекс КР от 23.01.2025 № 23 | 230021083 | 57548 от 28.07.2026 |
+| Земельный кодекс КР от 18.07.2025 № 149 | 230029465 | 34746 от 18.07.2025 |
+| Об охране здоровья граждан КР от 12.01.2024 № 14 | 230000918 | 47553 от 23.01.2026 |
+
+Это разрыв между нормализованными документами и индексом, а не полное
+отсутствие актов в локальном архиве: в `data/minjust-normalized` уже есть
+canonical-каталоги всех трёх документов, manifest сообщает 209 958 успешно
+обработанных документов. Однако трудовой акт там отстаёт от официальной
+редакции 57548 от 28.07.2026 (последняя подготовленная — № 60 от 23.06.2026);
+земельный акт нормализован до редакции № 10 от 18.07.2025; для акта о здоровье
+последняя подготовленная — № 60 от 23.01.2026. В base index эти три canonical
+ID не попали. Прежние отменённые трудовой и земельный кодексы нельзя переименовать
+или поднять как замену актуальным нормам.
+
+Для локальной изолированной проверки подготовлены точные официальные RU/KY
+редакции: 3 документа и 8 934 фрагмента. На runtime 8081 они были временно
+подключены отдельным supplement-index и доступны API через read-only overlay
+catalogues. Это не изменение base index или production и не доказательство
+полноты корпуса. Вложения и полная история новых canonical ID отдельно не
+подтверждены.
+
+Закрытие пробела требует отдельного контролируемого обновления данных:
+
+1. Заново сверить официальные canonical ID, статус, edition ID и дату; сохранить
+ исходный ответ, хеши и provenance в отдельном staging-каталоге.
+2. Проверить полноту обеих языковых версий, ссылки на редакции, историю и
+ вложения; не импортировать метаданные с ненужными персональными полями.
+3. Нормализовать документы отдельно и проверить exact-count/hash. Для
+ ограниченного дополнения собрать отдельный версионный индекс только из этих
+ актов; не пересобирать и не перезаписывать индекс из 42,6 млн фрагментов.
+4. Изолированный read-only trial уже проверил отдельный supplement-index,
+ подключённые каталоги `data_root`, RU/KY выдачу, current-edition routes,
+ collapse и контрольные запросы. Перед новым подключением нужно повторно
+ сверить official source и dataset guards; trial не записывал новые документы
+ в base index и не менял alias.
+5. Публикация требует отдельного владельческого разрешения и проверенного
+ rollback. При отключении локального supplement-index поиск и просмотр новых
+ редакций из него прекращаются; подготовленные файлы и уже сохранённые отзывы
+ сохраняются, базовый индекс не удаляется. PR
+ фиксирует код и результаты локального испытания; production, base index и
+ исходные данные не менялись.
+
+## Что остаётся проверить
+
+- Финальный code/data эксперимент и full 7 500 run относятся к executable SHA
+ `1c7ddf2`; текущий follow-up commit меняет только этот report и changelog.
+ CI и полное независимое ревью должны повторно пройти на новом PR head.
+- Browser fixture пустой выдачи прошёл на `726b1ee`; UI побайтно неизменён в
+ следующих commit. Для `1c7ddf2` выполнены реальные API/runtime smokes, но новый
+ полный browser run не заявляется.
+- Три supplement-акта не подтверждают полноту всего корпуса, полную историю,
+ вложения, лицензирование или production readiness. Для новой публикации нужен
+ свежий source/edition check и отдельное разрешение владельца.
+- Измерения top-k, AI-разметка и технические smoke checks не заменяют независимую
+ экспертную оценку юридической релевантности.
+
+Исходные измерения и хеши хранятся вне репозитория в рабочем наборе
+`search-benchmark-2026-11-02`. В этот репозиторий не включаются сырые ответы
+MinJust, диагностические логи, внешние пользовательские данные или выгрузка
+лабораторных комментариев.
diff --git a/tools/frontend-visual-review/check.cjs b/tools/frontend-visual-review/check.cjs
index 46685d4..44a207a 100644
--- a/tools/frontend-visual-review/check.cjs
+++ b/tools/frontend-visual-review/check.cjs
@@ -8,7 +8,7 @@ const root = path.join(repo, 'backend/search/static/fonts');
const {docs, reviewDocs, catalogs, passwordChangeAccount, ordinaryAccount, article, articleKy} = require('./fixtures.cjs');
fs.mkdirSync(artifacts, {recursive: true});
let fixtureAccount = passwordChangeAccount;
-const fixtureState = {users:[{username:'fixture-user',role:'Пользователь',can_manage_users:false,must_change_password:true,last_login_at:null,first_login_at:1780000000,password_reset_at:null}],passwordChanges:0,userCreates:0,passwordResets:0,userDeletes:0,reviewSaves:0,lastReviewRatings:[]};
+const fixtureState = {users:[{username:'fixture-user',role:'Пользователь',can_manage_users:false,must_change_password:true,last_login_at:null,first_login_at:1780000000,password_reset_at:null}],passwordChanges:0,userCreates:0,passwordResets:0,userDeletes:0,reviewSaves:0,lastReviewRatings:[],lastReviewSubmission:null,rejectNextReviewSave:false};
let failNextEdition = false; let delayNextEdition = false; let delayNextPublicMetadata = false; let failReviewDocumentB = false; let delayReviewDocumentA = false; let delayNextReviewSearch = false;
(async()=>{
const browser = await chromium.launch({headless:true});
@@ -21,7 +21,7 @@ let failNextEdition = false; let delayNextEdition = false; let delayNextPublicMe
const p=url.pathname;
if(p==='/auth/me') return route.fulfill({json:{account:fixtureAccount}});
if(p==='/search/filters') return route.fulfill({json:catalogs});
- if(p==='/search-reviews' && req.method()==='POST') { fixtureState.reviewSaves++; fixtureState.lastReviewRatings=req.postDataJSON().results.map((result)=>({code:result.code,rating:result.rating})); return route.fulfill({json:{id:42}}); }
+ if(p==='/search-reviews' && req.method()==='POST') { const submission=req.postDataJSON(); fixtureState.reviewSaves++; fixtureState.lastReviewSubmission=submission; fixtureState.lastReviewRatings=submission.results.map((result)=>({code:result.code,rating:result.rating})); if(fixtureState.rejectNextReviewSave){fixtureState.rejectNextReviewSave=false;return route.fulfill({status:400,json:{error:'Fixture review save error'}});} return route.fulfill({status:201,json:{id:42}}); }
if(p==='/auth/change-password' && req.method()==='POST') { fixtureState.passwordChanges++; return route.fulfill({json:{account:fixtureAccount}}); }
if(p==='/admin/users' && req.method()==='GET') return route.fulfill({json:{users:fixtureState.users}});
if(p==='/admin/users' && req.method()==='POST') { const body=req.postDataJSON(); fixtureState.userCreates++; fixtureState.users.push({username:body.username,role:'Пользователь',can_manage_users:false,must_change_password:true,last_login_at:null,first_login_at:1780000000,password_reset_at:null}); return route.fulfill({status:201,json:{username:body.username}}); }
@@ -30,7 +30,7 @@ let failNextEdition = false; let delayNextEdition = false; let delayNextPublicMe
const deleteMatch=p.match(/^\/admin\/users\/([^/]+)$/);
if(deleteMatch && req.method()==='DELETE') { fixtureState.userDeletes++; const username=decodeURIComponent(deleteMatch[1]); fixtureState.users=fixtureState.users.filter((candidate)=>candidate.username!==username); return route.fulfill({json:{}}); }
if(p==='/search') {
- if(url.searchParams.has('page_size')&&!url.searchParams.has('sort')) { if(delayNextReviewSearch){delayNextReviewSearch=false;await new Promise(resolve=>setTimeout(resolve,300));} return route.fulfill({json:{results:reviewDocs,has_next:false,review_token:'fixture-review-token'}}); }
+ if(url.searchParams.has('page_size')&&!url.searchParams.has('sort')) { if(delayNextReviewSearch){delayNextReviewSearch=false;await new Promise(resolve=>setTimeout(resolve,300));} if(url.searchParams.get('q')==='empty-review-fixture') return route.fulfill({json:{results:[],has_next:false,review_token:'fixture-signed-empty-snapshot-token'}}); return route.fulfill({json:{results:reviewDocs,has_next:false,review_token:'fixture-review-token'}}); }
return route.fulfill(url.searchParams.get('q')==='service-error' ? {status:503,json:{error:'Fixture search error'}} : {json:{results:url.searchParams.get('q')==='not-found'?[]:docs,has_next:false,review_token:'fixture-review-token'}});
}
if(p==='/documents/doc-2/editions') return route.fulfill({json:{available_languages:[],editions:[]}});
@@ -85,6 +85,31 @@ let failNextEdition = false; let delayNextEdition = false; let delayNextPublicMe
const labGeometry=await page.evaluate(()=>{const rect=s=>document.querySelector(s).getBoundingClientRect(),action=rect('body > .review__actions'),workspace=rect('.review__workspace'),footer=rect('body > footer.review__header');return {headingLeft:rect('#route-heading').left,helpRight:rect('#tutorial-open').right,subtitleLeft:rect('#route-description').left,workspaceActionGap:action.top-workspace.bottom,actionFooterGap:footer.top-action.bottom,actionsHeight:action.height,footerHeight:footer.height};});
const tutorialScreens={}; for(const [name,viewport] of [['desktop',{width:1280,height:800}],['mobile',{width:390,height:844}]]){await page.setViewportSize(viewport); await page.locator('#tutorial-open').click(); tutorialScreens[name]=[]; for(const target of ['#query','#results','#document-panel','#save']){const selector=name==='mobile'&&target==='#document-panel'?'#document-heading':target;const geometry=await page.evaluate((selector)=>{const dialog=document.querySelector('#tutorial-dialog'),target=document.querySelector(selector),d=dialog.getBoundingClientRect(),t=target.getBoundingClientRect(),style=getComputedStyle(dialog),targetStyle=getComputedStyle(target);return {step:document.querySelector('#tutorial-progress').textContent,width:d.width,height:d.height,overlaps:d.leftt.left&&d.topt.top,font:style.fontFamily,titleFont:getComputedStyle(document.querySelector('#tutorial-heading')).fontFamily,background:style.backgroundColor,targetDisplay:targetStyle.display,targetInViewport:t.bottom>0&&t.top320||!geometry.font.includes('Noto Sans')||!geometry.titleFont.includes('Noto Serif')||!geometry.background.includes('0.92')) throw new Error(`Tutorial style mismatch at ${name}: ${JSON.stringify(geometry)}`);tutorialScreens[name].push(geometry);if(target==='#query') await page.screenshot({path:path.join(artifacts,`review-tour-${name}.png`),fullPage:false});if(target!=='#save') await page.locator('#tutorial-next').press('Enter');}await page.locator('#tutorial-next').press('Enter');}
await page.setViewportSize({width:1280,height:800}); await page.locator('#query').fill('налог на прибыль'); delayNextReviewSearch=true; await page.locator('#search-form button[type=submit]').click(); await page.waitForFunction(()=>document.querySelector('#status').classList.contains('review__status--busy')); const labLoadingStatus=await page.locator('#status').evaluate(el=>({height:el.getBoundingClientRect().height,role:el.getAttribute('role'),busy:el.classList.contains('review__status--busy')})); await page.locator('.review__result').first().waitFor(); const successStatus=await page.locator('#status').evaluate(el=>({height:el.getBoundingClientRect().height,role:el.getAttribute('role'),live:el.getAttribute('aria-live'),success:el.classList.contains('review__status--success')})); await page.locator('.review__result-title').first().click(); await page.locator('#document-body h2').first().waitFor(); const compactResult=await page.locator('.review__result--selected').evaluate(el=>({padding:getComputedStyle(el).padding,titleTarget:el.querySelector('.review__result-title').getBoundingClientRect().height,ratingTarget:el.querySelector('.review__rating label').getBoundingClientRect().height,metaMargin:getComputedStyle(el.querySelector('.review__meta')).marginBlockStart})); const reviewSaveEnabled=await page.locator('#save').isEnabled(); const commentDisclosure=page.locator('.review__comment-disclosure').first(); const commentStartsCollapsed=!(await commentDisclosure.evaluate(e=>e.open)); await commentDisclosure.locator('summary').click(); await commentDisclosure.locator('textarea').fill('Фиктивный комментарий для проверки черновика'); await page.locator('input[name="rating-0"][value="2"]').check(); await page.waitForFunction(()=>document.querySelector('#draft-status').textContent==='Черновик сохранён на этом устройстве.'); await page.locator('.review__result-title').first().click(); await page.locator('#document-body h2').first().waitFor(); await page.locator('#document-body').evaluate(body=>{for(let i=0;i<160;i++){const p=document.createElement('p');p.textContent='Эталонный длинный документ в безопасном browser fixture. '.repeat(5);body.append(p);}}); await page.setViewportSize({width:1280,height:720}); const reviewScroll=await page.locator('#document-panel').evaluate(panel=>({height:panel.getBoundingClientRect().height,client:panel.clientHeight,scroll:panel.scrollHeight,viewport:innerHeight,internalScroll:panel.scrollHeight>panel.clientHeight,saveAfter:Boolean(panel.compareDocumentPosition(document.querySelector('#save'))&Node.DOCUMENT_POSITION_FOLLOWING),footerAfterSave:Boolean(document.querySelector('#save').compareDocumentPosition(document.querySelector('body > footer.review__header'))&Node.DOCUMENT_POSITION_FOLLOWING)})); await page.locator('#save').scrollIntoViewIfNeeded(); const saveViewport=await page.locator('#save').evaluate(el=>{const r=el.getBoundingClientRect();const footer=document.querySelector('body > footer.review__header').getBoundingClientRect();return {top:r.top,bottom:r.bottom,visible:r.top>=0&&r.bottom<=innerHeight,footerVisible:footer.top>=0&&footer.bottom<=innerHeight}}); await page.screenshot({path:path.join(artifacts,'lab-save-footer-1280x720.png'),fullPage:false}); await page.locator('#save').click(); await page.waitForFunction(()=>document.querySelector('#status').textContent.includes('Оценка сохранена')); const reviewFlow={reviewSaveEnabled,commentStartsCollapsed,commentExpanded:await commentDisclosure.evaluate(e=>e.open),draftStatus:await page.locator('#draft-status').textContent(),reviewScroll,saveViewport,reviewSaved:await page.locator('#status').textContent()};
+ const nonEmptyReviewSubmission=fixtureState.lastReviewSubmission;
+ if(!nonEmptyReviewSubmission||nonEmptyReviewSubmission.results.length!==2||nonEmptyReviewSubmission.review_token!=='fixture-review-token') throw new Error(`Non-empty review flow changed: ${JSON.stringify(nonEmptyReviewSubmission)}`);
+ await page.locator('#query').fill('empty-review-fixture');
+ await page.locator('#search-form button[type=submit]').click();
+ await page.locator('.review__empty').waitFor();
+ const emptyPrompt=await page.evaluate(()=>({buttonText:document.querySelector('#save').textContent,buttonEnabled:!document.querySelector('#save').disabled,ariaRequired:document.querySelector('#overall-comment').getAttribute('aria-required'),placeholder:document.querySelector('#overall-comment').placeholder,emptyState:document.querySelector('#results').textContent.includes('Результатов пока нет')}));
+ await page.screenshot({path:path.join(artifacts,'empty-review-required-comment.png'),fullPage:false});
+ const attemptsBeforeInvalid=fixtureState.reviewSaves;
+ await page.locator('#save').focus(); await page.keyboard.press('Enter');
+ await page.waitForFunction(()=>document.querySelector('#status').textContent.includes('Добавьте комментарий к пустой выдаче'));
+ const invalidEmptyComment=await page.evaluate(()=>({focus:document.activeElement.id,message:document.querySelector('#status').textContent,role:document.querySelector('#status').getAttribute('role'),live:document.querySelector('#status').getAttribute('aria-live')}));
+ const invalidRequestCount=fixtureState.reviewSaves-attemptsBeforeInvalid;
+ await page.locator('#overall-comment').fill('Пустая выдача проверена в синтетическом browser fixture.');
+ fixtureState.rejectNextReviewSave=true;
+ await page.locator('#save').focus(); await page.keyboard.press('Enter');
+ await page.waitForFunction(()=>document.querySelector('#status').textContent.includes('Не удалось сохранить'));
+ const failedEmptySubmission=fixtureState.lastReviewSubmission;
+ const failedSaveFeedback=await page.locator('#status').evaluate(el=>({message:el.textContent,role:el.getAttribute('role'),live:el.getAttribute('aria-live')}));
+ await page.locator('#save').focus(); await page.keyboard.press('Enter');
+ await page.waitForFunction(()=>document.querySelector('#status').textContent.includes('Оценка сохранена'));
+ const emptyReviewSubmission=fixtureState.lastReviewSubmission;
+ const emptyReviewFlow={emptyPrompt,invalidEmptyComment,invalidRequestCount,failedSaveFeedback,failedSubmission:failedEmptySubmission,savedSubmission:emptyReviewSubmission,feedback:await page.locator('#status').textContent()};
+ await page.screenshot({path:path.join(artifacts,'empty-review-saved.png'),fullPage:false});
+ console.log('emptyReviewFlow',JSON.stringify(emptyReviewFlow));
+ if(!emptyPrompt.buttonEnabled||emptyPrompt.buttonText!=='Сохранить пустую выдачу'||emptyPrompt.ariaRequired!=='true'||!emptyPrompt.placeholder.includes('обязательно')||!emptyPrompt.emptyState||invalidEmptyComment.focus!=='overall-comment'||invalidEmptyComment.role!=='alert'||invalidRequestCount!==0||failedSaveFeedback.role!=='alert'||failedSaveFeedback.live!=='assertive'||failedEmptySubmission.review_token!=='fixture-signed-empty-snapshot-token'||failedEmptySubmission.results.length!==0||!failedEmptySubmission.overall_comment||emptyReviewSubmission.review_token!=='fixture-signed-empty-snapshot-token'||emptyReviewSubmission.results.length!==0||emptyReviewSubmission.overall_comment!=='Пустая выдача проверена в синтетическом browser fixture.'||!emptyReviewFlow.feedback.includes('Оценка сохранена')) throw new Error(`Empty review browser contract failed: ${JSON.stringify(emptyReviewFlow)}`);
await page.locator('#query').fill('document-switch-fixture'); await page.locator('#search-form button[type=submit]').click(); await page.waitForFunction(()=>document.querySelector('.review__result-title span:last-child')?.textContent==='Fixture document A');
await page.locator('.review__comment-disclosure').first().locator('summary').click(); await page.locator('.review__comment').first().fill('Fixture-only note for result A.'); const fixtureRating=page.locator('input[name="rating-0"][value="2"]'); await fixtureRating.check(); const ratingBeforeDocumentSwitch=await fixtureRating.isChecked();
await page.locator('.review__result-title').nth(0).click(); await page.locator('#document-body h2').filter({hasText:'Fixture document A'}).waitFor();