Preserve multi-index scope in review snapshots
This commit is contained in:
1 parent
726b1ee745
commit
e653f3b50e
3 files changed
+60
-3
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
## Что остаётся проверить
|
||||
|
||||
|
||||
Reference in new issue
Block a user