diff --git a/CHANGELOG.md b/CHANGELOG.md index 73a890a..eeb37b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Не выпущено +- Добавлено безопасное переключение alias на новую версию поискового индекса + после полной загрузки; checkpoint защищает возобновление загрузки от смены + alias. - Добавлен roadmap готовности данных, поиска и API перед проектированием frontend. - Исправлена выдача для явных запросов об открытии ОсОО и ЖЧК: первыми diff --git a/README.md b/README.md index c6f8952..5edadb6 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,15 @@ Telegram-бот — только часть рабочего окружения ## Текущее состояние Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии -`0.5.2`: подготовка поискового индекса и оценка Recall@K/MRR@K на вручную +`0.6.0`: подготовка поискового индекса и оценка Recall@K/MRR@K на вручную размеченном наборе запросов. | Компонент | Версия | Состояние | |---|---:|---| | Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния | -| Backend | `0.5.2` | добавлено ранжирование подтверждённых запросов об открытии ОсОО/ЖЧК | +| Backend | `0.6.0` | добавлено атомарное переключение alias поискового индекса | | Frontend | — | ещё не создан | -| Сбор и обработка правовых данных | `0.5.2` | добавлены relevance set и baseline-метрики | +| Сбор и обработка правовых данных | `0.6.0` | добавлены relevance set и baseline-метрики | | RAG и база знаний | — | ещё не созданы | ## Структура репозитория @@ -59,4 +59,4 @@ python3 -m unittest discover -s tools/telegram-bot -v --- -Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан +Акылдаш · Telegram-бот v0.2.2 · Backend v0.6.0 · Frontend — не создан diff --git a/backend/README.md b/backend/README.md index 6b777a5..fb45519 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,6 +1,6 @@ # Backend Акылдаш -Версия: `0.5.2` +Версия: `0.6.0` Первая backend-область проекта — загрузка правовых документов из официального Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в @@ -128,6 +128,16 @@ python3 backend/search/minjust_opensearch.py \ После проверки production-индекса следует переключать alias, чтобы удалённые фрагменты не оставались в поиске. +Чтобы переключить alias атомарно только после успешной полной загрузки, +передайте `--alias`: + +```bash +python3 backend/search/minjust_opensearch.py \ + --url http://127.0.0.1:9200 \ + --index akyldash-fragments-v2 \ + --alias akyldash-fragments-current +``` + После каждого принятого Bulk-пакета загрузчик атомарно сохраняет checkpoint и печатает код безопасного возобновления. При временных HTTP 429/5xx, timeout и обрыве соединения запрос повторяется автоматически. Прерванную загрузку можно @@ -142,8 +152,10 @@ python3 backend/search/minjust_opensearch.py \ По умолчанию checkpoint хранится в `data/opensearch/.checkpoint.json`; путь можно изменить через -`--checkpoint`. Checkpoint привязан к URL, cluster UUID, index UUID, `--limit` -и SHA-256 нормализованного manifest. Resume отклоняется при любом несовпадении: +`--checkpoint`. Checkpoint привязан к URL, cluster UUID, index UUID, `--limit`, +`--alias` и SHA-256 нормализованного manifest. При `--resume` передавайте то же +значение `--alias`; старый checkpoint без alias можно продолжить только без +него. Resume отклоняется при любом несовпадении: для обновлённого корпуса или пересозданного индекса нужно создать новый версионный индекс, проверить его и переключить alias. Это не оставляет удалённые trailing-фрагменты старых документов. @@ -198,4 +210,4 @@ PYTHONPATH=backend python3 -m search.query "ЖЧК ачуу тартиби" --la --- -Акылдаш · Backend v0.5.2 · Frontend — не создан +Акылдаш · Backend v0.6.0 · Frontend — не создан diff --git a/backend/ingestion/minjust_cbd.py b/backend/ingestion/minjust_cbd.py index d17da03..1cf8d26 100644 --- a/backend/ingestion/minjust_cbd.py +++ b/backend/ingestion/minjust_cbd.py @@ -21,7 +21,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Callable, Iterable -APP_VERSION = "0.5.2" +APP_VERSION = "0.6.0" API_BASE_URL = "https://cbd.minjust.gov.kg/api/v1/OpenData/" LANGUAGES = {"Rus": "ru", "Kyr": "ky"} IMAGE_LANGUAGES = {"Russian": "ru", "Kyrgyz": "ky"} diff --git a/backend/normalization/minjust_cbd.py b/backend/normalization/minjust_cbd.py index 87f62c1..8ebd579 100644 --- a/backend/normalization/minjust_cbd.py +++ b/backend/normalization/minjust_cbd.py @@ -23,7 +23,7 @@ from pathlib import Path from typing import Callable from urllib.parse import urlsplit -APP_VERSION = "0.5.2" +APP_VERSION = "0.6.0" SCHEMA_VERSION = "1" NORMALIZER_VERSION = "1.0.0" LANGUAGES = ("ru", "ky") diff --git a/backend/search/minjust_opensearch.py b/backend/search/minjust_opensearch.py index a45c4cd..466f97a 100644 --- a/backend/search/minjust_opensearch.py +++ b/backend/search/minjust_opensearch.py @@ -15,7 +15,7 @@ import urllib.request from pathlib import Path from typing import Iterator -APP_VERSION = "0.5.2" +APP_VERSION = "0.6.0" LANGUAGES = {"ru", "ky"} DEFAULT_MAPPING = Path(__file__).with_name("minjust-fragments-index.json") @@ -246,6 +246,7 @@ def checkpoint_state( cluster_uuid: str, index_uuid: str, limit: int | None, + alias: str | None, ) -> tuple[dict, str | None]: source = input_root.resolve() destination = checkpoint.resolve() @@ -254,7 +255,7 @@ def checkpoint_state( manifest_sha256 = file_sha256(input_root / "manifest.sqlite3") if not resume: return { - "schema_version": 1, + "schema_version": 2, "url": url, "cluster_uuid": cluster_uuid, "index": index, @@ -262,6 +263,7 @@ def checkpoint_state( "input": str(source), "manifest_sha256": manifest_sha256, "limit": limit, + "alias": alias, "last_document_code": None, "complete": False, }, None @@ -276,13 +278,22 @@ def checkpoint_state( "input", "manifest_sha256", "limit", + "alias", "last_document_code", "complete", } - if not isinstance(state, dict) or set(state) != expected: + legacy_expected = expected - {"alias"} + if not isinstance(state, dict): + raise ValueError(f"Invalid checkpoint: {checkpoint}") + if set(state) == legacy_expected: + if state["schema_version"] != 1 or alias is not None: + raise ValueError(f"Legacy checkpoint does not support --alias: {checkpoint}") + state["schema_version"] = 2 + state["alias"] = None + elif set(state) != expected: raise ValueError(f"Invalid checkpoint: {checkpoint}") if ( - state["schema_version"] != 1 + state["schema_version"] != 2 or state["url"] != url or state["cluster_uuid"] != cluster_uuid or state["index"] != index @@ -292,6 +303,8 @@ def checkpoint_state( raise ValueError(f"Checkpoint does not match this load: {checkpoint}") if state["limit"] != limit: raise ValueError(f"Checkpoint limit does not match --limit: {checkpoint}") + if state["alias"] != alias: + raise ValueError(f"Checkpoint alias does not match --alias: {checkpoint}") if state["manifest_sha256"] != manifest_sha256: raise ValueError("Normalized manifest changed; create a new versioned index") if state["complete"] is not False: @@ -311,8 +324,11 @@ def load_bulk( limit: int | None = None, resume: bool = False, checkpoint: Path = Path("data/opensearch/minjust-fragments.checkpoint.json"), + alias: str | None = None, ) -> tuple[int, int]: base = url.rstrip("/") + if alias is not None and (not alias or alias == index): + raise ValueError("--alias must differ from --index") index_url = f"{base}/{urllib.parse.quote(index, safe='')}" if resume: cluster_uuid, index_uuid = opensearch_identity(base, index, index_url) @@ -328,6 +344,7 @@ def load_bulk( cluster_uuid, index_uuid, limit, + alias, ) documents = document_count(input_root, limit, start_at) if not resume: @@ -363,11 +380,31 @@ def load_bulk( state["last_document_code"] = last_code write_json_atomic(checkpoint, state) print(f"checkpoint={last_code} fragments={fragments}", flush=True) + if alias: + switch_alias(base, index, alias) state["complete"] = True write_json_atomic(checkpoint, state) return documents, fragments +def switch_alias(base: str, index: str, alias: str) -> None: + if not alias or alias == index: + raise ValueError("--alias must differ from --index") + result = request_json( + f"{base.rstrip('/')}/_aliases", + "POST", + json.dumps({ + "actions": [ + {"remove": {"index": "*", "alias": alias, "must_exist": False}}, + {"add": {"index": index, "alias": alias}}, + ] + }).encode(), + "application/json", + ) + if result.get("acknowledged") is not True: + raise RuntimeError(f"OpenSearch did not acknowledge alias switch: {alias}") + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--input", type=Path, default=Path("data/minjust-normalized")) @@ -375,6 +412,7 @@ def main() -> int: parser.add_argument("--index", default="akyldash-fragments-v1") parser.add_argument("--limit", type=int) parser.add_argument("--url", help="create the index and stream bounded Bulk requests instead of writing a file") + parser.add_argument("--alias", help="atomically point this alias at --index after a successful load") parser.add_argument("--mapping", type=Path, default=DEFAULT_MAPPING) parser.add_argument("--batch-mb", type=int, default=25) parser.add_argument("--resume", action="store_true", help="load into an existing index") @@ -389,6 +427,10 @@ def main() -> int: raise SystemExit("--resume requires --url") if arguments.checkpoint and not arguments.url: raise SystemExit("--checkpoint requires --url") + if arguments.alias == "": + raise SystemExit("--alias must not be empty") + if arguments.alias is not None and not arguments.url: + raise SystemExit("--alias requires --url") if arguments.url: checkpoint = arguments.checkpoint or Path("data/opensearch") / f"{arguments.index}.checkpoint.json" documents, fragments = load_bulk( @@ -400,6 +442,7 @@ def main() -> int: arguments.limit, arguments.resume, checkpoint, + arguments.alias, ) destination = arguments.url else: diff --git a/backend/test_minjust_opensearch.py b/backend/test_minjust_opensearch.py index db76c75..cf02ac8 100644 --- a/backend/test_minjust_opensearch.py +++ b/backend/test_minjust_opensearch.py @@ -9,10 +9,27 @@ from contextlib import closing from pathlib import Path from unittest.mock import patch -from search.minjust_opensearch import bulk_batches, document_codes, export_bulk, load_bulk, request_json +from search.minjust_opensearch import bulk_batches, document_codes, export_bulk, load_bulk, request_json, switch_alias class MinjustOpenSearchTest(unittest.TestCase): + def test_switches_alias_atomically_after_successful_load(self): + with patch("search.minjust_opensearch.request_json", return_value={"acknowledged": True}) as request: + switch_alias("http://127.0.0.1:9200/", "akyldash-fragments-v2", "akyldash-fragments-current") + + self.assertEqual(request.call_args.args[:2], ("http://127.0.0.1:9200/_aliases", "POST")) + body = json.loads(request.call_args.args[2]) + self.assertEqual(body["actions"][0], {"remove": {"index": "*", "alias": "akyldash-fragments-current", "must_exist": False}}) + self.assertEqual(body["actions"][1], {"add": {"index": "akyldash-fragments-v2", "alias": "akyldash-fragments-current"}}) + + with self.assertRaisesRegex(ValueError, "differ"): + switch_alias("http://127.0.0.1:9200", "same", "same") + with self.assertRaisesRegex(ValueError, "differ"): + switch_alias("http://127.0.0.1:9200", "index", "") + with patch("search.minjust_opensearch.request_json", return_value={"acknowledged": False}): + with self.assertRaisesRegex(RuntimeError, "did not acknowledge"): + switch_alias("http://127.0.0.1:9200", "index", "alias") + def test_exports_atomic_bulk_and_rejects_mismatched_fragment(self): with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) @@ -91,6 +108,7 @@ class MinjustOpenSearchTest(unittest.TestCase): {"cluster_uuid": "cluster-1"}, {"test-index": {"settings": {"index": {"uuid": "index-1"}}}}, {"errors": False, "items": [{"index": {}}, {"index": {}}]}, + {"acknowledged": True}, ] self.assertEqual( load_bulk( @@ -99,14 +117,40 @@ class MinjustOpenSearchTest(unittest.TestCase): "test-index", maximum_bytes=4096, checkpoint=checkpoint, + alias="test-current", ), (2, 2), ) - self.assertEqual(request.call_args_list[-1].args[3], "application/x-ndjson") + self.assertEqual(request.call_args_list[-2].args[3], "application/x-ndjson") + self.assertEqual(request.call_args_list[-1].args[:2], ("http://127.0.0.1:9200/_aliases", "POST")) state = json.loads(checkpoint.read_text(encoding="utf-8")) self.assertEqual(state["last_document_code"], "7") self.assertTrue(state["complete"]) + legacy = state.copy() + legacy.pop("alias") + legacy["schema_version"] = 1 + legacy["last_document_code"] = "8" + legacy["complete"] = False + checkpoint.write_text(json.dumps(legacy), encoding="utf-8") + with patch("search.minjust_opensearch.request_json") as request: + request.side_effect = [ + {"cluster_uuid": "cluster-1"}, + {"test-index": {"settings": {"index": {"uuid": "index-1"}}}}, + ] + self.assertEqual( + load_bulk( + root / "normalized", + "http://127.0.0.1:9200", + "test-index", + maximum_bytes=4096, + resume=True, + checkpoint=checkpoint, + ), + (1, 0), + ) + self.assertEqual(json.loads(checkpoint.read_text(encoding="utf-8"))["schema_version"], 2) + state["last_document_code"] = "8" state["complete"] = False checkpoint.write_text(json.dumps(state), encoding="utf-8") @@ -116,6 +160,21 @@ class MinjustOpenSearchTest(unittest.TestCase): {"test-index": {"settings": {"index": {"uuid": "index-1"}}}}, ] with self.assertRaisesRegex(ValueError, "does not match"): + load_bulk( + root / "normalized", + "http://127.0.0.1:9200", + "test-index", + maximum_bytes=4096, + resume=True, + checkpoint=checkpoint, + alias="test-current", + ) + with patch("search.minjust_opensearch.request_json") as request: + request.side_effect = [ + {"cluster_uuid": "cluster-1"}, + {"test-index": {"settings": {"index": {"uuid": "index-1"}}}}, + ] + with self.assertRaisesRegex(ValueError, "alias"): load_bulk( root / "normalized", "http://127.0.0.1:9200", @@ -138,11 +197,13 @@ class MinjustOpenSearchTest(unittest.TestCase): limit=1, resume=True, checkpoint=checkpoint, + alias="test-current", ) with patch("search.minjust_opensearch.request_json") as request: request.side_effect = [ {"cluster_uuid": "cluster-1"}, {"test-index": {"settings": {"index": {"uuid": "index-1"}}}}, + {"acknowledged": True}, ] self.assertEqual( load_bulk( @@ -152,10 +213,12 @@ class MinjustOpenSearchTest(unittest.TestCase): maximum_bytes=4096, resume=True, checkpoint=checkpoint, + alias="test-current", ), (1, 0), ) - self.assertTrue(all(call.args[1] == "GET" for call in request.call_args_list)) + self.assertTrue(all(call.args[1] == "GET" for call in request.call_args_list[:-1])) + self.assertEqual(request.call_args_list[-1].args[1], "POST") state["last_document_code"] = "9" state["complete"] = False @@ -173,6 +236,7 @@ class MinjustOpenSearchTest(unittest.TestCase): maximum_bytes=4096, resume=True, checkpoint=checkpoint, + alias="test-current", ) failed_checkpoint = root / "failed-checkpoint.json" @@ -218,6 +282,7 @@ class MinjustOpenSearchTest(unittest.TestCase): maximum_bytes=4096, resume=True, checkpoint=checkpoint, + alias="test-current", ) http_error = urllib.error.HTTPError( diff --git a/docs/README.md b/docs/README.md index feebcc7..b3fbba1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -35,4 +35,4 @@ --- -Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан +Акылдаш · Telegram-бот v0.2.2 · Backend v0.6.0 · Frontend — не создан diff --git a/docs/decisions/001-telegram-workspace-mvp.md b/docs/decisions/001-telegram-workspace-mvp.md index 186d7f6..2acba6d 100644 --- a/docs/decisions/001-telegram-workspace-mvp.md +++ b/docs/decisions/001-telegram-workspace-mvp.md @@ -78,4 +78,4 @@ Telegram позволяет запретить пользователям отп --- -Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан +Акылдаш · Telegram-бот v0.2.2 · Backend v0.6.0 · Frontend — не создан diff --git a/docs/operations/project-status.md b/docs/operations/project-status.md index 67b0434..df80cd2 100644 --- a/docs/operations/project-status.md +++ b/docs/operations/project-status.md @@ -5,7 +5,7 @@ - Telegram-бот: `0.2.2` - Telegram-бот на Synology: `0.2.1` -- Backend: `0.5.2` +- Backend: `0.6.0` - Frontend: не создан ## Краткий итог @@ -234,4 +234,4 @@ --- -Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан +Акылдаш · Telegram-бот v0.2.2 · Backend v0.6.0 · Frontend — не создан diff --git a/docs/operations/telegram-workspace-plan.md b/docs/operations/telegram-workspace-plan.md index 35ca7f4..3852ddd 100644 --- a/docs/operations/telegram-workspace-plan.md +++ b/docs/operations/telegram-workspace-plan.md @@ -398,4 +398,4 @@ Git сохраняет актуальную версию --- -Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан +Акылдаш · Telegram-бот v0.2.2 · Backend v0.6.0 · Frontend — не создан diff --git a/docs/product/frontend-search-sps-plan.md b/docs/product/frontend-search-sps-plan.md index 32f55ae..beeece2 100644 --- a/docs/product/frontend-search-sps-plan.md +++ b/docs/product/frontend-search-sps-plan.md @@ -258,4 +258,4 @@ runtime-зависимостями frontend. Регистрация в стор --- -Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан +Акылдаш · Telegram-бот v0.2.2 · Backend v0.6.0 · Frontend — не создан diff --git a/docs/product/minjust-document-normalization-agent-task.md b/docs/product/minjust-document-normalization-agent-task.md index 87100b6..dfc0246 100644 --- a/docs/product/minjust-document-normalization-agent-task.md +++ b/docs/product/minjust-document-normalization-agent-task.md @@ -214,4 +214,4 @@ python3 backend/normalization/minjust_cbd.py --- -Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан +Акылдаш · Telegram-бот v0.2.2 · Backend v0.6.0 · Frontend — не создан diff --git a/docs/product/project-overview.md b/docs/product/project-overview.md index 6290b27..10c7fa8 100644 --- a/docs/product/project-overview.md +++ b/docs/product/project-overview.md @@ -44,4 +44,4 @@ --- -Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан +Акылдаш · Telegram-бот v0.2.2 · Backend v0.6.0 · Frontend — не создан diff --git a/docs/team/ai-skills-for-beginners.md b/docs/team/ai-skills-for-beginners.md index 774ffb0..6770db1 100644 --- a/docs/team/ai-skills-for-beginners.md +++ b/docs/team/ai-skills-for-beginners.md @@ -180,4 +180,4 @@ --- -Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан +Акылдаш · Telegram-бот v0.2.2 · Backend v0.6.0 · Frontend — не создан diff --git a/tools/telegram-bot/README.md b/tools/telegram-bot/README.md index ed1b138..2d65772 100644 --- a/tools/telegram-bot/README.md +++ b/tools/telegram-bot/README.md @@ -48,4 +48,4 @@ python3 -m unittest -v --- -Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан +Акылдаш · Telegram-бот v0.2.2 · Backend v0.6.0 · Frontend — не создан