Preserve multi-index scope in review snapshots
CI / tests-and-images (pull_request) Successful in 2m12s
Runner smoke test / runner-smoke (pull_request) Successful in 2s

This commit is contained in:
codex committed 2026-11-03 01:41:58 +03:00
1 parent 726b1ee745
commit e653f3b50e
3 files changed
+60 -3

No files matched your search

+8 -1
View File
@@ -185,7 +185,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
+35 -1
View File
@@ -7,6 +7,37 @@ from search.api import Api, ApiError
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")
@@ -64,12 +95,14 @@ class SearchReviewTest(unittest.TestCase):
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"):
@@ -80,6 +113,7 @@ class SearchReviewTest(unittest.TestCase):
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": "Юрист",
@@ -102,6 +102,18 @@ API отвергал `results: []`, а интерфейс читал поля и
один раз, совпадающую с подписанным кодом документа. 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 прошёл,
@@ -151,7 +163,11 @@ API. Вложения и полная история новых canonical ID о
контрольную выдачу по старым индексам. Не создавать записи в основном
индексе и не менять alias до принятия результата.
5. Публикация требует отдельного владельческого разрешения и проверенного
rollback. Эта PR не загружает документы, не меняет индекс или production.
rollback. Если отдельный supplement-index будет включён, rollback отключит
поиск и просмотр новых редакций из него; подготовленные файлы и уже
сохранённые отзывы при этом сохраняются, базовый индекс не удаляется. Эта PR
не загружает документы, не подключает offline editions к API и не меняет
индекс или production.
## Что остаётся проверить